高级 WPF TreeViews(第 1 部分)






4.96/5 (28投票s)
关于 WPF TreeViews 的高级技巧与窍门列表
- 下载 CollectionView.zip
- 下载 SortableObservableCollection.zip
- 下载 SortableObservableDictionary.zip
- 下载 SortableObservableDirectionaryCollection.zip
- 下载 SortableObservableDirectionaryCollection_2.zip
- 下载 SortableObservableDirectionaryCollection_3.zip
- 下载 00_InplaceEditBoxLib.zip
引言
本文回顾了 WPF 树视图实现中的各种模式。我们调查了不同的场景,并试图指出为什么有些方法有时不太有用。理解本文所呈现信息的前提是,您对 Josh Smith 的文章 [1] 有一个良好而清晰的理解。本系列的下一部分将基于本文所呈现的信息,并从虚拟化和效率(速度)的角度来看待它。
本文试图回答一些我未能在其他文章中找到答案的高级问题,这些问题最多也只是零散地分布在一些 MSDN 或 Stackoverflow 页面上。我们将尝试回答诸如此类的问题:
- 我们应该使用
ObservableCollection
还是ObservableDictionary
来实现每个节点下的子节点集合?
- 我如何将
Interfaces
与HierarchicalDataTemplate
一起使用?
我们将尝试用附带的代码示例来解释每一点,这应有助于将概念与实际应用联系起来。
背景
www.codeplex.com 网站即将关闭,我们被要求将项目迁移到 GitHub。我正在利用这个过程来审查我当前的控件实现,在我认为合适的地方更新或改进它们,并将结果发布在 GitHub 和 Nuget 上。这个过程引导我重新审视了我当前的文件系统控件实现,并且我很快意识到,我应该回顾一下原地编辑控件,并对不同树视图实现在功能方面的比较进行一些研究。这项研究的结果是一个 Visual Studio 项目资源管理器的原型(见附件或 GitHub 项目 InplaceEditBoxLib 库)。
我想通过本文和附带的代码来总结我是如何做到这一点的,并为未来其他树视图应用的实现提供参考点。
目录
将节点绑定到集合
一个树视图有子节点,子节点又有子节点,以此类推。
+-> Root +- Child 1 | +- Child 2 | +- Child 3 | +-> Child 4
...并且每个集合(这里指 Root、Child 1 - Child 3 和 Child 4)都可以用多种不同的方式实现,使用不同类型的集合(List、ObservableCollection 等)和绑定。但真正有趣的问题是,哪种类型的集合最适合我的目的?
我的树视图目的最好地体现在这个 youtube 视频 中,该视频截取自实际的 WPF 原地编辑控件实现。该视频总结了我的需求,在比较不同解决方案及其属性时应该会有所帮助。
- CollectionView
- SortableObservableCollection,
- SortableObservableDictionary,或者
- SortableObservableDirectionaryCollection
我主要寻求以下要求:
- 项目列表按字母顺序排序
- 重命名一个项目会导致显示的列表重新排序。重命名的项目会被带到其正确的位置并进入视图。重命名的项目拥有输入焦点。
- 在每个子节点列表中,所有项目都有唯一的名称。
还有一些相关的要求,比如处理无效字符的输入和检查输入字符串的长度。我在这里省略了这些要求,因为它们更多地适用于原地编辑控件,而不是绑定到树视图的结构。请在 GitHub 仓库中查看这些细节。
示例应用概览
提到的每个实现场景都可以在附带的代码示例中进行验证。对于这些示例,我假设我们想要列出 GitHub 用户及其项目。因此,每个示例应用程序至少包含以下类:
附加行为只是一些标准类,它们附加到一个事件(这里是树视图的项选择和选择更改事件),并以可绑定的 ICommand
方式转发该事件机制。我通常在想要将选定的树视图项带入视图,或者想知道当前选定的项是什么时使用这些附加行为。
AppViewModel
是连接到 MainWindow
的 DataContext
的主视图模型。它包含一个 GitHubViewModel
的实例,该实例代表 GitHub 树的根视图模型。
用户可以在树中选择1个项目,在文本框中更改字符串,然后点击重命名按钮来重命名该项目。重命名的项目随后应移动到其新的正确位置,并在那里被选中,正如我试图在上面的视频中概述的那样。我选择这个示例实现是因为它比使用原地编辑文本框控件更容易理解和调试。
重要的是要理解,GitHubViewModel
对象并不是树中可见的根项目。根项目维护在该类的 Root
属性中。
public ObservableCollection<GitHubItemViewModel> Root
{
get
{
return _Root;
}
}
项目树由 GitHubItemViewModel
对象组成。任何树中只需要1个根项目。因此,我们可以在所有示例中都使用上述根定义。您应该能看到,GitHubViewModel
中的代码始终确保我们在任何给定时间只有一个根项目。
比根项目更有趣的是每个后续示例中 GitHubItemViewModel
里的 Children
,我们接下来将讨论它。
CollectionView
这个示例实现包含了使用 ObservableCollection
来实现 WPF 树视图的标准入门级方法,应用于所有的 Children
集合。这里唯一值得注意的部分是 IMultiValueConverter
IListToListCollectionViewConverter。
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var Length = values.Length;
if (Length >= 1 && Length < 3)
{
// First parameter should be list to be sorted
var IList = values[0] as IList;
// Second parameter should be name to sort by
var SortName = string.Empty;
if (Length > 1)
SortName = values[1].ToString();
// Third parameter SortDirection is optional
var SortDirection = ListSortDirection.Ascending;
if (Length > 2)
SortDirection = values[2] is ListSortDirection
? (ListSortDirection)values[2] : (values[2] is string
? (ListSortDirection)Enum.Parse(typeof(ListSortDirection), values[2].ToString()) : SortDirection);
var Result = new ListCollectionView(IList);
Result.SortDescriptions.Add(new SortDescription(SortName, SortDirection));
Result.IsLiveSorting = true;
return Result;
}
return null;
}
这是 XAML 实现:
<HierarchicalDataTemplate>
<HierarchicalDataTemplate.ItemsSource>
<MultiBinding Converter="{StaticResource IListToListCollectionViewConverter}">
<Binding Path="Children" />
<Binding Source="{StaticResource NameOfSortProperty}" />
</MultiBinding>
</HierarchicalDataTemplate.ItemsSource>
<Grid>
<TextBlock Grid.Column="1" Text="{Binding Name}"
VerticalAlignment="Center"/>
</Grid>
</HierarchicalDataTemplate>
该转换器需要2个参数,第一个是通常的 Children
集合,第二个是集合预期排序所依据的属性。排序本身则通过 ListCollectionView
高效实现,它能确保正确的排序顺序,并在数据更改时维持该顺序。
分析
如果我们试用这个示例,可以看到排序如预期般工作。但我们也能看到,选中的项会消失,除非用户开始手动查找,否则无法找回。<!-- 适合虚拟树吗? -->
上述问题或许可以通过附加到某个事件来解决(如果存在这样的事件),这个事件会在 ListCollectionView
重新排序时被调用。然后我们必须记住先前更改的节点,选中它,并通过附加行为将其带入视图,这样我们似乎就能解决问题了。
但是,审视上述解决方案(如果它存在的话),我意识到我们将不得不同步多个事件(项目的重命名、重新排序,以及选择/带入视图)。这或许可能,但似乎极其复杂,尤其是考虑到 WPF 有两个线程,一个用于 UI,一个用于后台应用程序。同样值得注意的是,原地编辑解决方案已经包含了一套复杂的视图和视图模型事件与动作的交互——因此,仅仅为了滚动到正确的位置而让它变得更加复杂,听起来并不对,除非有一个我目前可能忽略的简单解决方案。
另请注意,此解决方案不能确保子节点集合中的名称唯一。显然,这可以实现,但会带来在插入或更改项目前进行搜索的额外成本。
SortableObservableCollection
这个解决方案与前面讨论的几乎完全相同,只是我们对 ObservableCollection
做了一个小修改,通过实现一个类使其可排序,正如所引用的 StackOverflow 页面上建议的那样。
这个解决方案优于前一个,因为我们现在可以从视图模型内部控制(重新)排序,并且可以在项目的 Name
属性更改或新项目插入时触发它。更重要的是,SortableObservableCollection
在更改时确实会重新排序,使得当前选中的项目保持选中状态,bring-into-view 行为会滚动到新位置,并保持焦点在应在的位置。
分析
这里描述的良好行为是以调用一个(重新)排序方法为额外代价的,但考虑到重命名可能不那么频繁,且每个节点下显示的项目数量可能相当小(对于大多数实际应用来说,几千个节点可能已经足够多了),这个代价应该不会太高。
总而言之,这个解决方案非常有前途,但它没有解决唯一名称的问题,除非我们在实际插入或更改名称之前搜索 Children
列表以查找给定名称。这可以用一个简单的 LinQ 语句来完成。但这可能也会成为一个性能问题。所以,接下来让我们看看是否可以用字典更有效地解决这个问题。
SortableObservableDictionary
本示例中的 SortableObservableDictionary
实现基于 Dr. WPF 在 2007 年的一个实现,并加上了所引用博客文章 [2] 中建议的一个小修改。我们注意到,现在每个公开的 Children
属性都包含一个键/值对的集合。
public ObservableSortedDictionary<string, GitHubItemViewModel> Children
{
get
{
return _Children;
}
}
我们可以看到,测试应用程序中列出的项行为符合预期。每个项在重命名后都会移动到它应该去的位置——所以排序是开箱即用的。支持唯一名称的存储,如果我们尝试插入一个值两次,就会抛出异常,从而可以验证这一点。
这个实现真的很棒,Dr. WPF 肯定还能得到我的五星好评 :-) 我能看到的唯一缺点是,我们现在必须绑定到一个键/值对,而不是像通常那样绑定到单个属性。
<HierarchicalDataTemplate ItemsSource="{Binding Value.Children}">
<Grid DataContext="{Binding Value}">
<TextBlock Grid.Column="1" Text="{Binding Name}"
VerticalAlignment="Center"/>
</Grid>
</HierarchicalDataTemplate>
对我来说,如果你问我,这是一个可以接受的代价,但我接下来将回顾另一个基于可排序可观察集合的解决方案,它可以在不暴露键/值对的情况下跟踪唯一名称。
SortableObservableDirectionaryCollection
本示例中呈现的解决方案基于前面讨论的 SortableObservableCollection
,并增加了维护一个内部字典以确保名称唯一性的功能。
分析
这个解决方案也符合上面提到的所有要求。所以,从用户的角度来看:它提供了与 Dr. WPF 更复杂的实现相当的体验。
我们还注意到它没有暴露键/值对,只暴露了一个值的集合,这样做的代价是我们必须有一个自定义的实现,并且必须为每个树视图的实现进行定制,因为否则排序和跟踪唯一实体将无法工作。
但我们可以通过指定一个定义键的接口来解决这个问题,这样我们就可以始终重用相同的可观察类模板,只要该接口被实现。
public interface IKey<TKey>
{
TKey Key { get; }
}
我们还应确保,可用的添加或更改项目的方法,要么通过重载和抛出异常来禁用,要么通过将 SortableObservableDirectionaryCollection
隐藏在接口后面使其不可用。后一种选择似乎更自然,我将在下面进一步讨论。
摘要
总之,我们可以得出结论,我们至少找到了两种看起来足够直接、易于实现和维护的方案。我个人倾向于 Dr. WPF 的解决方案,因为它似乎最自然,而且我也不介意绑定到一个键/值对。但我期待听到其他人对此的看法。
您知道其他值得在此考虑的解决方案吗?
分组与排序
上一节基本上解决了保持树视图中项目排序和唯一性的问题。而一旦我们谈到排序,我就会听到人们说:
那分组呢?我的应用程序显示不同类型的项(例如文件夹和文件),结果应该分组显示,并且每个组内部应该依次排序。
我可以自信地告诉这些人,分组只是我们上面讨论的排序情况的一个特例。如果你能生成一个既能将条目聚集成组又能对其内部进行排序的键,你就可以用与排序相同的技术来进行分组。假设我们有以下5个项目的列表:
- dirk (目录)
- temp (目录)
- xtreme.cs (文件)
- windows (目录)
- notes.txt (文件)
我们希望按目录和文件分组,并在组内排序。那么,我们所要做的就是生成一个排序键,像这样:
- a_dirk
- a_temp
- a_windows
- f_notes.txt
- f_xtreme.cs
这个排序键显然不再适合向用户显示,但我们可以仅通过一个参数进行排序,从而解决两个需求 :-) 但这个解决方案不再能保证键的唯一性。
我们知道在同一个目录中不能有同名的目录和文件,但上述对排序键的更改会允许这种情况发生。所以,解决这个问题的办法是:
- 在更改或插入新项之前,尝试查找一个名称所有可能的名称和类型,或者
- 在两个独立的集合中分别跟踪排序键和唯一键。
我实际上在项目资源管理器的原型中实现了第二种方案。如果你需要更实际的指导,以实现一个可以工作并通过调试器进行调试的东西,你可以查看那个原地编辑控件的演示实现。请务必查看 public string GenSortKey(ISolutionBaseItem item) 方法以及所引用类中的所有相关方法。
提示与技巧
BindingProxy
+-> Root +- Child 1 | +- Child 2 | +- Child 3 | +-> Child 4
我清楚地记得,在我最初的实现中,我非常困惑,因为我不知道如何从像 Child 4
这样的深层节点回到根节点,反之亦然。我最终在每个子节点中都实现了树编辑功能——这本身不算太糟,但可以被认为是调试地狱。
一个更好的方法是说,Child 4
是我们想要执行某个操作的节点,比如重命名它,而该操作的实现在托管根节点的类中。幸运的是,这是可能的,并且让生活变得简单多了。
上述解决方案的一部分是一个 BindingProxy。一个代码示例片段可以在这里找到,你可以在这里看到它的应用。下面这行代码在根节点下的对象的上下文中创建了该对象。
<bindLocal:BindingProxy x:Key="DataContextProxy" Data="{Binding Solution}" />
下一条语句在该上下文中绑定了命令,并自然地将树视图项作为参数提供。
<MenuItem Command="{Binding Path=Data.StartRenameCommand, Source={StaticResource DataContextProxy}}"
CommandParameter="{Binding}"
Header="Rename"
ToolTip="Rename this item" />
这个解决方案初看起来很奇怪,但它每次都有效。当我看到 Josh Smith 写道 WPF 与 WinForms 相比是如此不同,以至于你实际上需要把你的大脑拿出来,旋转180度再放回去时,我想到的就是这些东西。
Parent 属性
所以,这真的很酷。我们现在可以在一个地方实现我们的树视图操作,并对项目进行操作,因为它们可以通过命令绑定作为调用的参数。但如果我想删除一个项目呢?——这是否意味着我必须从根节点遍历到给定的项目,以便通过其父节点的 Children 集合来删除它?
上述问题的答案当然是否定的。我们可以构建我们的视图模型项,使其包含一个 parent 属性,该属性链接回每个项的直接父项(根节点除外,它的这个位置可以是 null
)。
public interface IViewModelItem : INotifyPropertyChanged
{
IViewModelItem Parent { get; }
}
我们仍然可以使用一个 IMultiValueConverter
来提供要移除的项及其父项,该转换器将所有提到的参数放入一个 Tuple
(或任何其他对象)中,并将其发送到绑定的 Remove 命令。然后,Remove 命令可以解包发送的参数,并简单地调用:parent.Remove(item)
。
实现 Parent
属性其实很简单,因为我们只需要确保树中的所有项都实现了上述接口,并且我们知道树通常是从根到叶构建的——所以在项的构造函数中提供父参数在这个过程中几乎是自然而然的。
这两个技巧——BindingProxy 和 Parent 属性——是我愿意推荐给每个认为这是新知识的人。好了,既然我们对如何组织树视图有了基本的了解,我们就可以进阶到使用 interfaces
了。
将 HierarchicalDataTemplate 与接口一起使用
我们知道实现接口而不是向库的外部世界暴露完整类或对象的好处。本节我们讨论的问题是一个简单的事实:将 HierarchicalDataTemplate
绑定到接口内的属性(而不是绑定到类的属性)是行不通的。
让我们考虑 SortableObservableDirectionaryCollection 2 示例中的代码,并假设由于某种原因,我们得出了一个包含两个或更多 HierarchicalDataTemplate
的解决方案,并且我们必须通过它们关联的类类型来区分它们,而不是采用先前解决方案中展示的枚举方法。
xmlns:vm="clr-namespace:SortableObservableDirectionaryCollection.ViewModels"
...
<HierarchicalDataTemplate DataType="{x:Type vm:GitHubUserViewModel}"
ItemsSource="{Binding Children}">
<Grid>
<TextBlock Grid.Column="1" Text="{Binding Name}"
VerticalAlignment="Center"/>
</Grid>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type vm:GitHubProjectViewModel}"
ItemsSource="{Binding Children}">
<Grid>
<TextBlock Grid.Column="1" Text="{Binding Name}"
VerticalAlignment="Center"/>
</Grid>
</HierarchicalDataTemplate>
……现在我们想把所有东西都移到一个模块里,把实现细节隐藏在类中,只对外部世界提供接口——所以,命名空间变为:
xmlns:vm="clr-namespace:SortableObservableDirectionaryCollection.Interfaces"
并且类型从
vm:GitHubUserViewModel
和vm:GitHubProjectViewModel
- to
vm:IGitHubUserViewModel
和vm:IGitHubProjectViewModel
。
所以,我们现在使用接口而不是视图模型。如果你做了上面建议的更改,你会发现模板选择将不再起作用——现在的问题是 HierarchicalDataTemplate
是否可以与 Interfaces
一起工作,以及使其工作的确切条件是什么?
有两种方案可以解决这个问题。
方案1 - 仅使用一个 HierarchicalDataTemplate
事实证明,如果我们能设法通过定义在 TreeView.ItemTemplate
内部的一个 HierarchicalDataTemplate
来映射所有的 ItemTemplate
定义,我们就可以将 HierarchicalDataTemplate
与接口一起使用,如这里的示例应用程序所示。
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<Grid xmlns:loc="clr-namespace:InplaceEditBoxLib.Local;assembly=InplaceEditBoxLib"
>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Image Grid.Column="0" Height="16" Margin="3,0"
Focusable="False"
Source="{Binding Converter={StaticResource ISolutionBaseItemToImageConverter}}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{Binding Path=DisplayName, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="{Binding Description, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
/>
</Grid>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
上述代码绑定到接口,因为该解决方案的 SolutionLib 项目中的视图模型都是内部的。尽管如此,该解决方案通过这种隐式绑定到接口的方法是可行的。需要注意的是,我们现在必须使用转换器和其他类似的工具来为不同的项显示不同的图标。但这在 WPF 中并非高深莫测,而是标准做法——所以,这种方法本身并没有任何问题。
在下一节中,我们将考虑一种解决方案,它可以在我们因某些原因被迫使用多个 HierarchicalDataTemplate
来定义所有树节点时帮助我们。
与接口一起实现 HierarchicalDataTemplate
的另一种选择是实现一个 ItemTemplateSelector
,它可以根据给定的接口选择正确的 HierarchicalDataTemplate
。
一个 ItemTemplateSelector
是一个对象,它观察给定参数对象的类型,并将该类型转换为正确的 ItemTemplate
。设置 ItemTemplateSelector
选择器有多种不同的方法,但经典的方法是:
- 在资源区段中使用
x:key
声明所有HierarchicalDataTemplate
- 将每个
HierarchicalDataTemplate
的实例附加到资源区段中一个ItemTemplateSelector
的实例上。 - 将
ItemTemplateSelector
的实例作为 StaticResource 附加到树视图的ItemTemplateSelector
属性上。
让我们来看看 SortableObservableDirectionaryCollection 3 示例中的代码。一个 ItemTemplateSelector
通常是一个类,它具有将 HierarchicalDataTemplate
连接到其上的属性,并且通常有一个需要重写的 override
方法,用于自定义针对接口选择正确 DataTemplate 的过程。
public class GitHubItemDataTemplateSelector : DataTemplateSelector
{
public DataTemplate UserTemplate { get; set; }
public DataTemplate ProjectTemplate { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
if (item is IGitHubProjectViewModel)
return ProjectTemplate;
if (item is IGitHubUserViewModel)
return UserTemplate;
// Got here because given argument was not understood :-(
throw new System.ArgumentException();
}
}
每当树视图看到另一个项并想要确定正确的 DataTemplate 时,SelectDataTemplate
方法就会被调用。我在这里省略了 XAML 代码,但你可以在 SortableObservableDirectionaryCollection 3 示例的 MainWindow.xaml
中查看它。
隐藏 ObservableCollection 的细节
有时候,我们会实现一个自定义方法 CustomAdd(param1, param2 ...)
,用于在特定条件下向树中添加节点。在这种情况下,我们有时会发现自己不得不暴露一个类似 ObservableCollection
的东西。
public ObservableCollection<Person> { get; }
以便让 treeview、listview 或任何其他 ItemsControl
绑定到它。这里的问题通常是,这个集合的用户可以直接使用集合的 Add()
或 Remove()
方法来操纵视图模型,而绕过了自定义的编程逻辑。所以这里的问题是:
我如何在不暴露那些我想要保护的方法的情况下暴露 ObservableCollection
?答案出奇地简单。我们可以将视图模型存储在它自己的库中,只暴露一个 IEnumerable<...>
接口,而不是上面的属性。
public IEnumerable<Person> { get; }
IEnumerable<...>
没有用于操作集合的成员。所以,库外的任何人都很难操纵它。树视图或其他WPF控件的绑定仍然有效,因为WPF绑定系统会绕过接口,比通常可能的方式更直接地与对象的属性对话。
摘要
我试图解释为什么我认为 SortableObservableDictionary
和 SortableObservableDictionaryCollection
似乎是实现WPF中树视图的最佳基础。我还提供了一些关于使用暴露的接口(而不是类)实现树视图的基本技巧和提示,希望这能引导你实现更专业的代码。我真心希望你觉得这些信息有用,并能为你节省时间,因为我肯定曾走过弯路,花费了一些时间学习,然后回溯我的脚步,将它们在这篇文章中展示出来。我期待你的反馈。
这就是我们开始使用有助于在 WPF 中实现更好树视图的基本技术所需要知道的一切——如果你对高效处理大型数据集感兴趣,可以看看本系列的第 2 部分。
参考文献
- [1] Josh Smith 的《通过使用 ViewModel 模式简化 WPF TreeView》
https://codeproject.org.cn/articles/26288/simplifying-the-wpf-treeview-by-using-the-viewmode
- [2] 我可以将我的 ItemsControl 绑定到一个字典吗?
http://drwpf.com/blog/2007/09/16/can-i-bind-my-itemscontrol-to-a-dictionary/
在 WPF 和 Silverlight 中绑定到字典 - Pete Brown 的博客
http://10rem.net/blog/2010/03/08/binding-to-a-dictionary-in-wpf-and-silverlight
- [3] 高级 WPF TreeView - 第 2 部分
[4] 高级 WPF TreeView C#/VB.Net - 第 3 部分
[5] 高级 WPF TreeView C#/VB.Net - 第 4 部分
[6] 高级 WPF TreeView C#/VB.Net - 第 5 部分
[7] 高级 WPF TreeView C#/VB.Net - 第 6 部分