Introduction
Hierarchical data templates are a powerful way to organize and bind data that is available in Windows Presentation Foundation (WPF) and Silverlight. This article explores some of the features of hierarchal data templates through a sample "users and groups" application. We will use the Silverlight DataGrid and TreeView controls as well as value converters for this example.
When you are finished with this article and the sample code, you should have a solid knowledge of how hierarchical data templates work, how data binding in Silverlight works, and some creative ways to use it for your applications, as well as how to make some performance-oriented tweaks in your application to facilitate lazy loading of large data sets.
Background
There are many business requirements that involve displaying data in a hierarchical format. Traditionally, web UI design required managing the hierarchy through code and then iterating recursively to build out the tree. There were no hierarchical "data sets" available to use.
The hierarchical data template allows you to bind data that is hierarchical and self-referencing. The template understands how to traverse the tree recursively, and allows for a cleaner separation of the data and presentation implementation. By understanding how the hierarchical data template works, you can also take advantage of some performance benefits by loading data at the right time and only when needed for your display.
In this example, we are taking a common pattern: users within organizational groups. Groups form a hierarchy of "group" objects, for example, North America, might have a group for the Eastern Region, and within that, a group for Administrators, and so on. A user who belongs to any group automatically belongs to the parents of the group, and that group's parents, and so on.
What makes this pattern unique is that a group can have two types of children: another group, or a user. How can we represent two different types of data in a hierarchical data structure and take advantage of the template in our application? Furthermore, what if the person using our application wants to drill to a specific group and then examine the users there, without having to wait for the entire organization to load? Imagine having 10,000 users in the system and waiting to load all of those details vs. being able to simply load the users that are being displayed within a given level of the hierarchy.
Getting Started: Domain and Transports
One method I like to follow in building service-oriented code is to provide transport classes for my domain entities. A domain entity might be a complex class that contains several subclasses and a large object graph. For example, conceptually, we could imagine having a User
class with username, e-mail, first name, last name, social security number, multiple addresses, web addresses, personal blogs, twitter usernames, and many other data points that we don't need when presenting summary information. It would be a waste of network bandwidth and browser memory to ship all of this information to the Silverlight application.
Therefore, I create transport objects that contain the smaller, "unrolled" versions of the objects. To make it easy to convert a domain object to a transport object, I often provide a constructor, like this:
...
public UserTransport(UserEntity user)
{
ID = user.ID;
Username = user.Username;
}
...
To simplify this example, I've avoided building out the classes and service architecture on the web application side, and instead am creating a "test database" within the Silverlight application. This makes it easier to set up and run. I've wired in some Debug
statements so that you can see how certain "services" would be called.
You can learn more about how I abstract service calls by clicking here. Essentially, I will mimic the "helper object" that would be called to invoke a service call.
Transports
For our example, we have two transports: a group and a user. Let's take a look at these classes:
public class UserTransport
{
public string Username { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
You can see that UserTransport
is a simple, lightweight class that contains basic user information. The more complicated class is GroupTransport
, as it contains information about the group hierarchy. The class looks like this:
public class GroupTransport
{
public class GroupUserAddedEventArgs : EventArgs
{
public List<UserTransport> Users { get; set; }
public GroupUserAddedEventArgs(List<UserTransport> users)
{
Users = users;
}
}
public List<GroupTransport> Unroll()
{
List<GroupTransport> retVal = new List<GroupTransport> {this};
foreach(GroupTransport child in Children)
{
retVal.AddRange(child.Unroll());
}
return retVal;
}
public string GroupName { get; set; }
public event EventHandler<GroupUserAddedEventArgs> UsersAdded;
private readonly List<GroupTransport> _groups = new List<GroupTransport>();
public List<GroupTransport> Children
{
get { return _groups; }
set
{
_groups.AddRange(value);
foreach (GroupTransport group in value)
{
group.UsersAdded += _GroupUsersAdded;
}
}
}
private void _GroupUsersAdded(object sender, GroupUserAddedEventArgs e)
{
if (e != null && e.Users.Count > 0)
{
_users.AddRange(e.Users);
if (UsersAdded != null)
{
UsersAdded(this, e);
}
}
}
private readonly List<UserTransport> _users = new List<UserTransport>();
public void AddUsers(List<UserTransport> users)
{
_users.AddRange(users);
if (UsersAdded != null)
{
UsersAdded(this, new GroupUserAddedEventArgs(users));
}
}
public List<UserTransport> Users
{
get { return _users; }
}
}
It's important to note that because I've skipped the web-application side (the "server" in the client/server model), the class has some business functionality built into it that would normally only exist on the server side — Silverlight would only "see" the properties, not the methods to populate them.
The key properties are the name of the group (GroupName
), the users that belong to the group (Users
), and the subgroups that belong to the group (Children
).
The rest of the methods are used for the sake of populating the hierarchy. The user-added event allows a user added lower in the hierarchy to propagate to the top of the hierarchy. The "unroll" events flatten the hierarchy into a list to make it easy to search for a group within the list.
Data Generation
To simulate an actual database, I added the MockDB
class. It basically creates a group/user hierarchy. I included a list of common first and last names, then generate random "users" from the list.
The database uses the Singleton pattern to ensure that any access will always retrieve the same set of data during a single run of the application.
Service Mockup
In our application, we want to simulate two calls. Imagine one call that simply returns GroupTransport
data, without any users. This allows us to flesh out the main hierarchy for our tree. However, because the user list is so long, we only want to retrieve the list of users for a group when that group is being expanded in the tree.
The first element of our service endpoint is the ServiceArgs
class. It is used to encapsulate data returned from the asynchronous call:
public class ServiceArgs<T> : EventArgs where T: class
{
public T Entity { get; set; }
public ServiceArgs(T entity)
{
Entity = entity;
}
}
- Hint: — one way you can extend the concept of
ServiceArgs
is to also add an Exception
property. This way, you can completely encapsulate your call, and always inspect either a returned entity or an exception and handle it appropriately.
The UserService
class emulates the end point of a service call:
public class UserService
{
public event EventHandler<ServiceArgs<GroupTransport>> GroupLoaded;
public event EventHandler<ServiceArgs<List<usertransport>>> UserLoaded;
public void GetGroup()
{
Debug.WriteLine("GetGroup() invoked.");
DispatcherTimer timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(new Random().Next(1500) + 500)
};
timer.Tick += _TimerTick;
timer.Start();
}
void _TimerTick(object sender, EventArgs e)
{
((DispatcherTimer)sender).Stop();
if (GroupLoaded != null)
{
GroupLoaded(this, new ServiceArgs<grouptransport>(MockDB.GetGroupTree()));
}
}
public void GetUsersForGroup(string groupName)
{
Debug.WriteLine(string.Format("GetUsersForGroup() invoked for {0}.",groupName));
Thread.Sleep(new Random().Next(50) + 10);
GroupTransport group = (from gt in MockDB.GetGroupTree().Unroll()
where gt.GroupName == groupName
select gt).SingleOrDefault();
if (UserLoaded != null)
{
UserLoaded(this, new ServiceArgs<list<usertransport>>(group.Users));
}
}
}</usertransport>
The GetGroup
is used to invoke the call for the group hierarchy, and when it is completed, GroupLoaded
is raised with the root GroupTransport
. Notice, we use a dispatch timer to delay this slightly to simulate calling over a network. Also note the Debug
message that will appear in your output window when you are running it in debug mode.
The GetUsersForGroup
will start the "asynchronous" call to get the user list for a specific group. We also print a message, this will be important to see how lazy-loading works with hierarchical data templates. Note that I simulate a brief delay, then use my "cheat" function Unroll
to find the group I want and then raise the UserLoaded
event with the users for that group.
A Little Trickery
Now the fun starts. We want to bind a hierarchical data template, but the problem is that we are working with two types of data (users and groups). How can we get around this issue? Simple: we'll create a composite object, just for our tree, that contains the common data to display along with a reference to the original object. Because we are binding to a tree, I called this TreeNode
:
public class TreeNode
{
public object DataContext { get; set; }
public string Name { get; set; }
public bool IsUser { get; set; }
private bool _usersLoaded;
private ObservableCollection<treenode> _children =
new ObservableCollection<treenode>();
public ObservableCollection<treenode> Children
{
get
{
if (!_usersLoaded)
{
_usersLoaded = true;
UserService service = new UserService();
service.UserLoaded += _ServiceUserLoaded;
service.GetUsersForGroup(Name);
}
return _children;
}
set
{
_children = value;
}
}
public TreeNode(GroupTransport group)
{
DataContext = group;
Name = group.GroupName;
_usersLoaded = false;
IsUser = false;
foreach(GroupTransport child in group.Children)
{
_children.Add(new TreeNode(child));
}
}
public TreeNode(UserTransport user)
{
DataContext = user;
Name = user.Username;
_usersLoaded = true;
IsUser = true;
}
void _ServiceUserLoaded(object sender,
ServiceArgs<List<UserTransport>> e)
{
e.Entity.Sort((u1,u2)=>u1.Username.CompareTo(u2.Username));
foreach(UserTransport user in e.Entity)
{
TreeNode newNode = new TreeNode(user);
_children.Add(newNode);
}
}
}
The basic features of the tree node include a name (which will map to either the name of the group, or the user's username) and a flag to indicate whether the node contains a user (if not, it's a group). The key to note now is the collection of children. Notice how we use ObservableCollection
. This is a special type of collection that will automatically notify any control it is bound to when the contents of the list change. This is important to us for being able to lazy load the user information and have the tree view aware of the change.
There are two constructors. One takes in a UserTransport
and the other takes in a GroupTransport
. The pertinent fields are moved to the node, the original object is stored in the DataContext
, and in the case of groups, other tree nodes are recursively added to the children.
Keep your eye on the _usersLoaded
flag. We default this to false in the case of a GroupTransport
. The key here is the getter for the children. When the getter is invoked, this flag is checked. If it is set to false, then the service for users is invoked. When the service call returns, each user is converted to a TreeNode
and added to the collection.
This is key for hierarchical data templates. In the template, you specify which property is used to find the next "level" or children for the template. You'll see that in a minute when we wire it up. What is important to note is that the property isn't accessed until needed, based on the pattern of Level + 1.
In other words, when we are at our root, the tree view control will ask for the root's children (Level + 1). This will trigger a call to get the users for the root node. However, any child groups haven't been accessed yet and therefore their children only contain other groups (no children). Expanding the root node will expose the child groups, which in turn will trigger a call to their children, and cause those groups to invoke the call to retrieve their users. In most cases, barring an extremely slow connection, those users would be loaded by the time you got around to expanding a subgroup, but if not, they will slowly appear like magic as they are loaded.
This behavior is easier to see and understand when you run the application in debug. Open the output window and watch the prompts. Then, slowly expand the tree. You'll see when the child groups fire off the event to retrieve the users. While we always have them in our case because we are using a mock database, you can see how in a real service-based application the users will only get called when needed and never retrieved for nodes that aren't accessed or viewed.
The TreeView
After understanding our tree node, you can now tackle the XAML for the tree view control itself:
<UserControl x:Class="UserGroups.Controls.UserGroups"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Controls="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls"
xmlns:Data="clr-namespace:System.Windows;assembly=System.Windows.Controls"
xmlns:Converters="clr-namespace:UserGroups.Converters"
>
<UserControl.Resources>
<Converters:UserGroupConverter x:Key="TreeIcon"/>
<Data:HierarchicalDataTemplate x:Key="UserGroupTemplate"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal"
Height="Auto" Width="Auto" Grid.Row="0">
<Image Source="{Binding IsUser,Converter={StaticResource TreeIcon}}"/>
<TextBlock Text="{Binding Name}"/>
</StackPanel>
</Data:HierarchicalDataTemplate>
<Style TargetType="Controls:TreeView" x:Key="UserGroupStyle">
<Setter Property="ItemTemplate"
Value="{StaticResource UserGroupTemplate}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style TargetType="TextBlock" x:Key="LoadingStyle">
<Setter Property="FontSize" Value="10"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Margin" Value="3"/>
</Style>
</UserControl.Resources>
<Controls:TreeView x:Name="UserGroupsTree"
Style="{StaticResource UserGroupStyle}">
<Controls:TreeViewItem>
<Controls:TreeViewItem.HeaderTemplate>
<DataTemplate>
<TextBlock Text="Loading..."
Style="{StaticResource LoadingStyle}"></TextBlock>
</DataTemplate>
</Controls:TreeViewItem.HeaderTemplate>
</Controls:TreeViewItem>
</Controls:TreeView>
</UserControl>
The HierarchicalDataTemplate
points to the property Children
for its items source. This is how the control can parse the object graph and recurse the hierarchy. The template itself is a StackPanel
with an image for a nice icon showing the type of the node, then the name of the node.
For the icon, we use a value converter. We are showing an Image
so the value converter must return something applicable to the Source
property. We'll return an actual BitmapImage
based on the IsUser
value. The converter looks like this:
public class UserGroupConverter : IValueConverter
{
private static readonly BitmapImage _user =
new BitmapImage(new Uri("../Resources/user.png", UriKind.Relative));
private static readonly BitmapImage _group =
new BitmapImage(new Uri("../Resources/groups.png", UriKind.Relative));
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
return (bool) value ? _user : _group;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
Notice how we deal with loading the two bitmaps once and only once. We aren't re-referencing the images. The IsUser
property comes in, then we return either the user or the group bitmap to embed in the image. Also note that I am throwing NotSupportedException
in the ConvertBack
method, rather than the defaulted NotImplemented
exception. This tells anyone consuming my application that I don't intend to support that feature and it's not a question of unfinished code that is waiting to be written.
The code-behind for the control is straightforward:
public partial class UserGroups
{
public event EventHandler<ServiceArgs<TreeNode>> SelectionChanged;
public UserGroups()
{
InitializeComponent();
UserService service = new UserService();
service.GroupLoaded += _ServiceGroupLoaded;
service.GetGroup();
}
void _ServiceGroupLoaded(object sender, ServiceArgs<Transport.GroupTransport> e)
{
UserGroupsTree.Items.Clear();
UserGroupsTree.ItemsSource = new List<TreeNode> {new TreeNode(e.Entity)};
UserGroupsTree.SelectedItemChanged += _UserGroupsTreeSelectedItemChanged;
}
void _UserGroupsTreeSelectedItemChanged(object sender,
System.Windows.RoutedPropertyChangedEventArgs<object> e)
{
if (SelectionChanged != null)
{
SelectionChanged(this, new ServiceArgs<TreeNode>((TreeNode)e.NewValue));
}
}
}
In the constructor, we set up a call to get the root group. The tree view already has a hard-coded item that simply shows a friendly "Loading..." message. When the group is retrieved, this is cleared and the group is databound by setting the ItemsSource
property (remember, if you have anything existing, you must clear it first!).
Now, as the users expand the tree, the TreeNode
will handle retrieving users as needed. We also respond to a node being selected by raising an event with the selected TreeNode
object. This allows other controls to respond without having to understand how our control works internally.
In the main page, you'll find one other convenient piece of functionality. When a node is selected, we raise an event that the main page is listening to. Because we stored the original object in a property called DataContext
, we can easily retrieve this and respond. If the node is a user, we simply databind the user to a control that shows their name, username, and email. If the node is a group, we databind the users to a data grid and show a nice grid of users for that group. This is powerful because the original object is shared between the controls and no additional calls or round-trips need to be made in order to show them.
Next Steps
Obviously, there is a lot more you can do with this application to clean up the UI, separate concerns, even add some error handling. A good next step would be to take the mock database and move it into your web application, then stand up some services to transport them up to Silverlight. Then, you can use a utility like Fiddler to see when/how the services are accessed.
Hopefully, this has provided you with useful insights into the use of hierarchical data templates in Silverlight, as well as some basic means of communicating between services and controls.