Thursday, March 4, 2010

A better way of using the ComboBox on a DataForm in Silverlight 3

At first I tried to use the ComboBox on a DataForm in Silverlight 3 and was pretty successful. You read about it here. It worked fairly well for me. It doesn’t update the field that it is bound to, but I think that should be able to be done using the SelectionChanged event. I have not verified that though. The concept behind this implementation was that I had a ComboBox that was populated with Owners (people) objects and I wanted to bind to an OwnerID field (the foreign key).

There was a fair amount of code to translate between the object and id. I don’t really like having to write that code. It also seemed to me that there must be an easier way, especially in the day of RIA Services, Data Services, etc that use Object graphs and the like. The concept behind the implementation I want to cover here is a shift towards a more object based approach and one that I have to believe Microsoft had in mind when they designed the ComboBox because it works well.

Unfortunately, this example is not the same basic as the other implementation, but I think the point will still be pretty clear. This example uses Address and Country tables / objects. Think about an Address table, it could have a foreign key to a Country table so that users are not just typing countries, they are instead selecting them from a ComboBox of countries.

This example is a little more in-depth as far as a real world UI (example is still simple though). In this user interface the page starts with a DataGrid that shows a list of addresses (Address and Country information). When a row in the DataGrid is selected, the DataForm shows the detail of that row and allows the user to change the Country. Any changes are immediately reflected in the DataGrid. The user can save or cancel the changes. The cancel does NOT revert changes or save really, but you can do that by making the Person object implement the IEditableObject interface or by handling the EditEnded event on the DataForm.

This solution does properly notify the DataForm that the select has changed. You can tell because the OK button enables itself automatically when the ComboBox selection is changed. It also properly sets the Country property on the Person object automatically as you would expect. The translation of Country object to CountryID is handled by RIA Services seemlessly, but you could also (if you are not using RIA Services) do that when you save the Person object back to the database.

In order for the DataGrid to get the changes immediately, the Person object must implement the INotifyPropertyChanged interface.

The Country object MUST override the GetHashCode() and Equals() methods. This is because we want to treat instances of the Country class as the same object as long as the have the same foreign key. We can do this because the user can’t change Country, and we just really want a way of showing a user-friendly text version of the Country object to the user, but really have the entire Country object at our disposal.

In the database simulation methods I took care to always get new instances of objects and not reuse them between the ComboBox and the Country property on the Person object. The reason is that it will appear as though the GetHashCode() and Equals() methods don’t need to be overridden. That is because the objects would actually be the same. In a real life scenario, EF or web service, WCF, RIA Services, etc would return different instances of objects each time you return objects. We need to handle real life, so be sure to override those methods.

I populated the ComboBox using code, but you could do it in XML as described here. I find the code to be a little more comfortable to me, but either should work equally as well. The trick is to do that in the ContentLoaded (not to be confused with the Load event) event of the DataForm.

The Countries and Persons properties on the MainPage class are what everything will bind to. This means that we need to populate these variables, THEN set the LayoutRoot.DataContext = this. If you do it in the reverse order, the binding will bind to empty variables and your UI will not show any rows or details.

You can download a working solution from here. I have include the source code below for convenience.

Let’s start with the MainPage.xaml. Here is what it looks like:

<UserControl x:Class="DataFormTest2.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
xmlns:dataFormToolkit="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data.DataForm.Toolkit"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480">
<Grid x:Name="LayoutRoot">
<StackPanel Orientation="Vertical">
<TextBlock>Combobox example</TextBlock>

<StackPanel Orientation="Vertical">
<data:DataGrid x:Name="_datagrid" ItemsSource="{Binding Persons}" Margin="0,0,5,0" AutoGenerateColumns="False">
<data:DataGrid.Columns>
<data:DataGridTextColumn Binding="{Binding Name}" Header="Name"/>
<data:DataGridTemplateColumn Header="Country">
<data:DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<TextBlock Text="{Binding Country.CountryName}"/>
</DataTemplate>
</data:DataGridTemplateColumn.CellEditingTemplate>
</data:DataGridTemplateColumn>
</data:DataGrid.Columns>
</data:DataGrid>
<data:DataPager Grid.Row="1" Source="{Binding Persons}" PageSize="10" Margin="0" />
</StackPanel>

<dataFormToolkit:DataForm x:Name="_dataform"
ItemsSource="{Binding Persons}"
CommandButtonsVisibility="All"
AutoGenerateFields="True"
AutoCommit="False">
<dataFormToolkit:DataForm.EditTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical">
<dataFormToolkit:DataField>
<TextBox Text="{Binding Name, Mode=TwoWay}" />
</dataFormToolkit:DataField>
<dataFormToolkit:DataField>
<ComboBox x:Name="_comboCountries" DisplayMemberPath="CountryName"
SelectedItem="{Binding Country, Mode=TwoWay}" >
</ComboBox>
</dataFormToolkit:DataField>
</StackPanel>
</DataTemplate>
</dataFormToolkit:DataForm.EditTemplate>
</dataFormToolkit:DataForm>

</StackPanel>

</Grid>
</UserControl>
This is the MainPage.xaml.cs.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Windows.Data;
using System.ComponentModel;


// Basis for this code was copied from here: http://forums.silverlight.net/forums/p/165152/372651.aspx
// Giant help from: http://weblogs.asp.net/manishdalal/archive/2009/07/03/silverlight-3-combobox-control.aspx

namespace DataFormTest2
{
public partial class MainPage : UserControl
{
public MainPage()
{
InitializeComponent();
_dataform.ContentLoaded += new EventHandler<DataFormContentLoadEventArgs>(_dataform_ContentLoaded);

this.Loaded += new System.Windows.RoutedEventHandler(MainPage_Loaded);
}

// This could be done in the Constructor of this page also
void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
// IMPORTANT: The Countries and People properties must be set
// BEFORE the DataContext is set to this
Countries = GetCountries();
Persons = new PagedCollectionView(GetPeople());
this.LayoutRoot.DataContext = this;
}

public List<Country> Countries { get; set; }
public PagedCollectionView Persons { get; set; }

#region Database Simulation

// simulate a database query
// used to populate the DataGrid and then the DataForm
private List<Person> GetPeople()
{

// get values for ComboBox
List<Country> countries = GetCountries();

// Get rows for DataGrid / DataForm
List<Person> persons = new List<Person>();
persons.Add(new Person { Name = "Charlie", Country = countries[2] });
persons.Add(new Person { Name = "Lola", Country = countries[1] });
persons.Add(new Person { Name = "Gabe", Country = countries[0] });
persons.Add(new Person { Name = "Jack", Country = countries[2] });
persons.Add(new Person { Name = "Vic", Country = countries[0] });

return persons;

}


// simulate a database query
// use this to populate the ComboBox list of values
private List<Country> GetCountries()
{
List<Country> countries = new List<Country>();
countries.Add(new Country("Andorra", 1));
countries.Add(new Country("Belgium", 2));
countries.Add(new Country("Canada", 3));

return countries;

}

#endregion

void _dataform_ContentLoaded(object sender, DataFormContentLoadEventArgs e)
{
// find the ComboBox in the DataForm and set the ItemsSource
var comboCountries = (ComboBox)_dataform.FindNameInContent("_comboCountries");
if (comboCountries != null)
{
comboCountries.ItemsSource = Countries;
}

}
}


// NOTE: You need INotifyPropertyChanged if you want the DataGrid to automatically update
public class Person : System.ComponentModel.INotifyPropertyChanged
{
private string _Name;

public string Name
{
get { return _Name; }
set { _Name = value; NotifyPropertyChanged("Name"); }
}

private Country _Country;

public Country Country
{
get { return _Country; }
set { _Country = value; NotifyPropertyChanged("Country"); }
}

public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;

private void NotifyPropertyChanged(String info)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(info));
}
}


}

public class Country
{
public Country(string countryName, int id)
{
CountryName = countryName;
ID = id;
}

public string CountryName { get; set; }
public int ID { get; set; }

// we need to override this for comparisons (see Equals method).
public override int GetHashCode()
{
return ID.GetHashCode();
}

// We override this method so that the values will be selected properly in the ComboBox
// The reason is that two objects are not considered equal if they are not the same object.
// We get two different objects because one would be the objects we got back from the
// database to populate the ComboBox. We would have a different instance of each object
// when we reference the Country property in the Person object we are editing.
// Since ID is a primary key, that is all we really need to use for comparison
// as far as the combo is concerned. This is ok, since we are not editing Country
// in the scenario so its data members never change.
public override bool Equals(object obj)
{
if (obj == null) return false;
Country cityToCompare = obj as Country;
if (cityToCompare == null) return false;
return ID.Equals(cityToCompare.ID);
}
}
}

References

http://betaforums.silverlight.net/forums/p/146398/325962.aspx

http://weblogs.asp.net/manishdalal/archive/2009/07/03/silverlight-3-combobox-control.aspx

http://weblogs.asp.net/dwahlin/archive/2009/08/20/creating-a-silverlight-datacontext-proxy-to-simplify-data-binding-in-nested-controls.aspx

http://msmvps.com/blogs/deborahk/archive/2009/11/25/silverlight-and-ria-adding-a-combobox-to-a-dataform.aspx

4 comments:

Bounz said...

Thank you! I spent 2 hours adding ComboBox in DataForm and your article was very useful for me!

Joost said...

Started with Silverlight 4 RC and Ria Services RC and later on the RTM versions of both, I never got the ComboBox on a DataForm to work. All posts were or outdated or not usable.

Thanks to your post(s) I got it to work. Many many thanks for this, this post was extremely usefull.

PS: In SVL4 I use the SelectedValue and SelectedValuePath instead of SelectedItem. So something like this...
< toolkit:DataField Label="Country" >
< ComboBox x:Name="cmbUnloadCountry"
DisplayMemberPath="Name"
SelectedValuePath="Code"
SelectedValue="{Binding UNLOAD_COUNTRY_CODE, Mode=TwoWay}" / >
< /toolkit:DataField >
Again, many thanks!

Best regards,
Joost Van Gansbeke

Chief said...

After searching through every other blog for an answer that made sense, I found your blog and it made sense. Great job. It works too.

Anonymous said...

Oh Yes!!!!!!!!!!!!!!!!


thanks thanks thanks