4.3. Binding to List Data
So far, you've seen several examples of binding a control to a
single object. However, a more traditional use of binding is to a list of
objects. For example, imagine a new type that our object data source can create
that presents a list of Person objects, as in
Example 4-19.
Example 4-19. Declaring a custom list type
using System.Collections.Generic; // List<T>
...
namespace PersonBinding {
// XAML doesn't (yet) have a syntax
// for generic class instantiation
class People : List<Person> {}
}
We can hook this new list data source and bind to it in exactly
the same way as if we were binding to a single object data source, as in
Example 4-20.
Example 4-20. Declaring a collection in XAML
<!-- Window1.xaml -->
<?Mapping XmlNamespace="local" ClrNamespace="PersonBinding" ?>
<Window ... xmlns:local="local">
<Window.Resources>
<local:People x:Key="Family">
<local:Person Name="Tom" Age="9" />
<local:Person Name="John" Age="11" />
<local:Person Name="Melissa" Age="36" />
</local:People>
<local:AgeToForegroundConverter
x:Key="AgeToForegroundConverter" />
</Window.Resources>
<Grid DataContext="{StaticResource Family}">
...
<TextBlock ...>Name:</TextBlock>
<TextBox Text="{Binding Path=Name}" ... />
<TextBox
Text="{Binding Path=Age}"
Foreground="{Binding Path=Age, Converter=...}" ... />
<Button ...>Birthday</Button>
</Grid>
</Window>
In Example 4-20,
we've created an instance of the People collection and populated it
with three Person objects. However, running it will still look just
like Figure
4-6.
4.3.1. Current Item
While the text box properties can only be bound to a single
object at a time, the binding engine is giving them the current
item in the list of possible objects
they could bind against, as illustrated in
Figure 4-6.
By default, the first item in the list starts as the current
item. Since the first item in our list example is the same as the only item to
which we were binding before, things look and act in exactly the same way as
shown in Figure 4-11, except
for the Birthday button.
4.3.1.1. Getting the current item
Recall the current Birthday button click event handler
(Example 4-21).
Example 4-21. Finding a custom object declared in XAML
public partial class Window1 : Window {
...
void birthdayButton_Click(object sender, RoutedEventArgs e) {
Person person = (Person)this.FindResource("Tom"));
++person.Age;
MessageBox.Show(...);
}
}
Our Birthday button has always been about celebrating
the birthday of the current person, but so far the current person has always
been the same, so we could just shortcut things and go directly to the single Person
object. Now that we've got a list of objects, this no longer works (unless you
consider a message box containing the word "InvalidCastException" acceptable
behavior). Further, casting to People, our collection class, won't
tell us which Person is currently being shown in the UI, because it
has no idea about such things (nor should it). For this information, we're
going to have to go to the broker between the data-bound control and the
collection of items, the view.
The job of the view is to provide services on top of the data,
including sorting, filtering and, most importantly for our purposes at the
moment, control of the current item. A view is an implementation of a
data-specific interface, which, in our case, is going to be the ICollectionView
interface. We can access a view over our data with the static GetdefaultView
method of the BindingOperations class, as in
Example 4-22.
Example 4-22. Getting a collection's view
public partial class Window1 : Window {
...
void birthdayButton_Click(object sender, RoutedEventArgs e) {
People people = (People)this.FindResource("Family");
ICollectionView view =
BindingOperations.GetDefaultView(people);
Person person = (Person)view.CurrentItem;
++person.Age;
MessageBox.Show(...);
}
}
To retrieve the view associated with the Family collection,
Example 4-22 makes a call to the GetdefaultView method of BindingOperations,
which provides us with an implementation of the ICollectionView interface.
With it, we can grab the current item, cast it into an item from our collection
(the CurrentItem property returns an object), and use it for display.
4.3.1.2. Navigating between items
In addition to getting the current item, we can also change
which item is current using the MoveCurrentTo methods of the ICollectionView
interface, as in Example 4-23.
Example 4-23. Navigating between items via the view
public partial class Window1 : Window {
...
ICollectionView GetFamilyView( ) {
People people = (People)this.FindResource("Family");
return BindingOperations.GetDefaultView(people);
}
void birthdayButton_Click(object sender, RoutedEventArgs e) {
ICollectionView view = GetFamilyView( );
Person person = (Person)view.CurrentItem;
++person.Age;
MessageBox.Show(...);
}
void backButton_Click(object sender, RoutedEventArgs e) {
ICollectionView view = GetFamilyView( );
view.MoveCurrentToPrevious( );
if( view.IsCurrentBeforeFirst ) {
view.MoveCurrentToFirst( );
}
}
void forwardButton_Click(object sender, RoutedEventArgs e) {
ICollectionView view = GetFamilyView( );
view.MoveCurrentToNext( );
if( view.IsCurrentAfterLast ) {
view.MoveCurrentToLast( );
}
}
}
The ICollectionView methods MoveCurrentToPrevious
and MoveCurrentToNext change which item is currently selected by going
backward and forward through the collection. If we walk off the end of the list
in one direction or the other, the IsCurrentBeforeFirst or IsCurrentAfterLast
properties will tell us that. The MoveCurrentToFirst and MoveCurrentToLast
help us recover after walking off the end of the list and would be useful for
implementing the Back and Forward buttons shown in
Figure 4-12, as well as First and Last buttons (which
would be an opportunity for you to apply what you've learned...).
Figure 4-12 shows
the effect of moving forward from the first Person in the collection,
including the color changes based on the Person object's Age property
(which still works in exactly the same way).
4.3.2. List Data Targets
Of course, there's only so far we can push the user of list data
without providing them a control that can actually show more than one item at a
time, such as the ListBox control in
Example 4-24.
Example 4-24. Binding a list element to a list data
source
<!-- Window1.xaml -->
<?Mapping XmlNamespace="local" ClrNamespace="PersonBinding" ?>
<Window ... xmlns:local="local">
<Window.Resources>
<local:People x:Key="Family">...</local:People>
<local:AgeToForegroundConverter
x:Key="AgeToForegroundConverter" />
</Window.Resources>
<Grid DataContext="{StaticResource Family}">
...
<ListBox
ItemsSource="{Binding}"
IsSynchronizedWithCurrentItem="True" ... />
<TextBlock ...>Name:</TextBlock>
<TextBox Text="{Binding Path=Name}" ... />
...
</Window>
In Example 4-24,
the ItemsSource property of the ListBox is a Binding
with no path, which is the same as saying "bind to the entire current object."
Notice that there's no source, either, so the binding works against the first
non-null data context it finds. In this case, the first non-null data context
is the one from the Grid, the same one as shared between both name and age text
boxes. Also, we are setting the IsSynchronizedWithCurrentItem property
to true so that as the selected item of the listbox changes, it updates the
current item in the view and vice versa.
With our item's source binding in place, we should expect to see
all three Person objects in the listbox, as shown in
Figure 4-13.
As you might have noticed, everything is not quite perfect in
Figure 4-13. What's happening is that when you bind against a whole
object, data binding is doing its best to display each Person object.
Without special instructions, it'll use a type converter to get a string
representation. For name and age, which are of built-in types with built-in
conversions, this works just fine, but it doesn't work very well at all for a
custom type without a visual rendering, as is the case with our Person
type.
4.3.3. Data Templates
The right way to solve this problem is with a
data template. A data template is a tree of elements to expand in a
particular context. For example, for each Person object, we'd really
like to be able to concatenate the name and age together in a string like the
following:
Tom (age:9)
We can think of this as a logical template that looks like this:
Name (age:Age)
To define this template for items in the listbox, we create a DataTemplate
element, as in Example 4-25.
Example 4-25. Using a data template
<ListBox ... ItemsSource="{Binding}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock TextContent="{Binding Path=Name}" />
<TextBlock TextContent=" (age: " />
<TextBlock
TextContent="{Binding Path=Age}"
Foreground="
{Binding
Path=Age,
Converter={StaticResource AgeToForegroundConverter}}" />
<TextBlock TextContent=")" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
In this case, the ListBox control has an ItemTemplate
property, which accepts an instance of a DataTemplate object. The DataTemplate
allows us to specify a single child element to repeat for every item that the ListBox
control binds against. In our case, we're using a StackPanel to gather
together four TextBlock controls in a row, two for text bound to
properties on each Person object and two for the constant text. Notice
that we're also binding the Foreground to the Age property
using the AgeToForegroundConverter so that Age properties
show up in black or red, so that the listbox is consistent with the age text
box.
With the use of the data template, our experience goes from
Figure 4-13 to Figure 4-14.
Notice that the listbox shows all of the items in the collection
and keeps the view's idea of current item synchronized with it as the selection
moves or the back and forward buttons are pressed (actually, you can't really
"notice" this based on the screenshot in
Figure 4-14, but trust me, that's what happens). In addition, as data
changes in Person objects, the listbox, and the text boxes are all
kept in sync, including the Age color.
4.3.3.1. Typed data templates
In Example 4-25,
we explicitly set the data template for items in our listbox. However, if a Person
object showed up in a button or in some other element, we'd have to specify the
data template for those Person objects separately. On the other hand,
if you'd like a Person object to have a specific template no matter
where it shows up, you can do so with a typed data
template, as in Example 4-26.
Example 4-26. A typed data template
<Window.Resources>
<local:AgeToForegroundConverter
x:Key="AgeToForegroundConverter" />
<local:People x:Key="Family">...</local:People>
<DataTemplate DataType="{x:Type local:Person}">
<StackPanel Orientation="Horizontal">
<TextBlock TextContent="{Binding Path=Name}" />
<TextBlock TextContent=" (age: " />
<TextBlock TextContent="{Binding Path=Age}" ... />
<TextBlock TextContent=")" />
</StackPanel>
</DataTemplate>
</Window.Resources>
...
<!-- no need for an ItemTemplate setting -->
<ListBox ItemsSource="{Binding}" ...>
In Example 4-26,
we've hoisted the data template definition into a resources block and tagged it
with a type using the DataType property. Now, unless told otherwise,
whenever WPF sees an instance of the Person object, it will apply the
appropriate data template. This is a handy way to make sure that data is
displayed in a consistent way throughout your application without worrying
about just where it shows.
4.3.4. List Changes
Thus far, we've got a list of objects that we can edit in place
and navigate among, even highlighting certain data values with ease and
providing an automatic look for data that wasn't shipped with a rendering from
the manufacturer. In the spirit of how far we've come, you might suspect that
providing an Add button would be a breeze, as in
Example 4-27.
Example 4-27. Adding an item to a data bound collection
public partial class Window1 : Window {
...
void addButton_Click(object sender, RoutedEventArgs e) {
People people = (People)this.FindResource("Family");
people.Add(new Person("Chris", 35));
}
}
The problem with this implementation is that while the view can
figure out the existence of new items on the fly as you move to it, the listbox
itself has no idea that something new has been added to the collection, as
shown in Figure 4-15.
In interacting with the state of the application shown in
Figure 4-15, I ran the application, pressed the Add button and used the
Forward button to navigate to it. However, even though the new person is shown
in the text boxes, the listbox still has no idea something was added. Likewise,
it wouldn't have any idea if something was removed. Just like data-bound
objects need to implement the INotifyPropertyChanged interface,
data-bound lists need to implement the INotifyCollectionChanged
interface, as in Example 4-28.
Example 4-28. The INotifyCollectionChanged interface
namespace System.Collections.Specialized {
public interface INotifyCollectionChanged {
event NotifyCollectionChangedEventHandler CollectionChanged;
}
}
The INotifyCollectionChanged interface is used to
notify the data-bound control that items have been added or removed from the
bound list. While it's common to implement INotifyPropertyChanged in
your custom types to enable two-way data binding on your type's properties,
it's less common to implement your own collection classes, which leaves you
less opportunity to implement the INotifyCollectionChanged interface.
Instead, you'll most likely be relying on one of the collection classes in the
.NET Framework Class Library to implement INotifyCollectionChanged for
you. The number of such classes is small, and unfortunately, List<T>,
the collection class we're using to hold Person objects, is not among
them. While you're more than welcome to spend your evenings and weekends
implementing INotifyCollectionChanged, WPF provides the ObservableCollection<T>
class for those of us with more pressing duties, as in
Example 4-29.
Example 4-29. WPF's implementation of
INotifyCollectionChanged
namespace System.Windows.Data {
public class ObservableCollection<T> :
Collection<T>, INotifyCollectionChanged, INotifyPropertyChanged {
...
}
}
Since ObservableCollection<T> derives from Collection<T>
and implements the INotifyCollectionChanged interface, we can use it
instead of List<T> for our Person collection, as in
Example 4-30.
Example 4-30. ObservableCollection<T> in action
namespace PersonBinding {
class Person : INotifyPropertyChanged {...}
class People : ObservableCollection<Person> {}
}
Now, when an item is added to or removed from the Person
collection, those changes will be reflected in the list data-bound controls, as
shown in Figure 4-16.
4.3.5. Sorting
Once we have data targets showing more than one thing at a time
properly, a young person's fancy turns to more, well, fancy things, such as
sorting the view of the data or filtering it.
Recall that the view always sits between the data-bound target and the data
source. This means that it's possible to skip data that we don't want to show
(this is called filtering, and it will be
covered directly), and it's possible to change the order in which the data is
shown, a.k.a. sorting. The simplest way to
sort is by manipulating the Sort property of the view, as in
Example 4-31.
Example 4-31. Sorting
public partial class Window1 : Window {
...
ICollectionView GetFamilyView( ) {
People people = (People)this.FindResource("Family");
return BindingOperations.GetDefaultView(people);
}
void sortButton_Click(object sender, RoutedEventArgs e) {
ICollectionView view = GetFamilyView( );
if( view.Sort.Count == 0 ) {
view.Sort.Add(
new SortDescription("Name", ListSortDirection.Ascending));
view.Sort.Add(
new SortDescription("Age", ListSortDirection.Descending));
}
else {
view.Sort.Clear( );
}
}
}
Here we are toggling between sorted and unsorted views by
checking the SortDescriptionCollection exposed
by the ICollectionView Sort property. If there are no sort
descriptions, we sort first by the Name property in ascending order,
then by the Age property in Descending order. If there are sort
descriptions, we clear them, restoring the order to whatever it was before we
applied our sort. While the sort descriptions are in place, any new objects
added to the collection will be inserted into their proper sort position, as
Figure 4-17 shows.
A collection of SortDescription objects should cover
most cases, but if you'd like a bit more control, you can provide the view with
a custom sorting object by implementing the IComparer interface, as in
Example 4-32.
Example 4-32. Custom sorting
class PersonSorter : IComparer {
public int Compare(object x, object y) {
Person lhs = (Person)x;
Person rhs = (Person)y;
// Sort Name ascending and Age descending
int nameCompare = lhs.Name.CompareTo(rhs.Name);
if( nameCompare != 0 ) return nameCompare;
return rhs.Age - lhs.Age;
}
}
public partial class Window1 : Window {
...
ICollectionView GetFamilyView( ) {
People people = (People)this.FindResource("Family");
return BindingOperations.GetDefaultView(people);
}
void sortButton_Click(object sender, RoutedEventArgs e) {
ListCollectionView view = (ListCollectionView)GetFamilyView( );
if( view.CustomSort == null ) {
view.CustomSort = new PersonSorter( );
}
else {
view.CustomSort = null;
}
}
}
In the case of setting a custom sorter, we have to make an
assumption about the implementation of ICollectionViewspecifically,
that it is a ListCollectionView, which is what WPF wraps around an
implementation of IList (which our ObserverableCollection provides)
to provide view functionality. There are other implementations of ICollectionView
that don't provide custom sorting, so you'll want to test this code before
shipping it.
 |
While I'm sure this will get better as we approach
v1.0 of WPF, as of right now, the view implementations associated with specific
data characteristicssuch as the matching of ListCollectionView to
IList are undocumented (at least as far as I could tell). Also, it seems
somewhat funny that CustomSort was part of the view implementation
class and not part of the ICollectionView interface, so let's keep our
fingers crossed that this will also change as Microsoft moves toward the
release of WPF.
|
|
4.3.6. Filtering
Just because all of the objects are shown in an order that makes
you happy doesn't mean that you want all of the objects to be shown. For those
rogue objects that happen to be in the data but that don't belong in the view,
we need to feed the view an implementation of the CollectionFilterCallback
delegate
that takes a single object parameter and returns a Boolean indicating whether
the object should be shown or not, as in
Example 4-33.
Example 4-33. Filtering
public partial class Window1 : Window {
...
ICollectionView GetFamilyView( ) {
People people = (People)this.FindResource("Family");
return BindingOperations.GetDefaultView(people);
}
void filterButton_Click(object sender, RoutedEventArgs e) {
ICollectionView view = GetFamilyView( );
if( view.Filter == null ) {
view.Filter = delegate(object item) {
return ((Person)item).Age >= 18;
};
}
else {
view.Filter = null;
}
}
}
Like sorting, with a filter in place, new things are filtered
appropriately, as Figure 4-18
shows.
The top window in Figure
4-18 shows no filtering, the middle window shows filtering of the
initial list, and the bottom window shows adding a new adult with filtering
still in place.
 |