3

I am trying to create a SelectedPath property (e.g. in my view-model) that is synchronized with a WPF TreeView. The theory is as follows:

  • Whenever the selected item in the tree view is changed (SelectedItem property/SelectedItemChanged event), update the SelectedPath property to store a string that represents the whole path to the selected tree node.
  • Whenever the SelectedPath property is changed, find the tree node indicated by the path string, expand the whole path to that tree node, and select it, after de-selecting the previously selected node.

In order to make all of this reproducible, let us assume that all tree nodes are of type DataNode (see below), that every tree node has a name that is unique among the children of its parent node, and that the path separator be a single forward slash /.

Updating the SelectedPath property in the SelectedItemChange event is not a problem - the following event handler works flawlessly:

void TreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
    DataNode selNode = e.NewValue as DataNode;
    if (selNode == null) {
        vm.SelectedPath = null;
    } else {
        vm.SelectedPath = selNode.FullPath;
    }
}

However, I fail to make the other way round work properly. Hence, my question, based on the generalized and minimized code sample below, is: How do I make WPF's TreeView respect my programmatical selection of items?

Now, how far have I come? First of all, TreeView's SelectedItem property is read-only, so it cannot be set directly. I have found and read numerous SO questions discussing this in-depth (such as this, this or this), and also resources on other sites, such as this blogpost, this article or this blogpost.

Almost all of these resources point to defining a style for TreeViewItem that binds TreeViewItem's IsSelected property to an equivalent property of the underlying tree node object from the view-model. Sometimes (e.g. here and here), the binding is made two-way, sometimes (e.g. here and here) it's a one-way binding. I don't see the point in making this a one-way-binding (if the tree view UI somehow deselects the item, that change should of course be reflected in the underlying view-model), so I have implemented the two-way version. (The same is usually suggested for IsExpanded, so I have also added a property for that.)

This is the TreeViewItem style I'm using:

<Style TargetType="{x:Type TreeViewItem}" BasedOn="{StaticResource {x:Type TreeViewItem}}">
    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
    <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>

I have confirmed that this style is actually applied (if I add a setter to set the Background property to Red, all the tree view items do appear with a red background).

And here is the simplified and generalized DataNode class:

public class DataNode : INotifyPropertyChanged
{
    public DataNode(DataNode parent, string name)
    {
        this.parent = parent;
        this.name = name;
    }

    private readonly DataNode parent;

    private readonly string name;

    public string Name {
        get {
            return name;
        }
    }

    public override string ToString()
    {
        return name;
    }


    public string FullPath {
        get {
            if (parent != null) {
                return parent.FullPath + "/" + name;
            } else {
                return "/" + name;
            }
        }
    }

    protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        if (PropertyChanged != null) {
            PropertyChanged(this, e);
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private DataNode[] children;

    public IEnumerable<DataNode> Children {
        get {
            if (children == null) {
                children = DataSource.GetChildNodes(FullPath).Select(s => new DataNode(this, s)).ToArray();
            }

            return children;
        }
    }

    private bool isSelected;

    public bool IsSelected {
        get {
            return isSelected;
        }
        set {
            if (isSelected != value) {
                isSelected = value;
                OnPropertyChanged(new PropertyChangedEventArgs("IsSelected"));
            }
        }
    }

    private bool isExpanded;

    public bool IsExpanded {
        get {
            return isExpanded;
        }
        set {
            if (isExpanded != value) {
                isExpanded = value;
                OnPropertyChanged(new PropertyChangedEventArgs("IsExpanded"));
            }
        }
    }

    public void ExpandPath()
    {
        if (parent != null) {
            parent.ExpandPath();
        }
        IsExpanded = true;
    }
}

As you can see, each node has a name, a reference to its parent node (if any), it initializes its child nodes lazily, but only once, and it has an IsSelected and an IsExpanded property, both of which trigger the PropertyChanged event from the INotifyPropertyChanged interface.

So, in my view-model, the SelectedPath property is implemented as follows:

    public string SelectedPath {
        get {
            return selectedPath;
        }
        set {
            if (selectedPath != value) {
                DataNode prevSel = NodeByPath(selectedPath);
                if (prevSel != null) {
                    prevSel.IsSelected = false;
                }

                selectedPath = value;

                DataNode newSel = NodeByPath(selectedPath);
                if (newSel != null) {
                    newSel.ExpandPath();
                    newSel.IsSelected = true;
                }

                OnPropertyChanged(new PropertyChangedEventArgs("SelectedPath"));
            }
        }
    }

The NodeByPath method correctly (I've checked this) retrieves the DataNode instance for any given path string. Nonetheless, I can run my application and see the following behavior, when binding a TextBox to the SelectedPath property of the view-model:

  • type /0 => item /0 is selected and expanded
  • type /0/1/2 => item /0 remains selected, but item /0/1/2 gets expanded.

Similarly, when I first set the selected path to /0/1, that item gets correctly selected and expanded, but for any subsequent path values, the items only get expanded, never selected.

After debugging for a while, I thought the problem was a recursive call of the SelectedPath setter in the prevSel.IsSelected = false; line, but adding a flag that would prevent the execution of the setter code while that command is being executed did not seem to change the behaviour of the programme at all.

So, what am I doing wrong here? I don't see where I'm doing something different than what is suggested in all of those blogposts. Does the TreeView need to be notified somehow about the new IsSelected value of the newly selected item?

For your convencience, the full code of all 5 files that constitute the self-contained, minimal example (the data source obviously returns bogus data in this example, yet it returns a constant tree and hence makes the test cases indicated above reproducible):


DataNode.cs

using System;
using System.ComponentModel;
using System.Collections.Generic;
using System.Linq;

namespace TreeViewTest
{
    public class DataNode : INotifyPropertyChanged
    {
        public DataNode(DataNode parent, string name)
        {
            this.parent = parent;
            this.name = name;
        }

        private readonly DataNode parent;

        private readonly string name;

        public string Name {
            get {
                return name;
            }
        }

        public override string ToString()
        {
            return name;
        }


        public string FullPath {
            get {
                if (parent != null) {
                    return parent.FullPath + "/" + name;
                } else {
                    return "/" + name;
                }
            }
        }

        protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
        {
            if (PropertyChanged != null) {
                PropertyChanged(this, e);
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        private DataNode[] children;

        public IEnumerable<DataNode> Children {
            get {
                if (children == null) {
                    children = DataSource.GetChildNodes(FullPath).Select(s => new DataNode(this, s)).ToArray();
                }

                return children;
            }
        }

        private bool isSelected;

        public bool IsSelected {
            get {
                return isSelected;
            }
            set {
                if (isSelected != value) {
                    isSelected = value;
                    OnPropertyChanged(new PropertyChangedEventArgs("IsSelected"));
                }
            }
        }

        private bool isExpanded;

        public bool IsExpanded {
            get {
                return isExpanded;
            }
            set {
                if (isExpanded != value) {
                    isExpanded = value;
                    OnPropertyChanged(new PropertyChangedEventArgs("IsExpanded"));
                }
            }
        }

        public void ExpandPath()
        {
            if (parent != null) {
                parent.ExpandPath();
            }
            IsExpanded = true;
        }
    }
}

DataSource.cs

using System;
using System.Collections.Generic;

namespace TreeViewTest
{
    public static class DataSource
    {
        public static IEnumerable<string> GetChildNodes(string path)
        {
            if (path.Length < 40) {
                for (int i = 0; i < path.Length + 2; i++) {
                    yield return (2 * i).ToString();
                    yield return (2 * i + 1).ToString();
                }
            }
        }
    }
}

ViewModel.cs

using System;
using System.ComponentModel;
using System.Collections.Generic;
using System.Linq;

namespace TreeViewTest
{
    public class ViewModel : INotifyPropertyChanged
    {
        private readonly DataNode[] rootNodes = DataSource.GetChildNodes("").Select(s => new DataNode(null, s)).ToArray();

        public IEnumerable<DataNode> RootNodes {
            get {
                return rootNodes;
            }
        }

        private DataNode NodeByPath(string path)
        {
            if (path == null) {
                return null;
            } else {
                string[] levels = selectedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
                IEnumerable<DataNode> currentAvailable = rootNodes;
                for (int i = 0; i < levels.Length; i++) {
                    string node = levels[i];
                    foreach (DataNode next in currentAvailable) {
                        if (next.Name == node) {
                            if (i == levels.Length - 1) {
                                return next;
                            } else {
                                currentAvailable = next.Children;
                            }
                            break;
                        }
                    }
                }

                return null;
            }
        }

        private string selectedPath;

        public string SelectedPath {
            get {
                return selectedPath;
            }
            set {
                if (selectedPath != value) {
                    DataNode prevSel = NodeByPath(selectedPath);
                    if (prevSel != null) {
                        prevSel.IsSelected = false;
                    }

                    selectedPath = value;

                    DataNode newSel = NodeByPath(selectedPath);
                    if (newSel != null) {
                        newSel.ExpandPath();
                        newSel.IsSelected = true;
                    }

                    OnPropertyChanged(new PropertyChangedEventArgs("SelectedPath"));
                }
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
        {
            if (PropertyChanged != null) {
                PropertyChanged(this, e);
            }
        }
    }
}

Window1.xaml

<Window x:Class="TreeViewTest.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="TreeViewTest" Height="450" Width="600"
    >
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TreeView ItemsSource="{Binding RootNodes}" SelectedItemChanged="TreeView_SelectedItemChanged">
            <TreeView.Resources>
                <Style TargetType="{x:Type TreeViewItem}" BasedOn="{StaticResource {x:Type TreeViewItem}}">
                    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
                    <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
                </Style>
            </TreeView.Resources>
            <TreeView.ItemTemplate>
                <HierarchicalDataTemplate ItemsSource="{Binding Children}">
                    <TextBlock Text="{Binding .}"/>
                </HierarchicalDataTemplate>
            </TreeView.ItemTemplate>
        </TreeView>
        <TextBox Grid.Row="1" Text="{Binding SelectedPath, Mode=TwoWay}"/>
    </Grid>
</Window>

Window1.xaml.cs

using System;
using System.Windows;

namespace TreeViewTest
{
    public partial class Window1 : Window
    {
        public Window1()
        {
            InitializeComponent();

            DataContext = vm;
        }

        void TreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
        {
            DataNode selNode = e.NewValue as DataNode;
            if (selNode == null) {
                vm.SelectedPath = null;
            } else {
                vm.SelectedPath = selNode.FullPath;
            }
        }

        private readonly ViewModel vm = new ViewModel();
    }
}
Community
  • 1
  • 1
O. R. Mapper
  • 18,591
  • 9
  • 58
  • 102

1 Answers1

1

I could not reproduce the behavior you described. There is a problem with the code you posted unrelated to TreeView. The TextBox default UpdateSourceTrigger is LostFocus therefore the TreeView is affected only after the TextBox loses focus but there are only two controls in your example so to make the TextBox lose focus you have to select something in the TreeView (then the entire selection process is messed up).

What I did was to add a button at the bottom of the form. The button does nothing but when clicked the TextBox loses focus. Everything works perfectly now.

I compiled it in VS2012 using .Net 4.5

Buzzy
  • 2,715
  • 2
  • 19
  • 29
  • I made the `TextBox` lose focus by pressing the Tab key, which should focus the `TreeView` (but not select anything else there). Will try your solution with an extra button. – O. R. Mapper Dec 09 '12 at 14:29
  • 1
    Ok, that seems to have worked. So nothing was wrong with my selection-related code at all; the problem was just that the selection was changed by switching the focus to the tree view while the selection was being changed ... interesting and unexpected. Thank you :-) – O. R. Mapper Dec 09 '12 at 15:02