Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Displaying a Data Matrix in WPF

0.00/5 (No votes)
14 Jun 2009 2  
Reviews a matrix control that can be data bound and visually styled with ease

MoneyShot.png

Introduction

This article reviews a WPF control, and a set of related classes, that makes it easy to create and display a matrix of data. In addition to seeing how to use the matrix control, we will also explore how it works.

Background

A common way to display a set of related data entities, such that it is easy to compare them against each other, is in a matrix. Like a data grid, a matrix consists of rows and columns. Unlike a data grid, the rows in a matrix also have headers, each of which display an attribute or value related to the entities being displayed. By including row headers, a matrix exposes additional dimensions of data beyond what a standard data grid can display.

Anatomy of a Matrix

Throughout this article, and the associated source code, we reference specific parts of a matrix by name. The following annotated screenshot points out each of the parts.

Anatomy.png

Introducing MatrixControl and MatrixBase

The source code package available at the top of this article contains a Visual Studio 2008 solution with two projects. The MatrixLib project contains the MatrixControl class, which is a UI control that you can use to display a data matrix. MatrixControl derives from ItemsControl and arranges its child elements in a grid layout. You can easily drop a MatrixControl into any user interface and bind its ItemsSource property to a collection of objects that should be displayed as a matrix.

However, there’s more to it than that. Taking a set of data entities and transforming them into a one-dimensional collection of objects that can be bound to and displayed in a two-dimensional grid layout requires a bit of extra logic. You need to somehow let the MatrixControl know which ‘slot’ in the grid each item should be placed. In order to easily apply a visual style to the matrix, you need to somehow differentiate in XAML between column headers, row headers, and cells. Also, you need to figure out a way to get the value that should be displayed in each cell (i.e. the value that corresponds to each row/column intersection). In order to make it easier and faster to accomplish these tasks, I created the MatrixBase<TRow, TColumn> class. All you need to do is derive a class from MatrixBase and override a few methods; all of the heavy lifting will be taken care of for you.

The following two sections of this article demonstrate how to use MatrixControl and MatrixBase.

Demo 1 - Country Matrix

CountryMatrix.png

The WpfMatrixDemo project in this article’s source code package contains two examples of using MatrixControl. In this section we will see how to create a matrix that displays a list of countries and various attributes about those countries.

The data for this demo resides in Country objects. The Country class is seen below:

class Country
{
    public double ExportsInMillions { get; set; }
    public double ExternalDebtInMillions { get; set; }
    public string FlagIcon { get; set; }
    public double GDPInMillions { get; set; }
    public double LifeExpectancy { get; set; }
    public string Name { get; set; }
}

An array of Country objects is created by the Database class:

public static Country[] GetCountries()
{
    return new Country[]
    {
        new Country
        {
            Name = "Switzerland",
            ExportsInMillions = 172700,
            ExternalDebtInMillions = 1340000,
            FlagIcon = "Flags/switzerland.png",
            GDPInMillions = 492595,
            LifeExpectancy = 80.62
        },
        new Country
        {
            Name = "United Kingdom",
            ExportsInMillions = 468700,
            ExternalDebtInMillions = 10450000,
            FlagIcon = "Flags/uk.png",
            GDPInMillions = 2674085,
            LifeExpectancy = 78.7
        },
        new Country
        {
            Name = "United States",
            ExportsInMillions = 1377000,
            ExternalDebtInMillions = 13703567,
            FlagIcon = "Flags/usa.png",
            GDPInMillions = 14264600,
            LifeExpectancy = 78.06
        }
    };
}

The array of Country objects is loaded into CountryMatrix, which is declared below:

/// <summary>
/// A matrix that displays countries in the columns
/// and attributes of a country in the rows.
/// </summary>
class CountryMatrix : MatrixBase<string, Country>
{
    // ...
}

Notice that MatrixBase is a generic class and has two type parameters. The first type parameter, TRow, specifies what type of object will be placed into row headers. The second type parameter, TColumn, indicates the type of object will be placed into column headers.

CountryMatrix has the following initialization code:

public CountryMatrix()
{
    _countries = Database.GetCountries();
    _rowHeaderToValueProviderMap = new Dictionary<string, CellValueProvider>();
    this.PopulateCellValueProviderMap();
}

void PopulateCellValueProviderMap()
{
    // Use the American culture to force currency 
    // formatting to use the dollar sign ($).
    CultureInfo culture = new CultureInfo("en-US");

    _rowHeaderToValueProviderMap.Add(
        "Exports (millions)",
        country => country.ExportsInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "External Debt (millions)",
        country => country.ExternalDebtInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "GDP (millions)",
        country => country.GDPInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "Life Expectancy",
        country => country.LifeExpectancy.ToString("f2"));
}

// Fields
readonly Country[] _countries;
readonly Dictionary<string, CellValueProvider> _rowHeaderToValueProviderMap;

/// <summary>
/// This delegate type describes the signature of a method 
/// used to produce the value of a cell in the matrix.
/// </summary>
private delegate object CellValueProvider(Country country)

The _rowHeaderToValueProviderMap field associates the values shown in row headers with a callback method that is used to produce the value of each cell in that row. That callback receives a Country object as a parameter (which comes from the column header), and returns some value to display in that cell. We can see how this technique is put to use when looking at the overridden methods of CountryMatrix:

protected override IEnumerable<Country> GetColumnHeaderValues()
{
    return _countries;
}

protected override IEnumerable<string> GetRowHeaderValues()
{
    return _rowHeaderToValueProviderMap.Keys;
}

protected override object GetCellValue(
    string rowHeaderValue, Country columnHeaderValue)
{
    return _rowHeaderToValueProviderMap[rowHeaderValue](columnHeaderValue);
}

If you open the AppWindow.xaml file, you will see that an instance of CountryMatrix is set as the DataContext of a MatrixControl, and its MatrixItems property is the source for the control’s ItemsSource property binding.

<mx:MatrixControl ItemsSource="{Binding Path=MatrixItems}">
  <mx:MatrixControl.DataContext>
    <local:CountryMatrix />
  </mx:MatrixControl.DataContext>
  <mx:MatrixControl.Resources>
    <ResourceDictionary Source="CountryMatrixTemplates.xaml" />
  </mx:MatrixControl.Resources>
</mx:MatrixControl>

The MatrixControl declared above is injected with a ResourceDictionary contained in the CountryMatrixTemplates.xaml file. That file contains a DataTemplate for each of the various parts of the matrix. An abridged version of that file is shown below. The only template that remains intact is the one that is used to display column headers. In this demo, each column header contains a flag icon and the name of a country.

<ResourceDictionary 
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:mx="clr-namespace:MatrixLib.Matrix;assembly=MatrixLib"
  >
  <!-- Shared Resources -->
  <SolidColorBrush x:Key="BackBrush" Color="LightBlue" />
  <SolidColorBrush x:Key="BorderBrush" Color="LightBlue" />
  <Thickness x:Key="BorderThickness" Left="0" Top="0" Right="0.5" Bottom="0.5" />
  <SolidColorBrush x:Key="HeaderForeground" Color="DarkBlue" />

  <DataTemplate DataType="{x:Type mx:MatrixColumnHeaderItem}">
    <Border 
      Background="{StaticResource BackBrush}" 
      BorderBrush="{StaticResource BorderBrush}" 
      BorderThickness="{StaticResource BorderThickness}" 
      Padding="0,4"
      >
      <DockPanel>
        <Image 
          DockPanel.Dock="Left" 
          Margin="3,0,0,0"
          Source="{Binding Path=ColumnHeader.FlagIcon}"  
          Width="18" Height="12" 
          />
        <TextBlock 
          FontWeight="Bold"
          Foreground="{StaticResource HeaderForeground}"
          Text="{Binding Path=ColumnHeader.Name}" 
          TextAlignment="Center"
          />
      </DockPanel>
    </Border>
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixEmptyHeaderItem}">
    <!-- ... -->
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixRowHeaderItem}">
    <!-- ... -->
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixCellItem}">
    <!-- ... -->
  </DataTemplate>
</ResourceDictionary>

Each of the DataTemplates shown above targets a type that represents a certain part of a matrix, such as how the template for MatrixColumnHeaderItem is used to render column headers. All of those types derive from MatrixItemBase, as seen in the following class diagram. This information is useful when creating templates that are used to render instances of these types.

MatrixClassDiagram.png

Later in this article, we will review how these types are created and arranged internally by MatrixControl. For now, just accept that instances of these types will be placed into the MatrixControl automatically by MatrixBase.

Demo 2 - Person Matrix

PersonMatrix.png

In the previous demo, we saw how to display a matrix that contained the names of countries in the column headers, attributes of a country in the row headers, and the value of each attribute for each country in the cells. In this demo, we will create a different kind of matrix. This matrix displays the names of people in the column headers, a unique list of countries in which those people live in the row headers, and a visual indicator in the cells if a person lives in a certain country. Instead of showing various attributes of a person in the row headers, we show a list of unique values for one attribute of a person in the row headers.

Here is the method in the Database class that creates an array of Person objects:

public static Person[] GetPeople()
{
    return new Person[]
    {
        new Person 
        { 
            Name= "Brennon", 
            CountryOfResidence = "United Kingdom"
        },
        new Person
        {
            Name="Josh", 
            CountryOfResidence ="United States"
        },
        new Person
        {
            Name="Karl", 
            CountryOfResidence= "United States"
        },
        new Person
        {
            Name="Laurent", 
            CountryOfResidence="Switzerland"
        },
        new Person
        {
            Name="Sacha", 
            CountryOfResidence= "United Kingdom"
        }      
    };
}

In this demo, the PersonMatrix class derives from MatrixBase. That class is listed below in its entirety:

/// <summary>
/// A matrix that displays people in the columns 
/// and countries in which people live in the rows.
/// </summary>
public class PersonMatrix : MatrixBase<string, Person>
{
    public PersonMatrix()
    {
        _people = Database.GetPeople();
    }

    protected override IEnumerable<Person> GetColumnHeaderValues()
    {
        return _people;
    }

    protected override IEnumerable<string> GetRowHeaderValues()
    {
        // Return a sorted list of unique country names.
        return
            from person in _people
            orderby person.CountryOfResidence
            group person by person.CountryOfResidence into countryGroup
            select countryGroup.Key;
    }

    protected override object GetCellValue(
        string rowHeaderValue, Person columnHeaderValue)
    {
        return rowHeaderValue == columnHeaderValue.CountryOfResidence;
    }

    readonly Person[] _people;
}

Since this matrix does not require the value of multiple attributes for each data entity, there is no need for the row-to-cell value mapping technique used in the first demo. The GetCellValue method simply returns true if the specified person lives in the specified country, or false if that’s not the case.

Here is the markup in AppWindow.xaml that configures a MatrixControl to display a PersonMatrix instance:

<mx:MatrixControl ItemsSource="{Binding Path=MatrixItems}">
  <mx:MatrixControl.DataContext>
    <local:PersonMatrix />
  </mx:MatrixControl.DataContext>
  <mx:MatrixControl.Resources>
    <ResourceDictionary Source="PersonMatrixTemplates.xaml" />
  </mx:MatrixControl.Resources>
</mx:MatrixControl>

The PersonMatrixTemplates.xaml file contains DataTemplates used to visually style this matrix. One point of interest in that file is the template which renders each matrix cell. It uses a DataTrigger to hide the visual indicator if a person does not live in the country associated with the row in which the cell resides.

<DataTemplate DataType="{x:Type mx:MatrixCellItem}">
  <Border 
    x:Name="bd" 
    Background="#110000FF" 
    BorderBrush="{StaticResource BorderBrush}" 
    BorderThickness="{StaticResource BorderThickness}" 
    >
    <Ellipse 
      x:Name="ell"
      Fill="DarkBlue"
      HorizontalAlignment="Center"
      Width="16" Height="16"  
      VerticalAlignment="Center" 
      />
  </Border>
  <DataTemplate.Triggers>
    <DataTrigger Binding="{Binding Path=Value}" Value="False">
      <Setter TargetName="ell" Property="Visibility" Value="Collapsed" />
      <Setter TargetName="bd" Property="Background" Value="White" />
    </DataTrigger>
  </DataTemplate.Triggers>
</DataTemplate>

At this point we have reviewed two examples of how to use MatrixControl and MatrixBase. The remainder of this article explores how those classes work.

How MatrixControl Works

The MatrixControl class is quite simple. It is merely an ItemsControl subclass with a custom ItemsPanel and ItemContainerStyle. Its code-behind file is empty, apart from the standard boilerplate code that calls InitializeComponent in the constructor. The following XAML is all there is to MatrixControl:

<ItemsControl 
  x:Class="MatrixLib.Matrix.MatrixControl"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:layout="clr-namespace:MatrixLib.Layout"
  >
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <layout:MatrixGrid />
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>

  <ItemsControl.ItemContainerStyle>
    <!-- 
    Bind each ContentPresenter's attached Grid 
    properties to MatrixItemBase properties. 
    -->
    <Style TargetType="{x:Type ContentPresenter}">
      <Setter Property="Grid.Row" Value="{Binding Path=GridRow}" />
      <Setter Property="Grid.Column" Value="{Binding Path=GridColumn}" />
    </Style>
  </ItemsControl.ItemContainerStyle>
</ItemsControl>

The real logic behind this control lives in the MatrixGrid layout panel, as well as MatrixBase and an assortment of MatrixItemBase subclasses. Let’s see how those work next.

How MatrixGrid Works

As mentioned above, MatrixControl is an ItemsControl subclass that uses a MatrixGrid as its items panel. MatrixGrid derives from the standard Grid panel, and has the ability to add the appropriate number of rows and columns to itself in order to properly host its child elements. As of this writing, MatrixGrid does not remove rows or columns from itself when its visual children are removed or moved to a different row or column, simply because none of my use cases required that functionality.

MatrixGrid monitors its visual children (i.e. its “child elements”). It establishes bindings on its child elements, specifically to the Grid.Row and Grid.Column attached properties on its children. When it detects a new value for either attached property on a child element, it will, if necessary, add more rows or columns to itself, in order to allow that child element to be positioned in its desired location. The data binding source is the child element, and the target of the binding is a MatrixGridChildMonitor, which is defined as:

/// <summary>
/// Exposes two dependency properties which are bound to in
/// order to know when the visual children of a MatrixGrid are
/// given new values for the Grid.Row and Grid.Column properties.
/// </summary>
class MatrixGridChildMonitor : DependencyObject
{
    public int GridRow
    {
        get { return (int)GetValue(GridRowProperty); }
        set { SetValue(GridRowProperty, value); }
    }

    public static readonly DependencyProperty GridRowProperty =
        DependencyProperty.Register(
        "GridRow",
        typeof(int),
        typeof(MatrixGridChildMonitor),
        new UIPropertyMetadata(0));

    public int GridColumn
    {
        get { return (int)GetValue(GridColumnProperty); }
        set { SetValue(GridColumnProperty, value); }
    }

    public static readonly DependencyProperty GridColumnProperty =
        DependencyProperty.Register(
        "GridColumn",
        typeof(int),
        typeof(MatrixGridChildMonitor),
        new UIPropertyMetadata(0));
}

Each child element is bound to one of these monitor objects when it is added to a MatrixGrid. Here is the code in MatrixGrid that establishes the binding:

protected override void OnVisualChildrenChanged(
    DependencyObject visualAdded, DependencyObject visualRemoved)
{
    base.OnVisualChildrenChanged(visualAdded, visualRemoved);

    if (visualAdded != null)
        this.StartMonitoringChildElement(visualAdded);
    else
        this.StopMonitoringChildElement(visualRemoved);
}

void StartMonitoringChildElement(DependencyObject childElement)
{
    // Create a MatrixGridChildMonitor in order to detect
    // changes made to the Grid.Row and Grid.Column attached 
    // properties on the new child element.

    MatrixGridChildMonitor monitor = new MatrixGridChildMonitor();

    BindingOperations.SetBinding(
        monitor,
        MatrixGridChildMonitor.GridRowProperty,
        this.CreateMonitorBinding(childElement, Grid.RowProperty));

    BindingOperations.SetBinding(
        monitor,
        MatrixGridChildMonitor.GridColumnProperty,
        this.CreateMonitorBinding(childElement, Grid.ColumnProperty));

    _childToMonitorMap.Add(childElement, monitor);
}

Binding CreateMonitorBinding(DependencyObject childElement, DependencyProperty property)
{
    return new Binding
    {
        Converter = _converter,
        ConverterParameter = property,
        Mode = BindingMode.OneWay,
        Path = new PropertyPath(property),
        Source = childElement
    };
}

Dictionary<DependencyObject, MatrixGridChildMonitor> _childToMonitorMap;
MatrixGridChildConverter _converter;

You might be wondering how merely binding to the value of Grid.Row and Grid.Child on elements would allow MatrixGrid to know how many rows and columns it should create for itself. The answer lies in the use of a value converter, called MatrixGridChildConverter. That converter intercepts the value transferred from the child element to its associated MatrixGridChildMonitor, and lets the MatrixGrid know about the new value. Here is the Convert method in that value converter:

public object Convert(
    object value, Type targetType, 
    object parameter, CultureInfo culture)
{
    if (value is int)
    {
        int index = (int)value;
        if (parameter == Grid.RowProperty)
            _matrixGrid.InspectRowIndex(index);
        else
            _matrixGrid.InspectColumnIndex(index);
    }
    return value;
}

When the MatrixGrid is informed of a new row or column index on one of its child elements, via the InspectRowIndex or InspectColumnIndex methods, it adds the appropriate number of rows/columns to itself. Here is the method in MatrixGrid that adds the correct number of rows:

internal void InspectRowIndex(int index)
{
    // Delay the call that adds rows in case the RowDefinitions 
    // collection is currently read-only due to a layout pass.
    base.Dispatcher.BeginInvoke(new Action(delegate
        {
            while (base.RowDefinitions.Count - 1 < index)
            {
                base.RowDefinitions.Add(new RowDefinition());

                // Make the column headers just tall 
                // enough to display their content.
                if (base.RowDefinitions.Count == 1)
                    base.RowDefinitions[0].Height = 
                        new GridLength(1, GridUnitType.Auto);
            }
        }));
}

The InspectColumnIndex method is very similar to the one seen above.

How MatrixBase Works

MatrixBaseClassDiagram.png

The last piece of this puzzle is the MatrixBase class. As seen previously, you can derive from this class, override a few methods, and then use an instance of that class as the data source for MatrixControl. The ItemsSource property of MatrixControl should be bound to the MatrixItems property of MatrixBase, which is defined as:

/// <summary>
/// Returns a read-only collection of all cells in the matrix.
/// </summary>
public ReadOnlyCollection<MatrixItemBase> MatrixItems
{
    get
    {
        if (_matrixItems == null)
        {
            _matrixItems = new ReadOnlyCollection<MatrixItemBase>(this.BuildMatrix());
        }
        return _matrixItems;
    }
}

When the BuildMatrix method executes, the child class’s overridden methods are invoked to retrieve a list of the column headers and a list of the row headers. MatrixBase then starts constructing MatrixItemBase-derived objects and injecting them with whatever objects were returned by the child class’s overridden methods. When each MatrixCellItem is created, the child class is asked to provide a value for that cell. The complete algorithm from MatrixBase is listed below:

List<MatrixItemBase> BuildMatrix()
{
    List<MatrixItemBase> matrixItems = new List<MatrixItemBase>();

    // Get the column and row header values from the child class.
    List<TColumn> columnHeaderValues = this.GetColumnHeaderValues().ToList();
    List<TRow> rowHeaderValues = this.GetRowHeaderValues().ToList();

    this.CreateEmptyHeader(matrixItems);
    this.CreateColumnHeaders(matrixItems, columnHeaderValues);
    this.CreateRowHeaders(matrixItems, rowHeaderValues);
    this.CreateCells(matrixItems, rowHeaderValues, columnHeaderValues);

    return matrixItems;
}

void CreateEmptyHeader(List<MatrixItemBase> matrixItems)
{
    // Insert a blank item in the top left corner.
    matrixItems.Add(new MatrixEmptyHeaderItem
    {
        GridRow = 0,
        GridColumn = 0
    });
}

void CreateColumnHeaders(
    List<MatrixItemBase> matrixItems, List<TColumn> columnHeaderValues)
{
    // Insert the column header items in the first row.
    for (int column = 1; column <= columnHeaderValues.Count; ++column)
    {
        matrixItems.Add(new MatrixColumnHeaderItem(columnHeaderValues[column - 1])
        {
            GridRow = 0,
            GridColumn = column
        });
    }
}

void CreateRowHeaders(
    List<MatrixItemBase> matrixItems, List<TRow> rowHeaderValues)
{
    // Insert the row headers items in the first slot 
    // of each row after the column header row.
    for (int row = 1; row <= rowHeaderValues.Count; ++row)
    {
        matrixItems.Add(new MatrixRowHeaderItem(rowHeaderValues[row - 1])
        {
            GridRow = row,
            GridColumn = 0
        });
    }
}

void CreateCells(
    List<MatrixItemBase> matrixItems, 
    List<TRow> rowHeaderValues, 
    List<TColumn> columnHeaderValues)
{
    // Insert a cell item for each row/column intersection.
    for (int row = 1; row <= rowHeaderValues.Count; ++row)
    {
        TRow rowHeaderValue = rowHeaderValues[row - 1];

        for (int column = 1; column <= columnHeaderValues.Count; ++column)
        {
            // Ask the child class for the cell's value.
            object cellValue = this.GetCellValue(
                rowHeaderValue, 
                columnHeaderValues[column - 1]);

            matrixItems.Add(new MatrixCellItem(cellValue)
            {
                GridRow = row,
                GridColumn = column
            });
        }
    }
} 

Notice how each MatrixItemBase-derived object that is created in the code above is assigned values for its GridRow and GridColumn properties. Those properties are bound to by MatrixControl’s ItemContainerStyle, so that the ContentPresenter which hosts the MatrixItemBase object will be assigned the correct Grid.Row and Grid.Column attached property values. The XAML from MatrixControl that accomplishes this binding is seen below:

<ItemsControl.ItemContainerStyle>
  <!-- 
  Bind each ContentPresenter's attached Grid 
  properties to MatrixItemBase properties. 
  -->
  <Style TargetType="{x:Type ContentPresenter}">
    <Setter Property="Grid.Row" Value="{Binding Path=GridRow}" />
    <Setter Property="Grid.Column" Value="{Binding Path=GridColumn}" />
  </Style>
</ItemsControl.ItemContainerStyle>

Revision History

  • June 14, 2009 – Published the article to CodeProject

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here