Wednesday, January 19, 2011

Silverlight DataGrid with Dynamic Columns

I had to build a grid of images. Business requirements called for an image with a caption, arranged in rows and columns by certain user criteria. (In other words, I couldn't just use a StackPanel and flow them horizontally and wrap row to row). Additionally, the number of rows and columns is impossible to know at compile time.

I started with a class

public class Thumb
{
    public Thumb(string imageUri, string title)
    {
        Thumbnail = new BitmapImage(new Uri(imageUri, UriKind.Relative));
        Title = title;
    }
    public ImageSource Thumbnail { get; set; }

    public string Title { get; set; }
}

I wanted to arrange them into a 2D array of Thumb[][], or IEnumerable<Thumb[]>. I'll stick with the latter because it's clearer to explain: Enumerate over the rows, and index into the array for columns. Also, there is other code to guarantee that the data is the same in columns and that the 2D array is always a rectangular, never jagged.

As an aside, if this had been ASP.NET, I could have put these objects into a DataTable/DataSet, and its DataGrid would have dynamically generated the columns. But binding an IEnumerable<Thumb[]> to a Silverlight DataGrid with AutoGenerateColumns=True ends up with a grid where each row is an array…the columns are Length, SyncRoot, IsReadOnly, etc—the properties on the array, not indexing into it.

Now to wire it up in XAML. To start, let's do it by hand, for just the first column.

<sdk:DataGrid ItemsSource="{Binding MyThumbnailCollection}">
  <sdk:DataGrid.Columns>
    <sdk:DataGridTemplateColumn Header="Column Title">
      <sdk:DataGridTemplateColumn.CellTemplate>
        <DataTemplate>
          <StackPanel>
            <TextBlock Text="{Binding [0].Title}" />
            <Image Source="{Binding [0].Thumbnail}" />
          </StackPanel>
        </DataTemplate>
      </sdk:DataGridTemplateColumn.CellTemplate>
    </sdk:DataGridTemplateColumn>
  </sdk:DataGrid.Columns>
</sdk:DataGrid>

When the DataGrid renders with TemplateColumns, it enumerates its ItemsSource (which it expects to be IEnumerable), and then sets the DataContext of the child FrameworkElement of the DataTemplate to the given row in the collection—in our case, each row is an array, Thumb[]. You can see that the template above is bound to an indexer and a property of the indexed object. Pretty cool and powerful runtime binding.

But how do we wire that up dynamically? At compile time, we don't know how many columns will be in the grid, so hard-coded XAML columns are out. I need to modify that binding dynamically, after the ItemsSource is set.

Roadblocks

This is not, as it turns out, a simple task in Silverlight.

As far as I can see, there's no simple way to create columns dynamically with a data context! If I'm missing something here, please leave a comment!

XAML in C#

The first solution I found was to create the indexed binding XAML in C# and load it into the cell template – a crafty workaround to both problems. It looks something like this:

private void CreateGridColumns()
{
    grid.Columns.Clear();
    if (Thumbs.Count == 0) return;

    for (int i = 0; i < Thumbs[0].Length; i++)
    {
        var template = new StringBuilder();
        template.Append(@"<sdk:DataGridTemplateColumn 
            xmlns:sdk=""http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"" 
            xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
            xmlns:my=""clr-namespace:Client.App.Views;assembly=Client.App"" ");
        template.AppendFormat(@"Header=""{0}""> ", Thumbs[0][i].Title);
        template.Append(@"<sdk:DataGridTemplateColumn.CellTemplate>
              <DataTemplate>
                <StackPanel>
                  <TextBlock Text=""{");
        template.AppendFormat("Binding [{0}].Title", i);
        template.Append(@"}"" />
                  <Image Source=""{");
        template.AppendFormat("Binding [{0}].Thumbnail", i);
        template.Append(@"}"" />
                  </StackPanel>
                </DataTemplate>
            </sdk:DataGridTemplateColumn.CellTemplate>
        </sdk:DataGridTemplateColumn>");
        grid.Columns.Add((DataGridTemplateColumn)XamlReader.Load(template.ToString()));
    }
}

This code generates identical XAML as to what we wrote by hand, except one for each column.

As a matter of style though, I don't particularly like the way this code smells. C# isn't for writing XAML, and any UI that has do be done in code should be done using the object model.

Creating a Bindable Column

Edit: This solution fails to work in Silverlight 4 because of the DataGrid's virtualization aka container recycling. See below for a updated version.

The DataGrid recycles rows when they scroll in & out of view. That is to say, rather than create all of the UI elements for potentially millions of rows in a DataGrid, they only create enough to display those that fit on the screen. When a row scrolls out of view, it's put on a "recycled" Stack; when a row scrolls into view, it checks the Stack for a row to reuse before creating a new one. If it finds one, it sets the row's DataContext appropriately and then displays it.

Our solution below fails to work because we set the DataContext of the row UI elements directly—that means when a row gets recycled, it will display whatever data it had been assigned when it was first instantiated. My apologies; I tested it with a small set of data initially. Keep reading for a solution.

This solution is a bit cleaner – it's certainly more object oriented, and feels more at home in C#, despite the increase in lines of code. Create a column that is aware of its binding context so that it can "pass it down" to its cells.

DataGridTemplateColumn has a method GenerateElement that is used to build out the FrameworkElement that is the UI of the datagrid cell. We need to override it so that is can be aware of its "column context."

First, create this "bindable" template column, inspired via:

public class DataGridBindableTemplateColumn : DataGridTemplateColumn
{
    public string BindingPath { get; set; }

    protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
    {
        //dataItem is the row.
        var elem = base.GenerateElement(cell, dataItem);
        elem.SetBinding(FrameworkElement.DataContextProperty, new Binding(BindingPath) { Source = dataItem });
        return elem;
    }

}

Two things to note: first, we call base.GenerateElement() to let the DataGrid do its thing and build the UI. Second, when we modify the binding, we only modify the path. This will be a string like "[0].Title" or "[0].Thumbnail" in our case, and it is bound to the dataItem, which is a row in our grid (remember, enumerate over the rows, index into the columns). The framework handles the rest of the binding.

Note this same code "should" work with an IEnumerable<Dictionary<,>> and a BindingPath that keys into the dictionary. I haven't tested that, however.

Then, when your grid's ItemsSource changes, determine the columns and update dynamically:

private void CreateGridColumns(ObservableCollection<Thumb[]> thumbs)
{
    grid.Columns.Clear();

    if (thumbs.Count == 0) return;

    for (int i = 0; i < thumbs[0].Length; i++)
    {
        var dt = (DataTemplate)Resources["ThumbViewTemplate"];
        var col = new DataGridBindableTemplateColumn();
        col.CellTemplate = dt;
        col.BindingPath = String.Format("[{0}]", i);

        grid.Columns.Add(col);
    }
}

This code is a little different because, instead of creating the StackPanel, TextBlock and Image in source code, I moved it into a named resource in the XAML (below). You can see that I'm creating the new custom DataGridBindableTemplateColumn and setting its BindingPath to an index into the array, which the above GenerateElement override knows how to use.

<UserControl.Resources>
    <DataTemplate x:Key="ThumbViewTemplate">
        <StackPanel>
            <TextBlock HorizontalAlignment="Center" Text="{Binding Title}" />
            <Image HorizontalAlignment="Center" Stretch="Uniform" Width="200" Height="200" Source="{Binding Thumbnail}" />
        </StackPanel>
    </DataTemplate>
</UserControl.Resources>

As the grid renders, the <StackPanel> of each cell gets its DataContext bound to dataItem[i] – where dataItem is the row. Thus, its children are now bound to a single Thumb instance in our case.

DataGrid LoadingRow Event

While the above solution fails abysmally, there is fortunately an even more elegant option. First, you create the columns dynamically just as before. Second, you handle the DataGrid's LoadingRow event, which is fired every time a row is either created or scrolled into view. We can use this event to index into our columns and bind each cell individually.

Note we're using a regular DataGridTemplateColumn.

private void CreateGridColumns(ObservableCollection<Thumb[]> thumbs)
{
    grid.Columns.Clear();    
    if (thumbs.Count == 0) return;

    for (int i = 0; i < thumbs[0].Length; i++)
    {
        var dt = (DataTemplate)Resources["ThumbViewTemplate"];
        var col = new DataGridTemplateColumn();
        col.CellTemplate = dt;
        grid.Columns.Add(col);
    }
}

private void grid_LoadingRow(object sender, DataGridRowEventArgs e)
{
    var dataRow = (Thumb[])e.Row.DataContext;
    for (int i = 0; i < grid.Columns.Count; i++)
    {
        var col = grid.Columns[i];
        var elem = col.GetCellContent(e.Row);//FrameworkElement inside of DataGridCell
        elem.DataContext = ((Thumb[])e.Row.DataContext)[i];
    }
}

Another important point to note is that DataGridRow.Cells is an internal property. Use DataGridColumn.GetCellContent(DataGridRow) instead, it will return the FrameworkElement of the cell at the intersection of the row and column.

There you have it – dynamic columns in a Silverlight datagrid.

Hope this helps! Which solution do you prefer?

5 comments:

Anonymous said...

Great solution (the last)

Claudio

El G said...

The last solution is nice, the only remark I have is that in CreateGridColumns method this line should probably be put before the loop as this assignment should only be done once:

var dt = (DataTemplate)Resources["ThumbViewTemplate"];

nick_kritikos said...

Nice article..
In the last approach where is the definition for ThumbViewTemplate ?

Travis said...

I didn't release the ThumbViewTemplate. It's just a wrapper for an image with a caption.

Richard France said...

Genius, final bit of puzzle sorted.

On Load of page I have no idea of number of datagrids/charts, no idea of number of columns or what type.

Had got that far, last bit was I had no idea how many values I need to show in each cell. Thanks to you have solved the last bit :)