Using a Grid as the Panel for an ItemsControl

In this blog post I look at how to use a Grid as the ItemsPanel for an ItemsControl, solving a few of the issues that crop up along the way.

The Grid is probably the most useful of Silverlight and WPF's panels (panels are elements which provide a mechanism for laying out their children). The ItemsControl provides a mechanism for generating multiple instances of some template based on the data that is bound to it. The ItemsControl 'concept' is highlight versatile, and is used as the foundation for many of the framework controls, I find myself using it all the time.

Isn't it a shame that Grid and ItemsControl don't play together nicely?

So what do I mean by this? I am sure that if you have ever tried to generate Grid a layout using an ItemsControl you will know what I am talking about, but if not, here's a very brief summary of the problem. An ItemsControl manages a number of child elements, you can configure the type of panel the ItemsControl adds children to via its ItemsPanel property. If I have a list of strings which I want to render using a Grid, I might expect the following to work ... in XAML:

<ItemsControl ItemsSource="{Binding}">
  <!-- host the items generated by this ItemsControl in a grid -->
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <Grid/>
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <!-- render each bound item using a TextBlock-->
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding}"/>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

And in code-behind:

this.DataContext = new string[]
  { "one", "two", "three", "four", "five"  };

So what is wrong with the above? There are a couple of problems:

  1. When using a Grid you must add the required number of rows / columns via the RowDefinitions ColumnDefinitions properties. In the above example what we really want is for the grid rows to be added dynamically based on the number of items in the bound data. Unfortunately this is not supported either by the Grid or the ItemsControl.
  2. The second problem is that items within a grid must declare the row / column that they occupy via the Grid.Row and Grid.Column attached properties. You might think that you could add a Grid.Row property to the TextBlock in the above example, binding it to some other property of the bound collection. However, this will not work because the TextBlock elements are not added into the grid directly; each one is added as the content of a ContentPresenter which is generated for us.

The above two problems mean that it is not possible to use a Grid as the panel within an ItemsControl. In this blog post I will show a solution to these problems ...

Dynamically adding RowDefinitions

The first problem to overcome is how to dynamically add the required number of RowDefinitions to our Grid. To support this I added an attached property ItemsPerRow, which upon attachment handles the LayoutUpdated event. This event is fired when items are added to the grid, this enables us to compute the number of RowDefinitions that are required, and also to assign a row index to each of the Grid's children. The following code is the property changed handler for the attached ItemsPerRow property:

/// <summary>
/// Handles property changed event for the ItemsPerRow property, constructing
/// the required ItemsPerRow elements on the grid which this property is attached to.
/// </summary>
private static void OnItemsPerRowPropertyChanged(DependencyObject d,
                    DependencyPropertyChangedEventArgs e)
{
  Grid grid = d as Grid;
  int itemsPerRow = (int)e.NewValue;

  // construct the required row definitions
  grid.LayoutUpdated += (s, e2) =>
    {
      var childCount = grid.Children.Count;

      // add the required number of row definitions
      int rowsToAdd = (childCount - grid.RowDefinitions.Count) / itemsPerRow;
      for (int row = 0; row < rowsToAdd; row++)
      {
        grid.RowDefinitions.Add(new RowDefinition());
      }

      // set the row property for each chid
      for (int i = 0; i < childCount; i++)
      {
        var child = grid.Children[i] as FrameworkElement;
        Grid.SetRow(child, i / itemsPerRow);
      }
    };
}

Note the use of a lambda expression for the event handler in order to 'capture' a reference to the Grid (as described in my previous blog post on binding the ScrollViewers offset properties).

Changing our markup to use the above attached property:

<ItemsControl ItemsSource="{Binding}">  
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <!-- use the ItemsPerRow attached property to dynamically add rows -->
      <Grid local:GridUtils.ItemsPerRow="1"
          ShowGridLines="True"/>
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding}"/>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

The array of strings bound in code-behind results in the following grid layout being rendered:

Great, all fixed. Time to grab a coffee.

Unfortunately it isn't that simple. The above example has a single item per grid row, typically you would want to have multiple items in each row, with each item positioned in a different column. This is where it gets tricky. DataTemplate only supports a single child element, so we will have to wrap the elements which we want to add into our grid in a panel of some sort. However, if we do this, the column index that we apply to the element of each row will be ignored because the Grid only honours the Row and Column properties of its direct descendants.

Creating a Row Template

In order to circumnavigate this issue, I have created a 'phantom' panel, which hosts the elements that are added to each grid row. When a phantom panel is added to the grid, its children are removed and added to the grid and the phantom is destroyed.

public class PhantomPanel : Panel
{

}

The property changed handler shown above is extended to remove this phantom:

private static void OnItemsPerRowPropertyChanged(DependencyObject d,
                    DependencyPropertyChangedEventArgs e)
{
  Grid grid = d as Grid;
  int itemsPerRow = (int)e.NewValue;

  // construct the required row definitions
  grid.LayoutUpdated += (s, e2) =>
    {
      // iterate over any new content presenters (i.e. instances of our DataTemplate)
      // that have been added to the grid
      var presenters = grid.Children.OfType<ContentPresenter>().ToList();
      foreach (var presenter in presenters)
      {
        // the child of each DataTemplate should be our 'phantom' panel
        var phantom = VisualTreeHelper.GetChild(presenter, 0) as PhantomPanel;
        if (phantom != null)
        {

          // remove each of the children of the phantom and add to the grid
          foreach (FrameworkElement child in phantom.Children.ToList())
          {
            phantom.Children.Remove(child);
            grid.Children.Add(child);
            // ensure the child maintains its original datacontext
            child.DataContext = phantom.DataContext;
          }

          // remove the presenter (and phantom)
          grid.Children.Remove(presenter);
        }
      }

      var childCount = grid.Children.Count;
      int rowDifference = (childCount / itemsPerRow) - grid.RowDefinitions.Count;

      // if new items have been added, create the required grid rows
      // and assign the row index to each child
      if (rowDifference != 0)
      {
        grid.RowDefinitions.Clear();
        for (int row = 0; row < (childCount / itemsPerRow); row++)
        {
          grid.RowDefinitions.Add(new RowDefinition());
        }

        // set the row property for each chid
        for (int i = 0; i < childCount; i++)
        {
          var child = grid.Children[i] as FrameworkElement;
          Grid.SetRow(child, i / itemsPerRow);
        }
      }
    };
}

The above code is pretty straightforward, however there is one subtlety, we must ensure that the DataContext of each child element is set to the DataContext that the ItemsControl assigned to each PhantomPanel, i.e. the individual items that are bound to the Grid.

With the above code it is now possible to create a more complex grid layout:

<StackPanel Orientation="Vertical">
    
  <sdk:DataGrid ItemsSource="{Binding}" Margin="10"/>
    
  <Border x:Name="LayoutRoot" Background="White"
        BorderBrush="Black" BorderThickness="1" Margin="10">

    <ItemsControl ItemsSource="{Binding}">
      <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
          <Grid local:GridUtils.ItemsPerRow="2">
            <Grid.ColumnDefinitions>
              <ColumnDefinition/>
              <ColumnDefinition/>
            </Grid.ColumnDefinitions>
          </Grid>
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <!-- the row 'template' within a phantom panel -->
          <local:PhantomPanel>
            <TextBlock Text="{Binding Path=Item}"/>
            <TextBlock Text="{Binding Path=Quantity}" Grid.Column="1"/>
          </local:PhantomPanel>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>

  </Border>

</StackPanel>

The above XAML renders a list of simple model objects that implement INotifyPropertyChanged in a DataGrid and an ItemsControl (using our Grid layout). Notice that if you edit the properties of the bound objects via the DataGrid, these changes are reflected in the Grid below, i.e. it is all working!

Get Microsoft Silverlight

Handling CollectionChanged Events

The above example looks pretty complete, however there is one remaining issue, handling changes to the bound collection. The problem with the above solution is that it effectively subverts the ItemsControls usage of the ItemsPanel. The ItemsControl will expect that items that it adds to the Panel to appear at certain indices, by removing our phantom panels and adding their contente directly this is no longer the case.

A simple hack (and yes, it really is a hack!) is to confuse the ItemsControl into thinking that any change to our bound collection is a reset, i.e. the collection has changed completely, so the UI needs to be rebuilt from scratch. To do this, I have created an attached ItemsSource property that adapts the bound collection, ensuring that any collection changes result in the ItemsControl rebuilding its UI. The changed handler for this attached property is given below:

/// <summary>
/// Handles property changed event for the ItemsSource property.
/// </summary>
private static void OnItemsSourcePropertyChanged(DependencyObject d,
  DependencyPropertyChangedEventArgs e)
{
  ItemsControl control = d as ItemsControl;

  // set the ItemsSource of the ItemsControl that this property is attached to
  control.ItemsSource = e.NewValue as IEnumerable;

  INotifyCollectionChanged notifyCollection = e.NewValue as INotifyCollectionChanged;
  if (notifyCollection != null)
  {
    // if a collection changed event occurs, reset the ItemsControl's
    // ItemsSource, rebuilding the UI 
    notifyCollection.CollectionChanged += (s, e2) =>
      {
        control.ItemsSource = null;
        control.ItemsSource = e.NewValue as IEnumerable;
      };
  }
}

The above attached property is used in exactly the same way as the ItemsControl.ItemsSource property which it adapts. The XAML below shows how the above attached property can be used in place of the ItemsControl property:

<ItemsControl local:GridUtils.ItemsSource="{Binding}">
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <Grid local:GridUtils.ItemsPerRow="3">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="2*"/>
          <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
      </Grid>
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <local:PhantomPanel>
        <TextBlock Text="{Binding Path=Item}"/>
        <TextBlock Text="{Binding Path=Quantity}" Grid.Column="1"/>
        <Line Stroke="LightGray" StrokeThickness="1"
              VerticalAlignment="Bottom"
              X1="0" X2="1" Y1="0" Y2="0"
              Stretch="Fill"
              Grid.ColumnSpan="2"/>
      </local:PhantomPanel>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

And here it is with a DataGrid bound to the same data:

Get Microsoft Silverlight

You can add, remove and update the bound objects and the Grid rendered via the ItemsControl remains synchronised with the bound data.

Conclusions

Finally, the ItemsControl and Grid can be used together, a cause for much rejoicing! I must admit, I am pretty pleased with my solution; however, it is far from perfect. There are a couple of areas of this implementation that you should be wary of.

Firstly, the use of the LayoutUpdated event which we use to determine when items are added to our Grid. This event gets fired when anything changes in our visual tree's layout. It has the potential to be called a lot, which is why I am always careful to check conditions and exit this method as quickly as possible if the UI is not in the state I am interested in.

Secondly, adapting the ItemsSource to force a Reset whenever an items is added or removed is pretty costly!

If you do not use the technique for rendering large quantities of data it will probably be just fine. It may be fine for large volumes of data also, however, it is always better to be aware of potential issue.

If anyone has any ideas on how to improve on this technique, please leave a comment below:

You can download the full project sourcecode: ItemsControlGrid.zip

Regards, Colin E.

blog comments powered by Disqus