Search code examples
c#wpfdata-bindingtreeviewtreeviewitem

WPF tree view visibility converters not updating when item is added to bound observable collection


I have built out a tree view that is bound to an observable collection and have built it with connecting lines between each tree view item. The view model being used implements INotifyPropertyChanged and I'm using PropertyChanged.Fody for weaving. The tree view is bound to the collection and is updating great EXCEPT for one thing. When I add a new item to the list during runtime, the UI doesn't seem to update properly. I have tried everything under the sun that I could find searching the web on how to force an update to the UI without having to send a command to rebuild the entire tree when I add a root item, which does work, but there has to be another way I'm not finding.

I am using Ninject for dependency injection.

I'll place all of the code below my question, for reference. Again, all of this is working great, until an item is added to the collection at runtime. The item is added and visible in the tree view once added to the collection, but the last line converter doesn't update all the graphics properly.

Considering the following image:

Tree view after a root level item is added

Once an item is added, the node that now becomes second to last, his connecting lines visibility does not update and he still thinks he's the last on the branch. I've tried all types of UI refresh methods I could find, nothing worked. I'm missing something here, but am fairly new to WPF. Any advice anyone can provide would be extremely appreciated. Thanks!

Here's how I'm initially building the tree view, which works great:

ProjectHelpers.JsonObject = JObject.Parse(File.ReadAllText(ProjectPath.BaseDataFullPath));

//-- Get the channels, which are the top level tree elements
var children = ProjectHelpers.GetChannels();

//-- add the channels to the application channel collection
IoC.Application.Channels = new ObservableCollection<ProjectTreeItemViewModel>();

foreach(var c in children)
    IoC.Application.Channels.Add(new ProjectTreeItemViewModel(c.Path, ProjectItemType.Channel));

Which is contained within this class:


    /// <summary>
    /// The view model for the main project tree view
    /// </summary>
    public class ProjectTreeViewModel : BaseViewModel
    {

        /// <summary>
        /// Name of the image displayed above the tree view UI
        /// </summary>
        public string RootImageName => "blink";

        /// <summary>
        /// Default constructor
        /// </summary>
        public ProjectTreeViewModel()
        {
            BuildProjectTree();
        }

        #region Handlers : Building project data tree

        /// <summary>
        /// Builds the entire project tree
        /// </summary>
        public void BuildProjectTree()
        {

            ProjectHelpers.JsonObject = JObject.Parse(File.ReadAllText(ProjectPath.BaseDataFullPath));

            //-- Get the channels, which are the top level tree elements
            var children = ProjectHelpers.GetChannels();

            //-- add the channels to the application channel collection
            IoC.Application.Channels = new ObservableCollection<ProjectTreeItemViewModel>();

            foreach(var c in children)
                IoC.Application.Channels.Add(new ProjectTreeItemViewModel(c.Path, ProjectItemType.Channel));               
        }

        #endregion
    }

The view model for the items that get added to the observable collection


    /// <summary>
    /// The view model that represents an item within the tree view
    /// </summary>
    public class ProjectTreeItemViewModel : BaseViewModel
    {
        /// <summary>
        /// Default constructor
        /// </summary>
        /// <param name="path">The JSONPath for the item</param>
        /// <param name="type">The type of project item type</param>
        public ProjectTreeItemViewModel(string path = "", ProjectItemType type = ProjectItemType.Channel)
        {
            //-- Create commands
            ExpandCommand = new RelayCommand(Expand);
            GetNodeDataCommand = new RelayCommand(GetNodeData);

            FullPath = path;
            Type = type;

            //-- Setup the children as needed
            ClearChildren();
        }

        #region Public Properties

        /// <summary>
        /// The JSONPath for this item
        /// </summary>
        public string FullPath { get; set; }

        /// <summary>
        /// The type of project item
        /// </summary>
        public ProjectItemType Type { get; set; }

        /// <summary>
        /// Gets and sets the image name associated with project tree view headers.
        /// </summary>
        public string ImageName
        {
            get
            {
                switch (Type)
                {
                    case ProjectItemType.Channel:
                        return "channel";

                    case ProjectItemType.Device:
                        return "device";

                    default:
                        return "blink";

                }
            }
        }

        /// <summary>
        /// Gets the name of the item as a string
        /// </summary>
        public string Name => ProjectHelpers.GetPropertyValue(FullPath, "Name");

        /// <summary>
        /// Gets the associated driver as a string
        /// </summary>
        public string Driver => ProjectHelpers.GetPropertyValue(FullPath, "Driver");

        /// <summary>
        /// A list of all children contained inside this item
        /// </summary>
        public ObservableCollection<ProjectTreeItemViewModel> Children { get; set; }

        /// <summary>
        /// Indicates if this item can be expanded
        /// </summary>
        public bool CanExpand => (Type != ProjectItemType.Device);

        /// <summary>
        /// Indicates that the tree view item is selected, bound to the UI
        /// </summary>
        public bool IsSelected { get; set; }

        /// <summary>
        /// Indicates if the current item is expanded or not
        /// </summary>
        public bool IsExpanded
        {
            get {
                return (Children?.Count(f => f != null) >= 1);
            }
            set {
                //-- If the UI tells us to expand...
                if (value == true)
                    //-- Find all children
                    Expand();
                //-- If the UI tells us to close
                else
                    this.ClearChildren();
            }
        }

        #endregion


        #region Commands

        /// <summary>
        /// The command to expand this item
        /// </summary>
        public ICommand ExpandCommand { get; set; }

        /// <summary>
        /// Command bound by left mouse click on tree view item
        /// </summary>
        public ICommand GetNodeDataCommand { get; set; }

        #endregion


        #region Public Methods

        /// <summary>
        /// Expands a tree view item
        /// </summary>
        public void Expand()
        {
            //-- return if we are either a device or already expanded
            if (this.Type == ProjectItemType.Device || this.IsExpanded == true)
                return;

            //-- find all children
            var children = ProjectHelpers.GetChildrenByName(FullPath, "Devices");
            this.Children = new ObservableCollection<ProjectTreeItemViewModel>(
                            children.Select(c => new ProjectTreeItemViewModel(c.Path, ProjectHelpers.GetItemType(FullPath))));
        }

        /// <summary>
        /// Clears all children of this node
        /// </summary>
        public void ClearChildren()
        {
            //-- Clear items
            this.Children = new ObservableCollection<ProjectTreeItemViewModel>();

            //-- Show the expand arrow if we are not a device
            if (this.Type != ProjectItemType.Device)
                this.Children.Add(null);
        }

        /// <summary>
        /// Clears the children and expands it if it has children
        /// </summary>
        public void Reset()
        {
            this.ClearChildren();

            if (this.Children?.Count > 0)
                this.Expand();
        }

        #endregion


        #region Public Methods

        /// <summary>
        /// Shows the view model data in the node context data grid
        /// </summary>
        public void GetNodeData()
        {
            switch (Type)
            {
                //-- get the devices associated with that channel
                case ProjectItemType.Channel:
                    IoC.Application.UpdateDeviceDataContext(FullPath);
                    break;

                //-- get the tags associated with that device
                case ProjectItemType.Device:
                    IoC.Application.UpdateTagDataContext(FullPath);
                    break;
            }
        }

        #endregion
    }

Here's my template for the tree view item:


<Style x:Key="BaseTreeViewItemTemplate" TargetType="{x:Type TreeViewItem}">
    <Setter Property="Panel.ZIndex" Value="{Binding RelativeSource={RelativeSource Self}, Converter={StaticResource TreeViewItemZIndexConverter}}" />
    <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="BorderBrush" Value="Black" />
    <Setter Property="Padding" Value="1,2,2,2"/>

    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type TreeViewItem}">
                <Grid Name="ItemRoot">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="20"/>
                        <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>

                    <Grid Name="Lines" Grid.Column="0" Grid.Row="0">
                        <Grid.RowDefinitions>
                            <RowDefinition/>
                            <RowDefinition/>
                        </Grid.RowDefinitions>

                        <Grid.ColumnDefinitions>
                            <ColumnDefinition/>
                            <ColumnDefinition/>
                        </Grid.ColumnDefinitions>

                        <!-- L shape -->
                        <Border Grid.Row="0" Grid.Column="1" Name="TargetLine" BorderThickness="1 0 0 1" SnapsToDevicePixels="True" BorderBrush="Red"/>

                        <!-- line that follows a tree view item -->
                        <Border Name="LineToNextItem"
                                Visibility="{Binding RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource TreeLineVisibilityConverter}}"
                                Grid.Row="1" Grid.Column="1" BorderThickness="1 0 0 0" SnapsToDevicePixels="True" BorderBrush="Blue"/>
                    </Grid>

                    <ToggleButton x:Name="Expander" Grid.Column="0" Grid.Row="0"
                              Style="{StaticResource ExpandCollapseToggleStyle}" 
                              IsChecked="{Binding Path=IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" 
                              ClickMode="Press"/>

                    <!-- selected border background -->
                    <Border Name="ContentBorder" Grid.Column="1" Grid.Row="0"
                        HorizontalAlignment="Left"
                        Background="{TemplateBinding Background}" 
                        BorderBrush="{TemplateBinding BorderBrush}" 
                        BorderThickness="{TemplateBinding BorderThickness}" 
                        Padding="{TemplateBinding Padding}" 
                        SnapsToDevicePixels="True">
                        <ContentPresenter x:Name="ContentHeader" ContentSource="Header" MinWidth="20"/>
                    </Border>

                    <Grid Grid.Column="0" Grid.Row="1">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition/>
                            <ColumnDefinition/>
                        </Grid.ColumnDefinitions>

                        <Border BorderThickness="1 0 0 0"
                                Name="TargetBorder"
                                Grid.Column="1"
                                SnapsToDevicePixels="True"
                                BorderBrush="Olive"
                                Visibility="{Binding ElementName=LineToNextItem, Path=Visibility}"
                                />
                    </Grid>

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

                <ControlTemplate.Triggers>

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

                    <MultiTrigger>
                        <MultiTrigger.Conditions>
                            <Condition Property="HasHeader" Value="False"/>
                            <Condition Property="Width" Value="Auto"/>
                        </MultiTrigger.Conditions>
                        <Setter TargetName="ContentHeader" Property="MinWidth" Value="75"/>
                    </MultiTrigger>

                    <MultiTrigger>
                        <MultiTrigger.Conditions>
                            <Condition Property="HasHeader" Value="False"/>
                            <Condition Property="Height" Value="Auto"/>
                        </MultiTrigger.Conditions>
                        <Setter TargetName="ContentHeader" Property="MinHeight" Value="19"/>
                    </MultiTrigger>

                    <Trigger Property="IsEnabled" Value="True">
                        <Setter Property="Foreground" Value="{StaticResource OffWhiteBaseBrush}"/>
                    </Trigger>

                    <MultiTrigger>
                        <MultiTrigger.Conditions>
                            <Condition Property="IsSelected" Value="True"/>
                            <Condition Property="IsSelectionActive" Value="True"/>
                            </MultiTrigger.Conditions>
                        <Setter TargetName="ContentBorder" Property="Background" Value="{StaticResource SelectedTreeViewItemColor}"/>
                        <Setter Property="Foreground" Value="White" />
                    </MultiTrigger>

                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

My custom tree view control


<UserControl ...>
    <UserControl.Template>
        <ControlTemplate TargetType="UserControl">

            <StackPanel Background="Transparent"
                        Margin="8"
                        Orientation="Vertical"
                        VerticalAlignment="Top"
                        HorizontalAlignment="Left"
                        TextBlock.TextAlignment="Left">

                <Image x:Name="Root"
                       ContextMenuOpening="OnContextMenuOpened"
                       Width="18" Height="18"
                       HorizontalAlignment="Left"
                       RenderOptions.BitmapScalingMode="HighQuality"
                       Margin="2.7 0 0 3"
                       Source="{Binding RootImageName, Converter={x:Static local:HeaderToImageConverter.Instance}}" />

                <TreeView Name="ProjectTreeView"
                          Loaded="OnTreeViewLoaded"
                          SelectedItemChanged="OnTreeViewSelectedItemChanged"
                          ContextMenuOpening="OnContextMenuOpened"
                          BorderBrush="Transparent"
                          Background="Transparent"
                          VirtualizingStackPanel.IsVirtualizing="True"
                          VirtualizingStackPanel.VirtualizationMode="Recycling"
                          Style="{StaticResource ResourceKey=BaseTreeViewTemplate}"
                          ItemContainerStyle="{StaticResource ResourceKey=BaseTreeViewItemTemplate}"
                          ItemsSource="{Binding ApplicationViewModel.Channels, Source={x:Static local:ViewModelLocator.Instance}}">

                    <TreeView.ContextMenu>
                        <ContextMenu>
                            <MenuItem Header="New Item" />
                            <MenuItem Header="Cut" />
                            <MenuItem Header="Copy" />
                            <MenuItem Header="Delete" />
                            <MenuItem Header="Diagnostics" />
                            <MenuItem Header="Properties" />
                        </ContextMenu>
                    </TreeView.ContextMenu>

                    <TreeView.ItemTemplate>
                        <HierarchicalDataTemplate ItemsSource="{Binding Path=Children}">
                            <StackPanel Orientation="Horizontal" Margin="2">
                                <Image Width="15" Height="15" RenderOptions.BitmapScalingMode="HighQuality"
                                        Margin="-1 0 0 0"
                                        Source="{Binding Path=ImageName, Converter={x:Static local:HeaderToImageConverter.Instance}}" />

                                <TextBlock Margin="6,2,2,0" VerticalAlignment="Center" Text="{Binding Path=Name}" />
                            </StackPanel>
                        </HierarchicalDataTemplate>
                    </TreeView.ItemTemplate>
                </TreeView>

                <ContentPresenter />

            </StackPanel>
        </ControlTemplate>
    </UserControl.Template>
</UserControl>

The visibility converter for the connection lines in the tree view template


    /// <summary>
    /// Visibility converter for a connecting line inside the tree view UI
    /// </summary>
    public class TreeLineVisibilityConverter : BaseValueConverter<TreeLineVisibilityConverter>
    {
        public override object Convert(object value, Type targetType = null, object parameter = null, CultureInfo culture = null)
        {
            TreeViewItem item = (TreeViewItem)value;
            ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);

            bool isLastItem = (ic.ItemContainerGenerator.IndexFromContainer(item) == ic.Items.Count - 1);
            return isLastItem ? Visibility.Hidden : Visibility.Visible;
        }

        public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }


Solution

  • The problem exists due to this binding:

    Visibility="{Binding RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource TreeLineVisibilityConverter}}"
    

    You are binding to the the item container itself. This value never changes, therefore the Binding is only triggered once when the template is being applied to the container.

    You should bind to a property that changes too, whenever the ItemsSource changes. I think the best solution is one that moves this logic to the items and/or the converter.

    For this purpose I've added an IsLast property to the data model ProjectTreeItemViewModel which must raise INotifyPropertyChanged.PropertyChanged on changes.
    The initial default value of this property should be false.

    The border visibility binds to this property using your existing, but modified TreeLineVisibilityConverter.

    The converter has to be turned into a IMultiValueConverter as we need to bind to the new ProjectTreeItemViewModel.IsLast and to the item itself using a MultiBinding.

    Whenever a new item is added to the TreeView, its template will be loaded. This will trigger the MultiBinding and therefore the IMultiValueConverter. The converter checks if the current item is the last. If so, he will

    1. Set the previous item ProjectTreeItemViewModel.IsLast to false, which will re-trigger the MultiBinding for the previous item to show the line.

    2. Set the the current ProjectTreeItemViewModel.IsLast to true.

    3. Return the appropriate Visibility.

    TreeLineVisibilityConverter.cs

    public class TreeLineVisibilityConverter : IMultiValueConverter
    {
      public override object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
      {
        TreeViewItem item = (TreeViewItem) values[0];
        ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);
        int lastIndex = ic.Items.Count - 1;
    
        bool isLastItem = (ic.ItemContainerGenerator.IndexFromContainer(item) == lastIndex);
        if (isLastItem)
        {
          ResetIsLastOfPrevousItem(ic.Items.Cast<ProjectTreeItemViewModel>(), lastIndex);
          (item.DataContext as ProjectTreeItemViewModel).IsLast = true;
        }
    
        return isLastItem 
          ? Visibility.Hidden 
          : Visibility.Visible;
      }
    
      private void ConvertBack(IEnumerable<ProjectTreeItemViewModel> items, int lastIndex)
      {
        ProjectTreeItemViewModel previousItem = items.ElementAt(lastIndex - 1);
        if (previousItem.IsLast && items.Count() > 1)
        {
          previousItem.IsLast = false;
        }
      }
    
      public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
      {
        throw new NotSupportedException();
      }
    }
    

    ControlTemplate of TreeViewItem

    <ControlTemplate TargetType="TreeViewItem">
      ...
    
      <!-- line that follows a tree view item -->
      <Border Name="LineToNextItem">
        <Border.Visibility>
          <MultiBinding Converter="{StaticResource TreeLineVisibilityConverter}">
            <Binding RelativeSource="{RelativeSource TemplatedParent}"/>
            <Binding Path="IsLast" />
          </MultiBinding>
        </Border.Visibility>
      </Border>
    
      ...
    </ControlTemplate>
    

    Remarks

    For performance reasons you should consider to add a Parent property to your ProjectTreeItemViewModel. It's more efficient to traverse the model tree, than traversing the visual tree. Then in your ControlTemplate you simply replace the binding to the TemplatedParent (TreeViewItem) with a binding to the DataContext of the ControlTemplate e.g., {Binding} (or <Binding /> in case of the MultiBinding), which will return the current ProjectTreeItemViewModel. From here you can check if it's the last by accessing the ProjectTreeItemViewModel.Children property via the ProjectTreeItemViewModel.Parent. This way you don't have use the ItemContainerGenerator and don't have to cast the items of the ItemsControl.Items to IEnumerable<ProjectTreeItemViewModel>.



    MVVM tree view example

    This is a simple example on how to build a tree using MVVM. This example pretends to create the data tree from a text file.
    See the ProjectTreeItem class to see how to traverse the tree using recursion e.g. GetTreeRoot().

    At the end is also a revised version of the TreeLineVisibilityConverter to show how you can get to the parent collection using the Parent reference (and therefore without any need of static properties).

    ProjectTreeItem.cs

    // The data view model of the tree items.
    // Since this is the binding source of the TreeView,
    // this class should implement INotifyPropertyChanged.
    // This classes property setters are simplified.
    public class ProjectTreeItem : INotifyPropertyChanged
    {
      /// <summary>
      /// Default constructor
      /// </summary>
      public ProjectTreeItem(string data)
      {
        this.Data = data;
        this.Parent = null;
        this.Children = new ObservableCollection<ProjectTreeItem>();
      }
    
      // Traverse tree and expand subtree.
      public ExpandChildren()
      {
        foreach (var child in this.Children)
        {
          child.IsExpanded = true;
          child.ExpandChildren();
        }
      }
    
      // Traverse complete tree and expand each item.
      public ExpandTree()
      {
        // Get the root of the tree
        ProjectTreeItem rootItem = GetTreeRoot(this);
        foreach (var child in rootItem.Children)
        {
          child.IsExpanded = true;
          child.ExpandChildren();
        }
      }
    
      // Traverse the tree to the root using recursion.
      private ProjectTreeItem GetTreeRoot(ProjectTreeItem treeItem)
      {
        // Check if item is the root
        if (treeItem.Parent == null)
        {
          return treeItem;
        }
    
        return GetTreeRoot(treeItem.Parent);
      }
    
      public string Data { get; set; }
      public bool IsExpanded { get; set; }
      public ProjectTreeItem Parent { get; set; }
      public ObservableCollection<ProjectTreeItem> Children { get; set; }
    }
    

    Repository.cs

    // A model class in the sense of MVVM
    public class Repository
    {
      public ProjectTreeItem ReadData()
      {
        var lines = File.ReadAllLines("/path/to/data");
    
        // Create the tree structure from the file data
        return CreateDataModel(lines);
      }
    
      private ProjectTreeItem CreateDataModel(string[] lines)
      {
        var rootItem = new ProjectTreeItem(string.Empty);
    
        // Pretend each line contains tokens separated by a whitespace,
        // then each line is a parent and the tokens its children.
        // Just to show how to build the tree by setting Parent and Children.
        foreach (string line in lines)
        {
          rootItem.Children.Add(CreateNode(line));
        }
    
        return rootItem;
      }
    
      private ProjectTreeItem CreateNode(string line)
      {
        var nodeItem = new ProjectTreeItem(line);
        foreach (string token in line.Split(' '))
        {
          nodeItem.Children.Add(new ProjectTreeItem(token) {Parent = nodeItem});
        }
    
        return nodeItem;
      }
    }
    

    DataController.cs

    // Another model class in the sense of MVVM
    public class DataController
    {
      public DataController()
      {
        // Create the model. Alternatively use constructor 
        this.Repository = new Repository();
      }
    
      public IEnumerable<ProjectTreeItem> GetData()
      {
        return this.Repository.ReadData().Children;
      }
    
      private Repository Repository { get; set; }
    }
    

    MainViewModel.cs

    // The data view model of the tree items.
    // Since this is a binding source of the view,
    // this class should implement INotifyPropertyChanged.
    // This classes property setters are simplified.
    public class MainViewModel : INotifyPropertyChanged
    {
      public MainViewModel()
      {
        // Create the model. Alternatively use constructor injection.
        this.DataController = new DataController();
        Initialize();
      }
    
      private void Initialize()
      {
        IEnumerable<ProjectTreeItem> treeData = this.DataController.GetData();
        this.TreeData = new ObservableCollection<ProjectTreeItem>(treeData);
      }
    
      public ObservableCollection<ProjectTreeItem> TreeData { get; set; }
    
      private DataController DataController { get; set; }
    }
    

    TreeLineVisibilityConverter.cs

    public class TreeLineVisibilityConverter : IMultiValueConverter
    {
      public override object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
      {
        ProjectTreeItem item = values[0] as ProjectTreeItem;
    
        // If current item is root return
        if (item.Parent == null)
        {
          return Binding.DoNothing;
        }
    
        ProjectTreeItem parent = item?.Parent ?? item;
        int lastIndex = item.Parent.Chilidren.Count - 1;
    
        bool isLastItem = item.Parent.Chilidren.IndexOf(item) == lastIndex);
        if (isLastItem)
        {
          ResetIsLastOfPrevousItem(item.Parent.Chilidren, lastIndex);
          item.IsLast = true;
        }
    
        return isLastItem 
          ? Visibility.Hidden 
          : Visibility.Visible;
      }
    
      private void ConvertBack(IEnumerable<ProjectTreeItem> items, int lastIndex)
      {
        ProjectTreeItem previousItem = items.ElementAt(lastIndex - 1);
        if (previousItem.IsLast && items.Count() > 1)
        {
          previousItem.IsLast = false;
        }
      }
    
      public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
      {
        throw new NotSupportedException();
      }
    }
    

    UserControl.xaml

    <UserControl>
      <UserControl.DataContext>
        <MainViewModel />
      <UserControl.DataContext>
    
      <UserControl.Resources>
        <ControlTemplate TargetType="TreeViewItem">
          ...
    
          <!-- line that follows a tree view item -->
          <Border Name="LineToNextItem">
            <Border.Visibility>
              <MultiBinding Converter="{StaticResource TreeLineVisibilityConverter}">
                <Binding />
                <Binding Path="IsLast" />
              </MultiBinding>
            </Border.Visibility>
          </Border>
    
          ...
        </ControlTemplate>
      <UserControl.Resources>
    
      <TreeView ItemsSource="{Binding TreeData}" />
    </UserControl>