目录
介绍
TreeView的背景
ViewModel的背景
究竟是什么让TreeView如此困难?
ViewModel来救援
演示解决方案
演示 1 – 带有文本搜索的家谱
PersonViewModel
用户界面
FamilyTreeViewModel
演示 2 – 按需加载的地理细分
结论
本文探讨如何使用ViewModel模式来更轻松地使用WPF中的TreeView控件。在此过程中,我们研究了为什么人们在使用WPF TreeView时经常遇到困难、什么是ViewModel,以及展示如何将TreeView与ViewModel结合的两个演示应用程序。其中一个演示展示了如何创建可搜索的TreeView,另一个演示如何实现延迟加载(也称为按需加载)。
TreeView的背景WPF中的TreeView控件获得了不应有的坏名声。许多人尝试使用它,发现它非常困难。问题是人们经常尝试以与针对Windows窗体控件TreeView编写代码相同的方式使用它。为了利用WPF TreeView的广泛功能,您不能使用与Windows窗体中相同的编程技术。这是WPF如何要求您转换思维方式以适当地利用平台的另一个例子。
在Window窗体中,使用TreeView控件非常容易,因为它非常简单。这种简单性源于以下事实:Windows窗体TreeView完全不灵活,不支持UI虚拟化,对可视化自定义提供零可能性,并且由于它不支持数据绑定,因此需要您将数据存储在其节点中。WinForms TreeView “对于政府工作来说已经足够好了”。
相比之下,WPF TreeView极其灵活,固有地支持UI虚拟化(即,TreeViewItem是按需创建的),允许完全可视化定制,并且完全支持数据绑定。这些出色的功能是有代价的。它们使TreeView控件比WinForms更复杂。一旦您学会了如何正确使用 WPF TreeView,这些复杂性就消失了,并且很容易利用控件的全部功能。
ViewModel的背景早在2005年,John Gossman在博客中谈到了他在Microsoft的团队用来创建Expression Blend(当时称为“Sparkle”)的模型-视图-视图模型模式。它与Martin Fowler的Presentation Model模式非常相似,只是它利用WPF丰富的数据绑定填补了表示模型和视图之间的空白。在Dan Crevier撰写了他精彩的DataModel-View-ViewModel系列博文之后,(D)MVVM模式开始流行起来。
(Data)Model-View-ViewModel模式与经典的Model-View-Presenter相似,只是您有一个为View量身定制的模型,称为ViewModel。ViewModel包含所有必要的特定于UI的界面和属性,以便于开发用户界面。View绑定到ViewModel,并执行命令以向其请求操作。ViewModel反过来与Model通信,并告诉它更新以响应用户交互。
这使得为应用程序创建用户界面(UI)变得更加容易。在应用程序上添加UI越容易,对于技术有挑战的视觉设计师来说,在Blend中创建漂亮的UI就越容易。此外,UI与应用程序功能的耦合越松散,该功能就越可测试。谁不想要一个漂亮的UI和一套干净、有效的单元测试?
究竟是什么让TreeView如此困难?只要您以正确的方式使用它,它实际上很容易使用TreeView。矛盾的是,以正确的方式使用它意味着根本不直接使用它!当然,你需要设置属性并直接在TreeView中调用临时方法。这是不可避免的,这样做并没有错。但是,如果您发现自己深入控件的核心,那么您可能没有采取最好的方法。如果你的TreeView是数据绑定的,并且您发现自己试图以编程方式上下移动项,那么您做事的方式不正确。如果您发现自己正在挂钩ItemContainerGenerator的StatusChanged事件,以便您可以访问最终被创建时的TreeViewItem子项,你就偏离了轨道!相信我; 它不必如此丑陋和困难。有一个更好的方法!
对待WPF TreeView和对待WinForms TreeView的基本问题是,正如我前面提到的,它们是非常不同的控件。WPF TreeView允许您通过数据绑定生成其项。这意味着它将为您创建TreeViewItem。由于TreeViewItem是由控件而不是由您生成的,因此不能保证数据对象的对应项TreeViewItem在您需要时存在。您必须询问TreeView的ItemContainerGenerator是否已经为您生成了TreeViewItem。如果没有,您必须挂钩它的StatusChanged事件,以便在它创建其子元素时得到通知。
乐趣不止于此!如果您想获得嵌套在树中深处的一个TreeViewItem,您必须询问该项的父级/自己的TreeViewItem,而不是TreeView控件,如果ItemContainerGenerator创建了该项。但是,如果其父级TreeViewItem尚未创建它,您如何获得对其的引用?如果还没有生成父级的父级怎么办?依此类推,依此类推,依此类推。这可能是相当折磨人的。
如您所见,WPF TreeView是一个复杂的野兽。如果您尝试以错误的方式使用它,那将并不容易。幸运的是,如果你以正确的方式使用它,它就是小菜一碟。那么,让我们看看如何正确使用它……
ViewModel来救援WPF很棒,因为它实际上要求您将应用程序的数据与UI分开。上一节中列出的所有问题都源于试图违背常规并将UI视为后备存储。一旦您不再将其TreeView视为放置数据的地方,而是开始将其视为展示数据的地方,一切都会开始顺利进行。这就是ViewModel的想法发挥作用的地方。
与其编写在TreeView中的项目上下移动的代码,不如创建一个TreeView绑定到的ViewModel,然后编写操作您的ViewModel的代码。这不仅可以让您忽略TreeView的复杂性,还可以让您编写可以轻松进行单元测试的代码。为与TreeView的运行时行为密切相关的类编写有意义的单元测试几乎是不可能的,但是为对这种废话一无所知的类编写单元测试很容易。
现在,是时候看看如何实现这些概念了。
演示解决方案本文附带两个演示应用程序,可在本页顶部下载。该解决方案有两个项目。BusinessLib类库项目包含简单的领域类,仅用作数据传输对象。它还包含一个实例化并返回这些数据传输对象的Database类。另一个项目TreeViewWithViewModelDemo包含示例应用程序。这些应用程序使用BusinessLib程序集返回的对象,并将它们包装在ViewModel中,然后再将它们显示在TreeView中。
这是解决方案的解决方案资源管理器的屏幕截图:
我们将检查的第一个演示应用程序使用家谱填充TreeView。它提供了一种搜索功能,在UI底部可供用户使用。该演示可以在下面的屏幕截图中看到:
当用户输入一些搜索文本并按Enter或单击“查找”按钮时,将显示第一个匹配项。继续搜索将循环通过每个匹配的项目。所有这些逻辑都在ViewModel中。在深入了解ViewModel的工作原理之前,让我们先检查一下周围的代码。这是TextSearchDemoControl的代码隐藏:
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。该类Person位于BusinessLib程序集中,非常简单:
///
/// A simple data transfer object (DTO) that contains raw data about a person.
///
public class Person
{
readonly List _children = new List();
public IList Children
{
get { return _children; }
}
public string Name { get; set; }
}
由于Person类是应用程序的数据访问层返回的内容,因此它绝对不适合UI使用。每个Person对象最终都会被PersonViewModel类的一个实例包装,使其具有扩展的语义,例如被扩展和选择。如上所示,该类FamilyTreeViewModel启动了将Person对象包装在PersonViewModel对象内部的过程,如该类的构造函数所示:
public FamilyTreeViewModel(Person rootPerson)
{
_rootPerson = new PersonViewModel(rootPerson);
_firstGeneration = new ReadOnlyCollection(
new PersonViewModel[]
{
_rootPerson
});
_searchCommand = new SearchFamilyTreeCommand(this);
}
私有PersonViewModel构造函数递归地遍历族谱,将每个Person对象包装在一个PersonViewModel.中,这些构造函数如下所示:
public PersonViewModel(Person person)
: this(person, null)
{
}
private PersonViewModel(Person person, PersonViewModel parent)
{
_person = person;
_parent = parent;
_children = new ReadOnlyCollection(
(from child in _person.Children
select new PersonViewModel(child, this))
.ToList());
}
PersonViewModel有两种成员:与表示相关的成员和与Person的状态相关的成员。表示属性是TreeViewItem将绑定的内容,状态属性由TreeViewItem的内容绑定。表示属性之一,IsSelected,如下所示:
///
/// Gets/sets whether the TreeViewItem
/// associated with this object is selected.
///
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上的演示成员的一个更有趣的例子是IsExpanded属性。这个属性很容易解决确保数据对象的对应TreeViewItem在必要时被扩展的问题。请记住,当直接针对TreeView自身进行编程时,这些类型的问题可能非常棘手且难以处理。
///
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
///
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对象的状态相关的属性。这是一个例子:
public string Name
{
get { return _person.Name; }
}
绑定到PersonViewModel树的TreeView的XAML非常简单。请注意,TreeViewItem和PersonViewModel对象之间的连接在于控件的ItemContainerStyle中:
该演示的UI的另一部分是搜索区域。该区域为用户提供了一个TextBox输入搜索字符串的区域,以及一个“查找”按钮以执行对家谱的搜索。这是搜索区域的XAML:
现在,让我们看看支持这个用户界面的FamilyTreeViewModel中的代码。
FamilyTreeViewModel搜索功能封装在FamilyTreeViewModel类中。TextBox包含的搜索文本绑定到SearchText属性,声明如下:
///
/// Gets/sets a fragment of the name to search for.
///
public string SearchText
{
get { return _searchText; }
set
{
if (value == _searchText)
return;
_searchText = value;
_matchingPeopleEnumerator = null;
}
}
当用户单击“查找”按钮时,将执行FamilyTreeViewModel的SearchCommand命令。该命令类嵌套在FamilyTreeViewModel中,但向视图公开它的属性是public。该代码如下所示:
///
/// Returns the command used to execute a search in the family tree.
///
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技术和理念,您可能会惊讶地发现我在这里没有使用路由命令。出于多种原因,我通常更喜欢路由命令,但在这种情况下,使用简单的ICommand实现会更干净、更简单。注意,一定要阅读CanExecuteChanged事件声明中的注释。
搜索逻辑完全不依赖于TreeView或TreeViewItem。它只是遍历ViewModel对象并设置ViewModel属性。尝试直接针对TreeView API编写此代码将更加困难且容易出错。这是我的搜索逻辑:
IEnumerator _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 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;
}
下一个演示应用程序使用有关一个国家/地区的各个地方的信息填充TreeView。它处理三种不同类型的对象:Region、State和City。这些类型中的每一个都有一个对应的表示类,TreeViewItem绑定到该类。
每个表示类都派生自TreeViewItemViewModel基类,该基类提供了在之前的演示PersonViewModel类中看到的所有特定于表示的功能。此外,此演示中的项目是延迟加载的,这意味着程序不会获取项目的子项并将它们添加到对象图中,直到用户尝试查看它们。您可以在下面的屏幕截图中看到此演示:
正如我上面提到的,这里有三个独立的数据类,每个数据类都有一个关联的表示类。所有这些表示类都派生自TreeViewItemViewModel,由该接口描述:
interface ITreeViewItemViewModel : INotifyPropertyChanged
{
ObservableCollection Children { get; }
bool HasDummyChild { get; }
bool IsExpanded { get; set; }
bool IsSelected { get; set; }
TreeViewItemViewModel Parent { get; }
}
LoadOnDemandDemoControl的代码隐藏如下所示:
public partial class LoadOnDemandDemoControl : UserControl
{
public LoadOnDemandDemoControl()
{
InitializeComponent();
Region[] regions = Database.GetRegions();
CountryViewModel viewModel = new CountryViewModel(regions);
base.DataContext = viewModel;
}
}
该构造函数只是从BusinessLib程序集中加载一些数据对象,从中创建一些UI友好的包装器,然后让视图绑定到这些包装器。视图DataContext设置为此类的一个实例:
///
/// The ViewModel for the LoadOnDemand demo. This simply
/// exposes a read-only collection of regions.
///
public class CountryViewModel
{
readonly ReadOnlyCollection _regions;
public CountryViewModel(Region[] regions)
{
_regions = new ReadOnlyCollection(
(from region in regions
select new RegionViewModel(region))
.ToList());
}
public ReadOnlyCollection Regions
{
get { return _regions; }
}
}
有趣的代码在TreeViewItemViewModel中。 它主要是之前演示的PersonViewModel中的演示逻辑的副本,但有一个有趣的转折。TreeViewItemViewModel具有对子项按需加载的内置支持。该逻辑存在于类的构造函数和IsExpanded属性的设置器中。按需加载TreeViewItemViewModel逻辑如下图所示:
protected TreeViewItemViewModel(TreeViewItemViewModel parent, bool lazyLoadChildren)
{
_parent = parent;
_children = new ObservableCollection();
if (lazyLoadChildren)
_children.Add(DummyChild);
}
///
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
///
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();
}
}
}
///
/// Returns true if this object's Children have not yet been populated.
///
public bool HasDummyChild
{
get { return this.Children.Count == 1 && this.Children[0] == DummyChild; }
}
///
/// Invoked when the child items need to be loaded on demand.
/// Subclasses can override this to populate the Children collection.
///
protected virtual void LoadChildren()
{
}
加载对象的子项的实际工作留给子类处理。它们重写该LoadChildren方法以提供加载子项的特定于类型的实现。RegionViewModel如下所示,该类覆盖此方法以加载State对象并创建StateViewModel包装对象。
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进行配置:
如果您曾经使用过WPF TreeView,也许这篇文章已经阐明了使用该控件的另一种方法。一旦你开始顺其自然,不再试图逆流而上,WPF会让你的生活变得非常轻松。困难的部分是放弃你来之不易的知识和技能,并采用完全不同的方式来解决相同的问题。
https://www.codeproject.com/Articles/26288/Simplifying-the-WPF-TreeView-by-Using-the-ViewMode