WPF TreeView with Multi Select

2
November 22, 2015 // wpf

The WPF TreeView is very powerful, but out of the box it doesn’t support selecting multiple items at once.  By multiple selection I mean holding down the control key or shift key to select a set of items, similar to behavior available in other controls such as Windows Explorer.

To support enhancements like dragging and dropping multiple items at once to CodeMaid‘s Spade tool window, we wanted this capability to multi select.  There’s several different solutions out there, and in particular our implementation is largely based on the great work of Cristoph Gattnar available here.

Christoph’s solution utilizes attached properties.  Our solution converted this to a behavior and added support for arrow keys and the space bar for more keyboard navigation scenarios.

Here’s a little example of it working in action:

Multi-Select

Multi-Select

The living code can be found within CodeMaid’s open source repository here.

And just in case, here’s a static snapshot of the code:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interactivity;

namespace SteveCadwallader.CodeMaid.UI
{
    ///

<summary>
    /// A behavior that extends a <see cref="TreeView"/> with multiple selection capabilities.
    /// </summary>


    /// <remarks>
    /// Largely based on http://chrigas.blogspot.com/2014/08/wpf-treeview-with-multiple-selection.html
    /// </remarks>
    public class TreeViewMultipleSelectionBehavior : Behavior<TreeView>
    {
        #region SelectedItems (Public Dependency Property)

        ///

<summary>
        /// The dependency property definition for the SelectedItems property.
        /// </summary>


        public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
            "SelectedItems", typeof(IList), typeof(TreeViewMultipleSelectionBehavior));

        ///

<summary>
        /// Gets or sets the selected items.
        /// </summary>


        public IList SelectedItems
        {
            get { return (IList)GetValue(SelectedItemsProperty); }
            set { SetValue(SelectedItemsProperty, value); }
        }

        #endregion SelectedItems (Public Dependency Property)

        #region AnchorItem (Private Dependency Property)

        ///

<summary>
        /// The dependency property definition for the AnchorItem property.
        /// </summary>


        private static readonly DependencyProperty AnchorItemProperty = DependencyProperty.Register(
            "AnchorItem", typeof(TreeViewItem), typeof(TreeViewMultipleSelectionBehavior));

        ///

<summary>
        /// Gets or sets the anchor item.
        /// </summary>


        private TreeViewItem AnchorItem
        {
            get { return (TreeViewItem)GetValue(AnchorItemProperty); }
            set { SetValue(AnchorItemProperty, value); }
        }

        #endregion AnchorItem (Private Dependency Property)

        #region IsItemSelected (TreeViewItem Attached Property)

        ///

<summary>
        /// The dependency property definition for the IsItemSelected attached property.
        /// </summary>


        public static readonly DependencyProperty IsItemSelectedProperty = DependencyProperty.RegisterAttached(
            "IsItemSelected", typeof(bool), typeof(TreeViewMultipleSelectionBehavior),
            new FrameworkPropertyMetadata(OnIsItemSelectedChanged));

        ///

<summary>
        /// Gets the IsItemSelected value from the specified target.
        /// </summary>


        /// <param name="target">The target.</param>
        /// <returns>The value.</returns>
        public static bool GetIsItemSelected(TreeViewItem target)
        {
            return (bool)target.GetValue(IsItemSelectedProperty);
        }

        ///

<summary>
        /// Sets the IsItemSelected value on the specified target.
        /// </summary>


        /// <param name="target">The target.</param>
        /// <param name="value">The value.</param>
        public static void SetIsItemSelected(TreeViewItem target, bool value)
        {
            target.SetValue(IsItemSelectedProperty, value);
        }

        ///

<summary>
        /// Called when the IsItemSelected dependency property has changed.
        /// </summary>


        /// <param name="obj">The dependency object where the value has changed.</param>
        /// <param name="e">The <see cref="System.Windows.DependencyPropertyChangedEventArgs"/> instance containing the event data.</param>
        private static void OnIsItemSelectedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            var treeViewItem = obj as TreeViewItem;
            var treeView = treeViewItem?.FindVisualAncestor<TreeView>();
            if (treeView != null)
            {
                var behavior = Interaction.GetBehaviors(treeView).OfType<TreeViewMultipleSelectionBehavior>().FirstOrDefault();
                var selectedItems = behavior?.SelectedItems;
                if (selectedItems != null)
                {
                    if (GetIsItemSelected(treeViewItem))
                    {
                        selectedItems.Add(treeViewItem.DataContext);
                    }
                    else
                    {
                        selectedItems.Remove(treeViewItem.DataContext);
                    }
                }
            }
        }

        #endregion IsItemSelected (TreeViewItem Attached Property)

        #region Behavior

        ///

<summary>
        /// Called after the behavior is attached to an AssociatedObject.
        /// </summary>


        protected override void OnAttached()
        {
            base.OnAttached();

            AssociatedObject.AddHandler(UIElement.KeyDownEvent, new KeyEventHandler(OnTreeViewItemKeyDown), true);
            AssociatedObject.AddHandler(UIElement.MouseLeftButtonUpEvent, new MouseButtonEventHandler(OnTreeViewItemMouseUp), true);
        }

        ///

<summary>
        /// Called when the behavior is being detached from its AssociatedObject, but before it has
        /// actually occurred.
        /// </summary>


        protected override void OnDetaching()
        {
            base.OnDetaching();

            AssociatedObject.RemoveHandler(UIElement.KeyDownEvent, new KeyEventHandler(OnTreeViewItemKeyDown));
            AssociatedObject.RemoveHandler(UIElement.MouseLeftButtonUpEvent, new MouseButtonEventHandler(OnTreeViewItemMouseUp));
        }

        #endregion Behavior

        #region Event Handlers

        ///

<summary>
        /// Called when a TreeViewItem receives a key down event.
        /// </summary>


        /// <param name="sender">The sender.</param>
        /// <param name="e">
        /// The <see cref="System.Windows.Input.KeyEventArgs"/> instance containing the event data.
        /// </param>
        private void OnTreeViewItemKeyDown(object sender, KeyEventArgs e)
        {
            var treeViewItem = e.OriginalSource as TreeViewItem;
            if (treeViewItem != null)
            {
                TreeViewItem targetItem = null;

                switch (e.Key)
                {
                    case Key.Down:
                        targetItem = GetRelativeItem(treeViewItem, 1);
                        break;

                    case Key.Space:
                        if (Keyboard.Modifiers == ModifierKeys.Control)
                        {
                            ToggleSingleItem(treeViewItem);
                        }
                        break;

                    case Key.Up:
                        targetItem = GetRelativeItem(treeViewItem, -1);
                        break;
                }

                if (targetItem != null)
                {
                    switch (Keyboard.Modifiers)
                    {
                        case ModifierKeys.Control:
                            Keyboard.Focus(targetItem);
                            break;

                        case ModifierKeys.Shift:
                            SelectMultipleItemsContinuously(targetItem);
                            break;

                        case ModifierKeys.None:
                            SelectSingleItem(targetItem);
                            break;
                    }
                }
            }
        }

        ///

<summary>
        /// Called when a TreeViewItem receives a mouse up event.
        /// </summary>


        /// <param name="sender">The sender.</param>
        /// <param name="e">
        /// The <see cref="System.Windows.Input.MouseButtonEventArgs"/> instance containing the
        /// event data.
        /// </param>
        private void OnTreeViewItemMouseUp(object sender, MouseButtonEventArgs e)
        {
            var treeViewItem = FindParentTreeViewItem(e.OriginalSource);
            if (treeViewItem != null)
            {
                switch (Keyboard.Modifiers)
                {
                    case ModifierKeys.Control:
                        ToggleSingleItem(treeViewItem);
                        break;

                    case ModifierKeys.Shift:
                        SelectMultipleItemsContinuously(treeViewItem);
                        break;

                    default:
                        SelectSingleItem(treeViewItem);
                        break;
                }
            }
        }

        #endregion Event Handlers

        #region Methods

        ///

<summary>
        /// Selects a range of consecutive items from the specified tree view item to the anchor (if exists).
        /// </summary>


        /// <param name="treeViewItem">The triggering tree view item.</param>
        public void SelectMultipleItemsContinuously(TreeViewItem treeViewItem)
        {
            if (AnchorItem != null)
            {
                if (ReferenceEquals(AnchorItem, treeViewItem))
                {
                    SelectSingleItem(treeViewItem);
                    return;
                }

                var isBetweenAnchors = false;
                var items = DeSelectAll();

                foreach (var item in items)
                {
                    if (ReferenceEquals(item, treeViewItem) || ReferenceEquals(item, AnchorItem))
                    {
                        // Toggle isBetweenAnchors when first item is found, and back again when last item is found.
                        isBetweenAnchors = !isBetweenAnchors;

                        SetIsItemSelected(item, true);
                    }
                    else if (isBetweenAnchors)
                    {
                        SetIsItemSelected(item, true);
                    }
                }
            }
        }

        ///

<summary>
        /// Selects the specified tree view item, removing any other selections.
        /// </summary>


        /// <param name="treeViewItem">The triggering tree view item.</param>
        public void SelectSingleItem(TreeViewItem treeViewItem)
        {
            DeSelectAll();
            SetIsItemSelected(treeViewItem, true);
            AnchorItem = treeViewItem;
        }

        ///

<summary>
        /// Toggles the selection state of the specified tree view item.
        /// </summary>


        /// <param name="treeViewItem">The triggering tree view item.</param>
        public void ToggleSingleItem(TreeViewItem treeViewItem)
        {
            SetIsItemSelected(treeViewItem, !GetIsItemSelected(treeViewItem));

            if (AnchorItem == null)
            {
                if (GetIsItemSelected(treeViewItem))
                {
                    AnchorItem = treeViewItem;
                }
            }
            else if (SelectedItems.Count == 0)
            {
                AnchorItem = null;
            }
        }

        ///

<summary>
        /// Clears all selections.
        /// </summary>


        /// <remarks>
        /// The list of all items is returned as a convenience to avoid multiple iterations.
        /// </remarks>
        /// <returns>The list of all items.</returns>
        private IEnumerable<TreeViewItem> DeSelectAll()
        {
            var items = GetItemsRecursively<TreeViewItem>(AssociatedObject);
            foreach (var item in items)
            {
                SetIsItemSelected(item, false);
            }

            return items;
        }

        ///

<summary>
        /// Attempts to find the parent TreeViewItem from the specified event source.
        /// </summary>


        /// <param name="eventSource">The event source.</param>
        /// <returns>The parent TreeViewItem, otherwise null.</returns>
        private static TreeViewItem FindParentTreeViewItem(object eventSource)
        {
            var source = eventSource as DependencyObject;

            var treeViewItem = source?.FindVisualAncestor<TreeViewItem>();

            return treeViewItem;
        }

        ///

<summary>
        /// Gets items of the specified type recursively from the specified parent item.
        /// </summary>


        /// <typeparam name="T">The type of item to retrieve.</typeparam>
        /// <param name="parentItem">The parent item.</param>
        /// <returns>The list of items within the parent item, may be empty.</returns>
        private static IList<T> GetItemsRecursively<T>(ItemsControl parentItem)
            where T : ItemsControl
        {
            if (parentItem == null)
            {
                throw new ArgumentNullException(nameof(parentItem));
            }

            var items = new List<T>();

            for (int i = 0; i < parentItem.Items.Count; i++)
            {
                var item = parentItem.ItemContainerGenerator.ContainerFromIndex(i) as T;
                if (item != null)
                {
                    items.Add(item);
                    items.AddRange(GetItemsRecursively<T>(item));
                }
            }

            return items;
        }

        ///

<summary>
        /// Gets an item with a relative position (e.g. +1, -1) to the specified item.
        /// </summary>


        /// <remarks>This deliberately works against a flattened collection (i.e. no hierarchy).</remarks>
        /// <typeparam name="T">The type of item to retrieve.</typeparam>
        /// <param name="item">The item.</param>
        /// <param name="relativePosition">The relative position offset (e.g. +1, -1).</param>
        /// <returns>The item in the relative position, otherwise null.</returns>
        private T GetRelativeItem<T>(T item, int relativePosition)
            where T : ItemsControl
        {
            if (item == null)
            {
                throw new ArgumentNullException(nameof(item));
            }

            var items = GetItemsRecursively<T>(AssociatedObject);
            int index = items.IndexOf(item);
            if (index >= 0)
            {
                var relativeIndex = index + relativePosition;
                if (relativeIndex >= 0 && relativeIndex < items.Count)
                {
                    return items[relativeIndex];
                }
            }

            return null;
        }

        #endregion Methods
    }
}

About the author

Steve Cadwallader is a software developer who geeks out on user interfaces, clean code and making things easier.

2 Comments

  1. Hey Steve,

    This works fine except for one issue I am having.
    In my view I have it set up as:

    With SelectedItems being an ObservableCollection on my viewmodel.
    The binding seems to only work in one direction though, since if I add items to the collection in my viewModel the behaviour’s IList isnt updated to match.

    Any suggestions?

    • Hey Bram –

      Unfortunately it looks like your code was formatted away, you may need to wrap it in <code> or <pre> tags.

      In my case I’m using it against a List (vs. ObservableCollection). I do have a scenario where the List is cleared in the ViewModel and it works as expected, but it’s part of refreshing the whole control so it’s possible that is masking a two-way binding issue.

      Some of the usual binding debugging tricks I try:

      1. Explicitly specify Mode=TwoWay on the bindings. Rarely the problem since that’s generally the default, but worth confirming.
      2. Setup a PropertyChanged handler on the DependencyProperty, to see if it is firing at all when you expect.
      3. Explicitly raise INotifyPropertyChanged events to confirm the control is receiving events.
      4. Look at the depth of the binding stack, usually the deeper they are the more prone to error they become. If it is hopping through a few levels, check each level to see where it is being dropped.

      No silver bullet, but I hope it helps. If you do figure it out or have more questions please post back to help others who might stumble upon it.

      Thanks,
      -Steve

Leave a Comment