WPF Explorer TreeView with SelectedPath binding

While working on one of my projects, I had to implement a control for displaying file system. I found pretty good articles over the web (“A Simple WPF Explorer Tree” by Sacha Barber and some replies to it), but they were all about displaying folders.

It was pretty easy to track currently selected folder, but there were some issues with its dynamic changes. So I decided to build my own control from scratch that will have some property like SelectedPath that will allow you to use TwoWay bindings to get and set selected folder at the tree.

When user selects a node from the TreeView, text in the address bar is updated. When user enters address and clicks Update, TreeView selection gets updated. I have started with a simple CustomControl and inherited it from WPF TreeView:

public class ExplorerTreeView: TreeView
{
    public const string SelectedPathPropertyName = "SelectedPath";

    public static readonly DependencyProperty SelectedPathProperty = DependencyProperty.Register(
        SelectedPathPropertyName,
        typeof (String),
        typeof (ExplorerTreeView));

    public String SelectedPath
    {
        get { return (String) GetValue(SelectedPathProperty); }
        set { SetValue(SelectedPathProperty, value); }
    }
}

We will use some lazy-loading techniques to fill our TreeView with data. When control is loaded, it will display only drives on the user machine. Let’s write a method for creating initial drive nodes:

public void InitExplorer()
{
    Items.Clear();

    foreach (var drive in DriveInfo.GetDrives())
    {
        Items.Add(GenerateDriveNode(drive));
    }
}

private static TreeViewItem GenerateDriveNode(DriveInfo drive)
{
    var item = new TreeViewItem
    {
        Tag = drive,
        Header = drive.ToString()
    };

    item.Items.Add("*");
    return item;
}

GenerateDriveNode method takes DriveInfo argument and builds TreeViewItem for it. Note that we are adding a dummy child node (the one with ‘the star’ header text). If we don’t do this, then the user will not see a small button for expanding/collapsing for generated tree view item (it may look like ‘+’ character that is replaced with ‘-‘ when the node is expanded or like an arrow that changes it direction).

InitExplorer method is pretty straight-forward. It just enumerates all the drives and populates Items collection with corresponding TreeViewItems.

Now we just have to handle TreeViewItem.ExpandedEvent to react to user interaction. When node is expanded, it will display all the sub-folders of the selected folder.

Put this line to control constructor:

AddHandler(TreeViewItem.ExpandedEvent, new RoutedEventHandler(OnItemExpanded));

Then create OnItemExpanded handler

private void OnItemExpanded(object sender, RoutedEventArgs e)
{
    var item = (TreeViewItem)e.OriginalSource;
    item.Items.Clear();
    DirectoryInfo dir;
    if (item.Tag is DriveInfo)
    {
        var drive = (DriveInfo)item.Tag;
        dir = drive.RootDirectory;
    }
    else
    {
        dir = (DirectoryInfo)item.Tag;
    }

    foreach (var subDir in dir.GetDirectories())
    {
        item.Items.Add(GenerateDirectoryNode(subDir));
    }
}

*GenerateDirectoryNode *method will look like

private static TreeViewItem GenerateDirectoryNode(DirectoryInfo directory)
{
    var item = new TreeViewItem
    {
        Tag = directory,
        Header = directory.Name
    };
    item.Items.Add("*");

    return item;
}

Everything was rather simple so far. Now lets move towards the more complex stuff. We have to synchronize our tree view selection state and SelectedPath value. I created 2 helper methods for accessing and changing current tree view item selection (GetSelectedPath and SetSelectedPath).

private String GetSelectedPath()
{
    var item = (TreeViewItem)SelectedItem;
    if (item == null)
    {
        return null;
    }

    var driveInfo = item.Tag as DriveInfo;
    if (driveInfo != null)
    {
        return (driveInfo).RootDirectory.FullName;
    }

    var directoryInfo = item.Tag as DirectoryInfo;
    if (directoryInfo != null)
    {
        return (directoryInfo).FullName;
    }

    return null;
}

private void SetSelectedPath(String value)
{
    InitExplorer();

    if (String.IsNullOrEmpty(value))
    {
        return;
    }

    var split =
        Path.GetFullPath(value).Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
            StringSplitOptions.RemoveEmptyEntries);
    var drive = new DriveInfo(split[0]);

    foreach (TreeViewItem driveItem in Items)
    {
        var name = ((DriveInfo)driveItem.Tag).Name.ToLower();
        if (name == drive.Name.ToLower())
        {
            Expand(driveItem, 1, split);
            break;
        }
    }
}

private void Expand(TreeViewItem item, int index, string[] pathParts)
{
    if (index > pathParts.Length)
    {
        return;
    }

    if (index == pathParts.Length)
    {
        item.IsSelected = true;
        return;
    }

    if (!item.IsExpanded)
    {
        item.IsExpanded = true;
    }

    var name = pathParts[index].ToLower();

    foreach (TreeViewItem folderItem in item.Items)
    {
        var directoryInfo = (DirectoryInfo)folderItem.Tag;
        if (directoryInfo.Name.ToLower() == name)
        {
            Expand(folderItem, index + 1, pathParts);
            break;
        }
    } 
}

I have to make some remarks here:

  1. GetSelectedPath just returns information from selected DriveInfo or DirectoryInfo.
  2. SetSelectedPath splits target path to array of folders and uses recursive lambda-function to traverse down the tree and expand all the nodes on the way to the target folder. I had to put InitExplorer() call at the first line of this method. There were some issues with tree view selection system and SelectionPath dependency property value changes handling that didn’t allow to select a correct item, so the only work-around that I found was to rebuild the tree. Since we use lazy-loading and setting SelectedPath by code is not a common task, it works well.

Don’t forget to add handler for SelectedItemChanged event:

SelectedItemChanged += (s, e) => SelectedPath = GetSelectedPath();

We’re almost done – we have just to handle SelectedPath changes to update the UI:

protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
{
    base.OnPropertyChanged(e);

    if (e.Property == SelectedPathProperty)
    {
        var newValue = (String)e.NewValue;
        if (IsSelectionUpdateRequired(newValue))
        {
            SetSelectedPath(newValue);
        }
    }
}

IsSelectionUpdateRequired method is used to prevent infinite loops (user selects a node => SelectedPath changes –> TreeView.SelectedItem changes –> SelectedPath changes –> …..) and unnecessary calculations – it compares current SelectedPath value with new one:

private bool IsSelectionUpdateRequired(String newPath)
{
    if (String.IsNullOrEmpty(newPath))
    {
        return true;
    }

    var selectedPath = GetSelectedPath();
    if (String.IsNullOrEmpty(selectedPath))
    {
        return true;
    }

    return !Path.GetFullPath(newPath).Equals(Path.GetFullPath(selectedPath));
}

The source code of the sample app is rather trivial and is not listed here, but you can find it in the archives.
Download sources and sample app

Note: attached project contains improved version that supports error reporting via custom event and UnloadItemsOnCollapse property that lets you specify whether node content will be unloaded when it is collapsed, or not, and images for disks and folder (implemented with custom value converter).