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.
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
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:
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()
{
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"));
}
readonly Country[] _countries;
readonly Dictionary<string, CellValueProvider> _rowHeaderToValueProviderMap;
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"
>
<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 DataTemplate
s 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.
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
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:
public class PersonMatrix : MatrixBase<string, Person>
{
public PersonMatrix()
{
_people = Database.GetPeople();
}
protected override IEnumerable<Person> GetColumnHeaderValues()
{
return _people;
}
protected override IEnumerable<string> GetRowHeaderValues()
{
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 DataTemplate
s 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>
<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:
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)
{
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)
{
base.Dispatcher.BeginInvoke(new Action(delegate
{
while (base.RowDefinitions.Count - 1 < index)
{
base.RowDefinitions.Add(new RowDefinition());
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
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:
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>();
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)
{
matrixItems.Add(new MatrixEmptyHeaderItem
{
GridRow = 0,
GridColumn = 0
});
}
void CreateColumnHeaders(
List<MatrixItemBase> matrixItems, List<TColumn> columnHeaderValues)
{
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)
{
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)
{
for (int row = 1; row <= rowHeaderValues.Count; ++row)
{
TRow rowHeaderValue = rowHeaderValues[row - 1];
for (int column = 1; column <= columnHeaderValues.Count; ++column)
{
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>
<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