Tuning Up The TreeView - Part 2
Improved TreeView sorting, filtering, selection, and efficiency.
Introduction
In Tuning Up the TreeView - Part 1, I described the implementation of a TreeNode
, a ViewModel adaptor class for the WPF TreeView
control. TreeNode
solves three essential problems:
- UI Update. Ensures that the Tree UI responds to model changes (add, delete, rename) and always updates and maintains sort order.
- Efficiency. Avoids creating collections until they're needed.
- Deferred or lazy loading.
In Part 2, I describe the other design and UI issues I faced, and describe my solutions.
A Specialized TreeView UI
My application is a disk space profiler to identify "hot spots" of waste on a disk drive. The UI is very similar to the Windows file explorer, as shown below.
The folders in the tree view are color coded to reflect their size and state. The right-hand pane displays details about the currently selected directory, including the cumulative number of files and folders, and total size. Because these statistics take tens of seconds to compute, I need to load the tree with "partially constructed objects" that display the directory names, with the sizes filled in later.
Displaying Images in the TreeView
With TreeNode
out of the way, we can now look at the classes for the domain specific objects (directory, drive) in the TreeView
. The sample code is a simple UI test-bed, not the full app, so these are simplified versions of the actual application classes.
The problem of displaying the file system hierarchy in a TreeView has been solved numerous times. Sacha Barber's A Simple WPF Explorer Tree and Shail Srivastav's Advanced File Explorer are just two examples. In my case, besides having Directory
objects, I realized that I also needed a specific Drive
object for the root. Since the root drive is also a directory, I had to decide: is a drive a directory, or does a drive have a directory (inheritance vs. composition)?
The code was simpler with Drive
inheriting from Directory
, so I went with it. All of my TreeView
objects derive from TreeNode
, so the classes are declared like this:
public class Directory : TreeNodeLazy
. . .
public class Drive : Directory
Now, I had to figure out how to display a hard drive icon for Drive
objects. This is easily handled with a HierarchicalDataTemplate
for each class.
<HierarchicalDataTemplate DataType="{x:Type appvm:Directory}"
ItemsSource="{Binding Path=ChildrenView}" >
<Image Source="{StaticResource imgHardDisk}" />
. . .
<HierarchicalDataTemplate DataType="{x:Type appvm:Drive}"
ItemsSource="{Binding Path=ChildrenView}" >
. . .
<Image Style="{StaticResource FolderImage}"
Even though a Drive
is a Directory
, WPF selects the most specific template and does the right thing. A static resource is sufficient because all drives use the same image.
For the directory, I needed to display different images depending on its relative size. Directory
has two properties, Name
and Size
that use the standard property implementation. So, I need to use the continuous numeric value Size
to select one of a small number of images. This seems to be what Value Converters are for; Sacha uses this approach, and I initially copied it. On his blog, Josh Smith showed a different approach using an attached property. I finally decided to just use a simple enumerated property computed from the size. "Temperature
'" is an enumeration that characterizes a directory as "hot" or "cold" or even "invalid" (size not yet known):
// Directory Temperature - an indication of relative size
public enum DirTemp
{ Invalid = -1, // Size not yet set
Empty, // 0 empty, no contents
Cold, // small
Normal, // everything in the middle
Hot // large
}
Temperature
's value is strictly a function of Size
, like this:
private int size = -1;
public int Size
{
get { return size; }
set { size = value;
RaisePropertyChanged(SIZE);
RaisePropertyChanged(TEMPERATURE); }
}
public DirTemp Temperature
{
get
{ // Hard code for testing
DirTemp value = DirTemp.Normal;
if (size > 8000) value = DirTemp.Hot;
if (size < 2500) value = DirTemp.Cold;
if (size == 0) value = DirTemp.Empty;
if (size == -1) value = DirTemp.Invalid;
return value;
}
}
Since Temperature
is derived from Size
rather than being stored, we just have to remember to also notify Temperature
whenever Size
updates. The XAML uses a data trigger with Temperature
to select a particular folder image.
Is a simple derived property better than a value converter or attached property? I did it this way to eliminate a converter class and minimize lines of code, but it's mostly a case of moving complexity around rather than eliminating it.
Sorting the Tree
Collection views allow you to filter, sort, and group: I needed to do two out of three. In part 1, I describe using the TreeNode
ChildenView
property to apply SortDescription
s. SortDescription
s are a quick way to get things working, but they depend on Reflection and are therefore slow. WPF provides a second approach: implement an IComparer
by writing a custom compare function. Directories can be sorted either by Name
or by Size
, so the comparer looks like this:
public class DirectorySorter : IComparer
{
public static readonly DirectorySorter
SizeSorter = new DirectorySorter(true);
public static readonly DirectorySorter
NameSorter = new DirectorySorter(false);
private bool sortBySize;
private DirectorySorter(bool sortBySize)
{ this.sortBySize = sortBySize; }
// Returns < o if left < right
// > 0 if left > right
// 0 if equal
public int Compare(object x, object y)
{
Directory lhs = x as Directory;
Directory rhs = y as Directory;
if (sortBySize)
{
// Flip the order so larger items appear first
int delta = rhs.Size - lhs.Size;
return delta < 0 ? -1 : delta == 0 ? 0 : 1;
}
else
return string.Compare(lhs.Name, rhs.Name,
StringComparison.CurrentCultureIgnoreCase);
}
}
You could also structure this as two separate classes, but I just combined them and set up two static sorter objects that get stuffed into the CustomSort
property on the view:
protected override void OnCreateView()
{
ChildrenView.CustomSort = DirectorySorter.NameSorter;
}
Filtering the Tree
Filtering is important in any profiling tool in order to hone in on the problem areas. In my case, I wanted to filter out empty and "cold" directories to focus on the larger space hogs. I chose a similar UI to sorting, where a button would select the next filter when clicked, like this:
Initially, Drive1 is sorted by name with no filtering, so first, I sort it by size. When I click the Filter button, it changes from "none" to "empty", and the four black directories disappear. Click again, and the three blue "cold" directories also go away.
For filtering, WPF relies on a predicate function, not an interface. I found it convenient to put the arrays of these functions, along with the button labels and filter selection logic, in one class called DirectoryFilter
:
public sealed class DirectoryFilter
{
private static readonly Predicate<object>[]
Filters = { null, FilterEmpty, FilterCold };
private static readonly string[]
Labels = { "Filter: None",
"Filter: Empty", "Filter: Cold" };
// Cull empty items, but not unknown objects
private static bool FilterEmpty(object o)
{
Directory dir = o as Directory;
return dir == null ? true : dir.Temperature > DirTemp.Empty;
}
. . .
// Similar for Cold
The view is initialized with no filter. Pressing the Filter button calls SelectNextFilter
to move to the next filter in a circular fashion:
public static void SelectNextFilter(ContentControl control,
ICollectionView cvs)
{
int index = Array.IndexOf(Filters, cvs.Filter);
// move to next filter in (circular) sequence
index = ++index % Filters.Length;
cvs.Filter = Filters[index];
control.Content = Labels[index];
}
We look up the index of the current filter, increment, and update the filter and button label.
The filter logic works fine, but it causes yet another TreeView
update problem. Specifically, whenever I add a Directory
to the bound ObservableCollection
, it does not appear in the tree if there is a filter predicate set on the view. The reason turns out to be very subtle, and apparently depends on the order that things happen in the complex interaction between WPF and the application code. Here's the deal.
When my Directory
object gets added to the bound collection (in the TreeView
constructor), its size value isn't set because I calculate it on a background thread and update it later. This means that the predicate always fails, so WPF doesn't add it to the view. However, in this test code, as part of this same add, I do set the size and notify WPF, but it doesn't catch this notification and therefore doesn't retest the predicate. Why not?
One reason is that WPF hasn't yet subscribed to Property Notification for the object. This means the PropertyChanged
event is null
, so the notification doesn't go through. You may also recall that I transact the object as part of this notification to get other updates to work:
protected virtual void RaisePropertyChanged(string prop)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(prop));
if (parent != null)
{
parent.view.EditItem(this);
parent.view.CommitEdit();
}
}
}
I tried calling EditItem/CommitEdit
regardless of the event, but there are other issues - WPF hasn't added the object into the view yet. I think I'm caught out by the deferred way that WPF updates itself. So, I fell back to adding a Refresh
call specifically for the Filter case in the TreeNode
constructor:
protected TreeNode(TreeNode parent)
{
. . .
// Work-around WPF sequencing that fails to update the
// Tree *if* a filter is active
if (parent.ChildrenView.Filter != null)
parent.ChildrenView.Refresh();
Ugh, one more ugly hack to get the TreeView
to update. Let's hope this is the last one.
Selection Manager
I initially followed the approach to selection that I saw in other examples: declare a boolean IsSelected
property, bind it to the TreeViewItem
property in XAML, and call a virtual function to notify derived classes when the selection changes. It's natural to do it this way since the WPF TreeViewItem
provides a selection property.
The problem with this is the dependency created by having the selected object have to know who is interested in it being selected. In my case, a selected Directory
in the TreeView
pane has to know that the detailed directory list pane has to update. As you add more and more UI pieces that have to respond to selection, the coupling multiplies.
I handled this using a singleton "Selection Manager" (SM) object and Publish-Subscribe. The idea is shown in the diagram below:
Instead of using the IsSelected
property, you register the Selection Manager with any WPF control that generates selection events. In this case, its just the TreeView
, but the approach generalizes.
The Selection Manager makes the selection available in two ways. First, an object can subscribe to selected and unselected events if it wants to do something every time the selection changes. Or, if an object only wants to be called when it's selected, it implements the ISelected
interface. If it isn't selected, then it won't be called. The currently selected object is also available as a property.
This works because selection is typically an application wide concept. A general purpose Selection Manager has to handle different types of controls, allow event filtering, and maintain a selection list rather than a single selection object. My very simple implementation is sufficient for this app.
A good example of how this works is the way I update the label on a button based on the state of the Directory
the user selects. This is wired together in Main
:
public Main()
{
. . .
SelectionManager.Register(tvDirTree); // Register TreeView with SM
SelectionManager.Selected += UpdateButtonLabel;
Each time the selection changes, UpdateButtonLabel
gets called to fix up the buttons:
private void UpdateButtonLabel(Object sender, SelectedEventArgs e)
{
Directory dir = e.Selection as Directory;
ListCollectionView lcv = dir.ChildrenView;
bool sortBySize = lcv.CustomSort == DirectorySorter.SizeSorter;
btnSort.Content = !sortBySize ? NAME : SIZE;
DirectoryFilter.SynchronizeFilterButton(btnFilter, lcv);
}
Other than exposing the view property (via the TreeNode
base class), Directory
objects aren't involved in this UI update. The click handlers for the various buttons illustrate other uses of the Selection Manager.
Odds and Ends
Specialized Pop-Up Menu
One other function in Main
is a "refresh" command that deletes the entire directory tree structure for a drive so that it can be reloaded from scratch. I wanted the pop-up menu to appear only over Drive
objects, and I didn't want to select (left click) the drive first, since that implies other actions. If you run the sample, you will see that the mechanics are quite compact: mouse over a drive, right click to pop up the menu, and left click to select it.
The pop-up menu logic in the Main
XAML and code-behind is another case of trying to get WPF to perform a slightly non-standard UI. The code is surprisingly complex: it takes a ContextMenu
and two EventSetter
s in XAML, and three callbacks in Main
, to make this work. The code isn't nearly as bad as the time it took me to discover it.
Threading
C# and WPF provide such a great foundation for multithreaded programming that we have no excuses for locking up the UI. In my app, I can easily populate the top level of the directory hierarchy during a user event, but computing the cumulative statistics for the entire drive takes too long. So, I "partially" construct Directory
objects with their name and an invalid size, and stick them in the TreeView
. The XAML keys off of the invalid size (actually Temperature
) indicate that the directories aren't complete:
<Style x:Key="TreeText" TargetType="{x:Type TextBlock}" >
<Setter Property="Foreground" Value="Black" />
<Style.Triggers>
<DataTrigger Binding="{Binding Path=Temperature}"
Value="{x:Static appvm:DirTemp.Invalid}" >
<Setter Property="Foreground" Value="Lightgray" />
</DataTrigger>
</Style.Triggers>
</Style>
The sample code includes some simple threaded test code to validate the approach, and is called from OnInitialExpand
in the Directory
class. The key thing here is that I create the Directory
objects on the UI thread. These objects and their properties are bound to the UI, so WPF requires that they be created on the UI thread. If you run the sample, you will see that these directories show up in the TreeView
right away, but are rendered with grayed out text and a question mark icon to indicate that they are incomplete.
This prototype jumps to a background thread, fills in some random sizes, and sleeps for a couple of seconds to simulate a long compute. It then invokes back the UI thread to finish updating the sizes:
// update the UI
Main.Invoke((ThreadStart)delegate()
{
for (int i = 0; i < root.ChildrenCount; i++)
{
Directory dir = root.Children[i] as Directory;
dir.Size = sizes[i];
}
});
Every time I look at this, I'm amazed all over. This piece of C# magic does a context switch to the UI thread while allowing access to all of the variables in the calling scope. No parameter passing, no locking, no fuss, no muss. Really nice.
Invoke
is defined in the main window class, and relies on saving the Dispatcher
object at startup. I tried to use Application.MainWindow.Dispatcher
to call Invoke
, but this isn't legal from a background thread. Thus the need to store a reference to the Dispatcher
. Also note how this code uses the Children
and ChildrenCount
properties defined in TreeNode
. This is typical of most any tree traversal.
I should mention that I originally implemented this with BackgroundWorker
from the System.ComponentModel
namespace. That also works, but I found a simple Thread
object to be sufficient. Does anyone know why one approach should be preferred over the other?
Summary
When working with WPF or any other framework, you need an adaptor layer to interface the framework with your application code. For WPF, this is commonly called the ViewModel. The adaptor sorts out any framework policies or quirks that arise. For my app, I had to devote significant effort to getting the tree to correctly reflect changes to bound data under operations like sorting, filtering, add, delete, and rename. I also offer some design ideas to maximize efficiency by deferring the creation of lists and children nodes, and to reduce dependencies by using a centralized selection manager.
Every framework offers a similar value proposition: that it's much cheaper to learn to use the framework than it is to implement everything yourself, and with better results. WPF is very broad, powerful, and sophisticated, but also complex, difficult to learn, and sometimes downright baffling. Because there are often two or more ways to solve a problem with WPF, I rely on on-line articles like those on CodeProject to provide valuable lore and proven best practices.