Thursday, August 21, 2014

WPF TreeView with Multiple Selection

With the WPF TreeView it is not possible to select multiple items. But it can be easily extended. Therefore an Attached Property can be used. First we are adding the used namespace to the control.
xmlns:e="clr-namespace:MultipleSelectionTreeView;
                                            assembly=MultipleSelectionTreeView"
Then we can add the Attached Property to the TreeView. With IsMultipleSelection="True" we activate the multiple selection in the TreeView. With the property SelectedItems we can bind the multiple selected items to a property of the DataContext. We can define the style of a multiple selected TreeViewItem within a Style Trigger. Therefore the property IsItemSelected is used.
<TreeView ItemsSource="{Binding Elements}"
          MinHeight="20"
          e:TreeViewMultipleSelectionAttached.IsMultipleSelection="True"
          e:TreeViewMultipleSelectionAttached.SelectedItems=
                                                   "{Binding SelectedElements}">
    <TreeView.Resources>
        <Style x:Key="{x:Type TreeViewItem}" BasedOn="{StaticResource 
                                                        {x:Type TreeViewItem}}" 
                                                        TargetType=
                                                        "{x:Type TreeViewItem}">
            <Style.Triggers>
                <Trigger 
                   Property="e:TreeViewMultipleSelectionAttached.IsItemSelected"
                         Value="True">
                    <Setter Property="Background"
                            Value="LightGreen" />
                </Trigger>
            </Style.Triggers>
        </Style>
        <HierarchicalDataTemplate DataType="{x:Type e:Element}"
                                  ItemsSource="{Binding ChildElements}">
            <StackPanel Orientation="Horizontal"
                        VerticalAlignment="Stretch"
                        Margin="0,2,0,2">
                <TextBlock Text="{Binding ElementName}"
                           Margin="5,0,0,0"
                           VerticalAlignment="Center" />
            </StackPanel>
        </HierarchicalDataTemplate>
        <HierarchicalDataTemplate DataType="{x:Type e:ChildElement}">
            <StackPanel Orientation="Horizontal"
                        VerticalAlignment="Stretch"
                        Margin="0,2,0,2">
                <TextBlock Text="{Binding ElementName}"
                           VerticalAlignment="Center"
                           Margin="5,0,0,0" />
            </StackPanel>
        </HierarchicalDataTemplate>
    </TreeView.Resources>
</TreeView>
Now have a look at the implementation of the Attached Property. To create a custom Attached Property a static DependencyProperty is defined by using the RegisterAttached method. This method has the possibility to set a PropertyMetadata as parameter in that a PropertyChangedCallback can be defined by using Delegates. In the callback method an event handler is added or removed to DependencyObject. First we define the IsMultipleSelection property. With that a TreeView is marked in XAML to have multiple selection abilities.
    public static readonly DependencyProperty IsMultipleSelectionProperty =
        DependencyProperty.RegisterAttached(
            "IsMultipleSelection",
            typeof(Boolean),
            typeof(TreeViewMultipleSelectionAttached),
            new PropertyMetadata(false, OnMultipleSelectionPropertyChanged));
 
    public static bool GetIsMultipleSelection(TreeView element)
    {
        return (bool)element.GetValue(IsMultipleSelectionProperty);
    }
 
    public static void SetIsMultipleSelection(TreeView element, Boolean value)
    {
        element.SetValue(IsMultipleSelectionProperty, value);
    }
In the RegisterAttached method we have defined a PropertyMetadata object that defines a call back method (OnMultipleSelectionPropertyChanged) that is called if the property has changed. In that method a handler for the MouseLeftButtonDownEvent of TreeViewItem is added or removed to the TreeView. Remember to mark the handledEventsToo parameter in the AddHandler method as true to allow calling the handler OnTreeViewItemClicked correctly.
    private static void OnMultipleSelectionPropertyChanged(DependencyObject d,
                                         DependencyPropertyChangedEventArgs e)
    {
        TreeView treeView = d as TreeView;
 
        if (treeView != null)
        {
            if (e.NewValue is bool)
            {
                if ((bool)e.NewValue)
                {
                    treeView.AddHandler(TreeViewItem.MouseLeftButtonDownEvent,
                      new MouseButtonEventHandler(OnTreeViewItemClicked), true);
                }
                else
                {
                   treeView.RemoveHandler(TreeViewItem.MouseLeftButtonDownEvent,
                        new MouseButtonEventHandler(OnTreeViewItemClicked));
                }
            }
        }
    }
The OnTreeViewItemClicked method is now called on each click at a TreeViewItem if IsMultipleSelection for a TreeView is set to true.
    private static void OnTreeViewItemClicked(object sender, MouseButtonEventArgs e)
    {
        TreeViewItem treeViewItem = FindTreeViewItem(
                                        e.OriginalSource as DependencyObject);
        TreeView treeView = sender as TreeView;
 
        if (treeViewItem != null && treeView != null)
        {
            if (Keyboard.Modifiers == ModifierKeys.Control)
            {
                SelectMultipleItemsRandomly(treeView, treeViewItem);
            }
            else if (Keyboard.Modifiers == ModifierKeys.Shift)
            {
                SelectMultipleItemsContinuously(treeView, treeViewItem);
            }
            else
            {
                SelectSingleItem(treeView, treeViewItem);
            }
        }
    }
First we need to find the TreeViewItem that is invoked with the click. This happens with the recursive FindTreeViewItem method.
    private static TreeViewItem FindTreeViewItem(DependencyObject dependencyObject)
    {
        if (dependencyObject == null)
        {
            return null;
        }
 
        TreeViewItem treeViewItem = dependencyObject as TreeViewItem;
        if (treeViewItem != null)
        {
            return treeViewItem;
        }
 
        return FindTreeViewItem(VisualTreeHelper.GetParent(dependencyObject));
    }
Then the OnTreeViewItemClicked method checks what kind of click is happening. There are Left-Mouse-Button (LMB) + Ctrl, LMB + Shift, and all other cases with LMB. In the third case a single click is performed.
    private static void SelectSingleItem(TreeView treeView,
                                                    TreeViewItem treeViewItem)
    {
        // first deselect all items
        DeSelectAllItems(treeView, null);
        SetIsItemSelected(treeViewItem, true);
        SetStartItem(treeView, treeViewItem);
    }
Therefore at first all items are deselected by calling the recursive method DeSelectAllItems. 
    private static void DeSelectAllItems(TreeView treeView,
                                                 TreeViewItem treeViewItem)
    {
        if (treeView != null)
        {
            for (int i = 0; i < treeView.Items.Count; i++)
            {
                TreeViewItem item = treeView.ItemContainerGenerator.
                                           ContainerFromIndex(i) as TreeViewItem;
                if (item != null)
                {
                    SetIsItemSelected(item, false);
                    DeSelectAllItems(null, item);
                }
            } 
        }
        else
        {
            for (int i = 0; i < treeViewItem.Items.Count; i++)
            {
                TreeViewItem item = treeViewItem.ItemContainerGenerator.
                                           ContainerFromIndex(i) as TreeViewItem;
                if (item != null)
                {
                    SetIsItemSelected(item, false);
                    DeSelectAllItems(null, item);
                }
            } 
        }
    }
Selection and deselection is happening by the DependencyProperty IsItemSelected that we have also defined. This property is used in XAML to apply a certain style on the selected TreeViewItems.
    public static readonly DependencyProperty IsItemSelectedProperty =
        DependencyProperty.RegisterAttached(
            "IsItemSelected",
            typeof(Boolean),
            typeof(TreeViewMultipleSelectionAttached),
            new PropertyMetadata(false, OnIsItemSelectedPropertyChanged));
 
    public static bool GetIsItemSelected(TreeViewItem element)
    {
        return (bool)element.GetValue(IsItemSelectedProperty);
    }
 
    public static void SetIsItemSelected(TreeViewItem element, Boolean value)
    {
        element.SetValue(IsItemSelectedProperty, value);
    }
In the RegisterAttached method we have defined a PropertyMetadata object that defines a call back method (OnIsItemSelectedPropertyChanged) that is called if the property (selection of the TreeViewItem) has changed. In that method the Header of TreeViewItem will be added to or removed to a List of all selected TreeViewItems. This List is associated with the corresponding TreeView.
    private static void OnIsItemSelectedPropertyChanged(DependencyObject d,
                                           DependencyPropertyChangedEventArgs e)
    {
        TreeViewItem treeViewItem = d as TreeViewItem;
        TreeView treeView = FindTreeView(treeViewItem);
        if (treeViewItem != null && treeView != null)
        {
            var selectedItems = GetSelectedItems(treeView);
            if (selectedItems != null)
            {
                if (GetIsItemSelected(treeViewItem))
                {
                    selectedItems.Add(treeViewItem.Header);
                }
                else
                {
                    selectedItems.Remove(treeViewItem.Header);
                }
            }
        }
    }
To find the TreeView corresponding to the TreeViewItem the recursive method FindTreeView is used.
    private static TreeView FindTreeView(DependencyObject dependencyObject)
    {
        if (dependencyObject == null)
        {
            return null;
        }
 
        TreeView treeView = dependencyObject as TreeView;
        if (treeView != null)
        {
            return treeView;
        }
 
        return FindTreeView(VisualTreeHelper.GetParent(dependencyObject));
    }
To associate the List of all selected TreeViewItems with the corresponding TreeView the DependencyProperty SelectedItems is used. This property can be used in XAML to bind the multiple selected items to a property of the DataContext.
    public static readonly DependencyProperty SelectedItemsProperty =
        DependencyProperty.RegisterAttached(
            "SelectedItems",
            typeof(IList),
            typeof(TreeViewMultipleSelectionAttached),
            new PropertyMetadata());
 
    public static IList GetSelectedItems(TreeView element)
    {
        return (IList)element.GetValue(SelectedItemsProperty);
    }
 
    public static void SetSelectedItems(TreeView element, IList value)
    {
        element.SetValue(SelectedItemsProperty, value);
    }
Now let's go back to the SelectSingleItem method. After deselecting all TreeViewItems by setting the IsItemSelected DependencyProperty to false, the clicked TreeViewItem is marked as selected by setting the IsItemSelected DependencyProperty to true. After that the TreeViewItem is marked as StartItem within the corresponding TreeView. Therefore the private StartItem DependencyProperty is used. The StartItem DependencyProperty is used as starting point for a subsequent multiple selection where a continuous range is selected.
    private static readonly DependencyProperty StartItemProperty =
        DependencyProperty.RegisterAttached(
            "StartItem",
            typeof(TreeViewItem),
            typeof(TreeViewMultipleSelectionAttached),
            new PropertyMetadata());
 
    private static TreeViewItem GetStartItem(TreeView element)
    {
        return (TreeViewItem)element.GetValue(StartItemProperty);
    }
 
    private static void SetStartItem(TreeView element, TreeViewItem value)
    {
        element.SetValue(StartItemProperty, value);
    }
If a LMB + Ctrl is performed the method SelectMultipleItemsRandomly is called. In this method the IsItemSelected DependencyProperty of the clicked TreeViewItem is toggled. Furthermore the StartItem DependencyProperty will be set of not already set or unset if no TreeViewItem is selected anymore.
    private static void SelectMultipleItemsRandomly(TreeView treeView,
                                                    TreeViewItem treeViewItem)
    {
        SetIsItemSelected(treeViewItem, !GetIsItemSelected(treeViewItem));
        if (GetStartItem(treeView) == null)
        {
            if (GetIsItemSelected(treeViewItem))
            {
                SetStartItem(treeView, treeViewItem);
            }
        }
        else
        {
            if (GetSelectedItems(treeView).Count == 0)
            {
                SetStartItem(treeView, null);
            }
        }
    }
If a LMB + Shift is performed the method SelectMutlipleItemsContinuously is called. If no StartItem is set, no action takes place. If the selected TreeViewItem is equal to the startItem then the SelectSingleItem method is called and then the method is returned. If both conditions not met multiple continuously TreeViewItems selection takes place.
    private static void SelectMultipleItemsContinuously(TreeView treeView,
                                                     TreeViewItem treeViewItem)
    {
        TreeViewItem startItem = GetStartItem(treeView);
        if (startItem != null)
        {
            if (startItem == treeViewItem)
            {
                SelectSingleItem(treeView, treeViewItem);
                return;
            }
 
            ICollection<TreeViewItem> allItems = new List<TreeViewItem>();
            GetAllItems(treeView, null, allItems);
            DeSelectAllItems(treeView, null);
            bool isBetween = false;
            foreach (var item in allItems)
            {
                if (item == treeViewItem || item == startItem)
                {
                    // toggle to true if first element is found and
                    // back to false if last element is found
                    isBetween = !isBetween;
 
                    // set boundary element
                    SetIsItemSelected(item, true);
                    continue;
                }
 
                if (isBetween)
                {
                    SetIsItemSelected(item, true);
                }
            }
        }
    }
With the GetAllItems method all TreeViewItems of a TreeView are recursively collected into a collection. Furthermore all items are deselected by the DeSelectAllItems method.
    private static void GetAllItems(TreeView treeView, TreeViewItem treeViewItem,
                                    ICollection<TreeViewItem> allItems)
    {
        if (treeView != null)
        {
            for (int i = 0; i < treeView.Items.Count; i++)
            {
                TreeViewItem item = treeView.ItemContainerGenerator.
                                           ContainerFromIndex(i) as TreeViewItem;
                if (item != null)
                {
                    allItems.Add(item);
                    GetAllItems(null, item, allItems);
                }
            }
        }
        else
        {
            for (int i = 0; i < treeViewItem.Items.Count; i++)
            {
                TreeViewItem item = treeViewItem.ItemContainerGenerator.
                                           ContainerFromIndex(i) as TreeViewItem;
                if (item != null)
                {
                    allItems.Add(item);
                    GetAllItems(null, item, allItems);
                }
            }
        }
    }
Then each element of the list will be checked, if it is in the range of StartItem and selected TreeViewItem. If this is true the TreeViewItem will be marked as selected.


4 comments:

  1. One of the easiest yet working solutions I've found for multi-select, thank you.
    I had a side-effect when original "IsSelected" property of TreeViewItem "painted" the last clicked item as "selected". Setting "IsSelected" to false inside SetIsSelected() helped.

    ReplyDelete
  2. Hi.
    Thanks for sharing :)
    I have been looking for a good solution for selecting multiple nodes in a TreeView for a while now, and this is the best solution I have found. I have created a GitHub repo based on this solution where I have improved the selection functionality so that it works well with keyboard:

    https://github.com/cmyksvoll/MultiSelectTreeView

    ReplyDelete
  3. Detailed explanation and a nice solution for a problem that should not exist - thanks!

    ReplyDelete