Introduction
This article shows how to use a WPF Style and a little C# implementing a couple of Converters so the WPF TreeView control can be used to display a connected graph tree style diagram.
The interesting point is that whilst re-styling the TreeView and the contents it displays is relatively easy, trying to decorate the tree structure is harder.
Background
I had a data hierarchy that I wanted to display as a connected graph, i.e., a set of nodes with connecting lines between parent and child, along with nodes at the same depth being displayed adjacently.
The default TreeView
control style does not provide this. In fact, it doesn’t provide any sort of decoration, i.e., connecting lines.
However, one important aspect of the TreeView
control is that it works with the HierarchicalDataTemplate
. This is effectively an adapter that describes to the TreeView
control how a hierarchical data structure can be traversed. This means any such data structure can be visualized by a TreeView
control.
My first attempt at trying to draw the hierarchy as desired was based around creating a custom panel of which the data structure was assigned to the panel’s ItemSource
property, and the combination of MeasureOverride()
, ArrangeOverride()
, and OnRender()
were used to perform the layout and draw the connecting lines. This worked pretty well, but a Panel
can only work with the standard DataTemplate
which meant an alternative mechanism was needed to describe the hierarchical data structure. This seemed like re-inventing the wheel though it worked.
My next approach was to extend this to become a custom control, and the implementation for binding to ItemSource
required that the implementation support the HierarchicalDataTemplate
taking into account both Path
and XPath
. However, whilst interesting to implement, again it also seemed like re-inventing the wheel especially as the Control Template mechanism is expressly designed to allow a completely different rendering of an existing control. This re-galvanized my attempt at tackling this problem the WPF way. In particular, a post showing how to add connecting lines to the default TreeView
control style generated further impetus.
How it works
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BlogSample">
<local:HorzLineConv x:Key="horzLineConv"/>
<local:VertLineConv x:Key="vertLineConv"/>
<Style TargetType="TreeViewItem" x:Key="GraphStyle">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeViewItem">
<Grid> -->
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
-->
<RowDefinition Height="Auto"/>
-->
<RowDefinition Height="Auto" />
-->
</Grid.RowDefinitions>
...
</Grid> -->
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel
HorizontalAlignment="Center"
IsItemsHost="True"
Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
The code fragment above is taken from GraphStyle.xaml.
The style definition resides in a Resource Dictionary. This is done this way to partition the code. The first thing to note is the addition of two converters as static resources. The implementation couldn’t be done completely in XAML. We’ll come back to these later.
The control is composed of a main grid made up of 1 column and 3 rows. The rows are all of a Height
set to Auto
so they effectively size to content.
Row 0 contains the horizontal line that the parent and child node’s vertical connecting lines join to. Actually, this row contains a grid made of two columns and a single row and within them the line, but we’ll come to that later.
Row 1 contains another grid, this time a single column with three rows of which the top and bottom rows contain the vertical connecting lines and the middle row contains the actual node content.
Finally, row 2 which doesn't contain a grid. Instead, it’s the ItemPresenter
control that contains the Panel
that hosts the current node’s children.
The following diagram may help:
Drawing the item header
<Grid Grid.Row="1"> -->
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>-->
<RowDefinition Height="*"/> -->
<RowDefinition Height="Auto"/>-->
</Grid.RowDefinitions>
-->
<Rectangle Grid.Row="0"
Height="10" Stroke="Black" SnapsToDevicePixels="true">
<Rectangle.Width>
<Binding Mode="OneWay"
Converter="{StaticResource vertLineConv}"
ConverterParameter="top"
RelativeSource=
"{RelativeSource AncestorLevel=1,
AncestorType={x:Type TreeViewItem}}"/>
</Rectangle.Width>
</Rectangle>
-->
<ContentPresenter Grid.Row="1" ContentSource="Header"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
-->
<Rectangle Grid.Row="2" Height="10" Stroke="Black"
SnapsToDevicePixels="true">
<Rectangle.Width>
<Binding Mode="OneWay"
Converter="{StaticResource vertLineConv}"
ConverterParameter="bottom"
RelativeSource=
"{RelativeSource AncestorLevel=1,
AncestorType={x:Type TreeViewItem}}"/>
</Rectangle.Width>
</Rectangle>
</Grid> -->
The code fragment above shows the second row of the main grid. This needs to display the actual TreeViewItem
Header, i.e., the content of the node as this level, along with the vertical connecting lines above and below the content. These need to be horizontally centered to the content.
This naturally splits into a grid with a single column containing three rows. The middle row’s Height
is set to ‘*’ so it uses all the available space after the vertical connecting lines have been drawn. The rows for the vertical lines occupy a space governed by the height of the line which is hard-coded. The actual content is rendered by using a ContentPresenter
in the middle row.
For the lines, a rectangle with a height of 10 is specified though a line is not always required. If the node is the root node, then it should have no top connecting line and/or if the node has no children, then it should have no bottom connecting line. This is what the binding takes care of. It essentially binds the Width
property of the rectangle to a converter that determines if the specified connecting line is needed. If it is, a value of 1 is returned, otherwise 0. A width of 0 causes the rectangle to be of no size and thus is not drawn.
Drawing the horizontal line
<Grid Grid.Row="0"> -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
-->
<Rectangle Grid.Column="0" HorizontalAlignment="Stretch"
Stroke="Black" SnapsToDevicePixels="true">
<Rectangle.Height>
<Binding Mode="OneWay"
Converter="{StaticResource horzLineConv}"
ConverterParameter="left"
RelativeSource="{RelativeSource
AncestorLevel=1,
AncestorType={x:Type TreeViewItem}}"/>
</Rectangle.Height>
</Rectangle>
-->
<Rectangle Grid.Column="1" HorizontalAlignment="Stretch"
Stroke="Black"
SnapsToDevicePixels="true">
<Rectangle.Height>
<Binding Mode="OneWay"
Converter="{StaticResource horzLineConv}"
ConverterParameter="right"
RelativeSource="{RelativeSource
AncestorLevel=1,
AncestorType={x:Type TreeViewItem}}"/>
</Rectangle.Height>
</Rectangle>
</Grid> -->
Drawing a horizontal line the width of the container that holds the children is relatively easy. The required width of the line is dictated by the width of the panel containing the children. In fact, the width of the column (that contains the content grid) shown in yellow is determined by the width of the panel shown in red. This is because the column in red contains the panel that holds the children. As such, the horizontal line could have been drawn by adding another row and drawing a line within it.
However, this line would span the width of all the children and there are three cases where this is undesirable. Firstly, if a node has only a single child, in which case no horizontal line is required at all. Secondly, the left most node only requires a horizontal line above its right half running from its centre to the right edge. Conversely, the right most node has the same issue but needs a line running from its centre to the left edge.
The solution is to not have the parent node draw the horizontal line. Instead, a child node draws its portion of the line above itself which joins to form a continuous line.
Rather than a single line, the row contains a grid consisting of a single row with two columns. This is shown in light blue. The two columns divide the space equally, which means each column represents half the line centered on the item, just what's required! If the node has siblings both sides, then a line is drawn in both columns, creating a single line spanning the entire node.
For each node, a converter is used to determine if the node is left-most or right-most. If so, then either the left or right portion of the line is not drawn. The same technique for not drawing the top and bottom connecting lines is used here, i.e., a Rectangle
, but instead of setting its Width
to 0, this time the Height
is set to 0. It is important to not interfere with the Width
calculation as the Rectangle
has its Width
set to that of the column which is taken from the size of the item it's above, and so forth.
The Rectangle
Shape is really a very useful Shape for rendering optional lines tied to the implicit dynamic size of a control.
Why the converters?
public class HorzLineConv : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
TreeViewItem item = (TreeViewItem)value;
ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);
int index = ic.ItemContainerGenerator.IndexFromContainer(item);
if ((string)parameter == "left")
{
if (index == 0)
return (int)0;
else
return (int)1;
}
else {
if (index == ic.Items.Count - 1)
return (int)0;
else
return (int)1;
}
}
}
public class VertLineConv : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
TreeViewItem item = (TreeViewItem)value;
ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);
int index = ic.ItemContainerGenerator.IndexFromContainer(item);
if ((string)parameter == "top")
{
if (ic is TreeView)
return 0;
else
return 1;
}
else {
if (item.HasItems == false)
return 0;
else
return 1;
}
}
}
Unfortunately, the implementation was not XAML alone. In order to determine whether the top and bottom connecting lines should be drawn, and also whether a node was the left-most or right-most, the knowledge of the tree structure was needed. This required recourse to a Converter. These are defined in GraphStyle.xaml.cs.
The basis for this approach owes much to this post which shows how to obtain the TreeViewItem
being displayed.
There are two Converter classes: HorzLineConv
which determines whether each half segment of a node's horizontal line should be drawn, and VertLineConv
which determines whether the top or bottom connecting lines should be drawn.
These work and are used in much the same way. A binding is established between a Rectangle
’s Height
and the Converter for the horizontal lines and a Rectangle
’s Width
for the vertical lines. Each binding passes a string as the ConverterParamater
indicating which connecting line it’s interested in, i.e., top, bottom, left, or right. The source of the binding is the TreeViewItem
being displayed.
There is an interesting case for VertLineConv
in determining whether the top connecting line should be shown. This is true for all but the root node. The easiest way to do this was check the current item’s parent control as only the root item is owned by the TreeView
as opposed to another TreeViewItem
.
Putting it all together
<Window x:Class="BlogSample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="GraphStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
<XmlDataProvider x:Key="nodes" Source=".\nodes.xml" XPath="Node"/>
<HierarchicalDataTemplate DataType="Node"
ItemsSource="{Binding XPath=Children/Node}">
<Border Name="bdr" CornerRadius="360" BorderThickness="3"
BorderBrush="Blue" Width="Auto" Height="50" MinWidth="50">
<TextBlock Text="{Binding XPath=@Name}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</HierarchicalDataTemplate>
</ResourceDictionary>
</Window.Resources>
<TreeView ItemContainerStyle="{StaticResource GraphStyle}"
ItemsSource="{Binding Source={StaticResource nodes}}"/>
</Window>
The program to demonstrate the style is straightforward. It just merges the ResourceDicitionary
that contains the style into the Window
's resources. In addition, it turns a simple hierarchical XML structure into source that can be consumed by a TreeView
control. A HierarchicalDataTemplate
is also defined that describes how to expand it and also provides a basic rendering as an ellipse for each node, with the node's name centered within it.
Finally, the Window
has a single child of type TreeView
control whose source is bound to the XML data provider. Its style is set to the one we've been looking at. This needs to be specified as the style itself was given a key, which means it will not be applied automatically based on the type of the control it's a style for.
When run, it displays the window pictured at the top.
References
This is most definitely not all my work. In particular, the following articles and postings were instrumental in allowing me to get this far.
Firstly, those from Josh Smith provided the examples of how to re-style the TreeView
control.
Secondly, this post was where the idea of using a Rectangle
for the connecting lines and using a grid in order to cause the Rectangle
to be drawn to the correct dimensions came from. This is also where the mechanism of binding to the actual TreeViewItem
came from.
What next?
There are at least a couple of issues with this style that I haven't investigated yet. I think a TreeView
can handle multiple root nodes whereas the style really only accommodates one. Sometimes the vertical line of the left-most or right-most node doesn't meet perfectly with the horizontal line. This can change for the same graph depending on the size of the window, so I suspect it's something to do with SnapsToDevicePixels
.
I've changed the HierarchicalDataTemplate
so it renders the nodes as buttons, and also changed the data source to traverse the file-system, and this all works.
Another interesting enhancement would be to place each node in a Expander
so traversal of the hierarchy could be controlled as per a conventionally styled TreeView
control.