Search code examples
wpftreeviewvirtualizationtreeviewitemvirtualizingstackpanel

WPF - Virtualizing Tree View - SelectedItem selection desappearing after item is selected through a Custom Search Box


I have a custom tree view user control that is using a Virtualizing Stack Panel since i have to display ~8000 items. The tree view also lives in a groupbox with custom SearchBox user control that allows users to search a tree view item and select it from the searchbox results.

The Search Box has a dependency property for the SelectedItem and the custom tree view also has a SelectedItem property that get set on the SelectionChanged event. in my view model i am binding a single SelectedItem property to both dependency properties stated above.

Since i am using a virtualizing stack panel and selecting items from the search box, I needed some additional logic to find the TreeViewItem that matched the selectedItem in the search box, for this I followed the following article: https://learn.microsoft.com/en-us/dotnet/desktop/wpf/controls/how-to-find-a-treeviewitem-in-a-treeview?view=netframeworkdesktop-4.8. I made my own implementation so that it was faster.

Here is the code:

       private static TreeViewItem GetTreeViewItem(ItemsControl container, object item)
   {
       if (container == null) 
           return null;

       var rootFolder = (Folder)container.DataContext;
       var folderTreeItem = (IFolderTreeItem)item;

       var parentStack = new Stack<Folder>();
       var parentFolder = folderTreeItem.ParentFolder;
       while (parentFolder != rootFolder)
       {
           parentStack.Push(parentFolder);
           parentFolder = parentFolder.ParentFolder;
       }

       var rootTreeViewItem = (TreeViewItem)container;
       rootTreeViewItem.BringIntoView();
       rootTreeViewItem.IsExpanded = true;
       
       var virtualizingPanel = GetVirtualizingStackPanel(container);
       var currentFolder = rootFolder;
       while (parentStack.Count > 0)
       {
           var childFolder = parentStack.Pop();
           
           var childIndex = currentFolder.SubFolders.IndexOf(childFolder);
           
           if (childIndex == -1)
               throw new Exception();
           
           virtualizingPanel.BringIntoView(childIndex);
           var currentTreeViewItem = (TreeViewItem)rootTreeViewItem.ItemContainerGenerator.ContainerFromIndex(childIndex);
           currentTreeViewItem.IsExpanded = true;
           currentFolder = childFolder;
           rootTreeViewItem = currentTreeViewItem;
           virtualizingPanel = GetVirtualizingStackPanel(currentTreeViewItem);
       }
       
       var currentIndex = rootTreeViewItem.ItemContainerGenerator.Items.Cast<IFolderTreeItem>().ToList().FindIndex(tv => tv.Equals( folderTreeItem));
       
       virtualizingPanel.BringIntoView(currentIndex);
       var treeView = (TreeViewItem)rootTreeViewItem.ItemContainerGenerator.ContainerFromIndex(currentIndex);
       
       return treeView;
   }

Here is what my data model looks like:

    /// <summary>
/// Interface that propages path in order for a class to be displayed in a folder tree view
/// </summary>
internal interface IFolderTreeItem
{
    /// <summary>
    /// Gets the path folder tree where item located
    /// </summary>
    string FolderTreePath { get; }
    
    /// <summary>
    /// Gets or sets the folder where this item lives
    /// </summary>
    Folder ParentFolder { get; set;  }
    
    /// <summary>
    /// Gets the name of the object that will be displayed
    /// </summary>
    string Name { get; }
    
    /// <summary>
    /// Gets or sets the list of items to display on the tree view
    /// </summary>
    IEnumerable AllItems { get; }
}

Note: Folder being an instance of IFolderTreeItem Note: Im using AllItems property to bind to the HierarchicalDataTemplate. Note: GetVirtualizingStackPanel() and GetVisualChild() will match Microsoft article. Note: Not using any behaviors or attached properties (only drag/drop library but that didnt seem to be causing the issue).

Everything that I have stated before is working fine, the issue that I have, is that for some items that were selected from the search box results, the TreeViewItem selection is appearing and disappearing right after. see video example here: https://imgur.com/a/eadwBGg (focus on the bottom of the video).

My suspicion is that it is related to virtualization but I could be wrong, I could provide more code if needed, please advice on anything that could help, i have been battling this for a couple of days and i am out of ideas. The weirdest part is that for some items it will work, for some items it wont, which its why i think its an issue with virtualization.


Solution

  • I think you should disable container recycling if enabled. It can cause issues if you bind its properties to the data model. The container maintains its state when being reused with a different item. For example, this can lead to an item appearing as selected although it originally wasn't, just because it is rendered with a container that was previously selected.
    Setting VirtualizingStackPanel.VirtualizationMode to VirtualizationMode.Standard can already solve your issue.

    It looks like you have overridden the ControlTemplate of the TreeViewItem to modify the look. I further assume that you have not implemented states using the VisualStateManager or that you have not implemented all states. If you don't implement the SelectedInactive visual state, then TreeView will not be able to visualize a selected item after it lost focus. From your video it looks like the item is selected properly. But since the focus moves back to the TextBox it appears to unselected due to the missing visual state that handles this special case.

    Alternatively, move the focus to the selected TreeViewItem. However, I wouldn't do that as it means that you have to move the focus away from the search TextBox which can be quite annoying if the user wants to continue the search.

    If the visual state is not the issue, you probably fix the issue by implementing the traversal algorithm more cleanly (see proposal below).

    In terms of performance, you can further improve the search algorithm by avoiding accessing the ControlTemplate of each TreeViewItem to obtain the hosting Panel. You are calling GetVirtualizingStackPanel two times! And you even call ItemContainerGenerator three times! Additionally, you call IndexOf which is another O(n) operation on a potentially "~8000 items" big collection.
    Especially this line is really expensive (note you have "~8000 items"):

    var currentIndex = rootTreeViewItem.ItemContainerGenerator.Items
      .Cast<IFolderTreeItem>() // Deferred
      .ToList() // First iteration (always! completely)
      .FindIndex(tv => tv.Equals( folderTreeItem)); // Second iteration
    

    Worst case: assuming that you have a single node that holds all 8k items and the searched item is the last or doesn't exist, then the preceding line causes 16k iterations: one for Enumerable.ToList (ToList will always result in full 8k iterations) and the second for List.FindIndex.

    In this case, LINQ will not help as you have to call ToList to get access to the FindIndex method. LINQ will only add another cost of an O(n) operation.
    You must manually perform the iteration:

    int folderItemsCount = rootTreeViewItem.ItemContainerGenerator.Items.Count;
    var folderItems = rootTreeViewItem.ItemContainerGenerator.Items;
    int currentIndex = -1;
    for (int itemIndex = 0; itemIndex < folderItemsCount; itemIndex++)
    {
     IFolderTreeItem folderItem = (IFolderTreeItem)folderItems[itemIndex];
     if (folderItem.Equals(folderTreeItem)
     {
       currentIndex = itemIndex;
       break;
     }
    }
    

    A by far simpler TreeView traversal algorithm that will also perform faster could look as follows (you can convert the recursive implementation to an iterative if you like):

    // the parameter 'startNode' can be the TreeView (root) 
    // or a TreeViewitem
    private async Task<TreeViewItem> GetTreeViewItemAsync(ItemsControl startNode, object itemToFind)
    {
      if (startNode is TreeViewItem treeViewItem)
      {
        // Expanding the container automatically brings it into view
        treeViewItem.IsExpanded = true;
    
        // In case the conatainer was already expanded
        treeViewItem.BringIntoView();
    
        // Wait for container generation to complete
        await this.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ContextIdle);
            
        if (treeViewItem.Header == itemToFind)
        {
          return treeViewItem;
        }
      }
    
      foreach (object childItem in startNode.Items)
      {
        var childItemContainer = startNode.ItemContainerGenerator.ContainerFromItem(childItem) as TreeViewItem;
    
        bool isExpandedOriginal = childItemContainer.IsExpanded;
        TreeViewItem? result = await GetTreeViewItem(childItemContainer, item);
        if (result is not null)
        {
          return result;
        }
    
        // Only keep the result subtree expanded
        // but only if the node wasn't expanded before by the user
        if (!isExpandedOriginal)
        {
          childItemContainer.IsExpanded = false;
        }
      }   
    
      return null;
    }
    

    Data Model Based Traversal

    If you want to further improve the performance you should traverse the tree on the data model side.
    WPF is usually faster and requires less lines of code if you work on data models instead of UI containers. You also don't have to worry about container generation in context of Ui virtualization.

    Let your data model implement an IsExpanded and IsSelected property and bind it to the TreeViewItem. To trigger the node to be scrolled into the viewport we also track the TreeViewItem.Selected event:

    MainWindiw.xaml

    <TreeView>
      <TreeView.ItemContainerStyle>
        <Style TargetType="TreeViewItem">
          <Setter Property="IsExpanded"
                  Value="{Binding IsExpanded, Mode=TwoWay}" />
          <Setter Property="IsSelected"
                  Value="{Binding IsSelected}" />
          <EventSetter Event="Selected"
                       Handler="OnTreeViewItemSelected" />
        </Style>
      </TreeView.ItemContainerStyle>
    </TreeView>
    

    It's also useful to change the class design and make use of polymorphism to avoid type checks and casts. When traversing a filesystem like tree to find an item it really doesn't matter if you are searching a directory or a file (node or leaf). On object level you just compare instances for reference equality. This way you can further improve the algorithm and drop some casts and equality checks for ~8k items:

    IParent.cs
    A node that can have children.

    interface IParent
    {
      ObservableCollection<TreeItem> Children { get; }
    }
    

    TreeItem.cs
    The base class for a node model.

    All binding sources must implement INotifyPropertyChanged (your IFolderTreeItem does not seem to implement it). Otherwise, the memory leak will be huge for ~8k item containers.

    abstract class TreeItem : INotifyPropertyChanged
    {
      public bool IsSelected { get; set; }
      public TreeItem Parent { get; set; }
      public string Name { get; set; }
    }
    

    Folder.cs
    A specialized TreeItem node that can have children.

    class Folder : TreeItem, IParent
    {
      public bool IsExpanded {get; set; }
      public ObservableCollection<TreeItem> Children { get; set; }
    }
    

    FolderItem.cs
    The leaf node. This type only makes sense if the leaf is never allowed to have children (like a file in a file system tree).

    class FolderItem : TreeItem
    {
    }
    

    Next, implement the tree traversal for the TreeItem based tree structure:

    private TreeDataItem ExpandToItem(TreeItem startNode, TreeItem itemToFind)
    {
      if (startNode == itemToFind)
      {
        return startNode;
      }
    
      if (startNode is not IParent parentingNode)
      {
        return null;
      }
    
      parentingNode.IsExpanded = true;
      foreach (TreeItem treeItem in parentingNode.Children)
      {
        bool isExpandedOriginal = treeItem.IsExpanded;
        TreeItem childItem = ExpandToItem(treeItem, itemToFind);
        if (childItem is not null)
        {
          return childItem;
        }
    
        // No match found in subtree -> collapse and continue with next subtree
        if (!isExpandedOriginal)
        {
          treeItem.IsExpanded = false;
        }
      }
    
      return null;
    }
    

    Finally, bring the selected item into view. We have to be a little creative to avoid another expensive TreeView traversal.
    We make use of the behavior that the TreeItem.IsSelected is propagated to the TreeViewItem.IsSelected (via data binding defined in the ItemContainerStyle) as soon as the TreeViewItem was generated. Scrolling an item into view trigger container generation.

    So, all we have to do is to scroll until the TreeViewitem.Selected event is raised (because the container has been generated). MainWindow.xaml.cs

    private bool isItemSelected;
    private ScrollViewer scrollViewer;
    
    private async void OnTextChanged(object sender, RoutedEventArgs e)
    {
      this.isItemSelected = false;
    
      // TODO::Perform search and replace below lines of dummy code
      bool isSearchSuccessful = FindAndSelectItem();
      if (!isSearchSuccessful)
      {
        return;
      }
    
      /*** Bring the selected item container into view ***/
    
      if (this.scrollViewer is null)
      {
        if (!TryFindVisualChildElement(this.MvvmTreeView, out this.scrollViewer))
        {
          // No ScrollViewer found. Want to throw?
          return;
        }
      }
    
      // Scrolling causes the items to be generated.
      // The TreeViewItem.IsSelected property is set via data binding 
      // once the container is generated. We then can stop scrolling.
      //
      // Start scrolling from the top.
      scrollViewer.ScrollToTop();
      while (!this.isItemSelected)
      {
        // Use the Dispatcher with an appropriate priority
        // to prevent flooding the UI thread
        await this.Dispatcher.InvokeAsync(scrollViewer.PageDown, DispatcherPriority.Input);
      }
    }
    
    private void OnTreeViewItemSelected(object sender, RoutedEventArgs e)
    {
      // Disable scrolling
      this.isItemSelected = true;
    
      var treeViewItem = (TreeViewItem)sender;
      treeViewItem.BringIntoView();
    }
    
    public static bool TryFindVisualChildElement<TChild>(
      DependencyObject? parent,
      out TChild? resultElement) where TChild : DependencyObject
    {
      resultElement = null;
    
      if (parent == null)
      {
        return false;
      }
    
      if (parent is Popup popup)
      {
        parent = popup.Child;
        if (parent == null)
        {
          return false;
        }
      }
    
      for (int childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
      {
        DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);
    
        resultElement = childElement as TChild;
        if (resultElement is not null)
        {
          return true;
        }
    
        if (TryFindVisualChildElement(childElement, out resultElement))
        {
          return true;
        }
      }
    
      return false;
    }