Преобразователи видимости в виде дерева WPF не обновляются при добавлении элемента в связанную наблюдаемую коллекцию - PullRequest
1 голос
/ 24 апреля 2020

Я построил древовидное представление, которое привязано к наблюдаемой коллекции, и построил его с соединительными линиями между каждым элементом древовидного представления. Используемая модель представления реализует INotifyPropertyChanged, и я использую PropertyChanged.Fody для плетения. Древовидное представление привязано к коллекции и обновляет отличный EXCEPT с одной стороны. Когда я добавляю новый элемент в список во время выполнения, пользовательский интерфейс не обновляется должным образом. Я попробовал все под солнцем, что я мог найти в Интернете, как принудительно обновить пользовательский интерфейс, не отправляя команду для восстановления всего дерева, когда я добавляю элемент root, который работает, но есть чтобы быть другим способом я не нахожу.

Я использую Ninject для внедрения зависимостей.

Я помещу весь код ниже моего вопроса для справки. Опять же, все это прекрасно работает, пока элемент не будет добавлен в коллекцию во время выполнения. Элемент добавляется и отображается в виде дерева после добавления в коллекцию, но конвертер последней строки не обновляет всю графику должным образом.

Учитывая следующее изображение:

Tree view after a root level item is added

Как только элемент добавлен, узел, который теперь становится вторым по длине, его соединительные линии видимость не обновляется, и он все еще думает, что он последний на ветке. Я пробовал все типы методов refre sh, которые я смог найти, но ничего не получалось. Я что-то здесь упускаю, но я довольно новичок в WPF. Любой совет, который кто-либо может дать, будет очень признателен. Спасибо!

Вот как я изначально строю древовидное представление, которое прекрасно работает:

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));

, которое содержится в этом классе:


    /// <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
    }

Модель представления для элементов, добавляемых в наблюдаемую коллекцию


    /// <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
    }

Вот мой шаблон для элемента представления в виде дерева:


<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>

Мой пользовательский элемент управления представлением дерева


<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>

Преобразователь видимости для линий соединения в шаблоне представления дерева


    /// <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();
        }
    }

Ответы [ 2 ]

1 голос
/ 25 апреля 2020

Спасибо @BionicCode за помощь здесь, значит очень много. Я хотел поделиться своей реализацией обхода модели представления вместо визуального обхода дерева. В итоге я не создал поле для ссылки на родительский контейнер в классе ProjectTreeItemViewModel, а вместо этого создал ParentIndex и ChildIndex, которые позволяют мне быстро получить доступ к нужному элементу, ссылаясь на свойство FullPath, которое является просто JSONPath для json содержание. Честно говоря, я не совсем уверен, как вы хотели включить ссылку на родительский контейнер в класс, но хотел бы увидеть предложенную вами реализацию. Еще раз спасибо, @BionicCode, хороших выходных!

Вот мой конвертер:


    /// <summary>
    /// Visibility converter for the connecting lines on the tree view UI
    /// </summary>
    public class ConnectingLineVisibilityConverter : IMultiValueConverter
    {
        /// <summary>
        /// Returns the proper visibility according to location on the tree view UI
        /// </summary>
        public object Convert(object[] values, Type targetType = null, object parameter = null, CultureInfo culture = null)
        {
            ProjectTreeItemViewModel viewModel = (ProjectTreeItemViewModel)values[0];

            //-- collection context by default is the channels
            var collection = IoC.Application.Channels;
            int currentIndex = viewModel.ParentIndex;

            if (viewModel.Type == ProjectItemType.Device) {
                //-- change the collection context to the children of this channel
                collection = collection[currentIndex].Children;
                currentIndex = viewModel.ChildIndex;
            }

            int lastIndex = collection.Count - 1;
            bool isLastItem = (currentIndex == lastIndex);

            //-- is it the last of it's branch?
            if (isLastItem) {
                ResetPreviousSibling(collection, lastIndex);
                viewModel.IsLast = true;
            }

            return isLastItem ? Visibility.Hidden : Visibility.Visible;
        }

        /// <summary>
        /// Resets the previous sibling IsLast flag once a new item is added to the collection
        /// </summary>
        /// <param name="collection">The collection to search</param>
        /// <param name="lastIndex">The index of the previous sibling</param>
        private void ResetPreviousSibling(ObservableCollection<ProjectTreeItemViewModel> collection, int lastIndex)
        {
            //-- there's only one item in the collection
            if (lastIndex == 0)
                return;

            //-- get the previous sibling and reset it's IsLast flag, if necessary
            ProjectTreeItemViewModel previousSibling = collection[lastIndex - 1];
            if (previousSibling.IsLast)
                previousSibling.IsLast = false;
        }

        public object[] ConvertBack(object value, Type[] targetTypes = null, object parameter = null, CultureInfo culture = null)
        {
            throw new NotImplementedException();
        }
    }

Затем привязка становится ...

    <!-- connecting line to the next item -->
    <Border Name="LineToNextItem" Grid.Row="1" Grid.Column="1" BorderThickness="1 0 0 0" SnapsToDevicePixels="True" BorderBrush="Blue">
        <Border.Visibility>
            <MultiBinding Converter="{StaticResource ConnectingLineVisibilityConverter}">
                <Binding />
                <Binding Path="IsLast" />
            </MultiBinding>
        </Border.Visibility>
    </Border>

1 голос
/ 24 апреля 2020

Проблема существует из-за этой привязки:

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

Вы привязываетесь к самому контейнеру элемента. Это значение никогда не изменяется, поэтому Binding срабатывает только один раз, когда шаблон применяется к контейнеру.

При каждом изменении ItemsSource следует также связать свойство, которое также изменяется. Я думаю, что лучшее решение - это то, что перемещает эту логику c к элементам и / или конвертеру.

Для этой цели я добавил свойство IsLast к модели данных ProjectTreeItemViewModel, которая должна увеличить INotifyPropertyChanged.PropertyChanged при изменениях.
Первоначальное значение по умолчанию для этого свойства должно быть false.

Видимость границы привязывается к этому свойству с использованием существующего, но измененного TreeLineVisibilityConverter.

Конвертер должен быть превращен в IMultiValueConverter, так как нам нужно привязать к новому ProjectTreeItemViewModel.IsLast и к самому элементу, используя MultiBinding.

Всякий раз, когда новый элемент добавляется в TreeView, его шаблон будет загружен. Это вызовет MultiBinding и, следовательно, IMultiValueConverter. Конвертер проверяет, является ли текущий элемент последним. Если это так, он будет

  1. Установить предыдущий элемент ProjectTreeItemViewModel.IsLast на false, что приведет к повторному срабатыванию MultiBinding для предыдущего элемента, чтобы показать строку.

  2. Установить текущее ProjectTreeItemViewModel.IsLast на true.

  3. Вернуть соответствующее 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 из 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>

Примечания

По соображениям производительности следует учитывать добавьте свойство Parent к вашему ProjectTreeItemViewModel. Более эффективно обходить дерево моделей, чем обходить визуальное дерево. Затем в вашем ControlTemplate вы просто заменяете привязку к TemplatedParent (TreeViewItem) привязкой к DataContext из ControlTemplate, например, {Binding} (или <Binding /> в случае MultiBinding), который вернет текущее значение ProjectTreeItemViewModel. Отсюда вы можете проверить, является ли он последним, обратившись к свойству ProjectTreeItemViewModel.Children через ProjectTreeItemViewModel.Parent. Таким образом, вам не нужно использовать ItemContainerGenerator и не нужно приводить элементы ItemsControl.Items к IEnumerable<ProjectTreeItemViewModel>.



Пример представления дерева MVVM

Это простой пример того, как построить дерево с помощью MVVM. В этом примере делается вид, что создается дерево данных из текстового файла.
См. Класс ProjectTreeItem, чтобы узнать, как пройти по дереву с помощью рекурсии, например, GetTreeRoot().

В конце также пересмотренная версия TreeLineVisibilityConverter, показывающая, как вы можете попасть в родительскую коллекцию, используя ссылку Parent (и, следовательно, без необходимости использования static свойств).

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>
...