Monday, November 23, 2009

Silverlight 3 and the CollectionViewSource

I have been working on a really cool Silverlight application for the last several weeks.  The application is a graphically intensive businesses application with some pretty challenging lists nested in a grid and some very cool drag and drop functionality.  Here is the scenario: 

When the app starts up we make a call to a WCF service and get several hundred to several thousand objects back in an observable Collection.  We slice, dice, and manipulate the ObservableCollection<T> and now the challenge is we have to present several dozen to several hundred different views into this same data.  Throwing several hundred different views of the same data at a user all at once is not particularly useful so the views are stored in cells of a scrollable grid.  As I mentioned, each cell in the grid is a list of items that needs to be generated based on some attributes of the column and row in the grid.  Conceptually I want to break my original collection into a bunch of smaller collections but I didn’t want to physically break the collection  into multiple collections because the drag and drop functionality I spoke of would force me to get into the business of removing an item from one collection and adding it to another.  Additionally once the items are placed where we want them I would have to reassemble the master collection and send it back to the server.  Using a single ObservableCollection<T>) collection is a good fit.  How do we get multiple independent views into this ObservableCollection<T>) ? 

Enter the CollectionViewSource.  The CollectionViewSource allows us to set a source property (in our case the ObservableCollection<T> we got from the WCF call) then set one or many filters and finally we bind each cell to the View property of the CollectionViewSource to allow us to get a list of objects that satisfy our filter criteria.

Let’s whip up a very simple application and demonstrate the behavior. 

Here is the screen:  We have a grid with 2 rows and 2 columns.  Each Cell represents a list of type TestClass.  TestClass has two properties, SomeName – a string property, and SomeValue an integer property.  Below the grid we have two text boxes and and add button.  This will allow us to demonstrate that we can add items to the observable collection and have the UI automatically update.  Ignore the bottom 3 buttons for the moment.


Rather than make a WCF call we are building the “Main” collection of objects  in the MainPage constructor:

Code Snippet

  1.             theMainCollection = new ObservableCollection<TestClass>()
  2.                 { new TestClass() {SomeName="A",SomeValue=11},
  3.                     new TestClass() {SomeName="A",SomeValue=12},
  4.                     new TestClass() {SomeName="B",SomeValue=13},
  5.                     new TestClass() {SomeName="B",SomeValue=14},
  6.                     new TestClass() {SomeName="AB",SomeValue=21},
  7.                     new TestClass() {SomeName="BC",SomeValue=22},
  8.                     new TestClass() {SomeName="AB",SomeValue=23},
  9.                     new TestClass() {SomeName="BC",SomeValue=24}
  10.                 };

Now we need to create a collection for the grid to bind to.  Remember, what we want is for each grid cell to be a view into our “main” collection.  In our Example we are using a DataGrid object to display our views and we want the DataGrid to automatically update if we add an object to or remove an object from the main collection .  What we need here is an ObeservableCollection of objects with a CollectionViewSource property for each column.  In our example we have called the object TheRowClass. TheRowClass has two properties of type CollectionViewSource; Column1 and Column2.

Here is the code I use to initialize the ObservableCollection<TheRowClass>:

Code Snippet

  1.             theDataContext = new ObservableCollection<TheRowClass>();
  2.             theDataContext.Add(new TheRowClass(theMainCollection));
  3.             theDataContext.Add(new TheRowClass(theMainCollection));

We have two items in the ObservableCollection<TheRowClass>, each item has two properties of type CollectionViewSource for a total of four CollectionViewSource objects – one for each cell.  We are passing the main collection into the constructor and using it as the Source property for all of our CollectionViewSources.  All we have left to do is add a filter to each of these collections.  The filter we are adding is very simple:

if TestClass.SomeName contains the letter A and TestClass.SomeValue contains the number 1 display the item in row 1, column 1

if TestClass.SomeName contains the letter A and TestClass.SomeValue contains the number 2 display the item in row 1, column 2

if TestClass.SomeName contains the letter B and TestClass.SomeValue contains the number 1 display the item in row 2, column 1

if TestClass.SomeName contains the letter B and TestClass.SomeValue contains the number 2 display the item in row 2, column 2

Here is the code to pull that off:

Code Snippet

  1.             AddFilter(theDataContext[0].Column1, 1, 1);
  2.             AddFilter(theDataContext[0].Column2, 1, 2);
  3.             AddFilter(theDataContext[1].Column1, 2, 1);
  4.             AddFilter(theDataContext[1].Column2, 2, 2);

And the AddFilter method:

Code Snippet

  1.         private void AddFilter(CollectionViewSource cvs, int row, int column)
  2.         {
  3.             cvs.Filter += delegate(object o, FilterEventArgs eArgs)
  4.             {
  5.                 //Simple filter saying if column = 1 look for an A in the SomeName property
  6.                 //Else look for a B.  If we are in Row 1 look for a 1 in the SomeValue property
  7.                 //Else look for a 2
  8.                 string columnFilter;
  9.                 if (row == 1)
  10.                     columnFilter = "A";
  11.                 else
  12.                     columnFilter = "B";
  13.                 if ((eArgs.Item as TestClass).SomeValue.ToString().Contains(column.ToString()) && (eArgs.Item as TestClass).SomeName.Contains(columnFilter))
  14.                     eArgs.Accepted = true;
  15.                 else
  16.                     eArgs.Accepted = false;
  17.             };
  18.         \

Run the app, notice that the item with SomeName=’AB’ and SomeValue=21 is in all of the cells, this is because that item satisfies the filter criteria for each CollectionViewSource in each cell.  Very cool. 


Add the letter ‘A’ to the first textbox and 111 to the second textbox and click the add button.  Notice that an item is added to column 1 and row 1 of the grid without doing anything more than making a call to Collection.Add.


Play around with a few more combinations and see how the grid updates.

Now let’s look at the bottom 3 buttons, the first button changes the SomeName value of the last item in the main collection to “B”.  Here is the event handler:

theMainCollection[theMainCollection.Count-1].SomeName = "B";

Again, add the letter ‘A’ to the first textbox and 111 to the second textbox and click the add button.  Now click the Change Name button.  Notice that nothing happens.  I assure The item has changed.

Now click the Change Value button.  Notice that the last item in row 1 cell 1 has changed, the value has become 25.


We changed the SomeValue property to 25 in the button click event handler.   Here is the event handler:

theMainCollection[theMainCollection.Count - 1].SomeValue = 25;

Why did SomeValue change while SomeName remained the same?  Take a look at the TestClass and you will notice that it implements the INotifyPropertyChanged interface and in the SomeValue setter we call NotifyPropertyChanged which raises an event.  Silverlight has a Pub/Sub eventing system that the UI uses to determine when to update databound objects.  With just a few lines of code we were able to wire TestClass into this eventing system and force the UI to update anytime our SomeValue property changes.

This is all fine and good, call that event for SomeName and we are good to go, well…almops.  We still have a problem with the UI!  The last item in cell 1 column 1 is displaying an incorrect SomeName value, and since we changed SomeValue to 25 it should no longer be visible in this cell.  What is happening is that the Filter on the collection view source is applied when an item is added to or removed from the collection but NOT when an item changes.  To manually reapply the filter call CollectionViewSource.View.Refresh (for each CollectionViewSource) and you will see the grid display correctly.  Click the Refresh CVS button to see this functionality in action:


Below is all of the source code.

Click here to see how to sort the grid cells.

Here is the XAML:

Code Snippet

  1. <UserControl xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data" x:Class="SilverlightApplication1.MainPage"
  2.    xmlns=""
  3.    xmlns:x=""
  4.    xmlns:d="" xmlns:mc=""
  5.    mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480">
  6.     <UserControl.Resources>
  7.     </UserControl.Resources>
  8.   <Grid x:Name="LayoutRoot">
  9.         <StackPanel Orientation="Vertical">
  10.             <data:DataGrid x:Name="TheMainDataGrid"   AutoGenerateColumns="False" ItemsSource="{Binding}" HeadersVisibility="Column">
  11.                 <data:DataGrid.Columns>
  12.                     <data:DataGridTemplateColumn Header="Column 1">
  13.                         <data:DataGridTemplateColumn.CellTemplate>
  14.                             <DataTemplate>
  15.                                 <ListBox MinHeight="15" ItemsSource="{Binding Path=Column1.View}" >
  16.                                     <ListBox.ItemTemplate>
  17.                                         <DataTemplate>
  18.                                             <StackPanel Orientation="Horizontal">
  19.                                                 <TextBlock Margin="10" Text="{Binding Path=SomeName}" />
  20.                                                 <TextBlock Margin="10" Text="{Binding SomeValue}" />
  21.                                             </StackPanel>
  22.                                         </DataTemplate>
  23.                                     </ListBox.ItemTemplate>
  24.                                 </ListBox>
  25.                             </DataTemplate>
  26.                         </data:DataGridTemplateColumn.CellTemplate>
  27.                     </data:DataGridTemplateColumn>
  28.                     <data:DataGridTemplateColumn Header="Column 2">
  29.                         <data:DataGridTemplateColumn.CellTemplate>
  30.                             <DataTemplate>
  31.                                 <ListBox MinHeight="15" ItemsSource="{Binding Path=Column2.View}" >
  32.                                     <ListBox.ItemTemplate>
  33.                                         <DataTemplate>
  34.                                             <StackPanel Orientation="Horizontal">
  35.                                                 <TextBlock Margin="10" Text="{Binding SomeName}" />
  36.                                                 <TextBlock Margin="10" Text="{Binding SomeValue}" />
  37.                                             </StackPanel>
  38.                                         </DataTemplate>
  39.                                     </ListBox.ItemTemplate>
  40.                                 </ListBox>
  41.                             </DataTemplate>
  42.                         </data:DataGridTemplateColumn.CellTemplate>
  43.                     </data:DataGridTemplateColumn>
  44.                 </data:DataGrid.Columns>
  45.             </data:DataGrid>
  46.             <StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
  47.                 <TextBlock Margin="5" Text="Name:"/>
  48.                 <TextBox x:Name="tbNAme" Margin="5" Width="100"></TextBox>
  49.                 <TextBlock Margin="5" Width="100" Text="Value:"/>
  50.                 <TextBox x:Name="tbValue" Margin="5" Width="100"></TextBox>
  51.                 <Button x:Name="AddButton" Margin="5" Content="Add" Click="AddButton_Click"/>
  52.             </StackPanel>
  53.             <StackPanel HorizontalAlignment="Left">
  54.                 <Button x:Name="ChangeNameButton" Width="100" Content="Change Name" Click="ChangeNameButton_Click"/>
  55.                 <Button x:Name="ChangeValueButton" Width="100" Content="Change Value" Click="ChangeValueButton_Click"/>
  56.                 <Button x:Name="refresh" Width="100" Content="Refresh CVS" Click="refresh_Click" />
  57.             </StackPanel>
  58.         </StackPanel>
  59.   </Grid>
  60. </UserControl>

Here is the code behind:

Code Snippet

  1. using System.Collections.ObjectModel;
  2. using System.Windows.Controls;
  3. using System.Windows.Data;
  4. namespace SilverlightApplication1
  5. {
  6.     public partial class MainPage : UserControl
  7.     {
  8.         ObservableCollection<TestClass> theMainCollection ;
  9.         ObservableCollection<TheRowClass> theDataContext;
  10.         public MainPage()
  11.         {
  12.             InitializeComponent();
  13.             theMainCollection = new ObservableCollection<TestClass>()
  14.                 { new TestClass() {SomeName="A",SomeValue=11},
  15.                     new TestClass() {SomeName="A",SomeValue=12},
  16.                     new TestClass() {SomeName="B",SomeValue=13},
  17.                     new TestClass() {SomeName="B",SomeValue=14},
  18.                     new TestClass() {SomeName="AB",SomeValue=21},
  19.                     new TestClass() {SomeName="BC",SomeValue=22},
  20.                     new TestClass() {SomeName="AB",SomeValue=23},
  21.                     new TestClass() {SomeName="BC",SomeValue=24}
  22.                 };
  24.             theDataContext = new ObservableCollection<TheRowClass>();
  25.             theDataContext.Add(new TheRowClass(theMainCollection));
  26.             theDataContext.Add(new TheRowClass(theMainCollection));
  27.             TheMainDataGrid.DataContext = theDataContext;
  28.             AddFilter(theDataContext[0].Column1, 1, 1);
  29.             AddFilter(theDataContext[0].Column2, 1, 2);
  30.             AddFilter(theDataContext[1].Column1, 2, 1);
  31.             AddFilter(theDataContext[1].Column2, 2, 2);
  33.         }
  34.         private void AddFilter(CollectionViewSource cvs, int row, int column)
  35.         {
  36.             cvs.Filter += delegate(object o, FilterEventArgs eArgs)
  37.             {
  38.                 //Simple filter saying if column = 1 look for an A in the SomeName property
  39.                 //Else look for a B.  If we are in Row 1 look for a 1 in the SomeValue property
  40.                 //Else look for a 2
  41.                 string columnFilter;
  42.                 if (column == 1)
  43.                     columnFilter = "A";
  44.                 else
  45.                     columnFilter = "B";
  46.                 if ((eArgs.Item as TestClass).SomeValue.ToString().Contains(column.ToString()) && (eArgs.Item as TestClass).SomeName.Contains(columnFilter))
  47.                     eArgs.Accepted = true;
  48.                 else
  49.                     eArgs.Accepted = false;
  50.             };
  51.         }
  52.         private void AddButton_Click(object sender, System.Windows.RoutedEventArgs e)
  53.         {
  54.             TestClass tc = new TestClass();
  55.             tc.SomeName = tbNAme.Text;
  56.             tc.SomeValue = int.Parse(tbValue.Text);
  57.             theMainCollection.Add(tc);
  58.         }
  59.         private void ChangeNameButton_Click(object sender, System.Windows.RoutedEventArgs e)
  60.         {
  61.             theMainCollection[theMainCollection.Count-1].SomeName = "B";
  62.         }
  63.         private void ChangeValueButton_Click(object sender, System.Windows.RoutedEventArgs e)
  64.         {
  65.             theMainCollection[theMainCollection.Count - 1].SomeValue = 25;
  66.         }
  67.         private void refresh_Click(object sender, System.Windows.RoutedEventArgs e)
  68.         {
  69.             theDataContext[0].Column1.View.Refresh();
  70.             theDataContext[1].Column1.View.Refresh();
  71.             theDataContext[0].Column2.View.Refresh();
  72.             theDataContext[1].Column2.View.Refresh();
  73.         }
  74.     }
  75. }

Here is the TestClass Code:

Code Snippet

  1. using System;
  2. using System.ComponentModel;
  3. namespace SilverlightApplication1
  4. {
  5.     public class TestClass:INotifyPropertyChanged
  6.     {
  7.         public String SomeName { get; set; }
  8.         private int _someValue;
  9.         public int SomeValue
  10.         {
  11.             get
  12.             {
  13.                 return _someValue;
  14.             }
  15.             set
  16.             {
  17.                 if (value != this._someValue)
  18.                 {
  19.                     this._someValue = value;
  20.                     NotifyPropertyChanged("SomeValue");
  21.                 }
  22.             }
  23.         }
  24.         #region INotifyPropertyChanged Members
  25.         public event PropertyChangedEventHandler PropertyChanged;
  26.         #endregion
  27.         private void NotifyPropertyChanged(String info)
  28.         {
  29.             if (PropertyChanged != null)
  30.             {
  31.                 PropertyChanged(this, new PropertyChangedEventArgs(info));
  32.             }
  33.         }
  34.     }
  35. }

Here is TheRowClass:

Code Snippet

  1. using System.Windows.Data;
  2. using System.Collections;
  3. namespace SilverlightApplication1
  4. {
  5.     public class TheRowClass
  6.     {
  7.         public TheRowClass(IEnumerable source)
  8.         {
  9.             Column1 = new CollectionViewSource();
  10.             Column1.Source = source;
  11.             Column2 = new CollectionViewSource();
  12.             Column2.Source = source;
  13.         }
  14.         public CollectionViewSource Column1 { get; set; }
  15.         public CollectionViewSource Column2 { get; set; }
  16.     }
  17. }

1 comment:

  1. Thanks. Your examples actually helped get my stuff working. I thought the MSDN documentation was quite unclear.