Extending the TreeView Control
Microsoft Developer Network
Summary: Covers adding data-binding functionality to the TreeView control as one of a series of Microsoft Windows control development samples to be read in conjunction with an associated overview article. (16 printed pages)
This article is the fourth in a five-article series on developing controls in Microsoft® .NET:
- Developing Custom Windows Controls Using Visual Basic .NET (overview)
- Adding Regular Expression Validation
- Combining Multiple Controls into One
- Extending the TreeView Control
- Drawing Your Own Controls Using GDI+
Whenever possible you should be starting with an existing control; there is a huge amount of coding and testing that has gone into all of the provided Microsoft® Windows® Forms controls and you miss out on all that work if you have to start completely from scratch. With that in mind, in this sample I will inherit from an existing Windows Form control, the Tree View, and customize it. The code for this new TreeView control is included in a single download along with rest of the control development samples, and a sample application that demonstrates how this enhanced TreeView could be used together with other data-bound controls.
Adding data binding to the TreeView control is a topic that has come up time and time again amongst Windows developers, but the base control still doesn't support this feature because of a key difference between the TreeView and other controls like the ListBox or DataGrid; a TreeView displays hierarchal data. Given a single table of data, it is relatively clear how you would display that information into a ListBox or DataGrid, but taking advantage of the hierarchal nature of a TreeView to display that same data isn't as straightforward. Personally, I can think of many different ways in which I have used a TreeView to display data, but one way has been the most common: grouping the data from a table by certain fields, as shown in Figure 1.
Figure 1. Displaying data in a TreeView
So, for this sample I set out to create a TreeView control into which I could pass a flat set of data, as shown in the Figure 2, and easily produce the result shown in Figure 1.
Figure 2. Flat result set containing all the information necessary to create the Tree shown in Figure 1.
Before I started coding, I came up with a design for my new control that could handle this particular set of data, and hopefully many other similar situations. Adding a Groups collection, where a grouping field, display field, and value field (any or all of which could be the same field) is specified for each level of hierarchy, should be sufficiently generic to handle creating hierarchies from most flat data. To turn the data shown in Figure 2 into the TreeView shown in Figure 1, my new control would require that you define two grouping levels, Publisher and Title, defining pub_id as the grouping field for the Publisher group and title_id for the Title group. In addition to the grouping field, each group also needs to have display and value fields specified, to determine what text is shown on the appropriate group node and what value is used to uniquely identify a particular group. In the case of this data, using pub_name/pub_id and title/title_id will work as display/value fields for these two groups. The Author information would become the leaf nodes of the Tree (the nodes at the end of the grouping hierarchy) and you would need to specify ID (au_id) and display (au_lname) fields for these nodes as well.
When you are building a custom control, it really helps to work from the usage end of things by determining how a programmer would use your control before you start coding it. In this case, I wanted a programmer (given the data and desired result shown earlier) to be able to accomplish the grouping just described with a few lines of code that looked something like this:
With DbTreeControl .ValueMember = "au_id" .DisplayMember = "au_lname" .DataSource = myDataTable.DefaultView .AddGroup("Publisher", "pub_id", "pub_name", "pub_id") .AddGroup("Title", "title_id", "title", "title_id") End With
Note This isn't quite what I ended up with, but it isn't too far off either. During the control development I realized that I would need to associate the index of an image from the TreeView's associated ImageList with each grouping level, so I had to add an extra parameter to the AddGroup method.
To actually build the Tree, I will walk the data and look for changes in the fields specified as grouping values for each, creating new group nodes as necessary and creating one leaf node per data item. The total number of nodes will be larger than the number of items in the data source, due to the grouping nodes, but there will always be one, and only one, leaf node for each item in the underlying data.
Figure 3. Group vs. leaf nodes
The distinction between leaf and group nodes, as shown in Figure 3, will be important throughout the rest of this article, as I decided to treat these two types of nodes as being distinct, going so far as to create custom node classes for each type and to raise different events depending on which type of node has been selected.
The first step to writing the code for this control is to create a project and the appropriate starting class. In my case, I started off by creating a new Windows Control Library, and then removed the default UserControl class, replacing it with a new class that inherits from the TreeView control:
Public Class dbTreeControl Inherits System.Windows.Forms.TreeView
From this point on, I am working with a control that I can place onto a form and have it look and act just like a regular TreeView. The next step is to start adding code to handle the new functionality I want in my version of the TreeView, namely data binding and grouping data.
Adding a DataSource Property
All of the functionality of my new control is important, but the two keys to building a complex data-bound control are handling the DataSource property and retrieving individual item properties from each object in the data source.
Creating the property routine
To start with, any control that is implementing complex data binding will need to implement a DataSource property routine and maintain the appropriate member variables:
Private WithEvents cm As CurrencyManager Private m_DataSource As Object <Category("Data")> _ Public Property DataSource() As Object Get Return m_DataSource End Get Set(ByVal Value As Object) If Value Is Nothing Then cm = Nothing GroupingChanged() Else If Not (TypeOf Value Is IList Or _ TypeOf Value Is IListSource) Then 'not a valid data source for this purpose Throw New System.Exception("Invalid DataSource") Else If TypeOf Value Is IListSource Then Dim myListSource As IListSource myListSource = CType(Value, IListSource) If myListSource.ContainsListCollection = True Then Throw New System.Exception("Invalid DataSource") Else 'ok, ok.. it is a valid data source m_DataSource = Value cm = CType(Me.BindingContext(Value), _ CurrencyManager) GroupingChanged() End If Else m_DataSource = Value cm = CType(Me.BindingContext(Value), _ CurrencyManager) GroupingChanged() End If End If End If End Set End Property
The IList interface
Objects that can be used as the data source for complex data binding generally support the IList interface, which exposes the data as a collection of objects and provides several useful properties, such as Count. My new TreeView control requires an object that supports IList for its binding but another interface, the IListSource interface, is also fine since it provides an easy method (GetList) to get an IList object. When the DataSource property is set, I first try to determine if a valid object has been supplied—one that supports IList or IListSource. What I really want is IList, so if the object supplied supports just IListSource (like a DataTable) then I use the GetList() method of that interface to obtain the correct object.
Some objects that implement IListSource (such as DataSet) actually contain multiple lists, as indicated by the ContainsListCollection property. If this property is True, then GetList will return an IList object that represents a list of lists. For my example, I decided to support connections directly to IList objects or to IListSource objects that hold only a single IList object, and ignore objects such as the DataSet that would require additional work to specify a data source.
Note If you wished to support this type of object (a DataSet or similar), you could add a second property (such as DataMember) to specify which particular sub-list should be used for binding.
If a valid data source is provided, then the end result is the creation of an instance of the CurrencyManager class (cm = Me.BindingContext(Value)). This instance is stored into a local variable because it will be used to access the underlying data source, object properties, and position information.
Adding Display and Value Member Properties
Having a DataSource is the first step in complex data binding, but the control needs to know which particular fields or properties of the data should be used as the display and value members. The Display member will be used as the caption of the Tree nodes, while the Value member will be accessible through the Value property of the node. These properties are just strings, representing the field or property names, and can easily be added to the control:
Private m_ValueMember As String Private m_DisplayMember As String <Category("Data")> _ Public Property ValueMember() As String Get Return m_ValueMember End Get Set(ByVal Value As String) m_ValueMember = Value End Set End Property <Category("Data")> _ Public Property DisplayMember() As String Get Return m_DisplayMember End Get Set(ByVal Value As String) m_DisplayMember = Value End Set End Property
In this TreeView, these properties will represent the Display and Value members for the leaf nodes only, and the corresponding information for each grouping level will be specified in the AddGroup method.
In the DataSource property discussed earlier, an instance of the CurrencyManager class was created and stored into a class-level variable. The CurrencyManager class, accessed through this object, is a key part of implementing data binding as it has properties, methods, and events that allow you to:
- Access the underlying IList object for your data source
- Retrieve and set fields or properties on object in your data source, and
- Sync your control with other data-bound controls on the same form.
Retrieving Property/Field values
The CurrencyManager object allows you to retrieve property or field values from individual items in the data source, such as the values of the DisplayMember or ValueMember fields, through its GetItemProperties method. PropertyDescriptor objects are then used to obtain the value of a specific field or property on a specific list item. The code snippet to follow shows how these PropertyDescriptor objects are created and how the GetValue function can then be used to obtain a property value off of one of the items in the underlying data source. Notice the List property of the CurrencyManager object: it provides access to the IList instance that the control has been bound to:
Dim myNewLeafNode As TreeLeafNode Dim currObject As Object currObject = cm.List(currentListIndex) If Me.DisplayMember <> "" AndAlso Me.ValueMember <> "" Then 'add leaf node? Dim pdValue As System.ComponentModel.PropertyDescriptor Dim pdDisplay As System.ComponentModel.PropertyDescriptor pdValue = cm.GetItemProperties()(Me.ValueMember) pdDisplay = cm.GetItemProperties()(Me.DisplayMember) myNewLeafNode = _ New TreeLeafNode(CStr(pdDisplay.GetValue(currObject)), _ currObject, _ pdValue.GetValue(currObject), _ currentListIndex)
GetValue returns an object regardless of the underlying data type of the property, so you will need to convert the return value before using.
Keeping Data-Bound Controls in Sync
The CurrencyManager has one other key feature: in addition to providing access to the bound data source and to item properties, it allows for the coordination of data binding between this control and any other controls using the same DataSource. This support can be used to ensure that multiple controls, all bound to the same data source, stay on the same item in your data source. For my control, I want to ensure that when I select an item in the Tree, any other controls bound to the same data source are pointing at the same item (the same record, row, or even tuple, if you prefer to think in database terms). To make this work, I override the OnAfterSelect method from the underlying TreeView. In this method, which is called after a Tree node is selected, I set the Position property of the CurrencyManager object to the index of the currently selected item. The sample application that is included along with this TreeView control illustrates how this phenomenon of synced controls makes it easier to build data-bound user interfaces. To make it easier to determine the list position of the currently selected item, I use custom TreeNode classes (TreeLeafNode or TreeGroupNode) and store the list index of each node into a Position property as I create them:
Protected Overrides Sub OnAfterSelect _ (ByVal e As System.Windows.Forms.TreeViewEventArgs) Dim tln As TreeLeafNode If TypeOf e.Node Is TreeGroupNode Then tln = FindFirstLeafNode(e.Node) Dim groupArgs As New groupTreeViewEventArgs(e) RaiseEvent AfterGroupSelect(groupArgs) ElseIf TypeOf e.Node Is TreeLeafNode Then Dim leafArgs As New leafTreeViewEventArgs(e) RaiseEvent AfterLeafSelect(leafArgs) tln = CType(e.Node, TreeLeafNode) End If If Not tln Is Nothing Then If cm.Position <> tln.Position Then cm.Position = tln.Position End If End If MyBase.OnAfterSelect(e) End Sub
You may have noticed a function called FindFirstLeafNode used in the preceding code snippet, so I thought I should explain it a little. In my TreeView, only the leaf nodes (final node in the hierarchy) correspond to items in the DataSource, and any other node is only there to create the group structure. If I want a well-behaved data-bound control, though, there should always be an item selected that corresponds to the DataSource, so whenever a group node is selected I find the first leaf node under that group and act as if the leaf node is the current selection. Check the sample to see this in action, but for now you will just have to trust me that this actually works:
Private Function FindFirstLeafNode(ByVal currNode As TreeNode) _ As TreeLeafNode If TypeOf currNode Is TreeLeafNode Then Return CType(currNode, TreeLeafNode) Else If currNode.Nodes.Count > 0 Then Return FindFirstLeafNode(currNode.Nodes(0)) Else Return Nothing End If End If End Function
Setting the Position property of the CurrencyManager object keeps other controls in sync with my currently selected item, but the CurrencyManager also raises events when another control changes the position so that I can change my selected item accordingly. To be a good little data-bound component, the selection should move as the position of the data source changes, and if the data if an item is modified, the display should update. There are three events raised by the CurrencyManager: CurrentChanged, ItemChanged, and PositionChanged. The last event is fairly straightforward; one of the purposes of the CurrencyManager is to maintain a current position indicator for your data source so that multiple bound controls can all display the same record or list item, and this event will fire whenever that position changes. The other events overlap in some cases, so they aren't as clear. Here is a breakdown of how you should use them in your custom control: PositionChanged is the easy event, so I'll get that out of the way right away; use it when you want to adjust the currently selected item in a complex data-bound control like our Tree. Whenever any item in the data source is modified, the ItemChanged event is fired, while the CurrentChanged fires only when the current item is modified.
Inside my TreeView, I found that all three events would occur whenever I selected a new item, so I decided to handle the PositionChanged event by changing my currently selected item, and not to handle the other two events at all. The .NET Framework documentation recommends that I cast my data source into IBindingList (if my data source supports IBindingList) and use its ListChanged event instead, but I did not implement this functionality:
Private Sub cm_PositionChanged(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles cm.PositionChanged Dim tln As TreeLeafNode If TypeOf Me.SelectedNode Is TreeLeafNode Then tln = CType(Me.SelectedNode, TreeLeafNode) Else tln = FindFirstLeafNode(Me.SelectedNode) End If If tln.Position <> cm.Position Then Me.SelectedNode = FindNodeByPosition(cm.Position) End If End Sub Private Overloads Function FindNodeByPosition(ByVal index As Integer) _ As TreeNode Return FindNodeByPosition(index, Me.Nodes) End Function Private Overloads Function FindNodeByPosition(ByVal index As Integer, _ ByVal NodesToSearch As TreeNodeCollection) As TreeNode Dim i As Integer = 0 Dim currNode As TreeNode Dim tln As TreeLeafNode Do While i < NodesToSearch.Count currNode = NodesToSearch(i) i += 1 If TypeOf currNode Is TreeLeafNode Then tln = CType(currNode, TreeLeafNode) If tln.Position = index Then Return currNode End If Else currNode = FindNodeByPosition(index, currNode.Nodes) If Not currNode Is Nothing Then Return currNode End If End If Loop Return Nothing End Function
With the data-binding code completed, I can go ahead and work on adding the rest of the code to manage the grouping levels, build the Tree appropriately, and add a few custom events, methods, and properties.
Managing the Groups
AddGroup, RemoveGroup, and ClearGroups functions had to be created to allow the programmer to configure the collection of groups. In each case where the group collection is modified, the Tree will have to be redrawn (to reflect the new configuration), so I created a common procedure, GroupingChanged, that is called by various code throughout the control whenever something changes that should force a rebuild of the Tree:
Private treeGroups As New ArrayList() Public Sub RemoveGroup(ByVal group As Group) If Not treeGroups.Contains(group) Then treeGroups.Remove(group) GroupingChanged() End If End Sub Public Overloads Sub AddGroup(ByVal group As Group) Try treeGroups.Add(group) GroupingChanged() Catch End Try End Sub Public Overloads Sub AddGroup(ByVal name As String, _ ByVal groupBy As String, _ ByVal displayMember As String, _ ByVal valueMember As String, _ ByVal imageIndex As Integer, _ ByVal selectedImageIndex As Integer) Dim myNewGroup As New Group(name, groupBy, _ displayMember, valueMember, _ imageIndex, selectedImageIndex) Me.AddGroup(myNewGroup) End Sub Public Function GetGroups() As Group() Return CType(treeGroups.ToArray(GetType(Group)), Group()) End Function
Building the Tree
The actual rebuilding of the Tree is then handled by a pair of procedures: BuildTree and AddNodes. The code for these two procedures is quite long, so I will try to summarize their behavior instead of including the entire listing in the article (of course it is all in the download for your enjoyment). As I mentioned earlier, the programmer would interact with this control by setting up a series of groups, and in BuildTree those groups are then used to determine how to set up the nodes of the Tree. BuildTree clears the current node collection and then loops through the entire data source to processes the first level of grouping (Publisher in the examples and figures earlier in this article), adding one node for each distinct grouping value (one node for every pub_id value, using my example data), then calling AddNodes to fill in all the nodes below that first level of grouping. AddNodes calls itself recursively to handle any number of levels, adding in group nodes and leaf nodes as appropriate. Two custom classes, based on TreeNode, are used so that group nodes and leaf nodes can be distinguished and to provide each type of node with its own set of relevant properties.
Customizing the TreeView Events
Whenever a node is selected, the TreeView raises two events, BeforeSelect and AfterSelect. However, for my control I wanted to have different events for group nodes and leaf nodes, so I added my own events, BeforeGroupSelect/AfterGroupSelect and BeforeLeafSelect/AfterLeafSelect, with custom event argument classes that I raise in addition to the base events:
Public Event BeforeGroupSelect _ (ByVal sender As Object, ByVal e As groupTreeViewCancelEventArgs) Public Event AfterGroupSelect _ (ByVal sender As Object, ByVal e As groupTreeViewEventArgs) Public Event BeforeLeafSelect _ (ByVal sender As Object, ByVal e As leafTreeViewCancelEventArgs) Public Event AfterLeafSelect _ (ByVal sender As Object, ByVal e As leafTreeViewEventArgs) Protected Overrides Sub OnBeforeSelect _ (ByVal e As System.Windows.Forms.TreeViewCancelEventArgs) If TypeOf e.Node Is TreeGroupNode Then Dim groupArgs As New groupTreeViewCancelEventArgs(e) RaiseEvent BeforeGroupSelect(CObj(Me), groupArgs) ElseIf TypeOf e.Node Is TreeLeafNode Then Dim leafArgs As New leafTreeViewCancelEventArgs(e) RaiseEvent BeforeLeafSelect(CObj(Me), leafArgs) End If MyBase.OnBeforeSelect(e) End Sub Protected Overrides Sub OnAfterSelect _ (ByVal e As System.Windows.Forms.TreeViewEventArgs) Dim tln As TreeLeafNode If TypeOf e.Node Is TreeGroupNode Then tln = FindFirstLeafNode(e.Node) Dim groupArgs As New groupTreeViewEventArgs(e) RaiseEvent AfterGroupSelect(CObj(Me), groupArgs) ElseIf TypeOf e.Node Is TreeLeafNode Then Dim leafArgs As New leafTreeViewEventArgs(e) RaiseEvent AfterLeafSelect(CObj(Me), leafArgs) tln = CType(e.Node, TreeLeafNode) End If If Not tln Is Nothing Then If cm.Position <> tln.Position Then cm.Position = tln.Position End If End If MyBase.OnAfterSelect(e) End Sub
The custom node classes (TreeLeafNode and TreeGroupNode) and the custom event argument classes are all included in the downloadable code.
To fully understand all of the code in this sample control, you need to see how it would work in an application. The sample application included works with the pubs.mdb access database and shows how the Tree control can work together with other data-bound controls to create Windows applications. Key features of this sample that you should pay particular attention to include the synchronization of the Tree with the other bound controls and the automatic selection of a Tree node when a search is performed against the data source.
Note This sample application (named "TheSample") is included in the download for this article.
Figure 4. Demo application for data-bound TreeView
The data-bound Tree control shown in this article will not be the answer for every project that needs a Tree control to display information from a database, but it does illustrate one way in which you could customize this control for your own purposes. Keep in mind that the majority of the code in this control would be essentially the same in any complex data-bound control you wish to build, and that you can leverage that existing work to make future control development easier.
In the next sample, Drawing your own Controls using GDI+, you will see a much easier way to implement data binding for those situations where you don't have to use a specific base class, as I did in this control to inherit from the TreeView control.