A Multi-Threaded WPF TreeView Explorer






4.83/5 (34 votes)
Multi-Threaded WPF TreeView Explorer
Contents
Introduction
Most business applications need to display hierarchal data, and of course a TreeView
has been the way to go for as long as I can remember. However, many situations arise where loading data in a tree can take quite some time. And this is what this article tries to address. All loading of items in this TreeView
are done in a multi-threaded manner.
So naturally when starting to learn WPF, the first thing on the list was how to get a TreeView
up and running.
I ran into a great article, which is the basis of my work, by Sacha Barber titled "A Simple WPF Explorer Tree". I definitely encourage you to read this article, as well as, Sacha's great beginner's series on WPF. Josh Smith also added to Sacha's work on his blog at Reaction to: A Simple WPF Explorer Tree. Both guys give us a great start to displaying items in a TreeView
with images attached.
End Result
The end result of this article is a tree that loads sub-nodes on background threads, with instant feedback to the user as nodes are inserted. The UI never locks up waiting for long running I/O operations.

You may have also noticed that multiple nodes can be loaded simultaneously, and that the loading can also be cancelled.
So now that you've seen the end result, let's get to the implementation.
Implementation
For my sample implementation, I continued on Sacha's work of displaying the local file system. Of course, other types of data could just as easily be displayed and loaded in a threaded manner.
The difference with my implementation is when a node is clicked, its sub-items are fetched on a background thread. I tried to keep the implementation a bit generic, but at the same time not get too complex and abstract.
The concept here is very simple. We start with a root node that is created when the form is loaded.
void DemoWindow_Loaded(object sender, RoutedEventArgs e)
{
// Create a new TreeViewItem to serve as the root.
var tviRoot = new TreeViewItem();
// Set the header to display the text of the item.
tviRoot.Header = "My Computer";
// Add a dummy node so the 'plus' indicator
// shows in the tree
tviRoot.Items.Add(_dummyNode);
// Set the item expand handler
// This is where the deferred loading is handled
tviRoot.Expanded += OnRoot_Expanded;
// Set the attached property 'ItemImageName'
// to the image we want displayed in the tree
TreeViewItemProps.SetItemImageName(tviRoot, @"Images/Computer.png");
// Add the item to the tree folders
foldersTree.Items.Add(tviRoot);
}
Most of what you see above is pretty clear. The header
property is the actual display text. The dummy node is added so that the plus indicator is visible for the root node. The OnRoot_Expanded
handles the actual loading, back to this in a minute.
Now you might ask what is TreeViewItemProps.SetItemImageName
? This is an attached property that I defined, among a few others, in the static
class TreeViewItemProps
. These properties are databound in XAML to the TreeViewItem
's DataTemplate
to control display settings of the progress bar and cancel and reload buttons. Sacha did a great job explaining DependencyProperties in his article WPF: A Beginner's Guide - Part 4 of n (Dependency Properties).
public static class TreeViewItemProps
{
public static readonly DependencyProperty ItemImageNameProperty;
public static readonly DependencyProperty IsLoadingProperty;
public static readonly DependencyProperty IsLoadedProperty;
public static readonly DependencyProperty IsCanceledProperty;
static TreeViewItemProps()
{
ItemImageNameProperty = DependencyProperty.RegisterAttached
("ItemImageName", typeof(string),
typeof(TreeViewItemProps),
new UIPropertyMetadata(string.Empty));
IsLoadingProperty = DependencyProperty.RegisterAttached("IsLoading",
typeof(bool), typeof(TreeViewItemProps),
new FrameworkPropertyMetadata(false,
FrameworkPropertyMetadataOptions.AffectsRender));
IsLoadedProperty = DependencyProperty.RegisterAttached("IsLoaded",
typeof(bool), typeof(TreeViewItemProps),
new FrameworkPropertyMetadata(false));
IsCanceledProperty = DependencyProperty.RegisterAttached("IsCanceled",
typeof(bool), typeof(TreeViewItemProps),
new FrameworkPropertyMetadata(false));
}
public static string GetItemImageName(DependencyObject obj)
{
return (string)obj.GetValue(ItemImageNameProperty);
}
public static void SetItemImageName(DependencyObject obj, string value)
{
obj.SetValue(ItemImageNameProperty, value);
}
public static bool GetIsLoading(DependencyObject obj)
{
return (bool)obj.GetValue(IsLoadingProperty);
}
public static void SetIsLoading(DependencyObject obj, bool value)
{
obj.SetValue(IsLoadingProperty, value);
}
public static bool GetIsLoaded(DependencyObject obj)
{
return (bool)obj.GetValue(IsLoadedProperty);
}
public static void SetIsLoaded(DependencyObject obj, bool value)
{
obj.SetValue(IsLoadedProperty, value);
}
public static bool GetIsCanceled(DependencyObject obj)
{
return (bool)obj.GetValue(IsCanceledProperty);
}
public static void SetIsCanceled(DependencyObject obj, bool value)
{
obj.SetValue(IsCanceledProperty, value);
}
}
The OnRoot_Expanded
handler fires when the root item is expanded. The first thing it does is check to see if the IsLoaded
attached property is set to false
. If it is, then the loading logic fires.
void OnRoot_Expanded(object sender, RoutedEventArgs e)
{
var tviSender = e.OriginalSource as TreeViewItem;
if (IsItemNotLoaded(tviSender))
{
StartItemLoading(tviSender, GetDrives, AddDriveItem);
}
}
bool IsItemNotLoaded(TreeViewItem tviSender)
{
if (tviSender != null)
{
return (TreeViewItemProps.GetIsLoaded(tviSender) == false);
}
return (false);
}
Now the interesting stuff happens in StartItemLoading
:
void StartItemLoading(TreeViewItem tviSender,
DEL_GetItems actGetItems, DEL_AddSubItem actAddSubItem)
{
// Add a entry in the cancel state dictionary
SetCancelState(tviSender, false);
// Clear away the dummy node
tviSender.Items.Clear();
// Set all attached props to their proper default values
TreeViewItemProps.SetIsCanceled(tviSender, false);
TreeViewItemProps.SetIsLoaded(tviSender, true);
TreeViewItemProps.SetIsLoading(tviSender, true);
// Store a ref to the main loader logic for cleanup purposes
// This causes the progress bar and cancel button to appear
DEL_Loader actLoad = LoadSubItems;
// Invoke the loader on a background thread.
actLoad.BeginInvoke(tviSender, tviSender.Tag as string, actGetItems,
actAddSubItem, ProcessAsyncCallback, actLoad);
}
The SetCancelState
function is just making an entry in a lookup dictionary set to false
.
This is used in the load routine to check if the cancel button was pressed.
Notice I opted not to use a dependency property here due to the fact that the load routine is working on a background thread. If it had been a dependency property, then every time the loading routine wanted to check if a cancel had been initiated, it would have to dispatch the check to the UI thread. This just seemed a little more straightforward.
All the cancel state functions are below:
// Keeps a list of all TreeViewItems currently expanding.
// If a cancel request comes in, it causes the bool value to be set to true.
Dictionary<TreeViewItem, bool> m_dic_ItemsExecuting =
new Dictionary<TreeViewItem, bool>();
// Sets the cancel state of specific TreeViewItem
void SetCancelState(TreeViewItem tviSender, bool bState)
{
lock (m_dic_ItemsExecuting)
{
m_dic_ItemsExecuting[tviSender] = bState;
}
}
// Gets the cancel state of specific TreeViewItem
bool GetCancelState(TreeViewItem tviSender)
{
lock (m_dic_ItemsExecuting)
{
bool bState = false;
m_dic_ItemsExecuting.TryGetValue(tviSender, out bState);
return (bState);
}
}
// Removes the TreeViewItem from the cancel dictionary
void RemoveCancel(TreeViewItem tviSender)
{
lock (m_dic_ItemsExecuting)
{
m_dic_ItemsExecuting.Remove(tviSender);
}
}
You probably also noticed that I was passing around a few delegate
s to the BeginInvoke
, and actually we are primarily dealing with three.
// The main loader, in this sample app it is always "LoadSubItems"
// RUNS ON: Background Thread
delegate void DEL_Loader(TreeViewItem tviLoad, string strPath,
DEL_GetItems actGetItems, DEL_AddSubItem actAddSubItem);
// Adds the actual TreeViewItem, in this sample it's either
// "AddFolderItem" or "AddDriveItem"
// RUNS ON: UI Thread
delegate void DEL_AddSubItem(TreeViewItem tviParent, string strPath);
// Gets an IEnumerable for the items to load,
// in this sample it's either "GetFolders" or "GetDrives"
// RUNS ON: Background Thread
delegate IEnumerable<string> DEL_GetItems(string strParent);
Let's now take a look at the LoadSubItems
routine which is the target of the BeginInvoke
above and runs on a background thread, pay special attention to the two delegate
s passed in. The actGetItems delegate
returns the IEnumerable
of what we want to load, this runs on the background thread. The actAddSubItem delegate
creates a TreeViewItem
and adds it the TreeView
, this runs on the UI thread.
// Amount of delay for each item in this demo
static private double sm_dbl_ItemDelayInSeconds = 0.75;
// Runs on background thread.
// Queuing updates can help in rapid loading scenarios,
// I just wanted to illustrate a more granular approach.
void LoadSubItems(TreeViewItem tviParent, string strPath,
DEL_GetItems actGetItems, DEL_AddSubItem actAddSubItem)
{
try
{
foreach (string dir in actGetItems(strPath))
{
// Be really slow :) for demo purposes
Thread.Sleep(TimeSpan.FromSeconds(sm_dbl_ItemDelayInSeconds).Milliseconds);
// Check to see if cancel is requested
if (GetCancelState(tviParent))
{
// If cancel dispatch "ResetTreeItem" for the parent node and
// get out of here.
Dispatcher.BeginInvoke(DispatcherPriority.Normal, (Action)(() =>
ResetTreeItem(tviParent, false)));
break;
}
else
{
// Call "actAddSubItem" on the UI thread to create a TreeViewItem
// and add it the control.
Dispatcher.BeginInvoke(DispatcherPriority.Normal,
actAddSubItem, tviParent, dir);
}
}
}
catch (Exception ex)
{
// Reset the TreeViewItem to unloaded state if an exception occurs
Dispatcher.BeginInvoke(DispatcherPriority.Normal,
(Action)(() => ResetTreeItem(tviParent, true)));
// Rethrow any exceptions, the EndInvoke handler "ProcessAsyncCallback"
// will redispatch on UI thread for further processing and notification.
throw ex;
}
finally
{
// Ensure the TreeViewItem is no longer in the cancel state dictionary.
RemoveCancel(tviParent);
// Set the "IsLoading" dependency property is set to 'false'
// this will cause all loading UI (i.e. progress bar, cancel button)
// to disappear.
Dispatcher.BeginInvoke(DispatcherPriority.Normal, (Action)(() =>
TreeViewItemProps.SetIsLoading(tviParent, false)));
}
}
Now for the folder items it is just to other delegates that do the fetching of sub-folders and adding of the TreeViewItem
. I definitely encourage you to play with the code and get a feel for the simple logic at work. It really is just a matter of popping back and forth from UI to background threads.
Further Improvements
Now, obviously this code is not as cleanly separated as one would hope. I tried really not to get too abstract.
But for a follow-up version, I would like to provide an Interface based approach to loading the TreeViewItems
. And one that would support databinding a hierarchal collection.
One scenario would have the TreeViewItem
s implement a certain interface, call it IThreadTreeItem
. This interface would expose a GetItems
method that would be used to get an IEnumerable
, and few other methods maybe for UI feedback - something along the lines of showing an alert if the data could not be loaded and stuff like that.
The other scenario would be to databind directly to an ObservableCollection
, however, this may require some careful wrapping to dispatch changes properly.
The other improvement area would be to queue the inserts, instead of dispatching each one. I would like to expose a property that would specify the number of inserts to queue before actual dispatching.
Any Feedback
This is my first article on The Code Project. So please let me know what you think.
If this helped you in some way, then let me know. If my approach is totally wrong also feel free to let me know.
I definitely want to hear what the community thinks.
History
DATE | VERSION | DESCRIPTION |
9th March, 2008 | 1.0.0.0 | Simple demo |