WPF 的多功能 TreeView






4.95/5 (103投票s)
对常规 WPF TreeView 控件的强类型增强。
引言
我最近正在开发一个 WPF 项目,它主要涉及数据编辑和同步。数据是分层组织的,我必须根据各种外部因素,提供隔离和合并数据源的大量不同视图。
首先:我“喜欢”WPF 中的数据绑定——它让很多事情变得如此简单!因此,为了在 TreeView
控件上显示嵌套数据,我从分层数据模板开始。然而——我很快就遇到了难以解决的限制。尽管数据绑定到目前为止一直对我很有用,但当涉及到该树时,我就是需要从代码中获得更多的控制。此外,当树的数据源被刷新或替换时,我还遇到了其他一些问题,这导致了双向数据绑定的糟糕副作用。
底线是纯数据驱动的 UI 在我的情况下并不完全奏效——我需要一个替代方案。结果是我围绕 TreeView
构建了一个复合控件。这个解决方案解决了我的问题(至少是与树相关的问题,而我仍然可以依靠 TreeView
的基本功能,而无需重新发明轮子。结果简单而有效,所以我决定对其进行一些扩展,以获得一个通用的控件,我可以重用并与社区共享。这就是我现在必须写那篇该死的文章的原因...
目录
功能/示例应用程序
以下是最重要的功能一览
- 您可以调整树以获得最大性能、最小内存占用,甚至是按需获取数据并将其缓存直到您决定明确删除它的完全虚拟化解决方案。
- 该树提供对底层数据的简单且类型安全的访问(此处没有
object
类型的属性)。与标准TreeView
相反,SelectedItem
属性不是只读的。 - 简单的样式设置和排序。
- 在内部,UI(树节点)和绑定项之间有明确的区别。这解决了控件的重新加载和焦点问题。您可以通过绑定项(数据)获取树节点(UI),反之亦然,或者递归遍历所有树节点。
- 各种扩展点——许多代码是虚拟的,可以被重写以控制树的行为——直到创建单个
TreeViewItem
实例。 - 简化的上下文菜单处理。
- 树布局(展开/选定节点)可以保存和重新应用。
我在这里写的几乎所有内容都在随下载提供的示例应用程序中有所说明。如果你已经厌倦了阅读,你可以直接下载示例,玩一玩,如果需要更多信息,以后再回来。
实现你的树控件
关于术语:实际上,这是“hardcodet.net WPF TreeView Control”,因为我最初的版本发表在我的博客上。然而,我不会到处写这个,因为它听起来太自恋了,而且在 CodeProject 这里也毫无意义。由于我经常会引用 WPF 的内置 TreeView
控件,所以我将在本文中将其称为 V-Tree
(顺便说一句,这个名字是完全随机的)。
扩展 TreeViewBase<T>
V-Tree 的所有功能都由抽象的 TreeViewBase<T>
类提供。抽象意味着:你必须编写一些代码(天哪!)才能开始使用。但是,你很可能只需要编写不到 10 行代码,因为基类只需要知道三件事
- 如何为给定的树节点生成唯一标识符:
GetItemKey
- 如何获取绑定项的子项(如果可用):
GetChildItems
- 如何获取绑定项的父项(如果存在):
GetParentItem
有相当多的虚方法可以重写以控制树的行为,但这三个抽象方法可能就是你所需要的一切。下面是示例项目的 CategoryTree
控件的实现,它操作 ShopCategory
对象。正如你所看到的,CategoryTree
只需 3 行代码即可管理
//a tree control that handles ShopCategory objects
public class CategoryTree : TreeViewBase<ShopCategory>
{
//the sample uses the category's name as the identifier
public override string GetItemKey(ShopCategory item)
{
return item.CategoryName;
}
//returns subcategories that should be available through the tree
public override ICollection<ShopCategory> GetChildItems(ShopCategory parent)
{
return parent.SubCategories;
}
//get the parent category, or null if it's a root category
public override ShopCategory GetParentItem(ShopCategory item)
{
return item.ParentCategory;
}
}
提示:确保您的树控件类是 public
。否则,Visual Studio 中的 Intellisense 可能无法正常工作。
如果我想将各种类型的项绑定到树上怎么办?
如果您想将异构数据绑定到树上,您的绑定项很可能有一些共同点,并且让它们实现一个共同的接口或从自定义基类派生可能是一个很好的设计决策。但是,没有人阻止您实现一个处理 object
类型项的树控件,这允许您将几乎所有内容都抛给它。只是它不会那么方便了
//A tree that supports completely different items.
public class MyObjectTree : TreeViewBase<object>
{
public override string GetItemKey(object item)
{
if (item is ShopCategory)
{
return ((ShopCategory) item).CategoryName;
}
else if (item is Vendor)
{
return ((Vendor)item).VendorId;
}
else
{
...
}
}
public override ICollection<object> GetChildItems(object parent)
{
//some implementation
}
public override object GetParentItem(object item)
{
//some implementation
}
}
基本结构
我将使用的所有代码示例都引用了抽象 TreeViewBase
控件(即 CategoryTree
类)的示例实现。如下图所示,树的 Items
集合绑定到类型为 ShopCategory
的 ObservableCollection
。当需要在 UI 上呈现绑定数据时,TreeViewBase
使用一个常规的 TreeView
控件,并为每个绑定的 ShopCategory
创建一个 TreeViewItem
实例。
使用控件
本节介绍控件的功能。作为参考,下面是树的示例声明。请注意,下面的一些属性是冗余的(例如,IsLazyLoading
,它默认为 true)。
<local:CategoryTree x:Name="MyTree"
Items="{Binding Source={StaticResource Shop}, Path=Categories}"
IsLazyLoading="True"
ObserveChildItems="True"
ClearCollapsedNodes="True"
AutoCollapse="True"
RootNode="{StaticResource CustomRootNode}"
NodeContextMenu="{StaticResource CategoryMenu}"
TreeNodeStyle="{StaticResource SimpleFolders}"
TreeStyle="{StaticResource SimpleTreeStyle}"
NodeSortDescriptions="{StaticResource AscendingNames}"
PreserveLayoutOnRefresh="True"
SelectedItem="{Binding ElementName=Foo,
Path=Bar, Mode=TwoWay}"
SelectedItemChanged="OnSelectedItemChanged"
/>
设置 Items 属性
为了显示任何数据,需要设置树的 Items
依赖属性。Items
接受一个 IEnumerable<T>
,其中 T
是 V-Tree 实现处理的项目类型。在示例中,这些是 ShopCategory
对象。您可以在代码或 XAML 中设置此属性
<local:CategoryTree x:Name="MyTree"
Items="{Binding Source={StaticResource Shop}, Path=Categories}"
Items
为树提供了开始的根项。示例应用程序绑定到一个包含 3 个 ShopCategory
对象的集合
- 书籍
- 电影
- 音乐
现在发生的事情是这样的
- 对于三个
ShopCategory
项目中的每一个,树都会创建一个相应的TreeViewItem
实例,该实例分配给内部TreeView
控件。 - 如果树的一个节点被展开(如下图所示的“书籍”),控件通过调用
GetChildItems
来确定子类别,并为每个返回的项呈现子节点。在下面的截图中,这些是“小说”、“政治”和“旅行”类别。
它看起来还不那么性感,但它显然是一棵树
选定的项目
使用旧版 TreeView
控件选择项目有点麻烦,因为 SelectedItem
属性是只读的。然而,V-Tree 使这变得非常容易,因为它提供了一个强类型的 SelectedItem
属性,可以从外部设置
public virtual T SelectedItem
{
get { return (T) GetValue(SelectedItemProperty); }
set { SetValue(SelectedItemProperty, value); }
}
除了 SelectedItem
属性之外,还有一个 SelectedItemChanged
路由事件,它在选中项更改时立即被调用。RoutedTreeItemEventArgs
提供了树当前选中项和之前选中项。同样,此事件利用了类的强类型。
public void Test()
{
CategoryTree tree = new CategoryTree();
tree.SelectedItemChanged += Tree_SelectedItemChanged;
}
private void Tree_SelectedItemChanged(object sender, RoutedTreeItemEventArgs e)
{
//item properties are strongly typed - no conversion needed
ShopCategory newCategory = e.NewItem;
ShopCategory oldCategory = e.OldItem;
}
多重选择
就像内置的 TreeView
控件一样,V-Tree 不支持多选。
延迟加载、缓存和内存消耗
想象一下,您想在两种不同的场景中显示分层数据
- 数据本地可用,访问速度非常快。一个完全展开的树包含数千个节点。
- 数据正在从外部 Web 服务检索——一个请求可能需要相当长的时间,但数据量不大。
这些场景在管理树节点方面需要截然不同的概念。在第一种场景中,最好从一个空树开始,并按需创建树节点(延迟加载):一旦节点被展开。如果树节点被折叠,所有子节点都应该被处置,如果用户重新展开节点,则再次创建。
虽然延迟加载也可能是第二种场景的一个选项,但您不会希望处置数据——重新获取数据的成本太高。从 Web 服务检索到的数据应该在本地缓存,以防止重复请求相同的数据。
该控件提供了两个依赖项属性来支持这两种场景
IsLazyLoading
:此属性默认为 true,并告诉树在节点展开之前不要创建树节点。如果此属性设置为false
,则整个树将一次性创建。ClearCollapsedNodes
:此属性也默认为 true,并指示控件在节点的父级折叠时立即丢弃节点。这使得内存占用量最小,但如果父节点再次展开,会导致GetChildItems
被重新调用。如果IsLazyLoading
为 false,则此属性无效(因为它会一次性创建整个树)。
注意:即使 IsLazyLoading
为 true,您实现的 GetChildItems
方法默认情况下也会为每个可见节点调用,即使它尚未展开。这是因为树的 HasChildItems
方法的默认实现,该方法被调用以确定树节点是否应该带有或不带扩展器进行渲染。
protected virtual bool HasChildItems(T parent)
{
return GetChildItems(parent).Count > 0;
}
然而,这只适用于可见节点,并不会导致树遍历整个层次结构。但是,如果 GetChildItems
的开销确实很大,则应将其重写。即使在每种情况下都只返回 true 也是一个选项——有关更多详细信息,请查阅该方法的文档。
AutoCollapse 依赖属性
如果您希望树始终显示最少数量的节点,这个布尔依赖属性可能会派上用场。如果设置为 true,树将折叠所有不需要展开即可显示当前选定项的节点。查看示例应用程序以了解该功能。
示例
示例应用程序允许您独立设置这些属性,并在树控件底部显示当前内存中的树节点数量。下面是完整展开/折叠周期后的相同树两次:在第一个屏幕截图中,ClearCollapsedNodes
设置为 false
,第二个屏幕截图显示了该属性设置为 true
的树。您可以想象,对于一棵巨大的树,这会产生很大的差异
子集合监控与刷新
子集合监控
默认情况下,树会尝试监视所有现有树节点的子集合的变化,即使它们已折叠。这确保 UI 始终反映正确的状态(例如,如果所有子节点都被删除,则删除折叠树节点的展开器)。
重要:您的 GetChildItems
实现必须返回一个实现了 INotifyCollectionChanged
接口的集合,例如 ObservableCollection<T>
,才能使其正常工作。如果您返回另一个集合(例如一个简单的列表)并且 ObserveChildItems
为 true
,则不会抛出异常,但会向调试控制台写入警告。
您可以通过将树的 ObserveChildItems
依赖属性设置为 false
来明确禁用集合监控。
显式刷新树
如果树不能或不应该自行更新,您可以调用 V-Tree 的 Refresh
方法之一。这将导致控件完全重新创建底层树,同时可以选择保留其布局(展开/选定节点)。作为一个不错的副作用,您还可以使用此功能将任意布局应用于树
private void CopyTreeLayout(object sender, RoutedEventArgs e)
{
//get layout from tree A
TreeLayout layout = CategoryTree.GetTreeLayout();
//assign layout to tree B
SynchronizedTree.Refresh(layout);
}
样式化树
V-Tree 在 3 个级别提供样式设置点:绑定项、树节点和树本身。
数据模板
这是经典的 WPF 方式:不是对 UI 元素 (TreeViewItem
) 进行样式设置,而是为由树节点表示的绑定项(在示例中:ShopCategory
实例)定义一个 DataTemplate。只需确保数据模板在树的范围内(例如,声明为包含窗口的资源),即可完成设置。此技术应用于样式化示例应用程序的右手树
<Window.Resources>
<!--
A data template for bound ShopCategory items:
Shows a static folder image and the category name
-->
<DataTemplate DataType="{x:Type shop:ShopCategory}">
<StackPanel Orientation="Horizontal">
<Image Source="/Images/WinFolder.gif" />
<TextBlock Text="{Binding Path=CategoryName}"
Margin="2,0,0,0" />
</StackPanel>
</DataTemplate>
</Window.Resources>
节点样式
您可以通过设置树的 TreeNodeStyle
依赖项属性,显式地为所有树节点分配样式。此样式将分配给表示您的绑定项的每个树节点。树节点是 TreeViewItem
类型的元素,因此这是您的样式的目标类型。此技术应用于样式化示例应用程序的左侧树
<local:CategoryTree TreeNodeStyle="{StaticResource SimpleFolders}" />
<Style x:Key="SimpleFolders"
TargetType="{x:Type TreeViewItem}">
<Style.Resources>
<!-- override default brushes that show ugly background colors -->
<Brush x:Key="{x:Static SystemColors.HighlightBrushKey}">Transparent</Brush>
<Brush x:Key="{x:Static SystemColors.ControlBrushKey}">Transparent</Brush>
</Style.Resources>
<!-- everything else is done via the data template -->
<Setter Property="HeaderTemplate"
Value="{StaticResource CategoryTemplate}" />
</Style>
请注意,上面的样式仍然通过设置 TreeViewItem
的 HeaderTemplate
使用数据模板:UI 和数据之间的分离仍然有效,但您可以完全控制数据表示“和”您的树节点。
顺便说一句:如果您想以与其他节点完全不同的方式自定义某些节点,并且在 XAML 中无法实现,您可以重写 ApplyStyle
方法并单独设置节点样式。即使 TreeNodeStyle
属性为 null,此方法也始终被调用。
protected override void ApplyNodeStyle(TreeViewItem treeNode, ShopCategory item)
{
if (IsCheckableCategory(item))
{
//render the node with a checkbox
ApplyCheckBoxStyle(treeNode);
}
else
{
//just apply the default style
base(ApplyNodeStyle(treeNode, item);
}
}
树样式
在内部,V-TreeView 操作一个默认的 TreeView
控件,该控件处理大部分工作(此处无需重复造轮子)。您可以通过 Tree
依赖项属性注入自己的 TreeView
实例,但您可能只会让控件为您创建默认实例。尽管如此,您可能仍希望对树进行样式设置,您可以通过设置 V-Tree 的 TreeStyle
依赖项属性来完成此操作。它接受一个以 TreeView
为目标的样式。
<local:CategoryTree TreeStyle="{StaticResource SimpleTreeStyle}" />
<!-- set the tree's background and border properties -->
<Style x:Key="SimpleTreeStyle" TargetType="{x:Type TreeView}">
<Setter Property="Background" Value="#AAA" />
<Setter Property="BorderThickness" Value="4" />
<Setter Property="BorderBrush" Value="#FFA6AAAB" />
</Style>
排序
您可以通过将 IEnumerable<SortDescription>
设置到 V-Tree 的 NodeSortDescriptions
依赖属性来轻松排序数据。该属性的默认值为 null
,这将导致树以接收到的顺序呈现项目。
<local:CategoryTree NodeSortDescriptions="{StaticResource AscendingNames}" />
示例应用程序允许您在两个都在 XAML 中声明的集合之间进行切换。两者都根据 ShopCategory
项的 CategoryName
属性按升序或降序对树进行排序。请注意,声明的 SortDescriptions
的 PropertyName
属性包含一个 Header 前缀。这是因为排序发生在 UI 层(TreeViewItem
实例),而不是底层项。为了获取绑定的 ShopCategory
实例,您需要通过节点的 Header
属性
<!-- sorts categories by names in ascending order -->
<cm:SortDescriptionCollection x:Key="AscendingNames">
<cm:SortDescription PropertyName="Header.CategoryName"
Direction="Ascending" />
</cm:SortDescriptionCollection>
<!-- sorts categories by names in descending order -->
<cm:SortDescriptionCollection x:Key="DescendingNames">
<cm:SortDescription PropertyName="Header.CategoryName"
Direction="Descending" />
</cm:SortDescriptionCollection>
与样式一样,您可以重写一个虚方法来自定义特定节点的排序。下面的示例将跳过对绑定根项的排序。
protected override void ApplySorting(TreeViewItem node, ShopCategory item)
{
//only apply sorting for nested items (keep root item order)
if (item.ParentCategory != null)
{
base.ApplySorting(node, item);
}
}
自定义根节点
树提供了一个 RootNode
依赖项属性(默认为 null
),它接受一个任意的 TreeViewItem
。如果您不想让您的项目直接呈现在树下,而是一个提供一些信息的根节点,您可以设置此属性。请注意,此根节点不代表绑定项目,并且不受 TreeNodeStyle
或 NodeContextMenu
依赖项属性的影响。下面的屏幕截图显示了带和不带根节点的示例树。
<!-- a custom root node for the tree -->
<TreeViewItem x:Key="CustomRootNode">
<TreeViewItem.Header>
...
</TreeViewItem.Header>
</TreeViewItem>
<local:CategoryTree Items="{Binding Source={StaticResource Shop}, Path=Categories}"
RootNode="{StaticResource CustomRootNode}" />
上下文菜单
如果您想在所有节点上显示特定的上下文菜单,您可以设置树的 NodeContextMenu
依赖属性。这将导致树在其中一个树节点被右键单击时显示菜单。
<local:CategoryTree NodeContextMenu="{StaticResource CategoryMenu}" />
您可能会使用 WPF 的命令系统来触发菜单点击事件。示例应用程序提供了一个上下文菜单,允许用户添加新类别或删除现有类别(如果它们不是根类别)。对于示例,我使用了内置的应用程序命令 New
和 Delete
。
(如果您不熟悉 WPF 命令,这里有一个很好的介绍:命令概述)
<Window.Resources>
<!-- the context menu for the tree -->
<ContextMenu x:Key="CategoryMenu">
<MenuItem Header="Add Subcategory"
Command="New">
<MenuItem.Icon>
<Image Source="/Images/Add.png" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Remove Category"
Command="Delete">
<MenuItem.Icon>
<Image Source="/Images/Remove.png" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</Window.Resources>
为了处理命令,MainWindow.xaml(不是树控件!)声明了两个命令的事件处理程序
<Window.CommandBindings>
<!-- bindings for context menu commands -->
<CommandBinding Command="New"
Executed="AddCategory" />
<CommandBinding Command="Delete"
CanExecute="EvaluateCanDelete"
Executed="DeleteCategory" />
</Window.CommandBindings>
以下是代码。这里重要的是 GetCommandItem()
,它必须考虑到两种可能性
- 该命令是由上下文菜单中点击的菜单项触发的。但是,右键点击的树节点可能不是当前树上选定的节点,因此仅选择 V-Tree 的 SelectedItem 可能是错误的。但是,如果点击了菜单项,菜单的
IsVisible
属性为 true,并且 V-Tree 将点击的节点分配给菜单的PlacementTarget
属性。 - 该命令已通过快捷方式触发(例如,通过单击 Delete 键)。在这种情况下,上下文菜单根本没有参与。菜单的
IsVisible
属性为 false,但我们可以确定该命令引用树的当前选定项。
/// <summary>/// Determines the item that is the source of a given command.
/// As a command event can be routed from a context menu click
/// or a short-cut, we have to evaluate both possibilities.
/// </summary>
///// <returns></returns>
private ShopCategory GetCommandItem()
{
//get the processed item
ContextMenu menu = CategoryTree.NodeContextMenu;
if (menu.IsVisible)
{
//a context menu was clicked
TreeViewItem treeNode = (TreeViewItem) menu.PlacementTarget;
return (ShopCategory) treeNode.Header;
}
else
{
//the context menu is closed - the user has pressed a shortcut
//-> the command was triggered from the currently selected item
return CategoryTree.SelectedItem;
}
}
/// <summary>
/// Creates a sub category for the clicked item
/// and refreshes the tree.
/// </summary>
private void AddCategory(object sender, ExecutedRoutedEventArgs e)
{
//get the processed item
ShopCategory parent = GetCommandItem();
...
//mark the event as handled
e.Handled = true;
}
/// <summary>
/// Checks whether it is allowed to delete a category, which is only
/// allowed for nested categories, but not the root items.
/// </summary>
private void EvaluateCanDelete(object sender, CanExecuteRoutedEventArgs e)
{
//get the processed item
ShopCategory item = GetCommandItem();
e.CanExecute = item.ParentCategory != null;
e.Handled = true;
}
/// <summary>
/// Deletes the currently processed item. This can be a right-clicked
/// item (context menu) or the currently selected item, if the user
/// pressed delete.
/// </summary>
private void DeleteCategory(object sender, ExecutedRoutedEventArgs e)
{
//get item
ShopCategory item = GetCommandItem();
//remove from parent
item.ParentCategory.SubCategories.Remove(item);
//mark the event as handled
e.Handled = true;
}
显示上下文菜单之前选择项目
如果您希望树在右键单击节点时自动选择它们,可以将 SelectNodesOnRightClick
依赖属性设置为 true
。在这种情况下,您无需评估 ContextMenu.PlacementTarget
,因为点击的项在任何情况下都会被选中。
更新和新闻通讯
这是我为 WPF 编写的第一个控件,我相信会有一些 Bug 需要修复,以及一些不错的改进,希望这些都基于您的批判性反馈(毕竟,这一切都是为了学习)。所以请回来查看更新并继续提出建议
未来版本的控件将发布在 CodeProject,我也将在我的博客上发布相关信息。如果您希望自动收到相关更新通知,您可以在此处订阅特定新闻稿:http://www.hardcodet.net/newsletter。只需注册,然后从可用新闻稿列表中选择控件即可。
希望您会发现该控件能为您的工具箱增添有用的功能——祝您玩得开心!
更新日志
1.0.5 首次公开发布(2008.01.29)
1.0.6 为属性和事件添加了设计器属性,修复了一些文章问题。切换到 CPOL(2008.02.08)。