英文原文地址:Simplifying the WPF TreeView by Using the ViewModel Pattern
作者:Josh Smith
文中代码的下载地址:http://www.codeproject.com/KB/WPF/TreeViewWithViewModel/TreeViewWithViewModelDemo.zip
好像需要登录才能下载,我放一个上来吧http://files.cnblogs.com/RMay/TreeViewWithViewModelDemo.zip
译者按:WPF中对TreeView的操作同WinForm中有很大的不同。这篇文章讲述了如何用ViewModel模式来简化WPF的TreeView,个人感觉非常有价值,尤其是在WPF中思维模式跟以往有很大不同的情况下。希望能对大家有所帮助并且触类旁通。第一次做翻译,有错误的地方敬请谅解。
介绍
这篇文章探讨了如何通过使用ViewModel模式来更容易的使用WPF中的TreeView控件。在此过程中,我们会看到为何人们经常在使用WPF的TreeView时遇到困难,什么是ViewModel,以及两个实例程序,这两个程序展现了如何结合TreeView和ViewModel。其中一个实例展示了如何创建一个具有搜索功能的TreeView,另一个则说明了如何实现延迟加载(lazy-loading)。
TreeView的背景
WPF中的TreeView控件背负了一个名不副实的坏名声。很多人尝试着使用它,却发现非常难用。其实问题在于人们经常试着按照Windows Forms的TreeView控件的使用方式来使用它。为了发挥(原文是leverage,意为杠杆作用,不好翻译,呵呵)WPF TreeView的丰富特性,你不能再使用跟Windows Forms一样的编程技术了。这也是WPF要求你为了更好恰当地使用它(指WPF)而转换思维方式的另一个例子。毕竟我们已经走到这一步了。(原文是We aren't in Kansas anymore, Toto。来自电影《绿野仙踪》:Toto,I've got a feeling we're not in Kansas anymore)
在Windows Forms中,使用TreeView非常容易,因为它非常简单。这种简单建立在Windows Forms的TreeView完全不灵活的事实上,比如不提供UI的虚拟化(UI virtualization),不提供外观的个性化,同时由于它不支持数据绑定,你必须将数据存到它的节点中。WinForm的TreeView根本不够好(原文是The WinForms TreeView is "good enough for government work","good enough for government work"是说不够好)。
对比之下,WPF的TreeView非常的灵活,天生支持UI虚拟化(比如,TreeViewItems是按需创建created on-demand),完全允许个性化外观,同时完全支持数据绑定。这些优秀的特性是需要代价的。他们让WPF的TreeView比WinForm的TreeView更加复杂。一旦你学会了如何正确地使用WPF的TreeView,这些复杂性将不在话下,同时发挥WPF TreeView的全部能力将变得非常容易。
如果你对如何个性化WPF的TreeView感兴趣,可以查看这篇文章和这篇文章。
ViewModel的背景
早在2005年,John Gossman写了一篇关于Model-View-ViewModel模式的博文,这种模式被他所在的微软的项目组用来创建Expression Blend(即'Sparkle')。它跟Martin Fowler的Presentation Model非常相似,唯一不同的是,它填平了presentation model和使用了WPF的丰富的数据绑定的view之间的沟壑。在Dan Crevier发表了神作DataModel-View-ViewModel series博文系列之后,(D)MVVM模式开始变得流行起来。
(Data)Model-View-ViewModel模式跟经典的Model-View-Presenter模式很相似,除了你需要一个为View量身定制的model,这个model就是ViewModel。ViewModel包含所有由UI特定的接口和属性,它们是轻松构建UI的必要元素。View绑定到ViewModel,然后执行一些命令在向它请求一个动作。而反过来,ViewModel跟Model通讯,告诉它更新来响应UI。
这使得为应用构建UI非常的容易。往一个应用程序上贴一个界面越容易,外观设计师就越容易使用Blend来创建一个漂亮的界面。同时,当UI和功能越来越松耦合的时候,功能的可测试性就越来越强。为什么不想要一个漂亮的界面同时又有一套干净有效的单元测试呢?
究竟是什么让TreeView这么难用?
如果你的使用方法正确的话,其实TreeView是很好用的。正确的使用方法,反过来说,是根本不要直接的使用它!一般地,你需要直接地对一个TreeView设置属性,不时地调用方法。这是无法逃避的,同时这么做也没什么错。不过,如果你发现你正深陷于对控件的调用(原文是the guts of the control,不好翻译),那也许你并没有采取最佳的方式。如果你的TreeView是数据绑定的,然后你发现你正试图通过程序来折腾这些项,那么你就是没有使用正确的方法。如果你发现监听ItemContainerGenerator的StatusChanged事件来访问TreeViewItem的子节点,那你简直就是脱轨了!相信我,根本没必要这么丑陋和困难。有更好的方法!
按照WinForm TreeView的方式来使用WPF TreeView的根本问题在于,正如我前面提到的,它们是非常不同的控件。WPF的TreeView允许你通过数据绑定来生成它的Items。这意味着它会为你创建TreeViewItems。由于TreeViewItems是被控件创建的,而不是你,因此它不能保证当你需要时,某个数据项对应的TreeViewItem还存在。你必须问问TreeView的ItemContainerGenerator是否已经为你创建了TreeViewItem。如果没有,你必须监听它的StatusChanged事件,当它创建了自己的子元素之后通知你。
有趣的不止这些!如果你想获得树中一个非常复杂,非常深的TreeViewItem,你必须问问他的父TreeViewItem,而不是TreeView控件,是否它的ItemContainerGenerator已经创建了该项。但是,你要怎样才能拿到它的父节点的引用当这个父节点还没有被创建呢?当父节点的父节点还没有被创建的时候又是怎样的呢?子子孙孙,无穷溃也。这是相当痛苦的。
正如你所见的,WPF的TreeView是一个复杂的野兽。如果你试着用错误的方式来使用它,将不是那么容易的。幸运的是,如果你用正确的方式使用它,那就是小事一桩。那么,让我们来看看怎么样通过正确的方式来使用它……
ViewModel是解救的办法
WPF是伟大的,因为它基本上要求你分离应用程序的数据和UI。前面击节中列出的问题都是应为违背了这个原则并且把UI当作数据存储的地方。 一旦你不再把TreeView当成一个存储数据的地方,而是看做一个展现数据的地方,那么一切都将水到渠成。这就是ViewModel这个想法的由来。
比起写代码去折腾TreeView里面的项,更好的方法是写一个被TreeView绑定的ViewModel,然后写代码来操作你的ViewModel。这不仅仅能让你无视TreeView的复杂性,还能让你写出能够很容易进行单元测试的代码。要为那些紧密依赖于TreeView运行时行为的类写有意义的单元测试代码几乎是不可能的,但是要为那些对这些无关行为一无所知的类写单元测试代码却很容易。
现在,我们来看看如何实现这些概念。
示例解决方案
这篇文章附带了两个示例程序,能从页面顶部下载。该Solution包含了两个工程。BusinessLib类库工程包含了简单的描述地域的类,这些类被当作纯粹的数据传输对象。同时它还包含了一个Database类,这个类初始化数据,并且返回这些数据传输对象(译者:说白了就是模拟的数据源)。另外的一个工程,TreeViewWithViewModelDemo,包含一些示例程序。这些程序使用BusinessLib程序集中给出的数据对象,并且在放到TreeView中展示之前,把它们包装到一个ViewModel中去。
下面是这个Solution的一个工程树截图:
Demo1 - 带文本搜索的Family Tree
第一个示例程序中我们构建了一个展示Family Tree的TreeView。它在界面的底部提供了搜索的功能。截图如下:
当用户输入一些关键字,敲回车,或者是点击了“Find”按钮之后,第一个匹配的项目将会被展示出来。继续搜索将在所有匹配项之间循环。所有的这些逻辑都在ViewModel中。在深入ViewModel的工作方式之前,我们先看看相关代码。下面是TextSearchDemoControl的后台代码。
Code
public partial class TextSearchDemoControl : UserControl
{
readonly FamilyTreeViewModel _familyTree;
public TextSearchDemoControl()
{
InitializeComponent();
// Get raw family tree data from a database.
Person rootPerson = Database.GetFamilyTree();
// Create UI-friendly wrappers around the
// raw data objects (i.e. the view-model).
_familyTree = new FamilyTreeViewModel(rootPerson);
// Let the UI bind to the view-model.
base.DataContext = _familyTree;
}
void searchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
_familyTree.SearchCommand.Execute(null);
}
}
构造函数展示了我们如何把原始数据转换到ViewModel中,然后把它设为UserControl的DataContext。在BusinessLib程序集中定义的Person类,非常简单:
Code
/// <summary>
/// A simple data transfer object (DTO) that contains raw data about a person.
/// </summary>
public class Person
{
readonly List<Person> _children = new List<Person>();
public IList<Person> Children
{
get { return _children; }
}
public string Name { get; set; }
}
PersonViewModel
由于Person类是应用的数据访问层返回的东西,它绝对不适合直接被UI使用。每个Person对象最终被包装到一个PersonViewModel类的实例中,这样就让它拥有了额外的能力,比如被展开和选中。由此看出,FamilyTreeViewModel类,完成了把Person对象包装到PersonViewModel对象中的过程,下面是它的构造函数:
Code
public FamilyTreeViewModel(Person rootPerson)
{
_rootPerson = new PersonViewModel(rootPerson);
_firstGeneration = new ReadOnlyCollection<PersonViewModel>(
new PersonViewModel[]
{
_rootPerson
});
_searchCommand = new SearchFamilyTreeCommand(this);
}
私有的PersonViewModel的构造函数通过递归的方式遍历Family Tree,把每一个Person包装到PersonViewModel中。下面是代码:
Code
public PersonViewModel(Person person)
: this(person, null)
{
}
private PersonViewModel(Person person, PersonViewModel parent)
{
_person = person;
_parent = parent;
_children = new ReadOnlyCollection<PersonViewModel>(
(from child in _person.Children
select new PersonViewModel(child, this))
.ToList<PersonViewModel>());
}
PersonViewModel有两种成员:一种关联到展现,另外一种关联到Person的状态。展现相关的属性将会被TreeViewItem所绑定,而状态相关的属性绑定到TreeViewItem的内容。其中一个展现相关的属性IsSelected,如下:
Code
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is selected.
/// </summary>
public bool IsSelected
{
get { return _isSelected; }
set
{
if (value != _isSelected)
{
_isSelected = value;
this.OnPropertyChanged("IsSelected");
}
}
}
这个属性跟一个"person"没有任何关系,而是一个简单的用于同步View和ViewModel的状态标识。注意属性的setter调用了OnPropertyChanged方法,该方法会激发PropertyChanged事件。该事件是INotifyPropertyChanged接口的唯一成员。INotifyPropertyChanged是一个UI相关的接口,这就是为什么PersonViewModel类实现该接口,而Person类不实现。
一个展现相关属性的更有趣的例子是PersonViewModel的IsExpaned属性。这个属性解决了怎么保证在必要的时候,跟某个数据对象对应的TreeViewItem的展开问题。记住,如果直接用TreeView来编程解决这些问题,可是痛苦不堪的。
Code
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
get { return _isExpanded; }
set
{
if (value != _isExpanded)
{
_isExpanded = value;
this.OnPropertyChanged("IsExpanded");
}
// Expand all the way up to the root.
if (_isExpanded && _parent != null)
_parent.IsExpanded = true;
}
}
正如我前面所提到的,PersonViewModel同时还有跟Person状态相关的属性,比如:
Code
public string Name
{
get { return _person.Name; }
}
界面部分
把一个TreeView绑定到PersonViewModel的代码是非常简洁的。注意TreeViewItem和PersonViewModel之间的联系依靠的是控件的ItemContainerStyle:
Code
<TreeView ItemsSource="{Binding FirstGeneration}">
<TreeView.ItemContainerStyle>
<!--
This Style binds a TreeViewItem to a PersonViewModel.
-->
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</Style.Triggers>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<TextBlock Text="{Binding Name}" />
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
界面上的另外一块是搜索区。这个区域提供了一个可供用户输入关键字的输入框,一个提供搜索的Find按钮。下面是xaml代码:
Code
<StackPanel
HorizontalAlignment="Center"
Margin="4"
Orientation="Horizontal"
>
<TextBlock Text="Search for:" />
<TextBox
x:Name="searchTextBox"
KeyDown="searchTextBox_KeyDown"
Margin="6,0"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Width="150"
/>
<Button
Command="{Binding SearchCommand}"
Content="_Find"
Padding="8,0"
/>
</StackPanel>
现在,我们来看看FamilyTreeViewModel中对UI的支持。
FamilyTreeViewModel
搜索功能封装在FamilyTreeViewModel类中。用于输入关键字的TextBox绑定到SearchText属性,该属性的声明如下:
Code
/// <summary>
/// Gets/sets a fragment of the name to search for.
/// </summary>
public string SearchText
{
get { return _searchText; }
set
{
if (value == _searchText)
return;
_searchText = value;
_matchingPeopleEnumerator = null;
}
}
当用户点击Find按钮的时候,FamilyTreeViewModel的SearchCommand被执行。该命令类被包含在FamilyTreeViewModel中,不过它的属性是public的方式暴露给View的。
Code
/// <summary>
/// Returns the command used to execute a search in the family tree.
/// </summary>
public ICommand SearchCommand
{
get { return _searchCommand; }
}
private class SearchFamilyTreeCommand : ICommand
{
readonly FamilyTreeViewModel _familyTree;
public SearchFamilyTreeCommand(FamilyTreeViewModel familyTree)
{
_familyTree = familyTree;
}
public bool CanExecute(object parameter)
{
return true;
}
event EventHandler ICommand.CanExecuteChanged
{
// I intentionally left these empty because
// this command never raises the event, and
// not using the WeakEvent pattern here can
// cause memory leaks. WeakEvent pattern is
// not simple to implement, so why bother.
add { }
remove { }
}
public void Execute(object parameter)
{
_familyTree.PerformSearch();
}
}
如果你熟悉WPF技术和理论,你也许会吃惊为什么我没有使用RoutedCommand。通常我比较喜欢用RoutedComand,好处多多,不过在这里,直接实现一个ICommand接口更加清晰和简单。注意,请仔细阅读CanExecuteChanged声明前的注释。
搜索部分的逻辑完全不依赖TreeView或者TreeViewItem。它只是简单的遍历ViewModel对象,然后设置ViewModel的属性。试着直接用TreeView的API来写这段代码将难得多并且漏洞百出。以下是我的搜索逻辑:
Code
IEnumerator<PersonViewModel> _matchingPeopleEnumerator;
string _searchText = String.Empty;
void PerformSearch()
{
if (_matchingPeopleEnumerator == null || !_matchingPeopleEnumerator.MoveNext())
this.VerifyMatchingPeopleEnumerator();
var person = _matchingPeopleEnumerator.Current;
if (person == null)
return;
// Ensure that this person is in view.
if (person.Parent != null)
person.Parent.IsExpanded = true;
person.IsSelected = true;
}
void VerifyMatchingPeopleEnumerator()
{
var matches = this.FindMatches(_searchText, _rootPerson);
_matchingPeopleEnumerator = matches.GetEnumerator();
if (!_matchingPeopleEnumerator.MoveNext())
{
MessageBox.Show(
"No matching names were found.",
"Try Again",
MessageBoxButton.OK,
MessageBoxImage.Information
);
}
}
IEnumerable<PersonViewModel> FindMatches(string searchText, PersonViewModel person)
{
if (person.NameContainsText(searchText))
yield return person;
foreach (PersonViewModel child in person.Children)
foreach (PersonViewModel match in this.FindMatches(searchText, child))
yield return match;
}
Demo2 - 按需加载的Geographic Breakdown
下一个示例构建了一个包含一个国家不同地域信息的TreeView。它处理了三种不同类型的对象:Region,State和City。每种类型都有对应的展现类定义,TreeViewItem绑定到这些类的实例。
所有这些展现相关的类都继承自TreeViewItemViewModel,该类提供了和前一个Demo中PersonViewModel相同的跟展现相关的功能。同时,在这个Demo中的项的都是延迟加载的,这意味着程序不会去获取每个项的子项,把他们加到视图中,只有当用户想要查看时才这么做。截图如下:
正如我上面提到的,有三种独立的数据类定义,并且每个数据类定义都有相关的展现类定义。所有的这些展现类都从TreeViewItemViewModel继承而来,如下面的接口所描述的:
Code
interface ITreeViewItemViewModel : INotifyPropertyChanged
{
ObservableCollection<TreeViewItemViewModel> Children { get; }
bool HasDummyChild { get; }
bool IsExpanded { get; set; }
bool IsSelected { get; set; }
TreeViewItemViewModel Parent { get; }
}
LoadOnDemandDemoControl的后台代码如下所示:
Code
public partial class LoadOnDemandDemoControl : UserControl
{
public LoadOnDemandDemoControl()
{
InitializeComponent();
Region[] regions = Database.GetRegions();
CountryViewModel viewModel = new CountryViewModel(regions);
base.DataContext = viewModel;
}
}
构造函数简单的从BusinessLib程序集中加载一些数据对象,创建对界面友好(UI-friendly)的包装器,然后让ViewMode绑定到这些包装器。View的DataContext设为如下的类型的实例:
Code
/// <summary>
/// The ViewModel for the LoadOnDemand demo. This simply
/// exposes a read-only collection of regions.
/// </summary>
public class CountryViewModel
{
readonly ReadOnlyCollection<RegionViewModel> _regions;
public CountryViewModel(Region[] regions)
{
_regions = new ReadOnlyCollection<RegionViewModel>(
(from region in regions
select new RegionViewModel(region))
.ToList());
}
public ReadOnlyCollection<RegionViewModel> Regions
{
get { return _regions; }
}
}
TreeViewItemViewModel中的代码比较有趣。它几乎就是上一个Demo中PersonViewModel的复制,只是多了一个有趣的特性。TreeViewItemViewModel内建对子项按需加载的功能。这个逻辑写在该类的构造函数和IsExpanded属性的Setter中。该逻辑如下:
Code
protected TreeViewItemViewModel(TreeViewItemViewModel parent, bool lazyLoadChildren)
{
_parent = parent;
_children = new ObservableCollection<TreeViewItemViewModel>();
if (lazyLoadChildren)
_children.Add(DummyChild);
}
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
get { return _isExpanded; }
set
{
if (value != _isExpanded)
{
_isExpanded = value;
this.OnPropertyChanged("IsExpanded");
}
// Expand all the way up to the root.
if (_isExpanded && _parent != null)
_parent.IsExpanded = true;
// Lazy load the child items, if necessary.
if (this.HasDummyChild)
{
this.Children.Remove(DummyChild);
this.LoadChildren();
}
}
}
/// <summary>
/// Returns true if this object's Children have not yet been populated.
/// </summary>
public bool HasDummyChild
{
get { return this.Children.Count == 1 && this.Children[0] == DummyChild; }
}
/// <summary>
/// Invoked when the child items need to be loaded on demand.
/// Subclasses can override this to populate the Children collection.
/// </summary>
protected virtual void LoadChildren()
{
}
真正加载子项的工作留给子类去实现。它们重载LoadChildren方法来提供一个跟类型相关的加载子项的实现。比如下面的RegionViewModel类,它重载了该方法来加载State对象并且创建StateViewModel对象。
Code
public class RegionViewModel : TreeViewItemViewModel
{
readonly Region _region;
public RegionViewModel(Region region)
: base(null, true)
{
_region = region;
}
public string RegionName
{
get { return _region.RegionName; }
}
protected override void LoadChildren()
{
foreach (State state in Database.GetStates(_region))
base.Children.Add(new StateViewModel(state, this));
}
}
界面部分只包含一个TreeView,XAML如下:
Code
<TreeView ItemsSource="{Binding Regions}">
<TreeView.ItemContainerStyle>
<!--
This Style binds a TreeViewItem to a TreeViewItemViewModel.
-->
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</Style.Triggers>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.Resources>
<HierarchicalDataTemplate
DataType="{x:Type local:RegionViewModel}"
ItemsSource="{Binding Children}"
>
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images"Region.png" />
<TextBlock Text="{Binding RegionName}" />
</StackPanel>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate
DataType="{x:Type local:StateViewModel}"
ItemsSource="{Binding Children}"
>
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images"State.png" />
<TextBlock Text="{Binding StateName}" />
</StackPanel>
</HierarchicalDataTemplate>
<DataTemplate DataType="{x:Type local:CityViewModel}">
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images"City.png" />
<TextBlock Text="{Binding CityName}" />
</StackPanel>
</DataTemplate>
</TreeView.Resources>
</TreeView>
结论
如果你曾经跟WPF的TreeView较过劲儿,也许这篇文章给你指明一个使用该控件的别的方法。一旦你开始按部就班,而不是对着干(going down with the flow, not swim upstream),WPF会让你的生活非常好过。最难的是让你放弃你曾经苦学而来的知识,而适应处理同样的问题时迥异的方法。
特别鸣谢
我想感谢Sacha Barber 给我鼓励让我写这篇文章。在我开发示例程序的时候,他给予了我无价的回馈和请求。如果不是为了他,我想我永远都不会写这篇文章。
关于作者
Josh creates software. C# and XAML are his preferred modes of expression.
He works at Infragistics as an Experience Design Application Engineer, helping to make the .NET desktop world a better place.
He also plays the music of J.S. Bach on the piano.
Most of all, he loves being with his wonderful girlfriend.
Download his WPF.JoshSmith library here[^]
You can check out his WPF blog here[^].
You can take his guided tour of WPF here[^].
You can check out a powerful debugger visualizer he worked on called Mole for Visual Studio here[^].
His Microsoft MVP profile can be viewed here[^].
Occupation: | Software Developer (Senior) |
Company: | Infragistics, Inc. |
Location: | United States |