Introduction
I was recently working on a WPF project, and it was all about data editing and synchronization. Data was hierarchically organized, and I had to provide a lot of different views on isolated and merged data sources based on a variety of external factors.
First of all: I 'love' data binding in WPF — it just makes a lot of things so easy! So in order to display nested data on a TreeView
control, I started with a hierarchical data template. However — I soon reached limits that proved hard to get around. While data binding had served me very well so far, I just needed more control from code when it came to that tree. Furthermore, I faced a few other issues when the tree's data source was refreshed or replaced, which caused nasty side effects with TwoWay data binding.
Bottom line was that the purely data-driven UI didn't quite work in my case — I needed an alternative. The result was a composite control I built around TreeView
. This solution took care of my issues (at least the tree-related ones while I could still rely on the basic functionality of TreeView
without having to reinvent the wheel. The result was simple yet effective, so I decided to extend it a little in order to get a generic control which I could reuse and share with the community. Which is why I have to write that darn article now...
Contents
Here's the most important features at a glance:
- You can tweak the tree for maximum performance, minimum memory footprint, or even a fully virtualized solution that gets data on demand and keeps it cached until you decide to remove it explicitly.
- The tree provides simple and type-safe access to the underlying data (no properties of type
object
here). And as opposite to the standard TreeView
, the SelectedItem
property is not read-only.
- Simple styling and sorting.
- Internally, there is a clear distinction between UI (tree nodes) and bound items. This resolves reloading and focus issues with the control. You can get a tree node (UI) by a bound item (data) and vice versa, or recursively iterate through all your tree nodes.
- Various extension points — a lot of code is virtual and can be overridden in order to control the behavior of the tree — down to the creation of single
TreeViewItem
instances.
- Simplified context menu handling.
- The tree layout (expanded/selected nodes) can be saved and reapplied.
Pretty much everything I am going to write here is illustrated in the sample application that comes with the download. If you're already tired of reading, you might just download the sample, play around, and come back later if you need more information.
Regarding terms: Actually, this is the "hardcodet.net WPF TreeView Control," as I published the initial version on my blog. However, I won't write that all over the place because that just sounds horribly narcistic, and it doesn't really make sense here at CP anyway. As I'll refer to WPF's built-in TreeView
control a lot, I'll call it V-Tree
throughout this article (which is completely random, btw).
Extending TreeViewBase<T>
All the features of V-Tree are being provided by the abstract TreeViewBase<T>
class. That's abstract like in: You will have to write some code (gasp!) in order to getting started. However, chances are high that you'll stay below 10 lines of code, as the base class just needs to know three things:
- How to generate a unique identifier for a given tree node:
GetItemKey
- How to get a bound item's childs, if there are any available:
GetChildItems
- How to get a bound item's parent, if there is one:
GetParentItem
There are quite a few virtual methods you can override in order to control the tree's behavior, but these three abstract methods may well be everything you'll ever need. Below is the implementation of the sample project's CategoryTree
control, which operates upon ShopCategory
objects. As you can see, CategoryTree
manages with 3 lines of code:
public class CategoryTree : TreeViewBase<ShopCategory>
{
public override string GetItemKey(ShopCategory item)
{
return item.CategoryName;
}
public override ICollection<ShopCategory> GetChildItems(ShopCategory parent)
{
return parent.SubCategories;
}
public override ShopCategory GetParentItem(ShopCategory item)
{
return item.ParentCategory;
}
}
Hint: Make sure your tree control class is public
. Otherwise, Intellisense in Visual Studio might not work properly.
What if I want to bind items of various types to the tree?
If you want to bind heterogeneous data to the tree, chances are high that your bound items do have something in common, and having them implement a common interface or derive from a custom base class might be a good design decision anyway. However, nobody keeps you from implementing a tree control that works on items of type object
which allows you to throw pretty much everything at it. It just wouldn't be that convenient anymore:
public class MyObjectTree : TreeViewBase<object>
{
public override string GetItemKey(object item)
{
if (item is ShopCategory)
{
return ((ShopCategory) item).CategoryName;
}
else if (item is Vendor)
{
return ((Vendor)item).VendorId;
}
else
{
...
}
}
public override ICollection<object> GetChildItems(object parent)
{
}
public override object GetParentItem(object item)
{
}
}
All code samples I am going to use refer to the sample implementation of the abstract TreeViewBase
control, the CategoryTree
class. As you can see in the diagram below, the tree's Items
collection is bound to an ObservableCollection
of type ShopCategory
. When it comes to rendering the bound data on the UI, TreeViewBase
uses a regular TreeView
control, and creates a TreeViewItem
instance for every bound ShopCategory
:
This section covers the features of the control. As a reference, below is a sample declaration of the tree. Note that some of the properties below are redundant (e.g. IsLazyLoading
, which is true by default).
<local:CategoryTree x:Name="MyTree"
Items="{Binding Source={StaticResource Shop}, Path=Categories}"
IsLazyLoading="True"
ObserveChildItems="True"
ClearCollapsedNodes="True"
AutoCollapse="True"
RootNode="{StaticResource CustomRootNode}"
NodeContextMenu="{StaticResource CategoryMenu}"
TreeNodeStyle="{StaticResource SimpleFolders}"
TreeStyle="{StaticResource SimpleTreeStyle}"
NodeSortDescriptions="{StaticResource AscendingNames}"
PreserveLayoutOnRefresh="True"
SelectedItem="{Binding ElementName=Foo,
Path=Bar, Mode=TwoWay}"
SelectedItemChanged="OnSelectedItemChanged"
/>
In order to display any data, the tree's Items
dependency property needs to be set. Items
takes an IEnumerable<T>
, where T
is the type of items your V-Tree implementation handles. In the sample, these are ShopCategory
objects. You can set this property in code or XAML:
<local:CategoryTree x:Name="MyTree"
Items="{Binding Source={StaticResource Shop}, Path=Categories}"
Items
provides the tree with the root items to get started with. The sample application binds to a collection that contains 3 ShopCategory
objects:
What happens now, is this:
- For every of the three
ShopCategory
items, the tree creates a corresponding TreeViewItem
instance which is assigned to the internal TreeView
control.
- If one of the tree's nodes is being expanded (Books in the screenshot below), the control determines the child categories by invoking
GetChildItems
, and renders child nodes for every item that is being returned. In the screenshot below, that's the categories Fiction, Politics, and Travel.
It doesn't look too sexy yet, but it's clearly a tree:
Selecting items was a bit cumbersome with the legacy TreeView
control, as the SelectedItem
property is read-only. However, the V-Tree makes this very easy, as it provides a strongly-typed SelectedItem
property that can be set externally:
public virtual T SelectedItem
{
get { return (T) GetValue(SelectedItemProperty); }
set { SetValue(SelectedItemProperty, value); }
}
Next to the SelectedItem
property, there's a SelectedItemChanged
routed event which is being invoked as soon as the selected item changes. The RoutedTreeItemEventArgs
provides both the tree's current and the previously selected items. Again, this event makes use of the class' strong typing:
public void Test()
{
CategoryTree tree = new CategoryTree();
tree.SelectedItemChanged += Tree_SelectedItemChanged;
}
private void Tree_SelectedItemChanged(object sender, RoutedTreeItemEventArgs e)
{
ShopCategory newCategory = e.NewItem;
ShopCategory oldCategory = e.OldItem;
}
Multiple Selections
Just like the built-in TreeView
control, V-Tree doesn't support multiple selections.
Imagine you'd want to display hierarchical data in two different scenarios:
- Data is locally available, access is very fast. A fully expanded tree contains thousands of nodes.
- Data is being retrieved from an external web service — a request may take quite some time but there's not too much data.
These scenarios require fundamentally different concepts when it comes to managing tree nodes. In the first scenario, it would be desirable to start with an empty tree, and create tree nodes on demand (lazy loading): As soon as a node is being expanded. If a tree node is being collapsed, all child nodes should be disposed and recreated again if the user re-expands the node.
While lazy loading might be an option for the second scenario as well, disposing data is nothing you would want to do if — refetching the data would be far too expensive. Data that was retrieved from the web service should be cached locally in order to prevent repeated requests for the same data over and over again.
The control provides two dependency properties to support both scenarios:
IsLazyLoading
: This property defaults to true and tells the tree not to create tree nodes until a node is being expanded. If this property is set to false
, the whole tree is being created at once.
ClearCollapsedNodes
: This property defaults to true as well, and instructs the control to discard nodes as soon as their parent is being collapsed. This keeps the memory footprint at a minimum, but results in GetChildItems
being re-invoked if the parent node is expanded again. This property has no effect if IsLazyLoading
is false (because this creates the whole tree at once).
Attention: Even if IsLazyLoading
is true, the GetChildItems
method you implemented is being invoked for every visible node by default, even if it's not yet expanded. This is because of the default implementation of the tree's HasChildItems
method, which is invoked in order to determine whether a tree node should be rendered with or without an expander.
protected virtual bool HasChildItems(T parent)
{
return GetChildItems(parent).Count > 0;
}
However, this only applies for the visible nodes, and does not cause the tree to traverse the whole hierarchy. However, if GetChildItems
is really expensive, you should override it. Even just returning true in every case is an option — check the documentation of the method for further details.
AutoCollapse Dependency Property
This boolean dependency property might come in handy if you want the tree to always display the minimum amount of nodes possible. If set to true, the tree collapses all nodes that do not need to be expanded in order to display the currently selected item. Check the sample application in order to see the feature.
Sample
The sample application allows you to set these properties independently and shows the number of tree nodes that are currently in memory at the bottom of the tree control. Below is the same tree twice after a full expansion/collapse cyle: in the first screenshot, ClearCollapsedNodes
was set to false
, the second screenshot shows the tree with the property set to true
. You can imagine that with a huge tree, this makes quite a difference:
Child Collection Monitoring
Per default, the tree tries to monitor the child collections of all existing tree nodes for changes, even if they are being collapsed. This ensures that the UI always reflects the correct state (e.g. by removing the expander of a collapsed tree node if all its child nodes are being deleted).
Important: Your implementation of GetChildItems
must return a collection that implements the INotifyCollectionChanged
interface for this to work, e.g. an ObservableCollection<T>
. If you return another collection (e.g. a simple list) and ObserveChildItems
is true
, no exception will be thrown, but a warning will be written to the debug console.
You can explicitly disable collection monitoring by setting the tree's ObserveChildItems
dependency property to false
.
Explicitly Refreshing the Tree
If the tree can't or shouldn't update itself, you can invoke one of the V-Tree's Refresh
methods. This causes the control to completely recreate the underlying tree while optionally preserving its layout (expanded / selected nodes). As a nice side effect, you can also use this feature to apply an arbitrary layout to a tree:
private void CopyTreeLayout(object sender, RoutedEventArgs e)
{
TreeLayout layout = CategoryTree.GetTreeLayout();
SynchronizedTree.Refresh(layout);
}
V-Tree provides styling point on 3 levels: Bound items, Tree nodes, and the tree itself.
Data Templates
This is the classic WPF way: Rather than styling an UI element (TreeViewItem
), you define a DataTemplate for the bound item that is represented by the tree node (in the sample: ShopCategory
instances). Just make sure the data template is within the scope of your tree (e.g. declared as a resource of the containing Window) and you're all set. This technique is applied to style the right-hand tree of the sample application:
<Window.Resources>
<!--
A data template for bound ShopCategory items:
Shows a static folder image and the category name
-->
<DataTemplate DataType="{x:Type shop:ShopCategory}">
<StackPanel Orientation="Horizontal">
<Image Source="/Images/WinFolder.gif" />
<TextBlock Text="{Binding Path=CategoryName}"
Margin="2,0,0,0" />
</StackPanel>
</DataTemplate>
</Window.Resources>
Node Styles
You can explicitly assign a style to all your tree nodes by setting the tree's TreeNodeStyle
dependency property. This style will be assigned to every tree node that represents one of your bound item's. Tree nodes are elements of type TreeViewItem
, so this is the target type of your style. This technique is applied to style the left-hand tree of the sample application:
<local:CategoryTree TreeNodeStyle="{StaticResource SimpleFolders}" />
<Style x:Key="SimpleFolders"
TargetType="{x:Type TreeViewItem}">
<Style.Resources>
<!-- override default brushes that show ugly background colors -->
<Brush x:Key="{x:Static SystemColors.HighlightBrushKey}">Transparent</Brush>
<Brush x:Key="{x:Static SystemColors.ControlBrushKey}">Transparent</Brush>
</Style.Resources>
-->
<Setter Property="HeaderTemplate"
Value="{StaticResource CategoryTemplate}" />
</Style>
Note that the style above still uses a data template by setting the HeaderTemplate
of the TreeViewItem
: The separation between UI and data is still valid, but you have full control over both data representation 'and' your tree node.
Btw: If you want to customize some of your nodes completely differently than others, and doing it in XAML is not an option, you can override the ApplyStyle
method and style your nodes individually. This method is always invoked, even if the TreeNodeStyle
property is null:
protected override void ApplyNodeStyle(TreeViewItem treeNode, ShopCategory item)
{
if (IsCheckableCategory(item))
{
ApplyCheckBoxStyle(treeNode);
}
else
{
base(ApplyNodeStyle(treeNode, item);
}
}
Tree Style
Internally, the V-TreeView operates on a default TreeView
control that handles most of the work (no reason to reinvent the wheel here). You can inject your own TreeView
instance through the Tree
dependency property, but you'll probably just leave it to the control to create a default instance for you. Still, you might want to style the tree, which you can do by setting the V-Tree's TreeStyle
dependency property. It takes a style that targets TreeView
:
<local:CategoryTree TreeStyle="{StaticResource SimpleTreeStyle}" />
<!-- set the tree's background and border properties -->
<Style x:Key="SimpleTreeStyle" TargetType="{x:Type TreeView}">
<Setter Property="Background" Value="#AAA" />
<Setter Property="BorderThickness" Value="4" />
<Setter Property="BorderBrush" Value="#FFA6AAAB" />
</Style>
You can sort your data easily by setting an IEnumerable<SortDescription>
to V-Tree's NodeSortDescriptions
dependency property. The default value of the property is null
, which causes the tree to render items in the order they were received.
<local:CategoryTree NodeSortDescriptions="{StaticResource AscendingNames}" />
The sample application allows you to toggle between two collections which are both declared in XAML. Both sort the tree's ShopCategory
items by their CategoryName
property in either ascending or descending order. Note that the PropertyName
attributes of the declared SortDescriptions
contain a Header prefix. This is because sorting happens on the UI layer (TreeViewItem
instances), not the underlying items. In order to get to the bound ShopCategory
instances, you need to get over the nodes' Header
properties:
<!---->
<cm:SortDescriptionCollection x:Key="AscendingNames">
<cm:SortDescription PropertyName="Header.CategoryName"
Direction="Ascending" />
</cm:SortDescriptionCollection>
<!---->
<cm:SortDescriptionCollection x:Key="DescendingNames">
<cm:SortDescription PropertyName="Header.CategoryName"
Direction="Descending" />
</cm:SortDescriptionCollection>
As with styles, there is a virtual method you can override to customize sorting of specific nodes. The example below would skip sorting of the bound root items:
protected override void ApplySorting(TreeViewItem node, ShopCategory item)
{
if (item.ParentCategory != null)
{
base.ApplySorting(node, item);
}
}
The tree provides a RootNode
dependency property (defaults to null
) which takes an arbitrary TreeViewItem
. You can set this property if you don't want your items to be rendered directly under the tree but a root node that provides some information. Note that this root node does not represent a bound item, and it's not affected by the TreeNodeStyle
or NodeContextMenu
dependency properties. The screenshot below shows the sample tree with and without a root node:
<!---->
<TreeViewItem x:Key="CustomRootNode">
<TreeViewItem.Header>
...
</TreeViewItem.Header>
</TreeViewItem>
<local:CategoryTree Items="{Binding Source={StaticResource Shop}, Path=Categories}"
RootNode="{StaticResource CustomRootNode}" />
If you want to display a specific context menu on all your nodes, you can set the tree's NodeContextMenu
dependency property. This causes the tree to display the menu as soon as one of the tree's nodes is being right-clicked.
<local:CategoryTree NodeContextMenu="{StaticResource CategoryMenu}" />
You'll probably use WPF's command system to trigger menu click events. The sample application provides a context menu that allows the user to add new categories or delete existing ones, if they aren't root categories. For the sample, I used the built-in application commands New
and Delete
.
(In case you're new to WPF commands, here's a good introduction: Commanding Overview)
<Window.Resources>
<!-- the context menu for the tree -->
<ContextMenu x:Key="CategoryMenu">
<MenuItem Header="Add Subcategory"
Command="New">
<MenuItem.Icon>
<Image Source="/Images/Add.png" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Remove Category"
Command="Delete">
<MenuItem.Icon>
<Image Source="/Images/Remove.png" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</Window.Resources>
In order to handle commands, MainWindow.xaml (not the tree control!) declares event handlers for both commands:
<Window.CommandBindings>
<!-- bindings for context menu commands -->
<CommandBinding Command="New"
Executed="AddCategory" />
<CommandBinding Command="Delete"
CanExecute="EvaluateCanDelete"
Executed="DeleteCategory" />
</Window.CommandBindings>
And here's the code. The important thing here is GetCommandItem()
, which has to take into account that there are two possibilities:
- The command has been triggered by a clicked menu item of the context menu. However, the right-clicked tree node might not be the one that is currently selected on the tree, so just taking V-Tree's SelectedItem could be wrong. However, if a menu item was clicked, the menu's
IsVisible
property is true, and V-Tree assigned the clicked node to the menu's PlacementTarget
property.
- The command has been triggered through a shortcut (e.g. by clicking the Delete k key). In this case, the context menu has not been involved at all. The menu's
IsVisible
property is false, but we can be sure that the command refers to the tree's currently selected item.
private ShopCategory GetCommandItem()
{
ContextMenu menu = CategoryTree.NodeContextMenu;
if (menu.IsVisible)
{
TreeViewItem treeNode = (TreeViewItem) menu.PlacementTarget;
return (ShopCategory) treeNode.Header;
}
else
{
return CategoryTree.SelectedItem;
}
}
private void AddCategory(object sender, ExecutedRoutedEventArgs e)
{
ShopCategory parent = GetCommandItem();
...
e.Handled = true;
}
private void EvaluateCanDelete(object sender, CanExecuteRoutedEventArgs e)
{
ShopCategory item = GetCommandItem();
e.CanExecute = item.ParentCategory != null;
e.Handled = true;
}
private void DeleteCategory(object sender, ExecutedRoutedEventArgs e)
{
ShopCategory item = GetCommandItem();
item.ParentCategory.SubCategories.Remove(item);
e.Handled = true;
}
Selecting Items before Displaying a Context Menu
If you want the tree to automatically select nodes that are being right-clicked, you can set the SelectNodesOnRightClick
dependency property to true
. In this case, you never have to evaluate ContextMenu.PlacementTarget
as the clicked item is selected in every case.
This is the first control I've written for WPF, and I'm sure that there will be a few bugs to squash as well as a few nice improvements, both hopefully based on your critical feedback (after all, it's all about learning). So check back for updates and keep the suggestions coming
Future releases of the control will be available here at CodeProject, and I'll also post about them on my blog. If you want to get automatically notified of relevant updates, you can subscribe to a specific newsletter here: http://www.hardcodet.net/newsletter. Just register, then select the control from the list of available newsletters.
I hope you'll find that the control makes a useful addition to your toolbox - have fun!
1.0.5 Initial public release (2008.01.29)
1.0.6 Added designer attributes to properties and events, fixed a few article issues. Switched to CPOL (2008.02.08).