Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Miller Columns for WPF

5.00/5 (7 votes)
4 Jan 2023Public Domain9 min read 14.5K   158  
A simple implementation of Miller columns (cascading columns) control
This article provides a very basic implementation of Miller columns control for Windows. The control provides an alternative to the standard TreeView control and displays hierarchical data. The idea of the control comes from Mac OS X application Finder.

Disclaimer

These test projects are written for .NET Framework 4.5 using Visual Studio 2019. Probably earlier versions may be used too, but require some tweaking.

I do not use nullable reference types in the project. I am not against them, it just happened.

All sources in projects are in public domain. While I encourage you to use them, I do not claim them to be free of bugs.

Introduction

A long time ago, I read a first journal article about a new company created by Steve Jobs, NeXT, it’s revolutionary computer NeXT Cube and its unusual operating system, NeXT OS. One of the things that mesmerized me then was a picture of this new system’s file browser – a long scrollable list of columns with file names.

Many years later, I got my hands on Apple’s new Macintosh computer with its new operating system Mac OS X based, I knew, on NeXT OS. Default file browser view was spatial view but I quickly found the way to switch to columns view and test it. It was really unusual but very logical and convenient for me.

Last year, I became involved in a project that works with hierarchical data sets. It’s a Windows application that now uses WPF as its UI engine. My first solution was to use standard TreeView to represent data on screen and it worked fine at first, until I got a huge data set to test.

Don’t get me wrong, the application itself works fine with millions of data items, the problem is that the tree becomes too big to comprehend. Then I remembered the NeXT/Mac file browser. It works with hierarchies of files and folders by narrowing the number of items on screen and helping to find relations between them. Unfortunately, WPF doesn’t provide a control that implements these cascading columns or Miller columns as they are called. I decided to implement such a control.

I confess that I was too optimistic when I started my little project. I thought WPF custom controls were easy to create. In general they are, but this particular control appeared to be a tough nut to crack. Before creating something usable I had, I think, four projects started from scratch. Every time I stumbled into something small and insignificant, that eventually made me to start over.

The word that helped me to complete the task was MVVM. My control contains its own internal view model for child controls and delegates column management to the binding subsystem.

Let’s review this control creation in several simple steps.

Step 1

Test Solution

Create a WPF application project.

Add a new data object class that supports hierarchies and can be bound to a TreeView:

C#
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;

namespace Pvax.App.CascadingColumns
{
    public class DataNode
    {
        [DebuggerStepThrough]
        public DataNode()
        {
            SubNodes = new ObservableCollection<DataNode>();
        }

        public DataNode(string title) :
            this()
        {
            Title = title;
        }

        public DataNode(string title, IEnumerable<DataNode> subNodes) :
            this(title)
        {
            foreach(DataNode subNode in subNodes)
            {
                SubNodes.Add(subNode);
            }
        }

        public string Title
        {
            get;
            set;
        }

        public ObservableCollection<DataNode> SubNodes
        {
            get;
        }
    }
}

Note that I deliberately name child nodes collection SubNode, not Children. Add a prefilled ObservableCollection<DataNode> instance as main window’s DataContext:

C#
public MainWindow()
{
    InitializeComponent();

    DataContext = new ObservableCollection<DataNode>
    {
        new DataNode("First root", new[]
        {
            new DataNode("Folder 1", new[]
            {
                . . . . .
            },
        }),
        new DataNode("Third root", new[]
        {
            new DataNode("Folder 1"),
            new DataNode("Folder 2"),
            new DataNode("Folder 3"),
            new DataNode("Folder 4"),
        }),
    };
}

Split the main window into two horizontal spaces:

XAML
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition />
        <RowDefinition />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition />
    </Grid.ColumnDefinitions>
</Grid>

Put a TreeView to the top row and bind it to our DataContext:

XAML
<TreeView ItemsSource="{Binding}">
    <TreeView.ItemTemplate>
        <HierarchicalDataTemplate ItemsSource="{Binding SubNodes}">
            <TreeViewItem Header="{Binding Title}" />
        </HierarchicalDataTemplate>
    </TreeView.ItemTemplate>
</TreeView>

This treeview is used as data hierarchy reference and to compare the standard control with Miller columns view.

Create a new public class MillerView, declare its ancestor as Control, add static and default constructors:

C#
public class MillerView : Control
{
    static MillerView()
    {
    }

    public MillerView()
    {
    }
}

Put an instance of this control to the second row of our main window:

XAML
<local:MillerView Grid.Row="1">
</local:MillerView>

From this point on, you can compile and run the test project.

Initial project

Add a new project folder, Themes:

Themes project folder

Add a new XAML resource file, Generic.xaml into the new folder:

Generic resource dictionary

Add an empty MillerView control style in Generic.xaml:

XAML
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:Pvax.App.CascadingColumns">
     <Style TargetType="{x:Type local:MillerView}">
     </Style>
</ResourceDictionary>

And then associate MillerView control with this style by modifying the static constructor:

C#
static MillerView()
{
    DefaultStyleKeyProperty.OverrideMetadata(typeof(MillerView), 
                new FrameworkPropertyMetadata(typeof(MillerView)));
}

Compile and run the program. You will get something like this:

Sample application, take 1

MillerView is there, but is invisible.

Step 2

Minimal cascading columns implementation

Let’s design the control’s template:

XAML
<Style TargetType="{x:Type local:MillerView}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:MillerView}">
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

MillerView needs a container for columns. These columns will be list boxes or list views, the container should be horizontally scrollable. I tried to use a ListBox as the container but stumbled into many small issues. Mitigating these issues took too much effort and made the project hard to manage and support. So I went to ItemsControl as the container. This container is going to be a named part of the control:

XAML
<Style TargetType="{x:Type local:MillerView}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:MillerView}">
                <ItemsControl x:Name="PART_Columns">
                </ItemsControl>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Now add name of the part to the MillerView class:

C#
[TemplatePart(Name = "PART_Columns", Type = typeof(ItemsControl))]
public class MillerView : Control
{
    . . . . .
}

Now, I introduce a field visualColumns and attach this field to the named part by overriding OnApplyTemplate() method:

C#
private ItemsControl visualColumns;

public override void OnApplyTemplate()
{
    if(null != visualColumns)
    {
    }
    visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
    if(null != visualColumns)
    {
    }
}

About null checks. They are going to be filled soon. The first one is required for clean up – during control’s lifetime, the OnApplyTemplate() method may be called more than once.

Now, the important part. Our columns are going to be Selector class descendants. This includes ListBox and ListView classes. Such classes inherit the ItemsSource dependency property that maps collections to lists. ListBox and ListView utilize DataTemplate classes to define their item properties but DataTemplate doesn’t know about child collections our global view model has. TreeView uses HierarchicalDataTemplate instead. I don’t think it’s possible in the case of Miller columns.

So I create my own column model class, as nested class of MillerView:

C#
private sealed class Column
{
    public IEnumerable ColumnItems
    {
        get;
        set;
    }
}

and add a list of these view models:

C#
private readonly ObservableCollection<Column> dataColumns;

public MillerView()
{
    dataColumns = new ObservableCollection<Column>();
}

Now this dataColumns model should become an items source for our columns container. Modify our OnApplyTemplate() method:

C#
public override void OnApplyTemplate()
{
    if(null != visualColumns)
    {
        visualColumns.ItemsSource = null;
    }
    visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
    if(null != visualColumns)
    {
        visualColumns.ItemsSource = dataColumns;
    }
}

Now we need to add and remove Column items to dataColumns collection and the binding system will create and destroy columns for us.

However, we need to have access to view model data somehow. I create a dependency property ItemsSource in MillerView but with a twist – instead of providing full implementation, I use ItemsSourceProperty from ItemsControl class. If the next version of WPF changes this property, somehow these changes will automatically propagate to MillerView:

C#
public IEnumerable ItemsSource
{
    get => (IEnumerable)GetValue(ItemsSourceProperty);
    set => SetValue(ItemsSourceProperty, value);
}

public static readonly DependencyProperty ItemsSourceProperty = 
                       ItemsControl.ItemsSourceProperty.AddOwner(
    typeof(MillerView),
    new FrameworkPropertyMetadata
    (null, (d, e) => ((MillerView)d).OnItemsSourceChanged(e.OldValue, e.NewValue)));

private void OnItemsSourceChanged(object oldValue, object newValue)
{
    dataColumns.Clear();
    PopulateItemsSource(newValue);
}

Oh, I forgot two details. I replace property metadata specifically for MillerView and there is a method PopulateItemsSource() I need to implement. Here is how:

C#
private void PopulateItemsSource(object newItems)
{
    if(null != newItems)
    {
        var newList = newItems as IEnumerable;
        if(!IsEmpty(newList))
        {
            dataColumns.Add(new Column { ColumnItems = newList });
        }
    }
}

This IsEmpty() method checks if an IEnumerable is empty or not. I could use LINQ for such a test, but here is the implementation:

C#
private static bool IsEmpty(IEnumerable list)
{
    if(null != list)
    {
        IEnumerator enumerator = list.GetEnumerator();
        bool result = !enumerator.MoveNext();
        if(enumerator is IDisposable disposable)
        {
            disposable.Dispose();
        }
        return result;
    }
    else
    {
        return false;
    }
}

Control class declares some properties for its inheritors, including Background, BorderBush and BorderThickness. TreeView, as you can see running our test program, uses these properties to draw a border around its work area. Let’s do the same for MillerView by modifying the control’s template. Also provide binding for ItemSource property:

XAML
<Style TargetType="{x:Type local:MillerView}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:MillerView}">
                <Border Background="{TemplateBinding Background}" 
                 BorderBrush="{TemplateBinding BorderBrush}" 
                 BorderThickness="{TemplateBinding BorderThickness}">
                    <ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}">
                    </ItemsControl>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Now, we can bind MillerView to the same collection as our TreeView. I also painted the view a bit:

XAML
<local:MillerView Grid.Row="1" ItemsSource="{Binding}" 
 BorderThickness="1" BorderBrush="{DynamicResource 
 {x:Static SystemColors.MenuHighlightBrushKey}}">
</local:MillerView>

Here’s what our application looks like now:

Test application, take 2

WPF doesn’t know how to present MillerView on screen. Let’s provide this information with an item template.

The property goes first:

C#
public static DataTemplate GetItemTemplate(DependencyObject obj)
{
              return (DataTemplate)obj.GetValue(ItemTemplateProperty);
}

public static void SetItemTemplate(DependencyObject obj, DataTemplate value)
{
              obj.SetValue(ItemTemplateProperty, value);
}

public static readonly DependencyProperty ItemTemplateProperty = 
              ItemsControl.ItemTemplateProperty.AddOwner(
              typeof(MillerView));

I use the DependencyProperty.AddOwner() method again.

Now add this property to the template:

XAML
<ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}" 
 ItemTemplate="{TemplateBinding ItemTemplate}">
</ItemsControl>

And provide a default value for this property:

XAML
<Setter Property="ItemTemplate" >
    <Setter.Value>
        <DataTemplate>
            <ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}" 
             local:MillerView.ColumnItemChildren="{Binding SelectedItem.Children, 
             RelativeSource={RelativeSource Self}}" />
        </DataTemplate>
    </Setter.Value>
</Setter>

Oops! That wouldn’t compile, the system doesn't know MillerView.ColumnItemChildren attached property. Let’s add it:

C#
public static IEnumerable GetColumnItemChildren(DependencyObject obj)
{
    return (IEnumerable)obj.GetValue(ColumnItemChildrenProperty);
}

public static void SetColumnItemChildren(DependencyObject obj, IEnumerable value)
{
    obj.SetValue(ColumnItemChildrenProperty, value);
}

public static readonly DependencyProperty ColumnItemChildrenProperty = 
                                          DependencyProperty.RegisterAttached(
    "ColumnItemChildren",
    typeof(IEnumerable),
    typeof(MillerView),
    new PropertyMetadata(null, OnColumnItemChildrenChanged));

private static void OnColumnItemChildrenChanged
               (DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var selector = (Selector)d;
    MillerView millerView = GetMillerView(selector);
    if(null != millerView)
    {
        millerView.OnColumnItemChildrenChanged(selector, e.OldValue, e.NewValue);
    }
}

private void OnColumnItemChildrenChanged
        (Selector selector, object oldValue, object newValue)
{
    RemoveColumns(selector);
    PopulateItemsSource(newValue);
}

This property is a full attached property that allows me to provide data property names for each column through XAML and avoid conventions or hard coded names.

Still won’t compile, I need to implement two utility methods: GetMillerView() and RemoveColumns(). Both are quite simple – look up the visual tree for our control and just remove «extra» items from the dataColumns collection respectively:

C#
private static MillerView GetMillerView(Selector selector)
{
    DependencyObject current = selector;
    while(null != current)
    {
        if(current is MillerView millerView)
        {
            return millerView;
        }
        current = VisualTreeHelper.GetParent(current);
    }
    return null;
}

private void RemoveColumns(Selector selector)
{
    int count = dataColumns.Count;
    for(int i = count - 1; i > 0; i--)
    {
        if(dataColumns[i].ColumnItems == selector.ItemsSource)
        {
            break;
        }
        dataColumns.RemoveAt(i);
    }
}

Oh, and one more thing – add a new field to the control:

C#
private Selector selectedSelector;

In the default template, the name of the property bound to MillerView.ColumnItemChildren is Children, but the actual name of our DataNode class is SubNodes. XAML to the rescue, I modify item template of our control in MainWindow.xaml file:

XAML
<local:MillerView Grid.Row="1" ItemsSource="{Binding}" 
 BorderThickness="1" BorderBrush="{DynamicResource 
 {x:Static SystemColors.MenuHighlightBrushKey}}">
    <local:MillerView.ItemTemplate>
        <DataTemplate>
            <ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}" 
             local:MillerView.ColumnItemChildren="{Binding SelectedItem.SubNodes, 
             RelativeSource={RelativeSource Self}}" >
                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding Title}" />
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>
        </DataTemplate>
    </local:MillerView.ItemTemplate>
</local:MillerView>

At the same time, I bound ListBox items to the Title property of our DataNode class. Let’s take a look at the result:

Initial project

Close. I need to make the main items control horizontal and make all child list boxes of the same height. Let’s modify the control’s style:

XAML
<Style TargetType="{x:Type local:MillerView}">
    <Setter Property="ItemTemplate" >
        <Setter.Value>
            <DataTemplate>
                <ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}" 
                 local:MillerView.ColumnItemChildren="{Binding SelectedItem.Children, 
                 RelativeSource={RelativeSource Self}}" />
            </DataTemplate>
        </Setter.Value>
    </Setter>
    <Setter Property="ItemsPanel">
        <Setter.Value>
            <ItemsPanelTemplate>
                <StackPanel IsItemsHost="True" Orientation="Horizontal" />
            </ItemsPanelTemplate>
        </Setter.Value>
    </Setter>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:MillerView}">
                <Border Background="{TemplateBinding Background}" 
                 BorderBrush="{TemplateBinding BorderBrush}" 
                 BorderThickness="{TemplateBinding BorderThickness}">
                    <ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}" 
                     ItemTemplate="{TemplateBinding ItemTemplate}" 
                     ItemsPanel="{TemplateBinding ItemsPanel}" >
                        <ItemsControl.Template>
                            <ControlTemplate>
                                <ScrollViewer HorizontalScrollBarVisibility="Auto">
                                    <ItemsPresenter />
                                </ScrollViewer>
                            </ControlTemplate>
                        </ItemsControl.Template>
                    </ItemsControl>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

And I need to «borrow» from ItemsControl one more property ItemsPanel:

C#
public static ItemsPanelTemplate GetItemsPanel(DependencyObject obj)
{
    return (ItemsPanelTemplate)obj.GetValue(ItemsPanelProperty);
}

public static void SetItemsPanel(DependencyObject obj, ItemsPanelTemplate value)
{
    obj.SetValue(ItemsPanelProperty, value);
}

public static readonly DependencyProperty ItemsPanelProperty = 
                       ItemsControl.ItemsPanelProperty.AddOwner(
    typeof(MillerView));

That’s how the control looks like now:

Initial project

You can click on columns’ items, the control would show child node collections, if they are present. If you go down the hierarchy and then go back, columns disappear... But wait:

Initial project

If you start from the first item of the first column, go deep and then click on the first item of the first column, nothing happens. Why? Because from the point of view of the binding system, nothing changes. I need to fix it.

Step 3

Fix the last pesky issue.

First, we need to intercept mouse clicks on columns. I decided to use «mouse up» event because it comes after other mouse buttons events:

C#
public override void OnApplyTemplate()
{
    if(null != visualColumns)
    {
        visualColumns.MouseUp -= VisualColumns_MouseUp;
        visualColumns.ItemsSource = null;
    }

    selectedSelector = null;
    visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
    if(null != visualColumns)
    {
        visualColumns.ItemsSource = dataColumns;
        visualColumns.MouseUp += VisualColumns_MouseUp;
    }
}

private void VisualColumns_MouseUp(object sender, MouseButtonEventArgs e)
{
}

Now I need to find the column where mouse is clicked:

  • Take the mouse position.
  • Translate it into local coordinates.
  • Take the element below mouse cursor.
  • Go up the visual tree until I find the parent selector.

Let’s implement this:

C#
private static Selector FindParentSelector(DependencyObject currentElement)
{
    while(null != currentElement)
    {
        if(currentElement is Selector selector)
        {
            return selector;
        }
        currentElement = VisualTreeHelper.GetParent(currentElement);
    }
    return null;
}

Now find the selector the VisualColumns_MouseUp method:

C#
Point relativeToColumnsSet = e.GetPosition(visualColumns);
Selector currentSelector = FindParentSelector
         (visualColumns.InputHitTest(relativeToColumnsSet) as DependencyObject);

Then I need to check the data:

C#
if(null == currentSelector)
{
    return;
}

If user clicks outside of any column, do nothing.

C#
if(null == selectedSelector)
{
    selectedSelector = currentSelector;
    return;
}

If the selectedSelector field is null, it means the click is received for the first time. I then set it to the current selector and again, our work is done.

Now the interesting part. These selectors contain references to our internal view model objects of type Column. If two selectors refer to the same view model, I consider them equal:

C#
if(ReferenceEquals(selectedSelector.DataContext, currentSelector.DataContext))
{
    return;
}

The last check is to compare view models indices in dataColumns collection. To do this, I need first to find them:

C#
var selectedColumn = selectedSelector.DataContext 
    as Column; // NB: DataContext may contain something other than a Column here
var currentColumn = (Column)currentSelector.DataContext;
int selectedColumnIndex = dataColumns.IndexOf(selectedColumn);
int currentColumnIndex = dataColumns.IndexOf(currentColumn);

Again, there is a catch – I use IndexOf() methods, they in turn use Equals() methods of collection items. By default, Equals() compares objects by references but I need to compare ColumnItems properties, even if also by references. To solve this, let’s override Equals() and GetHashCode() methods for our Column class:

C#
public override bool Equals(object obj)
{
    return obj is Column column &&
        EqualityComparer<IEnumerable>.Default.Equals(ColumnItems, column.ColumnItems);
}

public override int GetHashCode()
{
    return 939713329 + EqualityComparer<IEnumerable>.Default.GetHashCode(ColumnItems);
}

Here I used VS’ standard code generator.

Then check and, if currentColumnIndex is less than selectedColumnIndex, remove obsolete columns from the collection:

C#
if(currentColumnIndex < selectedColumnIndex)
{
    for(int i = dataColumns.Count - 1; i > currentColumnIndex + 1; i--)
    {
        dataColumns.RemoveAt(i);
    }
    Selector nextSelector = FindChildSelector
    (visualColumns.ItemContainerGenerator.ContainerFromItem
    (dataColumns[currentColumnIndex + 1]));
    if(null != nextSelector)
    {
        nextSelector.SelectedIndex = -1;
    }
}

Note the highlighted line. If there is a column at the right of the current one, its item selection should disappear.

Before completing the implementation, I need to implement FindChildSelector() method:

C#
private static Selector FindChildSelector(DependencyObject rootElement)
{
    if(rootElement is Selector selector1)
    {
        return selector1;
    }
    // Breadth first search
    for(int i = 0; i < VisualTreeHelper.GetChildrenCount(rootElement); i++)
    {
        DependencyObject currentElement = VisualTreeHelper.GetChild(rootElement, i);
        if(currentElement is Selector selector2)
        {
            return selector2;
        }
    }
    for(int i = 0; i < VisualTreeHelper.GetChildrenCount(rootElement); i++)
    {
        DependencyObject currentElement = VisualTreeHelper.GetChild(rootElement, i);
        Selector selector3 = FindChildSelector(currentElement);
        if(null != selector3)
        {
            return selector3;
        }
    }
    return null;
}

The last step of out event handler implementation is selectedSelector assignment:

C#
selectedSelector = currentSelector;

Now compile and run the application and see how it works.

Finished application

Conclusion

Presented in this article, Miller columns implementation is just a basic sample. There are a lot of easy to do tasks like:

  • Make control’s design better by modifying its default template – add vertical scroll bars to individual columns instead of the ItemsContol.
  • Add properties that expose selected column and its selected item.
  • Add events notifying about user interactions.
  • Many more improvements.

However, I wanted to introduce a useful UI element upon which you can create your own advanced solutions.

Thank you for your attention and time!

History

  • 31st December, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under A Public Domain dedication