WPF – виртуализация древовидного представления – выбор SelectedItem исчезает после выбора элемента через поле пользовательского поиска

У меня есть пользовательский элемент управления в виде дерева, который использует панель стека виртуализации, поскольку мне нужно отображать около 8000 элементов. Древовидное представление также находится в групповом поле с настраиваемым пользовательским элементом управления SearchBox, который позволяет пользователям выполнять поиск по элементу древовидного представления и выбирать его из результатов окна поиска.

Поле поиска имеет свойство зависимости для SelectedItem, а пользовательское древовидное представление также имеет свойство SelectedItem, которое устанавливается в событии SelectionChanged. в моей модели представления я привязываю одно свойство SelectedItem к обоим свойствам зависимостей, указанным выше.

Поскольку я использую панель стека виртуализации и выбираю элементы из поля поиска, мне понадобилась дополнительная логика, чтобы найти TreeViewItem, соответствующий выбранному элементу в поле поиска. Для этого я воспользовался следующей статьей: https://learn. microsoft.com/en-us/dotnet/desktop/wpf/controls/how-to-find-a-treeviewitem-in-a-treeview?view=netframeworkdesktop-4.8. Я сделал свою собственную реализацию, чтобы она была быстрее.

Вот код:

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

Вот как выглядит моя модель данных:

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

Примечание. Папка является экземпляром IFolderTreeItem. Примечание. Я использую свойство AllItems для привязки к HierarchicalDataTemplate. Примечание. GetVirtualizingStackPanel() и GetVisualChild() будут соответствовать статье Microsoft. Примечание. Не используются какие-либо варианты поведения или прикрепленные свойства (только библиотека перетаскивания, но, похоже, это не вызывает проблемы).

Все, что я заявлял ранее, работает нормально, проблема, с которой я столкнулся, заключается в том, что для некоторых элементов, которые были выбраны из результатов окна поиска, выбор TreeViewItem появляется и сразу же исчезает. пример видео можно посмотреть здесь: https://imgur.com/a/eadwBGg (фокусируйтесь на нижней части видео).

Я подозреваю, что это связано с виртуализацией, но я могу ошибаться, при необходимости я мог бы предоставить больше кода, пожалуйста, посоветуйте что-нибудь, что может помочь, я боролся с этим уже пару дней, и у меня нет идей. Самое странное то, что для некоторых элементов это будет работать, для некоторых — нет, поэтому я думаю, что это проблема с виртуализацией.

У вас есть несколько мест, где вы «BringIntoView». А цикл while может работать еще дольше. По сути, вы мечетесь вокруг, прежде чем остановиться на конкретном предмете.

Gerry Schmitz 03.07.2024 04:25

Можете ли вы опубликовать стиль вашего TreeView и TreeViewItem? Похоже, вы настроили ControlTemplate.

BionicCode 03.07.2024 11:54
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
2
51
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Я думаю, вам следует отключить переработку контейнеров, если она включена. Это может вызвать проблемы, если вы привяжете его свойства к модели данных. Контейнер сохраняет свое состояние при повторном использовании с другим элементом. Например, это может привести к тому, что элемент будет отображаться как выбранный, хотя изначально это не так, просто потому, что он визуализируется с помощью контейнера, который был ранее выбран.
Установка VirtualizingStackPanel.VirtualizationMode на VirtualizationMode.Standard уже может решить вашу проблему.

Похоже, вы переопределили ControlTemplate из TreeViewItem, чтобы изменить внешний вид. Кроме того, я предполагаю, что вы не реализовали состояния с помощью VisualStateManager или что вы реализовали не все состояния. Если вы не реализуете визуальное состояние SelectedInactive, то TreeView не сможет визуализировать выбранный элемент после того, как он потерял фокус. Судя по вашему видео, товар выбран правильно. Но поскольку фокус возвращается к TextBox, он кажется невыбранным из-за отсутствия визуального состояния, которое обрабатывает этот особый случай.

Либо переместите фокус на выбранный TreeViewItem. Однако я бы не стал этого делать, поскольку это означает, что вам придется отодвинуть фокус от поиска TextBox, что может сильно раздражать, если пользователь захочет продолжить поиск.

Если проблема не в визуальном состоянии, вы, вероятно, решите проблему, реализовав алгоритм обхода более четко (см. предложение ниже).

С точки зрения производительности вы можете еще больше улучшить алгоритм поиска, избегая доступа к ControlTemplate каждого TreeViewItem для получения хостинга Panel. Вы звоните GetVirtualizingStackPanel два раза! И ты даже звонишь ItemContainerGenerator три раза! Кроме того, вы вызываете IndexOf, что является еще одной операцией O(n) над потенциально большой коллекцией «~8000 элементов».
Особенно эта линия очень дорогая (обратите внимание, что у вас «~ 8000 предметов»):

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

Худший случай: предположим, что у вас есть один узел, содержащий все 8 тысяч элементов, а искомый элемент является последним или не существует, тогда предыдущая строка вызывает 16 тысяч итераций: одна для Enumerable.ToList (ToList всегда приводит к полным 8 тысячам итераций) и второй для List.FindIndex.

В этом случае LINQ не поможет, так как вам придется вызвать ToList, чтобы получить доступ к методу FindIndex. LINQ лишь добавит еще одну стоимость операции O(n).
Вы должны вручную выполнить итерацию:

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

Гораздо более простой TreeView алгоритм обхода, который также будет работать быстрее, может выглядеть следующим образом (при желании вы можете преобразовать рекурсивную реализацию в итеративную):

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

Обход на основе модели данных

Если вы хотите еще больше повысить производительность, вам следует пройти по дереву со стороны модели данных.
WPF обычно работает быстрее и требует меньше строк кода, если вы работаете с моделями данных, а не с контейнерами пользовательского интерфейса. Вам также не нужно беспокоиться о создании контейнеров в контексте виртуализации пользовательского интерфейса.

Пусть ваша модель данных реализует свойства IsExpanded и IsSelected и привязывает их к TreeViewItem. Чтобы инициировать прокрутку узла в область просмотра, мы также отслеживаем событие TreeViewItem.Selected:

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>

Также полезно изменить дизайн класса и использовать полиморфизм, чтобы избежать проверок и приведения типов. При перемещении по файловой системе, такой как дерево, для поиска элемента на самом деле не имеет значения, ищете ли вы каталог или файл (узел или лист). На уровне объекта вы просто сравниваете экземпляры на предмет равенства ссылок. Таким образом, вы можете еще больше улучшить алгоритм и отказаться от приведения и проверки равенства для ~8 тыс. элементов:

IParent.cs
Узел, который может иметь дочерние элементы.

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

TreeItem.cs
Базовый класс для модели узла.

Все источники привязки должны реализовать INotifyPropertyChanged (ваш IFolderTreeItem, похоже, не реализует это). В противном случае утечка памяти будет огромной для контейнеров элементов размером около 8 тыс.

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

Папка.cs
Специализированный узел TreeItem, у которого могут быть дети.

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

FolderItem.cs
Листовой узел. Этот тип имеет смысл только в том случае, если лист никогда не имеет дочерних элементов (например, файл в дереве файловой системы).

class FolderItem : TreeItem
{
}

Далее реализуем обход дерева для древовидной структуры на основе TreeItem:

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

Наконец, поместите выбранный элемент в поле зрения. Нам придется проявить немного изобретательности, чтобы избежать еще одного дорогостоящего TreeView обхода.
Мы используем поведение, при котором TreeItem.IsSelected распространяется на TreeViewItem.IsSelected (через привязку данных, определенную в ItemContainerStyle), как только TreeViewItem был сгенерирован. Прокрутка элемента в поле зрения запускает генерацию контейнера.

Итак, все, что нам нужно сделать, это прокрутить до тех пор, пока не возникнет событие TreeViewitem.Selected (потому что контейнер уже создан). 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;
}

Привет, я ценю, что нашел время, чтобы отправить такой подробный ответ. У меня действительно был установлен режим виртуализации «Переработка», что приводило к выбору нескольких элементов, как вы упомянули. Установив это значение как стандартное и добавив свойства IsSelected и IsExpanded в мою модель, я смог исправить все свои проблемы. Для реализации INotifyPropertyChanged я использую легкие библиотеки Mvvm (хотя они устарели, но это единственная среда, которую я могу использовать из-за ограничений платформы .net, на которой мы работаем). Спасибо за предложения по производительности, посмотрю. Еще раз спасибо.

Jean 03.07.2024 16:55

Единственное, что касается вашего предложения с функцией ExpandToItem(), это то, что оно не центрирует древовидное представление по выбранному элементу, что, как я предполагаю, связано с тем, что мы больше не вызываем TreeViewItem.BringIntoView(), есть идеи, как с этим бороться? Спасибо.

Jean 03.07.2024 17:24

Без проблем. Как запустить поиск? Из кода программной части или из модели представления? Мне нужно синхронизировать поиск и прокрутку. Или ты думаешь, что сможешь это сделать? Я могу опубликовать очень простое решение, но оно будет синхронизировано с вашим поиском, поскольку оно должно выполняться после того, как вы выбрали модель данных. Если вы вызываете поиск из кода программной части, мы можем просто выполнить прокрутку как продолжение. Если из источника привязки, отличного от кода программной части, нам нужно ввести событие типа SearchCompleted, которое возникает, если что-то было найдено. Какую версию вы предпочитаете?

BionicCode 03.07.2024 19:50

Прямо сейчас я выполняю поиск в коде пользовательского элемента управления полем поиска, когда срабатывает событие TextChanged текстового поля. У меня также есть события для SelectionChanging и SelectionChanged, поэтому я думаю, что то, что вы предложили в первую очередь, работает. Если вы можете опубликовать пример, который очень поможет, дайте мне знать, если вам нужен пример кода того, как выглядит моя логика поиска. еще раз спасибо!

Jean 03.07.2024 20:10

Я обновил ответ. Пожалуйста, еще раз просмотрите полный раздел «Обход на основе модели данных», поскольку я также обновил TreeView XAML для регистрации обработчика событий. Функция отображения теперь выполняется из обработчика событий TextBox.TextChanged сразу после успешного завершения поиска. Вам придется добавить это утверждение. Дайте мне знать, если возникнут какие-либо проблемы с кодом.

BionicCode 03.07.2024 23:20

Другие вопросы по теме