У меня есть пользовательский элемент управления в виде дерева, который использует панель стека виртуализации, поскольку мне нужно отображать около 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 (фокусируйтесь на нижней части видео).
Я подозреваю, что это связано с виртуализацией, но я могу ошибаться, при необходимости я мог бы предоставить больше кода, пожалуйста, посоветуйте что-нибудь, что может помочь, я боролся с этим уже пару дней, и у меня нет идей. Самое странное то, что для некоторых элементов это будет работать, для некоторых — нет, поэтому я думаю, что это проблема с виртуализацией.
Можете ли вы опубликовать стиль вашего TreeView и TreeViewItem? Похоже, вы настроили ControlTemplate.
Я думаю, вам следует отключить переработку контейнеров, если она включена. Это может вызвать проблемы, если вы привяжете его свойства к модели данных. Контейнер сохраняет свое состояние при повторном использовании с другим элементом. Например, это может привести к тому, что элемент будет отображаться как выбранный, хотя изначально это не так, просто потому, что он визуализируется с помощью контейнера, который был ранее выбран.
Установка 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, на которой мы работаем). Спасибо за предложения по производительности, посмотрю. Еще раз спасибо.
Единственное, что касается вашего предложения с функцией ExpandToItem(), это то, что оно не центрирует древовидное представление по выбранному элементу, что, как я предполагаю, связано с тем, что мы больше не вызываем TreeViewItem.BringIntoView(), есть идеи, как с этим бороться? Спасибо.
Без проблем. Как запустить поиск? Из кода программной части или из модели представления? Мне нужно синхронизировать поиск и прокрутку. Или ты думаешь, что сможешь это сделать? Я могу опубликовать очень простое решение, но оно будет синхронизировано с вашим поиском, поскольку оно должно выполняться после того, как вы выбрали модель данных. Если вы вызываете поиск из кода программной части, мы можем просто выполнить прокрутку как продолжение. Если из источника привязки, отличного от кода программной части, нам нужно ввести событие типа SearchCompleted
, которое возникает, если что-то было найдено. Какую версию вы предпочитаете?
Прямо сейчас я выполняю поиск в коде пользовательского элемента управления полем поиска, когда срабатывает событие TextChanged текстового поля. У меня также есть события для SelectionChanging и SelectionChanged, поэтому я думаю, что то, что вы предложили в первую очередь, работает. Если вы можете опубликовать пример, который очень поможет, дайте мне знать, если вам нужен пример кода того, как выглядит моя логика поиска. еще раз спасибо!
Я обновил ответ. Пожалуйста, еще раз просмотрите полный раздел «Обход на основе модели данных», поскольку я также обновил TreeView
XAML для регистрации обработчика событий. Функция отображения теперь выполняется из обработчика событий TextBox.TextChanged
сразу после успешного завершения поиска. Вам придется добавить это утверждение. Дайте мне знать, если возникнут какие-либо проблемы с кодом.
У вас есть несколько мест, где вы «BringIntoView». А цикл while может работать еще дольше. По сути, вы мечетесь вокруг, прежде чем остановиться на конкретном предмете.