Click here to Skip to main content
15,884,425 members
Articles / Desktop Programming / WPF

Tuning Up The TreeView - Part 2

Rate me:
Please Sign up or sign in to vote.
4.75/5 (7 votes)
2 Mar 2010CPOL11 min read 41.8K   1.6K   40   1
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.

TuningTheTreeView21.jpg

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:

C#
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.

XML
<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):

C#
// 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:

C#
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 SortDescriptions. SortDescriptions 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:

C#
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:

C#
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:

TuningTheTreeView22.jpg

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:

C#
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:

C#
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:

C#
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:

C#
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:

TuningTheTreeView23.png

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:

C#
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:

C#
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 EventSetters 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:

XML
<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:

C#
// 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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United States United States
Lee has worked on user interfaces, graphics, computational geometry, memory management, threading, and assorted applications in C#, Java, C++, and C. He started out programming in Fortran on a 128 Kb PDP/11, which only proves that he's old, not smart. Lee also writes about chronic illness and his love of animals; his auto racing related articles are here.

Comments and Discussions

 
GeneralMy vote of 5 Pin
DevilVikas31-Dec-12 13:56
DevilVikas31-Dec-12 13:56 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.