WPF Behaviors – TreeView

Time Spent Amongst Trees is Never Wasted Time
Katrina Mayer

In my previous post, I described how a Behavior could alter the functionality of a standard WPF control – in that case having the selected item of a ListBox scroll into view automatically. In this post I’ll be working with TreeViews – including adding the same scroll into view functionality. This time however, it’s not quite so straightforward.

In the MVVM world, there shouldn’t be a reference to user interface elements in the ViewModel, so no TreeViewItems there. Instead, the ViewModel just publishes a collection of data items which can be bound as the root nodes of the tree, and lets the data templates defined in the View take care of things. The WPF TreeView control is pretty flexible – it can handle multiple data item types, even sibling items of a common parent node can be different types, each with their own display characteristics.

So far as the ViewModel layer is concerned, TreeView data items need to handle expansion, selection, checking (tick box), disabled state, lazy loading of child items etc. That sounds like a lot of common behaviour, which should be abstracted out into base class. If you remember, in a previous post, I said that WrapperViewModels had two common ancestor base classes, that were unused at that time. Well, for those applications that are TreeView based, one of those would be the ideal place to put all that functionality.

I’m not going to reproduce the code for perTreeViewItemViewModelBase here, as it is pretty much self-documenting, just talk about a couple of the features. The class is designed to operate in two modes, either

  • Build the entire tree in one go by calling AddChild() on the appropriate parent node for each item as required. The internal _childrenList is an observable collection, so you can add or remove nodes at any point, and the U.I. will automatically refresh.
  • Operate the tree in lazy loading mode – initially just create the root level items, and fetch the child nodes as required – Initialise() will be called, to build the immediate children, when a node is expanded for the first time. The virtual InitialiseChildrenAsync() method can be overridden to fetch the data for the new nodes (e.g. from a database or web service), and the virtual InitialisedChildren property allows for the children list to be generated as required, e.g. as the union of multiple internal child item collections.

To add the required functionality, I’ll be creating two new helper classes …

  • TreeView – to add a bindable selected item property
  • TreeViewItem – to add two different scroll into view operations
    • When an item is selected (via the selected property of the perTreeViewItemViewModelBase item)
    • When an item is expanded, the Tree should be scrolled to show as many of its children as possible

… which are included in the global style for TreeView controls.

TreeView Helper

This behavior creates a new attached property – BoundSelectedItem, which we can use as the target for data binding.

    public class perTreeViewHelper : Behavior<TreeView>
    {
        public object BoundSelectedItem
        {
            get { return GetValue(BoundSelectedItemProperty); }
            set { SetValue(BoundSelectedItemProperty, value); }
        }

        public static readonly DependencyProperty BoundSelectedItemProperty =
            DependencyProperty.Register("BoundSelectedItem",
                typeof(object),
                typeof(perTreeViewHelper),
                new FrameworkPropertyMetadata(null,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    OnBoundSelectedItemChanged));

        private static void OnBoundSelectedItemChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            var item = args.NewValue as perTreeViewItemViewModelBase;

            if (item != null)
                item.IsSelected = true;
        }

        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.SelectedItemChanged += OnTreeViewSelectedItemChanged;
        }

        protected override void OnDetaching()
        {
            if (AssociatedObject != null)
                AssociatedObject.SelectedItemChanged -= OnTreeViewSelectedItemChanged;

            base.OnDetaching();
        }

        private void OnTreeViewSelectedItemChanged(object obj, RoutedPropertyChangedEventArgs<object> args)
        {
            BoundSelectedItem = args.NewValue;
        }
    }

These attached properties can be added to extend any DependencyObject, but are most often used on Control descendents – two common examples are Grid.Row & DockPanel.Dock. The property changed callback for this new property (OnBoundSelectedItemChanged) just checks that the selected item descends from perTreeViewItemViewModelBase, and if so, sets its IsSelected property to true. The TreeView, through its ItemContainerStyle does the rest. Note that by rule, a TreeView can only have one selected item, which is handled automatically – we don’t have to manually clear the selection on the previously selected item. The OnAttached method of the behavior is used to add a handler to the existing SelectedItemChanged event of the TreeViewItem. This updates the value of BoundSelectedItem when the user manually selects a new item in the tree.

TreeViewItem Helper

This helper is designed to be used as part of a style for TreeViewItems. The simplest construct is coding it as a static class, which acts as a placeholder for the attached property definitions.

    public static class perTreeViewItemHelper
    {
        public static bool GetBringSelectedItemIntoView(TreeViewItem treeViewItem)
        {
            return (bool)treeViewItem.GetValue(BringSelectedItemIntoViewProperty);
        }

        public static void SetBringSelectedItemIntoView(TreeViewItem treeViewItem, bool value)
        {
            treeViewItem.SetValue(BringSelectedItemIntoViewProperty, value);
        }

        public static readonly DependencyProperty BringSelectedItemIntoViewProperty =
            DependencyProperty.RegisterAttached(
                "BringSelectedItemIntoView",
                typeof(bool),
                typeof(perTreeViewItemHelper),
                new UIPropertyMetadata(false, BringSelectedItemIntoViewChanged));

        private static void BringSelectedItemIntoViewChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (!(args.NewValue is bool))
                return;

            var item = obj as TreeViewItem;

            if (item == null)
                return;

            if ((bool)args.NewValue)
                item.Selected += OnTreeViewItemSelected;
            else
                item.Selected -= OnTreeViewItemSelected;
        }

        private static void OnTreeViewItemSelected(object sender, RoutedEventArgs e)
        {
            var item = e.OriginalSource as TreeViewItem;
            item?.BringIntoView();

            // prevent this event bubbling up to any parent nodes
            e.Handled = true;
        }

        public static bool GetBringExpandedChildrenIntoView(TreeViewItem treeViewItem)
        {
            return (bool)treeViewItem.GetValue(BringExpandedChildrenIntoViewProperty);
        }

        public static void SetBringExpandedChildrenIntoView(TreeViewItem treeViewItem, bool value)
        {
            treeViewItem.SetValue(BringExpandedChildrenIntoViewProperty, value);
        }

        public static readonly DependencyProperty BringExpandedChildrenIntoViewProperty =
            DependencyProperty.RegisterAttached(
                "BringExpandedChildrenIntoView",
                typeof(bool),
                typeof(perTreeViewItemHelper),
                new UIPropertyMetadata(false, BringExpandedChildrenIntoViewChanged));

        private static void BringExpandedChildrenIntoViewChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (!(args.NewValue is bool))
                return;

            var item = obj as TreeViewItem;

            if (item == null)
                return;

            if ((bool)args.NewValue)
                item.Expanded += OnTreeViewItemExpanded;
            else
                item.Expanded -= OnTreeViewItemExpanded;
        }

        private static void OnTreeViewItemExpanded(object sender, RoutedEventArgs e)
        {
            var item = e.OriginalSource as TreeViewItem;

            if (item == null)
                return;

            // use DispatcherPriority.ContextIdle, so that we wait for all of the UI elements for any newly visible children to be created

            // first bring the last child into view
            Action action = () =>
            {
                var lastChild = item.ItemContainerGenerator.ContainerFromIndex(item.Items.Count - 1) as TreeViewItem;
                lastChild?.BringIntoView();
            };
            item.Dispatcher.BeginInvoke(action, DispatcherPriority.ContextIdle);

            // then bring the expanded item (back) into view
            action = () => { item.BringIntoView(); };
            item.Dispatcher.BeginInvoke(action, DispatcherPriority.ContextIdle);

            // prevent this event bubbling up to any parent nodes
            e.Handled = true;
        }
    }

Note that because Selected and Expanded are routed events, the handlers have to mark the arguments as handled to prevent the event bubbling up to any parent items.

perDispatcherHelper is a utility which ensures that all UI operations take place on the main thread – it must be initialised in App.xaml.cs, so that the UI dispatcher can be captured at that point. It is based on DispatcherHelper from MvvmLight, but extended with DispatcherPriority options. This are important when dealing indirectly (e.g. by setting bound properties from the ViewModel) with controls with ItemTemplates and/or Virtualisation, as it allows us to wait for a control to be fully rendered before we perform an operation on a visual item.

TreeView Style

This style, which is included as part of the wpf view library, provides

  • a coloured icon for the expander button
  • the colour scheme for the tree view item – as with the ListBox style, keeping an item highlighted even when the control loses focus
  • including the two scroll into view behaviors for an item
  • using the item style as the ItemContainerStyle in a TreeView control
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:vhelp="clr-namespace:Peregrine.WPF.View.Helpers">

    <Style x:Key="perExpandCollapseToggleStyle"
           TargetType="ToggleButton">
        <Setter Property="Focusable" Value="False" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="ToggleButton">
                    <Grid Width="10"
                          Height="10"
                          Background="Transparent">
                        <Path x:Name="ExpanderGlyph"
                              Margin="1"
                              HorizontalAlignment="Left"
                              VerticalAlignment="Center"
                              Data="M 0,3 L 0,5 L 3,5 L 3,8 L 5,8 L 5,5 L 8,5 L 8,3 L 5,3 L 5,0 L 3,0 L 3,3 z"
                              Fill="LightGreen"
                              Stretch="None" />
                    </Grid>

                    <ControlTemplate.Triggers>
                        <Trigger Property="IsChecked" Value="True">
                            <Setter TargetName="ExpanderGlyph" Property="Data" Value="M 0,0 M 8,8 M 0,3 L 0,5 L 8,5 L 8,3 z" />
                            <Setter TargetName="ExpanderGlyph" Property="Fill" Value="Red" />
                        </Trigger>

                        <Trigger Property="IsEnabled" Value="False">
                            <Setter TargetName="ExpanderGlyph" Property="Fill" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <Style x:Key="perTreeViewItemContainerStyle"
           TargetType="{x:Type TreeViewItem}">
        
        <!-- Link the properties of perTreeViewItemViewModelBase to the corresponding ones on the TreeViewItem -->
        <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
        <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
        <Setter Property="IsEnabled" Value="{Binding IsEnabled}" />
        
        <!-- Include the two "Scroll into View" behaviors -->
        <Setter Property="vhelp:perTreeViewItemHelper.BringSelectedItemIntoView" Value="True" />
        <Setter Property="vhelp:perTreeViewItemHelper.BringExpandedChildrenIntoView" Value="True" />

        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type TreeViewItem}">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto"
                                              MinWidth="14" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto" />
                            <RowDefinition Height="*" />
                        </Grid.RowDefinitions>
                        <ToggleButton x:Name="Expander"
                                      Grid.Row="0"
                                      Grid.Column="0"
                                      ClickMode="Press"
                                      IsChecked="{Binding Path=IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"
                                      Style="{StaticResource perExpandCollapseToggleStyle}" />

                        <Border x:Name="PART_Border"
                                Grid.Row="0"
                                Grid.Column="1"
                                Padding="{TemplateBinding Padding}"
                                Background="{TemplateBinding Background}"
                                BorderBrush="{TemplateBinding BorderBrush}"
                                BorderThickness="{TemplateBinding BorderThickness}">

                            <ContentPresenter x:Name="PART_Header"
                                              Margin="0,2"
                                              HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                              ContentSource="Header" />

                        </Border>

                        <ItemsPresenter x:Name="ItemsHost"
                                        Grid.Row="1"
                                        Grid.Column="1" />
                    </Grid>

                    <ControlTemplate.Triggers>
                        <Trigger Property="IsExpanded" Value="false">
                            <Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed" />
                        </Trigger>

                        <Trigger Property="HasItems" Value="false">
                            <Setter TargetName="Expander" Property="Visibility" Value="Hidden" />
                        </Trigger>

                        <!--  Use the same colors for a selected item, whether the TreeView is focussed or not  -->
                        <Trigger Property="IsSelected" Value="true">
                            <Setter TargetName="PART_Border" Property="Background" Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
                            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.HighlightTextBrushKey}}" />
                        </Trigger>

                        <Trigger Property="IsEnabled" Value="false">
                            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <Style TargetType="{x:Type TreeView}">
        <Setter Property="ItemContainerStyle" Value="{StaticResource perTreeViewItemContainerStyle}" />
    </Style>
</ResourceDictionary>

The demo application for this post shows synchronisation between a TreeView and a ListBox. Whenever an item is selected in one control, it will be selected and scrolled into view in the other. Any checked items in the TreeView will be displayed in bold in the ListBox. Double-clicking on a ListBox item will expand that item in the TreeView.

In my next few posts, I’ll look at some aspects of WPF development that are often highlighted as excuses for not adopting the MVVM pattern. Using some of the techniques I’ve already covered in this series, I’ll build some library functionality that can be used to solve these ‘hard’ MVVM problems.

Don’t forget that all of the code samples for this blog are available on Github, along with my own personal C# / WPF library.

Leave a Reply

Your email address will not be published. Required fields are marked *