精通-WPF-全-
精通 WPF(全)
原文:
zh.annas-archive.org/md5/ee3baea14124681224dac16f7dd3dc6b译者:飞龙
前言
微软 Windows Presentation Foundation (WPF) 为开发者提供了多个库和 API,用于创建引人入胜的用户体验。本书包含了一系列从简单到复杂的示例,展示了如何使用 WPF 开发适用于 Windows 桌面的企业级应用程序。
《精通 Windows Presentation Foundation》的第二版更新版首先介绍了使用 WPF 的模型-视图-视图模型(MVVM)软件架构模式的好处,然后指导你调试你的 WPF 应用程序。接下来,本书将带你了解应用程序架构和为你的应用程序构建基础层。随着你的进步,你将掌握数据绑定,探索各种内置的 WPF 控件,并根据你的需求进行定制。
本书充满了引人入胜且实用的示例,阐述了帮助你将 WPF 技能提升到新水平的概念。你将学习如何发现 MVVM 以及它是如何帮助使用 WPF 进行开发的,实现你自己的自定义应用程序框架,了解如何适应内置控件,熟练使用动画,实现响应式数据验证,创建视觉上吸引人的用户界面,并提高应用程序的性能,以及学习如何部署你的应用程序。
此外,你还将发现使用 MVVM 软件架构模式与 WPF 一起工作的更智能的工作方式,学习如何创建自己的轻量级应用程序框架来构建未来的应用程序,了解数据绑定,并学习如何在应用程序中使用它。
当内置功能不足以满足你的需求时,你将学习如何创建自定义控件。你还将了解如何通过实用的动画、令人惊叹的视觉效果和响应式数据验证来增强你的应用程序。为了确保你的应用程序不仅具有交互性,而且效率高,你将专注于提高应用程序性能,并最终发现部署应用程序的不同方法。到本书结束时,你将熟练使用 WPF 开发既高效又健壮的用户界面。
本书面向的对象
这本 Windows 书籍适合对 Windows Presentation Foundation 有基础到中级知识的开发者,以及那些希望仅仅提升自己 WPF 技能的人。如果你想要学习更多关于应用程序架构以及以视觉吸引力的方式设计用户界面,你会发现这本书很有用。
本书涵盖的内容
第一章,《使用 WPF 的更智能的工作方式》,介绍了 模型-视图-视图模型 (MVVM) 软件架构模式和它在 WPF 中使用的好处。
第二章,《调试 WPF 应用程序》,提供了关于调试 WPF 应用程序的各种方法的必要技巧,确保能够解决可能出现的任何问题。
第三章,编写自定义应用程序框架,介绍了不可或缺的应用程序框架概念,并提供了早期示例,这些示例将在本书的后续内容中构建。您将拥有一个完全功能化的框架,用于构建您的应用程序。
第四章,精通数据绑定,揭示了数据绑定的神秘面纱,并清晰地展示了如何在实际应用中使用它。大量的示例将帮助您了解在任何特定情况下应使用哪种绑定语法,并使您有信心您的绑定将按预期工作。
第五章,使用适合工作的控件,解释了在特定情况下应使用哪些控件,并描述了在需要时如何修改它们。它清楚地概述了如何自定义现有控件,以及在需要时如何创建自定义控件。
第六章,适配内置控件,专注于通过扩展更改控件行为。它首先调查了内置控件如何通过派生类使我们能够操作它们,然后描述了我们可以如何在我们自己的控件中启用这种技术。它以一个扩展示例结束,展示了如何通过控件扩展来满足我们的需求。
第七章,精通实用动画,解释了 WPF 动画的方方面面,详细介绍了这一不太为人所知的功能。它以一系列实用动画的想法结束,并继续构建自定义应用程序框架。
第八章,创建视觉吸引人的用户界面,提供了充分利用 WPF 视觉效果的技巧,同时保持实用性,并提供了使应用程序脱颖而出的小贴士。
第九章,实现响应式数据验证,提出了多种数据验证方法以适应各种情况,并继续构建自定义应用程序框架。它涵盖了完整、部分、即时和延迟验证,以及显示验证错误的各种不同方式。
第十章,打造卓越的用户体验,提供了创建具有卓越用户体验的应用程序的技巧。这里介绍的概念,如异步数据访问和确保最终用户充分了解信息,将显著改善现有的自定义应用程序框架。
第十一章,提高应用程序性能,列出了从冻结资源到实现虚拟化的多种方法来提高 WPF 应用程序的性能。如果您遵循这些技巧和窍门,您可以放心,您的 WPF 应用程序将尽可能优化地运行。
第十二章,部署您的杰作应用程序,涵盖了所有专业应用程序的最终要求——部署。它包括使用 Windows Installer 软件的较老方法,以及使用更常见和更新的 ClickOnce 功能的方法。
第十三章,接下来是什么?,总结了您从本书中学到的内容,并建议您可以使用许多新的技能做什么。它为您提供了进一步扩展应用程序框架的想法。
要充分利用本书
本书包含大量代码示例,完整源代码可在 GitHub 上下载。为了运行代码,您需要以下先决条件:
-
Visual Studio 2019
-
必须安装.NET Framework 的 4.8 版本
如果您还没有这些,它们都可以免费下载和安装:
-
您可以从
visualstudio.microsoft.com/downloads/下载 Visual Studio Community 2019。 -
您可以从
dotnet.microsoft.com/download/dotnet-framework下载.NET Framework 的最新版本。
伴随本书的完整源代码可以从github.com/PacktPublishing/Mastering-Windows-Presentation-Foundation-Second-Edition下载。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保您使用最新版本解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Mastering-Windows-Presentation-Foundation-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包可供在 github.com/PacktPublishing/ 上获取。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载: static.packt-cdn.com/downloads/9781838643416_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“您可以在DragDropProperties类中使用BaseDragDropManager类进行声明。”
代码块设置如下:
public string Name
{
get { return name; }
set
{
if (name != value)
{
name = value;
NotifyPropertyChanged("Name");
}
}
}
任何命令行输入或输出都按以下方式编写:
System.Windows.Data Error: 17 : Cannot get 'Item[]' value (type 'ValidationError') from '(Validation.Errors)' (type 'ReadOnlyObservableCollection`1'). BindingExpression:Path=(Validation.Errors)[0].ErrorContent; DataItem='TextBox' (Name=''); target element is 'TextBox' (Name=''); target property is 'ToolTip' (type 'Object') ArgumentOutOfRangeException:'System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会以这种方式显示。以下是一个示例:“取消按钮已在第二行和第二列中声明。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并电子邮件我们至 customercare@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过链接至材料与我们联系至 copyright@packt.com。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一章:智能化使用 WPF 的方法
当Windows Presentation Foundation(WPF)在 2006 年作为.NET Framework 3.0 的一部分首次发布时,它被誉为桌面应用程序图形用户界面(GUI)语言的未来,支持者声称它将结束之前的 GUI 技术,即 Windows Forms。然而,随着时间的推移,它远远没有达到这一预期。
WPF 没有像预期的那样广泛流行,主要原因有三个。第一个原因与 WPF 无关,源于最近将一切托管在云中,并使用 Web 界面而不是桌面应用程序的趋势。第二个原因是 WPF 的学习曲线非常陡峭,需要一种非常不同的工作方式才能掌握。
最后一个原因是它不是一个非常高效的编程语言,如果一个 WPF 应用程序有很多“花哨的功能”,那么客户端计算机可能需要安装额外的 RAM 和/或显卡,或者它们可能会面临缓慢和卡顿的用户体验。
这解释了为什么许多今天使用 WPF 的公司都在金融行业,在那里他们可以承担起升级所有用户的计算机以优化运行其应用程序的费用。本书的目标是通过提供实用的技巧和窍门,使 WPF 对我们其他人来说更容易接近,帮助我们更容易、更高效地构建现实世界的应用程序。
我们可以做出的最简单且对工作流程改进最大的改变之一,就是遵循 MVVM 软件架构模式来改进我们使用 WPF 的方式。它描述了如何组织我们的类,使我们的应用程序更易于维护、测试,并且通常更容易理解。在本章中,我们将更深入地研究这个模式,并了解它如何帮助我们改进我们的应用程序。
在发现 MVVM 是什么以及它的好处之后,我们将学习几种在新的环境中在各个组件之间进行通信的新方法。然后,我们将专注于典型 MVVM 应用程序的代码库的物理结构,并调查各种替代安排。
什么是 MVVM 以及它如何帮助?
模型-视图-视图模型(MVVM)是一种软件架构模式,它由 John Gossman 在 2005 年在其博客上著名地引入,现在在开发 WPF 应用程序时被广泛使用。它的主要目的是在业务模型、用户界面(UI)和业务逻辑之间提供关注点分离。它是通过将它们分为三种不同的核心组件:模型、视图和视图模型来实现的。让我们看看它们的排列方式以及每个组件代表什么:

如我们所见,视图模型组件位于模型和视图之间,并为它们各自提供双向访问。此时应注意的是,视图和模型组件之间不应存在直接关系,而其他组件之间只有松散的联系。现在让我们更详细地看看每个组件代表什么。
模型
与其他 MVVM 组件不同,模型组成部分包括多个元素。它包括业务数据模型及其相关的验证逻辑,以及提供应用程序数据访问和持久性的数据访问层(DAL)或数据存储库。
数据模型表示在应用程序中持有数据的类。它们通常在某种程度上反映了数据库中的列,尽管它们通常是层次结构的,因此可能需要在数据源中执行连接操作以完全填充它们。
一种替代方案是设计数据模型类以适应 UI 的要求,但无论哪种方式,业务逻辑或验证规则通常都会驻留在与数据模型相同的项目中。
用于与我们在应用程序中使用的任何数据持久化技术接口的代码也包含在模式的模型组件中。在代码库中组织此组件时,应谨慎行事,因为有许多问题需要考虑。我们稍后再进一步探讨这个问题,但现在,让我们继续了解这个模式中的组件。
视图模型
视图模型可以很容易地解释;每个视图模型都为其关联的视图提供所需的所有数据和功能。在某种程度上,它们可以被认为是类似于旧 Windows Forms 代码背后的文件,除了它们与它们所服务的视图没有直接关系。如果你熟悉 MVC,一个更好的类比是,它们类似于模型-视图-控制器(MVC)软件架构模式中的控制器。实际上,在 John 的博客中,他将 MVVM 模式描述为 MVC 模式的变体。
他们与模型组件有双向连接,以便访问和更新视图所需的数据,并且通常以某种方式转换这些数据,使其更容易在用户界面中显示和交互。他们还通过数据绑定和属性更改通知与视图进行双向连接。简而言之,视图模型构成了模型和视图之间的桥梁,否则它们之间没有直接的联系。
然而,应注意的是,视图模型仅通过其数据绑定和接口与视图和模型组件松散连接。这种模式的美丽之处在于,它使每个元素能够独立于彼此运行。
为了保持视图模型和视图之间的分离,我们避免在视图模型中声明任何与 UI 相关类型的属性。我们不希望在视图模型项目中包含任何与 UI 相关的 DLL 引用,因此我们使用自定义的 IValueConverter 实现来将它们转换为原始类型。例如,我们可能将 UI 中的 Visibility 对象转换为普通的 bool 值,或将某些彩色 Brush 对象的选择转换为在视图模型中安全使用的 Enum 实例。我们将在本书中看到几个转换器的示例,但现在让我们继续。
视图
视图定义了 UI 的外观和布局。它们通常通过使用它们的 DataContext 属性与视图模型连接,并显示它提供的数据。它们通过将视图模型的命令连接到用户与之交互的 UI 控件来公开视图模型提供的功能。
通常,基本规则是每个视图都有一个相关的视图模型。这并不意味着视图不能绑定到多个数据源,或者我们不能重用视图模型。这仅仅意味着,一般来说,如果我们有一个名为 SecurityView 的类,那么我们很可能也会有一个名为 SecurityViewModel 的类的实例,该实例将被设置为该视图的 DataContext 属性的值。
数据绑定
MVVM 模式经常被忽视的一个方面是其对数据绑定的要求。没有它,我们就无法实现完整的 关注点分离,因为没有简单的方法在视图和视图模型之间进行通信。XAML 标记、数据绑定类以及 ICommand 和 INotifyPropertyChanged 接口是 WPF 中提供此功能的主要工具。
ICommand 接口是 .NET Framework 中命令实现的方式。它提供了实现并甚至扩展了始终有用的命令模式的行为,在该模式中,一个对象封装了执行动作所需的所有内容。WPF 中的大多数 UI 控件都有 Command 属性,我们可以使用这些属性将它们连接到命令提供的功能。
INotifyPropertyChanged 接口用于通知绑定客户端属性值已更改。例如,如果我们有一个 User 对象,并且它有一个 Name 属性,那么我们的 User 类将负责触发 INotifyPropertyChanged 接口的 PropertyChanged 事件,每次其值更改时指定属性名称。我们将在稍后更深入地探讨所有这些内容,但现在让我们看看这些组件的排列如何帮助我们。
那么 MVVM 如何帮助呢?
采用 MVVM 的一大好处是它为业务模型、UI 和业务逻辑之间提供了至关重要的关注点分离。这使我们能够做几件事情。它使视图模型从模型中解放出来,无论是业务模型还是数据持久化技术。
这反过来又使我们能够在其他应用程序中重用业务模型,并替换掉数据访问层(DAL),用模拟数据层(mock data layer)来替换它,这样我们就可以在不需要任何真实数据连接的情况下,有效地测试我们的视图模型中的功能。
它还切断了视图与它们所需的视图逻辑之间的联系,因为这是由视图模型提供的。这使得我们可以独立运行每个组件,这有一个优点,即允许一个团队设计视图,而另一个团队则专注于视图模型。并行工作流使得公司能够从大幅缩短的生产时间中受益。
此外,这种分离还使得我们能够在不修改模型代码的情况下,轻松地用不同的技术替换视图。我们可能需要更改视图模型的一些方面,例如,用于视图的新技术可能不支持 ICommand 接口,但原则上,我们需要更改的代码量将是相当小的。
MVVM 模式的简单性也使得 WPF 更易于理解。知道每个视图(View)都有一个视图模型(View Model)为其提供所有所需的数据和功能,这意味着当我们想要找到数据绑定属性在哪里声明时,我们总是知道该往哪里看。
那么有缺点吗?
然而,使用 MVVM 也有一些缺点,并且它并不适用于所有情况。实现 MVVM 的主要缺点是它给我们的应用程序增加了一定的复杂性。首先,有数据绑定,这可能需要一些时间来掌握。此外,根据您的 Visual Studio 版本,数据绑定错误可能仅在运行时出现,并且可能非常难以追踪。然而,我们将在下一章中探讨解决这个问题的方法。
然后,我们需要了解视图和视图模型之间通信的不同方式。以不寻常的方式执行命令和处理事件需要一段时间来适应。在代码库中找到所有所需组件的最佳排列也需要时间。因此,在确保能够熟练实现 MVVM 之前,我们需要攀登一个陡峭的学习曲线。本书将详细涵盖所有这些领域,并尝试减少学习曲线的梯度。
然而,即使我们对这种模式非常熟练,仍然有偶尔的情况,在这种情况下实现 MVVM 并没有意义。一个例子是,如果我们的应用程序非常小,我们不太可能想要为其编写单元测试或替换其任何组件。因此,当它提供的关注点分离(Separation of Concerns)的好处并不需要时,通过增加模式的复杂性来实现它是不切实际的。
消除关于代码后置的神话
关于 MVVM 的一个重大误解是,我们应该避免将任何代码放入视图的代码后文件中。虽然这一点有一定的真实性,但并不是在所有情况下都适用。如果我们稍微逻辑思考一下,我们已经知道使用 MVVM 的主要原因之一是利用其架构提供的关注点分离。其中一部分是将视图模型中的业务功能与视图中的用户界面相关代码分离。因此,规则实际上应该是我们应该避免将任何业务逻辑放入视图的代码后文件中。
考虑到这一点,让我们看看我们可能想要放入视图代码后文件的代码。最有可能的嫌疑人是一些与 UI 相关的代码,可能是处理某个特定事件,或者启动某种类型的子窗口。在这些情况下,使用代码后文件是完全可行的。这里没有与业务相关的代码,所以我们没有理由将其与其他 UI 相关代码分离。
另一方面,如果我们已经在视图的代码后文件中编写了一些与业务相关的代码,那么我们该如何测试它呢?在这种情况下,我们将无法将这部分代码与视图分离,不再有我们的关注点分离(Separation of Concerns),因此也就破坏了我们对 MVVM 的实现。所以在这种情况下,这个神话不再是神话...它是一条好的建议。
然而,即使在像这种情况一样,我们想要从视图调用一些与业务相关的代码时,也是有可能在不违反任何规则的情况下实现的。只要我们的业务代码位于视图模型中,它就可以通过该视图模型进行测试,因此在运行时它被调用的位置并不那么重要。理解了我们可以始终访问绑定到视图的DataContext属性的视图模型,让我们来看一个简单的例子:
private void Button_Click(object sender, RoutedEventArgs e)
{
UserViewModel viewModel = (UserViewModel)DataContext;
viewModel.PerformSomeAction();
}
现在,有些人可能会对这个代码示例感到犹豫,因为他们正确地认为视图(Views)不应该了解任何关于它们相关的视图模型(View Models)的信息。这段代码实际上将这个视图模型与这个视图绑定在一起。如果我们想在某个时候更改应用程序的 UI 层或者让设计师处理视图,那么这段代码就会给我们带来问题。然而,我们需要保持现实...我们真正需要这样做的机会有多大?
如果这种情况很可能会发生,那么我们真的不应该将这段代码放入代码后文件中,而是通过将其包装在一个附加属性(Attached Property)中处理事件,我们将在下一节中看到这个例子。然而,如果这种情况根本不可能发生,那么将其放在那里实际上并没有问题。
例如,如果我们有一个UserView,它有一个相应的UserViewModel类,并且我们确信我们不需要更改它,那么在这种情况下,我们可以安全地使用上述代码,无需担心直接转换会导致异常抛出。让我们在它们对我们有意义时遵循规则,而不是盲目地坚持它们,因为某人在不同场景中说它们是个好主意。
另一种情况下,我们可以忽略“无代码后端”规则,那就是在基于UserControl类编写自包含控件时。在这些情况下,后端代码文件通常用于定义依赖属性和/或处理 UI 事件,以及实现通用 UI 功能。但请记住,如果这些控件实现了某些与业务相关的功能,我们应该将其写入视图模型,并在控件中调用它,以便仍然可以进行测试。
避免在视图的后端文件中编写与业务相关的代码的一般想法确实很有意义,我们应该始终尝试这样做。然而,我们现在可能已经理解了这个想法背后的原因,并可以使用我们的逻辑来确定在每个可能出现的特定情况下是否可以这样做。
学习如何再次进行沟通
由于我们倾向于不直接处理 UI 事件,当使用 MVVM 时,我们需要实现它们提供相同功能的替代方法。需要不同的方法来重现不同事件的功能。例如,集合控件SelectionChanged事件的功能通常通过将视图模型属性数据绑定到集合控件的SelectedItem属性来重现:
<ListBox ItemsSource="{Binding Items}"
SelectedItem="{Binding CurrentItem}" />
在这个例子中,CurrentItem属性的设置器将在每次从ListBox中选择新项目时由 WPF 框架调用。因此,我们可以在视图模型的属性设置器中直接调用任何方法,而不是在代码后端处理SelectionChanged事件:
public TypeOfObject CurrentItem
{
get { return currentItem; }
set
{
currentItem = value;
DoSomethingWithTheNewlySelectedItem(currentItem);
}
}
注意,我们需要确保从数据绑定属性设置器调用的任何方法不要做得太多,因为执行它们所需的时间可能会在输入数据时对性能产生负面影响。然而,在这个例子中,我们通常会使用这个方法来启动一个异步数据访问函数,使用当前项的值或更改视图模型中另一个属性的值。
许多其他 UI 事件可以直接在 XAML 标记中使用某种形式的Trigger来替换。例如,假设我们有一个Image元素,它被设置为Button控件的Content属性值,并且我们希望当Button被禁用时,Image元素是半透明的。我们可以在后端文件中处理UIElement.IsEnabledChanged事件,而不是在Style中编写一个DataTrigger,并将其应用于Image元素:
<Style x:Key="ImageInButtonStyle" TargetType="{x:Type Image}">
<Setter Property="Opacity" Value="1.0" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsEnabled,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type Button}}, FallbackValue=False}"
Value="False">
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</Style.Triggers>
</Style>
绑定语法将在第四章“精通数据绑定”中详细介绍,但简而言之,这个 DataTrigger 中的绑定指定了目标为具有 Button 类型的 Image 元素的祖先(或父)的 IsEnabled 属性。当这个绑定目标值为 False 时,Image 的 Opacity 属性将被设置为 0.5,并在目标属性值变为 True 时恢复到原始值。因此,当按钮被禁用时,我们的 Button 中的 Image 元素将变为半透明。
介绍 ICommand 接口
当涉及到 WPF 和 MVVM 中的按钮点击时,我们通常使用实现 ICommand 接口的一些形式的命令来处理众所周知的 Click 事件。让我们看看一个基本标准命令的例子:
using System;
using System.Windows.Forms;
using System.Windows.Input;
public class TestCommand : ICommand
{
public event EventHandler CanExecuteChanged;
public void Execute(object parameter)
{
MessageBox.Show("You executed a command");
}
public bool CanExecute(object parameter)
{
return true;
}
}
请注意,在这本书中,我们将使用两个空格的缩进来显示代码,而不是更常用的四个空格缩进,以便使每个代码片段的更多字符能够适应每一行。
我们可以看到它有一个 Execute 方法,其中执行了命令提供的功能。CanExecute 方法在 CommandManager 认为输出值可能发生变化的各种时间点被调用。我们将在稍后更详细地介绍这一点,但基本上,引发 CanExecuteChanged 事件是触发 CommandManager 执行此操作的一种方式。CanExecute 方法的输出指定了是否可以调用 Execute 方法。
你可以想象,如果我们需要为每个需要实现的操作创建这样一个类,那将会多么繁琐。此外,除了单个命令参数之外,没有上下文可以说明命令是从哪里被调用的。这意味着,如果我们想让命令向一个集合中添加一个项目,我们就必须将集合和要添加的项目都放入另一个对象中,这样它们才能通过单个输入参数传递。
当使用 MVVM 时,我们倾向于使用一个单一、可重用的实现来处理命令,这允许我们在 View Model 中直接使用标准方法来处理操作。这使得我们无需为每个命令创建一个单独的类。这个命令有许多变体,但它的最简单形式如下所示:
using System;
using System.Windows.Input;
public class ActionCommand : ICommand
{
private readonly Action<object> action;
private readonly Predicate<object> canExecute;
public ActionCommand(Action<object> action) : this(action, null) { }
public ActionCommand(Action<object> action,
Predicate<object> canExecute)
{
this.action = action;
this.canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter)
{
return canExecute == null ? true : canExecute(parameter);
}
public void Execute(object parameter)
{
action(parameter);
}
}
类型为 Action<object> 的 action 参数将持有在执行命令时将被调用的方法的引用,而 object 泛型参数与可选使用的命令参数相关。类型为 Predicate<object> 的 canExecute 参数将持有在验证命令是否可以执行时将被调用的方法的引用,其 object 泛型参数再次与可选使用的命令参数相关。
当canExecute参数值发生变化时,应该触发CanExecuteChanged事件。通常,这个事件由命令源,例如Button控件,来适当地设置它们的IsEnabled属性值来处理。当一个命令源收到这个事件已被触发的通知时,它将调用ICommand.CanExecute方法来检查新的值。因此,当一个命令可以执行时,其数据绑定控件将被启用,而当它不能执行时,其数据绑定控件将被禁用。
当CommandManager检测到 UI 的变化,这可能影响命令是否可以执行时,将触发CommandManager.RequerySuggested事件。例如,这可能是由于用户交互,如选择集合中的项目或其他焦点变化。因此,将它们连接起来似乎是合乎逻辑的事情。实际上,这种做法的例子可以在.NET RoutedCommand类的源代码中找到。
这个命令类通常用于视图模型类中,如下面的示例所示,其中命令功能来自Save方法,而CanSave方法的bool返回值决定了命令是否可以执行:
public ICommand SaveCommand
{
get { return new ActionCommand(action => Save(),
canExecute => CanSave()); }
}
当CanExecute条件不满足时,确保命令不会被代码调用的一种更安全的方法是进行这种修改;然而,请注意,CommandManager类在调用任何命令之前总是会执行这个检查:
public void Execute(object parameter)
{
if (CanExecute(parameter)) action(parameter);
}
对于这个自定义命令,全部的功劳应该归功于 Josh Smith,因为他的RelayCommand类是我遇到的第一种类似实现,尽管网上可以找到几种变体。这种特定实现的美丽之处不应被低估。它不仅简单、优雅,节省了我们编写大量代码,而且还使得测试我们的功能变得更加容易,因为我们的命令代码现在可以直接定义在我们的视图模型中。
我们将在第三章“编写自定义应用程序框架”中再次详细地查看这个ActionCommand,但到目前为止,让我们继续到下一个通信方法。
在附加属性中处理事件
在 WPF 中,有一种处理事件的方法,无需在视图的代码背后文件中编写代码。使用附加属性,我们可以封装事件的处理,并有效地通过我们可以数据绑定到视图中的属性来公开它们的行为。让我们看看使用PreviewKeyDown事件的简单示例:
public static DependencyProperty OnEnterKeyDownProperty =
DependencyProperty.RegisterAttached("OnEnterKeyDown",
typeof(ICommand), typeof(TextBoxProperties),
new PropertyMetadata(OnOnEnterKeyDownChanged));
public static ICommand GetOnEnterKeyDown(DependencyObject dependencyObject)
{
return (ICommand)dependencyObject.GetValue(OnEnterKeyDownProperty);
}
public static void SetOnEnterKeyDown(DependencyObject dependencyObject,
ICommand value)
{
dependencyObject.SetValue(OnEnterKeyDownProperty, value);
}
private static void OnOnEnterKeyDownChanged(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
TextBox textBox = (TextBox)dependencyObject;
if (e.OldValue == null && e.NewValue != null)
textBox.PreviewKeyDown += TextBox_OnEnterKeyDown;
else if (e.OldValue != null && e.NewValue == null)
textBox.PreviewKeyDown -= TextBox_OnEnterKeyDown;
}
private static void TextBox_OnEnterKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter || e.Key == Key.Return)
{
TextBox textBox = sender as TextBox;
ICommand command = GetOnEnterKeyDown(textBox);
if (command != null && command.CanExecute(textBox))
command.Execute(textBox);
}
}
如此例所示,事件通过以正常方式附加事件处理程序来处理,但所有相关代码都被封装在声明附加属性的类中。让我们更仔细地看看。首先,我们在名为TextBoxProperties的类中声明了一个名为OnEnterKeyDown的附加属性,其类型为ICommand,并将我们处理方法的引用传递给PropertyMetadata构造函数的PropertyChangedCallback委托参数。
GetOnEnterKeyDown和SetOnEnterKeyDown方法代表了获取和设置附加属性值的正常方式。在不幸命名的OnOnEnterKeyDownChanged方法中,当属性值改变时将被调用,我们查看DependencyPropertyChangedEventArgs输入参数的NewValue和OldValue值,以决定我们是否需要将事件处理程序附加或从相关TextBox的PreviewKeyDown事件中分离。
如果OldValue值为null且NewValue值不为null,这意味着没有之前的值,因此属性是第一次被设置。在这种情况下,我们想要附加事件处理程序。相反,当OldValue值不为null且NewValue值为null时,这意味着之前有一个值,该值已被删除,因此我们应该分离事件处理程序。
最后,TextBox_OnEnterKeyDown事件处理方法首先检测是否按下了Enter键或Return键。如果其中一个被按下,则会检查数据绑定的ICommand实例是否为null,如果命令可以执行,则执行它。因此,我们有效地将一个PreviewKeyDown事件封装在一个附加属性中,现在可以在用户按下键盘上的Enter键时执行任何已数据绑定到它的命令。
为了使用这个附加属性,我们必须首先在需要此功能的视图的 XAML 文件中为我们的Attached文件夹添加一个 XAML 命名空间前缀。请注意,TextBoxProperties类将在视图项目的Attached文件夹中声明,因此其命名空间如下:
xmlns:Attached="clr-namespace:CompanyName.ApplicationName.Views.Attached;
assembly=CompanyName.ApplicationName.Views"
微软为这些前缀命名的约定是第一个字符为小写字母,但对我来说,简单地使用声明的命名空间的最后一段,并以大写字母开头,似乎更有意义。一旦你定义了前缀,你就可以使用附加属性,如下面的示例所示:
<TextBox Attached:TextBoxProperties.OnEnterKeyDown="{Binding Command}" />
我们可能需要在我们的应用程序中处理的任何 UI 事件都可以以这种方式封装在附加属性中。起初,与在代码背后文件中有一个简单的处理程序相比,这似乎是一种处理事件复杂的方法,但一旦我们声明了这些属性的集合,我们会发现自己需要创建越来越少的新属性。把它们看作是将事件转换为属性的可重用方式。
使用委托
委托与事件非常相似,实际上,事件可以被视为一种特定的委托。它们是在程序中将信号或消息从一个地方传递到另一个地方的非常简单的工具。与创建自定义事件不同,我们不必使用特定的输入参数,例如EventArgs类的一些形式。在创建自定义委托时,我们完全不受约束,能够定义自己的方法签名,包括输入和输出参数类型。
由于你们大多数人已经熟悉事件和事件处理,你们可能已经无意中知道了如何使用委托。让我们看看一个简单的例子。想象一下,我们有一个父视图模型,它会生成子视图模型,其中之一与一个视图配对,该视图允许管理员用户选择安全权限。现在,让我们想象一下,与父视图模型相关的父视图有一个菜单,需要根据用户在子视图中的选择进行更新。我们如何在选择时通知父视图模型?
这就是代表们拯救世界的地方。为了使这个例子简单起见,我们先假设我们只需要通知父视图模型(ViewModel)已经做出了一些特定的更改,以便它可以从数据库中刷新当前用户的权限。在这种情况下,我们只需要传递一个信号,因此我们可以创建一个没有输入或输出参数的委托。我们可以在将要发送信号的视图模型中声明它,在这种情况下,是子视图模型:
public delegate void Signal();
注意,我们定义它的方式与定义抽象方法的方式相同,只是在访问修饰符之后将abstract关键字替换为delegate关键字。简而言之,委托定义了一个引用具有特定签名的方法的类型。现在我们已经定义了我们的信号委托,我们需要创建一种方式,让视图模型之外的其他元素可以使用它。为此,我们可以在同一个视图模型中简单地创建一个新委托类型的属性:
public Signal OnSecurityPermissionChanged { get; set; }
由于我们不需要为这个属性提供任何属性更改通知,我们可以节省一些打字时间,并利用.NET 自动实现属性语法。请注意,委托以多播方式工作,就像事件一样,这意味着我们可以为每个委托附加多个处理程序。为了做到这一点,我们需要使用+=运算符为委托添加处理程序,在这个例子中,我们希望在子视图实例化时在父视图模型中这样做:
ChildViewModel viewModel = new ChildViewModel();
viewModel.OnSecurityPermissionChanged += RefreshSecurityPermissions;
在这里,我们将父视图模型中的RefreshSecurityPermissions方法指定为处理此代理的事件处理程序。请注意,在附加处理程序时,我们省略了括号以及如果有任何输入参数也一并省略。现在,你可能想知道,“这个处理程序的方法签名是什么样的?”,但你已经有了答案。如果你记得,我们声明代理时使用了我们想要处理的方法的签名。因此,任何具有相同签名的函数都可以成为此类代理的处理程序:
private void RefreshSecurityPermissions()
{
// Refresh user's security permissions when alerted by the signal
}
请注意,所使用的名称无关紧要,在匹配代理签名时,重要的是输入和输出参数。因此,我们现在已经声明了代理,并将其连接到父视图模型中的处理程序,但它仍然不会做任何事情,因为我们还没有实际调用它。在我们的例子中,是子视图模型将要调用代理,因为这是需要发送信息或信号的物体。
在调用代理时,我们必须始终记得在使用之前检查null,因为可能没有附加处理程序。在我们的例子中,我们将在需要从子视图模型发送信号的时刻调用我们的Signal代理,比如说,当用户更改自己的安全权限时:
if (OnSecurityPermissionChanged != null) OnSecurityPermissionChanged();
或者,我们可以使用 C# 6.0 中更简洁的空条件运算符来完成,如果代理不是null,它将调用代理的Invoke方法:
OnSecurityPermissionChanged?.Invoke();
请注意,即使在第一个例子中调用代理时需要包括括号,即使OnSecurityPermissionChanged是一个属性。这是因为属性的代理类型与一个方法相关联,而我们正在调用的是这个方法。请记住,这些方法中的第一个不是线程安全的,所以如果你的应用程序需要线程安全,那么你需要使用后面的方式。
我们现在有了完整的画面,但尽管有一个像这样的信号发送代理很常见,但它并不特别有用,因为它只传递信号而没有其他信息。在许多实际场景中,我们通常会想要有一个某种输入参数,这样我们就可以传递一些信息,而不仅仅是信号。
例如,如果我们想要在用户每次从 UI 中的集合控件中选择不同的项目时收到通知,我们可以在我们的应用程序中的通用BaseCollection类中添加一个CurrentItem属性,并将其绑定到数据绑定集合控件的SelectedItem属性。每次用户进行新的选择时,WPF 框架都会调用这个CurrentItem属性,因此我们可以从其属性设置器调用我们新的代理:
protected T currentItem;
public virtual CurrentItemChange CurrentItemChanged { get; set; }
public virtual T CurrentItem
{
get { return currentItem; }
set
{
T oldCurrentItem = currentItem;
currentItem = value;
CurrentItemChanged?.Invoke(oldCurrentItem, currentItem);
NotifyPropertyChanged();
}
}
代理可以用于在任何相关类之间进行通信,只要它们可以访问公开代理的类,以便它们可以附加处理程序。它们通常用于在子视图或视图模型及其父级之间发送信息,甚至可以在视图和视图模型之间使用,但它们也可以用于在应用程序的任何两个连接部分之间传递数据。
结构化应用程序代码库
现在我们对 MVVM 模式有了更好的理解,让我们看看我们如何在 WPF 应用程序中实现它。我们的应用程序文件夹结构应该是什么样的?显然,我们需要一个地方来放置我们的 Models、Views 和 View Models;然而,它们的排列将部分取决于我们应用程序的整体大小。
正如我们所听到的,非常小的项目并不真正适合 MVVM,因为实现它可能需要大量的准备工作,而且通常,好处并不适用。对于小型 WPF 应用程序,我们通常在我们的 WPF 应用程序中只有一个项目。在这些情况下,我们的类将在这个单一的项目中分离到不同的文件夹中。
对于更大规模的应用程序,我们按照相同的基本结构排列我们的类,但由于有更多的类,我们想要重用其中一些代码的可能性更大,因此使用单独的项目而不是文件夹是有意义的。无论如何,我们的类最终应该具有相同的 CLR 命名空间,因为它们往往遵循应用程序的结构,无论这些类是使用文件夹还是项目进行分离。
虽然在我们初创项目的 CLR 命名空间可能类似于 CompanyName.ApplicationName,但 Models 组件中类的命名空间将是,或者以,CompanyName.ApplicationName.Models 开头。在本书的剩余部分,我们将假设我们正在处理一个大规模的 WPF 应用程序,并使用项目来分离我们的类。
MVVM 模式中没有规定我们的代码库应该有什么结构,尽管有一些线索。我们显然需要 Views 和 ViewModels 项目,但 Models 项目定义得不太明确。MVVM 的 Models 组件中有几个元素,但我们不一定想将它们全部组合到我们的代码库中的单个项目中。还需要其他项目。让我们可视化一些可能的结构,这样我们就可以开始构建我们的应用程序了:


这些示例展示了基于 MVVM 的 WPF 应用程序的项目结构可能是什么样的。然而,没有任何东西是固定不变的,我们可以根据需要重命名和重新组织我们的应用程序项目。重要的是组件之间的连接方式,而不是应用程序文件的排列。
在我们开发了一系列 WPF 应用程序之后,我们会对哪些项目名称和哪些结构更偏好有所体会,所以我建议尝试几种不同的变体,看看哪种你感觉更舒适地工作。当然,我们中的一些人可能没有足够的自由来创建或修改我们所工作的应用程序的结构。让我们首先关注两个示例结构共有的项目。
我们可以看到 Images 和 Resources 文件夹位于启动项目中。虽然这是惯例,但它们在技术上可以位于任何项目中,甚至可以位于它们自己的项目中。然而,我更喜欢将它们放在这个项目中,因为这提供了一点点性能优势。通常,在使用 MVVM 时,启动项目中除了 MainWindow.xaml 和 MainWindow.xaml.cs 文件外,还可能有其他视图文件,以及 App.xaml(可能还有其代码后文件)和 app.config 文件。
Images 文件夹包含在 UI 控件中显示的图像和图标,而 Resources 文件夹通常包含任何资源文件,例如 XML 架构或文本或数据文件,这些文件被应用程序使用。
下一个项目被命名为 Converters,其名称相当直观。它只包含实现了 IValueConverter 或 IMultiValueConverter 接口的类,并且用于在视图中将数据绑定值进行转换。这些类都是可重用的,并且从这个项目中生成的 DLL 应该保持最新状态,并在我们的其他应用程序之间共享。
这两个示例都显示了一个名为 Extensions 的项目,但这完全是可选的,并不是 MVVM 模式的必需品。我恰好发现扩展方法是 .NET 开发的一个基本组成部分,因为我已经建立了一个包含大量宝贵辅助方法的庞大集合。例如,在习惯了在 IEnumerable 实例上调用 Add 或在查询结果上调用 ToObservableCollection 之后,我现在在每一个应用程序中都重用它们。我们将在第三章 编写自定义应用程序框架、第九章 实现响应式数据验证和第十章 完成那个伟大的用户体验中看到这些示例。
我们接下来可以看到的下一个常见项目是一个名为 Managers 的项目。其他人可能更喜欢将其称为 Engines、Services 或类似名称,但这只是个人偏好,无论如何,内容都是相同的。在这个项目中,我们通常会发现许多类,这些类共同为视图模型提供了广泛的功能。
例如,在这个项目中,我们可能会找到名为 ExportManager、FeedbackManager、HardDriveManager、WindowManager 等的类。
拥有一个这样的项目非常重要,这个项目可以为我们提供一个共同的地方,用于提供应用所需的所有专用功能,而不是在每个需要该功能的 View Model 中重复代码。这些类可以在不同的应用之间完全重用,并且这种安排也有助于在整个应用中保持行为一致性。
例如,如果没有在这个项目中整合所有功能,我们可能会倾向于从一个 View Model 复制和粘贴某些代码到另一个 View Model。如果代码在未来需要更改,我们可能不会记得已经复制了它,并且只在一个 View Model 中更新它,从而破坏了应用的一致性。
利用此类项目的另一个好处是,它减少了其他项目所需的引用数量。通常,Managers 项目需要添加许多引用,而使用其功能的 View Model 和其他类只需添加对该项目的单个引用。
这些类的一些或所有功能可以通过 BaseViewModel 类公开,因此可以提供给每个 View Model。我们将在第三章编写自定义应用程序框架中了解更多关于这一点,但现在,让我们开始探讨两种结构之间的差异。
在第一个结构示例中,Models 项目中的 Business 文件夹仅代表应用的业务数据模型。除了它们与 ViewModels.Business 视图模型和 Views.Business 视图相关联的事实之外,实际上没有必要将这些类放在单独的 Business 文件夹中。
从技术角度来看,我们应用中的数据模型类应该代表我们的业务对象,而不应包含任何与业务模型无关的属性,例如名为 CurrentItem 或 IsSelected 的属性。如果情况如此,并且它们在它们自己的项目中定义,如第一个示例所示,那么我们可以在我们的其他业务应用中重用它们的 DLL。或者,也许我们已经有了一个代表另一个应用中的业务模型的 DLL,我们将在下一个应用中重用它。
在这两种情况中,我们都需要在 ViewModels 项目中添加其他文件夹,在这些文件夹中,我们将为每个要显示的业务模型类实现一个额外的 View Model 类。这种安排在第一个示例的 ViewModels 中的 Business 文件夹中显示,并展示了数据模型与视图之间的分离。
在这些类中,我们将每个公共业务模型属性封装在一个新的属性中,该属性会触发更改通知,并添加 UI 所需的任何其他属性。它看起来类似于以下示例,其中BaseBusinessViewModel类只是实现了INotifyPropertyChanged接口:
using System;
namespace CompanyName.ApplicationName.Models.Business
{
public class User
{
public User(Guid id, string name, int age)
{
Id = id;
Name = name;
Age = age;
}
public Guid Id { get; set; }
public string Name { get; set; }
public int Age { get; set; }
}
}
using System;
using CompanyName.ApplicationName.Models.Business;
namespace CompanyName.ApplicationName.ViewModels.Business
{
public class UserViewModel : BaseBusinessViewModel
{
private User model;
private bool isSelected = false;
public UserViewModel(User model)
{
Model = model;
}
public User Model
{
get { return model; }
set { model = value; NotifyPropertyChanged(); }
}
public Guid Id
{
get { return Model.Id; }
set { Model.Id = value; NotifyPropertyChanged(); }
}
public string Name
{
get { return Model.Name; }
set { Model.Name = value; NotifyPropertyChanged(); }
}
public int Age
{
get { return Model.Age; }
set { Model.Age = value; NotifyPropertyChanged(); }
}
public bool IsSelected
{
get { return isSelected; }
set { isSelected = value; NotifyPropertyChanged(); }
}
}
}
在实现这个模式时,在从数据源加载每个数据对象之后,在将其显示在 UI 之前,需要将其包装在这些视图模型类之一中:
User user = new User(Guid.NewGuid(), "James Smith", 25);
UserViewModel userViewModel = new UserViewModel(user);
按照第一个示例结构中的模式,一直应用到Views项目,我们可以看到它也包含一个Business文件夹。通常,我们可以在那里为每个与业务模型相关的视图模型找到一个小型的、单独的对象大小的视图。然而,在绝大多数应用中,这种业务模型和 UI 之间的额外分离级别是根本不必要的。此外,遵循这种模式还会给所有实现和数据访问时间增加一点开销。
对于一些人来说,一个可行的替代方案是将 UI 所需的属性和属性更改通知直接添加到数据模型类中。如果我们不需要这种分离,那么编写所有额外代码就几乎没有意义。
我非常推崇敏捷实践,敏捷软件开发宣言中的十二条原则之一完美地总结了这一点:
“简洁——最大化未完成工作的艺术——是至关重要的”
这种更简单、替代的实现方式在第二个示例的DataModels项目中展示,其中业务模型类与 UI 相关的属性相结合,以及业务规则或验证逻辑。
在其他类型的应用中,你可能会找到一个位于 DAL 和 UI 层后面的代码之间的单独验证层。但正如我们将在第九章实现响应式数据验证中看到的那样,使用 WPF,我们可以在业务类中直接构建验证,包括它们正在验证的属性。
在这个DataModels项目中,包含了许多子文件夹,将相似类型的类分组在一起。Collections文件夹通常包含应用程序中每个数据模型类的ObservableCollection<T>类的扩展。Enums文件夹在大多数 WPF 应用中也经常被使用,因为枚举在绑定到单选按钮或复选框时非常有用。
在Interfaces文件夹中找到的接口对于启用基类的功能至关重要,正如我们将在第三章中看到的,编写自定义应用程序框架。如果我们可能在我们应用程序中使用大量的代理,那么将它们组织到一个单独的Delegates文件夹中也是有意义的。否则,如果一个代理与特定的类紧密相关,它们可以直接在将引发它们的类中声明。
另一个选择可能是在Models项目中有一个单独的类,它封装了所有的应用程序代理,尽管这样做在使用它们时需要将这个类的名称作为前缀添加到代理名称之前,例如,Delegates.CloseRequest。在使用它们的类中声明每个代理使我们能够直接引用它们,例如,CloseRequest。
这个项目中的数据模型类也可以被视为视图模型,尽管它们只服务于单个对象的显示,而不是服务于主应用程序的视图。它们将有一个基类,实现与主视图模型相同的INotifyPropertyChanged接口,然后通常还会实现一个验证错误接口。
它们也与主应用程序的视图模型不同,因为它们通常只为它们关联的视图提供验证以外的功能。我们可以将这些类视为仅具有一些额外属性以实现与 UI 有效通信的简单数据容器。
当遵循这种结构时,我们可以使用数据模板在 UI 中渲染这些单个对象大小的视图模型,所以我们通常不需要为每个它们声明一个单独的视图。此外,我们可能希望在应用程序的不同部分以不同的方式显示相同的对象,或者甚至根据某些用户操作切换它们的显示,而这也可以通过数据模板更容易地实现。
这解释了为什么这些对象不与主应用程序的视图模型一起位于View Models项目中。如果你还记得,每个视图模型应该只关联一个视图。为了本书的目的,这种更简单、替代的实现方式是我们将遵循的模式。现在,让我们继续通过调查应用程序的 DAL(数据访问层)来继续。
第一个示例中的DataProviders项目负责提供对应用程序持久数据源的访问。另一个常用的名称是Repositories,但同样,你可以称它为你喜欢的名字。重要的是它有一个包含一个或多个接口的Interfaces文件夹,这些接口形成了数据源(们)与应用程序其余部分之间的连接。
在第二个示例中,“数据提供程序”和“接口”文件夹位于“模型”项目中,但它们具有相同的职责。无论如何,正是通过使用这些接口,我们能够断开数据源,并在测试时用某种类型的模拟源替换它。我们将在第三章“编写自定义应用程序框架”中查看一个示例,但现在让我们继续。
“视图模型”项目相当容易理解,因为它只包含视图模型。你可能想知道为什么它里面有一个“命令”文件夹。如果我们像过去那样使用命令,为每个命令编写一个单独的类,那么我们可能会得到很多类,这可能会要求我们将它们放入自己的项目中。
然而,如果你记得,我们将只使用一个单独的命令,即“操作命令”。由于这个命令将由视图模型类单独使用,因此将其包含在它们的项目中是有意义的。我们已经讨论了两个示例结构之间视图模型和视图项目之间的差异,所以让我们完成对剩余共同部分的查看。
我们经常在“视图”项目中找到一个名为“附件”的文件夹,其中包含应用程序中使用的附件属性。由于这些类包含与视图相关的代码,并且仅由视图使用,因此它们应该位于此处是合乎逻辑的。除此之外,我们还可以看到“控件”文件夹,其中包含可重用的用户控件和/或自定义控件,例如,当点击时可以弹出一个子窗口以帮助编辑的自定义文本框,或者可以用来输入时间的自定义时钟面。
在两个示例结构的底部,我们看到包含测试应用程序代码的测试项目。如果你的应用程序需要测试,这是一个很好的模式。通过在我们的测试项目名称前加上“测试”域,它们将全部出现在 Visual Studio 解决方案资源管理器中的一个组中,要么在其它项目之上或之下,并且按照测试项目的顺序排列。
“模拟”项目通常包含在测试应用程序时使用的应用程序对象。这通常包括任何模拟数据生成或提供程序类以及模拟“管理器”类。如果我们不想在测试时使用昂贵的资源,或者它们访问我们希望在测试时避免的任何 UI 元素,我们可能需要创建这些模拟“管理器”类。让我们看看UiThreadManager类的一个可能的方法示例:
public Task RunAsynchronously(Action method)
{
return Task.Run(method);
}
此方法相当直接,使我们能够传递任何我们想要异步运行的方法的引用。它只是将方法引用传递给Task.Run方法,并让它自行处理。它可以这样调用:
UiThreadManager.RunAsynchronously(() => GenerateReports());
然而,在单元测试中异步运行代码可能会产生不可预测的结果,这可能导致测试失败。因此,在测试时,我们需要使用MockUiThreadManager类并实现其RunAsynchronously方法,如下所示:
public Task RunAsynchronously(Action method)
{
Task task = new Task(method);
task.RunSynchronously();
return task;
}
在这个方法中,我们可以看到我们使用Task类的RunSynchronously方法来同步运行引用的方法,换句话说,立即在同一线程上运行。实际上,这仅仅绕过了原始方法的功能。使用这些模拟对象,我们可以在测试时运行与运行时不同的代码。我们将在第三章中看到更多这些模拟对象的例子,编写自定义应用程序框架,但让我们首先回顾一下到目前为止我们已经覆盖了什么内容。
摘要
在本章中,我们已经了解了 MVVM 架构模式及其在开发 WPF 应用程序时的好处。我们现在处于更好的位置来决定哪些应用程序可以使用它,哪些不适用。我们开始研究这种模式中各个组件之间通信的各种新方法,并调查了组织源代码最常见的方式。我们现在准备开始制定我们自己的应用程序结构。
在下一章中,在我们正式开始构建我们的应用程序之前,我们将探讨几种调试数据绑定值这一有时棘手任务的方法。我们将发现其他有用的技巧和窍门,这些技巧和窍门可以帮助我们消除应用程序中可能出现的任何问题,这样一旦我们开始构建,我们就能避免浪费时间在可能出现的问题上。
第二章:调试 WPF 应用程序
当我们的 WPF 程序未按预期工作,我们需要像对待任何其他语言一样对其进行调试。然而,一开始这可能看起来是一项艰巨的任务,因为 WPF 与其他语言非常不同。例如,当我们声明依赖属性时,我们通常为了方便添加一个 CLR 属性包装器。然而,当属性值改变时,WPF 框架不会调用它,所以我们会在那个设置器的断点被命中之前等待很长时间。
当我们在测试新开发的代码时,我们需要能够检查我们数据绑定属性的值,并且有几种方法可以做到这一点,尽管其中一些并不明显。在本章中,我们将调查一些重要的信息来源,以帮助我们定位代码中的错误。
我们将发现各种策略来帮助我们调试数据绑定值,并找出在遇到可怕的 XamlParseException 时如何追踪问题的实际原因。我们将详细讨论所有这些主题,但现在,让我们首先从绝对的基础开始。
利用输出窗口
当我们对 XAML 进行了更改但未在 UI 中看到我们期望看到的内容时,首先查找错误的地方是 Visual Studio 的输出窗口。如果此窗口尚未可见,则可以通过从视图菜单中选择输出选项或按 Ctrl + W 然后按 O 来显示它。
然而,如果您有一个绑定错误但在输出窗口中没有看到任何关于它的引用,这可能是因为您的 Visual Studio 当前未设置将调试信息输出到其中。您可以在 Visual Studio 选项对话框中启用此功能。导航到工具 | 选项 | 调试 | 输出窗口 | 通用输出设置。
在“通用输出设置”部分有几个选项,您可以打开或关闭它们。其中最重要的选项是“所有调试输出”和“异常消息”,但通常将它们全部设置为开启是一个好的实践。当设置后,绑定错误将以以下格式在输出窗口中显示:
System.Windows.Data Error: 40 : BindingExpression path error:
'ViewName' property not found on 'object' ''MainViewModel'
(HashCode=3910657)'. BindingExpression:Path=ViewName;
DataItem='MainViewModel' (HashCode=3910657); target element is 'TextBox'
(Name='NameTextBox'); target property is 'Text' (type 'String')
让我们更仔细地看看这个错误。这个错误的普通英语翻译如下:
-
在类型为
MainViewModel且HashCode值为3910657的对象中不存在名为ViewName的公共属性。 -
错误是从
Binding.Path值引发的,该值被指定为ViewName,它设置在名为NameTextBox的TextBox实例的Text属性上
这可以用描述性的名称而不是具体细节来重写,如下所示:
System.Windows.Data Error: 40 : BindingExpression path error: 'PropertyOfBindingSource' property not found on 'object' ''TypeOfBindingSource' (HashCode=HashCodeOfBindingSource)'. BindingExpression:Path=UsedBindingPath; DataItem='TypeOfBindingSource' (HashCode=HashCodeOfBindingSource); target element is 'TypeOfBindingTarget' (Name='NameOfBindingTarget'); target property is
'PropertyOfBindingTarget' (type 'TypeOfBindingTargetProperty')
现在我们有了解释这些值代表什么的“关键”,我们可以看到它们确实是描述性的。不仅我们提供了数据绑定 UI 控件的名称,如果设置了,以及使用的绑定路径,还包括数据源的类型,以及正在使用的该类型实际实例的哈希码。
这些错误突出了在 XAML 文件中犯下的错误。在此窗口中显示的错误类型将包括错误标记的绑定路径,例如使用不存在的属性名称,或无效的绑定源路径。虽然它不会捕获每个问题,但有一种方法可以使其输出可能帮助我们追踪更难以捉摸的问题的额外信息。为了做到这一点,首先显示选项对话框窗口。导航到工具 | 选项 | 调试 | 输出窗口 | WPF 跟踪设置。
在这里,你可以找到许多选项,每个选项都有不同级别的输出:动画、数据绑定、依赖属性、文档、可冻结、HWND 托管、标记、名称范围、资源字典和路由事件。各种输出级别及其含义如下:
-
关键:仅启用跟踪关键事件
-
错误:启用跟踪关键和错误事件
-
警告:启用跟踪关键、错误和警告事件
-
信息:启用跟踪关键、错误、警告和信息事件
-
详尽:启用跟踪关键、错误、警告、信息和详尽事件
-
ActivityTracing:启用跟踪停止、开始、挂起、传输和恢复事件
永久将数据绑定选项设置为警告或错误,而将其他选项设置为关闭,这种情况相当常见。使用这些选项的一般规则是使用所需的最小级别,除非在尝试查找问题时,因为它们会减慢应用程序的运行速度。然而,需要注意的是,这种额外的调试跟踪输出不会对发布构建产生任何影响。
如果你将数据绑定条目设置为详尽或全部输出,并在运行应用程序时查看输出窗口,你将理解为什么它会负面影响性能。即使不在输出窗口中显示此调试信息,当存在绑定错误时,WPF 框架仍将执行大量的检查。因此,清除显示的所有错误和警告非常重要,以最大限度地减少框架在尝试解决它们时的工作量。
利用演示跟踪源
尽管它很有用,但在某些情况下,使用输出窗口是不够的。也许我们现在有太多的输出需要查看,希望在工作回家的路上查看,或者也许我们需要在应用程序部署后查看这种类型的调试跟踪信息。在这些情况下以及其他情况下,是时候启用 WPF 演示跟踪源了。
我们可以采用多种不同的跟踪源来为我们输出详细的跟踪数据。选择与 WPF 跟踪设置选项中找到的选择相同,实际上,在设置这些值之后,输出窗口已经显示给我们调试跟踪输出。
默认情况下,WPF 使用DefaultTraceListener对象将信息发送到输出窗口,但我们可以覆盖它,并/或配置输出以发送到文本和/或 XML 文件,或者同时发送。
为了完成这个任务,我们需要修改我们的app.config文件,该文件位于启动项目的根目录中。我们需要添加一个system.diagnostics部分,并在其中添加sources、switches和sharedlisteners元素。switches元素包含一个开关,用于确定输出级别,如前文所述。
sharedlisteners元素指定了我们想要利用的输出类型。这三种类型是:
-
System.Diagnostics.ConsoleTraceListener:将跟踪信息发送到输出窗口 -
System.Diagnostics.TextWriterTraceListener:输出到文本文件 -
System.Diagnostics.XmlWriterTraceListener:输出到 XML 文件
最后,我们需要为每个我们想要监听的跟踪源添加一个source元素,并指定我们想要与其一起使用的开关和监听器。因此,我们能够将不同的跟踪源输出到不同的媒体,并以不同的输出级别输出。这些跟踪源与 WPF 跟踪设置选项中找到的相同,尽管在配置文件中,我们需要指定它们的完整名称。
选项如下:
-
System.Windows.Media.Animation -
System.Windows.Data -
System.Windows.DependencyProperty -
System.Windows.Documents -
System.Windows.Freezable -
System.Windows.Interop.HwndHost -
System.Windows.Markup -
System.Windows.NameScope -
System.Windows.ResourceDictionary -
System.Windows.RoutedEvent -
System.Windows.Shell
让我们看看一个示例配置文件:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
<system.diagnostics>
<sources>
<source name="System.Windows.Data" switchName="Switch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
</sources>
<switches>
<add name="Switch" value="All" />
</switches>
<sharedListeners>
<add name="TextListener"
type="System.Diagnostics.TextWriterTraceListener"
initializeData="Trace.txt" />
</sharedListeners>
<trace indentsize="4" autoflush="true"></trace>
</system.diagnostics>
</configuration>
从示例中的system.diagnostics部分来看,我们看到有一个指定了System.Windows.Data源(用于数据绑定信息)的源元素,一个名为Switch的开关,以及TextListener监听器。首先在switches部分中查找名为Switch的开关,并注意它被设置为All输出级别。
在此之下,在sharedlisteners元素中,我们看到名为TextListener的监听器。这个监听器是System.Diagnostics.TextWriterTraceListener类型,它输出到一个由initializeData属性值指定的文本文件。我们以一个trace元素结束,该元素设置文本文档的制表符大小为四个空格,并确保在每次写入后刷新缓冲区,以防止由于崩溃而丢失跟踪数据。
要设置更简洁的输出,我们可以简单地更改开关以使用其他输出级别之一,如下所示:
<add name="Switch" value="Error" />
如前所述,当在 Visual Studio 中设置特定选项时,WPF 可以使用DefaultTraceListener对象将跟踪信息发送到输出窗口。该监听器的名称为Default。为了停止此DefaultTraceListener的默认行为,我们可以使用我们的source元素将其移除,如下所示:
<source name="System.Windows.Data" switchName="Switch">
<listeners>
<add name="TextListener" />
<remove name="Default" />
</listeners>
</source>
了解这一点是很好的,因为如果我们还配置了自己的 ConsoleTraceListener 对象,我们可能会遇到输出窗口重复跟踪事件的情况。然而,如果需要,也可以将多个监听器添加到每个 source 元素中:
<source name="System.Windows.Data" switchName="Switch">
<listeners>
<add name="TextListener" />
<add name="OutputListener" />
</listeners>
</source>
我们还可以为不同的来源添加不同的监听器:
<source name="System.Windows.Data" switchName="Switch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
<source name="System.Windows.DependencyProperty" switchName="Switch">
<listeners>
<add name="OutputListener" />
</listeners>
</source>
...
<sharedListeners>
<add name="TextListener"
type="System.Diagnostics.TextWriterTraceListener"
initializeData="Trace.txt" />
<add name="OutputListener"
type="System.Diagnostics.ConsoleTraceListener" />
</sharedListeners>
可以按如下方式为不同的来源添加不同的输出级别:
<source name="System.Windows.Data" switchName="ErrorSwitch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
<source name="System.Windows.DependencyProperty" switchName="AllSwitch">
<listeners>
<add name="OutputListener" />
</listeners>
</source>
...
<switches>
<add name="AllSwitch" value="All" />
<add name="ErrorSwitch" value="Error" />
</switches>
WPF 展示跟踪来源提供的一个很酷的功能是能够创建我们自己的自定义跟踪来源:
<source name="CompanyName.ApplicationName" switchName="Switch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
注意,DefaultTraceListener 已经在上一节提到的 WPF 跟踪设置选项中配置为向输出窗口发送信息,因此从这个来源发出的跟踪信息也将自动发送到输出窗口。如果您尚未设置这些选项但希望在那里查看跟踪输出,那么您需要手动将 ConsoleTraceListener 的引用添加到此来源,如前述代码片段所示。
在代码中,我们现在能够将自定义跟踪信息输出到这个来源:
TraceSource traceSource = new TraceSource("CompanyName.ApplicationName");
traceSource.TraceEvent(TraceEventType.Information, eventId, "Data loaded");
// Alternative way to output information with an event id of 0
traceSource.TraceInformation("Data loaded");
为了指定不同的重要性级别,我们使用 TraceEventType 枚举:
traceSource.TraceEvent(TraceEventType.Error, eventId, "Data not loaded");
在输出调试信息后,我们可以选择刷新现有的监听器,以确保它们在继续之前接收到缓冲区中的事件:
traceSource.Flush();
最后,我们需要确保在输出必要信息后关闭 TraceSource 对象以释放资源:
traceSource.Close();
跟踪功能最好的部分是我们可以通过配置文件来开启和关闭它,无论是在设计时、运行时,甚至在应用程序的生产版本中。由于配置文件基本上是一个文本文件,我们可以手动编辑它,然后重新启动应用程序,使其读取新的配置。
假设我们的文件中有两个开关,并且我们的默认配置使用名为 OffSwitch 的开关,因此没有跟踪输出:
<source name="CompanyName.ApplicationName" switchName="OffSwitch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
...
<switches>
<add name="AllSwitch" value="All" />
<add name="OffSwitch" value="Off" />
</switches>
现在想象一下,我们已经部署了我们的应用程序,并且它已安装在使用者的计算机上。此时值得注意的一点是,从 app.config 文件创建的实际部署配置文件将与可执行文件具有相同的名称。在我们的例子中,它将被命名为 CompanyName.ApplicationName.exe.config 并位于可执行文件相同的文件夹中。
如果此安装的应用程序表现不正确,我们可以定位此配置文件,并简单地将其切换到名为 AllSwitch 的选项:
<source name="CompanyName.ApplicationName" switchName="AllSwitch">
<listeners>
<add name="TextListener" />
</listeners>
</source>
在重新启动应用程序后,新的配置将被读取,我们的自定义跟踪信息将被写入指定的文本文件。重启应用程序的一个替代方案是调用 Trace 类的 Refresh 方法,它具有启动配置文件新读取的相同效果:
Trace.Refresh();
这个方法调用甚至可以连接到一个菜单项或其他 UI 控件,以便在不重新启动应用程序的情况下启用和关闭跟踪。使用这两种方法之一刷新配置文件,我们可以在软件处于生产状态时获取重要的调试信息。然而,应非常小心,确保在发布的软件上不会永久启用文本或 XML 文件跟踪,因为它会负面影响性能。
虽然如今 WPF 呈现跟踪源通常默认可用,但在少数情况下,我们可能需要手动通过添加以下注册表键来启用此跟踪功能:
HKEY_CURRENT_USER\Software\Microsoft\Tracing\WPF
一旦添加了WPF注册表键,我们需要向其中添加一个新的DWORD值,命名为ManagedTracing,并将其值设置为1。然后我们应该能够访问 WPF 呈现跟踪源。我们已经看到了几种在运行时找到所需信息的方法,但如果应用程序根本无法运行呢?
发现内部异常
当我们构建视图的内容时,我们经常在这里或那里犯一些打字错误。也许我们在绑定路径中误输了某个属性的名称,或者复制粘贴了一些引用了我们未复制的其他代码的代码。
起初,找到这些类型错误的来源可能看起来相当困难,因为当我们运行我们的应用程序时,Visual Studio 抛出的实际错误通常是XamlParseException类型,并且与实际错误没有直接关系。提供的信息也帮助不大。以下是一个典型的例子:

让我们进一步调查。我们可以看到这里提供的信息补充如下:
'在'System.Windows.Markup.StaticResourceHolder'上提供值时抛出了异常。'行号'48'和行位置'41'。
现在,让我们尝试将这个问题分解成一些有意义的 信息。首先,很明显异常是由System.Windows.Markup.StaticResourceHolder类抛出的。仅凭这个信息本身并不很有用,但至少我们知道问题与一个无法解析的StaticResource有关。
从这条消息中我们可以获取的下一个信息是问题发生在第 48 行和位置 41。然而,如果没有告诉我们这与哪个文件相关,这些信息也不是很有用。前一个屏幕截图所示的错误对话框通常会有一条指向当前文件行和位置的线,这也可能是一个误导。在这个特定的情况下,这确实是错误信息,因为那里没有错误,但至少这告诉我们问题并非来自当前文件。
找出实际发生问题的真正原因的技巧是点击窗口中的“查看详细信息...”链接。这将打开“查看详细信息”窗口,在那里我们可以看到 XamlParseException 的所有属性值。查看 StackTrace 和 TargetSite 属性值并不能像通常处理正常异常那样有所帮助。然而,如果我们打开并检查 InnerException 属性值,我们最终可以找出实际上发生了什么。
让我们用我们的例子来做这件事:

最后,我们终于有了可以工作的东西。InnerException.Message 属性的值表明:“找不到名为 'BaseButtonStyle' 的资源。资源名称区分大小写”。
因此,我们的问题对象引用了 BaseButtonStyle 样式。在 Visual Studio 中的解决方案文件中快速搜索 'BaseButtonStyle' 将定位问题的来源。在这种情况下,我们的问题出在 App.xaml 文件的 Application.Resources 部分中。让我们仔细看看:
<Style x:Key="SmallButtonStyle" TargetType="{x:Type Button}"
BasedOn="{StaticResource BaseButtonStyle}">
<Setter Property="Height" Value="24" />
<Setter Property="Width" Value="24" />
</Style>
在这里,我们可以看到一个基于另一个样式的样式,但基础样式显然缺失。正是这个缺失的基础样式,即名为 BaseButtonStyle 的 StaticResource,导致了这个错误。我们可以通过在 App.xml 文件中创建引用的基础样式,或者从 SmallButtonStyle 样式中删除 BasedOn 属性来轻松解决这个问题。
我们应该始终牢记,像这样的错误很可能存在于我们刚刚编辑过的代码中,这也帮助我们缩小搜索范围。因此,在实现可能包含错误的 XAML 时,经常运行应用程序是有益的,因为我们在检查进度之间写的代码越多,我们需要查找以找到问题的代码就越多。
调试数据绑定值
到目前为止,我们已经看到我们可以利用许多信息来源来帮助我们追踪问题的原因。然而,实际的调试呢?在其他 GUI 语言中,我们可以在代码的各个位置设置断点,并在逐步执行代码时观察值的改变。虽然我们也可以在 WPF 应用程序中这样做,但并不总是那么明显在哪里放置断点以确保程序执行会触碰到它们。
如果你还记得上一章的内容,当 CommandManager 检测到 UI 发生变化,可能会影响命令是否可以执行时,会触发 CommandManager.RequerySuggested 事件。好吧,结果是 CommandManager 寻找的两个条件之一是应用程序窗口被激活或停用,我们可以利用这一点来帮助我们进行调试。请注意,当用户将焦点从应用程序窗口移开时,应用程序窗口会被停用,当用户将焦点返回到它时,它会被重新激活。
因此,当我们在 Visual Studio 中与应用程序并行运行时,我们可以在用作ActionCommand类canExecute处理程序的任何方法中设置一个断点,从而将焦点从应用程序中移除。现在,当我们点击回 WPF 应用程序时,焦点将返回到它。
这将导致CommandManager.RequerySuggested事件被触发,结果,canExecute处理程序将被调用,我们的断点将被命中。这基本上意味着我们能够将程序执行控制到我们的视图模型中,以便在任何需要的时候调试参数值。让我们看看我们还能做些什么来帮助我们修复数据绑定错误。
将值输出到 UI 控件
了解我们的数据绑定属性具有哪些值的最简单方法之一是将它们绑定到具有文本输出的其他 UI 控件。例如,如果我们有一个项目集合,我们想要对所选项目执行某些操作,但无论是什么操作都没有正常工作,我们需要验证我们对所选项目的绑定是否正确。
为了可视化绑定结果,我们可以简单地复制并粘贴绑定路径到TextBox的Text属性,并运行应用程序。如果我们的绑定路径正确,我们将在TextBox中看到一些输出,如果不正确,我们将知道我们遇到的问题实际上是由于绑定路径引起的。因此,我们可以使用这种方法来验证那些通常没有文本输出的对象是否至少正确地进行了数据绑定。
这种简单的技术可以帮助在任何数据绑定错误尚未在基于文本的 UI 控件中渲染的情况下。例如,我们可能需要调试一个数据绑定值,因为使用DataTrigger实例创建的特定视觉效果没有正常工作,我们需要确定问题是否与 UI 控件或数据绑定路径有关。
捕获变化的依赖属性值
正如我们在本章开头所看到的,当属性值发生变化时,WPF 框架不会调用我们的依赖属性的 CLR 属性包装器。然而,有一种方法可以使用回调处理程序来完成这项任务。实际上,当我们查看OnEnterKeyDown附加属性创建时,我们已经看到了一个这样的例子。让我们回顾一下它的样子:
public static DependencyProperty OnEnterKeyDownProperty =
DependencyProperty.RegisterAttached("OnEnterKeyDown",
typeof(ICommand), typeof(TextBoxProperties),
new PropertyMetadata(OnOnEnterKeyDownChanged));
...
public static void OnOnEnterKeyDownChanged(
DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
{
TextBox textBox = (TextBox)dependencyObject;
if (e.OldValue == null && e.NewValue != null)
textBox.PreviewKeyDown += TextBox_OnEnterKeyDown;
else if (e.OldValue != null && e.NewValue == null)
textBox.PreviewKeyDown -= TextBox_OnEnterKeyDown;
}
对于这个附加属性,我们使用了DependencyProperty.RegisterAttached方法的特定重载,它接受一个PropertyMetadata对象,这使得我们能够将PropertyChangedCallback处理程序分配给属性。请注意,对于声明依赖属性,DependencyProperty.Register方法也有一个相同重载。
每当相关的依赖属性发生变化时,程序执行将进入这些PropertyChangedCallback处理程序,这使得它们非常适合调试它们的值。虽然我们并不经常需要附加这些处理程序,但当我们需要时,添加一个处理程序只需要片刻时间,并且它们使我们能够了解在运行时依赖属性值的情况。
利用转换器
如果我们遇到了使用IValueConverter将数据绑定值从一种类型转换为另一种类型的数据绑定问题,那么我们可以在转换器的Convert方法中放置一个断点。只要我们正确设置了转换器,我们就可以确信在运行时评估绑定时将命中断点。如果没有命中,那将意味着我们没有正确设置它。
然而,即使我们已经在绑定上使用了一个没有显示我们期望的值的转换器,我们仍然可以只为这个目的添加一个。如果我们有相关类型的现有转换器,我们可以将其添加到绑定中;或者,我们可以创建一个专门用于调试的简单转换器并使用它。让我们看看我们可能如何做到这一点:
[ValueConversion(typeof(object), typeof(object))]
public class DebugConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
CultureInfo culture)
{
if (Debugger.IsAttached) Debugger.Break();
return value;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (Debugger.IsAttached) Debugger.Break();
return value;
}
}
如您从前面的代码片段中可以看到,这是一个对IValueConverter接口的非常简单的实现。我们首先在ValueConversion属性中指定我们正在从object转换为object,从而概述了在这个转换器中实际上并没有转换任何数据绑定值。类中的其余部分代表了一个典型的转换器类,但没有包含任何转换代码。
这里真正值得关注的是对System.Diagnostics程序集中的Debugger.Break方法的两次调用。当程序执行到达这两个方法调用中的任何一个时,它将自动中断,就像在这些行上设置了断点一样。因此,当使用这个转换器时,我们甚至不需要设置断点;我们只需将其连接到绑定,运行程序,并调查value输入参数的值。
它可以像任何其他转换器一样附加:
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
...
<UserControl.Resources>
<Converters:DebugConverter x:Key="Debug" />
</UserControl.Resources>
...
<ListBox ItemsSource="{Binding Items, Converter={StaticResource Debug}}" />
然而,这种方法在生产环境中使用可能是不安全的,并且当调试完成后应该移除转换器。如果它在发布代码中保持连接,则在运行时将抛出一个异常,抱怨 Windows 遇到了用户定义的断点。虽然我不建议在用于调试数据绑定值的转换器连接到生产环境中,但我们可以对其进行轻微的修改,以完全消除这种情况发生的危险:
[ValueConversion(typeof(object), typeof(object))]
public class DebugConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
CultureInfo culture)
{
Break(value);
return value;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
Break(value);
return value;
}
[Conditional("DEBUG")]
private void Break(object value)
{
Debugger.Break();
}
}
现在,Debugger.Break 方法以及数据绑定值已经被移动到一个单独的 Break 方法中,其中可以检查 value 输入参数的值。注意这个新方法上使用的 ConditionalAttribute 属性。它提供了一种方式,根据当前解决方案配置来包含或排除被设置的方法。如果配置设置为调试,则可以调用此方法,否则,所有对其的调用都将从编译后的代码中移除。这样,我们可以确保我们的发布代码不会遇到问题。
摘要
在本章中,我们研究了追踪编码问题的最佳方法。我们查看了我们可访问的各种调试跟踪输出,甚至发现了如何输出我们自己的自定义跟踪信息。我们发现 WPF 中抛出的异常通常将它们有用的信息隐藏在它们的 InnerException 属性中。最后,我们找到了一些技巧和窍门,当尝试查找与数据绑定值相关的错误时可以使用。
下一章深入探讨了应用程序框架的主题,并开始构建我们自己的框架。我们了解到基类的优势,并发现了实现框架功能的其他替代方法。章节将以调查确保我们的应用程序保持 MVVM 提供的基本关注点分离的各种技术结束。
第三章:编写自定义应用程序框架
在本章中,我们将研究应用程序框架及其能为我们带来的好处。我们将了解通过基类和接口提供此功能之间的差异,并发现将功能构建到我们的框架中的其他方法。然后,我们将利用这些新获得的知识来开始构建我们自己的应用程序框架,以简化我们未来的应用程序开发。本章将以检查各种技术结束,以确保我们的应用程序保持 MVVM 提供的必要关注点分离。
什么是应用程序框架?
简而言之,应用程序框架由一个类库组成,这些类库共同提供了应用程序所需的最常见功能。通过使用应用程序框架,我们可以大大减少创建应用程序各个部分所需的工作量和时间。简而言之,它们支持应用程序的未来发展。
在典型的三层应用程序中,框架通常扩展到应用程序的所有层;即表示层、业务层和数据访问层。因此,在采用 MVVM 模式的 WPF 应用程序中,我们可以在模式的三个组件中看到应用程序框架的各个方面;即模型、视图模型和视图。
除了创建我们的应用程序组件所涉及的生产时间和努力减少的明显好处外,应用程序框架还提供了许多额外的优势。典型的应用程序框架促进可重用性,这是面向对象编程(OOP)的核心目标之一。它们通过提供通用的接口和/或基类来实现这一点,这些接口和基类可以用来定义各种应用程序组件。
通过重用这些应用程序框架接口和基类,我们还在整个应用程序中灌输了一种统一性和一致性。此外,由于这些框架通常提供额外的功能或服务,因此从事应用程序开发的开发人员在需要这种特定功能时可以节省更多时间。
通过使用应用程序框架,还可以实现诸如模块化、可维护性、可测试性和可扩展性等概念。这些框架通常具有独立运行各个组件的能力,这与 WPF 和 MVVM 模式非常契合。此外,应用程序框架还可以提供实现模式,以进一步简化构建新应用程序组件的过程。
为不同的技术创建了不同的框架,WPF 已经有一些公开可用的框架。有些相对较轻量级,如MVVM Light Toolkit和WPF Application Framework(WAF),而有些则更重量级,如Caliburn.Micro和现在开源的Prism。虽然你可能在工作中可能使用过这些框架之一或多个,但在这个章节中,我们将探讨如何创建我们自己的轻量级自定义框架,它将只实现我们需要的功能。
封装通用功能
在任何 WPF 应用程序中,最常用的接口可能是INotifyPropertyChanged接口,因为它需要正确实现数据绑定。通过在我们的基类中提供这个接口的实现,我们可以避免在每个单独的 ViewModel 类中重复实现它。因此,它非常适合包含在我们的基类中。根据我们的需求,有多种不同的实现方式,所以让我们先看看最基本的实现:
public virtual event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
在所有形式的这个实现中,我们首先需要声明PropertyChanged事件。这是我们用来通知应用程序中数据绑定值更改的各种绑定源和目标的唯一事件。请注意,这是INotifyPropertyChanged接口的唯一要求。我们没有必须实现的NotifyPropertyChanged方法,因此你可能会遇到不同名称的方法,但它们执行的功能是相同的。
当然,如果没有这个方法,仅仅实现事件什么也不会发生。这个方法的基本思想是,像往常一样,我们首先检查null,然后引发事件,将引发事件的类实例作为sender参数和PropertyChangedEventArgs中更改的属性名称传递。我们已经看到,C# 6.0 中的空条件运算符为我们提供了这个的简写符号:
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
注意,这个方法的声明访问修饰符是protected,以确保所有从该基类派生的 ViewModel 都可以访问它,而未派生的类则不能。此外,该方法也被标记为virtual,以便派生类在需要时可以覆盖这个功能。在 ViewModel 中,这个方法可以通过如下属性调用:
private string name = string.Empty;
public string Name
{
get { return name; }
set
{
if (name != value)
{
name = value;
NotifyPropertyChanged("Name");
}
}
}
然而,在.NET 4.5 中增加了一个新的特性,它为我们提供了使用这个实现的快捷方式。CallerMemberNameAttribute类使我们能够自动获取方法调用者的名称,或者更具体地说,在我们的情况下,是调用方法的属性名称。我们可以使用它,并带有可选的默认值输入参数,如下所示:
protected virtual void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
被调用的属性可以简化为这样:
public string Name
{
get { return name; }
set { if (name != value) { name = value; NotifyPropertyChanged(); } }
}
在此值得指出的是,在.NET 4.5.3 中,对调用此方法最基本实现的改进也被引入。nameof运算符还使我们能够避免使用字符串来传递属性名称,因为传递字符串可能会出错。此运算符基本上在编译时将属性、变量或方法的名称转换为字符串,因此最终结果与传递字符串完全相同,但在重命名定义时更不容易出错。以先前的属性为例,让我们看看这个运算符是如何使用的:
NotifyPropertyChanged(nameof(Name));
此外,我们还可以使用其他技巧。例如,我们经常需要通知框架同时更改多个属性值。设想一个场景,我们有两个名为Price和Quantity的属性,以及一个名为Total的第三个属性。正如你可以想象的那样,Total属性的值将来自Price值乘以Quantity值的计算:
public decimal Total
{
get { return Price * Quantity; }
}
然而,此属性没有设置器,那么我们应该从哪里调用 NotifyPropertyChanged 方法呢? 答案很简单。我们需要从 两个 组成属性设置器中调用它,因为它们都可以影响此属性的结果值。
传统上,我们必须为每个组成部分属性调用一次NotifyPropertyChanged方法,以及为Total属性调用一次。然而,我们可以重写此方法的实现,使其能够在单个调用中传递多个属性名称。为此,我们可以使用params关键字来启用任意数量的输入参数:
protected void NotifyPropertyChanged(params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
当使用params关键字时,我们需要声明一个数组类型的输入参数。然而,这个数组仅仅持有输入参数,我们调用此方法时不需要提供数组。相反,我们可以提供任意数量的相同类型的输入参数,它们将被隐式地添加到数组中。回到我们的例子,这使我们能够像这样调用方法:
private decimal price = 0M;
public decimal Price
{
get { return price; }
set
{
if (price != value)
{
price = value;
NotifyPropertyChanged(nameof(Price), nameof(Total));
}
}
}
因此,我们有多种不同的方法来实现此方法,具体取决于我们的需求。我们甚至可以添加多个方法重载,为我们的框架用户提供更多选择。我们将在稍后看到此方法的进一步增强,但现在,让我们看看我们的BaseViewModel类到目前为止可能的样子:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CompanyName.ApplicationName.ViewModels
{
public class BaseViewModel : INotifyPropertyChanged
{
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(
params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
}
}
protected virtual void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
总结一下,我们从一个声明单个事件的接口开始。接口本身不提供任何功能,实际上,作为实现者,我们必须提供功能,以NotifyPropertyChanged方法的形式,并在每次属性值更改时调用该方法。但这样做的好处是 UI 控件正在监听并响应这些事件,因此,通过实现此接口,我们已经获得了这种额外的数据绑定能力。
然而,我们可以在我们的应用程序框架中以多种不同的方式提供功能。这两种主要方式是通过使用基类和接口。这两种方法之间的主要区别在于,我们的框架用户为了创建各种应用程序组件需要完成多少开发工作。
当我们使用接口时,我们基本上是在提供一个开发者必须遵守的合同,即通过自己提供实现来遵守。然而,当我们使用基类时,我们能够为他们提供这种实现。因此,通常基类提供现成的功能,而接口则依赖于开发者自己提供部分或全部这些功能。
我们刚刚看到了在视图模型基类中实现接口的一个例子。现在,让我们看看我们可以在其他框架基类中封装什么,并比较在基类和接口中提供功能或功能性的差异。现在,让我们将注意力转向我们的数据模型类。
在基类中
我们已经看到,在一个 WPF 应用程序中,在我们的视图模型基类中实现INotifyPropertyChanged接口是至关重要的。同样,我们也将需要在我们的数据模型基类中实现类似的接口。记住,当在这里提到数据模型时,我们正在讨论与视图模型属性和功能结合使用的业务模型类,这些类来自第二应用程序结构示例的第一章,《以更智能的方式使用 WPF》。
所有这些DataModel类都需要扩展它们的基类,因为它们都需要访问其INotifyPropertyChanged实现。随着我们在这本书的章节中不断前进,我们将看到越来越多的原因说明为什么我们需要为我们的数据模型和视图模型使用单独的基类。例如,让我们想象一下,我们想要为这些数据模型提供一些简单的审计属性,并调查我们的基类可能看起来像什么:
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CompanyName.ApplicationName.DataModels
{
public class BaseDataModel : INotifyPropertyChanged
{
private DateTime createdOn;
private DateTime? updatedOn;
private User createdBy, updatedBy;
public DateTime CreatedOn
{
get { return createdOn; }
set { createdOn = value; NotifyPropertyChanged(); }
}
public User CreatedBy
{
get { return createdBy; }
set { createdBy = value; NotifyPropertyChanged(); }
}
public DateTime? UpdatedOn
{
get { return updatedOn; }
set { updatedOn = value; NotifyPropertyChanged(); }
}
public User UpdatedBy
{
get { return updatedBy; }
set { updatedBy = value; NotifyPropertyChanged(); }
}
#region INotifyPropertyChanged Members
...
#endregion
}
}
在这里,我们看到我们的审计属性,以及我们之前看到的隐藏的INotifyPropertyChanged实现。现在,让我们保持实现与BaseViewModel类相同。请注意,使用这个特定的基类会导致所有派生类都能访问这些属性,无论它们是否需要。
我们可能然后决定声明另一个基类,这样我们就可以有一个提供对INotifyPropertyChanged接口实现的访问的基类,以及一个扩展该基类并添加之前显示的新可审计属性的基类。通过这种方式,所有派生类都可以使用INotifyPropertyChanged接口实现,并且需要可审计属性的那些类也可以从第二个基类派生:

对于这个基本示例,我们似乎已经解决了问题。如果这些可审计属性是我们想要提供给派生类的唯一属性,那么这种情况就不会那么糟糕。然而,一个平均的框架通常会提供比这更多的功能。
让我们现在想象一下,如果我们想提供一些基本的撤销能力。我们将在本章后面看到这个例子,但现在我们保持简单。在不实际指定这个新基类所需成员的情况下,让我们先考虑这个问题。
现在我们面临的情况是我们已经有了两个不同的基类,我们还想提供一些额外的功能。我们应该在哪里声明我们的新属性? 我们可以从任何一个派生,或者间接地从现有的两个基类中派生,如图所示,以创建这个新的可同步基类:

因此,现在,我们可能有四个不同的基类,开发者可以使用我们的框架扩展。他们可能会对确切需要扩展哪个基类感到困惑,但总体来说,这种情况仍然只是勉强可控。然而,想象一下,如果我们想在基类的某个或多个级别提供一些额外的属性或功能。
为了使这些基类中的每一个功能组合都能被启用,我们可能最终会有多达八个不同的基类。我们提供的每个额外功能级别要么会使我们拥有的基类总数翻倍,要么意味着开发者有时不得不从具有他们不需要的功能或属性的基类中派生。既然我们已经揭示了一个使用基类的潜在问题,让我们看看声明接口是否可以帮助解决这个问题。
通过接口
回到我们的审计示例,我们可以在接口中声明这些属性。让我们看看这可能会是什么样子:
using System;
namespace CompanyName.ApplicationName.DataModels.Interfaces
{
public interface IAuditable
{
DateTime CreatedOn { get; set; }
User CreatedBy { get; set; }
DateTime? UpdatedOn { get; set; }
User UpdatedBy { get; set; }
}
}
现在,如果一个开发者需要这些属性,他们可以实施这个接口,同时扩展数据模型基类:

现在我们来看看代码中的例子:
using System;
using CompanyName.ApplicationName.DataModels.Interfaces;
namespace CompanyName.ApplicationName.DataModels
{
public class Invoice : BaseDataModel, IAuditable
{
private DateTime createdOn;
private DateTime? updatedOn;
private User createdBy, updatedBy;
public DateTime CreatedOn
{
get { return createdOn; }
set { createdOn = value; NotifyPropertyChanged(); }
}
public User CreatedBy
{
get { return createdBy; }
set { createdBy = value; NotifyPropertyChanged(); }
}
public DateTime? UpdatedOn
{
get { return updatedOn; }
set { updatedOn = value; NotifyPropertyChanged(); }
}
public User UpdatedBy
{
get { return updatedBy; }
set { updatedBy = value; NotifyPropertyChanged(); }
}
}
}
初始时,这似乎是一个更好的方法,但让我们继续调查我们之前查看的基类场景。现在让我们想象一下,我们想使用接口提供相同的基本撤销能力。我们实际上并没有调查需要哪些成员来完成这个任务,但它将需要属性和方法。
这就是接口方法开始出现问题的原因。我们可以确保实现我们ISynchronization接口的开发者具有特定的属性和方法,但我们无法控制他们实现这些方法的方式。为了提供撤销更改的能力,我们需要提供这些方法的实际实现,而不仅仅是所需的结构。
如果这留给开发者每次使用接口时来实现,他们可能无法正确实现它,或者可能在不同的类中以不同的方式实现它,从而破坏应用程序的一致性。因此,为了实现某些功能,似乎我们确实需要使用某种基类。
然而,我们还有一个第三种选择,它涉及两种方法的混合。我们可以在基类中实现一些功能,但不是从它派生我们的数据模型类,而是给它们添加一个这种类型的属性,这样它们仍然可以访问其公共成员。
然后,我们可以声明一个接口,它只有一个属性,这个属性是新基类的类型。这样,我们就可以自由地将不同基类的不同功能添加到只需要它们的类中。让我们看看一个这样的例子:
public interface IAuditable
{
Auditable Auditable { get; set; }
}
这个Auditable类将具有与前面代码中显示的IAuditable接口中相同的属性。新的IAuditable接口将通过在数据模型类中简单地声明一个类型为Auditable的属性来实现:
public class User : IAuditable
{
private Auditable auditable;
public Auditable Auditable
{
get { return auditable; }
set { auditable = value; }
}
...
}
它可以被框架使用,例如,将每个用户的名称和他们创建的时间输出到报告中。在下面的示例中,我们使用 C# 6.0 中引入的插值字符串语法来构建我们的字符串。它就像string.Format方法,但将方法调用替换为$符号,将数字格式项替换为它们的相应值:
foreach (IAuditable user in Users)
{
Report.AddLine($"Created on {user.Auditable.CreatedOn}" by
{user.Auditable.CreatedBy.Name});
}
最有趣的是,由于这个接口可以被许多不同类型的对象实现,前面的代码也可以与不同类型的对象一起使用。注意这个细微的区别:
List<IAuditable> auditableObjects = GetAuditableObjects();
foreach (IAuditable user in auditableObjects)
{
Report.AddLine($"Created on {user.Auditable.CreatedOn}" by
{user.Auditable.CreatedBy.Name});
}
值得指出的是,这种能够与不同类型的对象一起工作的有用能力并不仅限于接口。这也可以同样容易地通过基类实现。想象一下一个视图,它允许最终用户编辑多种不同类型的对象。
如果我们在稍后将在构建自定义应用程序框架部分看到的BaseSynchronizableDataModel类中添加一个名为PropertyChanges的属性,该属性返回更改属性的详细信息,我们就可以使用与此非常相似的代码来显示每个对象对用户的更改确认:
List<BaseSynchronizableDataModel> baseDataModels = GetBaseDataModels();
foreach (BaseSynchronizableDataModel baseDataModel in baseDataModels)
{
if (baseDataModel.HasChanges)
FeedbackManager.Add(baseDataModel.PropertyChanges);
}
在将一些预包装功能封装到我们的数据模型类中时,我们有多种选择。我们迄今为止调查的每种方法都有其优点和缺点。如果我们确定我们想在每个数据模型类中都包含一些预写的功能,比如 INotifyPropertyChanged 接口,那么我们只需在基类中封装它,并让所有模型类从该基类派生。
如果我们只想让我们的模型拥有某些属性或方法,这些方法可以从框架的其他部分调用,但我们不关心实现细节,那么我们可以使用接口。如果我们想结合这两种想法,那么我们可以通过结合使用这两种方法来实现解决方案。选择最适合当前需求的解决方案取决于我们。
使用扩展方法
在调查 第二章 调试 WPF 应用程序 中的应用程序结构时,提到了一种向应用程序开发者提供额外功能的方法。这是通过使用扩展方法实现的。如果你不熟悉这个令人惊叹的 .NET 功能,扩展方法使我们能够编写可以在我们未创建的对象上使用的方法。
在这个阶段,指出一点是值得的,我们通常不会为我们声明的类编写扩展方法。这有两个主要原因。第一个原因是,我们创建了这些类,因此我们可以访问它们的源代码,因此可以直接在这些类中声明新方法。
第二个原因是,我们的 Extensions 项目将被添加到大多数其他项目中,包括我们的 DataModels 项目,这样它们都可以利用额外的功能。因此,我们不能将任何其他项目的引用添加到 Extensions 项目中,因为这会创建循环依赖。
你可能已经知道扩展方法了,尽管可能是不经意间了解的,因为大多数 LINQ 方法都是扩展方法。一旦声明,它们就可以像在扩展的各种类中声明的普通方法一样使用,尽管在 Visual Studio IntelliSense 显示中它们有不同的图标:

声明它们的基本原则是有一个静态类,其中每个方法都有一个额外的输入参数,该参数以 this 关键字为前缀,表示被扩展的对象。请注意,这个额外的输入参数必须在参数列表中首先声明,并且在调用对象实例上的方法时在 IntelliSense 中不可见。
扩展方法被声明为静态方法,但通常使用实例方法语法来调用。一个简单的例子应该有助于澄清这种情况。让我们假设我们想要能够在集合中的每个项上调用一个方法。实际上,我们将在本章后面的BaseSynchronizableCollection类中看到这个例子是如何被使用的,但现在,让我们看看我们如何做到这一点:
using System;
using System.Collections.Generic;
namespace CompanyName.ApplicationName.Extensions
{
public static class IEnumerableExtensions
{
public static void ForEach<T>(this IEnumerable<T> collection,
Action<T> action)
{
foreach (T item in collection) action(item);
}
}
}
在这里,我们看到this输入参数指定了调用此扩展方法的目标类型的实例。记住,除非它通过静态类本身调用,否则这个参数不会出现在 Visual Studio 的 IntelliSense 参数列表中,如下面的代码所示:
IEnumerableExtensions.ForEach(collection, i => i.RevertState());
在这个方法内部,我们简单地遍历集合项,调用由action输入参数指定的Action,并将每个项作为其参数传入。在将using指令添加到CompanyName.ApplicationName.Extensions命名空间之后,让我们看看这个方法通常是如何被调用的:
collection.ForEach(i => i.PerformAction());
因此,你现在可以看到扩展方法的力量以及它们能为我们带来的好处。如果我们想要的功能还没有由.NET Framework 中的某个类提供,我们就可以简单地添加它。以下是一个例子。
这里是一个从现有的 LINQ 扩展方法中非常需要的扩展方法。与其他的 LINQ 方法一样,这个方法也作用于IEnumerable<T>接口,因此也作用于任何扩展它的集合:
public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
{
HashSet<TKey> keys = new HashSet<TKey>();
foreach (TSource element in source)
{
if (keys.Add(keySelector(element))) yield return element;
}
}
让我们首先看看这个方法的声明。我们可以看到我们的源集合将是类型TSource。注意,这与我们的其他例子中泛型类型参数被命名为T的情况完全相同,只是这提供了一点关于这个类型参数使用的细节。这种命名来自Enumerable.OrderBy<TSource, TKey>方法,其中类型TSource参数代表我们的源集合。
接下来,我们注意到方法名称后面跟着两个泛型类型参数;首先,是TSource参数,然后是TKey参数。这是因为我们需要两个泛型类型参数来指定类型为Func<TSource, TKey>的输入参数。如果你不熟悉微软称之为Func<T, TResult>的委托,它简单地说就是封装了任何具有单个输入参数类型T并返回类型为TResult的值的任何方法,或者在我们的情况下,返回TKey。
“我们为什么要使用这个 Func<T, TResult> 委托?”你可能会问。其实很简单;使用这个类,我们可以为开发者提供一个与源集合中相同的类型的对象,并能够选择该类的一个成员,特别是他们想要在它上面执行唯一查询的属性。在查看这个方法的其余部分之前,让我们看看它是如何被使用的:
IEnumerable<User> distinctUsers = Users.DistinctBy(u => u.Id);
让我们设想一下,我们有一个User对象的集合,这些对象都购买了商品。这个集合可能包含同一个User对象多次,如果他们购买了多个商品。现在,让我们想象一下,我们想要从原始集合中编译一个唯一的用户集合,这样就不会向订购多个商品的人发送多个账单。这个方法将为每个不同的Id值返回单个成员。
回到这个方法的源代码,User类代表TSource参数,这可以在示例中的 Lambda 表达式中看到,即u输入参数。TKey参数由开发者选择的类成员的类型决定,在这种情况下,是通过Guid的Id值。这个例子可以稍作修改以使其更清晰:
IEnumerable<User> distinctUsers = Users.DistinctBy((User user) => user.Id);
因此,我们的Func<TSource, TKey>可以在这里看到,有一个User输入参数和一个Guid返回值。现在,让我们关注我们方法的魔法。我们看到一个Guid类型的HashSet在我们的例子中被初始化。这种类型的集合对这个方法至关重要,因为它允许只添加唯一值。
接下来,我们遍历我们的源集合,在这个例子中是User类型,并尝试将集合中每个项目的相关属性值添加到HashSet中。在我们的例子中,我们将每个User对象的身份值添加到这个HashSet中。
如果身份值是唯一的,并且HashSet<T>.Add方法返回true,我们就产生,或从我们的源集合返回该物品。第二次以及每次读取已使用的Id值时,它将被拒绝。这意味着只有具有唯一身份值的第一个项目从这个方法返回。注意,在这个例子中,我们感兴趣的并不是购买,而是做出这些购买的唯一用户。
我们现在已经成功创建了我们的 LINQ 风格的扩展方法。然而,并不是所有的扩展方法都需要如此具有突破性。通常,它们可以用来简单地封装一些常用的功能。
在某种程度上,我们可以将它们用作简单的便利方法。看看以下例子,它将在本章后面的With Converters部分中使用:
using System;
using System.ComponentModel;
using System.Reflection;
namespace CompanyName.ApplicationName.Extensions
{
public static class EnumExtensions
{
public static string GetDescription(this Enum value)
{
FieldInfo fieldInfo = value.GetType().GetField(value.ToString());
if (fieldInfo == null) return Enum.GetName(value.GetType(), value);
DescriptionAttribute[] attributes = (DescriptionAttribute[])
fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), false);
if (attributes != null && attributes.Length > 0)
return attributes[0].Description;
return Enum.GetName(value.GetType(), value);
}
}
}
在这个方法中,我们尝试获取与由value输入参数提供的特定枚举实例相关的FieldInfo对象。如果尝试失败,我们简单地返回特定实例的名称。如果我们成功,然后我们使用该对象的GetCustomAttributes方法,传递DescriptionAttribute类的类型,来检索一个属性数组。
如果我们在特定枚举实例的DescriptionAttribute中声明了一个值,那么它将始终是属性数组中的第一个项。如果我们没有设置值,那么数组将为空,我们将返回实例的名称。注意,由于我们在该方法中使用了基Enum类,因此我们可以在任何枚举类型上调用此方法。
在创建这些方法时,应该注意的是,没有要求将它们放入由类型分开的单独类中,就像我们在这里所做的那样。也没有特定的命名约定,实际上,将所有扩展方法放入一个类中也是完全可行的。然而,如果我们有大量特定类型的扩展方法,那么这种分离可以帮助维护。
在继续之前,让我们看看这些扩展方法的一个最终示例。扩展方法最有用的特性之一是能够向现有的.NET Framework 类添加新的或缺失的功能。例如,让我们看看我们如何复制 Linq 并为IEnumerable类定义一个简单的Count方法:
public static int Count(this IEnumerable collection)
{
int count = 0;
foreach (object item in collection) count++;
return count;
}
如我们所见,这个方法几乎不需要解释。它实际上只是计算IEnumerable集合中的项目数量并返回该值。虽然它很简单,但它证明是有用的,正如我们将在后面的示例中看到的那样。现在我们已经研究了扩展方法,让我们将注意力转向另一种将更多能力构建到我们的框架中的方法,这次专注于视图组件。
在 UI 控件中
在应用程序框架中包含功能的一种常见方式是将它封装到自定义控件中。这样做,我们可以使用依赖属性公开所需的功能,同时隐藏实现细节。这也是在应用程序中推广重用性和一致性的另一种极好方式。让我们看看一个简单的UserControl示例,它封装了System.Windows.Forms.FolderBrowserDialog控件的功能:
<UserControl
x:Class="CompanyName.ApplicationName.Views.Controls.FolderPathEditField"
>
<TextBox Name="FolderPathTextBox"
Text="{Binding FolderPath, RelativeSource={RelativeSource
AncestorType={x:Type Controls:FolderPathEditField}}, FallbackValue='',
UpdateSourceTrigger=PropertyChanged}" Cursor="Arrow"
PreviewMouseLeftButtonUp="TextBox_PreviewMouseLeftButtonUp" />
</UserControl>
这个简单的UserControl只包含一个文本框,其Text属性数据绑定到我们在控制代码背后声明的FolderPath依赖属性。记住,在使用 MVVM 时,使用UserControl的代码背后进行此操作是完全可接受的。注意,我们在这里使用了RelativeSource绑定,因为没有设置此控制器的DataContext属性。我们将在第四章“精通数据绑定”中了解更多关于数据绑定的信息,但现在,让我们继续。
你可能会注意到,我们在代码后部附加了一个PreviewMouseLeftButtonUp事件处理器,并且由于那里没有使用业务相关的代码,所以在使用 MVVM 时这也是完全可以接受的。这里唯一值得注意的代码是我们将Cursor属性设置为当用户将鼠标悬停在我们的控件上时显示箭头。现在让我们看看UserControl的代码后部,看看功能是如何封装的:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using FolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class FolderPathEditField : UserControl
{
public FolderPathEditField()
{
InitializeComponent();
}
public static readonly DependencyProperty FolderPathProperty =
DependencyProperty.Register(nameof(FolderPath),
typeof(string), typeof(FolderPathEditField),
new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public string FolderPath
{
get { return (string)GetValue(FolderPathProperty); }
set { SetValue(FolderPathProperty, value); }
}
public static readonly DependencyProperty OpenFolderTitleProperty =
DependencyProperty.Register(nameof(OpenFolderTitle),
typeof(string), typeof(FolderPathEditField),
new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public string OpenFolderTitle
{
get { return (string)GetValue(OpenFolderTitleProperty); }
set { SetValue(OpenFolderTitleProperty, value); }
}
private void TextBox_PreviewMouseLeftButtonUp(object sender,
MouseButtonEventArgs e)
{
if (((TextBox)sender).SelectedText.Length == 0 &&
e.GetPosition(this).X <= ((TextBox)sender).ActualWidth -
SystemParameters.VerticalScrollBarWidth)
ShowFolderPathEditWindow();
}
private void ShowFolderPathEditWindow()
{
string defaultFolderPath = string.IsNullOrEmpty(FolderPath) ?
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
: FolderPath;
string folderPath = ShowFolderBrowserDialog(defaultFolderPath);
if (string.IsNullOrEmpty(folderPath)) return;
FolderPath = folderPath;
}
private string ShowFolderBrowserDialog(string defaultFolderPath)
{
using (FolderBrowserDialog folderBrowserDialog =
new FolderBrowserDialog())
{
folderBrowserDialog.Description = OpenFolderTitle;
folderBrowserDialog.ShowNewFolderButton = true;
folderBrowserDialog.SelectedPath = defaultFolderPath;
folderBrowserDialog.ShowDialog();
return folderBrowserDialog.SelectedPath;
}
}
}
}
我们从using指令开始,并看到一个使用别名指令的示例。在这种情况下,我们不想为System.Windows.Forms程序集添加一个正常的using指令,因为它包含许多与 UI 相关的类,这些类的名称与所需的System.Windows程序集中的名称冲突。
为了避免这些冲突,我们可以为我们要从该程序集中使用的单个类型创建一个别名。为了明确起见,微软决定不在System.Windows程序集中重新发明轮子,或者说在这个例子中,不重新发明FolderBrowserDialog控件,因此我们需要添加对System.Windows.Forms程序集的引用,并使用其中的控件。
观察这个类,我们看到大部分代码都是关于控件的依赖属性声明。我们有FolderPath属性,它将保存从Windows.Forms控件中选择的文件夹的文件路径,以及OpenFolderTitle属性,它将在显示FolderBrowserDialog窗口时填充标题栏。
接下来,我们看到处理我们控件中单个TextBox元素的PreviewMouseLeftButtonUp事件的TextBox_PreviewMouseLeftButtonUp事件处理器。在这个方法中,我们首先验证用户没有从TextBox控件中选择文本或滚动,然后,如果条件为true,我们调用ShowFolderPathEditWindow方法。
为了验证用户没有选择文本,我们只需检查TextBox控件SelectedText属性的长度。为了确认用户没有滚动TextBox控件,我们比较用户点击的相对水平位置与TextBox元素的长度减去其垂直滚动条的宽度,以确保鼠标没有位于(如果存在的话)滚动条上。
ShowFolderPathEditWindow方法首先准备显示Windows.Forms控件。它将defaultFolderPath变量设置为FolderPath属性的当前值,如果已设置,或者使用Environment.GetFolderPath方法和Environment.SpecialFolder.MyDocuments枚举设置当前用户的Documents文件夹。
然后它调用ShowFolderBrowserDialog方法来启动实际的FolderBrowserDialog控件并检索选定的文件夹路径。如果选定了有效的文件夹路径,我们直接将其值设置为数据绑定的FolderPath属性,但请注意,我们也可以以其他方式设置它。
要在我们的控件中添加一个 ICommand 属性以返回选定的文件夹路径,而不是使用这种直接赋值,将非常容易。在不需要立即设置数据绑定值的情况下,这可能很有用;例如,如果控件被用于需要点击确认按钮后才能更新数据绑定值的子窗口中。
ShowFolderBrowserDialog 方法将 FolderBrowserDialog 类的使用封装在 using 语句中,以确保一旦使用后就被释放。它使用 defaultFolderPath 变量和 OpenFolderTitle 属性来设置实际的 FolderBrowserDialog 控件。请注意,这个 OpenFolderTitle 属性只是用来展示我们如何从 FolderBrowserDialog 元素中公开所需的属性到我们的控件中。这样,我们就可以封装 Windows.Forms 控件和程序集的使用。
注意,我们可以添加额外的依赖属性,使用户能够进一步控制 FolderBrowserDialog 控件中的设置。在这个基本示例中,我们只是硬编码了 FolderBrowserDialog.ShowNewFolderButton 属性的正值,但我们可以将其公开为另一个属性。
我们还可以添加一个浏览按钮,甚至可能添加一个清除按钮来清除选定的文件夹值。然后我们可以添加额外的 bool 依赖属性来控制这些按钮是否显示。我们可以以许多其他方式改进这个控件,但它仍然展示了我们如何将功能封装到我们的视图组件中。我们将在下一节中看到另一种与视图相关的方法来捕获功能的小片段。
使用转换器
转换器是我们可以在框架中打包有用功能的另一种方式。我们已经在 第二章 中看到了 IValueConverter 接口的一个有用示例,调试 WPF 应用程序,但尽管这是一个非常简单的示例,转换器实际上可以非常灵活。
在微软推出他们的 BooleanToVisibilityConverter 类之前,开发者不得不自己创建版本。我们经常需要将 UIElement.Visibility 枚举转换为或从各种不同类型转换,因此从 BaseVisibilityConverter 类开始是一个好主意,它可以服务于多个转换器类。让我们看看这包括什么:
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
public abstract class BaseVisibilityConverter
{
public enum FalseVisibility { Hidden, Collapsed }
protected Visibility FalseVisibilityValue { get; set; } =
Visibility.Collapsed;
public FalseVisibility FalseVisibilityState
{
get { return FalseVisibilityState == Visibility.Collapsed ?
FalseVisibility.Collapsed : FalseVisibility.Hidden; }
set { FalseVisibilityState = value == FalseVisibility.Collapsed ?
Visibility.Collapsed : Visibility.Hidden; }
}
public bool IsInverted { get; set; }
}
}
此转换器需要一个值来表示可见值,由于在 UIElement.Visibility 枚举中只有一个相应的值,因此这显然将是 Visibility.Visible 实例。它还需要一个值来表示不可见值。
因此,我们声明了FalseVisibility枚举,包含来自UIElement.Visibility枚举的两个对应值和FalseVisibilityValue属性,以使用户能够指定哪个值应表示假状态。请注意,最常用的Visibility.Collapsed值被设置为默认值。
用户可以在使用控件时设置FalseVisibilityState属性,这将在内部设置受保护的FalseVisibilityValue属性。最后,我们看到不可或缺的IsInverted属性,它可选地用于反转结果。现在让我们看看我们的BoolToVisibilityConverter类看起来像什么:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(bool), typeof(Visibility))]
public class BoolToVisibilityConverter : BaseVisibilityConverter,
IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(bool))
return DependencyProperty.UnsetValue;
bool boolValue = IsInverted ? !(bool)value :(bool)value;
return boolValue ? Visibility.Visible : FalseVisibilityValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(Visibility))
return DependencyProperty.UnsetValue;
if (IsInverted) return (Visibility)value != Visibility.Visible;
return (Visibility)value == Visibility.Visible;
}
}
}
我们首先在ValueConversion属性中指定转换器实现中涉及的数据类型。这有助于工具了解转换器中使用了哪些类型,同时也使我们的框架的用户清楚。接下来,我们扩展我们的BaseVisibilityConverter基类,并扩展所需的IValueConverter接口。
在Convert方法中,我们首先检查我们的value输入参数的有效性,如果有效,我们将其转换为bool变量,同时考虑IsInverted属性的设置。对于无效的输入值,我们返回DependencyProperty.UnsetValue值。最后,我们从这个bool变量解析输出值,要么是Visibility.Visible实例,要么是FalseVisibilityValue属性的值。
在ConvertBack方法中,我们首先检查我们的value输入参数的有效性。对于无效的输入值,我们再次返回DependencyProperty.UnsetValue值,否则我们输出一个bool值,该值指定输入参数的类型Visibility是否等于Visibility.Visible实例,同时再次考虑IsInverted属性的值。
注意,使用IsInverted属性允许用户指定当数据绑定的bool值为false时,元素应该变为可见。这在我们需要根据同一条件使一个对象可见而另一个对象隐藏时非常有用。我们可以像这样声明从这个类中派生的两个转换器:
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
...
<Converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Converters:BoolToVisibilityConverter
x:Key="InvertedBoolToVisibilityConverter" IsInverted="True" />
如所述,我们经常需要从各种不同类型转换为UIElement.Visibility枚举,或者从UIElement.Visibility枚举转换为其他类型。现在让我们看看一个从Enum类型转换到和从Enum类型转换的例子。原理与上一个例子相同,其中单个数据绑定值表示Visibility.Visible实例,而所有其他值表示隐藏或折叠状态:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(Enum), typeof(Visibility))]
public class EnumToVisibilityConverter : BaseVisibilityConverter,
IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || (value.GetType() != typeof(Enum) &&
value.GetType().BaseType != typeof(Enum)) ||
parameter == null) return DependencyProperty.UnsetValue;
string enumValue = value.ToString();
string targetValue = parameter.ToString();
bool boolValue = enumValue.Equals(targetValue,
StringComparison.InvariantCultureIgnoreCase);
boolValue = IsInverted ? !boolValue : boolValue;
return boolValue ? Visibility.Visible : FalseVisibilityValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(Visibility) ||
parameter == null) return DependencyProperty.UnsetValue;
Visibility usedValue = (Visibility)value;
string targetValue = parameter.ToString();
if (IsInverted && usedValue != Visibility.Visible)
return Enum.Parse(targetType, targetValue);
else if (!IsInverted && usedValue == Visibility.Visible)
return Enum.Parse(targetType, targetValue);
return DependencyProperty.UnsetValue;
}
}
}
再次,我们在ValueConversion属性中指定了转换器实现中涉及的数据类型。在Convert方法中,我们首先检查value输入参数的有效性,如果有效,我们将它转换为值的string表示形式。这个特定的类使用parameter输入参数来传递将表示可见值的指定枚举实例,因此它被设置为targetValue变量作为一个string。
我们通过比较当前枚举实例与目标实例来创建一个bool值。一旦我们有了我们的bool值,最后两行代码与BoolToVisibilityConverter类中的代码相同。
ConvertBack方法的实现略有不同。从逻辑上讲,我们无法为隐藏的可见性返回正确的枚举实例,因为它可以是除了通过parameter输入参数传递的可见值之外的任何值。
因此,我们只能返回指定的值,如果元素是可见的并且IsInverted属性为false,或者如果元素不可见并且IsInverted属性为true。对于所有其他输入值,我们简单地返回DependencyProperty.UnsetValue属性,以表示没有值。
转换器可以做的另一件非常有用的事情是将单个枚举实例转换为特定的图像。让我们看看一个与我们的FeedbackManager相关的例子,或者更准确地说,是显示的Feedback对象。每个Feedback对象可以有一个特定的类型,该类型由FeedbackType枚举指定,所以让我们先看看这个:
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum FeedbackType
{
None = -1,
Error,
Information,
Question,
Success,
Validation,
Warning
}
}
为了使这起作用,显然我们需要为每个枚举实例提供一个合适的图像,除了None实例。我们的图像将位于启动项目根目录下的名为 Images 的文件夹中:
using CompanyName.ApplicationName.DataModels.Enums;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(FeedbackType), typeof(ImageSource))]
public class FeedbackTypeToImageSourceConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (!(value is FeedbackType feedbackType) ||
targetType != typeof(ImageSource))
return DependencyProperty.UnsetValue;
string imageName = string.Empty;
switch ((FeedbackType)value)
{
case FeedbackType.None: return null;
case FeedbackType.Error: imageName = "Error_16"; break;
case FeedbackType.Success: imageName = "Success_16"; break;
case FeedbackType.Validation:
case FeedbackType.Warning: imageName = "Warning_16"; break;
case FeedbackType.Information: imageName = "Information_16"; break;
case FeedbackType.Question: imageName = "Question_16"; break;
default: return DependencyProperty.UnsetValue;
}
return $"pack://application:,,,/CompanyName.ApplicationName;
component/Images/{ imageName }.png";
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
return DependencyProperty.UnsetValue;
}
}
}
再次,我们在ValueConversion属性中指定了转换器中涉及的数据类型。在Convert方法中,我们使用 C# 6.0 模式匹配来检查value输入参数的有效性,并将其转换为FeedbackType实例,如果有效。然后我们使用它在switch语句中,为每个枚举实例生成相关的图像名称。
如果使用了未知实例,我们返回DependencyProperty.UnsetValue值。在所有其他情况下,我们使用字符串插值来构建相关图像的完整文件路径,然后从转换器返回它作为转换值。由于这个转换器的ConvertBack方法没有有效用途,它没有实现,并简单地返回DependencyProperty.UnsetValue值。
你可能已经注意到,我们在ValueConversion属性中指定了类型ImageSource,但我们返回了一个string。这是可能的,因为 XAML 使用相关的类型转换器自动将string转换为ImageSource对象。当我们用 XAML 中的string设置Image.Source属性时,发生的情况完全相同。
与我们框架的其他部分一样,当我们结合其他领域的功能时,我们可以使我们的转换器更加有用。在这个特定的例子中,我们利用了本章前面展示的扩展方法之一。为了提醒您,GetDescription 方法将返回设置在每个枚举实例上的 DescriptionAttribute 的值。
DescriptionAttribute 允许我们将任何 string 值与我们的每个枚举实例关联起来,因此这是为每个实例输出用户友好描述的绝佳方式。以下是一个例子:
using System.ComponentModel;
public enum BitRate
{
[Description("16 bits")]
Sixteen = 16,
[Description("24 bits")]
TwentyFour = 24,
[Description("32 bits")]
ThirtyTwo = 32,
}
以这种方式,例如,我们可以在 RadioButton 控件中显示实例的名称,而不是显示这些属性中更人性化的描述。现在让我们看看这个转换器类:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(Enum), typeof(string))]
public class EnumToDescriptionStringConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || (value.GetType() != typeof(Enum) &&
value.GetType().BaseType != typeof(Enum)))
return DependencyProperty.UnsetValue;
Enum enumInstance = (Enum)value;
return enumInstance.GetDescription();
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
return DependencyProperty.UnsetValue;
}
}
}
正如我们现在习惯做的那样,我们首先在 ValueConversion 属性中指定转换器中使用的数据类型。在 Convert 方法中,我们再次检查我们的 value 输入参数的有效性,如果它无效,则返回 DependencyProperty.UnsetValue 值。
如果它是有效的,我们将它转换为 Enum 实例,然后使用我们扩展方法的力量从每个实例的 DescriptionAttribute 返回值。通过这样做,我们能够将此功能暴露给我们的视图,并使用户能够直接从 XAML 中利用它。现在,我们已经对如何将功能封装到我们的框架中的各种方式有了基本的了解,让我们专注于开始构建我们的基类。
构建自定义应用程序框架
对于不同的组件,可能会有不同的要求,但通常,我们将构建到我们的数据模型基类中的属性和功能将被我们的其他基类利用并变得更有用,所以让我们首先看看各种数据模型基类。
我们需要决定的是,我们是否希望我们的数据模型基类中的任何一个都是泛型的。这种差异可能很微妙,但很重要。想象一下,我们想在基类中添加一些基本的撤销功能。我们可以实现这一目标的一种方法是在基类中添加一个表示数据模型未编辑版本的对象。在一个普通基类中,它看起来是这样的:
public abstract class BaseSynchronizableDataModel : BaseDataModel
{
private BaseSynchronizableDataModel originalState;
public BaseSynchronizableDataModel OriginalState
{
get { return originalState; }
private set { originalState = value; }
}
}
在一个通用基类中,它看起来是这样的:
public abstract class BaseSynchronizableDataModel<T> : BaseDataModel
{
private T originalState;
public T OriginalState
{
get { return originalState; }
private set { originalState = value; }
}
}
为了使这个属性更有用,我们需要添加一些额外的功能。首先,我们将看到非通用版本:
public abstract void CopyValuesFrom(BaseSynchronizableDataModel dataModel);
public virtual BaseSynchronizableDataModel Clone()
{
BaseSynchronizableDataModel clone =
Activator.CreateInstance(this.GetType()) as BaseSynchronizableDataModel;
clone.CopyValuesFrom(this);
return clone;
}
public abstract bool PropertiesEqual(BaseSynchronizableDataModel dataModel);
现在,让我们看看通用版本:
public abstract void CopyValuesFrom(T dataModel);
public virtual T Clone()
{
T clone = new T();
clone.CopyValuesFrom(this as T);
return clone;
}
public abstract bool PropertiesEqual(T dataModel);
这个基类的最后几个成员在这两个版本中都是相同的:
public bool HasChanges
{
get { return originalState != null && !PropertiesEqual(originalState); }
}
public void Synchronize()
{
originalState = this.Clone();
NotifyPropertyChanged(nameof(HasChanges));
}
public void RevertState()
{
Debug.Assert(originalState != null, "Object not yet synchronized.");
CopyValuesFrom(originalState);
Synchronize();
NotifyPropertyChanged(nameof(HasChanges));
}
我们从 OriginalState 属性开始,它持有数据模型的未编辑版本。之后,我们看到开发人员需要实现的抽象 CopyValuesFrom 方法,我们很快就会看到一个实现示例。Clone 方法简单地调用 CopyValuesFrom 方法以执行数据模型的深度克隆。
接下来,我们有一个名为 PropertiesEqual 的抽象方法,开发人员需要实现这个方法以便将他们类中的每个属性与 dataModel 输入参数中的属性进行比较。同样,我们很快就会看到这个实现,但你可能想知道为什么我们不直接重写 Equals 方法,或者实现 IEquatable.Equals 方法来达到这个目的。
我们不想使用这些方法的原因是,它们以及 GetHashCode 方法在 WPF 框架的多个地方被使用,并且它们期望返回的值是不可变的。由于我们的对象的属性非常可变,它们不能用于返回这些方法的值。因此,我们实现了自己的版本。现在,让我们回到这段代码剩余部分的描述。
HasChanges 属性是我们希望将其数据绑定到 UI 控件以指示特定对象是否已被编辑的属性。Synchronize 方法将当前数据模型的深度克隆设置到 originalState 字段中,并且重要的是,它通知 WPF 框架 HasChanges 属性发生了变化。这样做是因为 HasChanges 属性没有自己的设置器,这个操作将影响其值。
我们将克隆版本设置到 originalState 字段中非常重要,而不是简单地将其实际对象引用赋值给它。这是因为我们需要一个完全独立的对象版本来表示数据模型的未编辑版本。如果我们只是将实际对象引用赋值给 originalState 字段,那么它的属性值会随着数据模型对象的变化而变化,使其对于这个功能变得无用。
RevertState 方法首先检查数据模型是否已同步,然后将 originalState 字段中的值复制回模型。最后,它调用 Synchronize 方法来指定这是新版本的对象,并且通知 WPF 框架 HasChanges 属性发生了变化。
所以,正如你所看到的,这两个版本的基类之间没有太多差异。实际上,差异在派生类的实现中可以更清楚地看到。现在,让我们专注于它们对示例抽象方法的实现,从非泛型版本开始:
public override bool PropertiesEqual(BaseClass genreObject)
{
Genre genre = genreObject as Genre;
if (genre == null) return false;
return Name == genre.Name && Description == genre.Description;
}
public override void CopyValuesFrom(BaseClass genreObject)
{
Debug.Assert(genreObject.GetType() == typeof(Genre), "You are using
the wrong type with this method.");
Genre genre = (Genre)genreObject;
Name = genre.Name;
Description = genre.Description;
}
在讨论这段代码之前,让我们先看看泛型实现:
public override bool PropertiesEqual(Genre genre)
{
return Name == genre.Name && Description == genre.Description;
}
public override void CopyValuesFrom(Genre genre)
{
Name = genre.Name;
Description = genre.Description;
}
最后,我们可以看到使用泛型和非泛型基类的区别。不使用泛型时,我们必须使用基类输入参数,在访问它们的属性之前,需要在每个派生类中将这些参数转换为适当类型。尝试转换不适当的类型会导致异常,所以我们通常尽量避免这些情况。
另一方面,当使用泛型基类时,不需要进行转换,因为输入参数已经是正确的类型。简而言之,泛型使我们能够创建类型安全的 Data 模型并避免重复特定类型的代码。既然我们已经看到了使用泛型类的益处,让我们暂时放下泛型,更仔细地看看这个基类。
一些同学可能已经注意到,WPF 框架通知我们HasChanges属性变化的地方只有Synchronize和RevertState方法。然而,为了使这个功能正常工作,我们需要在每次任何属性的值发生变化时通知框架。
我们可以依赖开发者每次调用NotifyPropertyChanged方法时传递HasChanges属性名,针对每个发生变化的属性进行调用,但如果他们忘记这样做,可能会导致难以追踪的错误。相反,一个更好的解决方案是我们覆盖基类中INotifyPropertyChanged接口的默认实现,并在每次调用时为他们通知HasChanges属性的变化:
#region INotifyPropertyChanged Members
protected override void NotifyPropertyChanged(
params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
if (propertyName != nameof(HasChanges)) PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasChanges)));
}
}
protected override void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
if (PropertyChanged != null)
{
if (propertyName != nameof(HasChanges)) PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasChanges)));
}
}
#endregion
第一种方法将引发PropertyChanged事件,只传递一次HasChanges属性的名称,无论传递给方法多少个属性名。第二种方法也执行检查以确保它不会多次引发带有HasChanges属性名称的事件,因此这些实现保持高效。
现在,我们的基类将按预期工作,并且当数据模型类中的其他属性发生变化时,HasChanges属性将正确更新。这种技术也可以在其他场景中使用;例如,在我们稍后将在第九章中看到的实现响应式数据验证时。不过,现在让我们暂停一下泛型的使用,更仔细地看看这个基类。
另一个经常使用泛型的地方与集合相关。我相信你们都知道,我们倾向于在 WPF 应用程序中使用ObservableCollection<T>类,因为它实现了INotifyCollectionChanged和INotifyPropertyChanged。对于每种数据模型类,扩展这个类是惯例,但不是必需的:
public class Users : ObservableCollection<User>
然而,而不是这样做,我们可以声明一个 BaseCollection<T> 类,它扩展了 ObservableCollection<T> 类,并为我们添加了更多的功能到我们的框架中。我们的框架用户可以扩展这个类:
public class Users : BaseCollection<User>
我们可以做的真正有用的一件事是将一个泛型属性 T 添加到我们的基类中,它将代表 UI 中数据绑定集合控件中当前选中的项。我们还可以声明一些委托来通知开发人员关于选择或属性值的变化。这里有很多快捷方式和辅助方法,我们可以根据需求提供,所以花些时间调查这一点是值得的。让我们看看一些可能性:
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class BaseCollection<T> :
ObservableCollection<T>, INotifyPropertyChanged
where T : class, INotifyPropertyChanged, new()
{
protected T currentItem;
public BaseCollection(IEnumerable<T> collection) : this()
{
foreach (T item in collection) Add(item);
}
public BaseCollection(params T[] collection) :
this(collection as IEnumerable<T>) { }
public BaseCollection() : base()
{
currentItem = new T();
}
public virtual T CurrentItem
{
get { return currentItem; }
set
{
T oldCurrentItem = currentItem;
currentItem = value;
CurrentItemChanged?.Invoke(oldCurrentItem, currentItem);
NotifyPropertyChanged();
}
}
public bool IsEmpty
{
get { return !this.Any(); }
}
public delegate void ItemPropertyChanged(T item,
string propertyName);
public virtual ItemPropertyChanged CurrentItemPropertyChanged
{ get; set; }
public delegate void CurrentItemChange(T oldItem, T newItem);
public virtual CurrentItemChange CurrentItemChanged { get; set; }
public T GetNewItem()
{
return new T();
}
public virtual void AddEmptyItem()
{
Add(new T());
}
public virtual void Add(IEnumerable<T> collection)
{
collection.ForEach(i => base.Add(i));
}
public virtual void Add(params T[] items)
{
if (items.Length == 1) base.Add(items[0]);
else Add(items as IEnumerable<T>);
}
protected override void InsertItem(int index, T item)
{
if (item != null)
{
item.PropertyChanged += Item_PropertyChanged;
base.InsertItem(index, item);
if (Count == 1) CurrentItem = item;
}
}
protected override void SetItem(int index, T item)
{
if (item != null)
{
item.PropertyChanged += Item_PropertyChanged;
base.SetItem(index, item);
if (Count == 1) CurrentItem = item;
}
}
protected override void ClearItems()
{
foreach (T item in this)
item.PropertyChanged -= Item_PropertyChanged;
base.ClearItems();
}
protected override void RemoveItem(int index)
{
T item = this[index];
if (item != null) item.PropertyChanged -= Item_PropertyChanged;
base.RemoveItem(index);
}
public void ResetCurrentItemPosition()
{
if (this.Any()) CurrentItem = this.First();
}
private void Item_PropertyChanged(object sender,
PropertyChangedEventArgs e)
{
if ((sender as T) == CurrentItem)
CurrentItemPropertyChanged?.Invoke(currentItem, e.PropertyName);
NotifyPropertyChanged(e.PropertyName);
}
#region INotifyPropertyChanged Members
...
#endregion
}
}
在这里有很多内容需要消化,所以让我们仔细地过一遍每一部分。我们首先从我们的私有成员 T 类型开始,它将支持我们的 CurrentItem 属性。然后我们发现了一些构造函数的重载,使我们能够从集合或相关类型的任意数量的输入参数中初始化我们的集合。
接下来,我们看到来自 第一章,《使用 WPF 的更智能的方法》,的 CurrentItem 属性,再次,但现在有一些进一步的上下文。如果一个类已订阅了 CurrentItemChanged 属性,我们将从这里调用委托,传递当前项的新旧值。IsEmpty 属性只是一个高效的便利属性,供开发人员在需要知道集合是否有内容时调用。
在此之后,我们看到集合委托和相关的属性包装器,使将使用我们的框架的开发人员能够利用它们。接下来,我们看到方便的 GetNewItem 和 AddEmptyItem 方法,这两个方法都生成一个 T 泛型类型参数的新项,在分别返回或添加到集合之前。这就是为什么我们需要在类定义中添加 new() 泛型类型约束的原因;这个类型约束指定使用的泛型类型必须有一个无参数的构造函数。
现在我们来到了集合的各种 Add 方法;请注意,必须处理向集合添加项的每一种方式,这样我们就可以将我们的 Item_PropertyChanged 处理程序附加到每个添加项的 PropertyChanged 事件上,以确保一致的行为。
因此,我们从所有其他重载和辅助方法中调用我们的 Add 方法,并从那里调用基类的 Collection.Add 方法。请注意,我们实际上是在受保护的 InsertItem 方法中附加我们的处理程序,因为这个重写的方法是从 Collection 类中的 Add 方法调用的。
同样,当使用索引表示法设置项目时,受保护的SetItem方法将由Collection类调用,因此我们也必须处理这一点。同样,当从集合中移除项目时,移除对每个对象的事件处理程序的引用同样重要,如果不是更重要的话。未能这样做可能会导致内存泄漏,因为对事件处理程序的引用可能会阻止垃圾回收器销毁数据模型对象。
因此,我们还需要处理从我们的集合中移除对象的每个方法。为此,我们覆盖了Collection基类的一些更多受保护的方 法。当用户在我们的集合上调用Clear方法时,ClearItems方法将在内部被调用。同样,当用户调用任何公共移除方法时,将调用RemoveItem方法,因此这是移除我们的处理程序的理想位置。
现在暂时跳过ResetCurrentItemPosition方法,在类的底部,我们到达了Item_PropertyChanged事件处理方法。如果属性已更改的项目是集合中的当前项目,那么我们将引发与CurrentItemPropertyChanged属性连接的ItemPropertyChanged委托。
对于每个属性更改通知,无论项目是否是当前项目,我们随后都会引发INotifyPropertyChanged.PropertyChanged事件。这使得使用我们框架的开发者能够直接在我们的集合上附加处理程序到PropertyChanged事件,并能够发现集合中任何项目的任何属性是否已更改。
你可能也注意到了在集合类代码中的一些地方,我们设置了CurrentItem属性的值。这里选择的选项是自动选择集合中的第一个项目,但也可以简单地改为选择最后一个项目,例如。像往常一样,这些细节将取决于你的具体需求。
声明这些基本集合类的另一个好处是,我们可以利用属性并扩展我们基础数据模型类中内置的功能。回顾一下我们简单的BaseSynchronizableDataModel类示例,让我们看看我们可以在新的基础集合类中添加什么来改进这个功能。
然而,在我们能够做到这一点之前,我们需要能够指定我们新集合中的对象已实现了BaseSynchronizableDataModel类的属性和方法。一个选项是像这样声明我们的新集合类:
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : BaseSynchronizableDataModel<T>
然而,在 C#中,我们只能扩展单个基类,而我们可以自由实现尽可能多的接口。因此,一个更可取的解决方案是从我们的基类中提取相关的同步属性到一个接口中,然后将该接口添加到我们的基类定义中:
public abstract class BaseSynchronizableDataModel<T> :
BaseDataModel, ISynchronizableDataModel<T>
where T : BaseDataModel, ISynchronizableDataModel<T>, new()
然后,我们可以像这样在我们的新集合类中指定这个新的泛型约束:
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : class, ISynchronizableDataModel<T>, new()
注意,任何放置在 BaseSynchronizableDataModel 类上的其他泛型约束也需要添加到这个声明的 where T 部分。例如,如果我们需要在基类中实现另一个接口,并且我们没有在基集合类中对 T 泛型类型参数添加相同的约束,那么在尝试使用基类的实例作为 T 参数时,我们会得到编译错误。现在让我们看看这个新的基类:
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CompanyName.ApplicationName.DataModels.Interfaces;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : class, ISynchronizableDataModel<T>,
INotifyPropertyChanged, new()
{
public BaseSynchronizableCollection(IEnumerable<T> collection) :
base(collection) { }
public BaseSynchronizableCollection(params T[] collection) :
base(collection as IEnumerable<T>) { }
public BaseSynchronizableCollection() : base() { }
public virtual bool HasChanges
{
get { return this.Any(i => i.HasChanges); }
}
public virtual bool AreSynchronized
{
get { return this.All(i => i.IsSynchronized); }
}
public virtual IEnumerable<T> ChangedCollection
{
get { return this.Where(i => i.HasChanges); }
}
public virtual void Synchronize()
{
this.ForEach(i => i.Synchronize());
}
public virtual void RevertState()
{
this.ForEach(i => i.RevertState());
}
}
}
虽然保持简单,但这个基集合类提供了一些强大的功能。我们从类声明开始,其中包含了从我们的目标 T 类型类和 BaseCollection<T> 类继承的泛型类型约束。然后我们实现了构造函数重载,并将初始化任务直接传递给基类。
注意,如果我们想要给我们的集合项附加一个额外的事件处理程序级别,我们应该遵循基类的模式,而不是以这种方式调用基类的构造函数。
HasChanges 属性可以用作标志来检测集合中的任何项是否有任何更改。这通常与保存命令的 canExecute 参数相关联,以便当集合中的任何项被编辑时,保存按钮会变为启用状态,如果更改被撤销,则禁用。
AreSynchronized 属性简单地指定集合中的项目是否都已经同步,但这个类的真正美妙之处在于 ChangedCollection 属性。使用简单的 LINQ 过滤器,我们只返回集合中发生变化的项。想象一下这样一个场景,我们允许用户一次性编辑多个项目。有了这个属性,我们的开发者可以轻松地从集合中提取他们需要保存的项。
最后,这个类提供了一个方法来一次性同步集合中的所有项,另一个方法可以撤销集合中所有已编辑项的更改。注意这两个最后方法中使用了自定义的 ForEach 扩展方法;如果你还记得前面 With Extension Methods 部分的内容,它使我们能够对集合中的每个项执行操作。
通过其他部分框架使用我们的数据模型基类的属性和方法,我们能够进一步扩展它们的功能。虽然以这种方式从不同的组件构建组合功能通常是可选的,但它也可能是必要的,正如我们在本书后面的内容中将会看到的。
我们可以将更常见的功能构建到我们的应用程序框架基类中,这样使用我们框架的开发人员在开发应用程序时需要做的工作就越少。然而,我们必须精心规划,不要强迫开发人员拥有他们不想要的属性和方法,以扩展具有他们想要的某些其他功能的特定基类。
通常,不同的组件会有不同的要求。数据模型类通常比视图模型类有更多的基类,因为它们比视图模型扮演着更重要的角色。视图模型只是为视图提供它们所需的数据和功能。然而,数据模型类包含了数据,以及验证、同步,以及可能的方法和属性。考虑到这一点,让我们再次看看视图模型基类。
我们已经看到,在我们的基类中需要实现INotifyPropertyChanged接口,但还需要实现什么? 如果每个视图都将提供一些特定的功能,例如保存和删除项目,那么我们也可以直接在我们的基类中添加命令和抽象方法,每个派生视图模型类都必须实现:
public virtual ICommand Refresh
{
get
{
return new ActionCommand(action => RefreshData(),
canExecute => CanRefreshData());
}
}
protected abstract void RefreshData();
protected abstract bool CanRefreshData();
再次强调,声明这个命令为虚拟的是很重要的,以防开发人员需要提供他们自己的、不同的实现。这种安排的替代方案是只为每个命令添加抽象属性,这样具体的实现就完全取决于开发人员:
public abstract ICommand Save { get; }
在讨论命令的同时,你可能还记得我们在第一章中提到的ActionCommand的基本实现,一种更智能的 WPF 工作方式。在这个时候,值得短暂偏离主题,进一步研究这个问题。请注意,虽然展示的基本实现大多数时候都工作得很好,但它有时可能会让我们陷入困境,我们可能会注意到一个按钮没有在应该的时候变为可用。
让我们看看一个例子。想象一下,在我们的 UI 中有一个按钮,它为用户打开一个文件夹以查看文件,并在ICommand.CanExecute方法中满足某个条件时启用。假设这个条件是文件夹应该有一些内容。毕竟,为用户打开一个空文件夹是没有意义的。
现在,让我们想象当用户在 UI 中执行其他操作时,这个文件夹会被填充。用户点击启动这个文件夹填充功能的按钮,应用程序开始填充它。当填充功能完成,文件夹现在包含了一些内容时,打开文件夹按钮应该变为可用状态,因为其关联命令的CanExecute条件现在是true。
尽管如此,CanExecute方法在那个点不会被调用,为什么应该调用它呢?按钮以及CommandManager类都不知道这个后台过程正在发生,以及CanExecute方法的条件现在已经满足。幸运的是,我们有一些选项来解决这个问题。
一种选择是手动引发CanExecuteChanged事件,使数据绑定命令源重新检查CanExecute方法的输出并相应地更新其启用状态。为此,我们可以在ActionCommand类中添加另一个方法,但首先我们需要重新排列一些事情。
当前实现并未存储附加到CanExecuteChanged事件的任何处理程序引用。实际上,这些引用被存储在CommandManager类中,因为它们只是直接传递给RequerySuggested事件以进行处理。为了能够手动引发事件,我们需要存储我们自己的处理程序引用,为此,我们需要一个EventHandler对象:
private EventHandler eventHandler;
接下来,我们需要添加引用到被附加的处理程序,并移除那些被断开连接的处理程序,同时仍然将它们的引用传递给CommandManager的RequerySuggested事件:
public event EventHandler CanExecuteChanged
{
add
{
eventHandler += value;
CommandManager.RequerySuggested += value;
}
remove
{
eventHandler -= value;
CommandManager.RequerySuggested -= value;
}
}
我们对ActionCommand类的最后修改是添加一个方法,当我们想要 UI 控件命令源检索新的CanExecute值并更新其启用状态时,可以调用此方法来引发CanExecuteChanged事件:
public void RaiseCanExecuteChanged()
{
eventHandler?.Invoke(this, new EventArgs());
}
现在,我们能够在需要时随时引发CanExecuteChanged事件,尽管我们也需要更改对ActionCommand类的使用方式。以前,每次调用其 getter 时,我们只是简单地返回一个新实例,而现在我们需要保留每个我们想要具有此能力的命令的引用:
private ActionCommand saveCommand = null;
...
public ICommand SaveCommand
{
get { return saveCommand ?? (saveCommand =
new ActionCommand(action => Save(), canExecute => CanSave())); }
}
如果你对前面代码中显示的??运算符不熟悉,它被称为空合并运算符,它简单地返回左操作数如果不是null,或者如果它是,则返回右操作数。在这种情况下,右操作数将初始化命令并将其设置为saveCommand变量。然后,为了引发事件,我们在完成操作后调用我们的ActionCommand实例上的新RaiseCanExecuteChanged方法:
private void ExecuteSomeCommand()
{
// Perform some operation that fulfills the canExecute condition
// then raise the CanExecuteChanged event of the ActionCommand
saveCommand.RaiseCanExecuteChanged();
}
虽然我们的方法集成在ActionCommand类中,但有时我们可能无法访问我们需要在其上引发事件的特定实例。因此,在此应指出,我们还有另一种更直接的方法可以使CommandManager类引发其RequerySuggested事件。
在这些情况下,我们可以简单地调用 CommandManager.InvalidateRequerySuggested 方法。我们还应该意识到,这些引发 RequerySuggested 事件的方 法只能在 UI 线程上工作,因此在使用异步代码时应小心。现在我们的与命令相关的短暂偏离已经完成,让我们回到查看我们可能想要放入我们的视图模型基类中的其他常见功能。
如果我们选择为我们的数据模型使用泛型基类,那么我们可以在我们的 BaseViewModel 类中利用这一点。我们可以提供利用这些泛型基类成员的泛型方法。让我们看看一些简单的例子:
public T AddNewDataTypeToCollection<S, T>(S collection)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
T item = collection.GetNewItem();
if (item is IAuditable)
((IAuditable)item).Auditable.CreatedOn = DateTime.Now;
item.Synchronize();
collection.Add(item);
collection.CurrentItem = item;
return item;
}
public T InsertNewDataTypeToCollection<S, T>(int index, S collection)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
T item = collection.GetNewItem();
if (item is IAuditable)
((IAuditable)item).Auditable.CreatedOn = DateTime.Now;
item.Synchronize();
collection.Insert(index, item);
collection.CurrentItem = item;
return item;
}
public void RemoveDataTypeFromCollection<S, T>(S collection, T item)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
int index = collection.IndexOf(item);
collection.RemoveAt(index);
if (index > collection.Count) index = collection.Count;
else if (index < 0) index++;
if (index > 0 && index < collection.Count &&
collection.CurrentItem != collection[index])
collection.CurrentItem = collection[index];
}
在这里,我们看到三个简单的方法,它们封装了更常见的功能。请注意,我们必须指定与我们的基类上声明的相同泛型类型约束。未能这样做将导致编译错误,或者我们无法使用这些方法与我们的数据模型类一起使用。
AddNewDataTypeToCollection 和 InsertNewDataTypeToCollection 方法几乎相同,它们首先使用我们的泛型 BaseSynchronizableCollection 类的 GetNewItem 方法创建相关类型的新项目。接下来,我们看到我们对 IAuditable 接口的使用。在这种情况下,如果新项目实现了此接口,我们将设置新项目的 CreatedOn 日期。
由于我们在 T-类型参数上声明了泛型类型约束,指定它必须是或扩展 BaseSynchronizableDataModel 类,因此我们可以调用 Synchronize 方法来同步新项目。然后我们将项目添加到集合中,并将其设置为 CurrentItem 属性的值。最后,这两个方法都返回新项目。
最后一个方法执行相反的操作;它从集合中删除一个项目。在这样做之前,它会检查项目在集合中的位置,如果可能,将 CurrentItem 属性设置为下一个项目,或者如果被删除的项目是集合中的最后一个项目,则设置为下一个最近的项目。
再次,我们看到如何将常用功能封装到我们的基类中,并为我们的框架的用户节省时间和精力,避免在每个视图模型类中重新实现此功能。我们可以以这种方式打包我们需要的任何常见功能。现在我们已经看到了在基类中提供功能的一些示例,让我们现在将注意力转向在框架的组件之间提供分离。
分离数据访问层
现在我们已经通过我们的基类和接口提供了各种功能,让我们来探讨如何提供在使用 MVVM 模式时至关重要的关注点分离。再次,我们转向谦逊的接口来帮助我们实现这一点。让我们看看一个简化的例子:
using System;
using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.Models.Interfaces
{
public interface IDataProvider
{
User GetUser(Guid id);
bool SaveUser(User user);
}
}
我们从一个非常简单的界面开始。当然,实际应用将会有比这多得多的方法,但无论界面的复杂程度如何,原理都是相同的。因此,这里我们只有一个GetUser和一个SaveUser方法,这是我们的DataProvider类需要实现的方法。现在,让我们看看ApplicationDataProvider类:
using System;
using System.Data.Linq;
using System.Linq;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace CompanyName.ApplicationName.Models.DataProviders
{
public class ApplicationDataProvider : IDataProvider
{
public ApplicationDataContext DataContext
{
get { return new ApplicationDataContext(); }
}
public User GetUser(Guid id)
{
DbUser dbUser = DataContext.DbUsers.SingleOrDefault(u => u.Id == id);
if (dbUser == null) return null;
return new User(dbUser.Id, dbUser.Name, dbUser.Age);
}
public bool SaveUser(User user)
{
using (ApplicationDataContext dataContext = DataContext)
{
DbUser dbUser =
dataContext.DbUsers.SingleOrDefault(u => u.Id == user.Id);
if (dbUser == null) return false;
dbUser.Name = user.Name;
dbUser.Age = user.Age;
dataContext.SubmitChanges(ConflictMode.FailOnFirstConflict);
return true;
}
}
}
}
这个ApplicationDataProvider类使用一些简单的 LINQ to SQL 来查询和更新由id值指定的User数据库。这意味着这个接口的特定实现需要连接到一个数据库。我们希望在测试应用程序时避免这种依赖,因此我们需要为测试目的使用接口的另一个实现。现在让我们看看我们的模拟实现:
using System;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace Test.CompanyName.ApplicationName.Models.DataProviders
{
public class MockDataProvider : IDataProvider
{
public User GetUser(Guid id)
{
return new User(id, "James Smith", 25);
}
public bool SaveUser(User user)
{
return true;
}
}
}
在这个MockDataProvider对IDataProvider接口的实现中,我们可以看到数据只是手动模拟的。实际上,它只是从GetUser方法返回一个单一的User,并且总是从SaveUser方法返回true,所以它相当无用。
在实际应用中,我们可能会利用一个模拟框架,或者手动模拟一些更实质的测试数据。尽管如此,这足以说明我们在这里关注的点。现在我们已经看到了相关的类,让我们看看它们可能的使用方式。
理念是我们有一些DataController类或类,它们位于IDataProvider接口和视图模型类之间。视图模型类从DataController类请求数据,然后它通过接口请求数据。
因此,它反映了接口的方法,并且通常引入一些额外的功能,例如反馈处理等。让我们看看我们的简化版DataController类是什么样的:
using System;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace CompanyName.ApplicationName.Models.DataControllers
{
public class DataController
{
private IDataProvider dataProvider;
public DataController(IDataProvider dataProvider)
{
DataProvider = dataProvider;
}
protected IDataProvider DataProvider
{
get { return dataProvider; }
private set { dataProvider = value; }
}
public User GetUser(Guid id)
{
return DataProvider.GetUser(id);
}
public bool SaveUser(User user)
{
return DataProvider.SaveUser(user);
}
}
}
如我们所见,DataController类有一个私有的成员变量,类型为IDataProvider,它在构造函数中被填充。正是这个变量用于访问应用程序数据源。当应用程序运行时,我们的ApplicationDataProvider类的一个实例被用来实例化DataController类,因此我们实际的数据源被使用:
DataController dataController =
new DataController(new ApplicationDataProvider());
然而,当我们测试应用程序时,我们可以使用我们的MockDataProvider类的一个实例来实例化DataController类,从而消除对实际数据源的依赖:
DataController dataController = new DataController(new MockDataProvider());
以这种方式,我们可以在保持其余代码不变的情况下替换掉为视图模型提供数据的代码。这使得我们能够在不连接到实际数据存储设备的情况下测试视图模型中的代码。在下一节中,我们将看到更好的初始化这些类的方法,但就目前而言,让我们看看我们的DataController类还能为我们做什么。
当接口被应用程序框架的各个部分使用,而不仅仅是实现类时,它们就更有用了。除了定义一些审计属性和有输出它们值的可能性之外,我们早期的IAuditable接口示例并不特别有用。然而,我们可以通过在DataController类中自动更新其值来进一步扩展其功能。为此,我们需要添加一些额外的成员:
using CompanyName.ApplicationName.DataModels.Interfaces;
...
public User CurrentUser { get; set; }
...
private void SetAuditUpdateFields<T>(T dataModel) where T : IAuditable
{
dataModel.Auditable.UpdatedOn = DateTime.Now;
dataModel.Auditable.UpdatedBy = CurrentUser;
return dataModel;
}
我们首先需要添加一个类型为User的属性,我们将用它来设置应用程序当前用户的价值。这可以在新用户登录应用程序时设置。接下来,我们需要一个方法来更新我们的IAuditable接口的“更新”值。同样,我们添加一个泛型类型约束,以确保只有实现我们接口的对象才能传递到这个方法中。结果是,使用我们应用程序框架的开发者可以轻松地更新这些值:
public bool SaveUser(User user)
{
return DataProvider.SaveUser(SetAuditUpdateFields(user));
}
我们可以为添加新对象时设置类似的“创建”审计属性的方法:
public bool AddUser(User user)
{
return DataProvider.AddUser(SetAuditCreateFields(user));
}
...
private void SetAuditCreateFields<T>(T dataModel) where T : IAuditable
{
dataModel.Auditable.CreatedOn = DateTime.Now;
dataModel.Auditable.CreatedBy = CurrentUser;
return dataModel;
}
继续这个例子,我们可以扩展我们的DataController类的构造函数,以接受一个User输入参数,我们可以使用它来设置StateManager类中的CurrentUser属性:
public DataController(IDataProvider dataProvider, User currentUser)
{
DataProvider = dataProvider;
CurrentUser = currentUser;
}
我们可以通过StateManager类中的CurrentUser属性和接下来的章节中我们将看到的DependencyManager类,将我们的数据源暴露给我们的视图模型通过它们的基类:
protected DataController Model
{
get { return new DataController(
DependencyManager.Instance.Resolve<IDataProvider>(),
StateManager.CurrentUser); }
}
实质上,我们需要对来自应用程序数据源的数据进行的任何操作都可以在单个DataController类中实现。然而,如果我们需要几个不同的修改,那么我们可以创建几个控制器类并将它们链接在一起,每个类依次执行它们各自的任务。
由于它们都可以实现相同的方法,它们都可以潜在地实现相同的接口:

我们将在第十章完成卓越的用户体验中看到一个例子,但现在我们已经对如何设置我们的应用程序数据源连接以提供 MVVM 模式所需的分离有了很好的理解,我们可以专注于将功能构建到我们的框架中的下一个方式。让我们继续探索如何将更复杂和/或特殊的功能插入到我们的框架中。
提供服务
在我们的应用程序框架中,基类和接口的职责是封装我们的视图模型和数据模型常用的功能。当所需的功能更复杂,或者当它涉及特定的资源或外部连接时,我们在单独的服务或管理类中实现它。在本书的剩余部分,我们将把这些称为管理类。在更大的应用程序中,这些通常在单独的项目中提供。
将它们封装在单独的项目中,使我们能够在我们其他应用程序中重用这些类的功能。我们在这个项目中使用的类将取决于我们正在构建的应用程序的需求,但通常包括提供发送电子邮件、访问最终用户的硬盘驱动器、以各种格式导出数据或管理全局应用程序状态等能力的类。
我们将在本书中研究这些类中的许多,以便我们有一个很好的想法,知道如何实现我们自己的自定义管理器类。这些类中最常用的通常可以通过基视图模型类中的属性直接访问。我们可以以几种不同的方式将这些类暴露给视图模型,让我们来检查它们。
当管理器类经常使用,并且每次使用时间较短时,我们可以每次都暴露一个新的实例,如下所示:
public FeedbackManager FeedbackManager
{
get { return new FeedbackManager(); }
}
然而,如果因为必须记住特定的状态或配置,在应用程序的生命周期中需要使用管理器类,那么我们通常会以某种方式使用static关键字。最简单的选择是声明一个普通类,但通过静态属性来暴露它:
private static StateManager stateManager = new StateManager();
...
public static StateManager StateManager
{
get { return stateManager; }
}
要实现一个类只有一个实例被实例化并且在整个应用程序运行期间保持活跃,我们可以使用单例模式。虽然二十年前它非常流行,但不幸的是,它最近受到了更多现代编程原则的抨击,例如 SOLID 原则,该原则指出每个类应该只有一个职责。
单例模式打破了这一原则,因为它服务于我们为其设计的任何目的,但它也负责实例化自身并保持一个单一的访问点。在进一步讨论这个模式的优点和缺点之前,让我们看看我们如何在管理器类中实现它:
namespace CompanyName.ApplicationName.Managers
{
public class StateManager
{
private static StateManager instance;
private StateManager() { }
public static StateManager Instance
{
get { return instance ?? (instance = new StateManager()); }
}
...
}
}
注意,它可以以多种方式实现,但这种方式使用的是延迟初始化,即实例只有在第一次通过Instance属性引用时才会被实例化。再次使用??运算符,Instance属性的 getter 可以读作“如果它不是null,则返回唯一的实例,如果是,则实例化唯一的实例然后返回它。”这个模式的关键部分是,由于没有公共构造函数,因此类不能被外部实例化,这个属性是访问内部对象的唯一方式。
然而,这正是给一些开发者带来麻烦的部分,因为这使得这些类无法进行继承。在我们的情况下,我们不需要扩展我们的StateManager类,所以这不是我们的问题。其他人可能会指出,像以下代码所示的那样公开这个 Singleton 类,将使其与声明的基视图模型类紧密耦合:
public StateManager StateManager
{
get { return StateManager.Instance; }
}
虽然这是真的,但这个类有什么危害呢?它的目的是维护用户设置的状态、常用或默认值以及 UI 显示和操作状态。它不包含任何资源,也没有任何真正的理由在运行单元测试时避免使用它,所以在这种情况下,紧密耦合是无足轻重的。在这方面,Singleton 模式在适当的情况下仍然是一个有用的工具,但我们确实应该意识到它的陷阱。
然而,如果一个特定的管理类确实使用了资源或与外部世界建立了某种形式的连接,例如,像EmailManager那样,那么我们需要为它创建一个接口来维护我们的关注点分离。记住,接口使我们能够在测试时断开实际的应用程序组件,并用模拟组件替换它们。在这些情况下,我们必须在基类中稍微不同地公开功能:
private IEmailManager emailManager;
...
public BaseViewModel(IEmailManager emailManager)
{
this.emailManager = emailManager; }
}
...
public IEmailManager EmailManager
{
get { return emailManager; }
}
这里的基本思路是我们不直接与当前的手头管理类接触,而是通过接口方法和属性来访问其功能。通过这样做,我们能够将管理类与其使用的视图模型解耦,从而使得它们可以独立使用。请注意,这是一个非常简单的依赖注入示例。
实现依赖注入
依赖注入是一种众所周知的设计模式,有助于解耦应用程序的各个组件。如果一个类使用另一个类来执行某些内部功能,那么被内部使用的类就成为了使用它的类的依赖。没有它,它无法实现其目标。在某些情况下,这可能不是问题,但在其他情况下,它可能代表一个巨大的问题。
例如,让我们假设我们有一个FeedbackManager类,它负责向最终用户提供操作反馈。在这个类中,我们有一个FeedbackCollection实例,它持有当前显示给当前用户的Feedback对象。在这里,Feedback对象是FeedbackCollection实例的依赖,而反过来,它是FeedbackManager类的依赖。
这些对象之间都是紧密耦合的,这在软件开发中通常是不好的事情。然而,由于必要性,它们之间也是紧密相关的。没有Feedback对象,FeedbackCollection对象将毫无用处,同样,FeedbackManager对象也是如此。
在这个特定的情况下,这些对象需要这种耦合才能使它们共同有用。这被称为组合,其中各个部分形成一个整体,但各自单独作用很小,因此它们以这种方式连接实际上并不成问题。
另一方面,现在让我们考虑我们的视图模型和我们的数据访问层(DAL)之间的联系。我们的视图模型肯定需要访问一些数据,所以最初看起来在视图模型中封装一个提供所需数据的类的做法似乎是合理的。
虽然这当然可以工作,但不幸的是,它会导致数据访问层(DAL)类成为视图模型类的依赖项。此外,它将永久地将我们的视图模型组件与数据访问层耦合起来,破坏了 MVVM 提供的关注点分离。在这种情况下,我们需要的连接更像是聚合,其中各个部分单独就有用。
在这些情况下,我们希望能够单独使用各个组件,并避免它们之间有任何紧密耦合。依赖注入是我们可以使用的一种工具,为我们提供这种分离。在绝对最简单的术语中,依赖注入是通过使用接口实现的。我们已经在 分离数据访问层 部分的 DataController 类和一些基本示例中看到了这一点,以及上一节中的 EmailManager 示例。
然而,它们只是非常基础的示例,有各种方法可以改进它们。许多应用程序框架将提供开发人员使用依赖注入将依赖项注入其类的能力,我们也可以用我们的方法做到同样的事情。在其最简单的形式中,我们的 DependencyManager 类只需注册依赖项并提供在需要时解决它们的方法。让我们看看:
using System;
using System.Collections.Generic;
namespace CompanyName.ApplicationName.Managers
{
public class DependencyManager
{
private static DependencyManager instance;
private static Dictionary<Type, Type> registeredDependencies =
new Dictionary<Type, Type>();
private DependencyManager() { }
public static DependencyManager Instance
{
get { return instance ?? (instance = new DependencyManager()); }
}
public int Count
{
get { return registeredDependencies.Count; }
}
public void ClearRegistrations()
{
registeredDependencies.Clear();
}
public void Register<S, T>() where S : class where T : class
{
if (!typeof(S).IsInterface) throw new ArgumentException("The S
generic type parameter of the Register method must be an
interface.", "S");
if (!typeof(S).IsAssignableFrom(typeof(T))) throw
new ArgumentException("The T generic type parameter must be a
class that implements the interface specified by the S generic
type parameter.", "T");
if (!registeredDependencies.ContainsKey(typeof(S)))
registeredDependencies.Add(typeof(S), typeof(T));
}
public T Resolve<T>() where T : class
{
Type type = registeredDependencies[typeof(T)];
return Activator.CreateInstance(type) as T;
}
public T Resolve<T>(params object[] args) where T : class
{
Type type = registeredDependencies[typeof(T)];
if (args == null || args.Length == 0)
return Activator.CreateInstance(type) as T;
else return Activator.CreateInstance(type, args) as T;
}
}
}
你可能已经注意到我们再次使用了 Singleton 模式来处理这个类。在这种情况下,它再次完全符合我们的要求。我们希望只有一个实例被实例化,并且我们希望它在应用程序运行期间保持活跃。在测试时,它用于将模拟依赖项注入视图模型,因此它是使我们的关注点分离成为可能的框架的一部分。
Count 属性和 ClearRegistrations 方法在测试时比在运行应用程序时更有用,因为真正的操作发生在 Register 和 Resolve 方法中。Register 方法注册由 S 泛型类型参数表示的接口类型,该接口的具体实现由 T 泛型类型参数表示。
由于 S 泛型类型参数必须是接口,如果提供的类型参数类不是接口,则在运行时会抛出 ArgumentException。然后执行进一步检查,以确保由 T 泛型类型参数指定的类型实际上实现了由 S 泛型类型参数指定的接口,如果检查失败,则抛出进一步的 ArgumentException。
然后,该方法验证提供类型参数的事实,即它尚未在 Dictionary 中,如果它是集合中的唯一项,则添加它。因此,在这个特定的实现中,我们只能为每个提供的接口指定一个具体实现。我们可以将其更改为在再次传递现有类型时更新存储的引用,或者甚至为每个接口存储多个具体类型。这完全取决于应用程序的需求。
注意这个方法上声明的通用类型约束,它确保类型参数至少是类。不幸的是,没有这样的约束可以让我们指定特定的通用类型参数应该是一个接口。然而,这种类型的参数验证应该在可能的情况下使用,因为它有助于我们的框架用户避免使用这些方法的不适当值。
Resolve 方法使用一些简单的反射来返回由用于泛型类型参数的通用类型参数表示的接口类型的具体实现。再次注意,这两个方法声明的通用类型约束,指定用于类型 T 参数的类型必须是类。这是为了防止 Activator.CreateInstance 方法在运行时抛出 Exception,如果使用了无法实例化的类型。
第一个重载可用于没有任何构造函数参数的类,第二个有一个额外的 params 输入参数,用于在实例化需要构造函数参数的类时传递参数。
DependencyManager 类可以在应用程序启动时设置,使用 App.xaml.cs 文件。为此,我们首先需要在 App.xaml 文件的顶部 Application 声明中找到以下 StartupUri 属性设置:
StartupUri="MainWindow.xaml"
我们需要将这个 StartupUri 属性设置替换为以下 Startup 属性设置:
Startup="App_Startup"
在这个例子中,App_Startup 是我们希望在启动时调用的初始化方法的名称。请注意,由于 WPF 框架不再启动 MainWindow 类,现在这是我们的责任:
using System.Windows;
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName
{
public partial class App : Application
{
public void App_Startup(object sender, StartupEventArgs e)
{
RegisterDependencies();
new MainWindow().Show();
}
private void RegisterDependencies()
{
DependencyManager.Instance.ClearRegistrations();
DependencyManager.Instance.Register<IDataProvider,
ApplicationDataProvider>();
DependencyManager.Instance.Register<IEmailManager, EmailManager>();
DependencyManager.Instance.Register<IExcelManager, ExcelManager>();
DependencyManager.Instance.Register<IWindowManager, WindowManager>();
}
}
}
当我们希望在运行时将这些依赖项注入到应用程序的 View Model 中时,我们可以像这样使用 DependencyManager 类:
UsersViewModel viewModel =
new UsersViewModel(DependencyManager.Instance.Resolve<IEmailManager>(),
DependencyManager.Instance.Resolve<IExcelManager>(),
DependencyManager.Instance.Resolve<IWindowManager>());
这个系统的真正美在于,当测试我们的 View Model 时,我们可以注册我们的模拟管理类。然后,相同的代码将解析接口到它们的模拟具体实现,从而让我们的 View Model 从它们的实际依赖中解脱出来:
private void RegisterMockDependencies()
{
DependencyManager.Instance.ClearRegistrations();
DependencyManager.Instance.Register<IDataProvider, MockDataProvider>();
DependencyManager.Instance.Register<IEmailManager, MockEmailManager>();
DependencyManager.Instance.Register<IExcelManager, MockExcelManager>();
DependencyManager.Instance.Register<IWindowManager, MockWindowManager>();
}
我们现在已经看到了代码,它使我们能够在测试应用程序时用模拟实现替换我们的依赖类。然而,我们也看到并非所有管理类都需要这样做。那么,究竟什么是依赖呢?让我们看看一个涉及 UI 弹出消息框的简单例子:
using CompanyName.ApplicationName.DataModels.Enums;
namespace CompanyName.ApplicationName.Managers.Interfaces
{
public interface IWindowManager
{
MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon);
}
}
这里,我们有一个声明单个方法的接口。这是开发人员在需要从视图模型类中在 UI 中显示消息框时将调用的方法。它在运行时将使用真实的MessageBox对象,但该对象使用System.Windows命名空间中的多个枚举。
我们希望避免在视图模型中与这些枚举实例交互,因为这将需要添加对PresentationFramework程序集的引用,并将我们的视图模型绑定到我们视图组件的一部分。
因此,我们需要从我们的接口方法定义中抽象它们。在这种情况下,我们只是将PresentationFramework程序集中的枚举替换为我们域中的自定义枚举,这些枚举仅仅复制了原始值。因此,在这里展示这些自定义枚举的代码几乎没有意义。
虽然复制代码从来都不是一个好主意,但将像PresentationFramework这样的 UI 程序集添加到我们的ViewModels项目中更是糟糕。通过在Managers项目中封装这个程序集并转换其枚举,我们可以暴露我们需要的功能,而无需将其绑定到我们的视图模型:
using System.Windows;
using CompanyName.ApplicationName.Managers.Interfaces;
using MessageBoxButton =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxButton;
using MessageBoxButtonSelection =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxButtonSelection;
using MessageBoxIcon =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxIcon;
namespace CompanyName.ApplicationName.Managers
{
public class WindowManager : IWindowManager
{
public MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon)
{
System.Windows.MessageBoxButton messageBoxButtons;
switch (buttons)
{
case MessageBoxButton.Ok: messageBoxButtons =
System.Windows.MessageBoxButton.OK; break;
case MessageBoxButton.OkCancel: messageBoxButtons =
System.Windows. MessageBoxButton.OkCancel; break;
case MessageBoxButton.YesNo: messageBoxButtons =
System.Windows.MessageBoxButton.YesNo; break;
case MessageBoxButton.YesNoCancel: messageBoxButtons =
System.Windows.MessageBoxButton.YesNoCancel; break;
default: messageBoxButtons =
System.Windows.MessageBoxButton.OKCancel; break;
}
MessageBoxImage messageBoxImage;
switch (icon)
{
case MessageBoxIcon.Asterisk:
messageBoxImage = MessageBoxImage.Asterisk; break;
case MessageBoxIcon.Error:
messageBoxImage = MessageBoxImage.Error; break;
case MessageBoxIcon.Exclamation:
messageBoxImage = MessageBoxImage.Exclamation; break;
case MessageBoxIcon.Hand:
messageBoxImage = MessageBoxImage.Hand; break;
case MessageBoxIcon.Information:
messageBoxImage = MessageBoxImage.Information; break;
case MessageBoxIcon.None:
messageBoxImage = MessageBoxImage.None; break;
case MessageBoxIcon.Question:
messageBoxImage = MessageBoxImage.Question; break;
case MessageBoxIcon.Stop:
messageBoxImage = MessageBoxImage.Stop; break;
case MessageBoxIcon.Warning:
messageBoxImage = MessageBoxImage.Warning; break;
default: messageBoxImage = MessageBoxImage.Stop; break;
}
MessageBoxButtonSelection messageBoxButtonSelection =
MessageBoxButtonSelection.None;
switch (MessageBox.Show(message, title, messageBoxButtons,
messageBoxImage))
{
case MessageBoxResult.Cancel: messageBoxButtonSelection =
MessageBoxButtonSelection.Cancel; break;
case MessageBoxResult.No: messageBoxButtonSelection =
MessageBoxButtonSelection.No; break;
case MessageBoxResult.OK: messageBoxButtonSelection =
MessageBoxButtonSelection.Ok; break;
case MessageBoxResult.Yes: messageBoxButtonSelection =
MessageBoxButtonSelection.Yes; break;
}
return messageBoxButtonSelection;
}
}
}
我们从using指令开始,并进一步查看使用别名指令的示例。在这种情况下,我们创建了一些与System.Windows命名空间中相同的枚举类。为了避免添加CompanyName.ApplicationName.DataModels.Enums命名空间的标准using指令可能引起的冲突,我们添加了别名,以便我们只需使用我们命名空间中所需的类型。
在此之后,我们的WindowManager类只是将 UI 相关的枚举值转换为我们自定义枚举,这样我们就可以使用消息框的功能,而不会绑定到其实现。想象一下我们需要用它来输出错误消息的情况:
WindowManager.ShowMessageBox(errorMessage, "Error", MessageBoxButton.Ok,
MessageBoxIcon.Error);
当执行到达这一点时,将弹出一个消息框,显示带有错误图标和标题的错误消息。应用程序将在此处冻结,等待用户反馈。如果用户没有在弹出窗口中点击按钮,它将无限期地保持冻结状态。如果在单元测试期间执行到达这一点而没有用户点击按钮,那么我们的测试将无限期地冻结,永远不会完成。
在这个例子中,WindowManager类依赖于有一个用户存在来与之交互。因此,如果视图模型直接使用这个类,它们也会有同样的依赖。其他类可能依赖于电子邮件服务器、数据库或其他类型的资源,例如。这些是视图模型应该仅通过接口与之交互的类。
通过这样做,我们提供了使用我们的组件独立于彼此的能力。使用我们的IWindowManager接口,我们能够独立于最终用户使用我们的ShowMessageBox方法。这样,我们能够打破用户依赖,在没有它们的情况下运行我们的单元测试。我们的接口模拟实现可以简单地每次都返回一个肯定响应,程序执行可以继续不受干扰:
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.Managers.Interfaces;
namespace Test.CompanyName.ApplicationName.Mocks.Managers
{
public class MockWindowManager : IWindowManager
{
public MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon)
{
switch (buttons)
{
case MessageBoxButton.Ok:
case MessageBoxButton.OkCancel:
return MessageBoxButtonSelection.Ok;
case MessageBoxButton.YesNo:
case MessageBoxButton.YesNoCancel:
return MessageBoxButtonSelection.Yes;
default: return MessageBoxButtonSelection.Ok;
}
}
}
}
这个简单的例子展示了从源公开功能到我们的视图模型的方法,但又不使其成为依赖。通过这种方式,我们可以向视图模型提供整个系列和多样的能力,同时仍然使它们能够独立地运行。
我们现在拥有了构建功能到我们的应用程序框架中的知识和工具,但我们对应用程序框架的探索仍然并不完整。另一个重要的问题是连接我们的视图与我们的视图模型。我们需要决定我们的框架用户应该如何做这件事,所以让我们看看一些选择。
连接视图与视图模型
在 WPF 中,有几种方法可以将我们的视图连接到它们的数据源。我们都见过视图在其代码背后将其DataContext属性设置为自身的最简单方法的例子:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = this;
}
}
然而,这仅应用于快速演示,绝不应在我们的实际应用中使用。如果我们需要将数据绑定到在视图代码背后声明的属性,比如说针对一个特定的自定义UserControl,那么我们应该使用RelativeSource绑定。我们将在第四章,精通数据绑定中了解更多关于这一点,但就目前而言,让我们继续探讨连接视图与其数据源的其他替代方法。
下一个最简单的方法利用了内置在 WPF 框架中的数据模板模型。这个主题也将在第四章,精通数据绑定中更详细地介绍,但简而言之,DataTemplate用于通知 WPF 框架我们希望它如何渲染特定类型的数据对象。简单的例子展示了我们如何定义User对象的视觉输出:
<DataTemplate DataType="{x:Type DataModels:User}">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
在这个例子中,DataType 属性指定了这与哪种类型的对象相关联,因此,包含的 XAML 绑定可以访问哪些属性。为了简单起见,我们现在只输出这个 DataTemplate 中每个 User 的名称。当我们将一个或多个 User 对象数据绑定到位于此 DataTemplate 范围内的 UI 控件时,WPF 框架将它们各自渲染为指定其名称的 TextBlock。
当 WPF 框架的渲染引擎遇到一个自定义数据对象时,它会寻找为其类型声明的 DataTemplate,如果找到了,它就会根据相关模板中包含的 XAML 来渲染该对象。这意味着我们可以为我们的视图模型类创建一个 DataTemplate,它只需指定其相关的视图类作为渲染输出:
<DataTemplate DataType="{x:Type ViewModels:UsersViewModel}">
<Views:UsersView />
</DataTemplate>
在这个例子中,我们指定当 WPF 框架看到我们的 UserViewModel 类的实例时,它应该将其渲染为我们 UserView 类之一。此时,它将我们的视图模型实例隐式地设置为相关视图的 DataContext 属性。这种方法的不利之处很小,我们只需为每个视图-视图模型对添加一个新的 DataTemplate 到我们的 App.xaml 文件中。
这种连接方法首先处理视图模型,其中我们提供视图模型实例,WPF 框架负责其余部分。在这些情况下,我们通常使用一个 ContentControl,其 Content 属性数据绑定到一个 ViewModel 属性,该属性被设置为应用程序的视图模型。WPF 框架记录设置的视图模型类型,并按照其指定的 DataTemplate 进行渲染:
private BaseViewModel viewModel;
public BaseViewModel ViewModel
{
get { return viewModel; }
set { viewModel = value; NotifyPropertyChanged(); }
}
...
ViewModel = new UserViewModel();
...
<ContentControl Content="{Binding ViewModel}" />
对于许多人来说,这是视图到视图模型连接的首选版本,因为 WPF 框架被留给处理大多数细节。然而,还有另一种构建这些连接的方法,它为这个过程添加了一层抽象。
定位视图模型
对于这种方法,我们需要为每个视图模型创建接口。这被称为视图模型定位,它与我们已经看到的依赖注入示例相当相似。事实上,我们甚至可以使用我们现有的 DependencyManager 来实现类似的结果。让我们先快速看一下:
DependencyManager.Instance.Register<IUserViewModel, UserViewModel>();
...
public partial class UserView : UserControl
{
public UserView()
{
InitializeComponent();
DataContext = DependencyManager.Instance.Resolve<IUserViewModel>();
}
}
...
<Views:UsersView />
在这个例子中,我们在一些初始化代码中将 IUserViewModel 接口与该接口的 UserViewModel 具体实现相关联,然后在设置它为视图的 DataContext 值之前解决依赖关系。在声明我们的视图后,它们在运行时自动将自己连接到相关的视图模型。
这种将视图连接到视图模型的方法首先处理视图,其中我们声明视图,它实例化自己的视图模型并设置自己的 DataContext。这种方法的不利之处在于,我们必须为所有视图模型创建接口,并使用 DependencyManager 注册和解析每个接口。
与视图模型定位器的实现相比,主要区别在于定位器为我们从 Singleton 类提供了一层抽象,这使得我们能够间接地从 XAML 中实例化我们的视图模型,而不使用代码后端。它们还具有一些额外的特定功能,允许在设计时使用占位数据。让我们看看最简单的例子:
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class ViewModelLocator
{
public IUserViewModel UserViewModel
{
get { return DependencyManager.Instance.Resolve<IUserViewModel>(); }
}
}
}
在这里,我们有一个非常基础的视图模型定位器,它只是简单地定位一个单一的视图模型。这个视图模型类必须有一个空构造函数,以便可以从 XAML 中实例化它。让我们看看我们如何做到这一点:
<UserControl x:Class="CompanyName.ApplicationName.Views.UserView"
xmlns:ViewModelLocators="clr-namespace:
CompanyName.ApplicationName.Views.ViewModelLocators"
Height="30" Width="300">
<UserControl.Resources>
<ViewModelLocators:ViewModelLocator x:Key="ViewModelLocator" />
</UserControl.Resources>
<UserControl.DataContext>
<Binding Path="UserViewModel"
Source="{StaticResource ViewModelLocator}" />
</UserControl.DataContext>
<TextBlock Text="{Binding User.Name}" />
</UserControl>
作为旁注,你可能已经注意到我们的 ViewModelLocator 类是在 Views 项目中声明的。这个类的位置并不重要,但它必须引用 ViewModels 和 Views 项目,这严重限制了它可以存在的项目数量。通常,只有 Views 项目和启动项目会访问这两个项目中的类。
回到我们的例子,ViewModelLocator 类的实例是在视图的 Resources 部分声明的,并且这只有在我们有无参构造函数的情况下才会工作(包括如果我们没有显式声明构造函数,系统为我们声明的默认无参构造函数)。如果没有无参构造函数,我们在 Visual Studio 设计器中将会收到错误。
我们的视图这次在 XAML 中设置了它自己的 DataContext 属性,使用绑定路径从我们的 ViewModelLocator 资源到 UserViewModel 属性。然后,该属性利用我们的 DependencyManager 来解析 IUserViewModel 接口的具体实现,并将其返回给我们。
使用这种模式还有其他好处。WPF 开发者经常遇到的一个问题是,Visual Studio WPF 设计器无法解析用于支持其具体实现的接口,在设计时也无法访问应用程序的数据源。结果是,设计器通常不会显示无法解析的数据项。
我们可以用我们的 ViewModelLocator 资源做的一件事是提供具有从其属性返回的占位数据的模拟视图模型,我们可以使用这些数据来帮助我们可视化我们构建的视图。为了实现这一点,我们可以利用来自 DesignerProperties .NET 类的 IsInDesignMode 附加属性:
public bool IsDesignTime
{
get { return
DesignerProperties.GetIsInDesignMode(new DependencyObject()); }
}
在这里,DependencyObject 对象是由附加属性所必需的,实际上,它就是被检查的对象。由于所有提供在这里的对象都会返回相同的值,我们可以自由地每次使用一个新的对象。如果我们担心这个属性会被垃圾回收器调用得更频繁,我们可以选择只为此目的使用一个单一成员:
private DependencyObject dependencyObject = new DependencyObject();
public bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(dependencyObject); }
}
然而,如果我们只是为了这个目的需要一个DependencyObject对象,那么我们可以通过从DependencyObject类扩展我们的ViewModelLocator类并使用它本身作为所需的参数来进一步简化事情。当然,这意味着我们的类将继承不需要的属性,所以有些人可能更喜欢避免这样做。让我们看看我们如何可以使用这个属性在设计时为 WPF 设计器提供模拟数据:
using System.ComponentModel;
using System.Windows;
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class ViewModelLocator : DependencyObject
{
public bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(this); }
}
public IUserViewModel UserViewModel
{
get
{
return IsDesignTime ? new MockUserViewModel() :
DependencyManager.Instance.Resolve<IUserViewModel>();
}
}
}
}
如果你查看我们的UserViewModel属性,你会看到我们返回的值现在取决于IsDesignTime属性的值。例如,如果我们处于设计时,例如当视图文件在 WPF 设计器中打开时,则将返回MockUserViewModel类。然而,在运行时,将返回我们注册到DependencyManager的IUserViewModel接口的具体实现。
MockUserViewModel类通常会在其属性中硬编码一些模拟数据,并在请求时返回这些数据。以这种方式,WPF 设计器将能够在开发者或设计师构建视图时可视化数据。
然而,每个视图将需要在我们的定位器类中有一个新属性,并且我们需要为每个属性复制从前面的代码中获取的此条件运算符语句。正如在 OOP 中始终所做的那样,我们可以进一步抽象化以隐藏实现,从而让将使用我们的框架的开发者不必关心。我们可以为我们的 ViewModel 定位器创建一个泛型基类:
using System.ComponentModel;
using System.Windows;
using CompanyName.ApplicationName.Managers;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public abstract class BaseViewModelLocator<T> : DependencyObject
where T : class
{
private T runtimeViewModel, designTimeViewModel;
protected bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(this); }
}
public T ViewModel
{
get { return IsDesignTime ?
DesignTimeViewModel : RuntimeViewModel; }
}
protected T RuntimeViewModel
{
get { return runtimeViewModel ??
(runtimeViewModel = DependencyManager.Instance.Resolve<T>()); }
}
protected T DesignTimeViewModel
{
set { designTimeViewModel = value; }
get { return designTimeViewModel; }
}
}
}
我们首先声明一个抽象类,它接受一个泛型类型参数,该参数表示我们正在尝试定位的 ViewModel 的接口类型。再次注意,在泛型类型参数上声明的泛型类型约束,指定使用的类型必须是一个类。这是现在所必需的,因为此类调用DependencyManager类的Resolve方法,并且在该方法上声明了相同的约束。
我们有两个与 ViewModel 接口相关联的内部成员,它们支持具有相同名称的属性。有一个是为我们的运行时 ViewModel 准备的,还有一个是为我们的设计时 ViewModel 准备的。同类型的第三个 ViewModel 属性是我们将从视图绑定到的那一个,它使用我们的IsDesignTime属性来确定返回哪个 ViewModel。
在这个类中的一个很好的特性是它为开发者做了很多连接工作。他们不需要关心IsDesignTime属性的实现,并且这个基类甚至会尝试自动解决运行时 ViewModel 属性的实体 ViewModel 依赖。因此,开发者只需为每个 ViewModel 声明以下代码即可利用此功能:
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class UserViewModelLocator : BaseViewModelLocator<IUserViewModel>
{
public UserViewModelLocator()
{
DesignTimeViewModel = new MockUserViewModel();
}
}
}
它可以以与我们原始定位器版本几乎相同的方式在 UI 中设置:
<UserControl x:Class="CompanyName.ApplicationName.Views.UserView"
...
<UserControl.Resources>
<Locators:UserViewModelLocator x:Key="ViewModelLocator" />
</UserControl.Resources>
<UserControl.DataContext>
<Binding Path="ViewModel" Source="{StaticResource ViewModelLocator}" />
</UserControl.DataContext>
...
</UserControl>
注意,尽管这应该在 Visual Studio 的新版本中自动工作,但你可能需要在旧版本中为 WPF 设计器提供帮助。mc:Ignorable属性指定了在标记文件中遇到的哪些 XAML 命名空间前缀可以被 XAML 处理器忽略,而dXAML 命名空间由设计器使用,因此我们可以在设计时直接指定DataContext位置:
mc:Ignorable="d" d:DataContext="{Binding ViewModel,
Source={StaticResource ViewModelLocator}}"
虽然这种安排有明显的优势,但像往常一样,我们必须权衡任何此类抽象的成本是否值得其带来的好处。对于一些人来说,提取接口、声明一个用于设计时的模拟版本,并为每个视图模型创建一个视图模型定位器的成本肯定值得设计可视化其数据的视图的好处。
对于其他人来说,这可能根本不值得。每次我们添加一个抽象层,我们就需要做更多的工作才能达到相同的目标。我们需要决定每个抽象在我们自己的情况下是否可行,并据此构建我们的应用程序框架。
摘要
我们现在已经调查了拥有应用程序框架的好处,并开始构建我们自己的框架。我们发现了很多种不同的方法来将所需的功能封装到我们的框架中,并且知道在什么情况下使用哪一种。在探索了多个管理类之后,我们也开始从外部来源公开功能,但不会与它们绑定。
我们设法维护和改进了应用程序所需的关注点分离,现在应该能够将各种应用程序组件分离出来,并独立于彼此运行。我们还能在设计时为我们的视图设计者提供模拟数据,同时在运行时保持松散耦合。
在下一章中,我们将彻底研究数据绑定这一基本主题,它是 MVVM 模式极少数要求之一。我们将全面覆盖各种绑定语法,包括长手和短手表示法,发现为什么在某些时候绑定无法工作,并更好地理解如何以我们想要的方式显示我们的数据。
第四章:成为数据绑定的熟练者
在本章中,我们将研究用于将我们的数据源连接到我们的 UI 控件的数据绑定语法。我们将检查如何声明依赖属性,以及我们在做这件事时所有可用的各种选项。我们将了解声明的绑定的范围,并揭示数据模板的细节。
正是 WPF 中的数据绑定使得它能够与 MVVM 模式如此良好地工作。它提供了视图和视图模型组件之间双向通信的连接。然而,这种抽象往往会导致困惑,并使得追踪问题比使用传统的 UI 到业务逻辑通信方法更困难。
由于数据绑定是 MVVM 模式如此重要的一个部分,我们将全面介绍这个主题,从基础知识到高级概念,并确保我们能够满足我们可能收到的任何绑定要求。
数据绑定基础
在 WPF 中,我们使用 Binding 类来创建我们的绑定。一般来说,可以说每个绑定都包含四个组成部分。现在让我们来看看它们:
-
第一个是绑定源;通常,这将是我们的视图模型之一。
-
第二个是从我们想要数据绑定的源对象属性路径。
-
第三个是绑定目标;这通常是一个 UI 控件。
-
第四个是我们想要数据绑定的绑定目标的属性路径。
如果我们的某个绑定不起作用,最可能的原因是以下四个设置中有一个没有正确设置。重要的是要强调,目标属性通常来自 UI 控件,因为有一个数据绑定规则指出,绑定目标必须是依赖属性。大多数 UI 控件的属性都是依赖属性,因此,这个规则只是简单地强制数据通常从我们的视图模型数据源流向绑定目标 UI 控件。
我们将在本章后面部分检查数据绑定数据的传输方向,但首先让我们专注于用于指定 Binding.Path 属性值的语法。
绑定路径语法
绑定可以声明为长格式,即在 XAML 中定义实际的 Binding 元素,或者声明为短格式,使用标记语言,该语言由 XAML 为我们转换为 Binding 元素。我们将主要关注短格式,因为这是我们将在整本书中主要使用的格式。
Binding.Path 属性的类型是 PropertyPath。此类型支持一种独特的语法,可以使用 XAML 标记扩展在 XAML 中表示。虽然有时可能会令人困惑,但我们可以学习一些特定的规则,使其更容易理解。让我们来调查一下。
首先,让我们理解绑定路径是相对于绑定源而言的,并且绑定源通常由DataContext属性或路径本身设置。为了绑定到整个绑定源,我们可以这样指定我们的绑定:
{Binding Path=.}
它也可以这样指定:
{Binding .}
最简单的情况下,我们可以这样指定我们的绑定:
{Binding}
注意,当路径值首先声明时,在这个语法中显式声明Path属性名是可选的。前面的三个例子都是相等的。为了简洁,本书中的绑定将省略Path属性声明。现在让我们看看剩余的属性路径语法迷你语言。
要绑定到大多数属性路径,我们使用与代码中相同的表示法。例如,当直接绑定到数据绑定对象的属性时,我们只需使用属性名:
{Binding PropertyName}
要绑定到由绑定源的属性直接引用的对象的属性,我们再次使用与代码中相同的语法。这被称为间接属性定位:
{Binding PropertyName.AnotherPropertyName}
类似地,当绑定到集合中的项或集合项的属性时,我们使用代码中的索引表示法。例如,这是我们如何访问数据绑定源中第一个项的属性:
{Binding [0].PropertyName}
当然,如果我们想访问第二个项,我们使用键1,如果我们想访问第三个项,我们使用键值2,依此类推。同样,要间接定位集合项的属性,其中集合是绑定源的属性,我们使用以下语法:
{Binding CollectionPropertyName[0].PropertyName}
如您所见,我们可以自由地组合这些不同的语法选项来生成更复杂的绑定路径。多维集合的访问方式与我们在代码中引用它们的方式相同:
{Binding CollectionPropertyName[0, 0].PropertyName}
{Binding CollectionPropertyName[0, 0, 0].PropertyName}
...
在讨论绑定到集合时,请注意,我们可以使用特殊的斜杠(/)语法来在任何时候访问选中项:
{Binding CollectionPropertyName/PropertyName}
这个特定的例子将绑定到由CollectionPropertyName属性指定的集合当前项的PropertyName属性。让我们快速看一下一个更实际的例子:
<StackPanel>
<ListBox ItemsSource="{Binding Users}"
IsSynchronizedWithCurrentItem="True" />
<TextBlock Text="Selected User's Name:" />
<TextBlock Text="{Binding Users/Name}" />
</StackPanel>
在这个基本示例中,我们使用UsersViewModel将Users集合绑定到一个列表框中。下面,我们输出当前选中项的Name属性值。注意IsSynchronizedWithCurrentItem属性的设置,因为没有它,这个斜杠绑定将无法正确工作。
尝试从示例中移除IsSynchronizedWithCurrentItem属性并再次运行应用程序,您将看到当前用户的名称最初会被输出,但在选中项更改后不会更新。
将此属性设置为True将确保每次选择更改时,ListBox.Items集合的ItemCollection.CurrentItem属性都会更新。请注意,我们也可以使用ListBox.SelectedItem属性而不是这个正斜杠表示法来达到相同的效果:
<StackPanel>
<ListBox Name="ListBox" ItemsSource="{Binding Users}"
IsSynchronizedWithCurrentItem="True" />
<TextBlock Text="Selected User's Name:" />
<TextBlock Text="{Binding SelectedItem.Name, ElementName=ListBox}" />
</StackPanel>
IsSynchronizedWithCurrentItem属性现在不再需要更新TextBlock中选定的用户名称,因为SelectedItem属性将负责这一点。然而,在这种情况下将其设置为True将确保ListBox中的第一个项目被选中,并且TextBlock将最初输出该项目的用户名称。让我们继续查看正斜杠表示法。
如果您正在尝试将数据绑定到集合中某个项目的属性,其中该集合本身是父集合的一个项目,我们可以在单个绑定路径中使用正斜杠表示法多次:
{Binding CollectionPropertyName/InnerCollectionPropertyName/PropertyName}
为了澄清,此路径将绑定到由InnerCollectionPropertyName属性指定的集合中选定项目的PropertyName属性,该属性本身是由CollectionPropertyName属性指定的集合的选定项目。
让我们不再关注集合,而是转向附加属性。为了将数据绑定到附加属性,我们需要使用与代码中使用的语法略有不同的语法;我们需要将属性名放在括号中,包括类名:
{Binding (ClassName.PropertyName)}
注意,当附加属性是自定义声明的属性时,我们必须在括号内包含 XAML 命名空间前缀,并用冒号分隔:
{Binding (XmlNamespacePrefix:ClassName.PropertyName)}
通常,当我们绑定到附加属性时,我们还需要指定绑定目标和目标属性。绑定目标通常是设置绑定的对象,或者另一个 UI 元素,因此在这些情况下我们倾向于看到使用RelativeSource或ElementName属性:
{Binding Path=(Attached:TextBoxProperties.Label),
RelativeSource={RelativeSource AncestorType={x:Type TextBox}}}
我们将在本书的后面部分看到这个示例的扩展版本,但简而言之,它绑定到父控件的TextBoxProperties.Label附加属性,该控件类型为TextBox。它是在ControlTemplate内部调用的,因此父文本框是正在数据绑定的控件的模板父控件。
转义无效字符
当使用PropertyPath语法迷你语言时,偶尔我们可能需要转义语法中使用的某些字符。一般来说,反斜杠(\)用作转义字符,我们只需要转义以下字符。
在我们的绑定路径中可能需要转义的最常见的字符是闭合花括号(}),它表示标记部分的结束。此外,如果您需要在绑定路径中使用实际的反斜杠,那么您必须通过在其前面加上另一个反斜杠来转义它。
我们需要转义的唯一其他两个字符是等号(=)和逗号字符(,),这两个字符都用于定义绑定路径。我们可能在绑定路径中使用的所有其他字符都被认为是有效的。
注意,如果我们需要在索引器绑定表达式中转义一个字符,有一个特殊的字符可以使用。在这些情况下,我们不需要使用反斜杠字符,而是需要使用 caret 字符 (^) 作为转义字符。
还要注意,当我们显式地在 XAML 中声明绑定时,我们需要通过将它们替换为其 XML 实体形式来转义 ampersand (&) 和大于号 (>)。如果您需要使用这些字符,那么将 ampersand 替换为 &,将大于号替换为 >。
探索 Binding 类
Binding 类具有比我们在这里讨论的空间更多的属性,但我们将很快详细讨论最重要的属性,并简要地看看其他值得注意的属性。Binding 类是每个绑定的顶级类,但内部它使用一个更低级别的类来维护绑定源和绑定目标之间的连接。
BindingExpression 类是那个底层对象。当使用 MVVM 模式时,开发者通常不会访问这个内部类,因为我们倾向于将功能保持在我们的 View Models 中。然而,如果我们正在编写自定义控件,那么了解它可能是有用的。
它可以在某些情况下用于程序化地更新相关的绑定源,我们将在本章后面了解到这一点。现在,让我们专注于 Binding 类能为我们做什么。
在 .NET 4.5 中,Binding 类增加了一个非常棒的新属性。Delay 属性允许我们指定一个以毫秒为单位的延迟时间,在修改绑定目标属性值后,用于延迟更新绑定源。
如果我们在执行一些依赖于用户在 TextBox 元素中的输入的重计算验证或其他处理时,这非常有用。为了进一步阐明这个功能,这个延迟实际上在数据绑定属性值每次改变时,或者在我们例子中的每次按键时都会重新启动。它通常用于分块更新绑定源,每次用户暂停或完成输入时,有点像缓冲:
<TextBox Text="{Binding Description,
UpdateSourceTrigger=PropertyChanged, Delay=400}" />
FallbackValue 属性是性能方面另一个有用的属性。为了从每个绑定返回一个值,WPF 框架最多执行四件事。第一件事是简单地验证目标属性类型与数据绑定值。如果成功,它将尝试解析绑定路径。
大多数情况下,这将有效,但如果不行,它将尝试找到一个转换器来返回值。如果找不到转换器,或者找到的转换器返回DependencyProperty.UnsetValue值,它将接着查看FallbackValue属性是否有值可以提供。如果没有备用值,则需要查找目标依赖属性的默认值。
通过设置FallbackValue属性,我们可以以轻微的方式提高性能,尽管只能做两件事。第一是,它将阻止 WPF 框架查找目标依赖属性的默认值。第二是,它将防止跟踪语句被发送到 Visual Studio 的输出窗口和任何其他已设置的跟踪输出。
TargetNullValue属性与FallbackValue属性类似,因为它允许我们在没有从绑定源绑定值时提供一些输出。区别在于,当数据绑定值无法解决时,FallbackValue属性值被输出,而当成功解决的数据绑定值为null时,使用TargetNullValue属性值。
我们可以使用此功能显示比null更人性化的值,或者甚至在我们的文本框控件中提供默认消息。为此,我们可以将数据绑定的string属性设置为null,并将适当值设置到TargetNullValue属性:
<TextBox Text="{Binding Name, TargetNullValue='Please enter your name'}" />
当然,此消息将实际出现在TextBox控件中,因此这不是提供此功能的好方法。我们将在本书后面的部分看到更好的例子,但现在,让我们继续探索Binding类。
如果我们的视图模型中有任何异步访问其数据的属性,或者如果它们是通过一个计算密集型过程计算的,那么我们需要在绑定上设置IsAsync方法为True:
<Image Source="{Binding InternetSource, IsAsync=True,
FallbackValue='pack://application:,,,/CompanyName.ApplicationName;
component/Images/Default.png'}" />
这将阻止用户界面在等待数据绑定属性计算或解决时被阻塞。在绑定源解决之前,如果设置了备用值,则使用备用值;否则,将使用默认值。在这个例子中,我们提供了一个默认图像,直到从互联网下载实际图像并解决绑定源为止。
Binding类的另一个有用属性是StringFormat属性。正如其名称所暗示的,它内部使用string.Format方法来格式化我们的数据绑定文本输出。然而,使用此功能有一些注意事项。首先,我们只能使用单个格式项,即正常绑定中的单个数据绑定值。我们将在本章后面了解如何使用多个值。
其次,我们需要仔细声明我们的格式,因为花括号被标记扩展所使用,我们不能使用双引号字符("),因为绑定已经在双引号内声明。一个解决方案是使用单引号来包围我们的格式字符串:
<TextBlock Text="{Binding Price, StringFormat='{0:C2}'}" />
另一个选项是使用一对花括号来跳出格式:
<TextBlock Text="{Binding Price, StringFormat={}{0:C2}}" />
大多数有用的绑定属性现在都已在此讨论,但应注意的是,Binding类中有一些属性在构建使用 MVVM 的 WPF 应用程序时通常不使用。这是因为它们涉及事件处理程序,而我们通常在使用 MVVM 时不会实现事件处理程序。
例如,三个NotifyOnSourceUpdated、NotifyOnTargetUpdated和NotifyOnValidationError属性与Binding.SourceUpdated、Binding.TargetUpdated和Validation.Error附加事件的引发有关。
同样,三个ValidatesOnDataErrors、ValidatesOnExceptions、ValidatesOnNotifyDataErrors和ValidationRules属性都与ValidationRule类的使用有关。这是一种非常与 UI 相关的验证方式,但这将我们的业务逻辑直接放入我们的视图组件中。
当使用 MVVM 时,我们希望避免组件的这种混合。因此,我们倾向于使用数据元素而不是 UI 元素,所以我们在这我们的数据模型和/或视图模型类中执行这些类型的任务。我们将在本书的第九章中看到这一点,实现响应式数据验证,稍后,现在让我们更深入地看看Binding类最重要的属性。
指导数据绑定流量
每个绑定中数据遍历的方向由Binding.Mode属性指定。BindingMode枚举中声明了四个不同的方向实例,以及一个额外的值。让我们首先看看这些方向值及其代表的意义。
第一个也是最常见的数据值反映了最常见的情况,即数据从绑定源流向绑定目标,例如,我们的视图模型之一,到 UI 控件。这种绑定模式称为单向,由OneWay枚举实例指定。这种模式主要用于仅显示或只读目的,以及数据绑定值无法在 UI 中更改的情况。
下一个最常见的旅行方向由TwoWay枚举实例表示,表示数据可以自由地从我们的视图模型流向 UI 控件,也可以在相反方向上。这是在将数据绑定到表单控件时最常用的模式,当我们希望用户的更改反映在我们的视图模型中时。
第三个方向枚举实例是OneWayToSource实例,它是OneWay实例的反面。也就是说,它指定数据只能从绑定目标(由 UI 控件表示)流向绑定源,例如,我们的一些视图模型之一。这种模式也适用于捕获用户输入的日期,当我们不需要更改绑定值的数据时。
最后一个方向实例与OneWay实例类似,但它只工作一次,并由OneTime实例表示。虽然这种模式确实只工作一次,但在其包含控件的实例化时,它实际上还会在每次设置相关绑定的DataContext属性时更新其值。然而,它的目的是提供比OneWay成员更好的性能,并且仅适用于绑定到非更改数据,因此如果数据将被更新,这不是正确的方向实例。
最后一个实例命名为Default,正如其名所暗示的,是Binding.Mode枚举的默认值。它指示绑定使用从指定目标属性声明的绑定模式。当每个依赖属性被声明时,我们可以指定是否默认使用单向或双向绑定模式。如果没有特别声明,则该属性将被分配一个单向模式。我们将在本章后面更详细地解释这一点。
对不同源的绑定
我们通常使用FrameworkElement.DataContext属性来设置绑定源。所有 UI 控件都扩展了FrameworkElement类,因此我们可以在它们中的任何一个上设置我们的绑定源。这是绑定能够工作所必需的,尽管它可以在Path属性中指定,或者从祖先控件继承,因此不需要显式设置。请看这个简单的例子,它假设在父控件上已经正确设置了合适的绑定源:
<StackPanel>
<TextBlock DataContext="{Binding User}" Text="{Binding Name}" />
<TextBlock DataContext="{Binding User}" Text="{Binding Age}" />
</StackPanel>
在这里,我们将第一个TextBlock的绑定源设置为User对象,并将路径设置为从该源到Name属性。第二个也是这样设置,但绑定源路径指向Age属性。请注意,我们已经将每个TextBox控件上的DataContext属性设置为User对象。
虽然这是完全有效的 XAML,你可以想象在大型表单中为每个我们想要进行数据绑定的控件这样做是多么的麻烦。因此,我们倾向于利用DataContext属性可以从其任何祖先控件继承其值的事实。通过这种方式,我们可以通过在父控件上设置DataContext来简化代码:
<StackPanel DataContext="{Binding User}">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Age}" />
</StackPanel>
实际上,在开发每个Window或UserControl时,通常会在这些顶级控件上设置DataContext,以便每个包含的控件都可以访问相同的绑定源。这就是为什么我们为每个Window或UserControl创建一个ViewModel,并指定每个ViewModel负责提供其相关视图所需的所有数据和功能。
除了设置DataContext属性之外,还有几种指定绑定源的方法。一种方法是使用绑定的Source属性,这使我们能够显式地覆盖从父DataContext继承的绑定源,如果设置了的话。使用Source属性,我们也能够将数据绑定到资源,就像我们在我们的ViewModel定位器示例中看到的那样,或者绑定到静态值,如下面的代码片段所示:
<TextBlock Text="{Binding Source={x:Static System:DateTime.Today},
Mode=OneTime, StringFormat='{}© {0:yyyy} CompanyName'}" />
另一种方法涉及使用绑定的RelativeSource属性。使用这种极其有用的RelativeSource类型属性,我们可以指定我们想要使用目标控件,或者该控件的父控件作为绑定源。
它还使我们能够覆盖DataContext中的绑定源,并且在尝试从DataTemplate元素绑定到ViewModel属性时通常至关重要。让我们调整之前为我们的User数据模型创建的DataTemplate,以便输出由DataTemplate设置的普通DataContext中的属性,以及由父控件的DataContext设置的ViewModel中的属性,使用RelativeSource类的AncestorType属性:
<DataTemplate DataType="{x:Type DataModels:User}">
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding DataContext.UserCount,
RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type Views:UserView}}}" />
</StackPanel>
</DataTemplate>
注意,设置指定绑定源相对于绑定目标的相对位置的Mode属性在这里是可选的。使用AncestorType属性隐式地将Mode属性设置为FindAncestor实例,因此我们可以不使用它来声明相同的绑定,如下所示:
<TextBlock Text="{Binding DataContext.UserCount,
RelativeSource={RelativeSource
AncestorType={x:Type Views:UserView}}}" />
Mode属性是RelativeSourceMode枚举类型,它有四个成员。我们已经看到了一个实例的例子,即FindAncestor成员,尽管这可以通过相关的RelativeSource.AncestorLevel属性进行扩展,该属性指定了要查找绑定源的祖先级别。这个属性只有在控件有多个相同类型的祖先时才有实际意义,如下面的简化示例所示:
<StackPanel Tag="Outer">
...
<StackPanel Orientation="Horizontal" Tag="Inner">
<TextBlock Text="{Binding Tag, RelativeSource={RelativeSource
Mode=FindAncestor, AncestorType={x:Type StackPanel},
AncestorLevel=2}}" />
...
</StackPanel>
</StackPanel>
在这个例子中,TextBox将在运行时输出单词"Outer",因为我们已经声明绑定源应该是StackPanel类型的第二个祖先。如果将AncestorLevel属性设置为 1 或从绑定中省略,那么TextBox将在运行时输出单词"Inner"。
下一个RelativeSourceMode枚举实例是Self,它指定绑定源与绑定目标相同。请注意,当使用RelativeSource.Self属性时,Mode属性会隐式设置为Self实例。我们可以使用此属性将 UI 控件的一个属性绑定到另一个,如下面的例子所示,它将控件的宽度值设置为它的Height属性,以确保它保持正方形,无论宽度如何:
<Rectangle Height="{Binding ActualWidth,
RelativeSource={RelativeSource Self}}" Fill="Red" />
RelativeSource.TemplatedParent属性仅用于从ControlTemplate内部访问控件的属性。模板父级是指应用了ControlTemplate的对象。当使用TemplatedParent属性时,Mode属性会隐式设置为RelativeSourceMode枚举的TemplatedParent实例。让我们看一个例子:
<ControlTemplate x:Key="ProgressBar" TargetType="{x:Type ProgressBar}">
...
<TextBlock Text="{Binding Value,
RelativeSource={RelativeSource TemplatedParent}}" />
...
</ControlTemplate>
在这个例子中,模板父级是应用此模板的ProgressBar实例,因此,使用TemplatedParent属性,我们能够从ControlTemplate内部访问ProgressBar类的各种属性。此外,任何绑定到模板父级Value属性的绑定源也将绑定到这个内部TextBox元素的Text属性。
接下来是最后的RelativeSource属性,PreviousData在定义集合中项的DataTemplate时非常有用。它用于将集合中的前一个项设置为绑定源。虽然不常用,但在某些情况下,我们需要比较集合中相邻项的值,我们将在本章后面看到一个完整的例子。
尽管有一个更简单的选项,但Binding类的ElementName属性也允许我们覆盖由DataContext设置的绑定源。它用于将一个 UI 控件的一个属性绑定到另一个控件的属性,或者同一控件上的另一个属性。使用此属性的唯一要求是我们需要在我们当前控件中命名我们想要数据绑定的元素。让我们看一个例子:
<StackPanel Orientation="Horizontal" Margin="20">
<CheckBox Name="Checkbox" Content="Service" Margin="0,0,10,0" />
<TextBox Text="{Binding Service}"
Visibility="{Binding IsChecked, ElementName=Checkbox,
Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
在这个例子中,我们有一个CheckBox元素和一个TextBlock元素。TextBlock元素的Visibility属性绑定到CheckBox元素的IsChecked属性,并且我们使用了之前看到的BoolToVisibilityConverter类将bool值转换为Visibility实例。因此,当用户勾选CheckBox元素时,TextBlock元素将变为可见。
ElementName属性也可以用作访问父控件的DataContext的快捷方式。如果我们把我们的视图命名为This,例如,那么我们就可以在数据模板中使用ElementName属性来绑定到父视图模型的一个属性:
<DataTemplate DataType="{x:Type DataModels:User}">
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding DataContext.UserCount, ElementName=This}" />
</StackPanel>
</DataTemplate>
当指定这些替代绑定源时,重要的是要知道我们一次只能使用这三种不同方法中的一种。如果我们设置绑定Source、RelativeSource或ElementName属性中的多个,那么绑定将抛出一个异常。
带优先级的绑定
在偶尔的情况下,我们可能需要指定多个源绑定路径,并将它们映射到单个绑定目标属性。我们可以做到这一点的一种方法是通过使用MultiBinding类,我们将在本章的最后部分看到一个例子。然而,还有一个我们可以使用的替代类,它为我们提供了一些额外的功能。
PriorityBinding类使我们能够指定多个绑定,并为每个绑定指定一个优先级,声明最早的绑定具有最高的优先级。这个类的特殊功能是,它将显示第一个返回有效值的绑定值,如果不是最高优先级的绑定,那么当它成功解析时,它将更新显示为最高优先级绑定的值。
为了进一步说明,这使我们能够指定一个绑定到正常属性,它将立即解析,而我们要数据绑定的实际值正在下载、计算或以其他方式在一段时间内被解决。这使得我们可以在实际所需的图像正在下载时提供默认图像源,或者在一个计算值准备好显示之前输出一条消息。让我们看看一个简单的 XAML 示例:
<TextBlock>
<TextBlock.Text>
<PriorityBinding>
<Binding Path="SlowString" IsAsync="True" />
<Binding Path="FastString" Mode="OneWay" />
</PriorityBinding>
</TextBlock.Text>
</TextBlock>
在前面的例子中,我们在TextBlock.Text属性上设置了PriorityBinding,并在其中指定了两个绑定。第一个具有更高的优先级,并具有我们想要显示的实际属性值。请注意,我们将IsAsync属性设置为True,以指定此绑定将需要一些时间来解析,并且它不应该阻塞 UI 线程。
第二个绑定使用单向绑定绑定到一个正常属性,简单地输出一条消息:
public string FastString
{
get { return "The value is being calculated..."; }
}
通过使用PriorityBinding元素,这条消息将立即输出,并在准备好时更新为SlowString属性的实际值。现在让我们继续前进,调查Binding类的一种更进一步的类型。
控制模板内的绑定
TemplateBinding是一种特定的绑定类型,用于在ControlTemplate元素内将数据绑定到正在模板化的类型的属性。它与我们在前面讨论的RelativeSource.TemplatedParent属性非常相似:
<ControlTemplate x:Key="ProgressBar" TargetType="{x:Type ProgressBar}">
...
<TextBlock Text="{TemplateBinding Value}" />
...
</ControlTemplate>
在这个例子中,我们稍作编辑,我们看到声明一个TemplateBinding比使用RelativeSource.TemplatedParent属性执行相同的绑定要简单得多。让我们回忆一下它看起来是什么样子:
<TextBlock Text="{Binding Value,
RelativeSource={RelativeSource TemplatedParent}}" />
如果可能的话,通常更倾向于使用TemplateBinding而不是RelativeSource.TemplatedParent属性,尽管它们在绑定中执行相同的连接,但它们之间有一些差异。例如,TemplateBinding是在编译时评估的,这使控制模板的实例化更快,而TemplatedParent绑定则是在运行时才进行评估。
此外,它是一种更简单的绑定形式,缺少了Binding类的一些属性,例如StringFormat和Delay。此外,它对用户施加了额外的约束,即它永久设置为具有OneWay绑定模式,并且绑定目标和绑定源都必须是依赖属性。它被设计用于单一位置和单一目的,在这种情况下,它比其对应物做得更好,更有效率。
绑定源变更
有时,我们可能需要更改我们的绑定源,并使这些更改传播到绑定目标控件。我们可能希望在新的表单上设置默认值,清除旧表单值,甚至从我们的视图模型设置表单标签。为了做到这一点,我们的视图模型必须实现INotifyPropertyChanged接口,这也是为什么我们将此实现构建到我们的基视图模型类中的原因。
当我们在 UI 中将绑定源数据绑定到控件时,会为源对象的PropertyChanged事件附加一个事件处理器。当接收到绑定源属性路径指定的属性变更通知时,控件会使用新值进行更新。
应该注意的是,如果未特别附加处理器并且其任何属性都没有绑定到 UI 控件,绑定源的PropertyChanged事件将是null。正因为如此,我们在引发此事件之前必须始终检查null。
所有的绑定模式都是绑定源到绑定目标的方向,除了OneWayToSource实例。然而,只有这个和Binding.Mode枚举的TwoWay实例会传播绑定目标到绑定源方向的变化。
当绑定以这两种模式中的任何一种工作的时候,它会为目标控件附加一个处理器来监听目标属性的变化。当它收到目标属性变更的通知时,其行为由绑定UpdateSourceTrigger属性的值决定。
此属性是枚举类型UpdateSourceTrigger,它有四个成员。最常见的是PropertyChanged实例,这表示源属性应在目标属性更改后立即更新。这是大多数控件默认的值。
LostFocus成员是下一个最常见的值,它指定当用户将焦点从数据绑定的控件移开时,应该更新绑定源。当用户在每个文本框中完成输入后触发验证而不是在输入时,这个选项可能很有用。
Explicit实例不会在没有明确指令的情况下更新绑定源。由于我们需要以编程方式调用内部BindingExpression对象的UpdateSource方法来传播更改到绑定源,因此这个选项通常在我们的正常视图中不使用。
相反,如果确实使用了它,我们会在我们的CustomControl类中找到它。请注意,如果绑定模式没有设置为OneWayToSource或TwoWay实例之一,调用UpdateSource方法将不会做任何事情。
如果我们有一个文本框的实例,并且我们想要显式更新绑定到其Text属性的绑定源,我们可以从BindingOperations.GetBindingExpression方法访问低级别的BindingExpression对象,并调用它的UpdateSource方法:
BindingExpression bindingExpression =
BindingOperations.GetBindingExpression(textBox, TextBox.TextProperty);
bindingExpression.UpdateSource();
或者,如果我们的绑定目标控件类扩展了FrameworkElement类(大多数都是这样),那么我们可以直接调用它的GetBindingExpression方法,并传入我们想要更新绑定的依赖属性键:
textBox.GetBindingExpression(TextBox.TextProperty);
UpdateSourceTrigger枚举的最后一个成员是Default实例。这与Binding.Mode枚举的Default实例类似,因为它使用每个目标依赖属性的指定值,并且是UpdateSourceTrigger属性的默认值。同样,我们将在本章后面了解到如何设置依赖属性的元数据。
转换数据绑定值
在开发 WPF 应用程序时,我们经常需要将数据绑定的属性值转换为不同的类型。例如,我们可能希望使用 ViewModel 中的bool属性来控制一些 UI 元素的可见性,这样我们就可以避免在 UI 相关的Visibility枚举实例中包含它。
我们可能想要将不同的枚举成员转换为不同的Brush对象,或将集合转换为包含集合项的字符串表示。我们已经看到了许多IValueConverter接口的示例,但现在让我们更彻底地看看:
public interface IValueConverter
{
object Convert(object value, Type targetType, object parameter,
CultureInfo culture);
object ConvertBack(object value, Type targetType, object parameter,
CultureInfo culture);
}
正如我们已经看到的,类型为object的value输入参数是绑定的数据绑定值。object返回类型与我们要返回的转换后的值相关。targetType输入参数指定绑定目标属性的类型,通常用于验证输入值以确保转换器正在使用预期的数据类型。
parameter 输入参数可选地用于将额外值传递给转换器。如果使用,其值可以使用 Binding.ConverterParameter 属性设置。最后,culture 输入参数为我们提供了一个 CultureInfo 对象,以便在文化敏感的应用程序中正确格式化文本输出。我们稍后会回到这一点,但首先让我们看看一个使用 parameter 输入参数的转换器示例:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(Enum), typeof(bool))]
public class EnumToBoolConverter : IValueConverter
{
public bool IsInverted { get; set; }
public object Convert(object value, Type targetType, object parameter,
CultureInfo culture)
{
if (value == null || parameter == null || (value.GetType() !=
typeof(Enum) && value.GetType().BaseType != typeof(Enum)))
return DependencyProperty.UnsetValue;
string enumValue = value.ToString();
string targetValue = parameter.ToString();
bool boolValue = enumValue.Equals(targetValue,
StringComparison.InvariantCultureIgnoreCase);
return IsInverted ? !boolValue : boolValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || parameter == null)
return DependencyProperty.UnsetValue;
bool boolValue = (bool)value;
string targetValue = parameter.ToString();
if ((boolValue && !IsInverted) || (!boolValue && IsInverted))
return Enum.Parse(targetType, targetValue);
return DependencyProperty.UnsetValue;
}
}
}
这个转换器的想法是,我们可以将枚举属性数据绑定到一个指定特定成员名称的 RadioButton 或 CheckBox 控件。如果数据绑定属性的值与指定的成员匹配,则转换器将返回 true 并选中控件。对于所有其他枚举成员,控件将不会被选中。然后我们可以在一组 RadioButton 控件中的每个控件中指定不同的成员,以便每个成员都可以设置。
在这个类中,我们首先在 ValueConversion 属性中指定了涉及转换器实现的 ValueConversion 数据类型。接下来,我们看到 IsInverted 属性,它在 BaseVisibilityConverter 类中出现过,使我们能够反转转换器的输出。
在 Convert 方法中,我们首先检查 value 和 parameter 输入参数的有效性,如果任一无效,则返回 DependencyProperty.UnsetValue 值。对于有效值,我们将两个参数转换为它们的 string 表示形式。然后我们通过比较两个 string 值创建一个 bool 值。一旦我们有了 bool 值,我们就用它结合 IsInverted 属性来返回输出值。
与我们之前的枚举转换器示例一样,ConvertBack 方法的实现又有所不同,因为我们无法为假值返回正确的枚举实例;它可以是除了由 parameter 输入参数指定的值之外的任何值。
因此,我们只能在数据绑定值为 true 且 IsInverted 属性为 false,或者为 false 且 IsInverted 属性为 true 的情况下返回指定的枚举实例。对于所有其他输入值,我们简单地返回 DependencyProperty.UnsetValue 属性,这是属性系统比 null 值更倾向于的。
让我们看看这个方法在实际应用中的例子,我们将使用之前章节中看到的 BitRate 枚举。让我们首先看看简单的视图模型:
using System.Collections.ObjectModel;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.ViewModels
{
public class BitRateViewModel : BaseViewModel
{
private ObservableCollection<BitRate> bitRates =
new ObservableCollection<BitRate>();
private BitRate bitRate = BitRate.Sixteen;
public BitRateViewModel()
{
bitRates.FillWithMembers();
}
public ObservableCollection<BitRate> BitRates
{
get { return bitRates; }
set { if (bitRates != value) { bitRates = value;
NotifyPropertyChanged(); } }
}
public BitRate BitRate
{
get { return bitRate; }
set { if (bitRate != value) { bitRate = value;
NotifyPropertyChanged(); } }
}
}
}
这个类只包含一个类型为 BitRate 的集合,它将包含所有可能的成员和一个类型为 BitRate 的选择属性,我们将使用我们的新转换器将这个属性数据绑定到各种 RadioButton 元素。
注意在构造函数中使用了 FillWithMembers 扩展方法。让我们首先看看这个方法:
public static void FillWithMembers<T>(this ICollection<T> collection)
{
if (typeof(T).BaseType != typeof(Enum))
throw new ArgumentException("The FillWithMembers<T> method can only be
called with an enum as the generic type.");
collection.Clear();
foreach (string name in Enum.GetNames(typeof(T)))
collection.Add((T)Enum.Parse(typeof(T), name));
}
在FillWithMembers扩展方法中,我们首先检查被该方法调用的集合是否为枚举类型,如果不是,则抛出ArgumentException。然后我们清除集合,以防它包含任何预存在的项。最后,我们遍历Enum.GetNames方法的结果,将每个string名称解析到相关的枚举成员,并将其转换为正确的类型,然后将其添加到集合中。
现在让我们看看视图的 XAML:
<UserControl x:Class="CompanyName.ApplicationName.Views.BitRateView"
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters">
<UserControl.Resources>
<Converters:EnumToBoolConverter x:Key="EnumToBoolConverter" />
</UserControl.Resources>
<GroupBox Header="Audio Quality" HorizontalAlignment="Left"
VerticalAlignment="Top" Padding="5">
<StackPanel>
<RadioButton Content="16 bits" IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter},
ConverterParameter=Sixteen}" VerticalContentAlignment="Center" />
<RadioButton Content="24 bits" IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter}, ConverterParameter=
TwentyFour}" VerticalContentAlignment="Center" />
<RadioButton Content="32 bits" IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter},
ConverterParameter=ThirtyTwo}" VerticalContentAlignment="Center" />
</StackPanel>
</GroupBox>
</UserControl>
在这个视图中,我们设置了Converters XAML 命名空间前缀,然后在Resources部分声明了EnumToBoolConverter类的实例。然后我们声明了一个包含三个RadioButton元素的StackPanel,这些RadioButton元素使用资源中的转换器绑定到视图模型中的相同BitRate属性。
每个按钮在其绑定的ConverterParameter属性中指定不同的枚举成员,并通过parameter输入参数传递给转换器。如果选中RadioButton,则其真实值传递给转换器,并转换为ConverterParameter值指定的值,BitRate属性将更新为该值。此代码的输出如下所示:

注意,如果我们有很多枚举成员,或者成员经常变动,像这个例子一样在 UI 中手动声明每一个可能不是好主意。在这些情况下,我们可以利用DataTemplate对象以更少的劳动生成相同的 UI。我们将在本章后面看到这个例子,但现在,让我们回到转换器的输入参数。
在Convert和ConvertBack方法的最终输入参数是CultureInfo类型的culture参数。在非国际化应用程序中,我们可以简单地忽略此参数,但是如果在您的应用程序中全球化扮演了角色,那么使用此参数是必不可少的。
这使我们能够使用object.ToString方法正确格式化转换器中可能有的任何文本输出,并使其与应用程序中的其他文本保持一致。我们还可以在Convert类的各种方法中使用它,以确保数字也以正确的格式正确输出。全球化超出了本书的范围,所以我们现在继续前进。
将多个源绑定到单个目标属性
在 WPF 中,还有另一种更常见的方法,可以同时将数据绑定到多个绑定源,并将各种值转换为单个输出值。为了实现这一点,我们需要使用一个MultiBinding对象,并结合实现IMultiValueConverter接口的类。
MultiBinding类使我们能够声明多个绑定源和一个单一的绑定目标。如果MultiBinding类的Mode或UpdateSourceTrigger属性被设置,那么它们的值将被包含的binding元素继承,除非它们明确设置了不同的值。
来自多个绑定源的价值可以通过两种方式之一组合;它们的string表示形式可以使用StringFormat属性输出,或者我们可以使用一个实现了IMultiValueConverter接口的类来生成输出值。这个接口与IValueConverter接口非常相似,但它与多个数据绑定值一起工作。
在实现IMultiValueConverter接口时,我们不会设置我们在创建的IValueConverter实现中习惯设置的ValueConversion属性。
在我们需要实现的Convert方法中,来自IValueConverter接口的value输入参数类型object被一个名为values的object数组所取代,该数组包含我们的输入值。
在ConvertBack方法中,我们有一个类型为Type的数组,用于绑定目标的类型,以及一个类型为object的数组,用于返回类型。除了这些细微的差异之外,这两个接口是相同的。让我们通过一个例子来帮助澄清这种情况。
想象一个场景,一个医疗保健应用程序需要显示患者随时间变化的体重测量值。如果我们可以输出一个指标,指示每个连续测量值是高于还是低于前一个值,以突出任何不健康趋势,那将是有帮助的。
这可以通过使用前面提到的RelativeSource.PreviousData属性、一个MultiBinding对象和一个IMultiValueConverter类来实现。让我们首先看看我们如何实现IMultiValueConverter接口:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
public class HigherLowerConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType,
object parameter, CultureInfo culture)
{
if (values == null || values.Length != 2 ||
!(values[0] is int currentValue) ||
!(values[1] is int previousValue))
return DependencyProperty.UnsetValue;
return currentValue > previousValue ? "->" : "<-";
}
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, CultureInfo culture)
{
return new object[2] { DependencyProperty.UnsetValue,
DependencyProperty.UnsetValue };
}
}
}
我们从对输入值的常规验证开始我们的实现。在这个特定的转换器中,我们期望两个int类型的值,因此我们使用 C# 6.0 模式匹配来验证这些值,然后再继续。如果有效,我们比较我们两个预转换的值,根据比较的结果返回适当的基于string的方向箭头。
由于我们的示例中不需要ConvertBack方法,我们只需返回一个包含两个DependencyProperty.UnsetValue值的object数组。接下来,让我们快速看一下我们的视图模型:
using System.Collections.Generic;
namespace CompanyName.ApplicationName.ViewModels
{
public class WeightMeasurementsViewModel : BaseViewModel
{
private List<int> weights =
new List<int>() { 90, 89, 92, 91, 94, 95, 98, 99, 101 };
public List<int> Weights
{
get { return weights; }
set { weights = value; NotifyPropertyChanged(); }
}
}
}
在这里,我们有一个非常简单的视图模型,只有一个字段和属性对。我们只是硬编码了一些测试值来演示。现在,让我们看看我们的视图:
<UserControl
x:Class="CompanyName.ApplicationName.Views.WeightMeasurementsView"
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
>
<UserControl.Resources>
<Converters:HigherLowerConverter x:Key="HigherLowerConverter" />
</UserControl.Resources>
<Border BorderBrush="Black" BorderThickness="1" CornerRadius="5"
HorizontalAlignment="Left" VerticalAlignment="Top">
<ItemsControl ItemsSource="{Binding Weights}" Margin="20,20,0,20">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type System:Int32}">
<StackPanel Margin="0,0,20,0">
<TextBlock Text="{Binding}" />
<TextBlock HorizontalAlignment="Center">
<TextBlock.Text>
<MultiBinding
Converter="{StaticResource HigherLowerConverter}">
<Binding />
<Binding
RelativeSource="{RelativeSource PreviousData}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</UserControl>
在 Converters XAML 命名空间前缀和 Resources 部分中声明的 HigherLowerConverter 元素之后,我们有一个带有边框的 ItemsControl,它绑定到作为此视图 DataContext 的视图模型的 Weights 属性。接下来,我们看到一个水平方向的 StackPanel 元素被用作 ItemsControl.ItemsPanel 属性的 ItemsPanelTemplate。这仅仅使得集合控件以水平方式显示项目,而不是垂直方式。
注意,在下面的 DataTemplate 对象中,我们需要指定数据类型,因此需要从 mscorlib 程序集导入 System 命名空间来引用 Int32 类型。第一个 TextBlock 中的 Text 属性的绑定指定它绑定到整个数据源对象,在这种情况下,它只是一个整数。
第二个 TextBlock 中的 Text 属性的绑定是我们使用 MultiBinding 和 IMultiValueConverter 元素的地方。我们将我们的 HigherLowerConverter 类设置为 MultiBinding 对象的 Converter 属性,并在其中指定两个 Binding 对象。第一个再次绑定到整数值,第二个使用 RelativeSource.PreviousData 属性来绑定到前一个整数值。现在让我们看看这个示例的输出:

第一个值之后的每个值下面都会显示一个箭头,指示它是否高于或低于前一个值。虽然这个示例的视觉输出可以改进,但它仍然突出了令人担忧的趋势,即样本数据末尾的重量测量值持续增加。这种有用的技术可以在任何需要比较当前数据值与前一个值的情况下使用,例如在显示股价或库存水平时。
依赖属性
我们已经在之前的章节中看到了一些依赖属性的示例,但现在让我们更深入地研究。当我们声明这些属性时,我们有大量选项可以使用,其中一些比其他更常用。让我们首先通过在名为 DurationPicker 的类中定义一个类型为 int 的 Hours 属性来调查标准的声明。
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker));
public int Hours
{
get { return (int)GetValue(HoursProperty); }
set { SetValue(HoursProperty, value); }
}
与所有依赖属性一样,我们首先将属性声明为静态和 readonly,因为我们只希望有一个单一的、不可变的实例。这也使我们能够不通过我们类的实例来访问它。
与正常的 CLR 属性不同,我们不是在支持属性的私有字段中存储我们的依赖属性值。相反,默认值直接存储在每个 DependencyProperty 对象的元数据中,而更改后的值存储在 DependencyObject 实例的单独数组中,该实例上的依赖属性值被设置。
让我们进一步澄清这一点,并记住所有内置控件都扩展了 DependencyObject 类。这意味着例如 TextBox 类中声明的 TextProperty 依赖属性更改后的值,存储在属性值被更改的实际 TextBox 实例中。这是绑定只能设置在依赖对象依赖属性上的主要原因。
每个 DependencyObject 实例中都有一个值数组存在,包含所有已显式设置在其上的已声明的依赖属性值。这是一个非常重要的点。这意味着默认情况下,如果没有更改的值,数组是空的,因此内存占用非常小。
这与 CLR 类相反,其中每个属性都有一个内存占用,无论它是否被设置。这种安排的结果是节省了大量的内存,因为只有显式设置的依赖属性值才会存储在值数组中,而默认值则直接从依赖属性对象中读取。
这个数组在 DependencyObject 类中存在的事实解释了为什么我们需要调用它的 GetValue 和 SetValue 方法来访问和设置依赖属性的值。这里的 HoursProperty 仅仅是标识符,被称为依赖属性标识符,其 GlobalIndex 属性值用于从该数组访问相关值。
注意,这个数组中的值是 object 类型,因此它可以与任何对象类型一起工作。这解释了为什么我们需要在 CLR 包装器属性的获取器中将 GetValue 方法的返回值从 object 类型转换到适当类型。现在让我们检查当我们声明依赖属性时内部会发生什么。
在 DependencyProperty 类中,有一个名为 PropertyFromName 的私有静态 Hashtable,它持有应用程序中每个已注册依赖属性的引用,并且被类的所有实例共享。为了声明每个属性并创建到 Hashtable 的键,我们使用 DependencyProperty 类的 Register 方法。
这种方法有多个重载,但它们都需要以下信息:属性的名称和类型以及声明类的类型,或者微软更愿意称之为所有者类型。让我们更深入地了解一下这个过程。
当我们使用 Register 方法之一注册依赖属性时,提供的元数据首先被验证,并在需要时替换为默认值。然后调用一个名为 RegisterCommon 的私有方法,并在其中使用一个名为 FromNameKey 的类来从依赖属性的名称和所有者类型生成唯一的键。它是通过结合调用传递给它的名称和所有者类型的 object.GetHashCode 方法的结果来创建一个唯一的哈希码来做到这一点的。
在创建 FromNameKey 对象之后,检查 PropertyFromName 集合中是否存在此键,如果其中已存在,则抛出 ArgumentException。如果它是唯一的,则从输入参数验证和设置默认元数据和默认值,如果缺失则自动生成。
在此步骤之后,实际的 DependencyProperty 实例使用 new 关键字和私有构造函数创建。然后,将此内部实例添加到 PropertyFromName Hashtable 中,使用 FromNameKey 对象作为唯一键,然后将其返回给 Register 方法的调用者,以便在公共静态 readonly Dependency Property Identifier 中本地存储。
注意,重载的 Register 方法都有一个额外的输入参数类型为 PropertyMetadata,我们将在下一节中探讨这一点。现在,让我们专注于最后一个重载,它还使我们能够将 ValidateValueCallback 处理器附加到我们的属性上。
如其名所示,这仅用于验证目的,我们无法在此方法中更改数据绑定值。相反,我们只需返回 true 或 false 以指定当前值的有效性。让我们看看我们如何将此处理器附加到我们的属性,以及其方法签名是什么:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker), new PropertyMetadata(12), ValidateHours));
private static bool ValidateHours(object value)
{
int intValue = (int)value;
return intValue > 0 && intValue < 25;
}
注意,ValidateValueCallback 委托不提供对我们类的任何引用,因此,我们无法从静态上下文中访问其其他属性。为了比较当前值与其他属性值,或者确保满足某些条件,我们可以使用 DependencyProperty.Register 方法 PropertyMetadata 输入参数的另一个重载,我们很快就会看到。但现在,让我们回到关注 PropertyMetadata 输入参数。
设置元数据
使用 PropertyMetadata 构造函数的重载,我们可以可选地为属性设置默认值,并附加在值更改或正在重新评估时被调用的处理器。现在让我们更新我们的示例,以附加一个 PropertyChangedCallback 处理器:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker), new PropertyMetadata(OnHoursChanged));
private static void OnHoursChanged(DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
// This is the signature of PropertyChangedCallback handlers
}
注意,我们的 PropertyChangedCallback 处理器也必须声明为静态,以便可以从声明的 DependencyProperty 的静态上下文中使用,如前述代码所示。然而,我们可能遇到需要调用实例方法而不是静态方法的情况,在这些情况下,我们可以声明一个匿名方法来调用我们的实例方法,如下所示:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours),
typeof(int), typeof(DurationPicker),
new PropertyMetadata((d, e) => ((DurationPicker)d).OnHoursChanged(d,e)));
private void OnHoursChanged(DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
// This is the signature of non-static PropertyChangedCallback handlers
}
由 Lambda 表达式组成的匿名方法可能会令人困惑,所以让我们首先提取相关代码:
(d, e) => ((DurationPicker)d).OnHoursChanged(d, e))
这可以重写以使示例更加清晰:
(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
=>
((DurationPicker)dependencyObject).OnHoursChanged(dependencyObject, e))
现在,我们可以清楚地看到PropertyChangedCallback处理程序的输入参数,然后是匿名方法体。在这个方法内部,我们只需将dependencyObject输入参数转换为声明类的类型,然后从类的转换实例调用非静态方法,如果需要,通过传递输入参数。
正如我们在第二章中看到的,调试 WPF 应用程序,当它们的值发生变化时,CLR 属性(提供对我们依赖属性的方便访问)不会被 WPF 框架调用。使用这个PropertyChangedCallback处理程序是我们能够在值变化时执行操作或调试变化值的方法。
PropertyMetadata构造函数的最后一个重载版本还允许我们设置一个CoerceValueCallback处理程序,这为我们提供了一个平台,以确保我们的值保持在有效范围内。与PropertyChangedCallback委托不同,它要求我们返回属性的输出值,因此这使我们能够在返回之前更改值。以下是一个简单示例,展示了我们如何调整属性值:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours),
typeof(int), typeof(DurationPicker),
new PropertyMetadata(0, OnHoursChanged, CoerceHoursValue));
...
private static object CoerceHoursValue(DependencyObject dependencyObject,
object value)
{
// Access the instance of our class from the dependencyObject parameter
DurationPicker durationPicker = (DurationPicker)dependencyObject;
int minimumValue = 1, maximumValue = durationPicker.MaximumValue;
int actualValue = (int)value;
return Math.Min(maximumValue, Math.Max(minimumValue, actualValue));
}
在这个简单的例子中,我们首先将dependencyObject输入参数进行转换,以便我们可以访问其MaximumValue属性。假设我们的DurationPicker控件可以处理 12 小时或 24 小时的时间格式,因此我们需要确定当前的上限小时数。因此,我们可以将Hours属性值限制在 1 到这个上限之间。
当使用CoerceValueCallback处理程序时,有一个特殊情况可以有效地取消值更改。如果你的代码检测到不符合要求的完全无效值,那么你可以简单地从处理程序中返回DependencyProperty.UnsetValue值。
这个值向属性系统发出信号,表示它应该丢弃当前更改并返回之前的值。你甚至可以使用这种技术有选择地阻止属性更改,直到类中的其他地方满足某个条件,例如。
这总结了我们在PropertyMetadata对象上可用的有用但相当有限的选择,尽管应该注意的是,有一些从该类派生出来的类,我们可以用它们来代替,并且每个都有自己的优点。UIPropertyMetadata类直接扩展了PropertyMetadata类,并添加了通过其IsAnimationProhibited属性禁用属性值所有动画的能力。
此外,FrameworkPropertyMetadata类进一步扩展了UIPropertyMetadata类,并为我们提供了设置属性继承、属性的默认Binding.Mode和Binding.UpdateSourceTrigger值以及影响布局的FrameworkPropertyMetadataOptions标志的能力。
让我们看看一些FrameworkPropertyMetadataOptions成员。如果我们认为大多数用户会希望使用双向数据绑定来绑定我们的属性,那么我们可以使用BindsTwoWayByDefault实例来声明它。这会将所有绑定到我们的属性的Binding.Mode从默认的OneWay成员切换到TwoWay成员:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker), new FrameworkPropertyMetadata(0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnHoursChanged,
CoerceHoursValue));
另一个常用的标志是Inherits实例,它指定属性值可以被子元素继承。想想看,可以在Window上设置的FontSize或Foreground属性,并且这些属性会被窗口内的每个控件继承。
注意,如果我们想使用这个Inherits成员来创建依赖属性,那么我们应该将其声明为一个附加属性,因为属性值继承与附加属性配合得更好。我们将在后续章节中了解更多关于这一点,但现在让我们继续。接下来是SubPropertiesDoNotAffectRender成员,它可以用来优化性能,我们将在第十二章,部署您的杰作应用程序中了解更多关于这个特定实例的信息。
最后常用的选项是AffectsArrange、AffectsMeasure、AffectsParentArrange和AffectsParentMeasure成员。这些通常用于在自定义面板或其他 UI 控件中声明的依赖属性,其中属性值会影响控件的外观,并且对它的更改需要引起视觉更新。
应当注意,这个FrameworkPropertyMetadataOptions枚举是用FlagsAttribute属性声明的,这意味着我们也可以为其实例值分配一个位运算组合,因此可以为每个依赖属性设置多个选项:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker), new FrameworkPropertyMetadata(0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault |
FrameworkPropertyMetadataOptions.AffectsMeasure, OnHoursChanged,
CoerceHoursValue));
为了设置Binding.UpdateSourceTrigger属性的默认值,我们需要使用参数最多的构造函数,传递所有六个输入参数:
public static readonly DependencyProperty HoursProperty =
DependencyProperty.Register(nameof(Hours), typeof(int),
typeof(DurationPicker), new FrameworkPropertyMetadata(0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnHoursChanged,
CoerceHoursValue, false, UpdateSourceTrigger.PropertyChanged));
注意,如果我们不需要使用回调处理程序,传递null值是完全正常的。CoerceValueCallback处理程序值后面的false设置了UIPropertyMetadata类的IsAnimationProhibited属性。这里设置的UpdateSourceTrigger值将用于所有没有在绑定上显式设置UpdateSourceTrigger属性,或者将UpdateSourceTrigger.Default成员设置为绑定属性的绑定的属性。
现在我们已经完全调查了使用DependencyProperty类的Register方法声明依赖属性时可以使用的各种选项,让我们看看这个类中的另一个注册方法。
声明只读依赖属性
通常,只读依赖属性最常见于自定义控件中,在这些情况下,我们需要将数据绑定到一个值,但又不希望它公开可访问。这可能是一个与屏幕上的视觉元素、中间计算点或前一个值相关联的属性,但通常,我们不希望我们的框架用户能够将其数据绑定到它。
让我们想象一个场景,我们想要创建一个按钮,它将使我们能够设置一个工具提示消息,当控件禁用时显示,除了正常的工具提示消息。在这种情况下,我们可以声明一个依赖属性来保存禁用时的工具提示消息,另一个来存储显示禁用工具提示时的原始工具提示值。这个原始工具提示属性是成为只读依赖属性的完美候选。让我们看看这个属性的样子:
private static readonly DependencyPropertyKey originalToolTipPropertyKey =
DependencyProperty.RegisterReadOnly("OriginalToolTip", typeof(string),
typeof(TooltipTextBox), new PropertyMetadata());
public static readonly DependencyProperty OriginalToolTipProperty =
originalToolTipPropertyKey.DependencyProperty;
public static string GetOriginalToolTip(DependencyObject dependencyObject)
{
return (string)dependencyObject.GetValue(OriginalToolTipProperty);
}
正如你所见,我们使用不同的语法来声明只读依赖属性。不是返回Register方法返回的DependencyProperty标识符,而是RegisterReadOnly方法返回一个DependencyPropertyKey对象。
此对象通常使用private访问修饰符声明,以防止它通过DependencyObject.SetValue方法在外部使用。然而,这个方法可以在注册只读属性的类中使用,以设置其值。
DependencyPropertyKey对象的DependencyProperty属性用于返回实际使用的DependencyProperty标识符,从我们之前讨论的字典中访问属性值。
RegisterReadOnly方法的输入参数与标准Register方法的选项相同,尽管有一个较少的重载。与Register方法不同,在调用RegisterReadOnly方法时,我们始终需要提供PropertyMetadata对象,尽管如果我们不需要它提供的内容,我们可以传递一个null值。
需要注意的一个非常重要的问题是,当数据绑定到一个只读依赖属性时,我们必须将绑定的Mode属性设置为OneWay枚举成员。未能这样做将在运行时导致错误。我们已经详细介绍了正常依赖属性的创建,现在让我们继续看看不同类型的依赖属性。
注册附加属性
DependencyProperty类使我们能够注册一种更进一步的、特殊的依赖属性类型。这些属性类似于 XAML 的扩展方法,因为它们使我们能够通过我们自己的功能扩展现有的类。当然,它们是附加属性。
我们已经在本书的早期部分看到了一些它们的示例,我们将在后面看到更多示例,但在这个章节中,我们将介绍它们的注册。我们可以以完全相同的方式声明附加属性,就像我们创建依赖属性一样,并且有所有相同的设置元数据和附加处理程序的各种选项。
RegisterAttached 和 RegisterAttachedReadOnly 方法有几个重载,它们在输入参数和功能上与 Register 和 RegisterReadOnly 方法相对应。然而,我们不需要为我们的附加属性声明 CLR 包装器,而是需要声明一对获取器和设置器方法来访问和设置它们的值。让我们看看 TextBoxProperties 类的另一个示例:
public static DependencyProperty IsFocusedProperty =
DependencyProperty.RegisterAttached("IsFocused",
typeof(bool), typeof(TextBoxProperties),
new PropertyMetadata(false, OnIsFocusedChanged));
public static bool GetIsFocused(DependencyObject dependencyObject)
{
return (bool)dependencyObject.GetValue(IsFocusedProperty);
}
public static void SetIsFocused(DependencyObject dependencyObject,
bool value)
{
dependencyObject.SetValue(IsFocusedProperty, value);
}
public static void OnIsFocusedChanged(DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
TextBox textBox = dependencyObject as TextBox;
if ((bool)e.NewValue && !(bool)e.OldValue && !textBox.IsFocused)
textBox.Focus();
}
在这里,我们声明了一个名为 IsFocused 的 bool 附加属性,它包含一个 PropertyMetadata 元素,用于指定默认值和 PropertyChangedCallback 处理程序。与依赖属性的 CLR 属性包装器一样,这些获取器和设置器方法不会被 WPF 框架调用。它们通常被声明为公共和静态。
然而,有一种情况下我们不需要将这些方法声明为公共的。如果我们想创建一个可以由其子元素继承值的依赖属性,那么我们应该使用 RegisterAttached 方法来声明它,即使我们不需要附加属性。在这种情况下,我们不需要公开暴露我们的属性获取器和设置器。
虽然我们可以在声明依赖属性及其值继承时指定 FrameworkPropertyMetadataOptions.Inherits 元数据选项,并且在某些情况下值继承可能有效,但在其他情况下并不保证。由于附加属性是属性系统中的全局属性,我们可以确信它们的属性值继承将在所有情况下都有效。
回到我们的示例,我们的 PropertyChangedCallback 处理程序是一个简单的事件。它将 dependencyObject 属性转换为属性附加到的控件类型,在本例中是一个 TextBox。然后它验证数据绑定的 bool 值是否已从 false 设置为 true,并且控件尚未聚焦。如果这些条件得到验证,控件随后将被聚焦。
此附加属性可以像这样绑定到视图模型中的 bool 属性:
...
<TextBox Attached:TextBoxProperties.IsFocused="{Binding IsFocused}"
Text="{Binding User.Name}" />
附加的 TextBox 控件可以通过以下方法在任何时候从视图模型中聚焦:
private void Focus()
{
IsFocused = false;
IsFocused = true;
}
注意,在将其设置为 true 之前,我们需要确保变量是 false,因为实际更改值将触发控件聚焦。现在我们已经知道了如何声明我们自己的自定义依赖属性,让我们将注意力转向管理它们设置的规则。
优先设置值来源
正如我们已经看到的,有几种方法可以设置依赖属性值;我们可以在代码中直接设置它们,在 XAML 中本地设置,或者通过使用我们的 CoerceValueCallback 处理程序,例如。然而,还有许多其他方法可以设置它们。例如,它们也可以在样式、动画或通过属性继承中设置,仅举几例。
当我们将 View Model 属性数据绑定到依赖属性,并发现显示的值不是我们期望的值时,其中一个原因可能是因为另一种设置属性的方法具有更高的优先级,因此覆盖了我们的期望值。这是因为所有设置依赖属性值的方法都按照重要性顺序排列在一个称为依赖属性设置优先级列表的列表中。现在让我们看看这个列表:
-
属性系统强制转换
-
动画属性
-
本地值
-
模板属性
-
隐式样式(仅适用于
Style属性) -
样式触发器
-
模板触发器
-
样式设置器
-
默认(主题)样式
-
继承
-
从依赖属性元数据中获取的默认值
在列表的末尾,优先级最低的是在依赖属性声明中指定的默认值。接下来是属性继承引起的变化。请记住,这可以在我们的依赖属性中使用 DependencyProperty.Register 方法的 FrameworkPropertyMetadata 输入参数中的 FrameworkPropertyMetadataOptions.Inherits 实例来定义。让我们看看一个例子来突出这个优先级顺序:
<StackPanel TextElement.FontSize="20">
<TextBlock Text="Black Text" />
<StackPanel Orientation="Horizontal" TextElement.Foreground="Red">
<TextBlock Text="Red Text" />
</StackPanel>
</StackPanel>
在这个第一个例子中,外层 StackPanel 中的 TextBlock 控件的 Foreground 颜色默认设置为黑色,这是在数据绑定的 Text 属性中设置的。然而,内层 StackPanel 中的 TextBlock 控件的默认 Foreground 属性值被其父控件的 TextElement.Foreground 附加属性值覆盖,该值设置在其父控件上。它从 StackPanel 继承了这个属性的值,这表明通过属性继承设置的属性优先级高于使用默认值设置的属性。
然而,在主题样式中设置的默认属性值在优先级列表中紧随其后,具有下一个最低的优先级,并覆盖了通过继承设置的属性值。由于很难给出一个简短的 XAML 示例来说明这一点,我们将跳过这一项,并继续下一项。在列表中排在第八位的是由样式设置器设置的属性值。让我们调整我们之前的例子来演示这一点:
<StackPanel TextElement.FontSize="20">
<TextBlock Text="Black Text" />
<StackPanel Orientation="Horizontal" TextElement.Foreground="Red">
<TextBlock Text="Red Text" Margin="0,0,10,0" />
<TextBlock Text="Green Text">
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Foreground" Value="Green" />
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</StackPanel>
在这个例子中,外层StackPanel中的TextBlock控件仍然通过数据绑定Text属性的默认值将其Foreground颜色设置为黑色。内层StackPanel中的顶部TextBlock控件仍然通过其父控件的TextElement.Foreground值覆盖了其默认的Foreground属性值。然而,现在我们还可以看到,在Style中设置的值将覆盖继承的属性值。这是此代码片段的输出:

接下来,在优先级列表的第七位,我们有模板触发器,它们覆盖了由样式设置器和之前提到的所有其他设置值方法设置的属性值。请注意,这专门处理在模板中声明的触发器,例如ControlTemplate,而不涉及在Style.Triggers集合内声明的触发器。让我们看一个例子:
<Button Content="Blue Text" FontSize="20">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<ContentPresenter />
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Foreground" Value="Blue" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>
在这个例子中,我们声明了一个按钮并覆盖了它的ControlTemplate,为它定义了一个新的、最小化的标记。在样式设置中,我们将Foreground属性值设置为绿色。然而,在我们的ControlTemplate中有一个Trigger,当其条件满足时,将覆盖这个值并将其设置为蓝色。请注意,如果我们将触发条件更改为false或删除整个触发器,按钮文本将变为绿色,这是由样式设置的。
在列表的第六位,是声明在Style.Triggers集合内的触发器。这里的一个重要点是,这仅与声明为内联本地、当前控件Resources部分或应用程序资源文件中的样式相关,而不是与具有较低优先级值的默认样式相关。我们可以通过向Style.Triggers集合中添加一个新的触发器来扩展我们之前的示例,以突出这个新优先级:
<Button Content="Orange Text" FontSize="20">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<ContentPresenter />
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Foreground" Value="Blue" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Foreground" Value="Orange" />
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
当运行这个示例时,我们的文本现在是橙色。由样式Triggers集合中的触发器设置的Foreground属性值覆盖了由模板触发器设置的值,而模板触发器本身又覆盖了由样式设置器设置的值。让我们继续。
在列表的第五位,我们有隐式样式。请注意,这个特殊的优先级级别仅适用于Style属性,不适用于其他属性。可以通过指定目标类型并在没有设置x:Key指令的情况下声明来隐式地将样式设置为类型的所有成员。以下是一个示例:
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
</Style>
相关样式必须在当前 XAML 页面中声明,或者位于App.xaml文件的Application.Resources部分。主题中的样式不包括在内,因为它们具有较低的值优先级。请注意,这个特殊位置在.NET 4 中才被添加,并且在.NET 3 的docs.microsoft.com网站上的文档中被省略。
接下来列表的第四个位置是设置在 ControlTemplate 或 DataTemplate 内部的属性。如果我们直接在模板内的任何元素上设置属性,该值将覆盖所有优先级较低的设置方法设置的所有值。例如,如果我们直接在我们的上一个示例中的 ContentPresenter 上设置 Foreground 属性,那么它的值将覆盖该示例中的所有其他设置,按钮文本将变为红色:
<ControlTemplate TargetType="{x:Type Button}">
<ContentPresenter TextElement.Foreground="Red" />
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Foreground" Value="Blue" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
在列表的第三个位置,我们有局部设置的值。为了演示这一点,我们可以在最后一个完整示例中的实际按钮上设置 Foreground 属性,但让我们强调一个很多开发者都会犯的极其常见的错误。想象一下,我们想要在大多数情况下以一种颜色输出值,但在某些情况下以另一种颜色输出。一些开发者可能会尝试这样做:
<TextBlock Text="{Binding Account.Amount, StringFormat={}{0:C}}"
Foreground="Green">
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Style.Triggers>
<DataTrigger Binding="{Binding Account.IsOverdrawn}" Value="True">
<Setter Property="Foreground" Value="Red" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
在运行这个示例时,有些人可能会期望它能够正常工作,但结果却令人困惑。这个不工作的原因是因为局部属性设置具有比由样式触发器设置的属性更高的值设置优先级。纠正这个错误的解决方案是利用我们新发现的价值设置优先级列表,并将局部属性设置移动到样式设置器,其优先级低于触发器:
<TextBlock Text="{Binding Account.Amount, StringFormat={}{0:C}}">
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Foreground" Value="Green" />
<Style.Triggers>
<DataTrigger Binding="{Binding Account.IsOverdrawn}" Value="True">
<Setter Property="Foreground" Value="Red" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
现在,TextBlock.Foreground 属性将由样式设置器设置为绿色,并在条件为真时被触发器覆盖,正如预期的那样。让我们继续向上列表到第二个位置。在倒数第二个位置,我们有由动画设置的属性值。一个非常简单的例子可以很好地向我们展示这一点:
<Rectangle Width="300" Height="300" Fill="Orange">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="Width">
<DoubleAnimation Duration="0:0:1" To="50" AutoReverse="True"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
在这个例子中,动画覆盖了局部设置的 Width 属性值,矩形按计划增长和缩小。如果我们逻辑上思考这个问题,那么很明显,动画系统必须在属性设置优先级列表中占据一个非常高的位置。否则,如果它在列表中位置较低,我们就无法进行任何动画。
然而,由动画设置的属性在列表中排在第二位,这意味着有一个地方可以设置属性,即使是由动画设置的值也可以覆盖。在依赖属性设置优先级列表中排在第一位,具有绝对最高优先级设置的是我们在 依赖属性 部分讨论过的属性强制系统。
这种情况只可能发生在我们构建了一个自定义控件,该控件动画化了一个具有特定要求的自定义依赖属性的情况下,例如指定它应该具有某个最大值或最小值。在这种情况下,我们可以在附加到依赖属性的 CoerceValueCallback 处理程序中强制执行这些规则。
如果我们有这些由属性强制系统强制执行的请求,但希望在 UI 中动画化它们,那么我们希望强制值覆盖动画设置的值,这又完全合理。这样,我们可以确信我们的强制属性值将始终在我们为其设置的范围内。
数据模板
我们已经看到了许多简单的DataTemplate示例,但它们是 WPF 的重要组成部分,因此我们现在将更深入地研究它们。简而言之,我们使用DataTemplate来定义我们希望在 UI 中渲染特定数据对象的方式*。
如果我们不提供特定类型的DataTemplate并将其数据绑定到 UI 控件,WPF 框架将不知道如何显示它。让我们用一个例子来强调这一点:
<ItemsControl ItemsSource="{Binding Users}" />
在这些情况下,WPF 框架能做的最好的事情就是显示每个对象的string表示形式。它是通过在数据对象上调用object.ToString方法并将该值设置为TextBlock的Text属性来实现的,它使用该属性来显示对象。如果此方法在对象的类中未被重写,这将导致在显示位置显示对象的类型名称:

知道 WPF 框架在显示数据对象之前会调用我们的ToString方法,使我们能够采取捷径,或者定义DataTemplate的简单替代方案,如果我们只需要在 UI 中输出文本。因此,我们始终覆盖object.ToString方法以输出一些有意义的显示是一个好主意:
public override string ToString()
{
return Name;
}
这将导致以下输出:

注意,Visual Studio IntelliSense 在显示数据对象之前也会调用我们的ToString方法,因此为其提供自定义实现的好处是双倍的。因此,我们通常在我们的基类中添加一个抽象方法,以确保所有派生类都将实现此方法:
namespace CompanyName.ApplicationName.DataModels
{
public abstract class BaseDataModel : INotifyPropertyChanged
{
...
public abstract override string ToString();
}
}
现在回到数据模板的话题,让我们首先看看我们User对象的一个更好的示例,然后调查我们可以在哪里声明我们的数据模板:
<DataTemplate x:Key="UserTemplate" DataType="{x:Type DataModels:User}">
<Border BorderBrush="Black" BorderThickness="1" CornerRadius="5"
Padding="5" Margin="0,0,0,5">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" Margin="0,0,3,0" />
<TextBlock Text="{Binding Age, StringFormat={}({0})}" />
</StackPanel>
</Border>
</DataTemplate>
在这个例子中,我们只是在TextBlock中输出用户的名字,在另一个TextBlock中输出他们的年龄。注意使用StringFormat属性将年龄括号包围在输出中。现在让我们看看这个DataTemplate是如何渲染我们的User对象的:

主要来说,我们可以在四个主要位置之一声明我们的数据模板。第一个是与将显示相关数据对象或对象的控件一致。对于这一点,我们也有两种主要的选择,具体取决于我们要显示的数据对象数量。
如果我们有一个单独的对象要显示,我们可以使用ContentControl元素来显示它,并使用ContentControl.ContentTemplate属性来定义它应该用来渲染数据对象的DataTemplate元素:
<ContentControl Content="{Binding Users[0]}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="{x:Type DataModels:User}">
...
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
类似地,在一个集合控件或ItemsControl,例如ListBox控件中,我们可以在ItemTemplate属性中直接声明我们的DataTemplate:
<ListBox ItemsSource="{Binding Users}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type DataModels:User}">
...
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
我们可以在将要显示数据对象或对象的控件的Resources部分中声明我们的数据模板。现在这是我们的ContentControl:
<ContentControl Content="{Binding Users[0]}"
ContentTemplate="{StaticResource UserTemplate}">
<ContentControl.Resources>
<DataTemplate x:Key="UserTemplate" DataType="{x:Type DataModels:User}">
...
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
我们还可以在包含显示数据对象的控件的Window或UserControl的Resources部分中声明我们的数据模板。如果我们有多个数据对象,我们可以这样设置我们的数据模板:
<UserControl.Resources>
<DataTemplate x:Key="UserTemplate" DataType="{x:Type DataModels:User}">
...
</DataTemplate>
</UserControl.Resources>
<ListBox ItemsSource="{Binding Users}"
ItemTemplate="{StaticResource UserTemplate}" />
我们可以在App.xaml文件的Application.Resources部分定义我们的数据模板。当 WPF 框架搜索特定数据类型的数据模板时,它首先搜索应用模板的控件本地的Resources部分。
如果它找不到匹配的类型,它将接着搜索父控件的Resources集合,然后是那个控件的父级,依此类推。如果它仍然找不到匹配类型的数据模板,它将搜索App.xaml页面的Application.Resources部分。
我们可以利用这种查找顺序来达到我们的目的。我们通常在App.xaml页面的Application.Resources部分声明我们的默认数据模板,因为这些资源在应用程序范围内可用。如果我们需要覆盖我们的默认数据模板,以在特定的视图中显示特定的输出,我们可以在视图的Resources部分本地声明一个新的数据模板,并使用相同的x:Key指令。
因为在搜索应用程序资源之前会先搜索本地Resources部分,所以它会使用本地声明的数据模板而不是默认模板。另一种覆盖我们的默认模板的方法是不设置它们的x:Key指令来声明它们:
<DataTemplate DataType="{x:Type DataModels:User}">
...
</DataTemplate>
以这种方式声明的资源会隐式应用于所有适当类型的、没有显式应用数据模板的数据对象。因此,为了覆盖这些默认数据模板,我们可以简单地声明一个新的数据模板,并使用其x:Key指令显式将其设置为相对模板属性。现在让我们看看指定数据模板的另一种方法。
完全控制
有时,我们可能想要根据它们的属性值以不同的方式显示相同类型的不同对象。例如,对于代表车辆的对象集合,你可能希望对不同类型的车辆有不同的显示,因为卡车和摩托艇有不同的规格。DataTemplateSelector类使我们能够做到这一点。
当扩展DataTemplateSelector类时,我们可以重写其单个SelectTemplate方法。在这个方法中,我们既提供了数据对象,也提供了数据绑定对象,可以根据数据对象的属性值选择不同的数据模板来返回。
让我们看看一个非常简单的例子,根据User的年龄返回两个数据模板之一。我们首先需要为我们的User类型声明另一个DataTemplate:
<DataTemplate x:Key="InverseUserTemplate"
DataType="{x:Type DataModels:User}">
<Border BorderBrush="White" BorderThickness="1" Background="Black"
TextElement.Foreground="White" CornerRadius="5" Padding="8,3,5,5"
Margin="0,0,0,5">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" Margin="0,0,3,0" />
<TextBlock Text="{Binding Age, StringFormat={}({0})}" />
</StackPanel>
</Border>
</DataTemplate>
在这个模板中,我们只是将背景和前景的颜色与第一个模板中的颜色进行了反转。现在让我们看看将引用这两个DataTemplate元素的DataTemplateSelector类:
using System.Windows;
using System.Windows.Controls;
using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.Views.DataTemplateSelectors
{
public class UserAgeDataTemplateSelector : DataTemplateSelector
{
public override DataTemplate SelectTemplate(object item,
DependencyObject container)
{
FrameworkElement element = container as FrameworkElement;
if (element != null && item != null && item is User user)
{
if (user.Age < 35) return
(DataTemplate)element.FindResource("InverseUserTemplate");
else return (DataTemplate)element.FindResource("UserTemplate");
}
return null;
}
}
}
在这个例子中,我们首先使用as关键字将container输入参数转换为FrameworkElement类型的对象。然后,我们对这个新对象和其他输入参数执行标准的null检查,并使用is关键字进行模式匹配以确定正确的类型,如果item参数是正确的类型,则自动将其转换为User对象。如果是,那么我们就在我们的FrameworkElement对象上调用FindResource方法,根据Age属性的值返回适当的数据模板。如果不是,则返回null。
FrameworkElement.FindResource方法首先在调用对象中搜索数据模板,然后是其父元素,依此类推,向上到逻辑树。如果在应用程序窗口的任何父元素中找不到它,它将接着在App.xaml文件中查找。如果在那里仍然找不到,它将接着在主题和系统资源中搜索。
container输入参数用于访问FindResource方法。请注意,如果我们使用的是常规集合控件,它通常将是ContentPresenter类型,因此我们可以将其转换为该类型以访问数据模板。
然而,默认容器可以被覆盖以使用ContentPresenter类派生的一个父类。因此,为了避免异常的可能性,将其转换为实际声明FindResource方法的FrameworkElement类更安全。
让我们看看我们如何使用这个类。首先,我们需要为我们的DataTemplateSelectors命名空间添加 XAML 命名空间前缀:
xmlns:DataTemplateSelectors=
"clr-namespace:CompanyName.ApplicationName.Views.DataTemplateSelectors"
然后我们需要将我们的UserAgeDataTemplateSelector类的一个实例添加到Resources部分:
<DataTemplateSelectors:UserAgeDataTemplateSelector
x:Key="UserAgeDataTemplateSelector" />
最后,我们将资源选择器设置为ItemTemplateSelector属性:
<ItemsControl ItemsSource="{Binding Users}" Padding="10"
ItemTemplateSelector="{StaticResource UserAgeDataTemplateSelector}" />
当现在运行应用程序时,我们将看到这个新的输出:

注意,DataTemplateSelector类通常与非常不同的模板一起使用,例如那些构成自定义控件不同编辑或查看模式的模板。在我们简单的例子中,这样的细微差别可以通过使用样式触发器更容易地实现,我们将在下一章中了解更多关于触发器和样式的内容。
显示分层数据
在.NET 框架中有一个类扩展了DataTemplate类,以便支持扩展HeaderedItemsControl类的 UI 控件。正如其名,HeaderedItemsControl类代表一种具有标题的特定类型的ItemsControl元素。例如包括MenuItem、TreeViewItem和ToolBar类。
HierarchicalDataTemplate类是为了显示层次化数据模型而创建的。为了进一步说明,层次化数据模型是一个包含与父对象相同类型的项的集合属性的数据模型。想象一下 Windows 资源管理器窗口中的文件夹视图;每个文件夹可以包含更多的文件夹。
HierarchicalDataTemplate类与DataTemplate类的主要区别在于,HierarchicalDataTemplate类有一个ItemsSource属性,我们可以用它来绑定每个项目的子项。
除了ItemsSource属性之外,还有许多其他与项相关的属性,例如ItemContainerStyle、ItemStringFormat和ItemTemplate属性。我们将在下一章中了解更多关于这些其他属性的功能,但现在让我们看看一个例子。
在网上可以找到许多HierarchicalDataTemplate示例,展示了如何使用TreeViewItem元素,所以在这个例子中,我们将看看如何使用数据绑定构建应用程序菜单。首先,我们需要一个视图模型来绑定到每个MenuItem控件。让我们看看我们的MenuItemViewModel类:
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace CompanyName.ApplicationName.ViewModels
{
public class MenuItemViewModel : BaseViewModel
{
private string header = string.Empty;
private ICommand command = null;
private ObservableCollection<MenuItemViewModel> menuItems =
new ObservableCollection<MenuItemViewModel>();
public string Header
{
get { return header; }
set { if (header != value) { header = value;
NotifyPropertyChanged(); } }
}
public ICommand Command
{
get { return command; }
set { if (command != value) { command = value;
NotifyPropertyChanged(); } }
}
public ObservableCollection<MenuItemViewModel> MenuItems
{
get { return menuItems; }
set { if (menuItems != value) { menuItems = value;
NotifyPropertyChanged(); } }
}
}
}
在这个简化的例子中,我们的视图模型只声明了三个属性来绑定到MenuItem控件属性。在实际应用程序中,我们通常会添加更多的属性,以便我们可以定义每个菜单项的图标或样式。然而,继续使用我们的视图模型作为例子,让我们看看将声明这些视图模型的类。
如果一个应用程序有一个菜单控制,它通常会位于MainWindow.xaml文件中。因此,数据绑定的MenuItemViewModel元素将声明在绑定到该视图数据上下文的视图模型中。让我们看看所需的属性:
private ObservableCollection<MenuItemViewModel> menuItems =
new ObservableCollection<MenuItemViewModel>();
public ObservableCollection<MenuItemViewModel> MenuItems
{
get { return menuItems; }
set { if (menuItems != value) { menuItems = value;
NotifyPropertyChanged(); } }
}
除了以编程方式声明各种菜单项视图模型之外,还可以在 XML 文件中定义项,读取它并在运行时从该文件生成项。然而,为了这个简单的例子,让我们只硬编码一些值来使用,为了简洁起见省略了命令:
MenuItems.Add(new MenuItemViewModel() { Header = "Users",
MenuItems = new ObservableCollection<MenuItemViewModel>() {
new MenuItemViewModel() { Header = "Details",
MenuItems = new ObservableCollection<MenuItemViewModel>() {
new MenuItemViewModel() { Header = "Banking" },
new MenuItemViewModel() { Header = "Personal" } } },
new MenuItemViewModel() { Header = "Security" } } });
MenuItems.Add(new MenuItemViewModel() { Header = "Administration" });
MenuItems.Add(new MenuItemViewModel() { Header = "View" });
MenuItems.Add(new MenuItemViewModel() { Header = "Help",
MenuItems = new ObservableCollection<MenuItemViewModel>() {
new MenuItemViewModel() { Header = "About" } } });
虽然这段代码读起来有些困难,但它比单独声明每个子项然后构建层次结构要紧凑得多。最终结果是一样的,所以现在让我们看看所需的 XAML 是什么样的:
<Menu ItemsSource="{Binding MenuItems}" FontSize="14" Background="White">
<Menu.ItemContainerStyle>
<Style TargetType="{x:Type MenuItem}">
<Setter Property="Command" Value="{Binding Command}" />
</Style>
</Menu.ItemContainerStyle>
<Menu.ItemTemplate>
<HierarchicalDataTemplate
DataType="{x:Type ViewModels:MenuItemViewModel}"
ItemsSource="{Binding MenuItems}">
<TextBlock Text="{Binding Header}" />
</HierarchicalDataTemplate>
</Menu.ItemTemplate>
</Menu>
在这里,我们声明了一个Menu控件,并将我们的MenuItems集合数据绑定到其ItemsSource属性。ItemContainerStyle使我们能够定义围绕我们每个数据项的 UI 容器的样式。在这种情况下,该控件是一个MenuItem控件。
在这种风格中,我们只需要将我们的视图模型的Command属性绑定到菜单项的Command属性。如果我们已经在我们的视图模型中声明了任何其他属性以映射到MenuItem类的属性,那么这种风格就是数据绑定的地方。
如前所述,ItemTemplate属性使我们能够提供一个数据模板,或者在这个例子中,我们的HierarchicalDataTemplate元素,这将定义每个项目如何被渲染。在模板声明中,我们声明了我们的数据项的类型,并指定了包含子项的集合属性。
在模板内部,我们简单地输出Header属性的值到一个TextBlock元素中。这将代表每个菜单项的名称。现在让我们看看当应用程序运行时这一切将看起来是什么样子:

将数据绑定到枚举集合
我们已经看到了许多将数据绑定到枚举实例的例子。我们看到了我们可以使用的转换器来转换我们的枚举值,以及我们可以用来从每个成员中提取额外信息的扩展方法。在本章的前面,我们甚至看到了一个完整但基本的例子,使用了我们的BitRate枚举。现在,凭借我们新获得的知识,让我们看看我们如何可以改进之前的例子。
如前所述,在前面的例子中,我们手动为我们的每个枚举声明了一个RadioButton控件。虽然这对我们的三个成员枚举来说是可以的,但如果枚举成员很多,使用这种方法就没有那么合理了。相反,让我们考虑一下我们如何可以使用DataTemplate来声明每个成员应该如何被渲染。让我们提醒自己如何在之前的例子中声明每个RadioButton:
<RadioButton Content="16 bits" IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter},
ConverterParameter=Sixteen}" VerticalContentAlignment="Center" />
我们首先注意到硬编码的Content值。显然,我们无法在DataTemplate中这样做,否则每个成员都会被赋予相同的标签。这正是我们可以使用我们之前创建的EnumToDescriptionStringConverter转换器的地方,所以现在让我们更新它:
<UserControl.Resources>
...
<Converters:EnumToDescriptionStringConverter
x:Key="EnumToDescriptionStringConverter" />
...
</UserControl.Resources>
...
<RadioButton Content="{Binding .,
Converter={StaticResource EnumToDescriptionStringConverter}}"
IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter},
ConverterParameter=Sixteen}" VerticalContentAlignment="Center" />
接下来,我们看到我们还将枚举成员Sixteen硬编码到了ConverterParameter属性,因此我们还需要在我们的数据模板中更改这一点。我们的第一次尝试可能是简单地从数据模板中数据绑定整个数据上下文,在我们的例子中,这是一个枚举实例:
<RadioButton Content="{Binding .,
Converter={StaticResource EnumToDescriptionStringConverter}}"
IsChecked="{Binding BitRate,
Converter={StaticResource EnumToBoolConverter},
ConverterParameter={Binding}}" VerticalContentAlignment="Center" />
然而,如果我们这样做并运行应用程序,我们将收到以下异常:
A 'Binding' cannot be set on the 'ConverterParameter' property of type 'Binding'. A 'Binding' can only be set on a DependencyProperty of a DependencyObject.
不幸的是,我们不能绑定到ConverterParameter属性,因为这个属性没有被声明为依赖属性。由于我们无法在数据模板内部绑定到这个属性,并且不再使用EnumToBoolConverter类来指定选择的枚举实例,这将使我们的例子变得有些复杂。
我们可以使用的一个技巧是利用ListBoxItem类的SelectedItem属性来保存我们选择的枚举成员的值。我们可以通过在DataTemplate中使用RelativeSource.FindAncestor绑定将此属性绑定到每个RadioButton的IsChecked属性来实现这一点:
<RadioButton Content="{Binding .,
Converter={StaticResource EnumToDescriptionStringConverter}}"
IsChecked="{Binding IsSelected,
RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}},
FallbackValue=False}" VerticalContentAlignment="Center" />
注意,在集合控件中的每个数据项都将隐式地包装在一个 UI 容器元素中。在我们的例子中,我们将使用ListBox控件,因此我们的枚举实例将被包装在ListBoxItem元素中,但如果我们选择了一个ComboBox,例如,那么我们的项目容器将是ComboBoxItem元素。我们将在下一章中了解更多关于这个内容,但现在,让我们继续看这个例子。
因此,现在我们已经将RadioButton的Content属性绑定到枚举中DescriptionAttribute属性声明的每个成员的描述,以及将IsChecked属性绑定到ListBoxItem元素的IsSelected属性。然而,我们已经失去了与视图模型中选择的枚举属性的连接。
为了恢复这种连接,我们可以将BitRate属性绑定到ListBox控制的SelectedItem属性。WPF 框架隐式地将此属性与每个ListBoxItem元素的IsSelected属性连接起来,因此我们现在恢复了BitRate属性与每个按钮的IsChecked属性之间的连接。让我们看看更新的 XAML:
<UserControl x:Class="CompanyName.ApplicationName.Views.BitRateView"
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
>
<UserControl.Resources>
<Converters:EnumToBoolConverter x:Key="EnumToBoolConverter" />
</UserControl.Resources>
<GroupBox Header="Audio Quality" FontSize="14" Margin="20"
HorizontalAlignment="Left" VerticalAlignment="Top" Padding="5">
<ListBox ItemsSource="{Binding BitRates}"
SelectedItem="{Binding BitRate}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type Enums:BitRate}">
<RadioButton Content="{Binding ., Converter={StaticResource
EnumToDescriptionStringConverter}}"
IsChecked="{Binding IsSelected,
RelativeSource={RelativeSource
AncestorType={x:Type ListBoxItem}}, FallbackValue=False}"
VerticalContentAlignment="Center" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</GroupBox>
</UserControl>
为了更新我们之前的例子,我们需要添加新的Enums XAML 命名空间前缀,这样我们就可以在数据模板中指定我们的BitRate枚举类型。接下来,我们需要更新我们的GroupBox元素的内容。现在我们使用ListBox控件,这样我们可以利用其项目选择功能。
我们将BitRates集合绑定到ItemsSource属性,并将选择的BitRate属性绑定到ListBox的SelectedItem属性。这种方法的一个问题是,由于我们现在在例子中使用ListBox元素,我们可以看到它及其包含的ListBoxItem对象。这通常不是单选按钮的显示方式:

这不是一个严重的问题,通过声明一些样式就可以轻松解决。我们将在下一章中回到这个例子,并展示如何对ListBox元素及其项目进行样式化,以完全隐藏它们的使用对最终用户的影响。
摘要
我们在本章中涵盖了大量重要信息,从检查绑定路径语法迷你语言到探索多种不同的绑定场景。我们研究了在声明自己的依赖属性时我们拥有的众多选项,并探讨了使用一些有趣的示例来创建附加属性。最后,我们考察了数据模板的细节,并探索了将数据绑定到枚举的多种方法。
在下一章中,我们将深入探讨 WPF 框架中的各种 UI 元素及其最相关的属性。我们将研究何时需要自定义它们,以及何时需要创建自己的控件。然后,我们将探索修改现有 WPF 控件的多种方法,最后,我们将详细探讨如何创建自己的自定义控件。
第五章:使用合适的控件完成任务
在本章中,我们将首先考虑 Windows Presentation Foundation(WPF)为我们提供的现有控件,并查看我们如何使用它们来创建所需布局。我们将调查我们可以修改这些控件的各种方式,以避免创建新控件的需求。
我们将检查现有控件中内置的各种功能级别,然后发现当需要时如何最好地声明我们自己的控件。我们将深入探讨我们拥有的各种选项,并确定何时使用每个选项最为合适。让我们直接跳入并查看各种布局控件。
调查内置控件
.NET Framework 中包含了许多控件。它们涵盖了大多数常见场景,在典型的基于表单的应用程序中,我们很少需要创建自己的控件。所有的 UI 控件通常都是从大量常见的基类构建其功能。
所有控件都将共享相同的核心级基类,这些基类提供核心级功能,然后是一系列派生框架级类,这些类提供与 WPF 框架关联的功能,例如数据绑定、样式化和模板化。让我们来看一个例子。
继承框架能力
就像我们应用程序框架中的基类一样,内置的 WPF 控件也有一个继承层次结构,每个后续的基类都提供一些额外的功能。让我们以Button类为例。以下是Button控件的继承层次结构:
System.Object
System.Windows.Threading.DispatcherObject
System.Windows.DependencyObject
System.Windows.Media.Visual
System.Windows.UIElement
System.Windows.FrameworkElement
System.Windows.Controls.Control
System.Windows.Controls.ContentControl
System.Windows.Controls.Primitives.ButtonBase
System.Windows.Controls.Button
就像.NET Framework 中的每个对象一样,我们首先从Object类开始,它为所有类提供低级服务。这些包括对象比较、终结和输出每个对象的可自定义string表示形式的能力。
接下来是DispatcherObject类,它为每个对象提供线程亲和性并将它们与一个Dispatcher对象关联起来。Dispatcher类管理为单个线程的工作项的优先级队列。只有创建相关Dispatcher对象的线程可以直接访问每个DispatcherObject,这使派生类能够强制执行线程安全。
在DispatcherObject类之后,我们有DependencyObject类,它使所有派生类都能够使用 WPF 属性系统并声明依赖属性。我们用来访问和设置它们值的GetValue和SetValue方法也是由DependencyObject类提供的。
接下来是Visual类,它的主要作用是提供渲染支持。在 UI 中显示的所有元素都将扩展Visual类。除了渲染每个对象外,它还计算它们的边界框并提供对击中测试、裁剪和转换的支持。
扩展Visual类的是UIElement类,它为所有派生类提供了一系列核心服务。这些服务包括事件和用户输入系统,以及确定元素布局外观和渲染行为的能力。
接下来是FrameworkElement类,它提供了第一级框架成员,建立在它所扩展的核心级别类的基础之上。是FrameworkElement类通过DataContext属性实现了数据绑定,通过Style属性实现了样式化。
它还提供了与对象生命周期相关的事件,将核心级别的布局系统升级为完整的布局系统,并改进了对动画的支持,以及其他功能。这通常是如果我们创建自己的基本元素时可能想要扩展的最低级别类,因为它使派生类能够参与 WPF UI 的大部分功能。
Control类扩展了FrameworkElement类,并且是大多数 WPF UI 元素的基类。它通过使用其ControlTemplate功能提供外观模板化,以及一系列与外观相关的属性。这些属性包括颜色属性,如Background、Foreground和BorderBrush,以及对齐和字体属性。
扩展Control类的是ContentControl类,它使得控件能够拥有任何 CLR 类型的单个对象作为其内容。这意味着我们可以将数据对象或 UI 元素设置为内容,尽管如果数据对象是自定义类型,我们可能需要提供一个DataTemplate。
Button类所扩展的父类长串中的最后一类是ButtonBase类。实际上,这是 WPF 中所有按钮的基类,并为按钮添加了有用的功能。这包括自动将某些键盘事件转换为鼠标事件,使用户能够在不使用鼠标的情况下与按钮交互。
Button类本身对其继承成员的添加很少,只有三个相关的bool属性;两个指定按钮是否是默认按钮,一个指定按钮是否是取消按钮。我们很快就会看到这个例子。它还有两个受保护的覆盖方法,当按钮被点击或为其创建自动化代理时会被调用。
虽然 WPF 使我们能够修改现有的控件到这种程度,以至于我们很少需要创建自己的控件,但了解这种继承层次结构非常重要,这样我们才能在我们需要时扩展满足我们要求的适当且最轻量级的基类。
例如,如果我们想创建自己的自定义按钮,通常更合理的是扩展ButtonBase类,而不是Button类;如果我们想创建一个完全独特的控件,我们可以扩展FrameworkElement类。现在我们已经很好地理解了可用控件的结构,让我们看看 WPF 布局系统是如何显示它们的。
按行排列
在 WPF 中,布局系统负责确定每个要显示的元素的大小,将它们定位在屏幕上,然后绘制它们。由于控件可以包含在其他控件中,布局系统以递归方式工作,每个子控件的总体位置由其父面板控件的位置决定。
布局系统首先在所谓的测量过程中测量每个面板中的每个子元素。在这个过程中,每个面板调用每个子元素的Measure方法,并指定它们理想上希望拥有的空间量;这确定了UIElement.DesiredSize属性值。请注意,这并不一定是它们将被分配的空间量。
在测量过程之后是排列过程,此时每个面板调用每个子元素的Arrange方法。在这个过程中,面板根据它们的DesiredSize值生成每个子元素的边界框。布局系统将调整这些大小以添加所需的边距或可能需要的任何其他调整。
它返回一个值到面板的ArrangeOverride方法的输入参数,每个面板在返回可能调整后的值之前执行其特定的布局行为。布局系统在将执行权返回给面板并完成布局过程之前执行任何剩余的必要调整。
在开发我们的应用程序时,我们需要小心,以确保我们不会不必要地触发布局系统的额外遍历,因为这可能导致性能下降。这可能在向集合中添加或删除项目、对元素应用转换或调用UIElement.UpdateLayout方法时发生,后者强制进行新的布局遍历。
包含控件
现有的控件大多可以分为两大类:一类为其他控件提供布局支持,另一类则构成可见的 UI,并通过第一类控件进行排列。第一类控件当然是面板,它们提供了多种方式来在 UI 中排列其子控件。
一些提供了调整大小功能,而另一些则没有,一些比其他更高效,因此使用正确的面板来完成工作非常重要。此外,不同的面板提供不同的布局行为,因此了解可用的面板以及它们在布局方面为我们提供什么是有益的。
所有面板都扩展了抽象的 Panel 类,该类扩展了 FrameworkElement 类,因此它具有该类的所有成员和功能。然而,它没有扩展 Control 类,因此它不能继承其属性。因此,它添加了自己的 Background 属性,使用户能够为面板的各种项目之间的间隙着色。
Panel 类还提供了一个 Children 属性,它表示每个面板中的项目,尽管我们通常不会与这个属性交互,除非创建一个自定义面板。相反,我们可以在 XAML 中直接在面板元素内声明我们的子元素来填充这个集合。
我们能够这样做是因为 Panel 类在其类定义中通过 ContentPropertyAttribute 属性指定了 Children 属性。虽然 ContentControl 的 Content 属性通常使我们能够添加单个内容项,但我们能够向面板中添加多个项,因为它们的 Children 属性,即设置的内容,是一个集合。
另一个我们可能需要使用的 Panel 类属性是 IsItemsHost 属性,它指定一个面板是否用作 ItemsControl 元素或其派生类(如 ListBox)的项目的容器。默认值是 false,因此显式将此属性设置为 false 没有意义。实际上,它只在非常特定的情况下才需要。
那种情况是我们正在替换 ItemsControl 的默认面板或其派生类(如 ListBox)的 ControlTemplate。通过在 ControlTemplate 中的面板元素上设置此属性为 true,我们告诉 WPF 将生成的集合元素放置在面板中。让我们看看一个快速示例:
<ItemsControl ItemsSource="{Binding Users}">
<ItemsControl.Template>
<ControlTemplate TargetType="{x:Type ItemsControl}">
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
</ControlTemplate>
</ItemsControl.Template>
</ItemsControl>
在这个简单的例子中,我们正在用水平 StackPanel 替换 ItemsControl 元素的默认内部项目面板。请注意,这是一个永久性的替换,没有提供新的 ControlTemplate,没有人可以进一步修改它。然而,有一种远更简单的方法可以达到相同的结果,我们已经在 第四章,精通数据绑定 中看到了这个例子:
<ItemsControl ItemsSource="{Binding Users}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
在这个替代示例中,我们只是通过 ItemsPanel 属性为 ItemsControl 提供一个新的 ItemsPanelTemplate。使用这段代码,内部面板仍然可以很容易地更改,而无需提供新的 ControlTemplate,因此当我们不希望其他用户能够替换内部面板时,我们使用第一种方法,否则我们使用这种方法。
Panel类还声明了一个ZIndex附加属性,子元素可以使用它来指定面板内的分层顺序。具有较高值的子元素将出现在具有较低值的元素之上或前面,尽管在未重叠其子元素的面板中,此属性将被忽略。我们将在下一节中看到这个例子,所以现在让我们专注于从Panel类派生的面板以及它们为我们提供了什么。
画布
Canvas类使我们能够使用Canvas.Top、Canvas.Left、Canvas.Bottom和Canvas.Right附加属性的组合来显式定位子元素。这与旧 Windows Forms 系统中控件放置的系统有些类似。
然而,在使用 WPF 时,我们通常不会在Canvas中布局 UI 控件。相反,我们更倾向于使用它们来显示形状、构建图表、显示动画或绘制应用程序。以下是一个例子:
<Canvas Width="256" Height="109" Background="Black">
<Canvas.Resources>
<Style TargetType="{x:Type Ellipse}">
<Setter Property="Width" Value="50" />
<Setter Property="Height" Value="50" />
<Setter Property="Stroke" Value="Black" />
<Setter Property="StrokeThickness" Value="3" />
</Style>
</Canvas.Resources>
<Canvas Canvas.Left="3" Canvas.Top="3" Background="Orange"
Width="123.5" Height="50">
<Ellipse Canvas.Top="25" Canvas.Left="25" Fill="Cyan" />
</Canvas>
<Canvas Canvas.Left="129.5" Canvas.Top="3" Background="Orange"
Width="123.5" Height="50" Panel.ZIndex="1" />
<Canvas Canvas.Left="3" Canvas.Top="56" Background="Red" Width="250"
Height="50" ClipToBounds="True">
<Ellipse Canvas.Top="-25" Canvas.Left="175" Fill="Lime" />
</Canvas>
<Ellipse Canvas.Top="29.5" Canvas.Left="103" Fill="Yellow" />
</Canvas>
这个例子演示了几个重要的点,所以在我们讨论它之前,让我们先看看这段代码的视觉输出:

左上角的矩形是一个画布的输出,右上角和下方的矩形来自另外两个画布实例。它们都包含在一个具有黑色背景的父画布元素中。三个内部画布被间隔开来,以产生每个画布都有边框的效果。它们按照从左上角、右上角、底部,到最后一个要声明的中间圆圈的顺序声明。
左侧的圆正在左上角的画布中绘制,我们可以看到它与画布的明显底部边界的重叠部分,这表明它没有被其父画布裁剪。然而,它被下方的画布元素裁剪,这证明了后来声明的 UI 元素将显示在较早声明的元素之上。
尽管如此,第二个声明的画布正在裁剪最后一个声明的元素,即中间的圆圈。这证明了将元素的Panel.ZIndex属性设置为任何正数将使该元素位于所有未明确设置此属性的其他元素之上。此属性的默认值为零,因此将此属性设置为1的元素将渲染在所有未明确为其设置值的元素之上。
下一个要声明的元素是底部的矩形,右边的圆被声明在其中。现在,由于这个元素是在顶部画布之后声明的,你可能预计右边的圆会与右上角的画布重叠。虽然这通常是情况,但我们的例子中不会发生这种情况,原因有两个。
第一个原因,正如我们刚刚发现的,是因为右上角面板的 ZIndex 属性值高于下面板,第二个原因是我们将 UIElement.ClipToBounds 属性设置为 true,这是 Canvas 面板用来确定是否应该裁剪可能位于面板边界之外的任何子元素的视觉内容。
这通常与动画一起使用,以便在某个事件发生时将视觉元素隐藏在面板边界之外,然后将其滑动到视图中。我们可以通过看到其明显的顶部边框,该边框在其边界之外,来判断右侧的圆圈已被其父面板裁剪。
最后声明的元素是中间的圆圈,我们可以看到,除了与具有更高 ZIndex 属性值的重叠画布元素外,它还与其他所有元素重叠。请注意,Canvas 面板不会对其子元素执行任何类型的调整大小操作,因此它通常不用于生成表单类型的 UI。
DockPanel
DockPanel 类主要用于控制层次结构的顶层,用于布局顶层控件。它为我们提供了将控件停靠到屏幕各个部分的能力,例如,停靠在顶部的菜单、左侧的上下文菜单、底部的状态栏以及屏幕剩余部分的主 View 内容控件:

在前一个图中展示的布局可以通过以下 XAML 轻松实现:
<DockPanel>
<DockPanel.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style TargetType="{x:Type Border}">
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="1" />
</Style>
</DockPanel.Resources>
<Border Padding="0,3" DockPanel.Dock="Top">
<TextBlock Text="Menu Bar" />
</Border>
<Border Padding="0,3" DockPanel.Dock="Bottom">
<TextBlock Text="Status Bar" />
</Border>
<Border Width="100" DockPanel.Dock="Left">
<TextBlock Text="Context Menu" TextWrapping="Wrap" />
</Border>
<Border>
<TextBlock Text="View" />
</Border>
</DockPanel>
我们使用 DockPanel.Dock 附加属性指定面板内每个元素想要停靠的位置。我们可以指定面板的左、右、上和下边。剩余的空间通常由未显式设置 Dock 属性的最后一个子元素填充。然而,如果我们不希望这种行为,则可以将 LastChildFill 属性设置为 false。
DockPanel 将会自动调整自身大小以适应其内容,除非其尺寸被指定,无论是通过显式使用 Width 和 Height 属性,还是通过父面板的隐式指定。如果它及其子元素都指定了尺寸,那么有可能某些子元素将不会获得足够的空间,并且无法正确显示,因为最后一个子元素是唯一可以被 DockPanel 调整大小的。还应注意的是,此面板不会与其子元素重叠。
还请注意,子元素的声明顺序将影响它们各自获得的空间和位置。例如,如果我们想让菜单栏填充屏幕顶部,上下文菜单占据剩余的左侧,而 View 和状态栏占据剩余的空间,我们只需在状态栏之前声明上下文菜单:
...
<Border Padding="0,3" DockPanel.Dock="Top">
<TextBlock Text="Menu Bar" />
</Border>
<Border Width="100" DockPanel.Dock="Left">
<TextBlock Text="Context Menu" TextWrapping="Wrap" />
</Border>
<Border Padding="0,3" DockPanel.Dock="Bottom">
<TextBlock Text="Status Bar" />
</Border>
<Border>
<TextBlock Text="View" />
</Border>
...
这种轻微的改变将导致以下布局:

Grid
当涉及到布局典型的 UI 控件时,网格面板是最常用的。它是功能最全面的,使我们能够执行一些技巧,以获得所需的布局。它提供了一个灵活的基于行和列的布局系统,我们可以用它来构建具有流动布局的 UI。流动布局能够响应用户调整应用程序窗口大小并改变大小。
网格是少数几个可以根据可用空间调整所有子元素大小的面板之一,这使得它成为性能最密集的面板之一。因此,如果我们不需要它提供的功能,我们应该使用性能更好的面板,例如画布或堆叠面板。
网格面板的子元素可以分别设置它们的边距属性,使用绝对坐标进行布局,这与画布面板类似。然而,应尽可能避免这样做,因为这会破坏我们 UI 的流畅性。相反,我们通常使用网格的行定义和列定义集合以及Grid.Row和Grid.Column附加属性来定义我们想要的布局。
虽然我们可以再次为我们的行和列硬编码确切的宽度和高度,但我们通常尽量避免这样做,原因相同。相反,我们通常利用网格的尺寸行为,并声明我们的行和列,主要使用两个值之一。
第一个是自动值,它从其内容获取大小;第二个是默认的*星号大小值,它获取所有剩余的空间。通常,我们将所有列或行设置为自动,除了包含最重要数据的列或行,这些列或行被设置为*。
注意,如果我们有多个星号大小的列,那么空间通常在它们之间平均分配。然而,如果我们需要剩余空间的非均等分配,我们可以使用星号指定一个乘数数字,这将乘以该行或列将获得的空间比例。让我们通过一个例子来澄清这一点:
<Grid TextElement.FontSize="14" Width="300" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2.5*" />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.ColumnSpan="3" HorizontalAlignment="Center"
VerticalAlignment="Center" Text="Are you sure you want to continue?"
Margin="40" />
<Button Grid.Row="1" Grid.Column="1" Content="OK" IsDefault="True"
Height="26" Margin="0,0,2.5,0" />
<Button Grid.Row="1" Grid.Column="2" Content="Cancel" IsCancel="True"
Height="26" Margin="2.5,0,0,0" />
</Grid>
这个例子演示了几个要点,所以在我们继续之前,让我们看看渲染的输出:

在这里,我们有一个非常基本的确认对话框控件。它由一个有三个列和两个行的网格面板组成。请注意,单个星号大小被用作列定义和行定义元素的默认宽度和高度值;我们不需要明确设置它们,只需声明空元素即可。同时请注意,星号大小仅在网格面板设置了某些大小的情况下才会工作,就像我们在这里所做的那样。
因此,在我们的例子中,第二列和第三列以及第一行将使用星号大小调整,并占用所有剩余的空间。第一列也使用星号大小调整,但是它指定了一个乘数值为2.5。因此,它将获得其他两列各自空间的两倍半。
注意,这个第一列仅用于将其他两列中的按钮推到正确的位置。虽然TextBlock元素在第一列中声明,但它不仅位于该列,因为它还指定了Grid.ColumnSpan附加属性,这允许它扩展到多个列。Grid.RowSpan附加属性对行做同样的处理。
Grid.Row和Grid.Column附加属性由每个元素用来指定它们应该渲染在哪个单元格中。然而,这些属性的默认值是零,所以当我们想在面板的第一列或第一行中声明一个元素时,我们可以省略这些属性的设置,就像我们例子中的TextBlock所做的那样。
“确定”按钮被声明在第二行和第二列,并将IsDefault键设置为true,这使用户可以通过按键盘上的Enter键来调用它。它还负责按钮上的蓝色边框,我们可以使用这个属性在我们自己的模板中为默认按钮设置不同的样式。“取消”按钮位于其旁边,在第三列,并将IsCancel属性设置为true,这使用户可以通过按键盘上的Esc键来选择它。
注意,我们本可以将RowDefinition.Height的较低属性设置为26,而不是在每个按钮上显式设置,最终结果将是相同的,因为Auto值无论如何都会从它们的高度计算得出。另外,请注意,这里的Margin属性已经设置在一些元素上,仅用于间距目的,而不是用于绝对定位目的。
Grid类还声明了两个其他有用的属性。第一个是ShowGridLines属性,正如你可以想象的那样,当设置为true时,它会在面板中显示行和列的边框。虽然对于像上一个例子中的简单布局来说这不是必需的,但在开发更复杂的布局时这可能很有用。然而,由于其性能较差,这个功能永远不应该在生产 XAML 中使用:
<Grid TextElement.FontSize="14" Width="300" Margin="10"
ShowGridLines="True">
...
</Grid>
现在我们来看看有了可见的网格线后是什么样子:

另一个有用的属性是IsSharedSizeScope附加属性,它使我们能够在两个或多个Grid面板之间共享大小信息。我们可以通过在父面板上设置此属性为true,然后在内部Grid面板的相关ColumnDefinition和/或RowDefinition元素上指定SharedSizeGroup属性来实现这一点。
为了使这个功能正常工作,我们需要遵守几个条件,第一个与作用域相关。IsSharedSizeScope属性需要在父元素上设置,但如果该父元素在资源模板内,而指定SharedSizeGroup属性的元素定义在模板外,则它将不起作用。然而,在相反方向上它是有效的。
另一点需要注意是,在共享大小信息时,星号大小不被尊重。在这些情况下,任何定义元素的星号值将被读取为Auto,所以我们通常不会在我们的星号大小列上设置SharedSizeGroup属性。然而,如果我们将其设置在其他列上,那么我们将得到我们想要的布局。让我们看看这个例子:
<Grid TextElement.FontSize="14" Margin="10" IsSharedSizeScope="True">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition />
</Grid.RowDefinitions>
<Grid TextElement.FontWeight="SemiBold" Margin="0,0,0,3"
ShowGridLines="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Name" />
<ColumnDefinition />
<ColumnDefinition Width="Auto" SharedSizeGroup="Age" />
</Grid.ColumnDefinitions>
<TextBlock Text="Name" />
<TextBlock Grid.Column="1" Text="Comments" Margin="10,0" />
<TextBlock Grid.Column="2" Text="Age" />
</Grid>
<Separator Grid.Row="1" />
<ItemsControl Grid.Row="2" ItemsSource="{Binding Users}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type DataModels:User}">
<Grid ShowGridLines="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Name" />
<ColumnDefinition />
<ColumnDefinition Width="Auto" SharedSizeGroup="Age" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Name}" />
<TextBlock Grid.Column="1" Text="Star-sized column takes all
remaining space" Margin="10,0" />
<TextBlock Grid.Column="2" Text="{Binding Age}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
在这个例子中,我们有一个ItemsControl,它绑定到我们之前例子中Users集合的一个稍微编辑过的版本。之前,所有用户名长度相似,所以其中一个已经被编辑,以更清楚地说明这一点。出于同样的原因,ShowGridLines属性也已在内部面板上设置为true。
在这个例子中,我们首先在父Grid面板上设置IsSharedSizeScope附加属性为true,然后应用内部Grid控件的定义的SharedSizeGroup属性,这些控件在外部面板内声明,并在DataTemplate元素内。在继续之前,让我们看看这段代码的渲染输出:

注意,我们在DataTemplate元素内部和外部都提供了相同数量的列和组名,这对于此功能正常工作至关重要。另外,请注意,我们没有在中间列上设置SharedSizeGroup属性,该属性是星号大小的。
仅对其他两个列进行分组将产生与对三个列进行分组相同的视觉效果,但不会失去中间列的星号大小。然而,让我们看看如果我们也在中间列的定义上设置SharedSizeGroup属性会发生什么:
<ColumnDefinition SharedSizeGroup="Comments" />
如预期,我们的中间列已经失去了星号大小,现在剩余的空间已经应用到最后一列:

模板内的Grid面板将为集合中的每个项目进行渲染,因此这实际上将导致几个面板,每个面板都有相同的组名,因此也有相同的列间距。我们非常重要的一点是,我们需要在所有内部面板的共同父元素Grid面板上设置IsSharedSizeScope属性为true,这些内部面板之间需要共享大小信息。
StackPanel
StackPanel 是 WPF 控件中仅对其子项提供有限调整大小能力的面板之一。只要它们没有指定明确的大小,它将自动将每个子项的 HorizontalAlignment 和 VerticalAlignment 属性设置为 Stretch。在这种情况下,子元素将被拉伸以适应包含面板的大小。这可以通过以下方式轻松演示:
<Border Background="Black" Padding="5">
<Border.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Padding" Value="5" />
<Setter Property="Background" Value="Yellow" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
</Border.Resources>
<StackPanel TextElement.FontSize="14">
<TextBlock Text="Stretched Horizontally" />
<TextBlock Text="With Margin" Margin="20" />
<TextBlock Text="Centered Horizontally"
HorizontalAlignment="Center" />
<Border BorderBrush="Cyan" BorderThickness="1" Margin="0,5,0,0"
Padding="5" SnapsToDevicePixels="True">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Stretched Vertically" />
<TextBlock Text="With Margin" Margin="20" />
<TextBlock Text="Centered Vertically"
VerticalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
</Border>
此面板实际上将每个子元素依次排列,默认为垂直,当其 Orientation 属性设置为 Horizontal 时则为水平。我们的示例使用了这两种方向,因此在我们继续之前,让我们快速看一下其输出:

我们整个示例被一个具有黑色背景的 Border 元素包裹。在其 Resources 部分中,我们为示例中的 TextBlock 元素声明了一些样式属性。在边框内部,我们声明了我们的第一个 StackPanel 控件,其默认垂直方向。在这个第一个面板中,我们有三个 TextBlock 元素和另一个包裹在边框中的 StackPanel。
第一个 TextBlock 元素会自动拉伸以适应面板的宽度。第二个添加了边距,但否则也会拉伸以适应面板的宽度。然而,第三个的 HorizontalAlignment 属性被显式设置为 Center,因此它不会被面板拉伸以适应。
内部面板在其内部声明了三个 TextBlock 元素,并将其 Orientation 属性设置为 Horizontal。因此,其子项是水平排列的。它的边框被着色,这样更容易看到其边界。注意它上面设置的 SnapsToDevicePixels 属性。
由于 WPF 使用设备无关的像素设置,细直线有时会跨越单个像素边界并呈现抗锯齿效果。将此属性设置为 true 将强制元素使用设备特定的像素设置精确渲染,从而形成更清晰、更锐利的线条。
下面板中的第一个 TextBlock 元素会自动拉伸以适应面板的高度。与上面板中的元素一样,第二个添加了边距,但否则也会拉伸以适应面板的高度。然而,第三个的 VerticalAlignment 属性被显式设置为 Center,因此它不会被面板垂直拉伸以适应。
作为旁注,我们在一些文本字符串中使用了十六进制实体来添加换行。这也可以通过使用 TextBlock.TextWrapping 属性并为每个元素硬编码一个 Width 来实现,但显然这种方法更简单。
UniformGrid
UniformGrid 面板是一个轻量级面板,提供了一种简单的方式来创建一个项目网格,其中每个项目的大小相同。我们可以设置其 Row 和 Column 属性来指定我们想要网格有多少行和列。如果我们没有设置一个或两个这些属性,面板将隐式地为我们设置它们,这取决于它拥有的可用空间及其子项的大小。
它还提供了一个 FirstColumn 属性,这将影响第一个子项将被渲染的列。例如,如果我们设置此属性为 2,则第一个子项将在第三列中渲染。这对于日历控件来说非常完美,所以让我们看看我们如何使用 UniformGrid 创建以下输出:

正如你所见,日历控件通常需要在前面几列留出空白空间,因此 FirstColumn 属性简单地实现了这一需求。让我们看看定义此日历示例的 XAML:
<StackPanel TextElement.FontSize="14" Background="White">
<UniformGrid Columns="7" Rows="1">
<UniformGrid.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Height" Value="35" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Padding" Value="0,5,0,0" />
</Style>
</UniformGrid.Resources>
<TextBlock Text="Mon" />
<TextBlock Text="Tue" />
<TextBlock Text="Wed" />
<TextBlock Text="Thu" />
<TextBlock Text="Fri" />
<TextBlock Text="Sat" />
<TextBlock Text="Sun" />
</UniformGrid>
<ItemsControl ItemsSource="{Binding Days}" Background="Black"
Padding="0,0,1,1">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="7" FirstColumn="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="Black" BorderThickness="1,1,0,0"
Background="White">
<TextBlock Text="{Binding}" Height="35"
HorizontalAlignment="Center" Padding="0,7.5,0,0" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
我们从一个 StackPanel 开始,它用于将一个 UniformGrid 面板直接堆叠在一个使用另一个 UniformGrid 作为其 ItemsPanel 的 ItemsControl 之上,并指定在控件内使用的字体大小。顶部的 UniformGrid 面板声明了一个包含七个列的单行,以及一些基本的 TextBlock 样式。它有七个子 TextBlock 项目,输出一周中每一天的名称。
ItemsControl 元素的 Background 属性被设置为 Black 以遮蔽当前月份之外的天,并且 Padding 被设置为使背景看起来像日历的右下角边框。顶部和左边的边框来自 UniformGrid 面板中的单个单元格。ItemsControl.ItemsSource 属性绑定到我们的视图模型中的 Days 属性,所以现在让我们看看它:
private List<int> days = Enumerable.Range(1, 31).ToList();
...
public List<int> Days
{
get { return days; }
set { days = value; NotifyPropertyChanged(); }
}
注意使用 Enumerable.Range 方法来填充集合。它提供了一个简单的方法来生成从提供的起始和长度输入参数开始的连续整数序列。作为一个 LINQ 方法,它是通过延迟执行实现的,实际值只有在实际访问时才会生成。
第二个 UniformGrid 面板被设置为 ItemsControl.ItemsPanel,它只指定应该有七列,但将行数留待从数据绑定的项目数量计算得出。注意,我们已将 FirstColumn 属性的值硬编码为 2,虽然在合适的控件中,我们通常会将其相关月份的值数据绑定到它。
最后,我们使用 DataTemplate 来定义日历上每一天的外观。请注意,在这个例子中,我们不需要为其 DataType 属性指定值,因为我们正在将整个数据源对象进行数据绑定,在这个例子中它只是一个整数。现在让我们继续研究 WrapPanel 面板。
WrapPanel
WrapPanel面板类似于StackPanel,但默认情况下它会在两个方向上堆叠其子项。它首先水平排列子项,当第一行空间不足时,它会自动将下一个项目换行到新行,并继续排列剩余控件。它使用所需行数重复此过程,直到所有项目都被渲染。
然而,它也提供了一个类似于StackPanel的Orientation属性,这将影响其布局行为。如果将Orientation属性从默认值Horizontal更改为Vertical,则面板的子项将垂直排列,从上到下直到第一列没有更多空间。项目将换行到下一列,并继续这种方式,直到所有项目都被渲染。
此面板还声明了ItemHeight和ItemWidth属性,使其能够限制项目的尺寸并产生类似于UniformGrid面板的布局行为。请注意,这些值实际上不会调整每个子项的大小,而只是限制它们在面板中提供的可用空间。让我们看看这个示例:
<WrapPanel ItemHeight="50" Width="150" TextElement.FontSize="14">
<WrapPanel.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Width" Value="50" />
</Style>
</WrapPanel.Resources>
<Button Content="7" />
<Button Content="8" />
<Button Content="9" />
<Button Content="4" />
<Button Content="5" />
<Button Content="6" />
<Button Content="1" />
<Button Content="2" />
<Button Content="3" />
<Button Content="0" Width="100" />
<Button Content="." />
</WrapPanel>
注意,虽然与UniformGrid面板的输出类似,但这个示例实际上不能使用该面板实现,因为其中一个子项的大小与其他不同。让我们看看这个示例的视觉输出:

我们首先声明了WrapPanel并指定每个子项应仅提供50像素的高度,而面板本身应宽150像素。在Resources部分,我们设置每个按钮的宽度为50像素,因此使得每个行上的三个按钮可以并排放置,在项目换行到下一行之前。
接下来,我们简单地定义了组成面板子项的十一个按钮,指定零按钮的宽度是其他按钮的两倍。请注意,如果我们设置了ItemWidth属性为50像素,以及ItemHeight属性,这将不会起作用。在这种情况下,我们会看到零按钮的一半,另一半被点号按钮覆盖,并且点号按钮当前所在的位置是一个空白空间。
提供自定义布局行为
当内置面板的布局行为不符合我们的要求时,我们可以轻松地定义一个新的具有自定义布局行为的面板。我们只需要声明一个扩展Panel类的类,并重写其MeasureOverride和ArrangeOverride方法。
在MeasureOverride方法中,我们简单地调用Children集合中每个子项的Measure方法,传递一个设置为double.PositiveInfinity的Size元素。这相当于对每个子项说“将你的DesriredSize属性设置为如果你有所有可能需要的空间”。
在ArrangeOverride方法中,我们使用每个子项新确定的DesiredSize属性值来计算其所需的位置,并调用其Arrange方法在该位置渲染它。让我们看看一个自定义面板的例子,该面板将其项均匀地放置在圆的周长上:
using System;
using System.Windows;
using System.Windows.Controls;
namespace CompanyName.ApplicationName.Views.Panels
{
public class CircumferencePanel : Panel
{
public Thickness Padding { get; set; }
protected override Size MeasureOverride(Size availableSize)
{
foreach (UIElement element in Children)
{
element.Measure(
new Size(double.PositiveInfinity, double.PositiveInfinity));
}
return availableSize;
}
protected override Size ArrangeOverride(Size finalSize)
{
if (Children.Count == 0) return finalSize;
double currentAngle = 90 * (Math.PI / 180);
double radiansPerElement =
(360 / Children.Count) * (Math.PI / 180.0);
double radiusX = finalSize.Width / 2.0 - Padding.Left;
double radiusY = finalSize.Height / 2.0 - Padding.Top;
foreach (UIElement element in Children)
{
Point childPoint = new Point(Math.Cos(currentAngle) * radiusX,
-Math.Sin(currentAngle) * radiusY);
Point centeredChildPoint = new Point(childPoint.X +
finalSize.Width / 2 - element.DesiredSize.Width / 2, childPoint.Y
+ finalSize.Height / 2 - element.DesiredSize.Height / 2);
Rect boundingBox =
new Rect(centeredChildPoint, element.DesiredSize);
element.Arrange(boundingBox);
currentAngle -= radiansPerElement;
}
return finalSize;
}
}
}
在我们的CircumferencePanel类中,我们首先声明我们自己的Padding属性,其类型为Thickness,这将用于使用户能够延长或缩短圆的半径,从而调整面板内渲染项的位置。MeasureOverride方法如前所述很简单。
在ArrangeOverride方法中,我们根据子项的数量计算定位子项的相关角度。在计算X和Y半径时,我们考虑了我们的Padding属性值,这样我们的自定义面板的用户将能够更好地控制渲染项的位置。
对于面板的Children集合中的每个子项,我们首先计算它应该显示在圆上的点。然后,我们使用元素的DesiredSize属性值偏移该值,这样每个项的边界框就位于该点上。
然后,我们使用一个Rect元素创建元素的边界框,包含偏移点和元素的DesiredSize属性,并将其传递给其Arrange方法以渲染它。每个元素渲染后,当前角度会改变以定位下一个项。记住,我们可以通过为Panels CLR 命名空间添加一个 XAML 命名空间并设置ItemsControl或其派生类的ItemsPanel属性来利用这个面板:
...
<ItemsControl ItemsSource="{Binding Hours}" TextElement.FontSize="24"
Width="200" Height="200">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Panels:CircumferencePanel Padding="20" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
给定一些合适的数据,我们可以使用这个面板在时钟控件上显示数字,例如。让我们看看我们的示例ItemsControl的ItemsSource属性绑定到的Hours属性:
private List<int> hours = new List<int>() { 12 };
public List<int> Hours
{
get { return hours; }
set { hours = value; NotifyPropertyChanged(); }
}
...
hours.AddRange(Enumerable.Range(1, 11));
由于小时数字必须从 12 开始然后回到 1,我们最初声明包含 12 个元素的集合。在某个后续阶段,可能在构造期间,我们然后向集合中添加剩余的数字,这就是使用我们新面板时的样子:

这就结束了我们对 WPF 中可用的主要面板的介绍。虽然我们没有足够的空间深入探讨每个其他 WPF 控件,但在这本书中,我们将找到许多控件的小技巧和技巧。相反,现在让我们集中精力在几个基本控件上,以及它们能为我们做什么。
内容控件
虽然这个控件不常直接使用,但其一个用途是根据特定模板渲染单个数据项。实际上,我们经常使用ContentControl来显示我们的视图模型,并使用一个渲染相关视图的DataTemplate对象。或者,我们可能使用某种形式的ItemsControl来显示一组项目,并使用ContentControl来显示所选项目。
正如我们之前在查看Button控件继承层次结构时发现的,ContentControl类扩展了Control类,并增加了派生类包含任何单个 CLR 对象的能力。请注意,如果我们需要指定多个内容对象,我们可以使用包含更多对象的单个面板对象:
<Button Width="80" Height="30" TextElement.FontSize="14">
<StackPanel Orientation="Horizontal">
<Rectangle Fill="Cyan" Stroke="Black" StrokeThickness="1" Width="16"
Height="16" />
<TextBlock Text="Cyan" Margin="5,0,0,0" />
</StackPanel>
</Button>

我们可以通过使用Content属性来指定此内容。然而,ContentControl类在其类定义中通过ContentPropertyAttribute属性指定了Content属性,这使得我们能够通过在 XAML 中直接声明控件内的子元素来设置内容。此属性由 XAML 处理器在处理 XAML 子元素时使用。
如果内容是string类型,则我们可以使用ContentStringFormat属性来指定其特定格式。否则,我们可以使用ContentTemplate属性来指定在渲染内容时使用的DataTemplate。或者,ContentTemplateSelector属性是DataTemplateSelector类型,也使我们能够根据某些自定义条件选择DataTemplate。所有派生类都可以访问这些属性以塑造其内容的输出。
然而,这个控件也能够显示许多原始类型,而无需我们指定自定义模板。现在让我们继续到下一节,我们将了解它如何实现这一点。
展示内容
在 WPF 中,有一个特殊元素是基本但往往理解不深的。ContentPresenter类基本上展示内容,正如其名称所暗示的。它实际上在ContentControl对象内部使用来展示其内容。
这就是它的唯一任务,它不应该用于其他目的。我们唯一应该声明这些元素的时间是在ContentControl元素或其许多派生类的ControlTemplate中。在这些情况下,我们在实际内容出现的位置声明它们。
注意,当使用ContentPresenter时,在ControlTemplate上指定TargetType属性将导致其Content属性隐式地数据绑定到相关ContentControl元素的Content属性。然而,我们可以自由地将其显式地数据绑定到我们想要的任何内容:
<ControlTemplate x:Key="ButtonTemplate" TargetType="{x:Type Button}">
<ContentPresenter Content="{TemplateBinding ToolTip}" />
</ControlTemplate>
ContentTemplate和ContentTemplateSelector属性都与ContentControl类相同,并且也允许我们根据自定义条件选择DataTemplate。像Content属性一样,如果ControlTemplate的TargetType属性已设置,这两个属性也将隐式地绑定到模板父级中同名属性。
这通常可以让我们避免显式地将这些属性数据绑定,尽管有一些控件的相关属性名称并不匹配。在这些情况下,我们可以使用ContentSource属性作为快捷方式来数据绑定Content、ContentTemplate和ContentTemplateSelector属性。
如果我们将此属性设置为Header,例如,框架将在ContentControl对象上查找名为Header的属性,以隐式地将它绑定到表示器的Content属性。同样,它将查找名为HeaderTemplate和HeaderTemplateSelector的属性,以隐式地将它们绑定到ContentTemplate和ContentTemplateSelector属性。
这主要用于HeaderedContentControl元素或其派生类的ControlTemplate:
<ControlTemplate x:Key="TabItemTemplate" TargetType="{x:Type TabItem}">
<StackPanel>
<ContentPresenter ContentSource="Header" />
<ContentPresenter ContentSource="Content" />
</StackPanel>
</ControlTemplate>
有一些特定的规则决定了ContentPresenter将显示什么。如果设置了ContentTemplate或ContentTemplateSelector属性,那么由Content属性指定的数据对象将应用结果数据模板。同样,如果在ContentPresenter元素的范围内找到了相关类型的数据模板,它将被应用。
如果内容对象是一个 UI 元素,或者从类型转换器返回了一个,那么该元素将直接显示。如果对象是一个string,或者从类型转换器返回了一个string,那么它将被设置为TextBlock控件的Text属性,并显示出来。同样,所有其他对象都将调用其ToString方法,然后在运行时以标准TextBlock渲染此输出。
项目控制
我们已经看到了许多ItemsControl类的示例,但现在我们将更仔细地研究这个控件。简单来说,ItemsControl类包含可变数量的ContentPresenter元素,并使我们能够显示一系列项目。它是大多数常见集合控件(如ListBox、ComboBox和TreeView控件)的基类。
每个派生类都添加了特定的外观和功能集,例如边框和选中项的概念。如果我们不需要这些额外功能,只想显示多个项目,那么我们应该只使用ItemsControl,因为它比其派生类更高效。
当使用模型-视图-视图模型(MVVM)模式时,我们通常将实现IEnumerable接口的集合从我们的视图模型数据绑定到ItemsControl.ItemsSource属性。然而,还有一个Items属性将反映数据绑定集合中的项目。
为了进一步阐明这一点,可以使用任一属性来填充要显示的项目集合。但是,一次只能使用一个,所以如果你已经将集合绑定到ItemsSource属性,那么你不能使用Items属性添加项目。在这种情况下,Items集合将变为只读。
如果我们需要显示一个不实现IEnumerable接口的项目集合,那么我们需要使用Items属性添加它们。请注意,当在 XAML 中将项目声明为ItemsControl元素的内容时,会隐式使用Items属性。然而,当使用 MVVM 时,我们通常使用ItemsSource属性。
在显示ItemsControl中的项目时,集合中的每个项目都将隐式地被包裹在一个ContentPresenter容器元素中。容器元素的类型将取决于所使用的集合控件类型。例如,ComboBox会将其项目包裹在ComboBoxItem元素中。
ItemContainerStyle和ItemContainerStyleSelector属性使我们能够为这些容器项目提供样式。我们必须确保我们提供的样式针对正确的容器控件类型。例如,如果我们使用ListBox,那么我们需要提供一个针对ListBoxItem类型的样式,如下面的示例所示。
注意,我们可以显式声明这些容器项目,尽管这样做几乎没有意义,因为否则它们会被自动完成。此外,当使用 MVVM 时,我们通常不与 UI 元素一起工作,而是更喜欢在视图模型中处理数据对象并将数据绑定到ItemsSource属性。
正如我们已经看到的,ItemsControl类有一个ItemsPanel属性,其类型为ItemsPanelTemplate,这使我们能够更改集合控件用于布局其项目的面板类型。当我们想要自定义ItemsControl的模板时,我们有两个选择来渲染控件子项的方式:
<ControlTemplate x:Key="Template1" TargetType="{x:Type ItemsControl}">
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
</ControlTemplate>
我们在上一节中已经看到了前面方法的示例。这样,我们指定实际的项面板本身,并将IsItemsHost属性设置为true以指示它确实是要用作控件的项目面板。使用替代方法,我们需要声明一个ItemsPresenter元素,该元素指定实际项面板将被渲染的位置。请注意,此元素将在运行时被实际使用的项面板替换:
<ControlTemplate x:Key="Template2" TargetType="{x:Type ItemsControl}">
<ItemsPresenter />
</ControlTemplate>
与ContentControl类一样,ItemsControl类也提供了使我们能够塑造其数据项的属性。ItemTemplate和ItemTemplateSelector属性使我们能够为每个项目应用数据模板。然而,如果我们只需要简单的文本输出,还有其他方法可以避免完全定义数据模板的需求。
我们可以使用DisplayMemberPath属性来指定从对象中显示的属性名称。或者,我们可以设置ItemStringFormat属性以将输出格式化为string,或者如我们之前所见,只需从数据对象的类的ToString方法提供一些有意义的输出。
另一个有趣的属性是AlternationCount属性,它使我们能够以不同的方式样式化交替的容器。我们可以将其设置为任何数字,并且在渲染了这么多项目之后,交替序列将重复。作为一个简单的例子,让我们使用ListBox,因为将围绕我们的项目包装的ListBoxItem控件具有我们可以交替的外观属性:
<ListBox ItemsSource="{Binding Users}" AlternationCount="3">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="5" />
<Style.Triggers>
<Trigger Property="ListBox.AlternationIndex" Value="0">
<Setter Property="Background" Value="Red" />
</Trigger>
<Trigger Property="ListBox.AlternationIndex" Value="1">
<Setter Property="Background" Value="Green" />
</Trigger>
<Trigger Property="ListBox.AlternationIndex" Value="2">
<Setter Property="Background" Value="Blue" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
在这里,我们将AlternationCount属性设置为3,因此我们可以为我们的项目提供三种不同的样式,并且这种模式将重复应用于所有三个后续项目。我们使用ItemContainerStyle属性为项目容器创建一个样式。
在这种风格中,我们使用一些简单的触发器来根据AlternationIndex属性的值改变容器背景的颜色。请注意,AlternationCount属性从0开始,因此第一个项目将具有红色背景,第二个将具有绿色,第三个将具有蓝色,然后模式将重复,第四个将再次是红色,以此类推。
或者,我们可以为每个想要更改的属性声明一个AlternationConverter实例,并将它们绑定到AlternationIndex属性和转换器。我们可以使用以下 XAML 创建相同的视觉输出:
<ListBox ItemsSource="{Binding Users}" AlternationCount="3">
<ListBox.Resources>
<AlternationConverter x:Key="BackgroundConverter">
<SolidColorBrush>Red</SolidColorBrush>
<SolidColorBrush>Green</SolidColorBrush>
<SolidColorBrush>Blue</SolidColorBrush>
</AlternationConverter>
</ListBox.Resources>
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="5" />
<Setter Property="Background"
Value="{Binding (ItemsControl.AlternationIndex),
RelativeSource={RelativeSource Self},
Converter={StaticResource BackgroundConverter}}" />
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
AlternationConverter类通过简单地返回与其指定的AlternationIndex值相关的集合中的项目来工作,其中索引为零时返回第一个项目。请注意,我们需要在数据绑定的类和属性名称周围包含括号,因为它是一个附加属性,并且我们需要使用RelativeSource.Self绑定,因为该属性是在项目容器对象本身上设置的。让我们看看这两个代码示例的输出:

ItemsControl类还提供了一个有用的属性,即GroupStyle属性,它用于在组中显示子项。要在 UI 中分组项目,我们需要完成几个简单的任务。首先,我们需要为我们的Converters项目和ComponentModel CLR 命名空间定义 XAML 命名空间:
接下来,我们需要将一个 CollectionViewSource 实例与一个或多个 PropertyGroupDescription 元素数据绑定到之前示例中的 Users 集合。然后,我们需要将其设置为 ItemsControl 的 ItemsSource 值,并设置其 GroupStyle。让我们看看在本地 Resources 部分需要声明的 StringToFirstLetterConverter 转换器和 CollectionViewSource 对象:
<Converters:StringToFirstLetterConverter x:Key="StringToFirstLetterConverter" />
<CollectionViewSource x:Key="GroupedUsers" Source="{Binding MoreUsers}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Name"
Converter="{StaticResource StringToFirstLetterConverter}" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<ComponentModel:SortDescription PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
我们使用 PropertyGroupDescription 元素的 PropertyName 属性指定我们想要用来按项目分组的属性。注意,在我们的情况下,我们只有几个 User 对象,如果我们仅仅按名称分组,将不会有任何组。因此,我们添加了一个转换器,从每个名称中返回第一个字母以进行分组,并使用 Converter 属性指定它。
然后,我们在 CollectionViewSource.SortDescriptions 集合中添加了一个基本的 SortDescription 元素,以便对 User 对象进行排序。我们在 SortDescription 元素的 PropertyName 属性中指定了 Name 属性,这样 User 对象就会按名称排序。现在让我们看看 StringToFirstLetterConverter 类:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(string), typeof(string))]
public class StringToFirstLetterConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
CultureInfo culture)
{
if (value == null) return DependencyProperty.UnsetValue;
string stringValue = value.ToString();
if (stringValue.Length < 1) return DependencyProperty.UnsetValue;
return stringValue[0];
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
return DependencyProperty.UnsetValue;
}
}
}
在这个转换器中,我们在 ValueConversion 属性中指定了涉及转换器实现的数据类型,即使它们是相同类型。在 Convert 方法中,我们检查 value 输入参数的有效性,如果它是 null,则返回 DependencyProperty.UnsetValue 值。然后我们调用它的 ToString 方法,如果它是一个空字符串,我们返回 DependencyProperty.UnsetValue 值。对于所有有效的 string 值,我们简单地返回第一个字母。
由于我们不需要(或无法)使用此转换器转换任何内容,ConvertBack 方法简单地返回 DependencyProperty.UnsetValue 值。通过将此转换器附加到 PropertyGroupDescription 元素,我们现在能够按每个名称的第一个字母进行分组。现在让我们看看如何声明 GroupStyle 对象:
<ItemsControl ItemsSource="{Binding Source={StaticResource GroupedUsers}}"
Background="White" FontSize="14">
<ItemsControl.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name,
Converter={StaticResource StringToFirstLetterConverter}}"
Background="Black" Foreground="White" FontWeight="Bold"
Padding="5,4" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ItemsControl.GroupStyle>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type DataModels:User}">
<TextBlock Text="{Binding Name}" Foreground="Black"
Padding="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
注意,我们需要使用 Binding.Source 属性来访问本地 Resources 部分中名为 GroupedUsers 的 CollectionViewSource 对象。然后,我们在 HeaderTemplate 属性中声明数据模板,定义每个组标题的外观。在这里,我们使用了也在合适的资源集合中声明的 StringToFirstLetterConverter 实例,并设置了一些基本样式属性。
接下来,我们指定第二个数据模板,但这个模板定义了每个组中的项目应该是什么样子。我们提供了一个非常简单的模板,仅稍微间隔元素并设置了一些样式属性。让我们看看这个示例的输出:

修饰符
装饰器是一种特殊的类,它在所有 UI 控件之上渲染,被称为装饰器层。在这个层中的装饰器元素将始终渲染在正常 WPF 控件之上,无论它们的Panel.ZIndex属性设置如何。每个装饰器都绑定到类型为UIElement的元素,并独立地在相对于被装饰元素的位置进行渲染。
装饰器的目的是向应用程序用户提供某些视觉提示。例如,我们可以使用装饰器来显示正在拖放操作中拖动的 UI 元素的视觉表示。或者,我们可以使用装饰器向 UI 控件添加把手,使用户能够调整元素的大小。
当装饰器被添加到装饰器层时,装饰器层是装饰器的父级,而不是被装饰的元素。为了创建自定义装饰器,我们需要声明一个扩展Adorner类的类。
当创建自定义装饰器时,我们需要意识到我们负责编写代码以渲染其视觉元素。然而,构建我们的装饰器图形有几种不同的方法;我们可以使用OnRender或OnRenderSizeChanged方法以及绘图上下文来绘制基本的线条和形状,或者我们可以使用ArrangeOverride方法来排列.NET 控件。
装饰器像其他.NET 控件一样接收事件,尽管如果我们不需要处理它们,我们可以安排它们直接传递到被装饰的元素。在这些情况下,我们可以将IsHitTestVisible属性设置为false,这将启用被装饰元素的穿透式命中测试。让我们看看一个调整大小装饰器的例子,它允许我们在画布上调整形状的大小。
在我们研究装饰器类之前,让我们首先看看我们如何使用它。装饰器需要在代码中进行初始化,因此一个好的地方是在UserControl.Loaded方法中这样做,当我们可以确定画布及其项目已经初始化时。请注意,由于装饰器纯粹与 UI 相关,在控制器的代码后部初始化它们在使用 MVVM 时不会产生任何冲突:
public AdornerView()
{
InitializeComponent();
Loaded += View_Loaded;
}
...
private void View_Loaded(object sender, RoutedEventArgs e)
{
AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(Canvas);
foreach (UIElement uiElement in Canvas.Children)
{
adornerLayer.Add(new ResizeAdorner(uiElement));
}
}
我们使用AdornerLayer.GetAdornerLayer方法访问将要添加装饰器的画布的装饰器层,将画布作为Visual输入参数传递。在这个例子中,我们将我们的ResizeAdorner实例附加到画布Children集合中的每个元素上,然后将其添加到装饰器层。
现在,我们只需要一个名为Canvas的Canvas面板和一些需要调整大小的形状:
<Canvas Name="Canvas">
<Rectangle Canvas.Top="50" Canvas.Left="50" Fill="Lime"
Stroke="Black" StrokeThickness="3" Width="150" Height="50" />
<Rectangle Canvas.Top="25" Canvas.Left="250" Fill="Yellow"
Stroke="Black" StrokeThickness="3" Width="100" Height="150" />
</Canvas>
现在我们来看看我们的ResizeAdorner类中的代码:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Views.Adorners
{
public class ResizeAdorner : Adorner
{
private VisualCollection visualChildren;
private Thumb top, left, bottom, right;
public ResizeAdorner(UIElement adornedElement) : base(adornedElement)
{
visualChildren = new VisualCollection(this);
top = InitializeThumb(Cursors.SizeNS, Top_DragDelta);
left = InitializeThumb(Cursors.SizeWE, Left_DragDelta);
bottom = InitializeThumb(Cursors.SizeNS, Bottom_DragDelta);
right = InitializeThumb(Cursors.SizeWE, Right_DragDelta);
}
private Thumb InitializeThumb(Cursor cursor,
DragDeltaEventHandler eventHandler)
{
Thumb thumb = new Thumb();
thumb.BorderBrush = Brushes.Black;
thumb.BorderThickness = new Thickness(1);
thumb.Cursor = cursor;
thumb.DragDelta += eventHandler;
thumb.Height = thumb.Width = 6.0;
visualChildren.Add(thumb);
return thumb;
}
private void Top_DragDelta(object sender, DragDeltaEventArgs e)
{
FrameworkElement adornedElement = (FrameworkElement)AdornedElement;
adornedElement.Height =
Math.Max(adornedElement.Height - e.VerticalChange, 6);
Canvas.SetTop(adornedElement,
Canvas.GetTop(adornedElement) + e.VerticalChange);
}
private void Left_DragDelta(object sender, DragDeltaEventArgs e)
{
FrameworkElement adornedElement = (FrameworkElement)AdornedElement;
adornedElement.Width =
Math.Max(adornedElement.Width - e.HorizontalChange, 6);
Canvas.SetLeft(adornedElement,
Canvas.GetLeft(adornedElement) + e.HorizontalChange);
}
private void Bottom_DragDelta(object sender, DragDeltaEventArgs e)
{
FrameworkElement adornedElement = (FrameworkElement)AdornedElement;
adornedElement.Height =
Math.Max(adornedElement.Height + e.VerticalChange, 6);
}
private void Right_DragDelta(object sender, DragDeltaEventArgs e)
{
FrameworkElement adornedElement = (FrameworkElement)AdornedElement;
adornedElement.Width =
Math.Max(adornedElement.Width + e.HorizontalChange, 6);
}
protected override void OnRender(DrawingContext drawingContext)
{
SolidColorBrush brush = new SolidColorBrush(Colors.Transparent);
Pen pen = new Pen(new SolidColorBrush(Colors.DeepSkyBlue), 1.0);
drawingContext.DrawRectangle(brush, pen,
new Rect(-2, -2, AdornedElement.DesiredSize.Width + 4,
AdornedElement.DesiredSize.Height + 4));
}
protected override Size ArrangeOverride(Size finalSize)
{
top.Arrange(
new Rect(AdornedElement.DesiredSize.Width / 2 - 3, -8, 6, 6));
left.Arrange(
new Rect(-8, AdornedElement.DesiredSize.Height / 2 - 3, 6, 6));
bottom.Arrange(new Rect(AdornedElement.DesiredSize.Width / 2 - 3,
AdornedElement.DesiredSize.Height + 2, 6, 6));
right.Arrange(new Rect(AdornedElement.DesiredSize.Width + 2,
AdornedElement.DesiredSize.Height / 2 - 3, 6, 6));
return finalSize;
}
protected override int VisualChildrenCount
{
get { return visualChildren.Count; }
}
protected override Visual GetVisualChild(int index)
{
return visualChildren[index];
}
}
}
注意,我们在Views项目中声明了Adorners命名空间,因为这是它唯一会被使用的地方。在类内部,我们声明了将包含我们想要渲染的视觉的VisualCollection对象,然后是形状本身,即Thumb控件。
我们选择Thumb元素,因为它们具有我们想要利用的内置功能。它们提供了一个DragDelta事件,我们将用它来记录用户在拖动每个Thumb时的鼠标移动。这些控件通常在Slider和ScrollBar控件内部使用,以使用户能够更改值,因此它们非常适合我们的目的。
我们在构造函数中初始化这些对象,为每个Thumb控件指定一个自定义的光标和不同的DragDelta事件处理器。在这些独立的事件处理器中,我们使用DragDeltaEventArgs对象的HorizontalChange或VerticalChange属性来指定触发事件的鼠标移动的距离和方向。
我们使用这些值来移动和/或按适当的数量和方向调整装饰元素的大小。请注意,我们使用Math.Max方法和示例中的值6来确保装饰元素不能小于每个Thumb元素的大小和每个装饰元素的Stroke大小。
在四个DragDelta事件处理器之后,我们发现有两种不同的方式来渲染我们的装饰器视觉元素。在第一种方法中,我们使用基类通过OnRender方法传入的DrawingContext对象来手动绘制形状。这有点类似于我们过去在Windows.Forms中使用Control.Paint事件处理器方法绘制的方式。
在这个覆盖的方法中,我们绘制一个矩形,它包围我们的元素,并且在两个维度上比它大四像素。请注意,我们为绘图刷定义了一个透明的背景,因为我们只想看到矩形边框。记住,装饰器图形是在装饰元素之上渲染的,但我们不希望覆盖它。
在ArrangeOverride方法中,我们使用.NET Framework 通过它们的Arrange方法来渲染我们的Visual元素,就像在自定义面板中一样。请注意,我们同样可以在这种方法中使用Rectangle元素来渲染我们的矩形边框;在这个例子中,OnRender方法仅用作演示。
在这个方法中,我们依次排列每个Visual元素在相关的位置和大小。通过将每个装饰元素的宽度和高度各自除以二并减去每个拇指元素的宽度和高度的一半,可以简单地计算出适当的位置。
最后,我们来到了受保护的覆盖VisualChildrenCount属性和GetVisualChild方法。Adorner类扩展了FrameworkElement类,并且通常从VisualChildrenCount属性返回零或一,因为每个实例通常由没有视觉元素或单个渲染视觉元素表示。
在我们的情况和其他情况下,当派生类有多个视觉元素需要渲染时,布局系统要求指定正确的视觉元素数量。例如,如果我们总是从这个属性返回值 2,那么只有两个我们的缩略图会在屏幕上渲染。
同样,当我们被要求从 GetVisualChild 方法返回正确的项目时,我们也需要从我们的视觉集合中返回正确的项目。例如,如果我们总是从我们的集合中返回第一个视觉元素,那么只有那个视觉元素会被渲染,因为相同的视觉元素不能被渲染多次。让我们看看我们的装饰器在渲染到我们每个形状上方时的样子:

修改现有控件
当我们发现现有的广泛控件并不完全满足我们的需求时,我们可能会认为我们需要创建一些新的控件,就像使用其他技术一样。当使用其他 UI 语言时,这可能是情况,但使用 WPF 时,这并不一定正确,因为它提供了多种修改现有控件以适应我们需求的方法。
如我们之前所发现,所有扩展 FrameworkElement 类的类都可以访问框架的样式化能力,而扩展 Control 类的类可以通过它们的 ControlTemplate 属性完全改变外观。所有现有的 WPF 控件都扩展了这些基本案例,因此具有这些能力。
除了这些使我们能够改变现有 WPF 控件外观的能力之外,我们还能够利用附加属性的力量为它们添加额外的功能。在本节中,我们将探讨修改现有控件的不同方法。
样式化
设置控件的各个属性是改变其外观的最简单方法,使我们能够对其进行细微或更显著的变化。由于大多数 UI 元素扩展了 Control 类,它们大多数共享相同的属性,这些属性影响它们的外观和对齐。当为控件定义样式时,我们应该在 TargetType 属性中指定它们的类型,因为这有助于编译器验证我们设置的属性实际上存在于类中:
<Button Content="Go">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
<Setter Property="Background" Value="White" />
</Style>
</Button.Style>
</Button>
如果不这样做,编译器会指出成员不被识别或不可访问。在这些情况下,我们需要指定类类型,格式为 ClassName.PropertyName:
<Button Content="Go">
<Button.Style>
<Style>
<Setter Property="Button.Foreground" Value="Green" />
<Setter Property="Button.Background" Value="White" />
</Style>
</Button.Style>
</Button>
Style 类声明的一个非常有用的属性是 BasedOn 属性。使用这个属性,我们可以基于其他样式创建样式,这使我们能够创建多个逐步不同的版本。让我们用一个例子来强调这一点:
<Style x:Key="TextBoxStyle" TargetType="{x:Type TextBox}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="1.5,2" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style x:Key="ReadOnlyTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource TextBoxStyle}">
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="Cursor" Value="Arrow" />
</Style>
在这里,我们为我们的应用程序中的文本框定义了一个简单的样式。我们将其命名为 TextBoxStyle,然后在第二个样式的 BasedOn 属性中引用它。这意味着第一个样式中声明的所有属性设置器和触发器也将应用于底部样式。在第二个样式中,我们添加了一些进一步的设置器,使应用的文本框变为只读。
最后一点需要注意的是,如果我们想基于控件的默认样式创建一个样式,我们可以使用通常输入到 TargetType 属性中的值作为键来识别我们想要基于其创建新样式的样式:
<Style x:Key="ExtendedTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource {x:Type TextBox}}">
...
</Style>
让我们现在深入探讨资源。
充分利用资源
样式通常在应用程序的各种 Resources 字典中声明,包括各种模板、应用程序颜色和画笔。资源属性是 ResourceDictionary 类型,并在 FrameworkElement 类中声明,因此几乎所有 UI 元素都继承它,因此可以托管我们的样式和其他资源。
虽然资源属性是 ResourceDictionary 类型,但我们不需要显式声明此元素:
<Application.Resources>
<ResourceDictionary>
<!-- Add resources here -->
</ResourceDictionary>
</Application.Resources>
虽然有些情况下我们需要显式声明 ResourceDictionary,但如果我们不声明,它将隐式声明:
<Application.Resources>
<!-- Add Resources here -->
</Application.Resources>
每个集合中的每个资源都必须有一个唯一标识它们的键。我们使用 x:Key 指令显式设置此键,然而,它也可以隐式设置。当我们声明任何 Resources 部分中的样式时,我们可以仅指定 TargetType 值,而不设置 x:Key 指令,在这种情况下,样式将隐式应用于所有正确类型的元素,这些元素在样式的范围内:
<Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
<Setter Property="Background" Value="White" />
</Style>
</Resources>
在这种情况下,x:Key 指令的值隐式设置为 {x:Type Button}。或者,我们可以显式设置 x:Key 指令,这样样式也必须显式应用:
<Resources>
<Style x:Key="ButtonStyle">
<Setter Property="Button.Foreground" Value="Green" />
<Setter Property="Button.Background" Value="White" />
</Style>
</Resources>
...
<Button Style="{StaticResource ButtonStyle}" Content="Go" />
样式也可以同时设置两个值,如下面的代码所示:
<Resources>
<Style x:Key="ButtonStyle" TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Green" />
<Setter Property="Background" Value="White" />
</Style>
</Resources>
如果两个值都没有设置,将会抛出编译错误:
<Resources>
<Style>
<Setter Property="Foreground" Value="Green" />
<Setter Property="Background" Value="White" />
</Style>
</Resources>
上述 XAML 会导致以下编译错误:
The member "Foreground" is not recognized or is not accessible.
The member "Background" is not recognized or is not accessible.
当请求具有特定键的 StaticResource 时,查找过程首先在本地控件中查找;如果它有一个样式并且该样式有一个资源字典,它首先检查该样式;如果没有找到匹配的项,它接下来会在控件本身的资源集合中查找。
如果仍然没有匹配项,查找过程会检查每个后续父控件的资源字典,直到它达到 MainWindow.xaml 文件。如果它仍然找不到匹配项,那么它将在 App.xaml 文件中的应用程序 Resources 部分中查找。
StaticResource 查找在初始化时发生一次,并且对于大多数时间都符合我们的需求。当使用 StaticResource 来引用另一个资源中要使用的资源时,所使用的资源必须事先声明。也就是说,一个资源中的 StaticResource 查找不能引用在资源字典中之后声明的另一个资源:
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="{StaticResource RedBrush}" />
</Style>
<SolidColorBrush x:Key="RedBrush" Color="Red" />
上述 XAML 代码会导致以下错误:
The resource "RedBrush" could not be resolved.
简单地将画笔声明的位置移动到样式的声明之前,可以清除此错误并使应用程序重新运行。然而,在某些情况下,使用 StaticResource 来引用资源并不合适。例如,我们可能需要在程序或用户交互(如更改计算机主题)响应时更新我们的样式。
在这些情况下,我们可以使用 DynamicResource 来引用我们的资源,并且可以确信当相关资源更改时,我们的样式将更新。请注意,资源值只有在实际请求时才会查找,因此这对于在应用程序启动后才准备好的资源来说非常完美。注意以下修改后的示例:
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="{DynamicResource RedBrush}" />
</Style>
<SolidColorBrush x:Key="RedBrush" Color="Red" />
在这种情况下,将不会有编译错误,因为 DynamicResource 将在设置值时检索该值。虽然这种能力很棒,但重要的是不要滥用它,因为使用 DynamicResource 会负面影响性能。这是因为它们每次请求值时都会重复查找,无论值是否已更改。因此,我们只有在真正需要时才应使用 DynamicResource。
最后一点关于资源样式的讨论与范围有关。虽然这个主题在本书的其他地方已经提到,但在这里再次概述,因为它对于理解资源查找过程至关重要。在 App.xaml 文件中声明的应用程序资源是全局可用的,因此这是一个声明我们常用样式的绝佳位置。
然而,这是我们声明样式的最远离处,忽略外部资源字典和主题样式。一般来说,规则是,在资源标识符冲突的情况下,最本地资源覆盖那些声明得更远的资源。因此,我们可以在应用程序资源中定义我们的默认样式,同时保留在本地覆盖它们的能力。
相反,没有 x:Key 指令的本地声明样式将隐式应用于本地,但不会应用于外部声明的相关类型的元素。因此,我们可以在面板的 Resources 部分声明隐式样式,并且它们只会应用于面板内相对类型的元素。
资源合并
如果我们的应用程序很大,并且应用程序资源变得过于拥挤,我们可以选择将默认的颜色、画笔、样式、模板和其他资源拆分到不同的文件中。除了组织和管理的好处外,这还使得我们的主要资源文件可以在我们的其他应用程序之间共享,从而也促进了可重用性。
为了完成这个任务,我们首先需要添加一个或多个额外的资源文件。我们可以通过在 Visual Studio 中右键单击相关项目并选择“添加”选项,然后选择“资源字典...”选项来添加一个额外的资源文件。执行此命令后,我们将得到一个类似这样的文件:
<ResourceDictionary
>
</ResourceDictionary>
这是我们需要明确声明 ResourceDictionary 元素的情况之一。一旦我们将样式或其他资源转移到这个文件中,我们就可以像这样将其合并到我们的主要应用程序资源文件中:
<Application.Resources>
<ResourceDictionary>
<!-- Add Resources here... -->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Default Styles.xaml" />
<ResourceDictionary Source="Default Templates.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- ... or add resources here, but not in both locations -->
</ResourceDictionary>
</Application.Resources>
注意,我们不需要为这个资源字典指定 x:Key 指令。实际上,如果我们指定了这个值在字典上,我们会收到一个编译错误:
The "Key" attribute can only be used on an element that is contained in "IDictionary".
还要注意,我们可以将 ResourceDictionary.MergedDictionaries 的值设置在我们的本地声明的资源之上或之下,但不能在它们中间的任何位置。在这个属性中,我们可以为每个我们想要合并的外部资源文件声明另一个 ResourceDictionary 元素,并使用 Source 属性中的 统一资源标识符(URI)指定其位置。
如果我们的外部资源文件位于包含我们的 App.xaml 文件的启动项目中,我们可以使用相对路径引用它们,如前例所示。否则,我们需要使用 Pack URI 表示法。要从引用的程序集引用资源文件,我们需要使用以下格式:
pack://application:,,,/ReferencedAssembly;component/ResourceFile.xaml
在我们的情况下,假设我们有一些资源文件位于一个名为 Styles 的文件夹中,该文件夹位于一个单独的项目中,或者在其他引用的程序集中,我们会使用以下路径合并该文件:
<ResourceDictionary
Source="pack://application:,,,/CompanyName.ApplicationName.Resources;
component/Styles/Control Styles.xaml" />
在合并资源文件时,了解命名冲突将如何解决是很重要的。尽管我们为资源设置的 x:Key 指令必须在它们声明的资源字典中是唯一的,但在不同的资源文件中拥有重复的关键值是完全合法的。因此,在这些情况下将遵循一个优先级顺序。让我们看看一个例子。
想象一下,我们有一个单独的项目中提到的资源文件,在该文件中,我们有以下资源:
<SolidColorBrush x:Key="Brush" Color="Red" />
注意,我们需要在该项目中添加对 System.Xaml 程序集的引用,以避免错误。现在假设我们还有一个在先前的例子中引用的本地声明的 Default Styles.xaml 资源文件,在该文件中,我们有以下资源:
<SolidColorBrush x:Key="Brush" Color="Blue" />
让我们添加一个包含此资源的 Default Styles 2.xaml 资源文件:
<SolidColorBrush x:Key="Brush" Color="Orange" />
现在,假设我们将所有这些资源文件合并,并在我们的应用程序资源文件中添加以下附加资源:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Default Styles.xaml" />
<ResourceDictionary Source="Default Styles 2.xaml" />
<ResourceDictionary Source="pack://application:,,,/
CompanyName.ApplicationName.Resources;
component/Styles/Control Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="Brush" Color="Green" />
...
</ResourceDictionary>
</Application.Resources>
最后,让我们假设我们在某个视图的 XAML 中有以下内容:
<Button Content="Go">
<Button.Resources>
<SolidColorBrush x:Key="Brush" Color="Cyan" />
</Button.Resources>
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="{StaticResource Brush}" />
</Style>
</Button.Style>
</Button>
假设我们在该文件的本地资源中有以下内容:
<UserControl.Resources>
<SolidColorBrush x:Key="Brush" Color="Purple" />
</UserControl.Resources>
当运行应用程序时,我们的按钮文本将是青色,因为资源作用域的主要规则是,将使用的最高优先级资源将是声明得最本地化的资源。如果我们移除或注释掉本地画笔声明,那么在应用程序下次运行时,按钮文本将变为紫色。
如果我们从控件的控制Resources部分移除本地紫色画笔资源,应用程序资源将随后在尝试解决Brush资源键时被搜索。下一个一般规则是最晚声明的资源将被解决。这样,按钮文本将变为绿色,因为App.xaml文件中声明的本地资源将覆盖合并字典中的值。
然而,如果移除这个绿色画笔资源,会发生有趣的事情。根据最近宣布的规则,我们可能会预期按钮文本将由引用程序集的Control Styles.xaml资源文件设置为红色。相反,它将由Default Styles 2.xaml文件中的资源设置为橙色。
这是两个规则结合的结果。两个本地声明的资源文件比引用程序集的资源文件具有更高的优先级,因为它们比它声明得更本地化。第二个本地声明的资源文件比第一个具有优先级,因为它是在第一个之后声明的。
如果我们移除对第二个本地声明的资源文件的引用,文本将由Default Styles.xaml文件中的资源设置为蓝色。如果我们然后移除对该文件的引用,我们最终会看到由引用程序集的Control Styles.xaml文件设置的红色按钮文本。
触发更改
在 WPF 中,我们有多个Trigger类,使我们能够修改控件,尽管最常见的是临时修改。所有这些类都扩展了TriggerBase基类,因此继承其EnterActions和ExitActions属性。这两个属性使我们能够指定一个或多个在触发器变为活动状态和/或非活动状态时应用的TriggerAction对象。
虽然大多数触发类型也包含一个Setters属性,我们可以用它来定义当满足某个条件时应发生的属性设置器,但EventTrigger类没有。相反,它提供了一个Actions属性,使我们能够设置一个或多个在触发器变为活动状态时应用的TriggerAction对象。
此外,与其他触发器不同,EventTrigger类没有状态终止的概念。这意味着当触发条件不再为真时,EventTrigger应用的动作不会被撤销。如果你还没有猜到这一点,触发EventTrigger实例的条件是事件,或者更具体地说,是RoutedEvent对象。让我们首先通过一个简单的例子来研究这种类型的触发器,这个例子我们在第四章中看到过,精通数据绑定:
<Rectangle Width="300" Height="300" Fill="Orange">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="Width">
<DoubleAnimation Duration="0:0:1" To="50" AutoReverse="True"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
在本例中,当FrameworkElement.Loaded事件被触发时,触发条件得到满足。应用的动作是声明动画的开始。请注意,BeginStoryboard类实际上扩展了TriggerAction类,这解释了为什么我们能够在触发器中声明它。此动作将被隐式添加到EventTrigger对象的TriggerActionCollection中,尽管我们也可以显式设置如下:
<EventTrigger RoutedEvent="Loaded">
<EventTrigger.Actions>
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="Width">
<DoubleAnimation Duration="0:0:1" To="50" AutoReverse="True"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger.Actions>
</EventTrigger>
除了EventTrigger类之外,还有Trigger、DataTrigger、MultiTrigger和MultiDataTrigger类,使我们能够在满足特定条件或多个条件(在多触发器的情况下)时设置属性或控制动画。每个都有其优点,但除了可以在任何触发器集合中使用的EventTrigger类之外,还有一些限制我们可以在哪里使用它们。
每个扩展FrameworkElement类的控件都有一个Triggers属性,其类型为TriggerCollection,使我们能够指定我们的触发器。然而,如果你曾经尝试在那里声明触发器,那么你可能已经意识到我们只能在那里定义EventTrigger类型的触发器。
然而,我们还可以使用其他触发器集合来声明我们的其他类型触发器。当定义ControlTemplate时,我们可以访问ControlTemplate.Triggers集合。对于所有其他需求,我们可以在Style.Triggers集合中声明我们的其他触发器。记住,在样式中定义的触发器优先级高于在模板中声明的触发器。
现在我们来看看剩余的触发器类型以及它们能为我们做什么。我们从最简单的Trigger类开始。请注意,属性触发器能做的任何事情,DataTrigger类也能做。然而,属性触发器的语法更简单,不涉及数据绑定,因此更高效。
然而,使用属性触发器有一些要求,如下所示。相关的属性必须是依赖属性。与EventTrigger类不同,其他触发器不指定在触发条件满足时应用的动作,而是属性设置器。
我们可以在每个Trigger对象中指定一个或多个Setter对象,如果未明确指定,它们也将隐式添加到触发器的Setters属性集合中。请注意,与EventTrigger类不同,所有其他触发器在触发条件不再满足时都将返回原始属性值。让我们看一个简单的例子:
<Button Content="Go">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Black" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="Red" />
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
在这里,我们有一个按钮,当用户鼠标悬停在其上时,其文本颜色会改变。然而,与EventTrigger不同,当鼠标不再悬停在按钮上时,其文本颜色将返回到之前设置的颜色。请注意,属性触发器使用它们声明的控件的属性作为它们的条件,因为它们没有其他目标可以指定。
如前所述,DataTrigger类也可以执行相同的绑定。让我们看看它可能的样子:
<Button Content="Go">
<Button.Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Black" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsMouseOver,
RelativeSource={RelativeSource Self}}" Value="True">
<Setter Property="Foreground" Value="Red" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
如您所见,当使用DataTrigger时,我们不需要设置Trigger类的Property属性,而是需要设置Binding属性。为了实现与属性触发器相同的功能,我们还需要指定RelativeSource.Self枚举成员,以将绑定源设置为声明触发器的控件。
一般的规则是,当我们能够使用一个简单的属性触发器,该触发器使用宿主控件的属性在其条件中时,我们应该使用Trigger类。当我们需要使用另一个控件的属性或触发条件中的数据对象时,我们应该使用DataTrigger。现在让我们来看一个有趣的实际例子:
<Style x:Key="TextBoxStyle" TargetType="{x:Type TextBox}">
<Style.Triggers>
<DataTrigger Binding="{Binding DataContext.IsEditable,
RelativeSource={RelativeSource AncestorType={x:Type UserControl}},
FallbackValue=True}" Value="False">
<Setter Property="IsReadOnly" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
在这种风格中,我们添加了一个DataTrigger元素,该元素数据绑定到一个在视图模型类中声明的IsEditable属性,这将确定用户是否可以编辑屏幕上的控件中的数据。这假设视图模型实例已正确设置为UserControl.DataContext属性。
如果IsEditable属性的值为false,则TextBox.IsReadOnly属性将被设置为true,控件将变为不可编辑。使用这种技术,我们可以通过从视图模型设置此属性来使表单中的所有控件可编辑或不可编辑。
我们之前查看的触发器都使用了单个条件来触发它们的操作或属性更改。然而,偶尔我们可能需要多个条件来触发我们的属性更改。例如,在一种情况下,我们可能想要一种特定的样式,而在另一种情况下,我们可能想要不同的外观。让我们看一个例子:
<Style x:Key="ButtonStyle" TargetType="{x:Type Button}">
<Setter Property="Foreground" Value="Black" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="Red" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsFocused" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="Green" />
</MultiTrigger>
</Style.Triggers>
</Style>
在这个例子中,我们有两个触发器。第一个会在鼠标悬停在按钮上时将其文本颜色变为红色。第二个如果鼠标悬停在按钮上并且按钮被聚焦时,会将按钮文本颜色变为绿色。
注意,我们必须按照这个顺序声明这两个触发器,因为触发器是从上到下应用的。如果我们交换它们的顺序,那么文本永远不会变成绿色,因为单个触发器总是会覆盖第一个触发器设置的值。
我们可以在Conditions集合中指定所需数量的Condition元素,并在MultiTrigger元素本身中指定所需数量的设置器。然而,每个条件都必须返回 true,才能应用设置器或其他触发器操作。
对于这里将要介绍的最后一个触发器类型,即MultiDataTrigger,也可以说同样的话。这个触发器与上一个触发器之间的区别与属性触发器和数据触发器之间的区别相同。也就是说,数据和多数据触发器具有更广泛的目标源范围,而触发器和多触发器仅与本地控件的属性一起工作:
<StackPanel>
<CheckBox Name="ShowErrors" Content="Show Errors" Margin="0,0,0,10" />
<TextBlock>
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Text" Value="No Errors" />
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsValid}" Value="False" />
<Condition Binding="{Binding IsChecked,
ElementName=ShowErrors}" Value="True" />
</MultiDataTrigger.Conditions>
<MultiDataTrigger.Setters>
<Setter Property="Text" Value="{Binding ErrorList}" />
</MultiDataTrigger.Setters>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
...
</StackPanel>
这个例子展示了MultiDataTrigger类的更广泛的应用范围,这是由于其可以访问广泛的绑定源。我们有一个显示错误的复选框,一个无错误的文本块,以及一些其他表单字段,这里没有显示。这个触发器的其中一个条件使用ElementName属性将绑定源设置为复选框,并要求它被勾选。
另一个条件绑定到我们的视图模型中的IsValid属性,如果没有验证错误,则该属性会被设置为true。其思路是,当复选框被勾选且存在验证错误时,TextBlock元素的Text属性将数据绑定到另一个名为ErrorList的视图模型属性,该属性可以输出验证错误的描述。
还要注意,在这个例子中,我们明确声明了Setters集合属性并在其中定义了我们的设置器。然而,这是可选的,我们可以在不声明集合的情况下隐式地将设置器添加到同一个集合中,就像之前的MultiTrigger例子所示。
在进入下一个主题之前,让我们花点时间研究一下TriggerBase类的EnterActions和ExitActions属性,这些属性使我们能够指定一个或多个TriggerAction对象,在触发器变为活动状态和/或非活动状态时应用。
注意,我们无法在这些集合中指定样式设置器,因为它们不是TriggerAction对象;设置器可以添加到Setters集合中。相反,我们使用这些属性在触发器变为活动状态和/或非活动状态时启动动画。为此,我们需要添加一个BeginStoryboard元素,它扩展了TriggerAction类。让我们看一个例子:
<TextBox Width="200" Height="28">
<TextBox.Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Opacity" Value="0.25" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="Opacity">
<DoubleAnimation Duration="0:0:0.25" To="1.0" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="Opacity">
<DoubleAnimation Duration="0:0:0.25" To="0.25" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
在这个例子中,Trigger条件与TextBox控件的IsMouseOver属性相关。请注意,当使用IsMouseOver属性时,在EnterActions和ExitActions属性中声明我们的动画实际上等同于有两个EventTrigger元素,一个用于MouseEnter事件,另一个用于MouseLeave事件。
在这个例子中,EnterActions集合中的动画将在用户的鼠标光标进入控件时开始,而ExitActions集合中的动画将在用户的鼠标光标离开控件时开始。
我们将在第七章掌握实用动画中详细讲解动画,但简而言之,当用户的鼠标光标进入控件时开始的动画,将使控件从几乎透明渐变到不透明。
另一个动画将在用户鼠标光标离开控件时将TextBox控件恢复到几乎透明状态。当鼠标拖动到具有这种样式的多个控件上时,这会产生一个很好的效果。现在我们已经很好地理解了触发器,让我们继续寻找其他自定义标准.NET 控件的方法。
控件模板化
虽然我们可以通过样式极大地改变每个控件的外观,但有时我们需要改变它们的模板以达到我们的目标。例如,没有直接的方法仅通过样式来改变按钮的背景颜色。在这些情况下,我们需要更改控件的默认模板。
所有扩展Control类的 UI 元素都提供了对其Template属性的访问。这个属性是ControlTemplate类型,使我们能够完全替换原来声明的模板,该模板定义了控件的正常外观。我们在第四章中看到了一个简单的例子,即精通数据绑定,但现在让我们看看另一个例子:
<Button Content="Go" Width="100" HorizontalAlignment="Center">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Grid>
<Ellipse Fill="Orange" Stroke="Black" StrokeThickness="3"
Height="{Binding ActualWidth,
RelativeSource={RelativeSource Self}}" />
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" TextElement.FontSize="18"
TextElement.FontWeight="Bold" />
</Grid>
</ControlTemplate>
</Button.Template>
</Button>
在这里,我们有一个我们改变成圆形样式的按钮。它非常基础,因为我们没有费心定义任何鼠标悬停或点击效果,但它表明覆盖控件的默认模板并没有什么可怕之处,而且实现起来很简单:

注意,ContentPresenter元素是在Ellipse元素之后声明的,因为椭圆不是一个内容控件,不能将另一个元素设置为它的内容。这导致内容被绘制在椭圆的上方。这个副作用是我们因此需要在模板内部添加一个面板,以便我们能够提供不止一个内容片段。
还要注意,与样式一样,我们需要指定模板的TargetType属性。为了澄清这一点,如果我们想要数据绑定到控件的任何属性,或者如果模板包含一个ContentPresenter元素,我们需要指定它。在后者的情况下,省略此声明不会引发编译错误,但内容将不会出现在我们的模板控件中。因此,始终将此属性设置为适当的类型是一个好习惯。
然而,与样式不同,如果我们声明了一个ControlTemplate并在Resources集合中设置了其TargetType属性,但没有指定x:Key指令,则它不会隐式应用于应用程序中的所有按钮。在这种情况下,我们会收到一个编译错误:
Each dictionary entry must have an associated key.
相反,我们需要设置x:Key指令并显式地将模板应用于控件的Template属性。如果我们希望我们的模板应用于该类型的每个控件,那么我们需要将其设置在该类型的默认样式上。在这种情况下,我们需要不设置样式的x:Key指令,这样它就会隐式应用:
<ControlTemplate x:Key="ButtonTemplate" TargetType="{x:Type Button}">
...
</ControlTemplate>
<Style TargetType="{x:Type Button}">
<Setter Property="Template" Value="{StaticResource ButtonTemplate}" />
</Style>
注意,我们通常不会像在这个模板示例中那样硬编码属性值,除非我们不希望我们的框架用户能够设置我们模板控件的自己的颜色。更常见的是,我们会正确使用TemplateBinding类来应用从控件外部设置的值到我们模板内定义的内部控件:
<Button Content="Go" Width="100" HorizontalAlignment="Center"
Background="Orange" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center" FontSize="18">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Grid>
<Ellipse Fill="{TemplateBinding Background}"
Stroke="{TemplateBinding Foreground}" StrokeThickness="3"
Height="{Binding ActualWidth,
RelativeSource={RelativeSource Self}}" />
<ContentPresenter HorizontalAlignment="{TemplateBinding
HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding
VerticalContentAlignment}"
TextElement.FontWeight="{TemplateBinding FontWeight}"
TextElement.FontSize="{TemplateBinding FontSize}" />
</Grid>
</ControlTemplate>
</Button.Template>
</Button>
虽然这个例子现在更加冗长,但它也更加实用,并使用户能够设置自己的按钮属性。在默认样式中设置此模板会使模板控件更加可重用。请注意,现在,硬编码的值是在按钮控件本身上进行的,除了StrokeThickness属性之外。
在Button类上没有合适的属性,我们可以用它来公开这个内部控件属性。如果这对我们来说是个问题,我们可以在自定义附加属性中公开该属性的值,并在按钮上绑定到它,如下所示:
<Button Attached:ButtonProperties.StrokeThickness="3" ... />
我们可以在控件模板内部执行以下操作:
<Ellipse StrokeThickness=
"{Binding (Attached:ButtonProperties.StrokeThickness)}" ... />
然而,尽管我们已经改进了我们的模板,但默认模板中定义的一些元素会影响其包含控件的看起来或工作方式。如果我们移除了这些元素,就像我们在前面的示例中所做的那样,我们将破坏默认功能。例如,我们的示例按钮不再具有聚焦或交互效果。
有时,我们可能只需要稍微调整原始模板,在这种情况下,我们通常会从默认的ControlTemplate开始,然后对其进行轻微调整。如果我们对我们的按钮示例做了这样的处理,仅仅替换了视觉方面,那么我们就可以保留其原始的交互性。
在过去的日子里,找到各种控件默认的控制模板可能相当困难。我们之前需要尝试在 docs.microsoft.com 网站上追踪它们,或者使用 Blend;然而,现在我们可以使用 Visual Studio 来为我们提供它。
在 WPF 设计器中,选择相关的控件,或者在 XAML 文件中用鼠标点击它。在选择了相关控件或聚焦后,按键盘上的 F4 键以打开属性窗口。接下来,打开“杂项”类别以找到模板属性,或者在属性窗口顶部的搜索字段中输入“模板”。
点击模板值字段右侧的小方块,并在模板选项工具提示中选择“转换为新资源...”项。在出现的弹出对话框窗口中,为新添加的 ControlTemplate 命名,并决定你希望它在何处定义:

一旦你输入了所需的详细信息,点击“确定”按钮以在所需位置创建所选控件默认模板的副本。例如,让我们看看 TextBox 控件的默认控件模板:
<ControlTemplate TargetType="{x:Type TextBox}">
<Border Name="border" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
SnapsToDevicePixels="True">
<ScrollViewer Name="PART_ContentHost" Focusable="False"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" TargetName="border" Value="0.56" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" TargetName="border"
Value="#FF7EB4EA" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter Property="BorderBrush" TargetName="border"
Value="#FF569DE5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
如我们所见,大多数设置在内部控件上的属性都通过使用 TemplateBinding 类暴露给了 TextBox 控件。在模板的末尾是响应各种状态(如焦点、鼠标悬停和启用状态)的触发器。
然而,在 Border 元素内部,我们看到一个名为 PART_ContentHost 的 ScrollViewer。这个以 PART_ 前缀命名的现实表明这个控件必须在这个模板中使用。每个 UI 元素的每个命名部分都将列在 docs.microsoft.com 的 [ControlType] 样式和模板 页面上。
这个命名的部分控件在文本框中是必需的,因为当文本框初始化时,它会将 TextBoxView 和 CaretElement 对象程序化地添加到 ScrollViewer 对象中,这些是构成文本框功能的主要元素。
这些特别命名的元素也需要在声明类中进行注册,我们将在本章后面了解更多关于这一点。因此,如果我们想保持现有功能,我们很重要地将这些命名控件包含在我们的自定义模板中。
注意,如果我们不包括这些命名的控件,我们将不会收到任何编译错误,甚至不会有跟踪警告,并且如果我们不需要它们的相关功能,我们可以自由地省略它们。以下示例虽然几乎不起作用,但仍然完全有效:
<TextBox Text="Hidden Text Box">
<TextBox.Template>
<ControlTemplate TargetType="{x:Type TextBox}">
<ContentPresenter Content="{TemplateBinding Text}" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
虽然这个TextBox控件确实会显示指定的文本值,但它将没有像正常TextBox元素那样的容器。当这个模板被渲染时,ContentPresenter元素将看到一个string并默认在TextBlock元素中显示它。
它的Text属性仍然绑定到我们的TextBox控件的Text属性,因此当它获得焦点时,它仍然会像正常的TextBox元素一样表现,并允许我们输入文本。当然,我们不会看到它获得焦点,因为我们没有添加任何触发器来实现这一点,并且不会出现光标,因为CaretElement对象将不再被添加。
相反,如果我们仅仅提供所需的命名控件,即使没有任何其他内容,我们仍然可以恢复大部分原始功能:
<TextBox Name="Text" Text="Does this work?">
<TextBox.Template>
<ControlTemplate TargetType="{x:Type TextBox}">
<ScrollViewer Margin="0" Name="PART_ContentHost" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
现在,当我们运行我们的应用程序时,当鼠标悬停在TextBox控件上时,我们将有光标和文本光标,因此我们恢复了更多的功能,但不是外观。然而,通常最好的选择是尽可能保留原始模板,只更改我们真正需要更改的部分。
附加属性
当使用 WPF 时,我们还有一个额外的工具可以用来操作内置控件并避免创建新的控件。我们当然是在讨论附加属性,所以让我们扩展一个我们在第四章,“精通数据绑定”中开始探讨的例子。
为了创建一个按钮,使我们能够设置当控件禁用时显示的第二个提示信息,我们需要声明两个附加属性。一个将保存禁用时的提示信息,另一个将是之前提到的只读属性,它暂时保存原始提示值。现在让我们看看完整的ButtonProperties类:
using System.Windows;
using System.Windows.Controls;
namespace CompanyName.ApplicationName.Views.Attached
{
public class ButtonProperties : DependencyObject
{
private static readonly DependencyPropertyKey
originalToolTipPropertyKey =
DependencyProperty.RegisterAttachedReadOnly("OriginalToolTip",
typeof(string), typeof(ButtonProperties),
new FrameworkPropertyMetadata(default(string)));
public static readonly DependencyProperty OriginalToolTipProperty =
originalToolTipPropertyKey.DependencyProperty;
public static string GetOriginalToolTip(
DependencyObject dependencyObject)
{
return
(string)dependencyObject.GetValue(OriginalToolTipProperty);
}
public static DependencyProperty DisabledToolTipProperty =
DependencyProperty.RegisterAttached("DisabledToolTip",
typeof(string), typeof(ButtonProperties),
new UIPropertyMetadata(string.Empty, OnDisabledToolTipChanged));
public static string GetDisabledToolTip(
DependencyObject dependencyObject)
{
return (string)dependencyObject.GetValue(
DisabledToolTipProperty);
}
public static void SetDisabledToolTip(
DependencyObject dependencyObject, string value)
{
dependencyObject.SetValue(DisabledToolTipProperty, value);
}
private static void OnDisabledToolTipChanged(DependencyObject
dependencyObject, DependencyPropertyChangedEventArgs e)
{
Button button = dependencyObject as Button;
ToolTipService.SetShowOnDisabled(button, true);
if (e.OldValue == null && e.NewValue != null)
button.IsEnabledChanged += Button_IsEnabledChanged;
else if (e.OldValue != null && e.NewValue == null)
button.IsEnabledChanged -= Button_IsEnabledChanged;
}
private static void Button_IsEnabledChanged(object sender,
DependencyPropertyChangedEventArgs e)
{
Button button = sender as Button;
if (GetOriginalToolTip(button) == null)
button.SetValue(originalToolTipPropertyKey,
button.ToolTip.ToString());
button.ToolTip = (bool)e.NewValue ?
GetOriginalToolTip(button) : GetDisabledToolTip(button);
}
}
}
与所有附加属性一样,我们从一个扩展了DependencyObject类的类开始。在这个类中,我们首先使用RegisterAttachedReadOnly方法和OriginalToolTipProperty属性及其关联的 CLR 获取器声明只读的originalToolTipPropertyKey字段。
接下来,我们使用RegisterAttached方法注册DisabledToolTip属性,该属性将保存当控件禁用时显示的提示信息。然后我们看到它的 CLR 获取器和设置方法以及至关重要的PropertyChangedCallback处理方法。
在OnDisabledToolTipChanged方法中,我们首先将dependencyObject输入参数转换为其实际类型Button。然后我们使用它来设置ToolTipService.SetShowOnDisabled附加属性为true,这是必需的,因为我们希望按钮的提示信息在按钮禁用时显示。默认值是false,所以没有这一步我们的附加属性将不会工作。
接下来,我们根据 DependencyPropertyChangedEventArgs 对象的 NewValue 和 OldValue 属性值确定是否需要附加或分离 Button_IsEnabledChanged 事件处理方法。如果旧值是 null,则属性之前尚未设置,我们需要附加处理程序;如果新值是 null,则我们需要分离处理程序。
在 Button_IsEnabledChanged 事件处理方法中,我们首先将 sender 输入参数转换为 Button 类型。然后我们使用它来访问 OriginalToolTip 属性,如果它是 null,我们就使用控制器的正常 ToolTip 属性的当前值来设置它。请注意,我们需要将 originalToolTipPropertyKey 字段传递给 SetValue 方法,因为它是一个只读属性。
最后,我们利用 e.NewValue 属性值来确定是否将原始提示或禁用提示设置为控制器的正常 ToolTip 属性。因此,如果控制器处于启用状态,e.NewValue 属性值将是 true,并返回原始提示;如果按钮被禁用,则显示禁用提示。我们可以如下使用这个附加属性:
<Button Content="Save" Attached:ButtonProperties.DisabledToolTip="You must
correct validation errors before saving" ToolTip="Saves the user" />
如此简单的示例所示,附加属性使我们能够轻松地向现有的 UI 控件系列添加新功能。这再次突出了 WPF 的多功能性,并证明了我们通常没有必要创建全新的控件。
结合控制
当我们需要以特定方式排列多个现有控件时,我们通常使用 UserControl 对象。这就是为什么我们通常使用这种类型的控件来构建我们的视图。然而,当我们需要构建一个可重用控件,例如地址控件时,我们倾向于将它们与我们的视图分开,通过在视图项目中 Controls 文件夹和命名空间内声明它们来实现。
在声明这些可重用控件时,通常在代码后定义依赖属性,只要控件中没有业务相关的功能,也可以使用代码后处理事件。如果控件与业务相关,则可以使用与正常视图相同的视图模型。让我们看看地址控件的一个示例:
<UserControl x:Class=
"CompanyName.ApplicationName.Views.Controls.AddressControl"
xmlns:Controls=
"clr-namespace:CompanyName.ApplicationName.Views.Controls">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Label" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="House/Street" />
<TextBox Grid.Column="1" Text="{Binding Address.HouseAndStreet,
RelativeSource={RelativeSource
AncestorType={x:Type Controls:AddressControl}}}" />
<TextBlock Grid.Row="1" Text="Town" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding Address.Town, RelativeSource={RelativeSource
AncestorType={x:Type Controls:AddressControl}}}" />
<TextBlock Grid.Row="2" Text="City" />
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Address.City, RelativeSource={RelativeSource
AncestorType={x:Type Controls:AddressControl}}}" />
<TextBlock Grid.Row="3" Text="Post Code" />
<TextBox Grid.Row="3" Grid.Column="1"
Text="{Binding Address.PostCode, RelativeSource={RelativeSource
AncestorType={x:Type Controls:AddressControl}}}" />
<TextBlock Grid.Row="4" Text="Country" />
<TextBox Grid.Row="4" Grid.Column="1"
Text="{Binding Address.Country, RelativeSource={RelativeSource
AncestorType={x:Type Controls:AddressControl}}}" />
</Grid>
</UserControl>
在这个示例中,我们在 Controls 命名空间内声明这个类,并为它设置一个 XAML 命名空间前缀。然后我们看到用于布局地址控件的 Grid 面板,并注意到 SharedSizeGroup 属性被设置在定义标签列的 ColumnDefinition 元素上。这将使此控件内的列大小可以与外部声明的控件共享。
我们接下来看到所有绑定到控制器地址字段的数据绑定 TextBlock 和 TextBox 控件。这里没有太多需要注意的,除了数据绑定属性都是通过一个 RelativeSource 绑定到一个在 AddressControl 代码后文件中声明的 Address 依赖属性来访问的。
记住,在使用 MVVM 模式时,只要我们不在这里封装任何业务规则,这样做是可以的。我们的控件仅仅允许用户输入或添加地址信息,这些信息将被各种视图和视图模型使用。现在让我们看看这个属性:
using System.Windows;
using System.Windows.Controls;
using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class AddressControl : UserControl
{
public AddressControl()
{
InitializeComponent();
}
public static readonly DependencyProperty AddressProperty =
DependencyProperty.Register(nameof(Address),
typeof(Address), typeof(AddressControl),
new PropertyMetadata(new Address()));
public Address Address
{
get { return (Address)GetValue(AddressProperty); }
set { SetValue(AddressProperty, value); }
}
}
}
这是一个非常简单的控件,只有一个依赖属性。我们可以看到 Address 属性是 Address 类型,所以让我们快速看一下这个类:
namespace CompanyName.ApplicationName.DataModels
{
public class Address : BaseDataModel
{
private string houseAndStreet, town, city, postCode, country;
public string HouseAndStreet
{
get { return houseAndStreet; }
set { if (houseAndStreet != value) { houseAndStreet = value;
NotifyPropertyChanged(); } }
}
public string Town
{
get { return town; }
set { if (town != value) { town = value; NotifyPropertyChanged(); } }
}
public string City
{
get { return city; }
set { if (city != value) { city = value; NotifyPropertyChanged(); } }
}
public string PostCode
{
get { return postCode; }
set { if (postCode != value) { postCode = value;
NotifyPropertyChanged(); } }
}
public string Country
{
get { return country; }
set { if (country != value) { country = value;
NotifyPropertyChanged(); } }
}
public override string ToString()
{
return $"{HouseAndStreet}, {Town}, {City}, {PostCode}, {Country}";
}
}
}
同样,这是一个主要由地址相关属性组成的非常简单的类。注意在重写的 ToString 方法中使用字符串插值来输出有用的类内容。现在我们已经看到了控件,让我们看看我们如何在应用程序中使用它。我们可以编辑我们之前看到的视图,所以现在让我们看看更新的 UserView XAML:
<Grid TextElement.FontSize="14" Grid.IsSharedSizeScope="True" Margin="10">
<Grid.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,5,5" />
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,0,5" />
</Style>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Label" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Name" />
<TextBox Grid.Column="1" Text="{Binding User.Name}" />
<TextBlock Grid.Row="1" Text="Age" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding User.Age}" />
<Controls:AddressControl Grid.Row="2" Grid.ColumnSpan="2"
Address="{Binding User.Address}" />
</Grid>
在这个例子中,我们可以看到在最外层的 Grid 面板上使用 Grid.IsSharedSizeScope 属性。记住,在 AddressControl XAML 中设置了 SharedSizeGroup 属性,尽管在没有在外部的 Grid 上设置此设置的情况下,它本身不会做任何事情。
看一下外部面板的列定义,我们可以看到我们还设置了 SharedSizeGroup 属性,使其与左侧列上的 Label 的相同值,以便两个面板的列对齐。
我们可以跳过在面板的 Resources 部分声明的两个样式,因为在正确应用中,这些样式很可能位于应用程序资源文件中。在视图的其余部分,我们只有几行用户属性,然后是 AddressControl。
此代码假设我们在 User 类中声明了一个类型为 Address 的 Address 属性,并在 UserViewModel 类中用合适的值填充了它。注意我们如何将 User 类的 Address 属性数据绑定到控件的 Address 属性,而不是设置 DataContext 属性。由于控件的内部控件使用 RelativeSource 绑定进行数据绑定,这些绑定指定了它们自己的绑定源,因此它们不需要设置任何 DataContext。实际上,在这个例子中这样做会阻止它工作。
创建自定义控件
当使用 WPF 时,我们可以使用本书中已经讨论过的许多技术来创建我们想要的 UI。然而,在需要具有自定义绘制外观和自定义功能的独特控件的情况下,我们可能需要声明一个自定义控件。
开发自定义控件与创建 UserControl 元素非常不同,掌握这一技能可能需要一些时间。首先,我们需要添加一个新的项目,类型为 WPF 自定义控件库,以便在其中声明它们。此外,我们只有代码文件,而没有 XAML 页面和代码后文件。在这个阶段,你可能想知道我们将在哪里定义我们的控件的外观。
实际上,在定义自定义控件时,我们在一个名为Generic.xaml的单独文件中声明我们的 XAML,这是 Visual Studio 在我们添加控件项目时添加的。为了澄清,我们在这个项目中声明的所有自定义控件的 XAML 都将放入这个文件中。这并不涉及扩展UserControl类的控件,我们不应该在这个项目中声明这些控件。
这个Generic.xaml文件被添加到我们的 WPF 自定义控件库项目的根目录下的Themes文件夹中,因为框架将在这里查找我们自定义控件的默认样式。因此,我们必须在这个文件中声明控件的 UI 设计,并将其设置为针对该文件中我们控件类型的样式的Template属性。
样式必须应用于我们控件的每个实例,因此样式定义时将TargetType设置,但不设置x:Key指令。如果你还记得,这将确保它隐式应用于所有没有显式应用替代模板的我们控件的实例。
另一个不同之处在于,我们无法直接在Generic.xaml文件中引用在样式内定义的任何控件。如果你还记得,当我们为内置控件提供新模板时,我们没有义务提供最初使用的相同控件。因此,如果我们尝试访问已被替换的原模板中的控件,将会引发错误。
相反,我们通常需要通过重写FrameworkElement.OnApplyTemplate方法来访问它们,该方法在我们控件的实例应用模板后会被触发。在这个方法中,我们应该预期所需的控件(们)可能缺失,并确保在这种情况下不会发生错误。
让我们看看一个简单的自定义控件示例,该控件可以创建一个仪表,用于监控 CPU 活动、RAM 使用、音频音量或任何其他定期变化的值。我们首先需要创建一个新的 WPF 自定义控件库类型项目,并将 Visual Studio 为我们添加的CustomControl1.cs类重命名为Meter.cs。
注意,我们只能将自定义控件添加到这种类型的项目中,并且当项目被添加时,Visual Studio 也会添加我们的Themes文件夹和Generic.xaml文件,其中已经声明了我们的控件样式。让我们看看Meter.cs文件中的代码:
using System;
using System.Windows;
using System.Windows.Controls;
namespace CompanyName.ApplicationName.CustomControls
{
public class Meter : Control
{
static Meter()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Meter),
new FrameworkPropertyMetadata(typeof(Meter)));
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value),
typeof(double), typeof(Meter),
new PropertyMetadata(0.0, OnValueChanged, CoerceValue));
private static object CoerceValue(DependencyObject dependencyObject,
object value)
{
return Math.Min(Math.Max((double)value, 0.0), 1.0);
}
private static void OnValueChanged(DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
Meter meter = (Meter)dependencyObject;
meter.SetClipRect(meter);
}
public double Value
{
get { return (double)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
public static readonly DependencyPropertyKey clipRectPropertyKey =
DependencyProperty.RegisterReadOnly(nameof(ClipRect), typeof(Rect),
typeof(Meter), new PropertyMetadata(new Rect()));
public static readonly DependencyProperty ClipRectProperty =
clipRectPropertyKey.DependencyProperty;
public Rect ClipRect
{
get { return (Rect)GetValue(ClipRectProperty); }
private set { SetValue(clipRectPropertyKey, value); }
}
public override void OnApplyTemplate()
{
SetClipRect(this);
}
private void SetClipRect(Meter meter)
{
double barSize = meter.Value * meter.Height;
meter.ClipRect =
new Rect(0, meter.Height - barSize, meter.Width, barSize);
}
}
}
这是一个相对较小的类,只有两个依赖属性及其关联的 CLR 属性包装器和回调处理程序。特别值得注意的是类的静态构造函数和DefaultStyleKeyProperty.OverrideMetadata方法的用法。
这也是 Visual Studio 在添加类时添加的,当从FrameworkElement类派生自定义类时,需要重写DefaultStyleKey依赖属性的特定类型元数据。
具体来说,这个键被框架用来找到我们控件的默认主题样式,因此,通过将我们类的类型传递给OverrideMetadata方法,我们告诉框架在我们的Themes文件夹中查找此类型的默认样式。
如果你还记得,主题样式是框架最后会查找特定类型样式的位置,而在应用程序的其他任何地方声明样式都将覆盖这里定义的默认样式。
第一个依赖属性是控件的主要Value属性,它用于确定可见仪表条的大小。此属性定义了一个默认值0.0,并附加了CoerceValue和OnValueChanged回调处理程序。
在CoerceValue处理方法中,我们确保输出值始终保持在0.0和1.0之间,因为这是我们将会使用的刻度。在OnValueChanged处理程序中,我们根据输入值更新其他依赖属性ClipRect的值。
要做到这一点,我们首先将dependencyObject输入参数转换为我们的Meter类型,然后将该实例传递给SetClipRect方法。在这个方法中,我们计算仪表条的相对大小,并根据此定义ClipRect依赖属性的Rect元素。
接下来,我们看到Value依赖属性的 CLR 属性包装器,然后是ClipRect依赖属性的声明。注意,我们使用DependencyPropertyKey元素来声明它,因此使其成为一个只读属性,因为它仅用于内部使用,公开它没有价值。实际的ClipRect依赖属性来自这个键元素。
之后,我们看到ClipRect依赖属性的 CLR 属性包装器,然后我们来到上述的OnApplyTemplate方法。在我们的情况下,重写此方法的目的通常是因为数据绑定值将在控件模板应用之前被设置,因此我们无法从这些值中正确设置仪表条的大小。
因此,当模板已经应用,控件已经排列和调整大小时,我们调用SetClipRect方法来设置ClipRect依赖属性的Rect元素为适当的值。在此时间点之前,meter实例的Height和Weight属性将是double.NaN(其中NaN代表Not a Number),无法正确调整Rect元素的大小。
当这个方法被调用时,我们可以确信meter实例的Height和Weight属性将具有有效的值。注意,如果我们需要从我们的模板中访问任何元素,我们可以在指定由我们的控件的Template属性指定的ControlTemplate对象上从这个方法调用FrameworkTemplate.FindName方法。
如果我们在 XAML 中将Rectangle元素命名为PART_Rectangle,我们就可以像这样从OnApplyTemplate方法中访问它:
Rectangle rectangle = Template.FindName("PART_Rectangle", this) as Rectangle;
if (rectangle != null)
{
// Do something with rectangle
}
注意,我们始终需要检查null,因为应用的模板可能是一个不包含Rectangle元素的定制模板。同时注意,当我们需要模板中存在特定元素时,我们可以用TemplatePartAttribute装饰我们的定制控件类声明,以指定所需控件的详细信息:
[TemplatePart(Name = "PART_Rectangle", Type = typeof(Rectangle))]
public class Meter : Control
{
...
}
这将不会强制执行任何操作,也不会因为命名部分未包含在定制模板中而引发任何编译错误,但它将在文档和各种 XAML 工具中使用。它帮助我们的定制控件用户在提供定制模板时了解需要哪些元素。
现在我们已经看到了这个控件的内幕工作原理,让我们看看Generic.xaml文件中我们控件的默认样式的 XAML,以了解ClipRect属性是如何使用的:
<ResourceDictionary
xmlns:CustomControls=
"clr-namespace:CompanyName.ApplicationName.CustomControls">
<Style TargetType="{x:Type CustomControls:Meter}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type
CustomControls:Meter}">
<ControlTemplate.Resources>
<LinearGradientBrush x:Key="ScaleColors"
StartPoint="0,1" EndPoint="0,0">
<GradientStop Color="LightGreen" />
<GradientStop Color="Yellow" Offset="0.5" />
<GradientStop Color="Orange" Offset="0.75" />
<GradientStop Color="Red" Offset="1.0" />
</LinearGradientBrush>
</ControlTemplate.Resources>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Border.ToolTip>
<TextBlock Text="{Binding Value, StringFormat={}{0:P0}}" />
</Border.ToolTip>
<Rectangle Fill="{StaticResource ScaleColors}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
SnapsToDevicePixels="True" Name="PART_Rectangle">
<Rectangle.Clip>
<RectangleGeometry Rect="{Binding ClipRect,
RelativeSource={RelativeSource
AncestorType={x:Type CustomControls:Meter}}}" />
</Rectangle.Clip>
</Rectangle>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
当在 WPF Custom Control Library 项目中创建每个定制控件类时,Visual Studio 会在Generic.xaml文件中添加一个几乎为空的默认样式,该样式设置了一个基本的ControlTemplate并将目标类型设置为类的类型。我们只需在这个模板中定义我们的定制 XAML。
我们首先在模板内声明ScaleColors渐变画笔资源。注意,GradientStop元素的Offset属性的默认值是0,因此如果我们想将其设置为这个值,我们可以省略设置这个属性。因此,当我们看到一个声明了GradientStop,比如设置了Color属性为LightGreen的GradientStop时,我们知道它的Offset属性被设置为0。
我们的控制器主要由一个包围着Rectangle元素的Border元素组成。我们使用TemplateBinding元素来将Background、BorderBrush和BorderThickness属性数据绑定到Border元素,并将其SnapsToDevicePixels属性设置为True以避免混叠。
这使得控件的用户能够从控件外部指定仪表控制内部Border元素的边框和背景颜色。我们同样可以公开一个额外的画笔属性来替换ScaleColors资源,并允许用户定义他们自己的仪表刻度画笔。
注意,我们无法使用TemplateBinding来将ToolTip元素中的Value属性数据绑定。这并不是因为我们无法通过模板访问它,而是因为我们需要使用Binding.StringFormat属性和P格式说明符将我们的double属性值转换为百分比值。
如果你还记得,TemplateBinding是一种轻量级绑定,它不提供这种功能。虽然当我们能够使用它时,这样做是有益的,但这个例子突出了这样一个事实:我们并不能在所有情况下都使用它。
最后,我们来到了至关重要的Rectangle元素,它负责显示我们控件的实际仪表条。在这里使用ScaleColors笔刷资源来绘制矩形的背景。我们将此元素的SnapsToDevicePixels属性设置为true,以确保显示的级别准确且定义良好。
这个控件中的魔法是通过使用UIElement.Clip属性形成的。本质上,这使我们能够提供任何类型的Geometry元素来改变 UI 元素可见部分的形状和大小。我们在这里分配的几何形状将指定控制的可见部分。
在我们的案例中,我们声明了一个RectangleGeometry类,其大小和位置由其Rect属性指定。因此,我们将ClipRect依赖属性数据绑定到这个Rect属性上,这样从传入的数据值计算出的尺寸就由这个RectangleGeometry实例表示,因此是Rectangle元素的可见部分。
注意,我们这样做是为了确保绘制在仪表条上的渐变保持不变,并且不会随着条的高度变化而改变。如果我们只是用笔刷资源绘制矩形的背景并调整其高度,背景渐变会随着仪表条的大小移动,从而破坏效果。
因此,整个矩形总是用渐变笔刷来填充,我们只需使用它的Clip属性来显示其适当的部分。为了在我们的视图中使用它,我们首先需要指定CustomControls XAML 命名空间前缀:
xmlns:CustomControls="clr-namespace:CompanyName.ApplicationName.
CustomControls;assembly=CompanyName.ApplicationName.CustomControls"
我们可以声明多个这样的控件,将适当的属性数据绑定到它们的Value属性上,并为它们设置样式,就像任何其他控件一样:
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<StackPanel.Resources>
<Style TargetType="{x:Type CustomControls:Meter}">
<Setter Property="Background" Value="Black" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Width" Value="20" />
<Setter Property="Height" Value="100" />
</Style>
</StackPanel.Resources>
<CustomControls:Meter Value="{Binding CpuActivity}" />
<CustomControls:Meter Value="{Binding DiskActivity}" Margin="10,0" />
<CustomControls:Meter Value="{Binding NetworkActivity}" />
</StackPanel>
给定一些有效的属性进行数据绑定,前面的示例将生成类似于以下内容的输出:

概述
在本章中,我们研究了内置 WPF 控件丰富的继承层次结构,确定了哪些能力来自哪些基类,并看到了每个控件是如何由其包含的面板布局的。我们检查了不同面板之间的差异,并理解在某些条件下,某些面板比其他面板工作得更好。
我们还揭开了ContentControl和ItemsControl元素的秘密,现在对ContentPresenter和ItemsPresenter对象有了很好的理解。我们继续探索了多种自定义内置控件的方法。最后,我们考虑了如何最好地创建我们自己的控件。
在下一章中,我们将进一步研究内置控制,特别关注派生类覆盖基类方法的多态能力。我们将介绍一些示例,每个示例都突出某些问题,并展示如何通过扩展内置控制和覆盖特定的基类方法逐一克服这些问题。
第六章:适配内置控件
.NET 框架附带了许多内置控件,涵盖了大多数现实世界场景。当我们需要稍微不同一点的东西时,我们已经看到我们可以利用 WPF 样式化和/或模板化系统来调整它们以满足我们的需求。然而,还有其他方法可以调整内置控件以适应我们的需求。
每个 .NET 控件都有一些方法,每个方法都以前缀 On 命名,例如 OnInitialized 或 OnApplyTemplate。这些是受保护的方法,可以在扩展 .NET 控件的任何自定义类中重写。它们在控件生命周期的特定点被调用,使我们能够改变每个控件的默认行为。
它们使我们能够做到像在控件初始化后立即启动一个进程这样简单的事情,或者一旦应用了自定义的ControlTemplate,就可以访问一个命名的控件。但它们也可以用来完全改变控件的默认行为或外观。在本章中,我们将研究这些方法,并给出如何利用它们为我们带来好处的示例。
我们将进一步探讨自定义内置控件的方法,通过调整它们的默认ControlTemplate并利用它们的新用途,同时保持或扩展它们现有的功能。在本章中,我们将内置控件仅视为我们需求的一个起点,并学习如何在此基础上构建,保留所需的部分并更改不需要的部分。
检查受保护的方法
每个 .NET 控件都有几个方法,这些方法允许开发者扩展该控件,以便与之交互或更改其功能。请注意,这些不是事件,而是受保护的方法,在控件生命周期的特定点被调用。正如我们已经在第五章,使用适合工作的控件中看到的那样,每个 .NET 控件都扩展了多个基类,每个基类都提供了一些额外的功能。
以类似的方式,每个基类也提供了一些这些受保护的方法,使我们能够与控件内部进行交互。在本章中,我们还将展示如何创建我们自己的方法,使扩展我们自己的控件类的开发者能够调整或扩展其功能。
让我们先看看 Window 类的受保护方法:
protected override Size ArrangeOverride(Size arrangeBounds);
protected override Size MeasureOverride(Size availableSize);
protected virtual void OnActivated(EventArgs e);
protected virtual void OnClosed(EventArgs e);
protected virtual void OnClosing(CancelEventArgs e);
protected override void OnContentChanged(object oldContent, object newContent);
protected virtual void OnContentRendered(EventArgs e);
protected override AutomationPeer OnCreateAutomationPeer();
protected virtual void OnDeactivated(EventArgs e);
protected virtual void OnLocationChanged(EventArgs e);
protected override void
OnManipulationBoundaryFeedback(ManipulationBoundaryFeedbackEventArgs e);
protected virtual void OnSourceInitialized(EventArgs e);
protected virtual void OnStateChanged(EventArgs e);
protected internal sealed override void
OnVisualParentChanged(DependencyObject oldParent);
你可能会注意到,它们都带有虚拟或重写关键字,这表明它们可以在扩展类中被重写。除了我们在第五章,使用适合工作的控件中发现的ArrangeOverride和MeasureOverride方法之外,你应该看到它们的名称都以前缀On开头。这表示它们在某个动作发生后会被调用。
例如,当 Window 成为计算机上的活动窗口时,会调用 OnActivated 方法,而当 Window 失去焦点时,会调用 OnDeactivated 方法。这些方法通常一起使用,以暂停和恢复动画或其他过程,当 Window 失去焦点时。
如预期的那样,当 Window 被关闭时,会调用 OnClosed 方法,这给了我们处理任何资源或关闭应用程序前保存用户首选项的机会。相反,OnClosing 方法在 Window 关闭之前被调用,这给了我们取消关闭操作的机会。
因此,OnClosing 方法是一个很好的方法,可以用来显示对话框,要求用户确认关闭操作。让我们快速看一下在一个扩展 Window 类的类中我们如何实现这一点:
using System.ComponentModel;
using System.Windows;
...
protected override void OnClosing(CancelEventArgs e)
{
base.OnClosing(e);
MessageBoxResult result = MessageBox.Show("Are you sure you want to close?",
"Close Confirmation", MessageBoxButton.OKCancel, MessageBoxImage.Question);
e.Cancel = result == MessageBoxResult.Cancel;
}
在这个简单的例子中,我们重写了 OnClosing 方法,并在其中首先调用基类方法,以确保任何基类例程按预期运行。然后我们向用户显示一个消息框,要求他们确认他们的关闭操作。
通过消息框按钮从用户那里获得的结果值,我们设置传入方法的 CancelEventArgs 对象的 Cancel 属性。如果返回值是 Cancel,则将 Cancel 属性设置为 true 并取消关闭操作,否则将其设置为 false 并关闭应用程序。
现在回到 Window 类,我们看到 OnLocationChanged 方法,该方法在 Window 被移动或调整大小,从而改变其左上角位置时被调用。我们可以使用这个方法来保存 Window 的最后位置,以便用户下次打开应用程序时能够返回到那里。然而,这种操作通常是在用户关闭应用程序时执行的。
OnSourceInitialized 方法在窗口源创建后、显示前被调用,而当 WindowState 属性改变时,会调用 OnStateChanged 方法。所以你看,这些方法为我们提供了在每个控件生命周期中特定点执行操作的机会。
每个基类都为我们添加了自己的受保护方法集合,我们可以在扩展类中重写这些方法。查看 Window 类的声明,我们看到它扩展了 ContentControl 类。注意,它的 OnContentChanged 方法被标记为 override 关键字。
这是因为这个方法实际上是在 ContentControl 类中声明的,但在 Window 类中被重写,以便在基类功能执行后添加自己的代码。让我们快速看一下 Window 类中这个方法的源代码。为了简洁,源代码中的注释已被删除:
protected override void OnContentChanged(object oldContent, object newContent)
{
base.OnContentChanged(oldContent, newContent);
SetIWindowService();
if (IsLoaded == true)
{
PostContentRendered();
}
else
{
if (_postContentRenderedFromLoadedHandler == false)
{
this.Loaded += new RoutedEventHandler(LoadedHandler);
_postContentRenderedFromLoadedHandler = true;
}
}
}
方法首先调用基类版本的方法,这通常是一个好习惯,除非我们不想停止现有功能的执行。接下来,它调用 SetIWindowService 方法,该方法只是将 Window 对象设置为 IWindowServiceProperty 依赖属性,然后它检查 Window 是否已经通过了加载阶段。
如果已经通过,那么它将调用 PostContentRendered 方法,该方法基本上使用 Dispatcher 对象调用 OnContentRendered 方法。否则,如果 _postContentRenderedFromLoadedHandler 变量是 false,它将事件处理程序附加到 Loaded 事件并将变量设置为 true,以确保不会附加多次。
回到我们的调查,我们看到 Window 类添加了与 Window 相关的受保护方法,而 ContentControl 类添加了与控件内容相关的受保护方法。现在让我们看看 ContentControl 类的受保护方法:
protected virtual void AddChild(object value);
protected virtual void AddText(string text);
protected virtual void OnContentChanged(object oldContent, object newContent);
protected virtual void OnContentStringFormatChanged(string oldContentStringFormat, string newContentStringFormat);
protected virtual void OnContentTemplateChanged(DataTemplate oldContentTemplate, DataTemplate newContentTemplate);
protected virtual void OnContentTemplateSelectorChanged(DataTemplateSelector oldContentTemplateSelector, DataTemplateSelector newContentTemplateSelector);
除了前两个方法,可以用来向 ContentControl 元素添加指定的对象或文本字符串外,其余四个方法都是在内容或控件内容格式发生变化时被调用。
现在继续,ContentControl 类扩展了 Control 类,它引入了 ControlTemplate 的概念。因此,它提供了一个受保护的 OnTemplateChanged 方法,当 ControlTemplate 值改变时被调用:
protected override Size ArrangeOverride(Size arrangeBounds);
protected override Size MeasureOverride(Size constraint);
protected virtual void OnMouseDoubleClick(MouseButtonEventArgs e);
protected virtual void OnPreviewMouseDoubleClick(MouseButtonEventArgs e);
protected virtual void OnTemplateChanged(ControlTemplate oldTemplate, ControlTemplate newTemplate);
Control 类扩展了 FrameworkElement 类,它提供了框架级别的 方法和事件。这些包括鼠标、键盘、笔、触摸和焦点相关的受保护方法,以及一些其他方法:
protected virtual Size ArrangeOverride(Size finalSize);
protected override Geometry GetLayoutClip(Size layoutSlotSize);
protected override Visual GetVisualChild(int index);
protected virtual Size MeasureOverride(Size availableSize);
protected virtual void OnContextMenuClosing(ContextMenuEventArgs e);
protected virtual void OnContextMenuOpening(ContextMenuEventArgs e);
protected override void OnGotFocus(RoutedEventArgs e);
protected virtual void OnInitialized(EventArgs e);
protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e);
protected virtual void OnToolTipClosing(ToolTipEventArgs e);
protected virtual void OnToolTipOpening(ToolTipEventArgs e);
可能到现在你已经注意到,许多这些方法名称与每个类引发的事件名称密切相关。事实上,有一个 .NET 框架编程指南,用于具有引发事件的受保护虚拟方法,以允许派生类覆盖事件调用行为,我们将在本章后面看到这个示例。
因此,在重写这些方法时,我们需要调用基类方法来引发相应的事件。如果有疑问,通常最好调用基类方法版本,以确保不会丢失默认功能。然而,查看基类方法源代码是一个好习惯,可以在 www.referencesource.microsoft.com 网站上查看,以检查我们是否需要调用它。
你可能想知道处理事件和重写相关受保护方法之间的区别,这取决于所讨论的方法。首先要注意的是,为了重写受保护方法,我们需要声明一个声明该方法的类的子类。
所以,假设我们已经有了一个扩展基类的类,它们之间有什么区别呢? 对于一些方法,例如我们探讨的 OnClosing 方法,差别很小。我们可以在附加到 Closing 事件的处理器中实现相同的功能,尽管没有调用基类方法。实际上,这是唯一的真正区别。
当重写 OnClosing 方法时,我们可以控制基类方法何时或是否被调用。在处理事件时,我们无法控制这一点。因此,如果我们需要在基类例程执行之前执行某些操作,或者我们想要阻止其执行,那么我们就需要重写 OnClosing 方法。
因此,OnClosing 方法的出现纯粹是为了方便,让我们能够改变 Closing 事件的默认行为。然而,其他方法,例如 OnContextMenuClosing 方法,为我们提供了一种进行类级处理相关事件的方式。
有时候,我们别无选择,只能重写这些受保护的成员方法。通常,这些类型的方法不以 On 前缀开头,也不与任何事件相关。偶尔,为了执行特定的操作,我们可能需要扩展一个类,以便我们可以为这些方法之一提供新的实现。
让我们看看一个例子,使用我们刚才看到的 FrameworkElement 类的 GetLayoutClip 方法。
布局剪辑
默认情况下,TextBlock 类会在其边界矩形内剪辑其文本内容,这样文本就不会从其中溢出。剪辑是剪切掉控件可见输出一部分的过程。但是如果我们想让文本扩展其边界呢?
有一个名为 Clip 的属性,我们通常用它来调整控件的可见部分。然而,这只能减少已经可见的部分。它不能增加控件可用的渲染空间。在我们继续我们的例子之前,让我们短暂地偏离一下,来调查这个属性。
定义在 UIElement 类中的 Clip 属性接受一个 Geometry 对象作为其值。我们可以从扩展 Geometry 类的任何类中创建传递给它的对象,包括 CombinedGeometry 类。因此,剪辑对象可以被制成任何形状。让我们查看一个简单的例子:
<Rectangle Fill="Salmon" Width="150" Height="100" RadiusX="25" RadiusY="50">
<Rectangle.Clip>
<EllipseGeometry Center="150,50" RadiusX="150" RadiusY="50" />
</Rectangle.Clip>
</Rectangle>
在这里,我们使用一个 EllipseGeometry 对象来使 Rectangle 元素呈现出一个小子弹形状。这是通过显示 EllipseGeometry 对象椭圆边界内的 Rectangle 元素的所有图像像素,并隐藏所有位于边界之外的那些像素来实现的。让我们看看这段代码的视觉输出:

回到我们之前的示例,TextBlock类也会以类似的方式剪辑其内容,但使用的是控件大小的矩形,而不是偏离中心的椭圆形。它不是使用提供用户与其它控件相同剪辑能力的Clip属性,而是使用一个受保护的方法来请求用于剪辑过程的Geometry对象。
我们确实可以从这个方法返回任何几何形状,但它不会产生与将形状传递给Clip属性相同的视觉效果。在我们的示例中,我们不想限制控件的可视大小,而是移除控件边界的剪辑区域。
如果我们确切知道要设置剪辑范围的尺寸,我们可以从GetLayoutClip方法返回该尺寸的Geometry对象。然而,出于我们的目的,以及为了使我们的任何自定义TextBlock对象能够超出其边界输出无限文本,我们可以简单地从这个方法返回null。让我们看看两种方法之间的区别。
首先,我们通过扩展TextBlock类来创建我们的BoundlessTextBlock类。在Visual Studio中,这可能是最简单的方法之一:将一个 WPF 用户控件对象添加到我们的 Controls 文件夹中,然后在 XAML 文件及其关联的后台代码文件中,将单词UserControl简单地替换为TextBlock。如果未能同时更改这两个文件,将导致设计时错误,错误信息会抱怨BoundlessTextBlock的局部声明必须不能指定不同的基类:
<TextBlock x:Class="CompanyName.ApplicationName.Views.Controls.BoundlessTextBlock"
/>
如此示例所示,我们的 XAML 文件可以非常简洁,对于我们的需求,我们只需要在后台代码文件中重写单个GetLayoutClip方法。在这个第一个示例中,我们将返回一个与将要用于用户界面的文本块相同大小的EllipseGeometry对象:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class BoundlessTextBlock : TextBlock
{
public BoundlessTextBlock()
{
InitializeComponent();
}
protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
return new EllipseGeometry(new Rect(new Size(150, 22)));
}
}
}
让我们看看如何使用我们的新类。首先,我们需要定义一个 XAML 命名空间,它映射到我们保存类的 CLR 命名空间。接下来,为了演示目的,我们将BoundlessTextBlock对象包裹在一个Border对象中,这样我们就可以看到它的自然边界:
...
<Border BorderBrush="Black" BorderThickness="1" HorizontalAlignment="Center"
VerticalAlignment="Center" SnapsToDevicePixels="True">
<Controls:BoundlessTextBlock Text="Can you see what has happened?"
Background="Aqua" FontSize="14" Width="150" Height="22" />
</Border>
让我们看看这个示例的视觉输出:

如您所见,我们的BoundlessTextBlock对象的视觉输出已被限制,只显示从GetLayoutClip方法返回的EllipseGeometry对象内的像素。但是,如果我们返回一个比我们的自定义文本块更大的EllipseGeometry对象会发生什么? 让我们通过返回这个对象来找出答案:
return new EllipseGeometry(new Rect(new Size(205, 22)));
现在,让我们看看BoundlessTextBlock对象的视觉输出,我们可以看到,由于Border对象和蓝色背景,我们自定义文本块的内容现在超出了其边界:

因此,我们可以看到,使用从GetLayoutClip方法返回的Geometry对象应用的裁剪不仅不受控件自然边界的限制,而且实际上可以直接改变它们。回到我们关于这个主题的原始想法,如果我们想完全移除控件边界处的裁剪,我们可以简单地从该方法返回null:
protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
return null;
}
让我们看看这个更改的结果:

如您所见,文本现在延伸到了包含TextBlock对象的边界之外,并继续到文本值的末尾。请注意,如果其父控件(们)提供了足够的空间,它将延伸到文本字符串所需的长度。
现在我们来看一下扩展这些类以修改其功能的另一个示例。
修改默认行为
ItemsControl类的开发者为其赋予了特定的默认行为。他们认为任何扩展UIElement类的对象都将有自己的 UI 容器,因此应该直接显示,而不是允许它们以通常的方式模板化。
ItemsControl类中有一个名为IsItemItsOwnContainer的方法,该框架调用它以确定Items集合中的项目是否是其自己的项目容器。让我们首先看看这个方法的源代码:
public bool IsItemItsOwnContainer(object item)
{
return IsItemItsOwnContainerOverride(item);
}
注意,在内部,此方法只是调用IsItemItsOwnContainerOverride方法,并返回其值不变。现在让我们看看该方法的源代码:
protected virtual bool IsItemItsOwnContainerOverride(object item)
{
return (item is UIElement);
}
在这里,我们看到两件事:第一是刚刚提到的默认实现,其中对于所有扩展UIElement类的项目返回true,对于所有其他类型返回false;第二是此方法被标记为virtual,因此我们可以扩展此类并重写该方法以返回不同的值。
现在我们来看看ItemsControl类源代码的关键部分(不带注释),其中将使用我们的重写方法。此摘录来自GetContainerForItem方法:
DependencyObject container;
if (IsItemItsOwnContainerOverride(item))
container = item as DependencyObject;
else
container = GetContainerForItemOverride();
在默认实现中,我们看到UIElement项目被转换为DependencyObject类型,并设置为容器,而为所有其他类型的项目创建了一个新的容器。在重写此方法之前,让我们用一个示例来看看默认行为的效果。
本例的目的是为集合中的每个项目渲染一个小空心圆。想象一下幻灯片放映,这些圆圈将代表幻灯片,或者页码编号或链接系统。因此,我们需要一个包含一些项目的集合控件和一个DataTemplate,以便定义这些圆圈。让我们首先看看包含项目的集合控件:
<UserControl
x:Class="CompanyName.ApplicationName.Views.ForcedContainerItemsControlView"
Height="175" Width="287">
<Grid>
<Grid.Resources>
<ItemsPanelTemplate x:Key="HorizontalPanelTemplate">
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
<Style TargetType="{x:Type Rectangle}">
<Setter Property="Width" Value="75" />
<Setter Property="Height" Value="75" />
<Setter Property="RadiusX" Value="15" />
<Setter Property="RadiusY" Value="15" />
</Style>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition />
</Grid.RowDefinitions>
<ListBox Name="ListBox" Height="105" Margin="20,20,20,0"
ItemsPanel="{StaticResource HorizontalPanelTemplate}">
<Rectangle Fill="Red" />
<Rectangle Fill="Orange" />
<Rectangle Fill="Green" />
</ListBox>
</Grid>
</UserControl>
我们从资源开始,其中我们声明了一个 ItemsPanelTemplate,设置为 StackPanel 的一个实例,其 Orientation 属性设置为 Horizontal。这将使面板的项目水平显示。然后我们添加了一个基本的 Style,在其中我们设置了 Rectangle 类的常用属性。
在标记中,我们有一个 Grid 面板,有两行。在第一行,我们有一个名为 ListBox 的 ListBox,在其 Items 集合中声明了三个彩色的 Rectangle 对象。其 ItemsPanel 属性设置为我们在控件 Resources 部分中声明的 ItemsPanelTemplate 实例。第二行目前为空,但让我们看看到目前为止的视觉输出:

到目前为止,一切顺利。我们可以在 ListBox 控件中看到我们的三个圆形矩形。现在,让我们在 Resources 部分添加一个 DataTemplate,并在 Grid 面板的第二行添加一个 ItemsControl 元素,直接在 ListBox XAML 下方声明:
<DataTemplate x:Key="EllipseDataTemplate" DataType="{x:Type UIElement}">
<Ellipse Width="16" Height="16"
Stroke="Gray" StrokeThickness="2" Margin="4" />
</DataTemplate>
...
<ItemsControl Grid.Row="1" ItemsSource="{Binding Items, ElementName=ListBox}"
ItemsPanel="{StaticResource HorizontalPanelTemplate}"
ItemTemplate="{StaticResource EllipseDataTemplate}"
HorizontalAlignment="Center" />
注意,这个 ItemsControl 元素的 ItemsSource 属性数据绑定到了 ListBox 的 Items 属性,使用了一个 ElementName 绑定。像 ListBox 控件一样,它也使用 ItemsPanelTemplate 资源水平排列其项目。它还应用了我们刚刚添加到 Resources 部分的新的 DataTemplate 元素。
在这个 DataTemplate 中,我们定义了一个用于渲染集合中每个项目的空心灰色 Ellipse 元素,指定了其尺寸、间距和描边设置。现在让我们看看我们示例的视觉输出:

如您所见,我们得到了一些意外的结果。我们没有渲染在 DataTemplate 中定义的小灰色椭圆,而是 ItemsControl 中的项目显示了 ListBox 的实际项目。更糟糕的是,由于每个 UI 元素在任何给定时间点只能显示在一个位置,原始项目甚至不再出现在 ListBox 中。
你可能会看到关于这个问题的 ArgumentException:
Must disconnect the specified child from current parent Visual before attaching to new parent Visual.
但是,根据我们的 DataTemplate,为什么这些对象没有被渲染成空心圆圈呢?* 你还记得我们调查过的* IsItemItsOwnContainerOverride 方法吗? 好吧,这就是原因。
绑定到 ItemsControl 的 ItemsSource 属性的对象扩展了 UIElement 类,因此 ItemsControl 类使用它们作为自己的容器,而不是为它们创建一个新的容器并应用项目模板。
那么,我们如何改变这种默认行为呢?* 对,我们需要扩展 ItemsControl 类并重写 IsItemItsOwnContainerOverride 方法,使其始终返回 false。这样,就会始终创建一个新的容器,并且始终应用项目模板。让我们看看在一个新类中这会是什么样子:
using System.Windows.Controls;
namespace CompanyName.ApplicationName.Views.Controls
{
public class ForcedContainerItemsControl : ItemsControl
{
protected override bool IsItemItsOwnContainerOverride(object item)
{
return false;
}
}
}
这里我们有非常简单的 ForcedContainerItemsControl 类,它有一个重写的方法,总是返回 false。在这个类中我们不需要做其他任何事情,因为我们很高兴使用 ItemsControl 类的默认行为来处理其他所有事情。
剩下的就是在我们示例中使用我们的新类。我们首先为我们的 Controls CLR 命名空间添加一个 XAML 命名空间:
接下来,我们将 ItemsControl XAML 替换为以下内容:
<Controls:ForcedContainerItemsControl Grid.Row="1"
ItemsSource="{Binding Items, ElementName=ListBox}"
ItemsPanel="{StaticResource HorizontalPanelTemplate}"
ItemTemplate="{StaticResource EllipseDataTemplate}"
HorizontalAlignment="Center" Height="32" />
让我们看看现在的新的视觉输出:

现在,我们看到我们最初期望看到的东西:为集合中的每个项渲染一个小空心圆。我们自定义的 ItemsControl 中的项现在都已经生成了新的容器,并且按照预期应用了我们的模板。
但是如果我们需要在这个示例中使用选中项怎么办? ItemsControl 类没有选中项的概念,所以在这种情况下,我们需要在 Grid 面板的第二行中使用 ListBox 控件。
然而,请注意,ListBox 类也重写了 IsItemItsOwnContainerOverride 方法,这样它就不会有相同的问题。
事实上,它只会将项用作容器,如果它实际上是这个类的正确容器;一个 ListBoxItem。让我们看看它重写的方法:
protected override bool IsItemItsOwnContainerOverride(object item)
{
return (item is ListBoxItem);
}
因此,如果我们需要从 ListBox 类中访问 SelectedItem 属性,那么我们不需要创建我们自己的扩展类来重写这个方法,而是可以使用它们的标准实现。然而,为了获得相同的视觉输出,我们需要一些样式来隐藏 ListBox 的边框和选中项的高亮。让我们看看这个基本示例:
<Style x:Key="HiddenListBoxItems" TargetType="{x:Type ListBoxItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<ContentPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="HiddenListBox" TargetType="{x:Type ListBox}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBox}">
<ScrollViewer>
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding
SnapsToDevicePixels}" />
</ScrollViewer>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
我们还需要更新我们的 EllipseDataTemplate 模板,以便包含一个触发器,在顶部的 ListBox 控件中选择相关项时突出显示小的 Ellipse 对象:
<DataTemplate x:Key="EllipseDataTemplate" DataType="{x:Type UIElement}">
<Ellipse Width="16" Height="16" Stroke="Gray" StrokeThickness="2"
Margin="8">
<Ellipse.Style>
<Style TargetType="{x:Type Ellipse}">
<Setter Property="Fill" Value="Transparent" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected,
RelativeSource={RelativeSource
AncestorType={x:Type ListBoxItem}}}" Value="True">
<Setter Property="Fill" Value="LightGray" />
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
</DataTemplate>
最后,我们需要将我们的 ForcedContainerItemsControl 元素替换为标准的 ListBox,并将其样式应用到它及其容器上:
<ListBox Grid.Row="1" ItemsSource="{Binding Items, ElementName=ListBox}"
ItemsPanel="{StaticResource HorizontalPanelTemplate}"
ItemTemplate="{StaticResource EllipseDataTemplate}"
SelectedItem="{Binding SelectedItem, ElementName=ListBox}"
Style="{StaticResource HiddenListBox}"
ItemContainerStyle="{StaticResource HiddenListBoxItems}"
HorizontalAlignment="Center" />
当我们现在运行应用程序时,我们看到当在顶部的 ListBox 中选择相关项时,小的空心 Ellipse 对象会变成实心:

因此,我们已经看到了如何重写这些受保护的方法来改变内置控件的默认行为。现在让我们看看如何将这些受保护的方法构建到我们自己的自定义类中,以便它们可以影响我们控件功能的一部分的自然流程。
创建可重写的方法
我们需要做的第一件事是在我们的基类中定义一个抽象方法或虚方法。请注意,为了声明一个抽象方法,类必须是抽象的。我们选择哪一个将取决于我们是否希望将实现留给使用我们代码的开发者,或者如果我们需要在方法中自己实现一些实现。
让我们通过一个例子来澄清这一点。这里,我们看到一个来自抽象 BaseDragDropManager 类的方法。它处理用作拖放源的控件上的 PreviewMouseMove 事件,例如,在一个 ListBox 上,一个项目正在被拖动:
private void DragSourcePreviewMouseMove(object sender, MouseEventArgs e)
{
if (_isMouseDown && IsConfirmedDrag(e.GetPosition(sender as ListBox)))
{
_isMouseDown = false;
OnDragSourcePreviewMouseMove(sender, e);
if (e.Handled) return;
OnDragStart(sender as UIElement);
}
}
protected virtual void
OnDragSourcePreviewMouseMove(object sender, MouseEventArgs e) { }
protected abstract void OnDragStart(UIElement uiElement);
在这个例子中,DragSourcePreviewMouseMove 方法首先执行一个检查,以验证是否由用户启动了拖动操作。然后,它调用标记为 virtual 的 OnDragSourcePreviewMouseMove 方法,这使得在派生类中重写它是可选的。
DragSourcePreviewMouseMove 方法的下一行检查 MouseEventArgs 输入参数的 Handled 属性,如果它在派生类中被设置为 true,则将执行权返回给调用者,而不是继续拖放操作。如果事件没有被处理,则调用 OnDragStart 方法。
这是将派生类可能的输入链接起来的关键部分。在扩展类中重写 OnDragSourcePreviewMouseMove 方法的唯一原因是将 MouseEventArgs 输入参数的 Handled 属性设置为 true 并停止拖放操作开始,这可能根据扩展类拥有的某些信息。
相反,OnDragStart 方法被标记为 abstract,要求在所有派生类中重写它。这是准备拖放过程数据的方法,并且需要调用基类的 StartDrag 方法以开始操作,传递准备好的数据。
在这个特定的例子中,我们的虚拟方法在基类中是空的,并且不需要从重写的方法中调用它。更典型的情况是,基类将包含一个默认实现,这可以在派生类中重写,但可能需要调用基类,以保留其功能。
例如,存在一个从派生类中引发基类事件的 .NET Framework 编程指南。通常,派生类不能引发基类事件,任何尝试这样做都会遇到编译错误:
The event ClassName.EventName can only appear on the left hand side of += or -= (except when used from within the type ClassName)
指南中解决这个问题的方法是,在基类中包装这些事件的调用在一个受保护的方法中,以便可以在派生类中调用或重写。让我们向我们的 AddressControl 控件添加一个自定义的 EventArgs 类和一个事件,以演示这个指南:
public class AddressEventArgs : EventArgs
{
public AddressEventArgs(Address oldAddress, Address newAddress)
{
OldAddress = oldAddress;
NewAddress = newAddress;
}
public Address OldAddress { get; }
public Address NewAddress { get; }
}
...
public event EventHandler<AddressEventArgs> AddressChanged;
...
public virtual Address Address
{
get { return (Address)GetValue(AddressProperty); }
set
{
if (!Address.Equals(value))
{
Address oldAddress = Address;
SetValue(AddressProperty, value);
OnAddressChanged(new AddressEventArgs(oldAddress, value));
}
}
}
...
protected virtual void OnAddressChanged(AddressEventArgs e)
{
AddressChanged?.Invoke(this, e);
}
首先,我们为我们的事件创建一个自定义的 EventArgs 类。然后,我们声明一个名为 AddressChanged 的事件和一个用于引发它的受保护的虚拟方法,使用空条件运算符。这可以直接从派生类中调用以引发事件,也可以重写,以添加到或阻止基类实现执行。
最后,我们更新我们的Address属性,以调用调用方法,并传入所需的先前和当前Address对象。请注意,我们现在也将此属性标记为virtual,这样派生类也可以重写它,以完全控制事件何时以及是否应该被触发。
这比在派生类中声明一个虚拟事件并重写它要好得多,因为编译器并不总是按预期处理这种情况,这是由于一些复杂的事件重写规则,我们也不能总是确定订阅者实际上会订阅哪个版本的事件。
现在我们对这些受保护的方法有了更好的理解,让我们看看通过在派生类中重写它们我们可以做些什么。我们将使用一个扩展示例,该示例提出了许多问题,我们可以通过重写这些受保护基类方法中的几个来修复这些问题。
定制以满足我们的需求
让我们想象一下,我们想要创建一个显示表格数据的程序。这听起来最初并不复杂,但实际上这是一个很好的示例,可以用来展示如何调整内置的.NET 控件以满足我们的需求。随着我们通过这个示例的进展,我们将遇到几个潜在的问题,并逐一找出如何克服这些问题。
对于这个扩展示例,我们将创建一个Spreadsheet控件。和往常一样,在创建新的控件时,我们会查看现有的控件,看看是否有任何控件可以作为我们良好的起点。第一个跳入脑海的控件是Grid面板,因为它有行、列,因此也有单元格,但创建所有的RowDefinition和ColumnDefinition对象可能会很繁琐或有问题。
还有UniformGrid面板,但正如其名称所暗示的,它的所有单元格都是统一的,或者彼此大小相同,但在电子表格中这并不总是情况。我们可以使用一个ItemsControl对象和一个自定义的DataTemplate来手动绘制每个单元格的边框和内容,但有没有更好的起点呢?
那么,DataGrid控件怎么样呢? 它有行、列和单元格,甚至为我们绘制单元格之间的网格线。它还有一个选中单元格的概念,如果我们想让用户与我们的电子表格控件交互,这可能很有用。它没有在网格轴上的数字、字母或选中单元格标记,但我们可以扩展控件来添加这些,所以它似乎是这项工作的最佳候选人。
我们需要做的第一件事是创建一个新的类,该类扩展了DataGrid类。正如我们在本章前面所看到的,我们可以通过向我们的项目中添加一个UserControl并将其 XAML 文件和其代码背后的文件中的UserControl一词替换为DataGrid来实现这一点。如果未能同时更改这两个地方,将导致设计时错误,错误信息会抱怨类不匹配。
让我们看看我们的新 Spreadsheet 类:
using System.Windows.Controls;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class Spreadsheet : DataGrid
{
public Spreadsheet()
{
InitializeComponent();
}
}
}
背后的代码很简单,目前没有自定义代码。然而,XAML 中设置了许多重要的属性,所以现在让我们看看它:
<DataGrid x:Class="CompanyName.ApplicationName.Views.Controls.Spreadsheet"
AutoGenerateColumns="False" SelectionUnit="Cell" SelectionMode="Single"
IsReadOnly="True" RowHeight="20" RowHeaderWidth="26" ColumnHeaderHeight="26"
CanUserAddRows="False" CanUserDeleteRows="False" CanUserReorderColumns="False"
CanUserResizeColumns="False" CanUserResizeRows="False"
HorizontalGridLinesBrush="{DynamicResource GridlinesBrush}"
VerticalGridLinesBrush="{DynamicResource GridlinesBrush}"
BorderBrush="{DynamicResource BorderBrush}">
<DataGrid.Resources>
<Color x:Key="BackgroundColor">#FFE6E6E6</Color>
<Color x:Key="BorderColor">#FF999999</Color>
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}" />
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}" />
<SolidColorBrush x:Key="SelectedBackgroundBrush" Color="#FFD2D2D2" />
<SolidColorBrush x:Key="GridlinesBrush" Color="#FFD4D4D4" />
<SolidColorBrush x:Key="SelectionBrush" Color="#FF217346" />
</DataGrid.Resources>
</DataGrid>
对于这个实现,我们将 AutoGenerateColumns 属性设置为 False,因为我们将会以编程方式创建电子表格控件的列。为了近似电子表格控件,我们还需要限制我们自定义 DataGrid 的选择可能性。
因此,我们将 SelectionUnit 属性设置为 Cell,这样用户就只会选择他们点击的单元格,而不是整行,这是默认的选择行为。此外,为了简化这个示例,我们还设置了 SelectionMode 属性为 Single,IsReadOnly 属性为 True,以及 RowHeight 属性为 20。
我们的行和列标题都将设置为 26 像素,因此我们将 RowHeaderWidth 和 ColumnHeaderHeight 属性设置为 26。请注意,我们也可以在它们的相对样式中设置行和列标题的尺寸,但我们需要稍后引用这些属性,所以在这里设置它们是很重要的。接下来的五个属性,以 CanUser 为前缀,也已设置为 False,以进一步缩短这个示例。
然后,我们将 HorizontalGridLinesBrush 和 VerticalGridLinesBrush 属性都设置为来自 Resources 部分的 GridlinesBrush 画笔,并将 BorderBrush 属性设置为 BorderBrush 画笔。请注意,在这些情况下,我们需要使用 DynamicResource 标记扩展,因为这些画笔是在 DataGrid 声明之后定义的,包括其他所有资源,而 XAML 解析器无法使用标准的 StaticResource 标记扩展来定位它们。
此外,请注意,我们必须删除 Visual Studio 添加到每个新 UserControl 中的空 Grid 面板。原因是任何在 DataGrid 控件内部声明的元素都被确定为它的项目,我们不能同时使用它的 Items 属性和 ItemsSource 属性,而这是我们打算使用的。如果我们同时使用它们,我们将在运行时看到这个异常被抛出:
System.InvalidOperationException: 'Items collection must be empty before using ItemsSource.'
现在让我们继续前进,研究我们如何在电子表格中显示数据。
数据填充
为了将一些数据放入我们的 Spreadsheet 控件中,我们需要一个类来表示电子表格中的每个单元格。现在让我们看看一个基本的 Cell 类:
namespace CompanyName.ApplicationName.DataModels
{
public class Cell : BaseDataModel
{
private string address = string.Empty, content = string.Empty;
private double width = 0;
public Cell(string address, string content, double width)
{
Address = address;
Content = content;
Width = width;
}
public string Address
{
get { return address; }
set { if (address != value) { address = value;
NotifyPropertyChanged(); } }
}
public string Content
{
get { return content; }
set { if (content != value) { content = value;
NotifyPropertyChanged(); } }
}
public double Width
{
get { return width; }
set { if (width != value) { width = value; NotifyPropertyChanged(); } }
}
public override string ToString()
{
return $"{Address}: {Content}";
}
}
}
这是一个非常直接的类,只有三个属性,一个用于填充这些属性的构造函数,以及一个重写的 ToString 方法。像往常一样,我们扩展了 BaseDataModel 类,以便我们可以访问 INotifyPropertyChanged 接口。请注意,在一个真正的基于电子表格的应用程序中,我们会在这个类中有更多的属性,以便我们可以适当地样式化和格式化内容。
现在让我们继续创建我们的SpreadsheetViewModel和SpreadsheetView类。在SpreadsheetViewModel类中,我们用一些基本示例数据填充一个DataTable,并将其数据绑定到SpreadsheetView类中的新Spreadsheet控件:
using CompanyName.ApplicationName.DataModels;
using System.Data;
namespace CompanyName.ApplicationName.ViewModels
{
public class SpreadsheetViewModel : BaseViewModel
{
private DataRowCollection dataRowCollection = null;
public SpreadsheetViewModel()
{
Cell[] Cells = new Cell[9];
Cells[0] = new Cell("A1", "", 64);
Cells[1] = new Cell("B1", "", 96);
Cells[2] = new Cell("C1", "", 64);
Cells[3] = new Cell("A2", "", 64);
Cells[4] = new Cell("B2", "Hello World", 96);
Cells[5] = new Cell("C2", "", 64);
Cells[6] = new Cell("A3", "", 64);
Cells[7] = new Cell("B3", "", 96);
Cells[8] = new Cell("C3", "", 64);
DataTable table = new DataTable();
table.Columns.Add("A", typeof(Cell));
table.Columns.Add("B", typeof(Cell));
table.Columns.Add("C", typeof(Cell));
table.Rows.Add(Cells[0], Cells[1], Cells[2]);
table.Rows.Add(Cells[3], Cells[4], Cells[5]);
table.Rows.Add(Cells[6], Cells[7], Cells[8]);
Rows = table.Rows;
}
public DataRowCollection Rows
{
get { return dataRowCollection; }
set { if (dataRowCollection != value) { dataRowCollection = value;
NotifyPropertyChanged(); } }
}
}
}
在这个非常简单的视图模型中,我们声明了一个类型为DataRowCollection的单个属性,以包含我们的电子表格数据。使用此类型使我们能够轻松地从DataTable对象填充我们的电子表格,例如,从数据库加载或从 XML 文件生成。
在构造函数中,我们通过编程初始化并填充一个DataTable,使用示例Cell对象,并将其Rows属性值设置为我们的Rows属性。现在让我们看看这个Rows属性是如何在SpreadsheetView类中与我们的Spreadsheet控件数据绑定的:
<UserControl x:Class="CompanyName.ApplicationName.Views.SpreadsheetView"
>
<Controls:Spreadsheet ItemsSource="{Binding Rows}" Margin="50" />
</UserControl>
再次强调,这是一个非常简单的类,除了为我们的Controls项目声明一个 XAML 命名空间以及一个Spreadsheet控件,其ItemsSource属性绑定到视图模型的Rows属性外,没有其他内容。代码背后更是简单,其中没有任何自定义代码。此外,请记住使用您喜欢的任何方法将视图和视图模型链接在一起。
然而,在我们能够在Spreadsheet控件中看到任何数据之前,我们需要声明一个DataTemplate来定义每个单元格应该如何渲染,并程序化设置我们的列,与数据绑定项相关。现在让我们在 XAML 文件中声明所需的 XAML 命名空间,并将DataTemplate添加到我们的Spreadsheet控件的Resources部分中:
...
<DataTemplate x:Key="CellTemplate" DataType="{x:Type DataModels:Cell}">
<TextBlock Text="{Binding Content}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</DataTemplate>
在这里,我们有一个水平居中的TextBlock控件,用于输出每个单元格的内容。在实际应用中,我们会用Border元素包围它,以着色每个单元格的背景,并将数据绑定到更多属性,以便我们为每个单元格设置不同的样式和格式设置。然而,在这个例子中,我们将保持简单。
回到列生成的主题,记住我们不知道传入数据中会有多少列,因此我们需要找到一个地方来程序化设置它们。为此,我们回到受保护的基类方法。
在查看DataGrid类的受保护方法时,我们看到一个好的候选者:OnItemsSourceChanged方法。此方法将在ItemsSource值更改时被调用,因此它是初始化电子表格列的绝佳位置,当数据源更改时。
但是我们的项目是DataRow对象,每个Cell对象在其ItemArray集合中的位置都不同。我们需要一种方法来使用数组语法来数据绑定每个Cell,但是内置的列类型没有这个功能。因此,我们需要创建一个自定义的,而DataGridTemplateColumn类是最好的起点。
我们可以重写这个类,添加一个名为 Binding 的 Binding 类型属性,并使用它来设置为每个单元格生成的 UI 元素上的绑定。通过查看 DataGridTemplateColumn 类中的受保护方法,我们找到了 GenerateElement 方法,它生成这些 UI 元素。现在让我们看看这个新的 DataGridBoundTemplateColumn 类:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Views.Controls
{
public class DataGridBoundTemplateColumn : DataGridTemplateColumn
{
public Binding Binding { get; set; }
protected override FrameworkElement GenerateElement(DataGridCell cell,
object dataItem)
{
FrameworkElement element = base.GenerateElement(cell, dataItem);
if (Binding != null)
element.SetBinding(ContentPresenter.ContentProperty, Binding);
return element;
}
}
}
这是一个简单的类,我们首先扩展 DataGridTemplateColumn 类并声明上述 Binding 属性。然后我们重写 GenerateElement 方法,在其中首先调用基类实现以生成与当前单元格相关的 FrameworkElement 对象,并通过不变的方式传递输入参数。
如果 Binding 属性不是 null,我们就在元素上调用 SetBinding 方法,指定 ContentPresenter.ContentProperty 依赖属性作为绑定目标,并通过 Binding 属性传递 Binding 对象以连接到它。最后,我们简单地返回生成的元素。
现在,让我们回到 Spreadsheet 类的代码背后,在那里我们需要使用我们新的 DataGridBoundTemplateColumn 类:
using CompanyName.ApplicationName.DataModels;
using System.Collections;
using System.Data;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
...
protected override void OnItemsSourceChanged(IEnumerable oldValue,
IEnumerable newValue)
{
if (!(newValue is DataRowCollection rows) || rows.Count == 0) return;
Cell[] cells = rows[0].ItemArray.Cast<Cell>().ToArray();
Columns.Clear();
DataTemplate cellTemplate = (DataTemplate)FindResource("CellTemplate");
for (int i = 0; i < cells.Length; i++)
{
DataGridBoundTemplateColumn column = new DataGridBoundTemplateColumn
{
Header = GetColumnName(i + 1),
CellTemplate = cellTemplate,
Binding = new Binding($"[{i}]"),
Width = cells[i].Width
};
Columns.Add(column);
}
}
private string GetColumnName(int index)
{
if (index <= 26) return ((char)(index + 64)).ToString();
if (index % 26 == 0)
return string.Concat(GetColumnName(index / 26 - 1), "Z");
return string.Concat(GetColumnName(index / 26),
GetColumnName(index % 26));
}
如前所述,我们重写 OnItemsSourceChanged 方法,以便在数据源更改时初始化我们的电子表格列。在其中,我们使用 C# 6.0 模式匹配来验证 newValue 输入参数不是 null 并且是 DataRowCollection 类型,然后再检查集合中是否有一行或多行。
如果 DataRowCollection 对象有效,那么我们将它的第一行的 ItemArray 集合中的项目转换为我们自定义类型 Cell 的数组。我们只需要使用第一行,因为在这里,我们只是设置列,而不是数据。然后我们清除电子表格控制的列,并从控制的 Resources 部分找到名为 CellTemplate 的 DataTemplate。
接下来,我们遍历数组中的 Cell 对象,为每个对象在电子表格的 Columns 集合中添加一个新的 DataGridBoundTemplateColumn 元素。每个列元素使用 Header 初始化,该 Header 来自 GetColumnName 方法,CellTemplate DataTemplate,Cell 对象的 Width,以及一个 Binding 对象。
注意,Binding 路径设置为 $"[{i}]",例如,对于第一个项目,它将转换为 "[0]",这代表标准的索引表示法。这将导致绑定路径设置为数据绑定集合中每一行的第一个项目,或者换句话说,就是我们的数据源的第一列中的每个单元格。
如果 GetColumnName 方法中的输入值在 1 到 26 之间,我们将其加 64,然后将其转换为 char 并在结果上调用 ToString 方法。大写字母 A 在 ASCII 表中的整数值为 65,因此,这段代码的效果是将前 26 列的索引转换为字母 A 到 Z。
如果输入值大于26并且是26的精确倍数,那么我们返回对GetColumnName方法的递归调用的字符串连接,传递输入值除以26的因子,并从它中减去1,以及字母Z。
如果没有任何if条件满足,我们将返回两次递归调用的结果:第一个传递的值代表输入值除以 26 后的因子,第二个代表输入值除以 26 的余数。
用简单的英语来说,第一行输出字母 A 到 Z,第二行处理包含多个字母并以字母 Z 结尾的列标识符,第三行处理所有其他情况。让我们看看到目前为止运行应用程序时我们有什么结果:

向目标迈进
那么,我们需要对哪些方面进行修改,才能将这个 DataGrid 控件变成更类似于电子表格的外观? 我们需要相应地对其进行样式化,并在行标题中填充标识每行的数字。我们还需要在单元格被选中时突出显示相关的行和列标题,并且可以实现一个动画选择矩形来突出显示选中的单元格,而不是使用图像中显示的默认突出显示。
首先,让我们用数字填充行标题。有几种方法可以实现这一点,但我更喜欢简单地要求每个行在转换器类中询问其索引,并通过数据绑定将其连接到行标题。现在让我们看看这个转换器:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(DataGridRow), typeof(int))]
public class DataGridRowToRowNumberConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
CultureInfo culture)
{
if (value is DataGridRow dataGridRow)
return dataGridRow.GetIndex() + 1;
return DependencyProperty.UnsetValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
这是一个简单的类,并且,像往常一样,我们首先在ValueConversion属性中指定转换器中涉及的数据类型。在这种情况下,我们的输入将是DataRow对象,我们的输出将是它们的整数行号。在Convert方法中,我们使用 C# 6.0 的匹配模式作为快捷方式来验证我们的输入值不是null并且是适当类型,如果合适的话,将其转换为该类型。
如果输入有效,我们在预转换的dataGridRow变量上调用GetIndex方法,记得在返回转换器之前将零基方法结果加 1。对于所有其他输入值,我们返回DependencyProperty.UnsetValue值。由于我们不需要在相反方向转换任何值,所以我们留空ConvertBack方法。
让我们看看现在如何使用这个converter类。首先,我们需要为我们的Converters CLR 命名空间设置一个 XAML 命名空间,并在控制器的Resources部分创建其实例:
...
<Converters:DataGridRowToRowNumberConverter
x:Key="DataGridRowToRowNumberConverter" />
然后,我们能够在DataTemplate中TextBlock元素的Text属性的数据绑定中使用它,该DataTemplate应用于我们自定义的DataGrid的RowHeaderTemplate属性:
<DataGrid.RowHeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path = .,
RelativeSource={RelativeSource AncestorType={x:Type DataGridRow}},
Converter={StaticResource DataGridRowToRowNumberConverter}}" />
</DataTemplate>
</DataGrid.RowHeaderTemplate>
注意,绑定路径被设置为.,正如你可能记得的,这会将它设置为整个绑定对象。RelativeSource绑定将绑定源设置为DataGridRow类型TextBlock的第一个祖先,因此我们将整个DataGridRow对象传递给绑定,以及所需的转换器。
此外,请注意,我们必须在 XAML 文件的Resources部分下方声明这个RowHeaderTemplate属性。未能这样做将导致以下运行时错误:
Cannot find resource named 'DataGridRowToRowNumberConverter'. Resource names are case sensitive.
虽然有时我们可以通过使用DynamicResource标记扩展而不是StaticResource标记扩展来修复这些“找不到引用”错误,但在这种情况下它不会起作用。这是因为我们只能在DependencyObject的DependencyProperty上使用它们,而Converter属性不是一个DependencyProperty,Binding类也不是一个DependencyObject。
让我们看看现在我们的电子表格看起来像什么:

如前图所示,我们显然需要添加一些样式来修复一些问题,并使其看起来更像一个典型的电子表格:
<!--Default Selection Colors-->
<SolidColorBrush
x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="Transparent" />
<SolidColorBrush
x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="Black" />
<SolidColorBrush
x:Key="{x:Static DataGrid.FocusBorderBrushKey}" Color="Transparent" />
<SolidColorBrush x:Key="{x:Static
SystemColors.InactiveSelectionHighlightBrushKey}" Color="Transparent" />
<LinearGradientBrush x:Key="HorizontalBorderGradient" StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="{StaticResource BackgroundColor}" />
<GradientStop Color="{StaticResource BorderColor}" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="VerticalBorderGradient" StartPoint="0,0"
EndPoint="1,0">
<GradientStop Color="{StaticResource BackgroundColor}" />
<GradientStop Color="{StaticResource BorderColor}" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="DiagonalBorderGradient" StartPoint="0.2,0"
EndPoint="1,1">
<GradientStop Color="{StaticResource BackgroundColor}" Offset="0.45" />
<GradientStop Color="{StaticResource BorderColor}" Offset="1" />
</LinearGradientBrush>
...
<Style TargetType="{x:Type DataGridRowHeader}">
<Setter Property="Background"
Value="{StaticResource BackgroundBrush}" />
<Setter Property="BorderThickness" Value="0,0,1,1" />
<Setter Property="BorderBrush"
Value="{StaticResource VerticalBorderGradient}" />
<Setter Property="Padding" Value="4,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style TargetType="{x:Type DataGridColumnHeader}">
<Setter Property="Background"
Value="{StaticResource BackgroundBrush}" />
<Setter Property="BorderThickness" Value="0,0,1,1" />
<Setter Property="BorderBrush"
Value="{StaticResource HorizontalBorderGradient}" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="13" />
</Style>
这里需要注意的只有我们声明的第一个四个SolidColorBrush对象。.NET Framework 使用它们来设置许多内置控件默认的选择颜色。我们可以使用它们来更改之前图像中显示的默认蓝色背景和白色文本。在SystemColors类中可以找到更多这些默认颜色,因此熟悉它们是值得的。
让我们看看现在我们的电子表格看起来像什么:

现在,我们的Spreadsheet控件开始看起来更像是一个典型的电子表格应用程序,但我们不再有选中单元格的高亮显示。你也许还会注意到,行标题没有像我们的样式建议的那样水平居中对齐。
这是因为,与DataGridColumnHeader类的默认ControlTemplate不同,DataGridRowHeader类的默认ControlTemplate没有将HorizontalContentAlignment属性映射到模板内部任何元素的HorizontalAlignment属性。
这可能最初看起来像是微软的一个疏忽,但实际上是因为,在默认的ControlTemplate中,每个DataGridRowHeader对象都有一个额外的控件,用于在标题内容右侧显示验证错误。由于这个额外控件占据了有限的空间,因此没有足够的空间水平居中行标题。
为了解决这个问题,我们需要修改默认的ControlTemplate,以移除显示错误模板的控制项。巧合的是,我们还需要修改这个模板,以便能够突出显示行标题中的选定单元格。同样,为了突出显示列标题中的选定单元格,我们需要调整DataGridColumnHeader类的默认ControlTemplate。
突出显示选择
现在我们来处理突出显示选定单元格的任务。在这里,我们将找出创建围绕选定单元格的选择矩形所需的内容,这个矩形能够平滑地从选择状态过渡到另一个选择状态。但在那之前,让我们研究一下我们如何还能在电子表格控制器的轴上指示哪个单元格被选中。
在轴上指示
为了突出显示行和列标题中的当前选定单元格,我们需要更新前面描述的两个相关默认ControlTemplate对象。但在我们这样做之前,我们还需要声明两个新的IValueConverter类。让我们首先看看行标题转换器类:
using System;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
public class DataGridRowHeaderSelectionMultiConverter :
IMultiValueConverter
{
public object Convert(object[] values, Type targetType,
object parameter, CultureInfo culture)
{
if (values == null || values.Count() != 2 ||
!(values[0] is DataRow selectedDataRow) ||
!(values[1] is DataRow dataRowToCompare)) return false;
return selectedDataRow.Equals(dataRowToCompare);
}
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
对于这个转换器,我们扩展了IMultiValueConverter接口,并在Convert方法中,我们首先验证我们的值输入参数。如果它是null,包含的对象多于或少于两个,或者如果包含的对象中的任何一个不是非null的DataRow对象,我们返回false。如果输入参数有效,我们使用 C# 6.0 模式匹配将两个包含的对象转换为DataRow类型。
第一个表示包含当前选定单元格的DataRow对象,第二个来自用于比较的DataRow对象。如果选定的DataRow对象等于当前比较的DataRow对象,我们返回true,对于所有其他对象返回false。你可以把这想成每一行标题依次询问转换器,它是否与当前选定的单元格在同一个DataRow对象中。对于我们的示例,ConvertBack方法是不需要的,因此留空未实现。
现在我们来研究我们需要对DataGridRowHeader类的默认ControlTemplate所做的更改。如第五章,使用正确的控件完成任务中所述,在修改现有控件部分,我们可以从 Visual Studio 的属性面板中创建默认ControlTemplate的副本。请注意,如果我们 XAML 文件中还没有正确类型的控件,我们可以简单地临时声明一个控件,选择它,然后继续按照描述的模板提取过程,记得之后删除它。
由于这个模板相当长,我们在这里不会展示全部内容,而是仅突出显示我们需要更改的区域。整个代码将在本书附带的可下载代码包中提供。然而,在我们可以使用此模板之前,我们需要将PresentationFramework.Aero程序集的引用添加到我们的项目中,并为Microsoft.Windows.Themes CLR 命名空间添加一个 XAML 命名空间:
接下来,我们需要在我们的电子表格控制的Resources部分添加我们新的转换器类的实例:
<Converters:DataGridRowHeaderSelectionMultiConverter
x:Key="DataGridRowHeaderSelectionMultiConverter" />
现在,让我们看看模板:
<ControlTemplate x:Key="DataGridRowHeaderControlTemplate"
TargetType="{x:Type DataGridRowHeader}">
<Grid>
<Themes:DataGridHeaderBorder Name="Border"
IsHitTestVisible="False" ... >
<ContentPresenter ... HorizontalAlignment="{TemplateBinding
HorizontalContentAlignment}" />
</Themes:DataGridHeaderBorder>
<Rectangle Name="ColorSelectionBar" Fill="Transparent"
IsHitTestVisible="False" VerticalAlignment="Stretch"
HorizontalAlignment="Right" Width="2" Margin="0,-1,0,0" />
...
</Grid>
<ControlTemplate.Triggers>
<DataTrigger Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource
DataGridRowHeaderSelectionMultiConverter}">
<Binding Path="CurrentCell.Item" RelativeSource="{RelativeSource
AncestorType={x:Type DataGrid}}" />
<Binding />
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Foreground"
Value="{StaticResource SelectionBrush}" />
<Setter TargetName="ColorSelectionBar" Property="Fill"
Value="{StaticResource SelectionBrush}" />
<Setter TargetName="Border" Property="Background"
Value="{StaticResource SelectedBackgroundBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
为了应用模板,我们需要在我们的DataGridRowHeader类的Style中添加另一个Setter元素,确保在 XAML 中先声明模板:
<Setter Property="Template"
Value ="{StaticResource DataGridRowHeaderControlTemplate}" />
在获得DataGridRowHeader类的默认ControlTemplate副本后,我们首先将DataGridHeaderBorder元素命名为Border,这样我们就可以从模板的Triggers集合中引用它。我们还将其IsHitTestVisible属性设置为False,以防止从行标题中选择。
然后,我们删除了显示验证错误模板的控制,并通过TemplateBinding元素将内部ContentPresenter元素的HorizontalAlignment属性与父DataGridRowHeader对象的HorizontalContentAlignment属性连接起来,以便我们的样式实际上会居中标题内容,正如之前所期望的那样。
接下来,我们添加了一个新的Rectangle元素,命名为ColorSelectionBar,以及一个DataTrigger对象。Rectangle元素将其Fill属性设置为Transparent,因此最初无法看到它,并将其IsHitTestVisible属性设置为False,以防止用户与之交互。
我们将其VerticalAlignment属性设置为Stretch,以便它跨越整个行标题的高度,并将其HorizontalAlignment属性设置为Right,以确保它位于标题的右侧,避开行指示器。最后,我们将其上边距设置为-1,以便将其扩展到标题的顶部边界,因为它已经通过一个像素扩展到底部边界。
然后,我们在Triggers集合中添加了一个DataTrigger对象,使用MultiBinding对象来定义其条件。我们将我们的DataGridRowHeaderSelectionMultiConverter实例分配给MultiBinding对象的Converter属性。
注意,MultiBinding对象有两个绑定:一个是与DataGrid控制的CurrentCell属性相关的DataRow对象,另一个直接设置为应用于模板的DataRow对象。
当转换器返回true时,我们使用我们的SelectionBrush画笔绘制ColorSelectionBar元素和标题前景,以及表示行标题背景的Border元素,使用SelectedBackgroundBrush画笔。这导致每次选择单元格时,所选单元格的行标题都会被突出显示。
让我们现在为列标题做同样的事情,从查看所需的 DataGridColumnHeaderSelectionMultiConverter 类开始:
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
public class DataGridColumnHeaderSelectionMultiConverter :
IMultiValueConverter
{
public object Convert(object[] values, Type targetType,
object parameter, CultureInfo culture)
{
if (values == null || values.Count() != 2 ||
values.Any(v => v == null || v == DependencyProperty.UnsetValue))
return false;
string selectedColumnHeader = values[0].ToString();
string columnHeaderToCompare = values[1].ToString();
return selectedColumnHeader.Equals(columnHeaderToCompare);
}
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
我们再次扩展了 IMultiValueConverter 接口,并在 Convert 方法中,我们首先检查输入值以确保它们对于此转换器的有效性。我们验证 values 输入参数不是 null,并且它包含两个非 null 值,这些值也不是未设置值。如果这些检查中的任何一个失败,我们返回 false。
如果 values 输入参数有效,我们将在其中包含的两个对象上调用 object.ToString 方法。第一个值表示所选列标题中的文本,第二个表示要比较的列标题中的文本。
每个列标题将依次调用此转换器,如果要比较的列标题等于选定的列标题,则表示它是包含所选单元格的列,我们返回 true,否则返回 false。由于 ConvertBack 方法对于此示例不是必需的,因此它被留空未实现。
在我们修改 DataGridColumnHeader 类的默认 ControlTemplate 之前,我们需要将我们的新 converter 类引用添加到我们的电子表格控件中的 Resources 部分中:
<Converters:DataGridColumnHeaderSelectionMultiConverter
x:Key="DataGridColumnHeaderSelectionMultiConverter" />
现在,让我们看看编辑后的模板:
<ControlTemplate x:Key="DataGridColumnHeaderControlTemplate"
TargetType="{x:Type DataGridColumnHeader}">
<Grid>
<Themes:DataGridHeaderBorder Name="Border" ... />
<Rectangle Name="ColorSelectionBar" Fill="Transparent"
IsHitTestVisible="False" HorizontalAlignment="Stretch"
VerticalAlignment="Bottom" Height="2" Margin="-1,0,0,0" />
...
</Grid>
<ControlTemplate.Triggers>
<DataTrigger Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource
DataGridColumnHeaderSelectionMultiConverter}">
<Binding Path="CurrentCell.Column.Header" RelativeSource="{
RelativeSource AncestorType={x:Type DataGrid}}" />
<Binding Path="Content" RelativeSource="{RelativeSource Self}" />
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Foreground"
Value="{StaticResource SelectionBrush}" />
<Setter TargetName="ColorSelectionBar" Property="Fill"
Value="{StaticResource SelectionBrush}" />
<Setter TargetName="Border" Property="Background"
Value="{StaticResource SelectedBackgroundBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
从 DataGridColumnHeader 类的默认 ControlTemplate 开始,我们再次添加了一个 UI 元素和一个 DataTrigger 对象,用于控制其可见性,就像我们为自定义的 DataGridRowHeader ControlTemplate 所做的那样。然而,此模板需要更少的修改,因为其标题已经居中,我们不需要删除任何元素。
再次强调,名为 ColorSelectionBar 的 Rectangle 对象是新的元素。请注意,我们将其 Fill 属性设置为 Transparent,这样它最初是不可见的。记住,每一列都有一个列标题,我们不希望它们同时都被突出显示。
我们将 Rectangle 元素的 IsHitTestVisible 属性设置为 False,以防止用户与其交互。我们将它的 HorizontalAlignment 属性设置为 Stretch,以便它跨越列标题的全宽,无论其大小如何,并将它的 VerticalAlignment 属性设置为 Bottom,以确保它位于标题的底部,为列标识符留出空间。
我们将其左外边距设置为 -1,以便它扩展到标题的左侧边界,因为它已经通过一个像素扩展到右侧边界。在 ControlTemplate 的 Triggers 部分中,我们添加了一个 DataTrigger 对象,它具有一个 MultiBinding 对象,该对象将我们的 DataGridColumnHeaderSelectionMultiConverter 类引用设置为它的 Converter 属性。
注意,我们有两个 Binding 元素连接到它:一个设置为当前选中 Column 对象的 Header 属性,另一个直接设置为 DataGridColumnHeader 类的 Content 属性。每个列标题将依次调用转换器,如果您还记得,包含选中单元格的列将导致转换器返回 true。
当连接到此转换器的 MultiBinding 对象返回 true 时,DataTrigger 的 Setter 会用 SelectionBrush 资源填充标题前景和 ColorSelectionBar 矩形,并用 SelectedBackground 资源填充标题的背景,从而突出显示当前选中单元格的列标题。
为了应用这个 ControlTemplate 对象,我们还需要在我们的 Style 中为 DataGridColumnHeader 类添加一个 Setter 元素,确保模板声明在 Style 声明之前,在 XAML 中:
<Setter Property="Template"
Value ="{StaticResource DataGridColumnHeaderControlTemplate}" />
如您所见,我们对两个默认的 ControlTemplate 对象进行了最小程度的修改,但我们已经成功地使它们适应了我们的目的。通过这种方式,我们能够操纵内置的 .NET 控件,进一步扩展它们原有的用途。让我们看看我们代码的最新添加部分的视觉输出:

现在我们有一个网格,开始看起来更像是一个典型的电子表格应用程序。让我们继续并添加用户的选择突出显示。
强调选择
现在我们唯一要做的就是实现选择矩形和位于控件右上角的“全选”按钮的样式。我们可以通过调整 DataGrid 类的默认 ControlTemplate 来完成这两个任务。让我们将其分解为步骤。首先,我们需要在我们的 Resources 部分添加一个 ControlTemplate 用于“全选”按钮:
<ControlTemplate x:Key="SelectAllButtonControlTemplate"
TargetType="{x:Type Button}">
<Border BorderThickness="0,0,1,1" BorderBrush="{StaticResource
DiagonalBorderGradient}" Background="{StaticResource BackgroundBrush}">
<Polygon Fill="#FFB3B3B3" Points="0,12 12,12 12,0"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Stretch="Uniform" Margin="10,3,3,3" />
</Border>
</ControlTemplate>
这里,我们有一个非常简单的模板,其中我们用基本三角形替换了 Button 控件的默认定义。它包含一个 Border 元素,使用我们添加到电子表格控件资源中的 DiagonalBorderGradient 画笔绘制其右侧和底部边框。它还用我们的 BackgroundBrush 资源绘制 Button 控件的背景。
在 Border 元素内部,我们声明了一个 Polygon 形状,并用灰色画笔填充它。其形状由其 Points 属性中声明的值决定,因此它从 0,12 开始,继续到 12,12,然后到 12,0,最后返回到 0,12。将这些值绘制在图上会显示一个三角形,这就是 Polygon 元素将要渲染的形状。
我们将其对齐到 Border 元素的右下角,并将其 Stretch 属性设置为 Uniform 以确保在大小变化时保持其纵横比。最后,我们将其 Margin 属性设置为与 Border 元素的边缘保持一定的空间。
接下来,我们需要将 SelectAllButtonControlTemplate 模板应用到全选按钮上,并在 DataGrid 类的默认 ControlTemplate 中添加一个透明的 Canvas 元素,该元素将出现在 ScrollViewer 对象内部。让我们从默认模板中提取这部分,并在我们的 Resources 部分中声明它:
<ControlTemplate x:Key="ScrollViewerControlTemplate"
TargetType="{x:Type ScrollViewer}">
<Grid>
...
<Button Command="ApplicationCommands.SelectAll"
Focusable="False" Width="26" Height="26"
Template="{StaticResource SelectAllButtonControlTemplate}" />
...
<ScrollContentPresenter x:Name="PART_ScrollContentPresenter" ... />
<Border Grid.Row="1" Grid.Column="1" ClipToBounds="True"
BorderThickness="0" IsHitTestVisible="False" Margin="-2">
<Canvas Name="SelectionRectangleCanvas" Background="{x:Null}"
IsHitTestVisible="False" RenderTransformOrigin="0.5,0.5"
Margin="2" />
</Border>
<ScrollBar x:Name="PART_VerticalScrollBar" ... />
...
</Grid>
</ControlTemplate>
我们首先将全选按钮的宽度和高度设置为 26 像素,与我们的行和列标题的尺寸相匹配。然后,我们将 Resources 部分中的 ControlTemplate 应用到它上。我们还从默认模板中移除了 Visibility 绑定,因为在我们的示例中我们不需要它。请注意,在这个示例中,这个按钮没有任何动作,纯粹是装饰性的。
接下来,我们在 Border 元素内添加了透明的 Canvas 控件,该控件将显示选择矩形。请注意,我们必须在名为 PART_ScrollContentPresenter 的必需部分之后添加它,以确保选择矩形将出现在单元格的 Z 轴上方。此外,请注意,我们必须将其包裹在一个不可见的 Border 元素中,这样我们就可以剪切它的边界。尝试移除 ClipToBounds 属性并缩小控件的大小作为一个实验,看看会发生什么。
我们将 Border 元素的 Margin 属性设置为所有方向上的 -2,以便它可以在每个单元格的边界内和边界外显示选择矩形。因此,我们需要将绘制矩形的 Canvas 的 Margin 属性在所有方向上设置为 2,以补偿边界的负边距。
我们给 Canvas 元素命名,以便我们可以从代码后面访问它,并将其 Background 属性设置为 null,这比设置为 Transparent 稍微便宜一些。然后,我们将 IsHitTestVisible 属性设置为 False,使其对用户和他们的鼠标光标不可见,并将渲染变换的原点居中,我们将使用它来更新包含的 ScrollViewer 对象每次移动时 Canvas 元素的位置。
现在让我们看看 DataGrid 类的简化 ControlTemplate:
<ControlTemplate x:Key="DataGridControlTemplate"
TargetType="{x:Type DataGrid}">
<Border ... >
<ScrollViewer x:Name="DG_ScrollViewer" Focusable="False"
CanContentScroll="False"
Template="{StaticResource ScrollViewerControlTemplate}">
<ItemsPresenter
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</ControlTemplate>
我们对 DataGrid 控件的默认 ControlTemplate 进行了一些更改。首先,我们将名为 DG_ScrollViewer 的 ScrollViewer 元素的 CanContentScroll 属性设置为 False,使其在物理单位(像素)而不是逻辑单位(行)中滚动。唯一的其他更改是将它的内联 ControlTemplate 对象替换为我们在 Resources 部分中添加的自定义模板的引用。
我们还必须记住将这个自定义的 ControlTemplate 对象分配给我们的电子表格控件。这可以在类声明中实现:
<DataGrid
x:Class="CompanyName.ApplicationName.Views.Controls.Spreadsheet" ...
Template="{DynamicResource DataGridControlTemplate}">
...
</DataGrid>
现在,让我们再次看看我们的 电子表格 控件,并查看所有最新的更改:

我们可以看到工作几乎完成了。我们现在已经设置了所有 XAML 以显示选择矩形,但我们仍然需要通过编程方式定位和动画化它。首先,我们需要从我们的自定义DataGrid模板中获取对Scrollviewer的引用。
我们可以通过重写DataGrid基类中的另一个方法来实现这一点。OnApplyTemplate方法在应用ControlTemplate时被调用,因此它是一个访问其中包含的元素的理想位置:
private ScrollViewer scrollViewer;
...
public override void OnApplyTemplate()
{
scrollViewer = Template.FindName("DG_ScrollViewer", this) as ScrollViewer;
}
在此方法中,我们在Spreadsheet控件模板上调用FindName方法,传入我们的ScrollViewer对象名称和作为模板父级的Spreadsheet引用。然后,我们使用as运算符关键字将返回的对象强制转换为ScrollViewer,以避免抛出异常。
注意,由于此电子表格示例相当长,我们省略了通常的null检查,关于从ControlTemplate元素访问内部控件。在实际应用中,这些检查始终应该实现,因为我们永远不能确定我们的所需元素是否在模板中,因为模板可能已被更改。
接下来,我们需要一个引用到我们将绘制选择矩形的Canvas面板:
private Canvas selectionRectangleCanvas;
...
private void SpreadsheetScrollViewer_ScrollChanged(object sender,
ScrollChangedEventArgs e)
{
if (selectionRectangleCanvas == null) GetCanvasReference();
}
private void GetCanvasReference()
{
ControlTemplate scrollViewerControlTemplate = scrollViewer.Template;
selectionRectangleCanvas = scrollViewerControlTemplate.
FindName("SelectionRectangleCanvas", scrollViewer) as Canvas;
selectionRectangleCanvas.RenderTransform = new TranslateTransform();
}
在SpreadsheetScrollViewer_ScrollChanged事件处理程序中,我们首先检查selectionRectangleCanvas私有变量是否为null。如果是,我们调用GetCanvasReference方法,以获取对其的引用并将其分配给一个私有成员变量。
在GetCanvasReference方法中,我们从之前存储引用的ScrollViewer元素的Template属性中访问ControlTemplate对象。我们调用其上的FindName方法,传入我们的Canvas对象名称和作为其模板父级的ScrollViewer元素引用。
然后,我们将返回的对象(转换为Canvas类型)分配给私有的selectionRectangleCanvas成员变量,并设置一个新的TranslateTransform对象到其RenderTransform属性。我们将使用它来更新包含ScrollViewer对象的视口每次移动时Canvas元素的位置,这将确保选择矩形会随着工作表一起滚动。
注意,我们仅在此事件处理程序中获取Canvas元素的引用是为了简化示例。一个更好的解决方案是扩展ScrollViewer类并声明一个TemplateChanged事件,该事件通过自定义EventArgs类传递新模板的引用。
我们可以从重写的OnApplyTemplate方法中引发它,就像我们访问我们的ScrollViewer引用一样,并从我们的Spreadsheet类中订阅它。我们当前实现的问题在于ScrollChanged事件被多次引发,每次引发时,我们都会检查是否已经有了引用,因此在滚动时会有大量的 CPU 周期浪费。
返回到当前实现,让我们将我们的ScrollChanged事件处理程序分配给ScrollViewer,在我们的DataGrid类的自定义模板中:
<ScrollViewer x:Name="DG_ScrollViewer" ...
ScrollChanged="SpreadsheetScrollViewer_ScrollChanged">
现在,让我们调查用于绘制和动画化选择矩形的代码:
using System;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
...
private Rectangle selectionRectangle;
private bool isSelectionRectangleInitialized = false;
...
private void UpdateSelectionRectangle(Point startPosition,
Point endPosition)
{
TimeSpan duration = TimeSpan.FromMilliseconds(150);
if (!isSelectionRectangleInitialized)
InitializeSelectionRectangle(startPosition, endPosition);
else
{
selectionRectangle.BeginAnimation(WidthProperty, new DoubleAnimation(
endPosition.X - startPosition.X, duration), HandoffBehavior.Compose);
selectionRectangle.BeginAnimation(HeightProperty, new DoubleAnimation(
endPosition.Y - startPosition.Y, duration), HandoffBehavior.Compose);
}
TranslateTransform translateTransform =
selectionRectangle.RenderTransform as TranslateTransform;
translateTransform.BeginAnimation(TranslateTransform.XProperty,
new DoubleAnimation(startPosition.X - RowHeaderWidth +
scrollViewer.HorizontalOffset, duration), HandoffBehavior.Compose);
translateTransform.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(startPosition.Y - ColumnHeaderHeight +
scrollViewer.VerticalOffset, duration), HandoffBehavior.Compose);
}
private void InitializeSelectionRectangle(Point startPosition,
Point endPosition)
{
selectionRectangle = new Rectangle();
selectionRectangle.Width = endPosition.X - startPosition.X;
selectionRectangle.Height = endPosition.Y - startPosition.Y;
selectionRectangle.Stroke =
new SolidColorBrush(Color.FromRgb(33, 115, 70));
selectionRectangle.StrokeThickness = 2;
selectionRectangle.RenderTransform = new TranslateTransform();
Canvas.SetTop(selectionRectangle, 0); // row and column header
Canvas.SetLeft(selectionRectangle, 0);
selectionRectangleCanvas.Children.Add(selectionRectangle);
isSelectionRectangleInitialized = true;
}
在UpdateSelectionRectangle方法中,我们首先声明一个 150 毫秒的持续时间用于我们的动画,并检查选择矩形是否已经初始化。如果没有,我们调用InitializeSelectionRectangle方法,通过传入startPosition和endPosition输入参数。在继续之前,让我们检查这个方法。
在InitializeSelectionRectangle方法中,我们使用从两个Point输入参数计算出的尺寸初始化SelectionRectangle元素,并为其 stroke 设置默认值。我们将一个新的TranslateTransform对象分配给其RenderTransform属性,以便在代码中操作其位置。
然后,我们使用Canvas类的SetTop和SetLeft附加属性来定位矩形,将其放置在我们为ScrollViewer类添加的自定义ControlTemplate中的Canvas面板的左上角。
我们通过将SelectionRectangle元素添加到selectionRectangleCanvas面板的Children集合中,并将isSelectionRectangleInitialized变量设置为true来结束操作,以确保初始化代码只被调用一次。
返回到UpdateSelectionRectangle方法,如果选择矩形已经初始化,那么我们使用startPosition和endPosition输入参数来动画化其大小,从之前单元格的大小过渡到新选中单元格的大小。
我们对SelectionRectangle元素的WidthProperty和HeightProperty依赖属性调用BeginAnimation方法,这样矩形的尺寸将平滑地从之前选中单元格的大小动画过渡到新单元格的大小。
接下来,我们从SelectionRectangle元素的RenderTransform属性中访问TranslateTransform实例,并对其调用BeginAnimation方法,针对Xproperty和Yproperty依赖属性。这正是我们在ScrollViewer元素模板中添加的Canvas上动画化选择矩形位置的方式。
为了计算水平位置,我们从startPosition输入参数的X属性值中减去之前在 XAML 类声明中设置的RowHeaderWidth属性值,然后加上ScrollViewer元素的HorizontalOffset属性值。
同样,垂直位置是通过从startPosition输入参数的Y属性值中减去ColumnHeaderHeight属性值,并加上ScrollViewer元素的VerticalOffset属性值来计算的。
四个动画共享相同的持续时间,我们在开始时声明了它,所以它们可以统一地改变选择矩形的尺寸和位置。它们还都设置了HandoffBehavior值为Compose,这基本上提供了连续动画之间的更平滑的连接。我们将在第七章,掌握实用动画中了解更多关于这个,但现在我们将保持简单。
因此,我们的UpdateSelectionRectangle方法负责在先前和当前单元格选择之间动画化选择矩形,但是它从哪里被调用呢?没错...我们将从另一个重写的受保护基类方法中调用它。
通过查看DataGrid类的受保护基类方法,我们发现OnSelectedCellsChanged方法,它在用户在我们的工作表控件中选择新单元格时被调用,所以它是完美的候选者。现在让我们看看它的实现:
protected override void
OnSelectedCellsChanged(SelectedCellsChangedEventArgs e)
{
// base.OnSelectedCellsChanged(e);
if (e.AddedCells != null && e.AddedCells.Count == 1)
{
DataGridCellInfo cellInfo = e.AddedCells[0];
if (!cellInfo.IsValid) return;
FrameworkElement cellContent =
cellInfo.Column.GetCellContent(cellInfo.Item);
if (cellContent == null) return;
DataGridCell dataGridCell = (DataGridCell)cellContent.Parent;
if (dataGridCell == null) return;
Point relativePoint =
dataGridCell.TransformToAncestor(this).Transform(new Point(0, 0));
Point startPosition =
new Point(relativePoint.X - 3, relativePoint.Y - 3);
Point endPosition =
new Point(relativePoint.X + dataGridCell.ActualWidth,
relativePoint.Y + dataGridCell.ActualHeight);
UpdateSelectionRectangle(startPosition, endPosition);
}
}
注意,这个方法的基类版本负责引发SelectedCellsChanged事件,所以如果我们需要它发生,我们应该从这个方法中调用它。如果我们对是否调用我们正在重写的基类版本的方法有疑问,通常更安全的是这样做,因为我们可能会丢失它提供的某些必需功能。然而,在这个例子中,我们不需要这个事件,所以我们可以安全地省略对基类方法的调用。
在我们重写的OnSelectedCellsChanged方法中,我们检查SelectedCellsChangedEventArgs输入参数的AddedCells属性是否恰好包含一个项目。请注意,在这个例子中,它应该只包含一个项目,因为我们已经将我们的工作表控件的SelectionMode属性设置为Single,但始终验证这些事情是一个好的做法。
然后,我们从AddedCells属性中提取单个DataGridCellInfo对象,如果它无效,就从方法中返回执行。如果它是有效的,我们在它的Column属性上调用GetCellContent方法,传入它的Item属性,以作为FrameworkElement对象访问单元格内容。这可能会需要更多的解释。
Column属性包含与所选单元格相关的DataGridBoundTemplateColumn元素,同样,Item属性持有包含所选单元格的DataRow对象。返回的FrameworkElement对象代表DataGridCell元素的内容,在我们的例子中是一个ContentPresenter对象。
我们在应用于DataGridBoundTemplateColumn的DataTemplate元素中声明的任何 UI 元素。可以通过这个ContentPresenter对象,通过遍历视觉树来访问CellTemplate属性。在我们的例子中,这是一个简单的TextBlock元素。回到我们的代码,如果这个单元格内容是null,我们就从方法中返回执行。
如果单元格内容有效,我们将其 Parent 属性值强制转换为其实际的 DataGridCell 类型。如果这个 DataGridCell 对象为 null,我们也从方法中返回执行。如果它是有效的,我们调用其 TransformToAncestor 方法,然后是 Transform 方法,以找到相对于电子表格控件的位置。
我们然后使用相对位置来创建矩形的起点,即左上角,通过在每个轴上减去 3 像素。这确保矩形将位于单元格内容之外,略微重叠。
类似地,我们也使用相对位置来创建矩形的终点,即右下角,通过向其中添加 DataGridCell 对象的实际尺寸。最后,我们调用 UpdateSelectionRectangle 方法来绘制选择矩形,并通过传递计算出的起始点和终点。
现在,我们的选择矩形正在工作,并且可以平滑地从选中的单元格动画过渡到下一个。然而,在一个更大的电子表格中,你可能会注意到它不会与电子表格本身一起滚动。这是因为它的位置和定义在其内部的 ScrollViewer 的水平和垂直偏移量之间还没有连接。
为了解决这个问题,我们需要在每次电子表格控件滚动时更新 TranslateTransform 对象的位置信息,该对象是在绘制选择矩形时使用的 Canvas 元素。现在让我们看看我们如何通过向我们的 SpreadsheetScrollViewer_ScrollChanged 事件处理器中添加更多代码来实现这一点:
private void SpreadsheetScrollViewer_ScrollChanged(object sender,
ScrollChangedEventArgs e)
{
if (selectionRectangleCanvas == null) GetCanvasReference();
TranslateTransform selectionRectangleCanvasTransform =
selectionRectangleCanvas.RenderTransform as TranslateTransform;
selectionRectangleCanvas.RenderTransform = new TranslateTransform(
selectionRectangleCanvasTransform.X - e.HorizontalChange,
selectionRectangleCanvasTransform.Y - e.VerticalChange);
}
跳过现有代码中引用我们选择矩形 Canvas 面板的代码,我们通过其 RenderTransform 属性访问我们在 GetCanvasReference 方法中声明的 TranslateTransform 元素。然后我们创建一个新的 TranslateTransform 对象,其值来自原始对象,加上任意方向的滚动距离,并将其设置回 RenderTransform 属性。
注意,我们必须这样做,因为 TranslateTransform 元素是不可变的,不能被更改。因此,我们需要用新元素替换它,而不是仅仅更新其属性值。任何修改它的尝试都将导致抛出运行时异常:
System.InvalidOperationException: 'Cannot set a property on object 'System.Windows.Media.TranslateTransform' because it is in a read-only state.'
现在我们来最后看看我们的电子表格控件的可视输出:

当然,我们可以继续改进我们的电子表格控件,例如,通过添加事件处理器来检测用户调整行和列大小时行和列大小的变化,并相应地更新选择矩形。我们还可以扩展 Cell 类,添加样式和格式属性,以样式化每个单元格并格式化内容。
我们可以添加一个公式栏或一个备选信息面板,当点击时显示公式或从单元格中获取的更多信息。我们可以实现多单元格选择,或者允许用户编辑单元格内容。但无论如何,希望这个扩展示例已经为你提供了足够理解,以便能够成功独立完成这类高级项目。
摘要
在本章中,我们进一步研究了内置控件,特别关注在派生类中覆盖基类方法的多态能力。我们首先检查了.NET Framework 源代码中的示例,然后继续创建我们自己的示例,以突出这种能力。
我们继续介绍扩展示例,以帮助完全理解使用这种方法可以获得的好处。通过这些示例,我们突出了许多问题,并学会了如何逐一克服它们,通过扩展内置控件和覆盖特定的基类方法。
在下一章中,我们将彻底研究 WPF 动画系统,并了解我们如何在日常应用中利用它。我们还将发现许多微调动画以获得完美效果的技术,并了解我们如何将动画功能直接构建到我们的应用程序框架中。
第七章:掌握实用动画
WPF 提供了从简单到非常复杂的广泛动画可能性。在本章中,我们将彻底探索 WPF 属性动画系统,但主要关注那些可以适用于现实世界商业应用的部分。我们将研究如何实时控制运行中的动画,并主要关注基于 XAML 的语法。然后我们将看到如何将动画直接构建到我们的应用程序框架中。
在 WPF 中,动画是通过在固定间隔内重复更改单个属性值来创建的。动画由多个组件组成:我们需要一个时间系统、一个负责更新特定类型对象值的动画对象以及一个合适的属性来进行动画化。
为了能够动画化一个属性,它必须是DependencyObject的依赖属性,并且其类型必须实现IAnimatable接口。由于大多数 UI 控件扩展了DependencyObject类,这使得我们可以动画化大多数控件属性。
此外,还必须存在相关类型的动画对象。在 WPF 中,动画对象也充当时间系统,因为它们扩展了Timeline类。在研究各种动画对象之前,让我们首先检查时间系统。
研究时间线
动画需要某种时间机制,负责在正确的时间更新相关属性值。在 WPF 中,这种时间机制由抽象的Timeline类提供,简而言之,它代表一段时间。所有可用的动画类都扩展了这个类,并添加了自己的动画功能。
当使用Timeline类进行动画时,会创建一个内部副本并冻结,使其不可变。此外,会创建一个Clock对象来保存Timeline对象的运行时时间状态,并负责实际的时间更新。Timeline对象本身除了定义相关的时间段外,几乎不做其他事情。
当我们定义Storyboard对象或调用Animatable.BeginAnimation方法之一时,Clock对象将自动为我们创建。请注意,我们通常不需要直接关注这些Clock对象,但了解它们对于理解整体情况可能有所帮助。
存在多种不同的Timeline对象类型,从AnimationTimeline类到TimelineGroup和ParallelTimeline类。然而,对于动画目的,我们主要使用Storyboard类,它扩展了ParallelTimeline和TimelineGroup类,并添加了动画目标属性和方法来控制时间线。让我们首先研究基类Timeline的主要属性。
Duration 属性指定由相关 Timeline 对象表示的时间。然而,时间线可以有重复,因此 Duration 属性的更准确描述可能是它指定了相关 Timeline 对象的单次迭代的持续时间。
Duration 属性的类型是 Duration,它包含一个 TimeSpan 属性,该属性包含指定持续时间值的实际时间。然而,WPF 包含一个类型转换器,它使我们能够在以下格式中指定 XAML 中的 TimeSpan 值,其中方括号突出显示可选部分:
Duration="[Days.]Hours:Minutes:Seconds[.FractionalSeconds]"
Duration="[Days.]Hours:Minutes"
然而,Duration 结构还接受除了 TimeSpan 持续时间之外的其他值。有一个 Automatic 值,这是包含其他时间线的组件时间线的默认值。在这些情况下,此值仅意味着父时间线的持续时间将与它的子时间线中最长持续时间相同。我们明确使用此值几乎没有意义。
然而,还有一个对我们非常有用的额外价值。Duration 结构还定义了一个表示无限时间段的 Forever 属性。我们可以使用这个值使动画无限期地继续,或者更准确地说,只要其相关的视图正在显示:
Duration="Forever"
当 Timeline 对象达到其持续时间结束时,它将停止播放。如果它与任何关联的子时间线相关联,那么它们也将在此点停止播放。然而,时间线的自然持续时间可以通过其他属性进行扩展或缩短,我们将在稍后看到。
一些时间线,如 ParallelTimeline 和 Storyboard 类,能够包含其他时间线,并且可以通过设置自己的 Duration 属性值来影响它们的持续时间,这将覆盖子时间线设置的值。让我们通过修改 第五章 的早期动画示例,使用正确的控件完成任务 来演示这一点:
<Rectangle Width="0" Height="0" Fill="Orange">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Duration="0:0:2.5">
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2.5" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:5" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
在这个先前的示例中,我们有一个初始尺寸设置为零的 Rectangle 对象。Storyboard 对象包含两个独立的动画对象,将动画其尺寸从零到三百像素。将动画矩形宽度的动画对象的持续时间设置为两秒半,而将动画高度的动画对象的持续时间设置为五秒。
然而,包含的 Storyboard 对象的持续时间是两秒半,因此这将停止两个子动画对象的时间线在两秒半后,无论它们声明的持续时间如何。这将导致动画完成后,我们的 Rectangle 对象将显示为一个矩形,而不是具有相等高度和宽度值的正方形。
如果我们改变了故事板的持续时间以匹配较长的子动画,或者改变了该动画持续时间以匹配较短的子动画,那么我们的动画形状将结束为一个正方形,而不是一个矩形。
调整动画元素分配的持续时间的另一种方法是设置其 AutoReverse 属性。实际上,将此属性设置为 True 通常会将 Duration 属性指定的长度加倍,因为时间线在完成正常的正向迭代后将以反向播放。让我们修改上一个示例中的故事板来演示这一点:
<Storyboard Duration="0:0:5">
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2.5" AutoReverse="True" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:5" />
</Storyboard>
现在,两个子时间线将具有相同的时间线整体持续时间,因为第一个,之前较短的时间线实际上已经加倍了长度。然而,这将导致第一个时间线将矩形的宽度动画化到三百像素,然后回到零,因此在动画完成后将不可见。此外,请注意,我们必须将父故事板持续时间设置为五秒钟,才能看到子时间线中的差异。
再次注意,设置在包含其他时间线的时间线上的属性将影响子时间线上这些属性的值。因此,在父时间线(Storyboard 对象)上设置 AutoReverse 属性为 True 将使子动画的总运行时间加倍;在我们的例子中,使用以下示例,矩形现在将总共动画化十秒钟:
<Storyboard Duration="0:0:5" AutoReverse="True">
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2.5" AutoReverse="True" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:5" />
</Storyboard>
RepeatBehavior 属性的类型为 RepeatBehavior,并且也可以影响时间线的整体持续时间。与 AutoReverse 属性不同,它既可以缩短整体持续时间,也可以延长它。通过使用 RepeatBehavior 属性,我们可以以多种方式指定值,使用不同的行为。
最简单的方法是提供一个乘数,表示我们希望将时间线的原始持续时间乘以多少次。一个现有的 XAML 类型转换器使我们能够在 XAML 中通过在计数后指定一个 x 来设置重复计数,如下面的示例所示。请注意,我们还可以在此处指定带有小数点的数字,包括小于一的值:
<Storyboard Duration="0:0:5" AutoReverse="True" RepeatBehavior="2x">
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2.5" AutoReverse="True" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:5" />
</Storyboard>
在这个例子中,正常持续时间应该是五秒钟,但 AutoReverse 属性被设置为 True,因此该持续时间被加倍。然而,RepeatBehavior 属性被设置为 2x,这将把加倍后的十秒钟乘以二十秒钟。这个乘数值为二将被存储在 RepeatBehavior 结构的 Count 属性中。
使用计数选项的另一种选择是简单地设置我们希望动画持续的时间。用于设置Duration属性的相同 XAML 语法也可以用于设置RepeatBehavior属性。同样,RepeatBehavior结构还定义了一个Forever属性,表示无限的时间段,我们可以使用这个值使动画无限期地继续。
一个可以影响动画持续时间的额外属性是SpeedRatio属性。这个值会乘以其他相关的持续时间属性,因此可以加快或减慢相关的时间线。让我们再次更新我们的示例,以帮助解释这个属性:
<Storyboard Duration="0:0:5" AutoReverse="True" SpeedRatio="0.5">
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2.5" AutoReverse="True" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:5" SpeedRatio="2" />
</Storyboard>
再次,这里的正常持续时间应该是五秒,AutoReverse属性设置为True,因此持续时间加倍。然而,SpeedRatio属性设置为0.5,因此加倍后的持续时间再次加倍,达到二十秒。请注意,SpeedRatio值为0.5表示正常速度的一半,因此是正常持续时间的两倍。
第二个子时间线也设置了SpeedRatio属性,但设置为2,因此其速度加倍,持续时间减半。由于其指定的时间是其兄弟时间线时间的一半,且其速度现在是正常速度的两倍,这起到了重新同步两个子动画的效果,因此两个维度现在作为一个正方形而不是矩形一起增长。
我们还可以使用两个与速度相关的属性来微调我们的动画:AccelerationRatio和DecelerationRatio属性。这些属性调整相关动画加速和减速所需的时间比例。虽然这种效果有时可能很微妙,但正确使用时也可以给我们的动画带来专业的触感。
这两个属性的可接受值介于零和一之间。如果同时使用这两个属性,则它们值的总和必须仍然介于零和一之间。如果违反此规则,则在运行时将抛出以下异常:
The sum of AccelerationRatio and DecelerationRatio must be less than or equal to one.
在这两个属性中的任何一个上输入超出可接受范围的值也会导致错误,尽管这样做将导致编译错误:
Property value must be between 0.0 and 1.0.
让我们看看一个示例,突出这两个属性不同值之间的差异:
<StackPanel Margin="20">
<StackPanel.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever" Duration="0:0:1.5"
SpeedRatio="0.5" Storyboard.TargetProperty="Width">
<DoubleAnimation Storyboard.TargetName="RectangleA"
AccelerationRatio="1.0" From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleB"
AccelerationRatio="0.8" DecelerationRatio="0.2"
From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleC"
AccelerationRatio="0.6" DecelerationRatio="0.4"
From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleD"
AccelerationRatio="0.5" DecelerationRatio="0.5"
From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleE"
AccelerationRatio="0.4" DecelerationRatio="0.6"
From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleF"
AccelerationRatio="0.2" DecelerationRatio="0.8"
From="0" To="300" />
<DoubleAnimation Storyboard.TargetName="RectangleG"
DecelerationRatio="1.0" From="0" To="300" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</StackPanel.Triggers>
<Rectangle Name="RectangleA" Fill="#FF0000" Height="30" />
<Rectangle Name="RectangleB" Fill="#D5002B" Height="30" />
<Rectangle Name="RectangleC" Fill="#AB0055" Height="30" />
<Rectangle Name="RectangleD" Fill="#800080" Height="30" />
<Rectangle Name="RectangleE" Fill="#5500AB" Height="30" />
<Rectangle Name="RectangleF" Fill="#2B00D5" Height="30" />
<Rectangle Name="RectangleG" Fill="#0000FF" Height="30" />
</StackPanel>
此代码在StackPanel控件中定义了多个Rectangle对象,每个对象都与其自己的DoubleAnimation元素相关联,该元素在 1.5 秒内将宽度从零增加到三百像素。
在这里,我们使用了Storyboard.TargetName和Storyboard.TargetProperty属性从单个EventTrigger中定位矩形,以减少前面示例中的代码量。我们将在稍后详细介绍这些附加属性,但现在,我们只需说它们用于指定要动画化的目标元素和属性。
每个动画都针对不同的矩形,并为AccelerationRatio和DecelerationRatio属性设置了不同的值。顶部矩形的动画将AccelerationRatio属性设置为1.0,而底部矩形的动画将DecelerationRatio属性设置为1.0。
介于这些矩形之间的动画具有不同的值。矩形越高,AccelerationRatio属性的值就越高,DecelerationRatio属性的值就越低;而矩形越低,AccelerationRatio属性的值就越低,DecelerationRatio属性的值就越高。
当这个示例运行时,我们可以清楚地看到各种比率值之间的差异。在每个迭代的开始附近,我们可以看到使用较高AccelerationRatio值的顶部矩形增长的大小不如使用较高DecelerationRatio值的底部矩形;然而,所有矩形在大约相同的时间内达到 300 像素:

在Timeline类中,另一个有用的属性是BeginTime属性。正如其名所示,它设置动画开始的时间;它可以被视为一个延迟时间,它相对于父级和兄弟时间轴延迟其动画的开始。
这个属性的默认值是零秒,当它被设置为正值时,延迟只在时间轴的开始处发生一次,并且不受可能设置在其上的其他属性的影响。它通常用于延迟一个或多个动画的启动,直到另一个动画完成。让我们再次调整之前的示例来演示这一点:
<Rectangle Width="0" Height="1" Fill="Orange">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:2" BeginTime="0:0:2" />
<DoubleAnimation Storyboard.TargetProperty="Width" To="0.0"
Duration="0:0:2" BeginTime="0:0:4" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="0.0"
Duration="0:0:2" BeginTime="0:0:4" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
在这个示例中,我们有一个单像素高的矩形,其宽度向外扩展直到达到三百像素宽,然后垂直扩展直到达到三百像素高。在这一点上,其尺寸均匀减小,直到形状缩小到无。
这是通过在宽度增加动画运行时延迟最后三个动画,然后在高度增加动画运行时延迟最后两个动画来实现的。最后两个动画的BeginTime属性被设置为相同的值,这样它们就可以相互同步开始和运行。
最后一个非常有用的时间线属性是FillBehavior属性,它指定当时间线达到其总持续时间或填充周期结束时,数据绑定属性值应该发生什么。此属性的类型为FillBehavior,并且只有两个值。
如果我们将此属性设置为HoldEnd值,数据绑定属性值将保持在动画结束前刚刚达到的最终值。相反,如果我们将此属性设置为默认值Stop,数据绑定属性值将恢复到动画开始之前属性原有的值。让我们用一个简单的例子来说明这一点:
<StackPanel Margin="20">
<StackPanel.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Duration="0:0:1.5" SpeedRatio="0.5"
Storyboard.TargetProperty="Opacity">
<DoubleAnimation Storyboard.TargetName="RectangleA" To="0.0"
FillBehavior="HoldEnd" />
<DoubleAnimation Storyboard.TargetName="RectangleB" To="0.0"
FillBehavior="Stop" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</StackPanel.Triggers>
<Rectangle Name="RectangleA" Fill="Orange" Height="100"
HorizontalAlignment="Stretch" Margin="0,0,0,20" />
<Rectangle Name="RectangleB" Fill="Orange" Height="100"
HorizontalAlignment="Stretch" />
</StackPanel>
在这个示例中,两个FillBehavior枚举实例之间的差异得到了清晰的展示。我们有两个大小相同的矩形,它们设置了相同的时间线来动画化它们的Opacity属性值,除了它们的FillBehavior属性值不同。
两个矩形以相同的时间从不透明淡出到不可见,但一旦两个时间线完成,将FillBehavior属性设置为Stop的矩形会立即再次变得可见,就像动画开始之前一样,而将FillBehavior属性设置为HoldEnd的另一个矩形则保持不可见,就像动画结束时一样。
虽然这涵盖了Timeline类直接暴露的主要属性,但还有一些属性是由扩展Timeline类的许多动画类声明的,这些属性对于完全理解是必不可少的。它们是From、By和To属性,这些属性指定了动画的起始点和结束点。
由于动画类生成属性值,因此针对不同的属性类型有不同的动画类。例如,生成Point值的动画类称为PointAnimation类,所有正常的动画类都遵循相同的命名模式,即使用相关类型的名称,形式为<TypeName>Animation,例如ColorAnimation。
正常的动画类,通常被称为From、By和To动画,通常需要指定两个值,尽管其中之一有时可以隐式提供。然后,相关的属性将沿着两个指定值之间的自动插值值路径进行动画化。
最常见的是使用From属性提供起始值,使用To属性提供结束值。然而,我们也可以指定一个单一的起始值、结束值或偏移值,第二个值将取自动画属性的当前值。我们可以使用By属性设置偏移值,这表示属性值将在整个持续时间内的确切变化量。
为这些不同的属性指定值可以对最终动画产生截然不同的效果。仅使用From属性将使动画从所需值开始,并动画化该属性,直到它达到属性的基本值。
仅使用To属性将使属性从其当前值开始动画化,并结束在指定的值。仅使用By属性将使属性从其当前值动画化,直到该值与指定的偏移量之和达到。
这三个属性的组合可以用来针对正确的属性值范围。设置From和By属性将使动画从From属性指定的值开始,并动画化该属性,直到达到By属性指定的偏移量。
同时设置From和To属性将使动画从From属性指定的值开始,并动画化该属性,直到达到To属性指定的值。由于By和To属性都指定了动画的结束值,如果它们都设置在动画元素上,则By属性指定的值将被忽略。
当这些更常见的动画使用一个或两个From、By和To属性组合来指定要动画化的相关属性的值范围时,还有另一种指定目标值的方法。现在让我们来看看关键帧动画。
引入关键帧
关键帧动画使我们能够执行一些使用From、By和To动画无法做到的事情。与那些动画不同,使用关键帧动画,我们能够指定超过两个目标值,并以离散的步骤动画化通常无法动画化的对象。因此,<TypeName>AnimationUsingKeyFrames类的数量比<TypeName>Animation类多,例如:RectAnimationUsingKeyFrames、SizeAnimationUsingKeyFrames。
每个<TypeName>AnimationUsingKeyFrames类都有一个KeyFrames属性,我们通过填充关键帧来指定动画过程中必须传递的各种值。每个关键帧都有一个KeyTime和Value属性,用于指定值和应达到的相对时间。
如果没有声明带有零秒关键时间的键帧,动画将从相关属性的当前值开始。动画将根据其KeyTime属性的值对关键帧进行排序,而不是它们声明的顺序,并根据它们的插值方法在各个值之间创建过渡,我们稍后会了解到这一点。
注意,KeyTime属性是KeyTime类型,这使我们能够使用除TimeSpan值以外的类型设置它。我们还可以指定百分比值,这些值确定每个关键帧将分配的指定动画持续时间的百分比。请注意,我们需要使用累积值,以确保最终的关键帧关键时间值始终为100%。
或者,我们可以使用一些特殊的值。当我们想要一个具有恒定速度的动画,无论指定的值如何,我们都可以为每个关键帧指定Paced值。这会在将它们分配到父时间线的持续时间之前,考虑每个关键帧值之间的变化,从而创建一个平滑、均匀的过渡。
与此方法相反,我们还可以为每个关键帧指定Uniform值,这基本上会在父动画的持续时间内在关键帧之间均匀分配。为此,它只需计算关键帧的数量,并将该数量除以总持续时间长度,这样每个关键帧将持续相同的时间。
对于不同的<TypeName>AnimationUsingKeyFrames类,存在不同种类的关键帧,也使用了不同的插值方法。这些关键帧的命名约定遵循格式<插值方法><TypeName>KeyFrame,例如:LinearDoubleKeyFrame。
有三种插值方法。第一种是Discrete,它不执行插值,只是从一个值跳到另一个值。这种方法对于设置bool或object值很有用。
下一种方法是Linear,它在关键帧的值和前一个关键帧的值之间执行线性插值。这意味着动画看起来将是平滑的,但如果你的关键帧时间不均匀分布,动画速度会加快和减慢。
最后也是最复杂的一种插值方法是Spline,但它也提供了对动画时间控制的最大灵活性。它添加了一个名为KeySpline的额外属性,使我们能够指定一个从0.0,0.0延伸到1.0,1.0的贝塞尔曲线上的两个控制点。第一个控制点影响曲线的前半部分,而第二个点影响后半部分。
使用这两个控制点,我们可以调整动画在其持续时间内的速度。例如,将第一个控制点设置为0.0,1.0并将第二个设置为1.0,0.0将导致原始线性曲线的最大扭曲,并导致动画迅速加速,然后在中间几乎停止,然后在结束时再次急剧加速。
通过这两个点,我们可以完全控制每对关键帧值之间值变化的速率。这种插值类型在尝试创建更逼真的动画时非常有用。请注意,我们可以在每个关键帧动画内部自由混合和匹配不同的插值方法的关键帧。
例如,如果我们想动画化一个Point元素,在这种情况下我们需要使用PointAnimationUsingKeyFrames类,然后我们可以选择代表不同插值方法的关键帧类。在这个例子中,我们可以使用DiscretePointKeyFrame、LinearPointKeyFrame和SplinePointKeyFrame类的任何组合。
注意,由于KeyFrames属性被设置为ContentPropertyAttribute属性中的name输入参数,该属性是每个<TypeName>AnimationUsingKeyFrames类中声明的类签名的一部分,因此我们不需要在 XAML 中显式声明这个属性,可以直接在这些元素内部声明各种关键帧,如下面的代码所示:
<Ellipse Width="100" Height="100" Stroke="Black" StrokeThickness="3">
<Ellipse.Fill>
<RadialGradientBrush>
<GradientStop Color="Yellow" Offset="0" />
<GradientStop Color="Orange" Offset="1" />
</RadialGradientBrush>
</Ellipse.Fill>
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever"
Storyboard.TargetProperty="Fill.GradientOrigin">
<PointAnimationUsingKeyFrames>
<DiscretePointKeyFrame Value="0.5, 0.5" KeyTime="0:0:0" />
<LinearPointKeyFrame Value="1.0, 1.0" KeyTime="0:0:2" />
<SplinePointKeyFrame KeySpline="0,0.25 0.75,0" Value="1.0, 0.0"
KeyTime="0:0:4" />
<LinearPointKeyFrame Value="0.0, 0.0" KeyTime="0:0:5" />
<SplinePointKeyFrame KeySpline="0,0.75 0.25,0" Value="0.5, 0.5"
KeyTime="0:0:8" />
</PointAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>
在这个例子中,我们声明了一个Ellipse形状,其Fill属性设置为RadialGradientBrush类的实例。这个画刷中心是黄色,边缘是橙色。请注意,这些画刷有一个名为GradientOrigin的属性,它指定了渐变的中心点,默认值为点0.5,0.5。在这个例子中,我们动画化了这个属性,这相当于在 3D 球体周围移动光源的效果:

我们使用一个带有Loaded事件的EventTrigger来启动我们的动画,并将关联的故事板的RepeatBehavior属性设置为Forever。正如之前提到的,我们将TargetProperty属性设置为画刷的GradientOrigin属性,该画刷被设置为Fill属性。
在故事板内部,我们声明了一个PointAnimationUsingKeyFrames元素,并且直接在其内部,我们声明了多个不同类型的<InterpolationMethod><Type>KeyFrame对象。正如之前提到的,我们不需要显式声明KeyFrames属性,就可以在它内部声明这些关键帧元素。
注意,这里使用的DiscretePointKeyFrame元素完全是可选的,如果移除它,也不会有任何变化。这是因为点0.5,0.5既是动画的起始值也是渐变画刷的默认值,同时也是动画的结束值。此外,如果我们省略一个零时间的关键帧,系统会隐式地为我们添加一个具有该值的关键帧。
接下来,我们声明一个LinearPointKeyFrame元素,它将以线性、均匀的方式将渐变中心从点0.5,0.5动画化到点1.0,1.0。随后,我们有一个SplinePointKeyFrame元素,它将渐变中心从上一个点动画化到点1.0,0.0。注意KeySpline属性,它调整动画过程中的速度。
从那里,我们使用另一个LinearPointKeyFrame元素,在一秒钟内平滑且均匀地过渡到点0.0,0.0。最后,我们使用第二个SplinePointKeyFrame元素来将渐变起点动画化回圆的中心及其起始位置,占用总持续时间的最后三秒。
当这个示例运行时,我们可以清楚地看到它在这两个LinearPointKeyFrame元素期间均匀地动画化渐变起点,并在两个SplinePointKeyFrame元素期间改变速度。
讲述故事
虽然扩展Timeline类的各种动画类可以直接在代码中动画化控件属性,但为了仅使用 XAML 声明和触发动画,我们需要使用Storyboard类。这被称为容器时间线,因为它扩展了抽象的TimelineGroup类,这使得它能够包含子时间线。
Storyboard类扩展的另一个容器时间线类是ParallelTimeline类,这些类使我们能够对子时间线进行分组,并作为一组设置它们的属性。当创建更复杂的动画时,如果我们只需要延迟一组子时间线的开始,我们应该使用ParallelTimeline类而不是Storyboard类,因为它更高效。
我们可以将之前的BeginTime示例重写为使用ParallelTimeline元素来延迟最后两个时间线的开始。让我们看看它可能是什么样子:
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width" To="300.0"
Duration="0:0:2" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="300.0"
Duration="0:0:2" BeginTime="0:0:2" />
<ParallelTimeline BeginTime="0:0:4">
<DoubleAnimation Storyboard.TargetProperty="Width" To="0.0"
Duration="0:0:2" />
<DoubleAnimation Storyboard.TargetProperty="Height" To="0.0"
Duration="0:0:2" />
</ParallelTimeline>
</Storyboard>
由于Storyboard类是一个Timeline对象,它也具有各种动画对象相同的属性。它从ParallelTimeline类继承的一个额外属性是SlipBehavior属性。这个属性只有在我们要将动画时间线与MediaTimeline元素的播放同步时才真正有用,但了解这一点是值得的。
这个属性是枚举类型SlipBehavior,它只有两个成员。Grow的值表示我们不需要我们的动画时间线与我们的媒体时间线同步,并且是这个属性的默认值。
相反,Slip的值表示我们希望在必要时使我们的动画时间线向前或向后滑动,以保持它们与播放的媒体同步。如果使用此设置时媒体需要时间来加载,那么故事板中的动画时间线将等待媒体准备好,并在该点继续。
除了从各种基类继承的属性外,Storyboard类还声明了三个重要的附加属性,这些属性对于将动画定位到单个 UI 元素及其属性至关重要。
Storyboard.Target附加属性指定了应该被动画化的 UI 控件,尽管仅设置此属性是不够的,因为它没有指定目标属性。此属性的类型为object,尽管它只能与类型为DependencyObject的对象一起使用。
为了使用这个属性,我们需要指定一个绑定路径,该路径引用目标 UI 控件。如果目标元素扩展了FrameworkElement或FrameworkContentElement类,那么一种方法是为目标元素命名,并使用ElementName绑定来引用它:
Storyboard.Target="{Binding ElementName=TargetControlName}"
大多数 UI 元素扩展了声明了Name属性的这两个类之一。然而,如果我们为目标控件提供了一个名称,那么有一个更简单的方式来针对它。我们不需要使用Storyboard.Target属性,而是可以使用Storyboard.TargetName附加属性,仅通过它们声明的名称来指定目标元素,而不需要任何绑定:
Storyboard.TargetName="TargetControlName"
我们并不总是需要指定这个属性值,因为有时目标元素可以隐式地确定。如果相关的故事板是通过BeginStoryboard元素启动的,那么声明它的 UI 元素将被针对。另外,如果相关的故事板是另一个时间线的子项,那么父时间线的目标将被继承。
Storyboard类声明的最重要的属性是TargetProperty附加属性。我们使用这个属性来指定我们想在目标元素上动画化的属性。请注意,为了使这生效,目标属性必须是一个依赖属性。
有时,我们可能需要针对之前提到的框架类中任何一个都没有扩展的对象;在 WPF 中,我们同样能够针对扩展了Freezable类的可冻结类。为了在 XAML 中针对这些类之一,我们需要使用x:Name指令来指定对象名称,因为它们没有Name属性。
作为旁注,WPF 类如果声明了自己的Name属性,实际上会通过x:Name指令映射名称值,这是 XAML 规范的一部分。在这些情况下,我们可以自由使用这两个中的任何一个来为元素注册名称,但我们不能同时设置两个。
注意,无名称的元素仍然可以被我们的动画引用,尽管它们需要间接引用。我们不是直接引用它们,而是需要指定父属性或可冻结对象的名称,然后在TargetProperty附加属性中链式调用属性,直到达到所需的元素。我们在上一节的最后一个示例中使用了这种方法:
Storyboard.TargetProperty="Fill.GradientOrigin"
在这个例子中,我们引用了 Fill 属性,它属于 RadialGradientBrush 类型,然后从那里链接到画刷的 GradientOrigin 属性。请注意,如果我们在这里使用 SolidColorBrush 类的实例而不是这个,这个引用将会失败,因为那个画刷中没有 GradientOrigin 属性。然而,尽管动画无法工作,这不会引发任何错误。
控制故事板
为了在 XAML 中开始一个故事板,我们需要使用一个 BeginStoryboard 元素。这个类扩展了 TriggerAction 类,如果你还记得,那就是我们在 EventTrigger 类的 TriggerActionCollection 以及 TriggerBase.EnterActions 和 TriggerBase.ExitActions 属性中需要使用的类型。
我们通过将 Storyboard 属性设置为代码中的 Storyboard 属性来指定要使用的 storyboard。当使用 XAML 时,Storyboard 属性隐式设置为在 BeginStoryboard 元素内声明的 storyboard。
BeginStoryboard 动作负责将动画时间线与动画目标及其目标属性连接起来,并且还负责在其故事板内启动各种动画时间线。它是通过调用关联的 Storyboard 对象的 Begin 方法来做到这一点的,一旦其父级的触发条件得到满足。
如果一个已经运行的故事板被要求再次开始,无论是通过 BeginStoryboard 动作间接地,还是通过 Begin 方法直接地,所发生的情况将取决于 HandoffBehavior 属性设置的值。
这个属性是枚举类型 HandoffBehavior,有两个值。默认值是 SnapshotAndReplace,这将重新启动内部时钟,本质上具有用另一个时间线替换一个时间线的效果。另一个值更有趣:Compose 值将在重新启动动画时保留原始时钟,并在当前动画之后附加新的动画,在它们之间执行一些插值,从而实现更平滑的连接。
这种方法的一个问题是保留的时钟将继续使用系统资源,如果处理不当,这可能会导致内存问题。然而,这种方法产生的动画更加平滑、自然和流畅,这可能值得额外的资源。这最好用一个小的例子来演示:
<Canvas>
<Rectangle Canvas.Top="200" Canvas.Left="25" Width="100" Height="100"
Fill="Orange" Stroke="Black" StrokeThickness="3">
<Rectangle.Style>
<Style TargetType="{x:Type Rectangle}">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Duration="0:0:2"
Storyboard.TargetProperty="(Canvas.Top)" To="0" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Duration="0:0:2"
Storyboard.TargetProperty="(Canvas.Top)" To="200" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
<Rectangle Canvas.Top="200" Canvas.Left="150" Width="100" Height="100"
Fill="Orange" Stroke="Black" StrokeThickness="3">
<Rectangle.Style>
<Style TargetType="{x:Type Rectangle}">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Duration="0:0:2"
Storyboard.TargetProperty="(Canvas.Top)" To="0" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard HandoffBehavior="Compose">
<Storyboard>
<DoubleAnimation Duration="0:0:2"
Storyboard.TargetProperty="(Canvas.Top)" To="200" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</Canvas>
在这个例子中,我们有两个矩形,每个矩形都有自己的动画。它们之间的唯一区别是,启动右侧矩形动画的 BeginStoryboard 元素有一个 HandoffBehavior 的值为 Compose,而另一个使用默认值 SnapshotAndReplace。
当示例运行时,当鼠标光标放置在矩形上方时,每个矩形都会向上移动;当光标从矩形上移开时,矩形会向下移动。如果我们保持鼠标光标在每个矩形的边界内,将光标移动到屏幕顶部与矩形一起,然后移开光标让矩形落下,这两个动画将看起来完全相同。
然而,如果我们把鼠标光标从一边移动到另一边,穿过两个矩形,我们将会看到两个动画之间的差异。我们会看到,当光标进入每个矩形的边界时,它们各自开始向上移动。但一旦光标离开矩形边界,我们就看到了差异。
左边的矩形,具有默认值SnapshotAndReplace,将停止向上移动并立即开始其向下动画,而另一个矩形将在开始向下动画之前继续向上移动一段时间。这导致两个动画之间的转换看起来更加平滑、自然。
然而,这两种交手行为的区别,最清楚地通过简单地放置鼠标光标在一个矩形上并保持在那里来演示。这样做左边的矩形会导致矩形向上移动,直到鼠标光标不再在其边界内,然后它将立即开始再次向下移动。
然而,由于鼠标光标将再次位于矩形的边界内,它将再次开始向上动画。这将导致矩形再次远离鼠标光标,因此我们将结束一个重复的行为循环,这会导致矩形在鼠标光标上方看起来像快速震动或卡顿。
另一方面,右边的矩形,具有HandoffBehavior的Compose,将向上移动直到鼠标光标不再在其边界内,但随后会继续向上移动一段时间,然后开始向下移动。这再次创建了一个远更平滑的动画,并将导致矩形在鼠标光标上方轻轻弹跳,与另一个卡顿的矩形形成鲜明对比。
有几个相关的TriggerAction派生类,它们以单词Storyboard结尾,使我们能够控制相关Storyboard元素的各种方面。通过在其它动作的BeginStoryboardName属性中指定BeginStoryboard元素的Name属性值,我们能够进一步控制正在运行的 Storyboard。
我们可以使用PauseStoryboard元素暂停正在运行的动画故事板,使用ResumeStoryboard来恢复暂停的动画故事板。如果相关的动画故事板没有在运行,PauseStoryboard元素将不执行任何操作,同样,如果相关的动画故事板尚未暂停,ResumeStoryboard动作也不会执行任何操作。因此,不能使用ResumeStoryboard触发动作来启动动画故事板。
StopStoryboard动作将停止正在运行的动画故事板,但如果相关的动画故事板尚未运行,则不执行任何操作。最后,有一个RemoveStoryboard触发动作,当其父级触发条件满足时,将移除动画故事板。由于动画故事板消耗资源,当它们不再需要时,我们应该移除它们。
例如,如果我们使用带有Loaded事件的EventTrigger来启动一个其RepeatBehavior属性设置为Forever的时间线,那么我们应该在Unloaded事件中使用另一个带有RemoveStoryboard动作的EventTrigger元素来移除动画故事板。这有点类似于在IDisposable实现上调用Dispose方法。
注意,删除由BeginStoryboard动作启动的动画故事板非常重要,其HandoffBehavior属性设置为Compose,因为它可能会导致许多内部时钟被实例化但未释放。删除动画故事板还将导致内部使用的时钟被释放。让我们看看我们如何使用这些元素的实用示例:
<StackPanel TextElement.FontSize="14">
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Margin="20">
<TextBox.Effect>
<DropShadowEffect Color="Red" ShadowDepth="0" BlurRadius="0"
Opacity="0.5" />
</TextBox.Effect>
<TextBox.Style>
<Style TargetType="{x:Type TextBox}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsValid}" Value="False">
<DataTrigger.EnterActions>
<BeginStoryboard Name="GlowStoryboard">
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.
TargetProperty="Effect.(DropShadowEffect.BlurRadius)"
To="25" Duration="0:0:1.0" AutoReverse="True" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsValid}" Value="False" />
<Condition Binding="{Binding IsFocused,
RelativeSource={RelativeSource Self}}" Value="True" />
</MultiDataTrigger.Conditions>
<MultiDataTrigger.EnterActions>
<PauseStoryboard BeginStoryboardName="GlowStoryboard" />
</MultiDataTrigger.EnterActions>
</MultiDataTrigger>
<Trigger Property="IsFocused" Value="True">
<Trigger.EnterActions>
<PauseStoryboard BeginStoryboardName="GlowStoryboard" />
</Trigger.EnterActions>
<Trigger.ExitActions>
<ResumeStoryboard BeginStoryboardName="GlowStoryboard" />
</Trigger.ExitActions>
</Trigger>
<DataTrigger Binding="{Binding IsValid}" Value="True">
<DataTrigger.EnterActions>
<StopStoryboard BeginStoryboardName="GlowStoryboard" />
</DataTrigger.EnterActions>
</DataTrigger>
<EventTrigger RoutedEvent="Unloaded">
<EventTrigger.Actions>
<RemoveStoryboard BeginStoryboardName="GlowStoryboard" />
</EventTrigger.Actions>
</EventTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<TextBox Margin="20 0" />
</StackPanel>
此示例有两个文本框,下方的文本框仅用于使我们能够从第一个文本框中移除焦点。第一个文本框绑定到我们的视图模型中的Name属性。让我们假设我们有一些验证代码,当Name属性发生变化时,它将更新一个名为IsValid的属性。我们将在第九章中深入探讨验证,实现响应式数据验证,但现在让我们保持简单:
private string name = string.Empty;
private bool isValid = false;
...
public string Name
{
get { return name; }
set
{
if (name != value)
{
name = value;
NotifyPropertyChanged();
IsValid = name.Length > 2;
}
}
}
public bool IsValid
{
get { return isValid; }
set { if (isValid != value) { isValid = value;
NotifyPropertyChanged(); } }
}
在这里,我们只是验证Name属性是否有三个或更多字符的值。在这个例子中的基本思想是,我们有一个动画,突出显示特定表单字段需要有效值的事实。
这可能是一个抖动,或者表单字段的增长和缩小,或者相邻元素的动画,但在我们的情况下,我们使用了一个DropShadowEffect元素来创建围绕它的发光效果。
在我们风格的Triggers集合中,我们声明了多个触发器。第一个是一个DataTrigger,它将数据绑定到视图模型中的IsValid属性,并使用名为GlowStoryboard的BeginStoryboard触发动作元素来使文本框周围的发光效果在属性值为false时增长和缩小。
虽然动画在吸引眼球方面很出色,但它们也可能相当分散注意力。暂时跳过MultiDataTrigger,因此当文本框获得焦点时,我们的动画将暂停,以便用户可以不受干扰地输入详细信息。我们通过在触发器中声明一个带有IsFocused属性为true条件的PauseStoryboard动作来实现这一点。
使用触发器的EnterActions集合确保当IsFocused属性设置为true时运行PauseStoryboard动作。在触发器的ExitActions集合中声明ResumeStoryboard动作确保当IsFocused属性设置为false时运行,换句话说,当控件失去焦点时。
当用户输入一个值时,我们的视图模型验证提供的值是否确实有效,如果是这样,它将IsValid属性设置为true。在我们的例子中,我们只是验证输入的字符串是否包含三个或更多字符,以便它是有效的。将UpdateSourceTrigger属性设置为PropertyChanged确保了每次按键时都会进行验证。
我们的例子使用DataTrigger来数据绑定到这个属性,当它为true时,它触发StopStoryboard动作,停止故事板继续运行。由于我们的故事板的FillBehavior属性没有明确设置,它将默认为Stop值,动画属性值将返回到动画之前拥有的原始值。
然而,如果用户输入了三个或更多字符然后删除它们会发生什么? 数据触发器将触发StopStoryboard动作,并停止故事板。随着字符的删除和IsValid属性被设置为false,第一个DataTrigger的条件将触发初始的BeginStoryboard动作,再次启动故事板。
但这将在文本框仍然获得焦点且效果上的动画不应运行时发生。这就是我们之前跳过的MultiDataTrigger元素的原因。在这个触发器中,我们有两个条件。一个是IsFocused属性应该是true,仅为此,我们本可以使用一个MultiTrigger。
然而,另一个条件要求我们将数据绑定到视图模型的IsValid属性,为此,我们需要使用MultiDataTrigger元素。因此,当文本框获得焦点并且数据绑定的值变为无效时,或者换句话说,当用户删除第三个字符时,这个触发器将运行其PauseStoryboard动作。
触发器按照在 XAML 中声明的顺序从上到下进行评估,并且当用户删除第三个字符时,第一个触发器开始动画。MultiDataTrigger必须在第一个触发器之后声明,这样故事板就会在暂停之前开始。在这种情况下,当用户将焦点从第一个文本框移开时,所需的辉光效果将再次开始。
最后,这个示例演示了我们可以如何使用RemoveStoryboard触发动作在不再需要故事板时将其移除,从而释放其资源。通常的做法是利用相关控制的Unloaded事件中的EventTrigger来完成此操作。
虽然这些是控制其相关故事板元素运行状态的唯一触发动作元素,但还有另外三个动作可以控制故事板的其它方面或设置其它属性。
SetStoryboardSpeedRatio触发动作可以设置相关故事板的SpeedRatio。我们在其SpeedRatio属性中指定所需的比率,并在满足动作的相关触发条件时应用此值。请注意,此元素只能作用于已经启动的故事板,尽管它可以在这一点之后任何时候工作。
SkipStoryboardToFill触发动作将故事板的当前位置移动到其填充周期,如果有的话。记住,FillBehavior属性决定了填充周期期间应该发生什么。如果故事板有子时间轴,那么它们的当前位置也将在此点前移到它们的填充周期。
最后但同样重要的是,存在一个SeekStoryboard触发动作,它使我们能够将故事板的当前位置移动到相对于由Origin属性指定的位置的某个位置,该属性的默认开始时间为零秒。在声明SeekStoryboard动作时,我们在Offset属性中指定所需的查找位置,并可选择设置Origin属性。
Offset属性是TimeSpan类型,我们可以在 XAML 中使用前面突出显示的时间表示法来指定其值。Origin属性是TimeSeekOrigin类型,我们可以指定两个值之一。
首先是BeginTime的默认值,它将时间轴的起点定位在时间轴的开始处,而第二个是Duration,它将时间轴定位在自然持续时间的单个迭代结束时。请注意,在遍历时间轴持续时间时,不会考虑各种速度比值的设置。
这就完成了我们对可以使用来控制故事板的触发动作范围的探讨。这些触发动作中的每一个在满足其相关触发条件时都会在Storyboard类中调用相应的方法。
缓动函数
当使用 WPF 声明动画时,我们能够利用一种强大的功能,帮助我们定义更专业的动画。虽然我们通常为动画提供起始值和结束值,让 WPF 插值中间值,但有一种方法可以影响这个插值过程。
有许多数学函数提供了复杂的动画路径,被称为缓动函数。例如,这些可以精确地复制弹簧的运动或球体的弹跳。
我们可以在动画的EasingFunction属性中简单地声明适当的缓动函数。每个缓动函数都扩展了EasingFunctionBase类,并具有其特定的属性。例如,BounceEase元素提供了Bounces和Bounciness属性,而ElasticEase类声明了Oscillations和Springiness属性。
所有缓动函数都从基类继承了EasingMode属性。这个属性是枚举类型EasingMode,并提供了三个选项。EaseIn选项遵循与每个缓动函数相关的正常数学公式。EaseOut选项使用数学公式的逆。
EaseInOut选项在第一半使用标准公式,在第二半使用逆公式。虽然不是严格正确,但可以大致理解为EaseIn影响动画的开始,EaseOut影响动画的结束,而EaseInOut则影响动画的开始和结束。让我们通过一个弹跳球动画的例子来展示这个功能:
<Canvas>
<Ellipse Width="50" Height="50" Fill="Orange" Stroke="Black"
StrokeThickness="3">
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<Storyboard Storyboard.TargetProperty="(Canvas.Top)">
<DoubleAnimation Duration="00:00:3" From="0" To="200">
<DoubleAnimation.EasingFunction>
<BounceEase EasingMode="EaseOut" Bounces="10"
Bounciness="1.5" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<Storyboard Storyboard.TargetProperty="(Canvas.Left)">
<DoubleAnimation Duration="00:00:3.5" From="0" To="200"
DecelerationRatio="0.2" />
</Storyboard>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>
<Line Canvas.Top="250" Canvas.Left="25" X1="0" Y1="1.5" X2="225" Y2="1.5"
Stroke="Black" StrokeThickness="3" />
</Canvas>
在这里,我们有一个包含两个形状的Canvas面板:一个椭圆和一个线条。线条只是为了让人们有地面的印象。Ellipse元素定义了一些基本的外观属性,然后是一个EventTrigger元素,当形状对象加载时启动我们的缓动动画。我们有一个设置为无限重复的外部Storyboard元素,它包含两个内部故事板。
这些内部故事板中的第一个使用Storyboard.TargetProperty针对Canvas.Top附加属性,而第二个则针对其Canvas.Left附加属性。请注意,我们在这里不需要指定Storyboard.Target属性值,因为故事板位于目标元素内,这将隐式地设置为我们的目标。此外,请记住,为了使其生效,我们需要用方括号将附加属性名称及其类名称括起来。
第一个故事板负责我们球体的垂直运动,因此这是我们想要使用BounceEase函数的动画。为了利用这个功能,我们只需在DoubleAnimation.EasingFunction属性中声明BounceEase对象,并设置所需的属性值。
Bounces属性决定了球应该弹跳多少次,或者从动画的下限反弹多少次。请注意,这不包括这个缓动函数将执行的最终半弹跳。Bounciness属性指定了球的弹跳程度。奇怪的是,这个值越高,球的弹跳性就越小。此外,请注意这个值必须是正数。
由于物理学决定球的水平速度在大多数情况下应该保持恒定,我们不需要对第二个动画应用缓动函数。相反,我们为其DecelerationRatio属性添加了一个小值,这很好地模拟了球侧面的摩擦力。
如所见,利用这些数学公式来大大增加动画的运动是非常容易的。虽然这本书中没有足够的空间让我们涵盖所有这些缓动函数,但自己调查它们是非常值得的。让我们看看另一个例子,看看我们如何使用ElasticEase类来模拟弹簧的运动:
<Rectangle Canvas.Top="250" Canvas.Left="25" Width="25" Height="50"
Fill="Orange" Stroke="Black" StrokeThickness="3">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<Storyboard Storyboard.TargetProperty="Height">
<DoubleAnimation Duration="00:00:3" From="50" To="200">
<DoubleAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="6"
Springiness="2" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
在这个例子中,我们有一个细长的Rectangle元素,使用ElasticEase函数模拟弹簧的运动。Oscillations属性指定了矩形在动画效果的生命周期内将增长和收缩的次数,而Springiness属性决定了弹簧的刚度,其中较大的值等于更大的弹力。
虽然这两个演示的缓动函数相当专业,不适用于许多情况,但剩余的大多数函数都是标准圆形、指数曲线或使用公式 f(t) = t^n 的曲线的变体,其中 n 要么由所使用的确切缓动函数确定,要么由PowerEase函数的Power属性确定。
例如,QuadraticEase函数使用公式 f(t) = t²,CubicEase函数使用公式 f(t) = t³,QuarticEase函数使用公式 f(t) = t⁴,QuinticEase函数使用公式 f(t) = t⁵,而PowerEase函数使用公式 f(t) = t^n,其中 n 由其Power属性确定。
除了这些标准加速度/减速度曲线的变体之外,还有一个非常有用的缓动函数名为BackEase。它根据EasingMode属性的值,会超出其起始或结束的From或To值,然后反转回它。这是更可用的缓动函数之一,让我们看看一个TextBox元素在屏幕上滑动的例子:
<Canvas ClipToBounds="True">
<TextBox Canvas.Top="50" Canvas.Left="-150" Width="150" Height="25">
<TextBox.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="(Canvas.Left)"
Duration="00:00:2" RepeatBehavior="Forever">
<DoubleAnimation Duration="00:00:1" From="-150" To="50">
<DoubleAnimation.EasingFunction>
<BackEase EasingMode="EaseOut" Amplitude="0.75" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBox.Triggers>
</TextBox>
</Canvas>
在这个例子中,我们从一个Canvas对象开始,其ClipToBounds属性设置为true。这确保了超出画布边界的元素将不可见。在画布内部,我们声明了一个TextBox控件,它最初放置在画布的完全外部,因此它是不可见的。
当控件加载时,EventTrigger元素将启动针对Canvas.Left附加属性的动画。请注意,故事板上的持续时间比动画上的持续时间长一秒,因此故事板将在动画完成后等待一秒再重新启动。这给了我们时间去欣赏应用缓动函数的效果。
动画将使文本框从其初始的离屏位置滑动到结束位置。通过使用BackEase函数,文本框将稍微滑动过其结束位置,然后反向回到它。它将滑过结束位置的距离由其Amplitude属性的值决定,更高的值会延长超调距离。
虽然我们迄今为止只讨论了使用这些缓动函数与From、By和To动画一起使用,但也可以将它们与关键帧动画一起使用。有一些类遵循Easing<Type>KeyFrame命名约定,例如EasingColorKeyFrame类。这些类有一个EasingFunction属性,使我们能够指定要使用哪个函数:
<TextBlock Text="The operation was successful" Margin="20">
<TextBlock.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard Storyboard.TargetProperty="FontSize">
<DoubleAnimationUsingKeyFrames Duration="00:00:2.5">
<DiscreteDoubleKeyFrame KeyTime="0:0:0" Value="8" />
<EasingDoubleKeyFrame KeyTime="0:0:1" Value="36">
<EasingDoubleKeyFrame.EasingFunction>
<BounceEase EasingMode="EaseOut" Bounces="2"
Bounciness="1.5" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:2" Value="8">
<EasingDoubleKeyFrame.EasingFunction>
<ElasticEase EasingMode="EaseIn" Oscillations="2"
Springiness="1.5" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:2.5" Value="36">
<EasingDoubleKeyFrame.EasingFunction>
<BackEase EasingMode="EaseOut" Amplitude="2" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
在这个示例中,我们使用多个关键帧来动画化TextBlock元素中的文本大小。这创建了类似于我们在 Microsoft PowerPoint 演示文稿中的文本行上看到的过渡效果,并且可能适合用于向用户展示文本信息的应用程序。
我们首先针对FontSize属性,并指定总共两秒半的持续时间。我们的第一个关键帧简单地设置我们的起始字体大小为零秒,因此我们可以使用一个DiscreteDoubleKeyFrame。第二个关键帧是一个具有BounceEase缓动函数和一秒持续时间或关键时间的EasingDoubleKeyFrame元素。
随后,我们还有一个持续一秒的EasingDoubleKeyFrame元素,但这个元素使用的是ElasticEase函数。最后,我们用一个具有BackEase缓动函数和半秒持续时间的EasingDoubleKeyFrame元素结束。请注意,我们使用了小的Bounces和Oscillations属性值,以保持动画更易于使用。
使用这些缓动函数与关键帧一起,使我们能够将任意数量的它们串联起来,以创建更复杂的动画效果。然而,很容易过度使用并创建过于复杂的动画效果,正如在这个示例中通过增加Bounces和Oscillations属性设置的值所看到的那样。实际上,这里使用的即使是适度的值也可能被认为对于实际应用来说太多了。
沿路径动画
在 WPF 中还有另一种动画属性值的方法。使用PathFigure和PathSegment对象,我们可以构建一个PathGeometry对象,然后根据路径的X、Y和/或旋转角度值来动画化属性值。
由于这种方法主要用于沿复杂路径动画化对象,因此并不针对典型的商业应用,我们在这里只介绍这种功能的基本内容。与其他类型的动画类一样,存在不同的路径动画类型,它们操作不同的 CLR 类型。路径动画类遵循命名约定<Type>AnimationUsingPath。
每个<Type>AnimationUsingPath类都有一个PathGeometry属性,我们可以使用它来指定一个动画路径,使用类型为PathGeometry的对象。为了利用除了旋转角度之外还能动画化路径X和Y值的能力,我们需要使用一个MatrixTransform元素。让我们看看这个示例:
<TextBlock Margin="100,125" Text="Hello World" FontSize="18">
<TextBlock.RenderTransform>
<MatrixTransform x:Name="MatrixTransform">
<MatrixTransform.Matrix>
<Matrix />
</MatrixTransform.Matrix>
</MatrixTransform>
</TextBlock.RenderTransform>
<TextBlock.Triggers>
<EventTrigger RoutedEvent="TextBlock.Loaded">
<BeginStoryboard>
<Storyboard>
<MatrixAnimationUsingPath
Storyboard.TargetName="MatrixTransform"
Storyboard.TargetProperty="Matrix" Duration="0:0:4"
RepeatBehavior="Forever" DoesRotateWithTangent="True">
<MatrixAnimationUsingPath.PathGeometry>
<PathGeometry>
<PathFigure StartPoint="49.99,49.99">
<ArcSegment Point="50,50" Size="50,50"
SweepDirection="Clockwise" IsLargeArc="True" />
</PathFigure>
</PathGeometry>
</MatrixAnimationUsingPath.PathGeometry>
</MatrixAnimationUsingPath>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
在这个示例中,我们使用MatrixAnimationUsingPath元素围绕圆形路径动画化一个TextBlock元素。圆形路径由单个PathFigure元素内的单个ArcSegment元素定义。我们将PathFigure.StartPoint属性的值设置为几乎与ArcSegment.Point值相匹配,以便椭圆的两端相遇。
为了使用MatrixAnimationUsingPath元素动画化文本元素的旋转,我们需要将其DoesRotateWithTangent属性设置为true。如果这个属性被设置为false,或者简单地省略,那么文本元素仍然会以圆形运动进行动画化,但它将不再与圆形路径的切线方向旋转,而是保持直立。
除了MatrixAnimationUsingPath类之外,我们还可以使用DoubleAnimationUsingPath或PointAnimationUsingPath类之一来沿路径动画化对象。然而,而不是提供这些替代方法的示例,我们现在继续了解如何将日常动画包含到我们的应用程序框架中。
创建日常动画
在介绍了 WPF 提供的广泛动画之后,我们可以看到许多动画都是设计来使我们能够执行模拟现实世界情况的动画,而不是在标准商业应用中动画化表单字段。因此,本章中讨论的一些技术不适合用于我们的应用程序框架。
然而,这并不意味着我们不能创建动画用于我们的日常应用。只要我们记住在商业应用中动画越少越好,我们当然可以将简单的动画构建到我们的应用程序框架中。在框架中封装这些基本动画的最好方法之一是编写一个或多个自定义动画面板。让我们看看一个简单的StackPanel动画示例:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace CompanyName.ApplicationName.Views.Panels
{
public class AnimatedStackPanel : Panel
{
public static DependencyProperty OrientationProperty =
DependencyProperty.Register(nameof(Orientation),
typeof(Orientation), typeof(AnimatedStackPanel),
new PropertyMetadata(Orientation.Vertical));
public Orientation Orientation
{
get { return (Orientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
protected override Size MeasureOverride(Size availableSize)
{
double x = 0, y = 0;
foreach (UIElement child in Children)
{
child.Measure(availableSize);
if (Orientation == Orientation.Horizontal)
{
x += child.DesiredSize.Width;
y = Math.Max(y, child.DesiredSize.Height);
}
else
{
x = Math.Max(x, child.DesiredSize.Width);
y += child.DesiredSize.Height;
}
}
return new Size(x, y);
}
protected override Size ArrangeOverride(Size finalSize)
{
Point endPosition = new Point();
foreach (UIElement child in Children)
{
if (Orientation == Orientation.Horizontal)
{
child.Arrange(new Rect(-child.DesiredSize.Width, 0,
child.DesiredSize.Width, finalSize.Height));
endPosition.X += child.DesiredSize.Width;
}
else
{
child.Arrange(new Rect(0, -child.DesiredSize.Height,
finalSize.Width, child.DesiredSize.Height));
endPosition.Y += child.DesiredSize.Height;
}
AnimatePosition(child, endPosition,
TimeSpan.FromMilliseconds(300));
}
return finalSize;
}
private void AnimatePosition(UIElement child, Point endPosition,
TimeSpan animationDuration)
{
if (Orientation == Orientation. Vertical)
GetTranslateTransform(child).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(endPosition.Y, animationDuration));
else GetTranslateTransform(child).BeginAnimation(
TranslateTransform.XProperty,
new DoubleAnimation(endPosition.X, animationDuration));
}
private TranslateTransform GetTranslateTransform(UIElement child)
{
return child.RenderTransform as TranslateTransform ??
AddTranslateTransform(child);
}
private TranslateTransform AddTranslateTransform(UIElement child)
{
TranslateTransform translateTransform = new TranslateTransform();
child.RenderTransform = translateTransform;
return translateTransform;
}
}
}
与所有自定义面板一样,我们只需要提供MeasureOverride和ArrangeOverride方法的实现。然而,在我们的情况下,我们想要重新创建原始StackPanel控件的功能,因此我们还声明了一个类型为System.Windows.Controls.Orientation的Orientation依赖属性,其默认值为Vertical。
在MeasureOverride方法中,我们遍历面板的每个子元素,调用它们的Measure方法,传入availableSize输入参数。请注意,这设置了它们的DesiredSize属性,直到这一点之前,它将被设置为0,0。
在调用每个子元素的Measure方法之后,我们能够使用它们的DesiredSize属性值来计算根据Orientation属性的值,正确显示渲染项目所需的总尺寸。
如果将Orientation属性设置为Vertical,我们使用Math.Max方法来确保我们考虑到最宽元素的尺寸;如果设置为Horizontal,则使用它来找到最高元素的尺寸。一旦每个子元素都被测量,并且计算出了面板的整体所需尺寸,我们就从MeasureOverride方法返回这个尺寸值。
在ArrangeOverride方法中,我们再次遍历子元素集合,但这次我们在每个子元素上调用Arrange方法,将每个子元素定位在面板外部,这将成为它们动画的起点。
如果将Orientation属性设置为Horizontal,我们将子元素定位在原点左侧一个子元素宽度处,并将它们的高度设置为面板的高度。如果将Orientation属性设置为Vertical,我们将它们定位在原点上方一个子元素高度处,并将它们的宽度设置为面板的宽度。
这会产生在每个面板的高度或宽度上拉伸每个项目的效果,具体取决于Orientation属性的值,因为整齐排列且尺寸均匀的项目比边缘不齐的项目看起来更整洁、更专业。通过这种方式,我们可以将这些决策直接构建到我们的框架控件中。
接下来,我们使用endPosition变量计算每个子元素动画后的期望位置,然后调用AnimatePosition方法,传入子元素、结束位置和动画持续时间。我们通过返回未改变的finalSize输入参数来结束该方法。
在AnimatePosition方法中,我们调用GetTranslateTransform方法来获取我们将用于移动每个子元素穿过面板的TranslateTransform对象。如果Orientation属性设置为Vertical,我们动画TranslateTransform.YProperty属性到endPosition.Y属性的值;否则,我们动画TranslateTransform.XProperty属性到endPosition.X属性的值。
为了动画化这些属性值,我们在具有要添加的属性的UIElement对象上使用BeginAnimation方法。这个方法有两个重载版本,但我们使用的是接受要动画化的依赖属性的键和动画对象的那个。另一个重载使我们能够指定与动画一起使用的HandoffBehavior。
对于我们的动画,我们正在使用一个DoubleAnimation,其构造函数接受动画的To值和持续时间,尽管我们还可以使用其他几个重载版本,如果我们需要指定更多的属性,例如From和FillBehavior值。
为了动画化面板中项的移动,我们需要确保它们在容器的RenderTransform属性上应用了TranslateTransform元素。记住,不同的ItemsControl类将使用不同的容器项,例如,ListBox控件将使用ListBoxItem容器元素。
因此,如果一个项还没有应用TranslateTransform元素,我们必须添加一个。一旦每个元素都有一个TranslateTransform元素,我们就可以使用它的X和Y属性来移动项。
在GetTranslateTransform方法中,我们只需从每个子项的RenderTransform属性中返回现有的TranslateTransform元素,如果存在的话,或者如果没有,就调用AddTranslateTransform方法来返回一个新的。在AddTranslateTransform方法中,我们只是初始化一个新的TranslateTransform元素,并将其设置为child输入参数的RenderTransform属性,然后返回它。
我们现在已经创建了一个基本的动画面板,并且只需要大约七十行代码。现在,使用我们的应用程序框架的开发者可以通过在ItemsPanelTemplate中指定它作为ItemsPanel值,简单地动画化任何ItemsControl或其派生集合控件中项的进入:
...
<ListBox ItemsSource="{Binding Users}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<Panels:AnimatedStackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
然而,我们的面板目前只提供一种类型的动画,尽管有两种可能的方向,并且仅在添加新项时工作。动画对象的退出有些复杂,因为当在数据绑定集合上调用Remove方法时,它们通常会被立即从面板的Children集合中移除。
为了实现工作的退出动画,我们需要实现一些功能。我们需要更新我们的数据模型类,为它们提供新的属性来标识它们当前处于动画的哪个阶段,并在当前状态改变时引发新的事件。
我们需要一个IAnimatable接口和一个为每个数据模型提供实现的Animatable类。让我们首先看看接口:
namespace CompanyName.ApplicationName.DataModels.Interfaces
{
public interface IAnimatable
{
Animatable Animatable { get; set; }
}
}
注意,在System.Windows.Media.Animation命名空间中已经定义了一个Animatable类和一个IAnimatable接口。虽然创建与现有类和接口同名的新类和接口可能不明智,但出于本书有限的目的,我们将使用这些名称,并注意防止冲突。
现在让我们继续前进,看看我们Animatable类的实现:
using System;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.DataModels.Interfaces;
namespace CompanyName.ApplicationName.DataModels
{
public class Animatable
{
private AdditionStatus additionStatus = AdditionStatus.ReadyToAnimate;
private RemovalStatus removalStatus = RemovalStatus.None;
private TransitionStatus transitionStatus = TransitionStatus.None;
private IAnimatable owner;
public Animatable(IAnimatable owner)
{
Owner = owner;
}
public Animatable() { }
public event EventHandler<EventArgs> OnRemovalStatusChanged;
public event EventHandler<EventArgs> OnTransitionStatusChanged;
public IAnimatable Owner
{
get { return owner; }
set { owner = value; }
}
public AdditionStatus AdditionStatus
{
get { return additionStatus; }
set { additionStatus = value; }
}
public TransitionStatus TransitionStatus
{
get { return transitionStatus; }
set
{
transitionStatus = value;
OnTransitionStatusChanged?.Invoke(this, new EventArgs());
}
}
public RemovalStatus RemovalStatus
{
get { return removalStatus; }
set
{
removalStatus = value;
OnRemovalStatusChanged?.Invoke(this, new EventArgs());
}
}
}
}
这个类不需要太多解释,只需注意,当TransitionStatus和RemovalStatus属性的值分别改变时,OnTransitionStatusChanged和OnRemovalStatusChanged事件会被触发,并且在这个情况下,类将自身作为sender输入参数传递。让我们看看在Animatable类中使用的三个新枚举类:
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum AdditionStatus
{
None = -1, ReadyToAnimate = 0, DoNotAnimate = 1, Added = 2
}
public enum TransitionStatus
{
None = -1, ReadyToAnimate = 0, AnimationComplete = 1
}
public enum RemovalStatus
{
None = -1, ReadyToAnimate = 0, ReadyToRemove = 1
}
}
我们随后需要在每个我们想要动画化的数据模型类中实现这个接口:
public class User : ... , IAnimatable
{
private Animatable animatable;
...
public User(Guid id, string name, int age)
{
Animatable = new Animatable(this);
...
}
public Animatable Animatable
{
get { return animatable; }
set { animatable = value; }
}
...
}
我们接下来需要做的是停止Remove方法在调用时实际移除每个项目。我们需要更新我们的BaseCollection<T>类,或者添加一个新的BaseAnimatableCollection<T>类,以便它触发动画而不是直接移除项目。以下是一个简化的例子,展示了我们可能实现这一点的几种方法之一:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.DataModels.Interfaces;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class BaseAnimatableCollection<T> : BaseCollection<T>
where T : class, IAnimatable, INotifyPropertyChanged, new()
{
private bool isAnimatable = true;
public BaseAnimatableCollection(IEnumerable<T> collection)
{
foreach (T item in collection) Add(item);
}
...
public bool IsAnimatable
{
get { return isAnimatable; }
set { isAnimatable = value; }
}
public new int Count => IsAnimatable ?
this.Count(i => i.Animatable.RemovalStatus == RemovalStatus.None) :
this.Count();
public new void Add(T item)
{
item.Animatable.OnRemovalStatusChanged +=
Item_OnRemovalStatusChanged;
item.Animatable.AdditionStatus = AdditionStatus.ReadyToAnimate;
base.Add(item);
}
public new virtual void Add(IEnumerable<T> collection)
{
foreach (T item in collection) Add(item);
}
public new virtual void Add(params T[] items)
{
Add(items as IEnumerable<T>);
}
public new void Insert(int index, T item)
{
item.Animatable.OnRemovalStatusChanged +=
Item_OnRemovalStatusChanged;
item.Animatable.AdditionStatus = AdditionStatus.ReadyToAnimate;
base.Insert(index, item);
}
protected override void ClearItems()
{
foreach (T item in this) item.Animatable.OnRemovalStatusChanged -=
Item_OnRemovalStatusChanged;
base.ClearItems();
}
public new bool Remove(T item)
{
item.Animatable.RemovalStatus = RemovalStatus.ReadyToAnimate;
return true;
}
public void Item_OnRemovalStatusChanged(object sender, EventArgs e)
{
Animatable animatable = (Animatable)sender;
if (animatable.RemovalStatus == RemovalStatus.ReadyToRemove ||
(animatable.RemovalStatus == RemovalStatus.ReadyToAnimate &&
!IsAnimatable))
{
base.Remove(animatable.Owner as T);
animatable.RemovalStatus = RemovalStatus.None;
}
}
}
}
请记住,这是一个基本的例子,可以从许多方面进行改进,例如添加对null的检查、启用不触发动画的添加、移除和插入功能,以及添加其他有用的属性。
在这个类中,我们首先指定泛型T类型参数必须实现IAnimatable接口。正如我们其他的基础集合类一样,我们确保所有添加和插入的项目都会调用一个新的Add方法,该方法附加我们的动画相关处理程序。我们在构造函数中展示了这个例子,但跳过了其他构造函数声明以节省空间。
我们随后声明了一个IsAnimatable属性,我们可以使用这个属性来使这个集合在没有动画的情况下工作。这个属性在重写的(或new)Count属性中使用,以确保那些即将被移除的项目不会被包含在集合子项的计数中。
在新的Add方法中,我们将我们的Item_OnRemovalStatusChanged处理程序的引用附加到被添加项目的Animatable对象的OnRemovalStatusChanged事件上。然后我们将Animatable对象的AdditionStatus属性设置为ReadyToAnimate成员,以表示该对象已准备好开始其入场动画。
由于这个基础集合扩展了另一个基础类,我们需要记住调用它的Add方法,传入项目,以便它可以附加自己的处理程序来处理项目的PropertyChanged事件。其他Add重载允许将多个项目添加到集合中,但两者都内部调用第一个Add方法。Insert方法与第一个Add方法做相同的事情。
ClearItems 方法遍历集合中的每个项目,在调用基类的 ClearItems 方法之前,从每个项目移除对 Item_OnRemovalStatusChanged 处理器的引用。实际上,这个方法可以保留用于从集合中移除所有项目而不使用动画,但通过为每个项目调用 Remove 方法可以包括动画效果。
这个类中的 Remove 方法使我们能够为每个项目的退出进行动画处理;它实际上并没有从集合中移除项目,而是将项目的 Animatable 对象的 RemovalStatus 属性设置为 ReadyToAnimate 成员,以表示该对象已准备好开始其退出动画。然后,该方法返回 true 以表示成功移除项目。
最后,我们来到了 Item_OnRemovalStatusChanged 事件处理器,这是启用退出动画的下一个重要部分。在其中,我们将 sender 输入参数转换为我们的 Animatable 类的实例。记住,当引发事件时,它将自身作为 sender 参数传递。
然后,我们检查 Animatable 实例的 RemovalStatus 属性是否设置为 ReadyToRemove 成员,或者其 RemovalStatus 属性设置为 ReadyToAnimate 且集合不可动画化。如果任一条件为真,我们最终调用基类的 Remove 方法来实际从集合中移除项目并将 RemovalStatus 属性设置为 None。
以这种方式,当集合被设置为不可动画化并且调用 Remove 方法时,项目会立即被移除,并且在 Item_OnRemovalStatusChanged 处理器中将 Animatable 对象的 RemovalStatus 属性设置为 None 成员。如果你还记得,当 RemovalStatus 属性值改变时,会引发 OnRemovalStatusChanged 事件。
然而,我们仍然缺少这个谜题的一部分。是什么将 Animatable 对象的 RemovalStatus 属性设置为 ReadyToRemove 成员以移除每个项目? 我们需要更新我们的动画面板来完成这个任务,为此,它需要维护一个需要移除的元素集合,并在它们的退出动画完成后通知该集合移除它们:
private List<UIElement> elementsToBeRemoved = new List<UIElement>();
我们可以使用 Storyboard.Completed 事件来通知我们动画何时完成,然后通过将 Animatable 对象的 RemovalStatus 属性设置为 ReadyToRemove 成员,在该点发出移除项目的信号。让我们看看我们动画面板所需的更改。首先,我们需要添加以下 using 声明:
using System.Collections.Generic;
using CompanyName.ApplicationName.DataModels.Enums;
using Animatable = CompanyName.ApplicationName.DataModels.Animatable;
using IAnimatable =
CompanyName.ApplicationName.DataModels.Interfaces.IAnimatable;
接下来,我们需要将原始 ArrangeOverride 方法中的 AnimatePosition 方法的调用替换为以下行:
BeginAnimations(child, finalSize, endPosition);
然后,我们需要在 ArrangeOverride 方法之后添加以下附加方法:
private void BeginAnimations(UIElement child, Size finalSize,
Point endPosition)
{
FrameworkElement frameworkChild = (FrameworkElement)child;
if (frameworkChild.DataContext is IAnimatable)
{
Animatable animatable =
((IAnimatable)frameworkChild.DataContext).Animatable;
animatable.OnRemovalStatusChanged -= Item_OnRemovalStatusChanged;
animatable.OnRemovalStatusChanged += Item_OnRemovalStatusChanged;
if (animatable.AdditionStatus == AdditionStatus.DoNotAnimate)
{
child.Arrange(new Rect(endPosition.X, endPosition.Y,
frameworkChild.ActualWidth, frameworkChild.ActualHeight));
}
else if (animatable.AdditionStatus == AdditionStatus.ReadyToAnimate)
{
AnimateEntry(child, endPosition);
animatable.AdditionStatus = AdditionStatus.Added;
animatable.TransitionStatus = TransitionStatus.ReadyToAnimate;
}
else if (animatable.RemovalStatus == RemovalStatus.ReadyToAnimate)
AnimateExit(child, endPosition, finalSize);
else if (animatable.TransitionStatus ==
TransitionStatus.ReadyToAnimate)
AnimateTransition(child, endPosition);
}
}
private void Item_OnRemovalStatusChanged(object sender, EventArgs e)
{
if (((Animatable)sender).RemovalStatus == RemovalStatus.ReadyToAnimate)
InvalidateArrange();
}
private void AnimateEntry(UIElement child, Point endPosition)
{
AnimatePosition(child, endPosition, TimeSpan.FromMilliseconds(300));
}
private void AnimateTransition(UIElement child, Point endPosition)
{
AnimatePosition(child, endPosition, TimeSpan.FromMilliseconds(300));
}
private void AnimateExit(UIElement child, Point startPosition,
Size finalSize)
{
SetZIndex(child, 100);
Point endPosition =
new Point(startPosition.X + finalSize.Width, startPosition.Y);
AnimatePosition(child, startPosition, endPosition,
TimeSpan.FromMilliseconds(300), RemovalAnimation_Completed);
elementsToBeRemoved.Add(child);
}
private void AnimatePosition(UIElement child, Point startPosition,
Point endPosition, TimeSpan animationDuration,
EventHandler animationCompletedHandler)
{
if (startPosition.X != endPosition.X)
{
DoubleAnimation xAnimation = new DoubleAnimation(startPosition.X,
endPosition.X, animationDuration);
xAnimation.AccelerationRatio = 1.0;
if (animationCompletedHandler != null)
xAnimation.Completed += animationCompletedHandler;
GetTranslateTransform(child).BeginAnimation(
TranslateTransform.XProperty, xAnimation);
}
if (startPosition.Y != endPosition.Y)
{
DoubleAnimation yAnimation = new DoubleAnimation(startPosition.Y,
endPosition.Y, animationDuration);
yAnimation.AccelerationRatio = 1.0;
if (startPosition.X == endPosition.X && animationCompletedHandler !=
null) yAnimation.Completed += animationCompletedHandler;
GetTranslateTransform(child).BeginAnimation(
TranslateTransform.YProperty, yAnimation);
}
}
private void RemovalAnimation_Completed(object sender, EventArgs e)
{
for (int index = elementsToBeRemoved.Count - 1; index >= 0; index--)
{
FrameworkElement frameworkElement =
elementsToBeRemoved[index] as FrameworkElement;
if (frameworkElement.DataContext is IAnimatable)
{
((IAnimatable)frameworkElement.DataContext).Animatable.RemovalStatus
= RemovalStatus.ReadyToRemove;
elementsToBeRemoved.Remove(frameworkElement);
}
}
}
让我们检查这段新代码。首先,我们有BeginAnimations方法,在这个方法中,我们将容器控件强制转换为FrameworkElement类型,以便我们可以访问其DataContext属性。我们的数据对象通过这个属性访问,并将其转换为IAnimatable实例,这样我们就可以通过其Animatable属性访问Animatable对象。
在重新附加之前,我们从OnRemovalStatusChanged事件中移除我们的Item_OnRemovalStatusChanged事件处理器,以确保无论每个子项通过此方法的次数如何,都只附加一个处理器。
如果AdditionStatus属性被设置为DoNotAnimate,我们将立即且无动画地安排项目到其结束位置,而如果它被设置为ReadyToAnimate,我们将调用AnimateEntry方法,然后将AdditionStatus属性设置为Added。最后,如果RemovalStatus属性被设置为ReadyToAnimate,我们将调用AnimateExit方法。
在Item_OnRemovalStatusChanged事件处理器中,如果RemovalStatus属性被设置为ReadyToAnimate,我们会调用面板的InvalidateArrange方法。这是退出动画策略的另一个重要部分,它请求布局系统调用ArrangeOverride方法,从而触发退出动画的开始。
记住,当RemovalStatus属性的值发生变化时,会触发OnRemovalStatusChanged事件。同时,回忆一下,RemovalStatus属性在BaseAnimatableCollection<T>类的Remove方法中被设置为ReadyToAnimate成员。这会引发事件,并且这个事件处理器会启动动画。
AnimateEntry方法简单地调用我们第一次动画尝试中的原始、未更改的AnimatePosition方法。AnimateExit方法接受一个额外的startPosition输入参数,它表示每个项目在面板中的当前位置。
我们首先将每个子项的Panel.SetZIndex附加属性设置为100,以确保它们的动画离开被渲染在剩余项目之上或覆盖其上。然后,我们使用起始位置和面板的大小计算动画的结束位置。
接下来,我们调用AnimatePosition方法的另一个重载版本,传入我们的子项、起始位置、结束位置、动画持续时间和事件处理器作为参数。在子项的位置动画开始后,我们将子项添加到elementsToBeRemoved集合中。
在AnimatePosition方法中,我们首先检查我们的起始位置和结束位置是否不同,然后创建并启动我们的DoubleAnimation对象。如果X值不同且事件处理器输入参数不是null,则在开始其动画之前,我们将它附加到xAnimation对象的Completed事件上。
如果Y值不同,并且事件处理程序输入参数不是null,并且事件处理程序尚未附加到xAnimation对象上,那么在开始其动画之前,我们将它附加到yAnimation对象的Completed事件。请注意,我们只需要将一个处理程序附加到该事件,因为我们只有一个对象需要从集合中移除。
此外,请注意,我们在本重载中设置AccelerationRatio属性为1.0,以便项目加速离开屏幕。然而,在一个商业应用程序框架中,我们希望保持我们的动画属性同步,因此,我们可能会在原始的AnimatePosition方法中的动画对象上也将AccelerationRatio属性设置为1.0。
最后一个拼图碎片是RemovalAnimation_Completed事件处理方法。当退出动画完成后,该方法会被调用,并遍历elementsToBeRemoved集合。如果任何要移除的元素实现了IAnimatable接口,则将其Animatable对象的RemovalStatus属性设置为ReadyToRemove成员。
如果你还记得,这会引发OnRemovalStatusChanged事件,该事件由BaseAnimatableCollection类中的Item_OnRemovalStatusChanged事件处理程序处理。在那个方法中,会检查Animatable对象的RemovalStatus属性中的ReadyToRemove成员,如果找到,则实际从集合中移除拥有该项目的项。
因此,总结一下;动画集合的Remove方法被调用,但不是移除项目,而是在其上设置一个属性,这会引发一个由动画面板处理的事件;面板随后开始退出动画,当动画完成后,它引发一个由集合类处理的事件,导致项目实际上被从集合中移除。
虽然这个动画面板已经完全可用,但它可以通过多种方式进一步改进。我们可以做的一件重要的事情是将所有属性和动画代码从此类中提取出来,并将它们放入一个基AnimatedPanel类中。这样,我们就可以在创建其他类型的动画面板时重用此类,例如AnimatedWrapPanel。
然后,我们可以通过公开额外的动画属性进一步扩展基类,以便我们的面板用户可以更多地控制它提供的动画。例如,我们可以声明VerticalContentAlignment和HorizontalContentAlignment属性来指定我们的面板项应在面板中如何对齐。
此外,我们可以添加EntryAnimationDirection和ExitAnimationDirection属性来指定在将面板项添加到或从面板中移除时动画的方向。我们还可以通过动画Opacity属性或RotationTransform元素的Angle属性来启用不同类型的动画,例如淡入或旋转。
此外,我们可以添加 EntryAnimationDuration 和 ExitAnimationDuration 属性来指定每个动画应该持续的时间长度,而不是直接将值硬编码到我们的面板中。实际上,我们使用应用程序框架面板所能提供的功能几乎没有任何限制,除了由最终用户的计算机硬件所规定的限制。
摘要
在本章中,我们研究了 WPF 提供的动画可能性,主要关注 XAML 和更易用的选项。我们发现了时间线的细节,还探讨了如何将动画融入我们的应用程序框架,以便用户可以轻松利用动画的力量,而无需了解任何关于动画的知识。
在下一章中,我们将探讨多种方法来改善我们应用程序的整体外观和感觉,从提供一致的应用程序样式和图标到检查创建丰富图形的多种技术。
第八章:创建视觉上吸引人的用户界面
在一个视图中添加表单元素很简单,但要制作一个看起来视觉上吸引人的应用程序则需要更多的工作。幸运的是,Windows Presentation Foundation(WPF)为我们提供了许多可以帮助我们实现这一目标的特性,例如渐变画刷、圆角、透明度控制、分层视觉效果和动画。
在本章中,我们将探讨多种使用这些元素的方法,以极大地改善我们应用程序的视觉外观。我们将研究简单易实现的解决方案,例如使用样式属性,以及需要更多工作的解决方案,如动画和自定义控件。
一致地样式化应用程序
让我们的应用程序脱颖而出的一种最简单的方法是让它们看起来独特。这可以通过为我们在其中使用的控件定义自定义样式来实现。然而,如果我们决定为我们的控件添加样式,那么为所有使用的控件添加样式是至关重要的,因为半样式化的应用程序往往比仅使用默认样式的应用程序看起来更糟糕。
因此,我们设计应用程序控件样式的一致性是绝对必要的,以便为我们的应用程序提供专业的外观。在本节中,我们将讨论一些技巧和窍门,以帮助我们实现这些应用程序样式。
覆盖默认控件样式
在为我们的应用程序控件提供自定义样式时,这通常需要我们为每个控件定义一个新的ControlTemplate元素。由于这些元素往往非常大,因此通常将它们声明在一个单独的资源文件中,并在App.xaml文件中将它们与应用程序资源合并,如第五章中所示,“为工作选择正确的控件”。
在开始这项任务之前,我们需要规划我们的控件外观,然后将这种外观应用到每个控件上。另一个错误是使用不同的样式定制不同的控件,因为一致性是提供专业外观的关键。例如,如果我们希望我们的单行文本框具有特定的高度,那么我们也应该定义其他控件具有相同的高度。
我们为控件声明的自定义样式可以是我们的应用程序框架的一部分。如果我们通过x:Key指令未命名它们,它们将被隐式应用,因此利用我们的应用程序框架的开发者无需关心每个控件的外观,从而有效地使他们能够专注于将它们聚合到各种视图中。
在开始设计自定义样式之前,首先要定义我们将要在应用程序中使用的一小部分颜色。在应用程序中使用过多的颜色可能会使其看起来不够专业,因此我们应该选择少量颜色的一两种色调来使用。有许多在线工具可以帮助我们选择要使用的调色板。
一旦我们选择了应用程序颜色,我们首先应该在 App.xaml 文件中将它们声明为 Color 对象,然后声明使用它们的画笔元素,因为大多数控件使用画笔而不是颜色。这有两个好处;只使用这些颜色将促进一致性,并且如果我们需要更改颜色,我们只需要在一个地方更改它:
<Color x:Key="ReadOnlyColor">#FF585858</Color>
...
<SolidColorBrush x:Key="ReadOnlyBrush"
Color="{StaticResource ReadOnlyColor}" />
定义最常见的控件类型的多个命名样式通常也是一个好主意。例如,为 TextBlock 元素提供一个 Label 样式,使其右对齐并添加合适的边距,或者提供一个 Heading 样式,设置更大的字体大小和更重的字体重量。为开发者提供一组预定义的样式有助于使应用程序看起来保持一致。
在定义多个命名样式时,通常会在其他样式中重用其中的一些。例如,如果我们为 TextBox 控件有一个默认样式,我们可以在其他样式变体上基于它。让我们看看一些 XAML 示例:
<Style x:Key="TextBoxStyle" TargetType="{x:Type TextBox}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="1.5,2" />
<Setter Property="MinHeight" Value="25" />
<Setter Property="TextWrapping" Value="Wrap" />
...
</Style>
<Style x:Key="Max2LineTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource TextBoxStyle}">
<Setter Property="MaxHeight" Value="44" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ToolTip"
Value="{Binding Text, RelativeSource={RelativeSource Self}}" />
</Style>
<Style x:Key="Max3LineTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource Max2LineTextBoxStyle}">
<Setter Property="MaxHeight" Value="64" />
</Style>
<Style x:Key="ReadOnlyTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource TextBoxStyle}">
<Setter Property="Background" Value="{StaticResource ReadOnlyBrush}" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="Cursor" Value="Arrow" />
</Style>
在这里,简化的 TextBoxStyle 样式定义了所有 TextBox 控件的大部分属性。Max2LineTextBoxStyle 样式从该样式继承所有属性设置,并设置了一些额外的属性,以确保在需要时可以出现垂直滚动条,并强制控件的最大高度。
Max3LineTextBoxStyle 样式扩展了 Max2LineTextBoxStyle 样式,因此继承了其所有属性设置,以及 TextBoxStyle 样式的属性。它覆盖了之前样式中设置的 MaxHeight 属性。ReadOnlyTextBoxStyle 样式也扩展了 TextBoxStyle 样式,并设置了属性以确保控件为只读。以这种方式定义样式可以确保每个视图中的控件保持一致性。
除了为我们的应用程序控件定义默认样式外,通常还为应用程序中的每个数据模型提供默认数据模板资源。与控件类似,预先定义这些数据模板可以提高一致性。我们还可以定义多个命名模板来覆盖默认模板并在不同场景中使用。
如果应用程序中有大量数据模型,将它们的数据模板声明在单独的资源文件中,并在 App.xaml 文件中将它们与应用程序资源合并,例如合并到默认控件模板中,可能会有所帮助。因此,在应用程序资源文件中合并多个资源文件并不罕见。
使用专业图标
在开发应用程序时,经常会被低估的是一套一致且质量不错的图标对整体影响。使用来自多个不同地方的不匹配图标,真的会让一个原本看起来专业的应用程序显得不那么专业。
如果您或您的公司无法负担或出于任何其他原因购买一套自定义图标,这并不意味着一切都结束了。Visual Studio 早已提供了一系列不同格式的专业图标,我们可以在我们的应用程序中免费使用。这些图标实际上是 Visual Studio、Office 和其他 Microsoft 应用程序中使用的图标,因此许多用户已经熟悉它们。
在 Visual Studio 的旧版本中,例如 2010 版或甚至 2008 版,提供的图像库与应用程序一起安装,可以在以下路径之一找到:
-
C:\Program Files\Microsoft Visual Studio 9.0\Common7\VS2008ImageLibrary\1033 -
C:\Program Files\Microsoft Visual Studio 10.0\Common7\VS2010ImageLibrary\1033
注意,在 64 位机器上,此路径将更改为以下内容:
C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\VS2010ImageLibrary\1033
然而,Microsoft 从 Visual Studio 的 2012 版本开始改变了访问图像库的方式。在这些后续版本中,图像库不再包含在 Visual Studio 的安装中。相反,我们必须搜索Visual Studio Image Library并从Microsoft网站手动下载它们。
新的图标集还包含可搜索的 Adobe Reader 文件,列出了图标集的内容,并提供到每个图标相关文件夹的链接。大多数图标还包括多个尺寸,因此新的库比之前的库大得多。
以下图像显示了 2010 年图标的一些示例:

以下图像显示了相同的图标,但采用了 2015 年引入的扁平化风格:

以下图像显示了 2017 年扁平化风格图标的变化,以供比较:

注意,Visual Studio 2019 没有提供任何图像库,这可能是未来趋势的一个迹象。然而,当前的图标集合将覆盖大多数用途。
层叠视觉元素
到目前为止,我们只是通过改变形状、大小、边框和其他常见属性来查看标准控件的基本重新定义。然而,我们可以用 WPF 做更多的事情。在继续本节之前,重要的是要知道,每个控件包含的视觉元素越多,渲染它们所需的时间就越长,这可能会对性能产生负面影响。
因此,如果我们的应用程序将在运行速度慢、旧的计算机上运行,那么我们就不应该过度强调控件的外观。相反,如果我们知道我们的最终用户将有足够的 RAM 和/或显卡,那么我们可以走得更远,开发出视觉上令人惊叹的控件。让我们看看我们可以使用的一些提高控件外观的技术。
投影阴影
让我们的 UI 元素从屏幕中跳出来的一种最简单的方法是为它们添加阴影。每个控件都有一个 Effect 属性,该属性是从 UIElement 类继承的。我们可以将 DropShadowEffect 类型的对象设置到这个属性上,为我们的控件添加阴影。
然而,我们必须对 DropShadowEffect 元素上使用的设置持保守态度,因为这个效果很容易过度使用。我们也不想将此效果应用于每个控件,因为这会破坏整体效果。当设置在包含其他控件的面板上,或设置在围绕此类面板的边框上时,它最有用。让我们看看应用此效果的一个简单示例:
<Button Content="Click Me" Width="140" Height="34" FontSize="18">
<Button.Effect>
<DropShadowEffect Color="Black" ShadowDepth="6" BlurRadius="6"
Direction="270" Opacity="0.5" />
</Button.Effect>
</Button>
让我们看看这段代码的输出效果:

在这个例子中,我们有一个标准的按钮,它将 DropShadowEffect 元素设置为 Effect 属性。正如我们将在本章后面看到的,DropShadowEffect 类有许多用途,但其主要用途是创建阴影效果。
当使用此元素创建阴影效果时,我们通常希望将其 Color 属性设置为黑色,并将 Opacity 属性设置为至少半透明的值,以获得最佳或最逼真的效果。ShadowDepth 属性决定了阴影应该落在元素多远的位置。与 BlurRadius 属性一起,此属性用于为元素添加高度感。
BlurRadius 属性在扩展阴影区域的同时,也使其密度降低。与 ShadowDepth 属性类似,此属性的默认值为五。Direction 属性指定阴影应该落在哪个方向,零度值使阴影落在右侧,增加的值使阴影角度逆时针移动。
注意,270 的值使阴影直接落在应用控件下方,这在商业应用中通常是最合适的。使用这个角度会产生一种元素略微悬停在屏幕上方或前面的效果,光源来自上方,这是光线最自然的方向。
与此相反,例如 45 度的角度会将阴影放置在元素的右上角,这会向大脑传达有一个光源在左下方的信息。然而,这种特殊效果看起来不自然,可能会削弱而不是增强应用程序的样式。
声明多个边框
我们可以使用的一种简单技术是,为每个控件声明多个Border元素。通过在外边框内声明一个或多个边框,我们可以使我们的控件看起来更专业。我们将在稍后看到,当用户的鼠标光标悬停在按钮上时,我们可以如何使这些边框以不同的方式动画化,但现在,让我们看看我们如何创建这种效果:
<Grid Width="160" Height="68">
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="Red" />
<GradientStop Color="Yellow" Offset="1" />
</LinearGradientBrush>
</Grid.Background>
<Button Content="Click Me" Width="120" Height="28" FontSize="14"
Margin="20">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Border BorderBrush="Black" BorderThickness="1"
Background="#7FFFFFFF" Padding="1" CornerRadius="5"
SnapsToDevicePixels="True">
<Border BorderBrush="#7F000000" BorderThickness="1"
Background="White" CornerRadius="3.5"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Border>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
在这个例子中,我们为我们的Button控件声明了一个简单的ControlTemplate元素,以演示双边框技术。请注意,我们通常会在这个App.xaml文件的Application.Resources部分声明此模板,以便它可以被重用,但在这里我们将其局部声明以节省空间。
注意,我们需要调整内边框的圆角半径,以确保它能够准确适应外边框。如果我们使用相同的大小,它们将无法正确地结合在一起。此外,我们已将两个边框的SnapsToDevicePixels属性设置为true,以确保它们不会被反走样伪影模糊。
另一点需要注意的是,我们使用了#7FFFFFFF作为外边框和内边框边框刷的背景值。在这个值中,alpha 通道被设置为7F,相当于不透明度值为0.5。这意味着这些元素将部分透明,因此背景颜色将部分透过边框边缘显示。
我们将我们的按钮添加到一个Grid面板中,并将其背景设置为LinearGradientBrush对象以演示这种半透明效果。当渲染时,我们的背景渐变和按钮将看起来像以下图像:

重复使用复合视觉元素
下一个技术涉及定义将在控件背景中渲染的特定图案。这可能是一个公司标志的全部或部分、一个特定的形状,甚至只是一个简单、位置恰当的曲线。这将成为我们控件视觉的最低层,并且可以在其上方有额外的视觉层。让我们看看我们可以如何实现这种设计,首先从定义一些资源开始:
<RadialGradientBrush x:Key="LayeredButtonBackgroundBrush" RadiusX="1.85"
RadiusY="0.796" Center="1.018,-0.115" GradientOrigin="0.65,- 0.139">
<GradientStop Color="#FFCACACD" />
<GradientStop Color="#FF3B3D42" Offset="1" />
</RadialGradientBrush>
<LinearGradientBrush x:Key="LayeredButtonCurveBrush" StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#FF747475" Offset="0" />
<GradientStop Color="#FF3B3D42" Offset="1" />
</LinearGradientBrush>
<Grid x:Key="LayeredButtonBackgroundElements">
<Rectangle Fill="{StaticResource LayeredButtonBackgroundBrush}" />
<Path StrokeThickness="0"
Fill="{StaticResource LayeredButtonCurveBrush}">
<Path.Data>
<CombinedGeometry GeometryCombineMode="Intersect">
<CombinedGeometry.Geometry1>
<EllipseGeometry Center="-20,50.7" RadiusX="185" RadiusY="46" />
</CombinedGeometry.Geometry1>
<CombinedGeometry.Geometry2>
<RectangleGeometry Rect="0,0,106,24" />
</CombinedGeometry.Geometry2>
</CombinedGeometry>
</Path.Data>
</Path>
</Grid>
<VisualBrush x:Key="LayeredButtonBackground"
Visual="{StaticResource LayeredButtonBackgroundElements}" />
这个设计有几个元素,所以让我们逐个查看。我们首先声明了一个具有键LayeredButtonBackgroundBrush的RadialGradientBrush元素和一个具有键LayeredButtonCurveBrush的LinearGradientBrush。
RadialGradientBrush元素的RadiusX和RadiusY属性指定了包含径向渐变的最大椭圆的X和Y半径,而Center和GradientOrigin属性决定了径向渐变的中心和焦点,并使我们能够精确地在矩形内定位它。
LinearGradientBrush元素具有StartPoint值为0,0和EndPoint值为1,1,这导致了一个对角渐变。在这个特定的设计中,目的是在中心处两个渐变之间有鲜明的对比,并在边缘处将它们稍微混合在一起。
接下来,我们声明一个具有键LayeredButtonBackgroundElements的Grid面板,它包含一个Rectangle和一个Path元素。默认情况下,矩形被拉伸以填充面板,并用LayeredButtonBackgroundBrush资源绘制。Path元素用LayeredButtonCurveBrush资源绘制。
Path对象的Data属性是我们定义路径形状的地方。我们可以用多种方式指定路径数据;然而,在这个例子中,我们使用了一个具有GeometryCombineMode值为Intersect的CombinedGeometry元素,它输出一个代表两个指定几何形状交集的单个形状。
在CombinedGeometry元素内部,我们有Geometry1和Geometry2属性,其中我们根据GeometryCombineMode属性指定的Intersect模式组合这两个几何形状。
我们的第一个形状定义了设计中的曲线,并来自一个EllipseGeometry元素,使用Center属性定位椭圆,使用RadiusX和RadiusY属性来塑造它。第二个形状是一个来自RectangleGeometry元素的矩形,由其Rect属性定义。
这两个形状的交集是此路径的结果,并大致覆盖了我们整体形状的底部部分,直到曲线。背后部分遮挡的矩形元素完成了整体形状的其余部分。
具有带有LayeredButtonBackground键的VisualBrush元素的Visual属性被设置为LayeredButtonBackgroundElements面板,因此任何用此画笔绘制的 UI 元素现在都将印有这种设计。一旦我们将这些资源添加到App.xaml文件中的Application.Resources部分,我们就可以通过VisualBrush元素使用它们,如下所示:
<Button Background="{StaticResource LayeredButtonBackground}" Width="200"
Height="40" SnapsToDevicePixels="True" />
这将在按钮背景中渲染渐变,如下所示:

在这个例子中,我们手动指定了视觉画笔的引用来绘制Button对象的背景。然而,以这种方式设置背景将要求使用我们应用程序框架的开发者在每次添加按钮时都这样做。更好的解决方案是重新设计默认按钮模板,以便视觉画笔自动应用于每个按钮。我们将在本章后面看到这个例子,当我们汇集这些技术时。
反射光线
另一种技术是在我们的控件顶部添加一个半透明层,该层具有渐变到透明的效果,以产生光源反射的外观。这可以通过简单的 Border 元素和一个 LinearGradientBrush 实例轻松实现。让我们看看我们如何完成这个任务:
<Button Content="Click Me" Width="140" Height="34" FontSize="18"
Foreground="White" Margin="20">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Border Background="#FF007767" CornerRadius="5"
SnapsToDevicePixels="True">
<Grid>
<Rectangle RadiusX="4" RadiusY="4" Margin="1,1,1,7"
SnapsToDevicePixels="True">
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#BFFFFFFF" />
<GradientStop Color="#00FFFFFF" Offset="0.8" />
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</ControlTemplate>
</Button.Template>
</Button>
当运行此示例时,将生成一个看起来像这样的按钮:

让我们检查这个示例。我们首先声明具有一些样式属性的 Button 元素。与在现实世界应用中定义单独的样式或控件模板不同,我们再次在这里内联声明模板以节省空间。
在控制模板中,我们首先声明一个具有翡翠绿色背景和 CornerRadius 值为 5 的 Border 元素。我们再次将 SnapsToDevicePixels 属性设置为 true,以确保边缘保持锐利。
在边框内部,我们在 Grid 面板中定义了两个元素。第一个是产生反射效果的 Rectangle 元素,第二个是所需的 ContentPresenter 对象。矩形使用 RadiusX 和 RadiusY 属性中的 4 值,并适当地设置 Margin 属性以确保在反射边缘周围有一个微小的间隙。
它还将其 SnapsToDevicePixels 属性设置为 true,以确保这个微小的间隙不会被模糊。请注意,底部边距的值为 7,因为我们不希望反射效果覆盖按钮的下半部分。Fill 属性实际上是创建反射效果的地方。
在矩形的 Fill 属性中,我们通过设置 StartPoint 和 EndPoint 属性的 X 值以及 StartPoint.Y 属性为 0 和 Endpoint.Y 属性为 1,定义了一个垂直的 LinearGradientBrush 元素;将这些点绘制在图上会产生一条垂直线,因此这产生了一个垂直渐变。
在 LinearGradientBrush 对象的 GradientStops 集合中,我们定义了两个 GradientStop 元素。第一个元素的偏移量为零,设置为白色,并具有十六进制 alpha 通道值 BF,这大约相当于不透明度值 0.7。第二个元素的偏移量为 0.8,设置为白色,并具有十六进制 alpha 通道值 00,这导致颜色完全透明,可以用 Transparent 颜色替换。
因此,生成的渐变从顶部略微透明开始,并在底部完全透明,结合底部边距和偏移值,实际上位于按钮的中间。与我们的其他示例一样,ContentPresenter 对象随后声明,以便它被渲染在反射效果之上。
创建发光效果
我们可以为我们的控件创建另一种效果,即发光外观,就像光线从控件内部向外照射一样。我们需要另一个LinearGradientBrush实例和 UI 元素来绘制它。Rectangle元素非常适合这个角色,因为它非常轻量。我们应该在App.xaml文件中的应用程序资源中定义这些资源,以使每个视图都能使用它们:
<TransformGroup x:Key="GlowTransformGroup">
<ScaleTransform CenterX="0.5" CenterY="0.85" ScaleY="1.8" />
<TranslateTransform Y="0.278" />
</TransformGroup>
<RadialGradientBrush x:Key="GreenGlow" Center="0.5,0.848"
GradientOrigin="0.5,0.818" RadiusX="-1.424" RadiusY="-0.622"
RelativeTransform="{StaticResource GlowTransformGroup}">
<GradientStop Color="#CF65FF00" Offset="0.168" />
<GradientStop Color="#4B65FF00" Offset="0.478" />
<GradientStop Color="#0065FF00" Offset="1" />
</RadialGradientBrush>
<Style x:Key="GlowingButtonStyle" TargetType="{x:Type Button}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border BorderBrush="White" BorderThickness="1"
Background="DarkGray" CornerRadius="3">
<Grid>
<Rectangle IsHitTestVisible="False" RadiusX="2"
RadiusY="2" Fill="{StaticResource GreenGlow}" />
<ContentPresenter Content="{TemplateBinding Content}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
<Border.Effect>
<DropShadowEffect Color="#FF65FF00" ShadowDepth="4"
Opacity="0.4" Direction="270" BlurRadius="10" />
</Border.Effect>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
我们首先声明一个TransformGroup元素,它使我们能够将一个或多个变换对象组合在一起。在其内部,我们定义一个ScaleTransform元素,该元素将应用元素的垂直缩放因子设置为默认值1,水平缩放因子设置为1.8。我们使用其CenterX和CenterY属性指定这个变换的中心。接下来,我们声明一个TranslateTransform元素,该元素将应用元素向下移动一小段距离。
在此之后,我们定义一个RadialGradientBrush对象,它将代表我们设计中的发光效果。我们使用RadiusX和RadiusY属性来塑造画笔元素,并指定Center和GradientOrigin属性来指定径向渐变的中心和焦点。
然后我们将TransformGroup元素设置为画笔的RelativeTransform属性,以将变换应用到它上面。请注意,三个GradientStop元素都使用相同的R、G和B值,只是在透明度通道或不透明度值上有所不同。
接下来,我们声明GlowingButtonStyle样式,用于类型Button,将SnapsToDevicePixels属性设置为true,以保持其线条清晰和锐利。在Template属性中,我们定义一个包含白色Border元素的ControlTemplate元素,该Border元素具有略微圆滑的角落。
在边框内部,我们声明一个包含Rectangle和ContentPresenter元素的Grid面板。同样,矩形的RadiusX和RadiusY属性设置为比父边框控件CornerRadius属性更小的值,以确保它均匀地适合其中。我们的RadialGradientBrush资源被分配为矩形的Fill属性。
ContentPresenter对象被居中,以确保按钮的内容将在其中心渲染。回到Border元素,我们看到在其Effect属性中声明了一个DropShadowEffect。然而,这个元素不是为了创建阴影效果;这个类是多功能的,也可以渲染发光效果以及阴影效果。
技巧在于将其Color属性设置为非黑色,并将BlurRadius属性设置为比我们通常在创建阴影效果时使用的更大值。在这种情况下,我们将Direction属性设置为270,将ShadowDepth属性设置为4,以便将发光效果定位在边界的底部,即光线应该从那里发出的位置。
不幸的是,这种效果并不能很好地转换为灰度或纸张效果,因此在非彩色和屏幕上查看时,发光效果会有所损失。对于本书电子书版本的读者,以下是我们的示例中发光效果的外观:

将所有这些放在一起
虽然这些各种效果可以单独改善我们的控件的外观,但最大的改进往往是在将它们合并到单一设计中时实现的。在接下来的例子中,我们将这样做。我们首先需要添加一些额外的资源来使用:
<SolidColorBrush x:Key="TransparentWhite" Color="#7FFFFFFF" />
<SolidColorBrush x:Key="VeryTransparentWhite" Color="#3FFFFFFF" />
<SolidColorBrush x:Key="TransparentBlack" Color="#7F000000" />
<SolidColorBrush x:Key="VeryTransparentBlack" Color="#3F000000" />
<VisualBrush x:Key="SemiTransparentLayeredButtonBackground"
Visual="{StaticResource LayeredButtonBackgroundElements}"
Opacity="0.65" />
这里并没有什么太复杂的东西。我们只是定义了具有不同透明度级别的一系列颜色,以及一个稍微透明的视觉笔刷版本,它引用了我们的分层背景元素。现在让我们继续到包含的样式:
<Style TargetType="{x:Type Button}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border CornerRadius="3"
BorderBrush="{StaticResource TransparentBlack}"
BorderThickness="1"
Background="{StaticResource TransparentWhite}">
<Border Name="InnerBorder" CornerRadius="2"
Background="{StaticResource LayeredButtonBackground}"
Margin="1">
<Grid>
<Rectangle IsHitTestVisible="False" RadiusX="2"
RadiusY="2" Fill="{StaticResource GreenGlow}" />
<ContentPresenter Content="{TemplateBinding Content}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding
HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding
VerticalContentAlignment}" />
</Grid>
</Border>
<Border.Effect>
<DropShadowEffect Color="Black" ShadowDepth="6"
BlurRadius="6" Direction="270" Opacity="0.5" />
</Border.Effect>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="InnerBorder"
Property="Background" Value="{StaticResource
SemiTransparentLayeredButtonBackground}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="InnerBorder" Property="Background"
Value="{StaticResource LayeredButtonBackground}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
观察示例 XAML,我们可以看到SnapsToDevicePixels属性设置为true,以避免反走样伪影模糊按钮的边缘,并且Cursor属性设置为当用户的鼠标悬停在按钮上时显示指向的手指光标。
在控件模板中,我们看到两个嵌套的Border元素。请注意,外部边框使用TransparentBlack和TransparentWhite笔刷资源,使其半透明。此外,请注意,白色内部边框实际上来自外部边框的背景,而不是内部边框,这设置了Margin属性为1,以产生内部边框的印象。
在这个例子中,内部边框元素仅负责从视觉笔刷中显示分层按钮元素,并且它本身没有显示的边框。再次强调,我们已经调整了它的CornerRadius属性,使其能够整齐地嵌入外部边框中。我们可以在 WPF 设计器中放大到放大级别,以帮助我们决定在这里应该使用哪些值。
在内部边框内,我们声明了一个Grid面板,这样我们就可以添加所需的ContentPresenter和用资源中的GreenGlow笔刷绘制的Rectangle元素。再次强调,我们将它的IsHitTestVisible属性设置为false,这样用户就不能与之交互,并将RadiusX和RadiusY属性设置为与内部边框的CornerRadius值相匹配。
我们使用TemplateBinding元素将ContentPresenter对象的属性映射到模板对象的适当属性,以便设置按钮上的属性可以影响其定位和内容。接下来,我们将之前显示的DropShadowEffect元素设置为外部边框的Effect属性,这样就在模板中总结了包含的 UI 元素。
为了使模板更有用,我们在ControlTemplate.Triggers集合中设置了一些Trigger对象,这些对象将为我们的按钮添加鼠标悬停效果。第一个触发器针对IsMouseOver属性,并在属性为真时将内部边框的背景设置为分层按钮元素视觉画刷的略微透明版本。
第二个触发器针对IsPressed属性,并在属性为真时重新应用原始视觉画刷。请注意,这两个触发器必须按此顺序定义,以便当两个条件都为真时,针对IsPressed属性的触发器将覆盖另一个。当然,按钮在点击时是点亮还是熄灭,或者甚至改变颜色,这完全取决于个人喜好。
注意,我们在这个样式中省略了x:Key指令,因此它将隐式应用于所有没有明确应用不同样式的Button元素。因此,我们可以在不指定样式的情况下声明我们的Button元素,如下面的代码片段所示:
<Button Content="Click Me" Width="200" Height="40" FontSize="20"
Foreground="White" />
这导致了以下视觉输出:

我们还可以通过定义多个不同的颜色资源并在数据模板中使用数据触发器来进一步扩展这个发光的想法,以改变发光的颜色来指示数据对象的不同状态。这使我们能够向用户提供额外的视觉信息,除了通常的文本反馈方法之外。
例如,数据模型对象上的蓝色光芒可以指定未更改的对象,而绿色可以表示有有效更改的对象,红色可以突出显示错误的对象。我们将在下一章中看到如何实现这个想法,但就目前而言,让我们继续探讨不同的方法来使我们的应用脱颖而出。
离开常规
在一般商业应用中,大多数看起来相当普通,包含各种包含标准矩形表单字段的表单页面。另一方面,视觉吸引人的应用则脱颖而出。因此,为了创建视觉吸引人的应用,我们需要离开常规。
这意味着为我们的控件添加具有圆角的控件模板,或者更多,这取决于你。我们可以以许多不同的方式增强控件的外观,我们将在本节中探讨这些想法中的许多。让我们从一个最适合与徽标或启动和背景图像一起使用的反射效果开始。
投射反射
所有FrameworkElement派生类都有一个RenderTransform属性,我们可以利用它以各种方式转换它们的渲染输出。一个ScaleTransform元素使我们能够同时在水平和垂直方向上缩放每个对象。关于ScaleTransform对象的一个有用方面是,我们也可以负向缩放,从而反转视觉输出。
我们可以用这个特定的特性创建一个视觉上令人愉悦的效果,即对象的镜像或反射。为了增强这个效果,我们可以使用透明度遮罩来随着反射从对象退去而淡出反射。这可以给视觉上造成一个物体在光滑表面上反射的印象,如下面的图像所示:

让我们看看我们如何实现这个结果:
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Width="348">
<TextBlock Name="TextBlock" FontFamily="Candara"
Text="APPLICATION NAME" FontSize="40" FontWeight="Bold">
<TextBlock.Foreground>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="Orange" />
<GradientStop Color="Red" Offset="0.5" />
<GradientStop Color="Orange" Offset="1" />
</LinearGradientBrush>
</TextBlock.Foreground>
</TextBlock>
<Rectangle Height="31" Margin="0,-11.6,0,0">
<Rectangle.Fill>
<VisualBrush Visual="{Binding ElementName=TextBlock}">
<VisualBrush.RelativeTransform>
<ScaleTransform ScaleY="-1.0" CenterX="0.5" CenterY="0.5" />
</VisualBrush.RelativeTransform>
</VisualBrush>
</Rectangle.Fill>
<Rectangle.OpacityMask>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#DF000000" />
<GradientStop Color="Transparent" Offset="0.8" />
</LinearGradientBrush>
</Rectangle.OpacityMask>
</Rectangle>
</StackPanel>
在这个例子中,我们使用StackPanel对象将TextBlock元素放置在Rectangle元素之上。文本将是反射的对象,反射将在矩形中生成。面板的宽度被限制以确保反射正好适合文本元素。我们首先命名TextBlock元素,并设置一些字体属性,以及要输出的文本。
我们设置了一个LinearGradientBrush对象作为文本的颜色,使其更有趣,尽管这并不参与创建反射效果。接下来,请注意,Rectangle元素的大小和位置被精确设置以适应来自TextBlock元素的文本大小。我们当然可以使用这种技术来反射任何东西,并不限于仅反射文本元素。
矩形的背景是用VisualBrush对象绘制的,其中Visual属性绑定到TextBlock元素的视觉输出,使用ElementName属性。注意VisualBrush对象的RelativeTransform属性,它使我们能够以某种方式变换视觉,并设置为ScaleTransform类的实例。
这是最重要的组成部分之一,因为这个元素在垂直平面上反转了相关的视觉。将ScaleY属性设置为-1将垂直反转视觉,而将ScaleX属性设置为-1将水平反转视觉。请注意,我们在这里省略了ScaleX属性,因为我们希望将其设置为默认值1。
接下来,我们看到OpacityMask属性,它允许我们设置一个渐变画刷来映射到矩形的透明度。当画刷的 alpha 通道为1时,矩形将是完全不透明的,当它为0时,矩形将是透明的,当它在两者之间时,矩形将是半透明的。这是这个效果的另一个重要部分,它创建了反射图像的淡出效果。
在我们的例子中,我们有一个垂直渐变,顶部几乎是纯黑色,并且逐渐变得透明,直到达到四分之四的位置,此时它变得完全透明。当设置为矩形的OpacityMask时,只使用 alpha 通道值,这导致它在顶部完全可见,然后逐渐淡出到四分之四的位置,如前图所示。
探索无边框窗口
使用 WPF,我们可以创建没有边框、标题栏以及标准最小化、恢复和关闭按钮的窗口。我们还可以创建不规则形状的窗口和具有透明区域的窗口,这些区域可以显示其下方的任何内容。虽然使我们的主应用程序窗口无边框可能有些不寻常,但我们仍然可以利用这一功能。
例如,我们可以创建一个无边框窗口用于自定义消息框,或者可能是扩展工具提示,或者任何其他向最终用户提供信息的弹出控件。创建无边框窗口可以通过几个简单的步骤实现。让我们从基础知识开始,并假设我们正在将其添加到现有的应用程序框架中。
在这种情况下,我们已经有了一个 MainWindow 类,并需要添加一个额外的窗口。正如我们在第六章中看到的,适配内置控件,我们可以通过向项目中添加一个新的 UserControl 并将 UserControl 中的单词替换为 Window,在 XAML 文件及其关联的代码后文件中来实现这一点。如果未能同时更改这两个文件,将导致设计时错误,并抱怨类不匹配。
或者,我们可以右键点击启动项目,选择添加,然后点击 Window…,接着将其剪切并粘贴到你希望它所在的位置。不幸的是,Visual Studio 没有提供其他方法将 Window 控件添加到我们的其他项目中。
一旦我们有了 Window 对象,我们只需要将其 WindowStyle 属性设置为 None 并将其 AllowsTransparency 属性设置为 true。这将导致窗口的白色背景出现:
<Window
x:Class="CompanyName.ApplicationName.Views.Controls.BorderlessWindow"
Height="100" Width="200" WindowStyle="None" AllowsTransparency="True">
</Window>
...
using System.Windows;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class BorderlessWindow : Window
{
public BorderlessWindow()
{
InitializeComponent();
}
}
}
然而,虽然这样做移除了我们所有人都习惯的默认窗口边框,并为我们提供了一个无边框窗口,但它也移除了标准按钮,因此我们无法直接关闭、调整大小或移动窗口。幸运的是,使我们的窗口可移动是一个非常简单的事情。我们只需要在调用 InitializeComponent 方法之后,将以下代码行添加到窗口的构造函数中:
MouseLeftButtonDown += (o, e) => DragMove();
这个 DragMove 方法是在 Window 类中声明的,它使我们能够从其边界内的任何位置点击并拖动窗口。我们可以轻松地通过添加自己的标题栏并将此匿名事件处理程序附加到该对象的 MouseLeftButtonDown 事件来重新创建仅能从标题栏移动窗口的正常窗口功能。
如果我们希望我们的无边框窗口可调整大小,Window 类中有一个 ResizeMode 属性,它为我们提供了一些选项。我们可以用于我们的无边框窗口的一个值是 CanResizeWithGrip。此选项添加了一个所谓的调整大小手柄,由窗口右下角的三角形点图案指定,用户可以使用它来调整窗口大小。
如果我们将 ResizeMode 属性设置为这个值,并将背景设置为与调整大小控件形成对比的颜色,我们将得到这个视觉输出:

然而,我们仍然没有关闭窗口的方法。为此,我们可以添加自己的按钮,或者也许可以启用通过按键盘上的 escape Esc 键或其他键来关闭窗口。无论哪种方式,无论触发器是什么,关闭窗口只是调用窗口的 Close 方法这么简单。
而不是实现一个带有边框的替代窗口样式,这可以通过几个边框轻松实现,让我们专注于开发一个无边框且形状不规则的窗口,我们可以用它来弹出对用户有用的信息。通常,我们需要将窗口的背景设置为透明来隐藏它,但我们将替换其控件模板,因此我们不需要这样做。
对于这个示例,我们也不需要调整大小控件,因此让我们将 ResizeMode 属性设置为 NoResize。我们也没有必要通过鼠标移动这个调用窗口,因此不需要添加调用 DragMove 方法的匿名事件处理器。
由于这个窗口只为用户提供信息,我们还应该设置一些其他窗口属性。一个重要的属性是设置 ShowInTaskbar 属性,它指定应用程序图标是否应该出现在 Windows 任务栏中。由于这个窗口将是我们主应用程序的一个组成部分,我们将此属性设置为 false,以便其图标将被隐藏。
对于这种情况,另一个有用的属性是 WindowStartupLocation 属性,它允许使用 Window.Top 和 Window.Left 属性来定位窗口。这样,调用窗口就可以在屏幕上任何需要的位置进行程序化定位。在继续进行之前,让我们看看这个窗口的代码:
<Window x:Class="CompanyName.ApplicationName.Views.Controls.CalloutWindow"
xmlns:Controls=
"clr-namespace:CompanyName.ApplicationName.Views.Controls"
WindowStartupLocation="Manual">
<Window.Resources>
<Style TargetType="{x:Type Controls:CalloutWindow}">
<Setter Property="ShowInTaskbar" Value="False" />
<Setter Property="WindowStyle" Value="None" />
<Setter Property="AllowsTransparency" Value="True" />
<Setter Property="ResizeMode" Value="NoResize" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Controls:CalloutWindow}">
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="5*" />
</Grid.ColumnDefinitions>
<Path Grid.ColumnSpan="2"
Fill="{TemplateBinding Background}"
Stroke="{TemplateBinding BorderBrush}"
StrokeThickness="2" Stretch="Fill">
<Path.Data>
<CombinedGeometry GeometryCombineMode="Union">
<CombinedGeometry.Geometry1>
<PathGeometry>
<PathFigure StartPoint="0,60">
<LineSegment Point="50,45" />
<LineSegment Point="50,75" />
</PathFigure>
</PathGeometry>
</CombinedGeometry.Geometry1>
<CombinedGeometry.Geometry2>
<RectangleGeometry RadiusX="20" RadiusY="20"
Rect="50,0,250,150" />
</CombinedGeometry.Geometry2>
</CombinedGeometry>
</Path.Data>
</Path>
<ContentPresenter Grid.Column="1"
Content="{TemplateBinding Content}"
HorizontalAlignment="{TemplateBinding
HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding
VerticalContentAlignment}"
Margin="{TemplateBinding Padding}">
<ContentPresenter.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
<Grid.Effect>
<DropShadowEffect Color="Black"
Direction="270" ShadowDepth="7" Opacity="0.3" />
</Grid.Effect>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
</Window>
虽然这个示例不是很长,但这里有很多东西要讨论。为了澄清情况,在我们检查这段代码之前,让我们也看看它的代码隐藏部分:
using System.Windows;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class CalloutWindow : Window
{
static CalloutWindow()
{
BorderBrushProperty.OverrideMetadata(typeof(CalloutWindow),
new FrameworkPropertyMetadata(
new SolidColorBrush(Color.FromArgb(255, 238, 156, 88))));
HorizontalContentAlignmentProperty.OverrideMetadata(
typeof(CalloutWindow),
new FrameworkPropertyMetadata(HorizontalAlignment.Center));
VerticalContentAlignmentProperty.OverrideMetadata(
typeof(CalloutWindow),
new FrameworkPropertyMetadata(VerticalAlignment.Center));
}
public CalloutWindow()
{
InitializeComponent();
}
public new static readonly DependencyProperty BackgroundProperty =
DependencyProperty.Register(nameof(Background), typeof(Brush),
typeof(CalloutWindow),
new PropertyMetadata(new LinearGradientBrush(Colors.White,
Color.FromArgb(255, 250, 191, 143), 90)));
public new Brush Background
{
get { return (Brush)GetValue(BackgroundProperty); }
set { SetValue(BackgroundProperty, value); }
}
}
}
这个代码隐藏文件比 XAML 文件简单,所以让我们先快速浏览一下。我们添加了一个静态构造函数,以便在几个预存在的依赖属性上调用 OverrideMetadata 方法。这使得我们可以覆盖这些属性的默认设置,我们这样做是在静态构造函数中,因为我们希望这个代码在每个类中只运行一次,并且因为它在类中的任何其他构造函数或方法之前被调用。
在这个构造函数中,我们覆盖了 BorderBrush 属性的元数据,以便为我们的调用窗口设置默认边框颜色。我们也为 HorizontalContentAlignment 和 VerticalContentAlignment 属性做了同样的事情,以确保窗口内容默认居中。通过这样做,我们正在重用这些现有属性。
然而,我们也可以完全替换现有的属性。例如,我们已将 Background 属性替换为绘制我们的调用背景。在这种情况下,我们声明了自己的 Background 属性,由 new 关键字指定,并设置了其默认画笔颜色。然后我们使用它来绘制调用形状的背景,尽管我们也可以在我们的样式中添加另一个设置器来重用原始的 Background 属性。
现在查看 XAML 代码,我们可以看到在 Window 声明中设置了 WindowStartupLocation 属性,随后是窗口 Resources 部分中的样式。在这个样式中,我们设置了上述属性并定义了窗口的控制模板。在 ControlTemplate 对象内部,我们定义了一个 Grid 面板。我们稍后会回到这里,但到目前为止,请注意面板底部设置了九像素的边距。
接下来,请注意该面板声明了两个星号大小的 ColumnDefinition 元素,一个宽度为 *,另一个宽度为 5*。如果我们把这些加起来,最终得到的总宽度是六个等分。这意味着第一列将是窗口总宽度的六分之一,而第二列将占据剩余的五分之六。我们很快就会看到为什么是这样设置的。
在 Grid 面板内部,我们首先声明了用于定义我们调用形状的 Path 元素。我们将 Grid.ColumnSpan 属性设置为 2,以确保它占据父窗口的所有空间。接下来,我们将新的 Background 属性设置为 Fill 属性,这样我们的窗口用户就可以设置 Background 属性,并且该画笔只绘制路径的背景。
我们还设置了 Path 元素的 Stroke 属性为重写的 BorderBrush 属性,尽管我们没有这样做,但我们本可以通过声明另一个依赖属性来公开 StrokeThickness 属性。请注意,我们使用 TemplateBinding 元素来访问窗口的属性,因为在这个特定情况下,它们是最有效的。
特别注意 Path.Stretch 属性,我们将其设置为 Fill,并定义了形状应该如何填充它所提供的空间。使用此 Fill 值指定内容应填充所有可用空间,而不是保留其最初定义的宽高比。然而,如果我们想保留宽高比,则可以将此属性更改为 Uniform 值。
路径最重要的部分位于 Path.Data 部分。这定义了渲染路径的形状,就像我们的分层背景示例一样,我们在这里使用 CombinedGeometry 元素来组合两个独立的几何形状。与前面的示例不同,这里我们使用 GeometryCombineMode 的值为 Union,这会将两个几何形状的输出一起渲染。
在CombinedGeometry.Geometry1元素中,我们声明了一个具有PathFigure元素的PathGeometry对象,该元素有一个起始点和两个LineSegment元素。与起始点一起,这两个元素形成了我们的注释的三角形部分,指向屏幕上与窗口信息相关联的区域。请注意,这个三角形在路径中宽度为五十像素。
在CombinedGeometry.Geometry2元素中,我们声明了一个RectangleGeometry对象,其大小由Rect属性指定,其圆角的大小由RadiusX和RadiusY属性指定。矩形距离左侧边缘五十像素,宽度为二百五十像素。
因此,矩形和三角形的总面积是三百像素。三百的六分之一是五十,这就是我们形状中三角形的宽度。这解释了为什么我们的第一个Grid列被设置为占据总空间的六分之一。
在Path对象之后,我们声明了ContentPresenter元素,这是输出窗口实际内容所必需的,并将其设置为面板的第二列。简而言之,这一列用于将ContentPresenter元素直接定位在我们的形状的矩形部分上方,避免三角形部分。
在ContentPresenter元素中,我们使用TemplateBinding元素将几个位置属性数据绑定到窗口的相关属性。我们还使用另一个TemplateBinding元素将其Content属性数据绑定到窗口的Content属性。
注意,我们本来可以直接在Window控件中声明我们的 UI 控件。然而,如果我们那样做了,我们就无法以这种方式将其Content属性数据绑定,因为外部设置会替换掉我们声明的所有 XAML 控件,包括ContentPresenter对象。通过提供一个新的模板,我们完全覆盖了窗口的默认行为。
还请注意,我们在ContentPresenter元素的Resources部分中声明了一个样式。这个样式没有使用x:Key指令声明。这样做是为了使其隐式应用于作用域内的所有TextBlock对象,特别是为了影响ContentPresenter元素将自动为string值生成的TextBlock对象,而不会影响其他对象。
样式将TextBlock.TextWrapping属性设置为TextWrapping枚举的Wrap成员,这会将长文本行换行到下一行。默认设置是NoWrap,这会导致长字符串在我们的窗口中无法完全显示。
最后,我们来到了 XAML 示例的结尾,发现一个DropShadowEffect对象被设置为Grid面板的Effect属性。与所有阴影效果一样,我们将Color属性设置为黑色,将Opacity属性设置为小于或等于0.5的值。Direction属性设置为270,产生一个直接位于我们的 callout 形状下方的阴影。
注意,我们将ShadowDepth属性设置为7。现在,你还记得在网格上设置的底部边距吗? 那是设置在这个值之上,以确保在窗口中留出足够的空间来显示我们的阴影在 callout 形状下方。如果没有这个设置,阴影将位于窗口的边界框之外,并且不会被显示。
如果我们为Direction属性设置了不同的值,那么我们需要调整Grid面板的边距,以确保在窗口周围留出足够的空间来显示阴影在其新位置。现在让我们看看我们如何使用我们的新窗口:
CalloutWindow calloutWindow = new CalloutWindow();
calloutWindow.Width = 225;
calloutWindow.Height = 120;
calloutWindow.FontSize = 18;
calloutWindow.Padding = new Thickness(20);
calloutWindow.Content = "Please fill in the first line of your address.";
calloutWindow.Show();
从合适的位置运行此代码会产生以下渲染输出:

在我们的窗口显示代码中,我们将一个string设置到窗口的Content属性。然而,这个属性的类型是object,因此我们可以将其值设置为任何对象。就像我们在本书前面将我们的视图模型实例设置到ContentControl的Content属性一样,我们也可以用我们的窗口来做同样的事情。
给定一个合适的DataTemplate,它定义了特定自定义对象类型的 UI,我们可以将该对象的实例设置到窗口的Content属性,并使该模板中的控件在 callout 窗口中渲染,这样我们就不限于只使用类型string作为内容。让我们使用一个先前的例子:
calloutWindow.DataContext = new UsersViewModel();
通过对calloutWindow的维度属性进行一些轻微的调整,我们会看到以下内容:

数据可视化
虽然在 WPF 中存在许多现成的图形控件和第三方数据可视化控件,但我们可以相对容易地创建自己的。仅用文本术语表达数据,虽然通常是可以接受的,但并不是最佳选择。在应用程序中打破常规,总是能让该应用程序在严格遵守标准的其他应用程序中脱颖而出。
例如,想象一个简单的情况,我们有一个仪表盘,它可视化了进入的工作任务数量和已完成的工作任务数量。我们只需用大号粗体字输出这些数字,但这将是正常的输出方式。如果我们把每个数字可视化成一个形状,其大小由数字指定会怎样呢?
让我们重用我们之前的技术,设计一些视觉上吸引人的球体,它们的大小根据特定值增长。为此,我们可以创建另一个自定义控件,具有一个Value依赖属性进行数据绑定。让我们首先看看Sphere类的代码:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using CompanyName.ApplicationName.CustomControls.Enums;
using MediaColor = System.Windows.Media.Color;
namespace CompanyName.ApplicationName.CustomControls
{
[TemplatePart(Name = "PART_Background", Type = typeof(Ellipse))]
[TemplatePart(Name = "PART_Glow", Type = typeof(Ellipse))]
public class Sphere : Control
{
private RadialGradientBrush greenBackground =
new RadialGradientBrush(new GradientStopCollection() {
new GradientStop(MediaColor.FromRgb(0, 254, 0), 0),
new GradientStop(MediaColor.FromRgb(1, 27, 0), 0.974) });
private RadialGradientBrush greenGlow =
new RadialGradientBrush(new GradientStopCollection() {
new GradientStop(MediaColor.FromArgb(205, 67, 255, 46), 0),
new GradientStop(MediaColor.FromArgb(102, 88, 254, 72), 0.426),
new GradientStop(MediaColor.FromArgb(0, 44, 191, 32), 1) });
private RadialGradientBrush redBackground =
new RadialGradientBrush(new GradientStopCollection() {
new GradientStop(MediaColor.FromRgb(254, 0, 0), 0),
new GradientStop(MediaColor.FromRgb(27, 0, 0), 0.974) });
private RadialGradientBrush redGlow =
new RadialGradientBrush(new GradientStopCollection() {
new GradientStop(MediaColor.FromArgb(205, 255, 46, 46), 0),
new GradientStop(MediaColor.FromArgb(102, 254, 72, 72), 0.426),
new GradientStop(MediaColor.FromArgb(0, 191, 32, 32), 1) });
static Sphere()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Sphere),
new FrameworkPropertyMetadata(typeof(Sphere)));
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(double),
typeof(Sphere), new PropertyMetadata(50.0));
public double Value
{
get { return (double)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
public static readonly DependencyProperty ColorProperty =
DependencyProperty.Register(nameof(Color), typeof(SphereColor),
typeof(Sphere), new PropertyMetadata(SphereColor.Green,
OnColorChanged));
public SphereColor Color
{
get { return (SphereColor)GetValue(ColorProperty); }
set { SetValue(ColorProperty, value); }
}
private static void OnColorChanged(DependencyObject
dependencyObject, DependencyPropertyChangedEventArgs e)
{
((Sphere)dependencyObject).SetEllipseColors();
}
public override void OnApplyTemplate()
{
SetEllipseColors();
}
private void SetEllipseColors()
{
Ellipse backgroundEllipse =
GetTemplateChild("PART_Background") as Ellipse;
Ellipse glowEllipse = GetTemplateChild("PART_Glow") as Ellipse;
if (backgroundEllipse != null) backgroundEllipse.Fill =
Color == SphereColor.Green ? greenBackground : redBackground;
if (glowEllipse != null) glowEllipse.Fill =
Color == SphereColor.Green ? greenGlow : redGlow;
}
}
}
由于这个类将声明自己的Color属性,我们首先添加一个MediaColor使用别名指令,我们将仅将其用作访问System.Windows.Media.Color类的方法的快捷方式,当在Sphere类中声明将要使用的画笔时。
从类声明中,我们可以看到在TemplatePartAttribute属性中指定了两个命名的部分。这指定了在我们的控制模板的Generic.xaml文件中需要两个提到的Ellipse元素。在类内部,我们定义了多个RadialGradientBrush资源来绘制我们的球体。
在静态构造函数中,我们调用OverrideMetadata方法让框架知道我们的控制默认样式在哪里。然后我们看到Value和Color依赖属性的声明,以及与Color属性相关的PropertyChangedCallback处理方法。
在这个OnColorChanged方法中,我们将dependencyObject输入参数转换为我们的Sphere类的一个实例,并调用它的SetEllipseColors方法。在那个方法中,我们使用FrameworkElement.GetTemplateChild方法从我们的ControlTemplate元素访问两个主要的Ellipse对象。
记住,我们必须始终检查这些对象是否为null,因为我们的ControlTemplate可能被替换为一个不包含这些椭圆元素的模板。如果它们不是null,我们使用三元运算符并根据我们的Color属性值将它们的Fill属性设置为我们的画笔资源之一。
创建此功能的一个替代方案是声明一个类型为Brush的依赖属性,将其数据绑定到每个椭圆的Fill属性,并将相关的画笔资源设置为这些属性,而不是直接访问 XAML 元素。在查看控制默认样式之前,让我们看看由Color属性使用的SphereColor枚举:
namespace CompanyName.ApplicationName.CustomControls.Enums
{
public enum SphereColor
{
Green, Red
}
}
如您所见,这是一件简单的事情,可以很容易地扩展。请注意,这个枚举是在CustomControls命名空间和项目中声明的,这样项目就是自包含的,可以在其他应用程序中重用,而无需任何外部依赖。现在让我们看看我们的控制默认样式从Generic.xaml:
<Style TargetType="{x:Type CustomControls:Sphere}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type CustomControls:Sphere}">
<ControlTemplate.Resources>
<DropShadowEffect x:Key="Shadow" BlurRadius="10"
Direction="270" ShadowDepth="7" Opacity="0.5" />
<LinearGradientBrush x:Key="Reflection"
StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#90FFFFFF" Offset="0.009" />
<GradientStop Color="#2DFFFFFF" Offset="0.506" />
<GradientStop Offset="0.991" />
</LinearGradientBrush>
</ControlTemplate.Resources>
<Grid Height="{Binding Value,
RelativeSource={RelativeSource TemplatedParent}}"
Width="{Binding Value,
RelativeSource={RelativeSource TemplatedParent}}">
<Grid.RowDefinitions>
<RowDefinition Height="5*" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse Name="PART_Background" Grid.RowSpan="2"
Grid.ColumnSpan="3" Stroke="#FF1B0000"
Effect="{StaticResource Shadow}" />
<Ellipse Name="PART_Glow" Grid.RowSpan="2"
Grid.ColumnSpan="3" />
<Ellipse Grid.Column="1" Margin="0,2,0,0"
Fill="{StaticResource Reflection}" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
当查看我们控制的默认模板时,我们可以看到在ControlTemplate.Resources部分定义的一些资源。我们首先声明了一个DropShadowEffect元素,类似于我们之前对这个类的使用。接下来,我们定义了一个垂直的LinearGradientBrush元素,用作光反射层,与之前的示例类似。
之前,我们看到 GradientStop.Offset 属性的默认值是零,因此,如果我们需要使用这个值,我们可以省略此属性的设置。在这个画笔资源中,我们看到最后一个 GradientStop 元素没有指定 Color 值。这是因为该属性的默认值是 Transparent,这正是我们需要在这里使用的值。
在我们控件的实际标记中,我们在 Grid 面板内声明了三个 Ellipse 对象。其中两个元素在控件的代码中被命名并引用,而第三个椭圆使用资源中的画笔来创建其他椭圆上的“光泽”。面板的大小属性绑定到 Value 依赖属性,使用 TemplatedParent 源。
注意,我们使用了 Grid 面板的星型尺寸功能来定位和调整我们的椭圆元素的大小,除了在反射椭圆上指定的顶部边距的两像素。这样,我们的控件可以是任何大小,而各种层的定位将保持视觉上的正确性。注意,我们无法通过为每个元素硬编码确切的边距值来实现这一点。
让我们看看我们如何在简单的视图中使用它:
<Grid TextElement.FontSize="28" TextElement.FontWeight="Bold" Margin="20">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<CustomControls:Sphere Color="Red" Value="{Binding InCount}"
VerticalAlignment="Bottom" />
<CustomControls:Sphere Grid.Column="1" Value="{Binding OutCount}"
VerticalAlignment="Bottom" />
<TextBlock Grid.Row="1" Text="{Binding InCount}"
HorizontalAlignment="Center" Margin="0,10,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding OutCount}"
HorizontalAlignment="Center" Margin="0,10,0,0" />
</Grid>
这是我们示例渲染后的样子:

如您所见,WPF 非常强大,使我们能够创建外观完全独特的控件。然而,我们也可以用它来重新创建更常见的控件。作为一个例子,让我们看看我们如何创建一个替代控件来衡量我们可能接近特定目标值的程度:

这个例子展示了一个半圆形弧线,这在 XAML 中不存在可用的形式,所以我们将首先创建一个 Arc 控件,用于在我们的 Gauge 控件内部使用。让我们看看我们如何通过添加一个新的自定义控件来实现这一点:
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Shapes;
namespace CompanyName.ApplicationName.CustomControls
{
public class Arc : Shape
{
public static readonly DependencyProperty StartAngleProperty =
DependencyProperty.Register(nameof(StartAngle), typeof(double),
typeof(Arc), new FrameworkPropertyMetadata(180.0,
FrameworkPropertyMetadataOptions.AffectsRender));
public double StartAngle
{
get { return (double)GetValue(StartAngleProperty); }
set { SetValue(StartAngleProperty, value); }
}
public static readonly DependencyProperty EndAngleProperty =
DependencyProperty.Register(nameof(EndAngle), typeof(double),
typeof(Arc), new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsRender));
public double EndAngle
{
get { return (double)GetValue(EndAngleProperty); }
set { SetValue(EndAngleProperty, value); }
}
protected override Geometry DefiningGeometry
{
get { return GetArcGeometry(); }
}
private Geometry GetArcGeometry()
{
Point startPoint = ConvertToPoint(Math.Min(StartAngle, EndAngle));
Point endPoint = ConvertToPoint(Math.Max(StartAngle, EndAngle));
Size arcSize = new Size(Math.Max(0, (RenderSize.Width -
StrokeThickness) / 2), Math.Max(0, (RenderSize.Height -
StrokeThickness) / 2));
bool isLargeArc = Math.Abs(EndAngle - StartAngle) > 180;
StreamGeometry streamGeometry = new StreamGeometry();
using (StreamGeometryContext context = streamGeometry.Open())
{
context.BeginFigure(startPoint, false, false);
context.ArcTo(endPoint, arcSize, 0, isLargeArc,
SweepDirection.Counterclockwise, true, false);
}
streamGeometry.Transform =
new TranslateTransform(StrokeThickness / 2, StrokeThickness / 2);
streamGeometry.Freeze();
return streamGeometry;
}
private Point ConvertToPoint(double angleInDegrees)
{
double angleInRadians = angleInDegrees * Math.PI / 180;
double radiusX = (RenderSize.Width - StrokeThickness) / 2;
double radiusY = (RenderSize.Height - StrokeThickness) / 2;
return new Point(radiusX * Math.Cos(angleInRadians) + radiusX,
radiusY * Math.Sin(-angleInRadians) + radiusY);
}
}
}
注意,我们在创建 Arc 类时扩展了 Shape 类。我们这样做是因为它为我们提供了广泛的笔触和填充属性,以及从 Geometry 对象渲染自定义形状的设备。此外,我们的 Arc 控件的用户也将能够通过其 Stretch 和 GeometryTransform 属性利用 Shape 类的变换能力。
要绘制我们的弧线,我们将使用 StreamGeometryContext 类的 ArcTo 方法,并且我们需要为其起始点和终点指定确切的 Point 值。然而,为了在弧线的大小中正确反映值,使用起始点和终点的角度值来定义它更容易。
因此,我们在Arc类中添加了StartAngle和EndAngle依赖属性。请注意,这两个属性是用FrameworkPropertyMetadataOptions.AffectsRender成员声明的。这通知框架,这些属性的变化需要引起新的渲染过程,因此新值将准确地在控件中表示出来。
在这些属性声明之后,我们看到重写的DefiningGeometry属性,它使我们能够返回一个Geometry对象,该对象定义了要渲染的形状。我们只需从这个属性返回GetArcGeometry方法的结果即可。
在GetArcGeometry方法中,我们从ConvertToPoint方法获取所需的起始和结束Point元素,传递StartAngle和EndAngle属性值。请注意,我们在这里使用Math类的Min和Max方法来确保起点是从较小的角度计算得出的,而终点是从较大的角度计算得出的。
我们弧形的填充实际上来自几何弧的描边,因此我们无法为其添加描边。在 WPF 中,厚度为一像素的形状的描边将不会超出形状的边界框。然而,在最远点,厚度更大的描边会被渲染,使得它们的中心保持在边界框的线上,因此,一半的描边会延伸到元素的外部,另一半会在边界框内渲染:

因此,我们通过将RenderSize值减去StrokeThickness值除以二来计算弧的大小。这将减小弧的大小,使其完全位于我们的控件边界内。我们使用Math.Max方法确保传递给Size类的值永远不会小于零,以避免异常。
当使用ArcTo方法时,我们需要指定一个值来决定我们是否希望用短弧或长弧连接我们的起点和终点。因此,我们的isLargeArc变量决定了这两个指定的角度是否会产生超过一百八十度的弧。
接下来,我们创建一个StreamGeometry对象,并从其Open方法中检索一个StreamGeometryContext对象,用其来定义我们的几何形状。请注意,我们在这里同样可以使用PathGeometry对象,但由于我们不需要其数据绑定、动画或其他功能,我们使用更高效的StreamGeometry对象。
我们在BeginFigure方法中输入弧的起点,并在ArcTo方法中输入剩余的参数。请注意,我们在这两个方法上调用我们的StreamGeometryContext对象,并在using语句内完成,以确保一旦我们完成使用,它就会被正确关闭和释放。
接下来,我们将一个TranslateTransform元素应用到StreamGeometry对象的Transform属性上,以便将弧线完全包含在我们的控制范围内。如果不进行这一步,我们的弧线将超出控制范围的左上角边界框,超出量等于StrokeThickness属性值的一半。
一旦我们完成了对StreamGeometry对象的操作,我们就调用它的Freeze方法,这使得它不可修改,并为我们带来额外的性能优势。我们将在第十一章中了解更多关于这一点,提高应用程序性能,但现在,让我们继续查看这个示例。
最后,我们来到了ConvertToPoint方法,该方法将我们的两个角度依赖属性值转换为二维Point对象。我们的第一个任务是先将每个角度从度转换为弧度,因为我们需要使用Math类的方法,而这些方法需要弧度值。
接下来,我们使用RenderSize值的一半减去StrokeThickness属性值来计算我们的弧线的两个半径,这样弧线的大小就不会超过我们的Arc控制边界框。最后,在计算返回Point元素时,我们使用Math.Cos和Math.Sin方法进行一些基本的三角运算。
这样就完成了我们的简单Arc控制,因此现在我们可以利用这个新类在我们的Gauge控制中。我们需要为它创建另一个新的自定义控制,所以让我们首先看看我们新的Gauge类中的属性和代码:
using System.Windows;
using System.Windows.Controls;
namespace CompanyName.ApplicationName.CustomControls
{
public class Gauge : Control
{
static Gauge()
{
DefaultStyleKeyProperty.OverrideMetadata (typeof(Gauge),
new FrameworkPropertyMetadata(typeof(Gauge)));
}
public static readonly DependencyPropertyKey valueAnglePropertyKey =
DependencyProperty.RegisterReadOnly(nameof(ValueAngle),
typeof(double), typeof(Gauge), new PropertyMetadata(180.0));
public static readonly DependencyProperty ValueAngleProperty =
valueAnglePropertyKey.DependencyProperty;
public double ValueAngle
{
get { return (double)GetValue(ValueAngleProperty); }
private set { SetValue(valueAnglePropertyKey, value); }
}
public static readonly DependencyPropertyKey
rotationAnglePropertyKey = DependencyProperty.RegisterReadOnly(
nameof(RotationAngle), typeof(double), typeof(Gauge),
new PropertyMetadata(180.0));
public static readonly DependencyProperty RotationAngleProperty =
rotationAnglePropertyKey.DependencyProperty;
public double RotationAngle
{
get { return (double)GetValue(RotationAngleProperty); }
private set { SetValue(rotationAnglePropertyKey, value); }
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(double),
typeof(Gauge), new PropertyMetadata(0.0, OnValueChanged));
private static void OnValueChanged(DependencyObject
dependencyObject, DependencyPropertyChangedEventArgs e)
{
Gauge gauge = (Gauge)dependencyObject;
if (gauge.MaximumValue == 0.0)
gauge.ValueAngle = gauge.RotationAngle = 180.0;
else if ((double)e.NewValue > gauge.MaximumValue)
{
gauge.ValueAngle = 0.0;
gauge.RotationAngle = 360.0;
}
else
{
double scaledPercentageValue =
((double)e.NewValue / gauge.MaximumValue) * 180.0;
gauge.ValueAngle = 180.0 - scaledPercentageValue;
gauge.RotationAngle = 180.0 + scaledPercentageValue;
}
}
public double Value
{
get { return (double)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
public static readonly DependencyProperty MaximumValueProperty =
DependencyProperty.Register(nameof(MaximumValue), typeof(double),
typeof(Gauge), new PropertyMetadata(0.0));
public double MaximumValue
{
get { return (double)GetValue(MaximumValueProperty); }
set { SetValue(MaximumValueProperty, value); }
}
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(nameof(Title), typeof(string),
typeof(Gauge), new PropertyMetadata(string.Empty));
public string Title
{
get { return (string)GetValue(TitleProperty); }
set { SetValue(TitleProperty, value); }
}
}
}
如同往常一样,我们在静态构造函数中重写我们控制类型的DefaultStyleKeyProperty的元数据,以帮助框架找到其默认样式定义的位置。然后我们声明了内部的、只读的ValueAngle和RotationAngle依赖属性以及常规的公共Value、MaximumValue和Title依赖属性。
我们为Value属性声明了一个PropertyChangedCallback处理程序,并在该方法中,我们首先将dependencyObject输入参数转换为我们的Gauge类的一个实例。如果MaximumValue属性的值为零,那么我们只需将ValueAngle和RotationAngle属性都设置为180.0,这样弧线和指针就会显示在其起始位置,即左侧。
如果数据绑定Value属性的新值大于MaximumValue属性的值,那么我们将弧线和指针显示在其末端或完全位置,即右侧。我们通过将ValueAngle属性设置为0.0和将RotationAngle属性设置为360.0来实现这一点。
如果Value属性的新值有效,那么我们计算scaledPercentageValue变量。我们首先将新值除以MaximumValue属性的值,以得到最大值的百分比。然后我们乘以180.0,因为我们的仪表覆盖了 180 度的范围。
我们随后从ValueAngle属性的scaledPercentageValue变量值中减去180.0,并将其加到RotationAngle属性的180.0上。这是因为ValueAngle属性被我们的弧形使用,需要介于180.0和0.0之间,而RotationAngle属性被我们的仪表指针使用,需要介于180.0和360.0之间。
这很快就会变得清晰,现在让我们看看我们是如何在Generic.xaml文件中从Gauge控件的默认样式中使用这些属性和Arc控件的:
<Style TargetType="{x:Type CustomControls:Gauge}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type CustomControls:Gauge}">
<Grid Background="{Binding Background,
RelativeSource={RelativeSource TemplatedParent}}">
<Grid Margin="{Binding Padding,
RelativeSource={RelativeSource TemplatedParent}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="{Binding Title,
RelativeSource={RelativeSource TemplatedParent}}"
HorizontalAlignment="Center" />
<Canvas Grid.Row="1" Width="300" Height="150"
HorizontalAlignment="Center" Margin="0,5">
<CustomControls:Arc Width="300" Height="300"
StrokeThickness="75" Stroke="#FF444444" />
<CustomControls:Arc Width="300" Height="300"
StrokeThickness="75" Stroke="OrangeRed" StartAngle="180"
EndAngle="{Binding AngleValue,
RelativeSource={RelativeSource TemplatedParent}}" />
<Path Canvas.Left="150" Canvas.Top="140"
Fill="White" StrokeThickness="5" Stroke="White"
StrokeLineJoin="Round" Data="M0,0 L125,10, 0,20Z"
Stretch="Fill" Width="125" Height="20">
<Path.RenderTransform>
<RotateTransform Angle="{Binding RotationAngle,
RelativeSource={RelativeSource TemplatedParent}}"
CenterX="0" CenterY="10" />
</Path.RenderTransform>
</Path>
</Canvas>
<TextBlock Grid.Row="2" Text="{Binding Value, StringFormat=N0,
RelativeSource={RelativeSource TemplatedParent}}"
HorizontalAlignment="Center" FontWeight="Bold" />
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
我们像往常一样开始我们的默认样式,通过在样式和控制模板中指定我们的控件类型。在模板内部,我们有两个Grid面板,并将外部面板的Background属性和内部面板的Margin属性数据绑定到模板控件的属性,以便用户可以外部设置它们。
我们然后在我们的内部面板中定义了三行。控件的Title属性与第一行中水平居中的TextBlock元素的数据绑定。在第二行中,我们声明了一个水平居中的Canvas面板,其中包含我们两个新的Arc控件和一个Path对象。
第一个Arc控件是灰色,代表Gauge控件Value属性所坐的背景轨道。第二个Arc控件是橙红色,显示我们的Gauge控件Value属性当前值,通过将其EndAngle属性数据绑定到Gauge控件的AngleValue依赖属性。
注意,我们Arc控件中的角度遵循常见的笛卡尔坐标系,其中零度角指向右侧,值增加时逆时针移动。因此,为了从左到右绘制半圆形弧,我们以180度的角度开始,以0度结束,正如我们在Gauge控件中的背景弧所展示的那样。
此外,我们的Arc控件具有相同的宽度和高度值,但由于我们不需要它们的下半部分,我们使用画布面板的高度来裁剪它们。Path对象代表我们的控件中的仪表指针,并涂成白色。
我们将StrokeLineJoin属性设置为Round值,以便将针路径线条相遇处的三个角弯曲。注意,指针正好位于画布宽度的中点,并且比底部高十像素,以便其中心线沿着画布底部。
我们不是通过声明PathFigure和LineSegment对象来定义指针,而是在Data属性中使用了内联的简写符号。M指定我们应该移动到(或从)点0,0,L指定我们想要画一条线到点125,10然后从那里到点0,20,而Z意味着我们想要通过连接第一个和最后一个点来闭合路径。
然后,我们将路径的宽度和高度设置为在Data属性中声明的相同值。现在,使这个指针指向相关位置以反映绑定到Value属性的数据的必要部分是应用于路径的RenderTransform属性的RotateTransform对象。请注意,其中心点被设置为针的底部中心,因为这是我们想要旋转的点。
随着RotateTransform对象以增加的Angle值顺时针旋转,我们不能与它重用AngleValue依赖属性。因此,在这个特定的例子中,我们定义了指向右边的指针,并在RotationAngle只读依赖属性中使用180.0到360.0度的范围,与变换对象匹配值弧的位置。
在示例的末尾,我们看到另一个水平居中的TextBlock元素,它输出绑定到Value依赖属性的当前未修改的值。请注意,我们使用StringFormat的N0值在显示之前从值中删除小数位。
这样就完成了我们的新Gauge控件,因此我们现在需要做的是看看我们如何使用它:
<CustomControls:Gauge Width="400" Height="300"
MaximumValue="{Binding InCount}" Value="{Binding OutCount}"
Title="Support Tickets Cleared" Foreground="White" FontSize="34"
Padding="10" />
我们可以通过几种方式扩展我们的新Gauge控件,使其更易于使用。我们可以添加一个MinimumValue依赖属性,使其能够与不以零开始的值范围一起使用,或者我们可以公开更多的属性,使用户能够着色、调整大小或进一步自定义控件。或者,我们可以重写它,使其能够是任何大小,而不是像我们之前那样硬编码大小。
使 UI 控件生动起来
除了使我们的 UI 控件看起来视觉上吸引人之外,我们还可以通过添加鼠标悬停效果的用户交互来“使它们生动起来”。虽然大多数鼠标悬停效果是通过使用Trigger和Setter对象创建的,这些对象在满足相关触发条件时立即更新相关样式属性,但我们可以使用动画来产生这些效果。
在状态之间有细微的过渡,而不是立即切换,也可以提供更丰富的用户体验。让我们重用我们之前提到的初始双边框示例,并添加一些鼠标交互动画来演示这一点。我们需要将一些额外的资源添加到合适的资源集合中,并调整我们之前声明的几个资源:
<Color x:Key="TransparentWhiteColor">#7FFFFFFF</Color>
<Color x:Key="TransparentBlackColor">#7F000000</Color>
现在我们已经声明了半透明的Color资源,我们可以调整我们之前的画笔资源以利用它们:
<SolidColorBrush x:Key="TransparentWhite"
Color="{StaticResource TransparentWhiteColor}" />
<SolidColorBrush x:Key="TransparentBlack"
Color="{StaticResource TransparentBlackColor}" />
让我们现在查看我们的完整示例:
<Grid Width="160" Height="68">
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="Red" />
<GradientStop Color="Yellow" Offset="1" />
</LinearGradientBrush>
</Grid.Background>
<Button Content="Click Me" Width="120" Height="28" FontSize="14"
Margin="20">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Border Name="OuterBorder"
BorderBrush="{StaticResource TransparentBlack}"
BorderThickness="1" Padding="1"
Background="{StaticResource TransparentWhite}"
CornerRadius="5" SnapsToDevicePixels="True">
<Border Name="InnerBorder"
BorderBrush="{StaticResource TransparentBlack}"
BorderThickness="1" Background="White"
CornerRadius="3.5" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard Storyboard.TargetName="OuterBorder"
Storyboard.TargetProperty=
"BorderBrush.(SolidColorBrush.Color)">
<ColorAnimation To="Black" Duration="0:0:0.25" />
</Storyboard>
</BeginStoryboard>
<BeginStoryboard>
<Storyboard Storyboard.TargetName="InnerBorder"
Storyboard.TargetProperty=
"BorderBrush.(SolidColorBrush.Color)">
<ColorAnimation To="Black" Duration="0:0:0.3" />
</Storyboard>
</BeginStoryboard>
<BeginStoryboard Name="BackgroundFadeIn"
HandoffBehavior="Compose">
<Storyboard Storyboard.TargetName="InnerBorder"
Storyboard.TargetProperty=
"Background.(SolidColorBrush.Color)">
<ColorAnimation To="{StaticResource
TransparentWhiteColor}" Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard Storyboard.TargetName="OuterBorder"
Storyboard.TargetProperty=
"BorderBrush.(SolidColorBrush.Color)">
<ColorAnimation To="{StaticResource
TransparentBlackColor}" Duration="0:0:0.5" />
</Storyboard>
</BeginStoryboard>
<BeginStoryboard>
<Storyboard Storyboard.TargetName="InnerBorder"
Storyboard.TargetProperty=
"BorderBrush.(SolidColorBrush.Color)">
<ColorAnimation To="{StaticResource
TransparentBlackColor}" Duration="0:0:0.3" />
</Storyboard>
</BeginStoryboard>
<BeginStoryboard Name="BackgroundFadeOut"
HandoffBehavior="Compose">
<Storyboard Storyboard.TargetName="InnerBorder"
Storyboard.TargetProperty=
"Background.(SolidColorBrush.Color)">
<ColorAnimation To="White" Duration="0:0:0.4" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Trigger.EnterActions>
<BeginStoryboard Name="MouseDownBackground"
HandoffBehavior="Compose">
<Storyboard Storyboard.TargetName="InnerBorder"
Storyboard.TargetProperty=
"Background.(SolidColorBrush.Color)">
<ColorAnimation From="#D6FF21" Duration="0:0:1"
DecelerationRatio="1.0" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
<EventTrigger RoutedEvent="Unloaded">
<RemoveStoryboard BeginStoryboardName="BackgroundFadeIn" />
<RemoveStoryboard BeginStoryboardName="BackgroundFadeOut" />
<RemoveStoryboard BeginStoryboardName="MouseDownBackground" />
</EventTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
虽然这个例子可能看起来相当长,但实际上相当简单。我们以原始控件模板开始,尽管之前硬编码的画笔值被我们新定义的资源所取代。与原始示例的主要区别在于ControlTemplate.Triggers集合。
第一个触发器将在Button元素的IsMouseOver属性为真时启动其各种故事板,换句话说,当用户将鼠标光标移至按钮上时。我们的故事板在Trigger.EnterActions和Trigger.ExitActions集合之间分配。
记住,Trigger.EnterActions集合中的故事板将在鼠标进入按钮边界时启动,而Trigger.ExitActions集合中的故事板将在鼠标离开按钮边界时启动。我们在每个TriggerActionCollection对象内部声明了三个与相关Storyboard对象关联的BeginStoryboard对象。
第一个动画目标是OuterBorder元素的BorderBrush属性。请注意,此属性的类型为Brush,但在 WPF 中没有BrushAnimation类。因此,我们需要针对实际应用于此属性的SolidColorBrush的Color属性,并使用ColorAnimation对象。
为了做到这一点,我们需要使用间接目标首先引用BorderBrush属性,然后使用语法BorderBrush.(SolidColorBrush.Color)来链接到Color属性。请注意,这只有在实际上使用SolidColorBrush元素时才会起作用,正如在这个例子中一样。
然而,如果我们使用的是渐变画笔而不是SolidColorBrush元素,我们可以使用稍有不同的语法来针对其GradientStop元素的各个颜色。例如,我们可以这样针对渐变画笔中的第一个GradientStop元素的颜色:
BorderBrush.(GradientBrush.GradientStops)[0].(GradientStop.Color)
回到这个例子,第二个动画目标是InnerBorder元素的BorderBrush属性,并遵循第一个动画的语法示例。虽然第三个动画也使用间接目标来引用InnerBorder元素的Background属性,但它与其他两个动画略有不同。
对于这个动画,我们给BeginStoryboard对象命名为BackgroundFadeIn,并将其HandoffBehavior属性设置为Compose,以实现与其他动画之间的更平滑过渡。在示例中稍后将会使用指定的名称。
注意,这三个ColorAnimation对象只设置了它们的To和Duration属性,并且三个持续时间值略有不同。这会产生略微加厚效果,尽管同步时间也工作得很好。
我们省略了这些动画的From值,以避免当前动画颜色与From值不匹配的情况,不得不在动画到To值之前立即跳转到起始值。通过省略这些值,动画将从当前颜色值开始,并导致更平滑的过渡。
Trigger.ExitActions集合中的三个动画与EnterActions集合中的动画非常相似,尽管它们是将颜色动画回原始起始颜色,所以我们在这里可以跳过它们的解释。然而,值得注意的是,第三个动画也是在具有其HandoffBehavior属性设置为Compose的命名BeginStoryboard中声明的。
下一个Trigger对象将在Button元素的IsPressed属性为真时启动其关联的故事板,并且由于它是在EnterActions集合中声明的,它将在用户按下鼠标按钮时启动,而不是在其释放时。
这个动画也使用间接目标来引用InnerBorder元素的Background属性,并且还有一个具有其HandoffBehavior属性设置为Compose的命名BeginStoryboard对象。与其它动画不同,这个动画具有更长的持续时间,并且还将DecelerationRatio属性设置为1.0,这导致快速开始和缓慢结束。
最后,我们到达最后一个触发器,它是一个EventTrigger对象,当Button对象卸载时将被触发。在这个触发器中,我们移除了三个命名的故事板,从而释放了它们在使用Compose传递行为时消耗的额外资源。这就是为什么给三个引用Background属性的BeginStoryboard对象命名的原因。
当对按钮上的鼠标悬停效果进行动画处理时,我们不仅限于简单地更改背景和边框颜色。我们越有想象力,我们的应用程序就越能脱颖而出。
例如,而不是简单地更改按钮的背景颜色,我们可以用鼠标移动渐变的焦点。为此,我们需要使用一些代码,因此我们需要创建另一个自定义控件来演示这一点。让我们首先看看我们新自定义控件中的代码:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using CompanyName.ApplicationName.CustomControls.Enums;
namespace CompanyName.ApplicationName.CustomControls
{
[TemplatePart(Name = "PART_Root", Type = typeof(Grid))]
public class GlowButton : ButtonBase
{
private RadialGradientBrush glowBrush = null;
static GlowButton()
{
DefaultStyleKeyProperty.OverrideMetadata (typeof(GlowButton),
new FrameworkPropertyMetadata(typeof(GlowButton)));
}
public GlowMode GlowMode { get; set; } = GlowMode.FullCenterMovement;
public static readonly DependencyProperty GlowColorProperty =
DependencyProperty.Register(nameof(GlowColor), typeof(Color),
typeof(GlowButton), new PropertyMetadata(
Color.FromArgb(121, 71, 0, 255), OnGlowColorChanged));
public Color GlowColor
{
get { return (Color)GetValue(GlowColorProperty); }
set { SetValue(GlowColorProperty, value); }
}
private static void OnGlowColorChanged(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
((GlowButton)dependencyObject).SetGlowColor((Color)e.NewValue);
}
public override void OnApplyTemplate()
{
Grid rootGrid = GetTemplateChild("PART_Root") as Grid;
if (rootGrid != null)
{
rootGrid.MouseMove += Grid_MouseMove;
glowBrush =
(RadialGradientBrush)rootGrid.FindResource("GlowBrush");
SetGlowColor(GlowColor);
}
}
private void SetGlowColor(Color value)
{
GlowColor = Color.FromArgb(121, value.R, value.G, value.B);
if (glowBrush != null)
{
GradientStop gradientStop = glowBrush.GradientStops[2];
gradientStop.Color = GlowColor;
}
}
private void Grid_MouseMove(object sender, MouseEventArgs e)
{
Grid grid = (Grid)sender;
if (grid.IsMouseOver && glowBrush != null)
{
Point mousePosition = e.GetPosition(grid);
double x = mousePosition.X / ActualWidth;
double y = GlowMode != GlowMode.HorizontalCenterMovement ?
mousePosition.Y / ActualHeight : glowBrush.Center.Y;
glowBrush.Center = new Point(x, y);
if (GlowMode == GlowMode.HorizontalCenterMovement)
glowBrush.GradientOrigin =
new Point(x, glowBrush.GradientOrigin.Y);
else if (GlowMode == GlowMode.FullCenterMovement)
glowBrush.GradientOrigin = new Point(x, y);
}
}
}
}
我们像往常一样开始,通过添加相关引用并声明PART_RootGrid面板元素为TemplatePartAttribute属性中控制模板的必需部分。由于我们的自定义控件是一个按钮,我们扩展了ButtonBase类。
接下来,我们定义glowBrush字段并将其设置为null。在静态构造函数中,我们调用OverrideMetadata方法来通知框架我们的控件默认样式在哪里。然后我们声明一个类型为GlowMode的GlowMode CLR 属性并将其设置为默认的FullCenterMovement成员。现在让我们看看这个GlowMode枚举的成员:
namespace CompanyName.ApplicationName.CustomControls.Enums
{
public enum GlowMode
{
NoCenterMovement, HorizontalCenterMovement, FullCenterMovement
}
}
返回到我们的GlowButton类,我们还声明了一个GlowColor依赖属性,并定义了一个默认的紫色颜色,一个属性更改处理程序以及一些 CLR 属性包装器。在OnGlowColorChanged处理程序方法中,我们将dependencyObject输入参数转换为我们的GlowButton类,并调用SetGlowColor方法,传入新的Color输入值。
接下来,我们看到当按钮元素的控件模板被应用时调用的OnApplyTemplate方法。在这个方法中,我们尝试使用GetTemplateChild方法访问PART_Root面板元素并检查它是否为null。如果不是null,我们就会做很多事情。
首先,我们将Grid_MouseMove事件处理方法附加到网格的MouseMove事件上。请注意,这是将事件处理程序附加到在Generic.xaml文件中声明的 UI 元素的方法,因为它没有相关的后端代码文件。
接下来,我们调用网格的FindResource方法,以便从其Resources部分访问GlowBrush资源,并将其设置到我们的本地glowBrush字段中,因为我们将会经常引用它。之后,我们调用SetGlowColor方法并传入当前的GlowColor值。
我们这样做是因为OnApplyTemplate方法通常在属性设置之后被调用,但我们无法在模板应用之前更新画笔资源。在编写自定义控件时,我们经常需要在这个方法中更新属性,一旦模板被应用。
接下来是SetGlowColor方法,在其中我们首先将设置的颜色设置为半透明。如果glowBrush变量不是null,然后我们从其GradientStops集合中访问第三个GradientStop元素并将其Color属性设置为GlowColor属性的值。
注意,第三个GradientStop元素代表这个渐变中的主导颜色,因此在这个例子中,我们只更新这个单个元素,以节省这本书的空间。这给人一种整体颜色变化的印象,但任何仔细观察的人都会看到从另外两个未更改的GradientStop元素中透出的紫色。你可能希望扩展这个例子以更新整个GradientStops集合。
接下来,我们看到在OnApplyTemplate方法中附加到rootGrid变量的Grid_MouseMove事件处理方法。在其中,我们检查鼠标是否当前位于网格上,并且glowBrush变量不是null。如果这些条件成立,我们就在MouseEventArgs输入参数上调用GetPosition方法以获取鼠标的当前位置。
使用鼠标位置和当前的GlowMode属性值,我们确定移动模式并更新glowBrush字段Center和/或GradientOrigin属性的位置。
这使得当鼠标悬停在我们的发光按钮上时,渐变的中心点和/或焦点随着鼠标光标移动。现在让我们看看Generic.xaml文件中的 XAML:
<Style TargetType="{x:Type CustomControls:GlowButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type CustomControls:GlowButton}">
<Grid Name="PART_Root">
<Grid.Resources>
<RadialGradientBrush x:Key="GlowBrush"
RadiusY="0.622" Center="0.5,0.848"
GradientOrigin="0.5,0.818" RadiusX="1.5">
<RadialGradientBrush.RelativeTransform>
<ScaleTransform x:Name="ScaleTransform"
CenterX="0.5" CenterY="0.5" ScaleX="1.0" ScaleY="1.8" />
</RadialGradientBrush.RelativeTransform>
<GradientStop Color="#B9F6F2FF" />
<GradientStop Color="#A9F4EFFF" Offset="0.099" />
<GradientStop Color="{Binding GlowColor,
RelativeSource={RelativeSource AncestorType={x:Type
CustomControls:GlowButton}}}" Offset="0.608" />
<GradientStop Offset="1" Color="#004700FF" />
</RadialGradientBrush>
<RadialGradientBrush x:Key="LayeredButtonBackgroundBrush"
RadiusX="1.85" RadiusY="0.796" Center="1.018, -0.115"
GradientOrigin="0.65,-0.139">
<GradientStop Color="#FFCACACD" />
<GradientStop Color="#FF3B3D42" Offset="1" />
</RadialGradientBrush>
<LinearGradientBrush x:Key="LayeredButtonCurveBrush"
StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF747475" Offset="0" />
<GradientStop Color="#FF3B3D42" Offset="1" />
</LinearGradientBrush>
<Grid x:Key="LayeredButtonBackgroundElements">
<Rectangle
Fill="{StaticResource LayeredButtonBackgroundBrush}" />
<Path StrokeThickness="0"
Fill="{StaticResource LayeredButtonCurveBrush}">
<Path.Data>
<CombinedGeometry GeometryCombineMode="Intersect">
<CombinedGeometry.Geometry1>
<EllipseGeometry Center="-20,50.7" RadiusX="185"
RadiusY="46" />
</CombinedGeometry.Geometry1>
<CombinedGeometry.Geometry2>
<RectangleGeometry Rect="0,0,106,24" />
</CombinedGeometry.Geometry2>
</CombinedGeometry>
</Path.Data>
</Path>
</Grid>
<VisualBrush x:Key="LayeredButtonBackground"
Visual="{StaticResource LayeredButtonBackgroundElements}" />
</Grid.Resources>
<Border CornerRadius="3" BorderBrush="#7F000000"
BorderThickness="1" Background="#7FFFFFFF"
SnapsToDevicePixels="True">
<Border CornerRadius="2" Margin="1"
Background="{StaticResource LayeredButtonBackground}"
SnapsToDevicePixels="True">
<Grid>
<Rectangle x:Name="Glow" IsHitTestVisible="False"
RadiusX="2" RadiusY="2"
Fill="{StaticResource GlowBrush}" Opacity="0" />
<ContentPresenter Content="{TemplateBinding Content}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</Border>
<Grid.Triggers>
<EventTrigger RoutedEvent="MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Glow"
Storyboard.TargetProperty="Opacity" To="1.0"
Duration="0:0:0.5" DecelerationRatio="1" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Glow"
Storyboard.TargetProperty="Opacity" To="0.0"
Duration="0:0:1" DecelerationRatio="1" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="MouseDown">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="ScaleTransform"
Storyboard.TargetProperty="ScaleX" From="10.0"
To="1.0" Duration="0:0:0.15" AccelerationRatio="0.5" />
<DoubleAnimation Storyboard.TargetName="ScaleTransform"
Storyboard.TargetProperty="ScaleY" From="10.0"
To="1.8" Duration="0:0:0.15" AccelerationRatio="0.5" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Grid.Triggers>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
在这个ControlTemplate内部,我们看到名为PART_Root的Grid,在其内部,我们看到在其Resources部分声明了许多资源。大部分这个 XAML 都是我们用于分层按钮背景示例中的相同资源,因此我们可以跳过它们的解释。
然而,有一个新的资源类型为RadialGradientBrush,命名为GlowBrush。这是将颜色放入我们的按钮的画笔。特别是,请注意,它的RelativeTransform属性被设置为名为ScaleTransform的ScaleTransform元素,并且它的第三个GradientStop对象与我们的控件中的GlowColor属性数据绑定。
在实际模板中,我们看到我们的双Border元素,它们的SnapsToDevicePixels属性被设置为 true 以确保清晰的渲染图像。同样,外边框的CornerRadius值比内边框大,以确保它们紧密地结合在一起,内边框的背景是用我们之前看到的LayeredButtonBackground视觉画笔绘制的。
在内部边框内,我们有一个包含一个Rectangle元素和所需的ContentPresenter对象的Grid面板。我们使用GlowBrush资源来绘制矩形的背景,并将其IsHitTestVisible属性设置为false,这样它就不会参与用户交互。请注意,在这个例子中,我们将它的Opacity属性设置为零,使其最初不可见。
我们将按钮的Content和Padding属性分别数据绑定到ContentPresenter元素的Content和Margin属性,并在控件内居中。这样就完成了我们的发光按钮的视觉标记,现在,我们到达了至关重要的Grid.Triggers集合,在这里我们声明了三个EventTrigger对象来触发我们的鼠标悬停效果。
第一个触发器在MouseEnter事件被触发时开始其相关的故事板。它相关的DoubleAnimation对象将“发光”矩形的Opacity属性在半秒内动画化到1.0。请注意,我们在这里省略了From属性,这样Opacity值将从一个当前值开始动画化,而不是每次动画开始时跳回到0.0。
第二个触发器在MouseLeave事件被触发时开始其故事板。它的DoubleAnimation对象将矩形的Opacity属性在整整一秒内动画化回0.0。请注意,我们在这里省略了From属性,这样Opacity值将从一个当前值开始动画化,而不是每次动画开始时跳转到1.0。这确保了更平滑的过渡。
第三个触发器在MouseDown事件被触发时开始其故事板,并包含两个DoubleAnimation对象。它们将ScaleTransform对象的ScaleX和ScaleY属性从10.0动画化到它们的正常值,在 150 毫秒内完成,当用户点击按钮时会产生有趣的效果。
使用GlowColor和GlowMode属性,我们可以产生广泛的按钮和交互效果。在我们在视图中定义了相关的 XAML 命名空间之后,我们可以以下这种方式使用这个发光按钮示例:
<CustomControls:GlowButton Content="Glowing button"
GlowMode="NoCenterMovement" GlowColor="Red" FontSize="28"
Foreground="White" Height="60" Width="275" />
当我们的示例运行时,它可以产生鼠标悬停效果,这些效果根据鼠标光标的位置而变化,如下面的示例所示:

左上角的按钮展示了HorizontalCenterMovement模式,右上角显示了FullCenterMovement模式,底部两个按钮在NoCenterMovement模式下突出了鼠标的位置。顶部两个使用默认颜色,底部两个使用GlowColor的Red进行渲染。这揭示了我们在示例中各种GlowMode值之间的差异。
摘要
在本章中,我们探讨了多种技术,这些技术可以帮助我们改善应用程序的外观,从简单地添加阴影到实现更复杂的分层视觉效果。我们看到了在整个应用程序中保持一致性的重要性,以及如何获得专业的外观。
我们随后探讨了使我们的应用程序脱颖而出的高级技术,并进一步展示了如何创建各种自定义控件。最后,我们探讨了如何将动画融入我们的日常控件中,以给我们的应用程序带来一种独特感。
在下一章中,我们将探讨多种验证应用程序中数据的方法。我们将检查 WPF 中可用的各种验证接口,并致力于通过使用数据注释扩展我们的应用程序框架,以实现一个完整的验证系统。
第九章:实现响应式数据验证
数据验证与数据输入表单密切相关,对于促进清洁、可用的数据至关重要。虽然 WPF 中的 UI 控件可以自动验证输入的值是否与它们的数据绑定属性的类型匹配,但它们无法验证输入数据的正确性。
例如,一个绑定到整数的 TextBox 控件如果用户输入了非数字值,可能会突出显示错误,但它不会验证输入的数字是否具有正确的位数,或者前四位数字是否适合指定的信用卡类型。
为了在使用 MVVM 时验证这些类型的数据正确性,我们需要实现 .NET 验证接口之一。在本章中,我们将详细检查可用的接口,查看多个实现,并探索 WPF 提供的其他与验证相关的功能。让我们首先看看验证系统。
在 WPF 中,验证系统很大程度上围绕静态的 Validation 类。这个类有几个附加属性、方法和一个附加事件,支持数据验证。每个绑定实例都有一个 ValidationRules 集合,可以包含 ValidationRule 元素。
WPF 提供了三个内置规则:
-
ExceptionValidationRule对象检查在更新绑定源属性时抛出的任何异常。 -
DataErrorValidationRule类检查实现IDataErrorInfo接口的类可能引发的错误。 -
NotifyDataErrorValidationRule类检查实现INotifyDataErrorInfo接口的类引发的错误。
每次尝试更新数据源属性时,绑定引擎首先清除 Validation.Errors 集合,然后检查绑定的 ValidationRules 集合,看它是否包含任何 ValidationRule 元素。如果包含,它会依次调用每个规则的 Validate 方法,直到所有规则都通过,或者其中一个返回错误。
当数据绑定值在 ValidationRule 元素的 Validation 方法中失败条件时,绑定引擎会将一个新的 ValidationError 对象添加到数据绑定目标控件的 Validation.Errors 集合中。
这将使元素的 Validation.HasError 附加属性设置为 true,并且如果绑定的 NotifyOnValidationError 属性设置为 true,绑定引擎还会在数据绑定目标上引发 Validation.Error 附加事件。
使用验证规则——做还是不做?
在 WPF 中,处理数据验证有两种不同的方法。一方面,我们有基于 UI 的 ValidationRule 类、Validation.Error 附加事件以及 Binding.NotifyOnValidationError 和 UpdateSourceExceptionFilter 属性,另一方面,我们还有两个基于代码的验证接口。
虽然ValidationRule类及其相关验证方法工作得非常完美,但它们是在 XAML 中指定的,因此与 UI 绑定。此外,当使用ValidationRule类时,我们实际上是将验证逻辑从它们验证的数据模型中分离出来,并将其存储在完全不同的程序集中。
当使用 MVVM 方法开发 WPF 应用程序时,我们与数据而不是 UI 元素一起工作,因此我们往往避免直接使用ValidationRule类及其相关的验证策略。
此外,Binding类的NotifyOnValidationError和UpdateSourceExceptionFilter属性也需要事件或委托处理程序,正如我们所发现的,我们更喜欢在 MVVM 中使用时避免这样做。因此,我们不会在本书中探讨基于 UI 的验证方法,而是专注于两个基于代码的验证接口。
掌握验证接口
在 WPF 中,我们有访问两个主要验证接口的能力;原始的一个是IDataErrorInfo接口,而在.NET 4.5 中,添加了INotifyDataErrorInfo接口。在本节中,我们将首先研究原始验证接口及其不足,并看看我们如何使其更易于使用,然后再检查后者。
实现 IDataErrorInfo 接口
IDataErrorInfo接口非常简单,只需要实现两个必需的属性。Error属性返回描述验证错误的错误消息,而Item[string]索引器返回指定属性的错误消息。
这看起来足够简单,那么让我们看看这个接口的基本实现。让我们创建另一个基类来实现这个接口,并且现在省略所有其他无关的基类成员,以便我们可以专注于这个接口:
using System.ComponentModel;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
public abstract class BaseValidationModel : INotifyPropertyChanged,
IDataErrorInfo
{
protected string error = string.Empty;
#region IDataErrorInfo Members
public string Error => error;
public virtual string this[string propertyName] => error;
#endregion
#region INotifyPropertyChanged Members
...
#endregion
}
}
在这个最简单的实现中,我们声明了一个受保护的error字段,它将被派生类访问。请注意,返回它的Error属性使用了 C# 6.0 表达式主体属性语法。这种语法是方法的简写表示法,其中成员体被内联表达式替换。
我们已将类索引器(this属性)声明为virtual,这样我们就可以在派生类中重写它。另一个选择是将它声明为abstract,这样派生类就必须重写它。您是否更喜欢使用virtual或abstract将取决于您的具体情况,例如,您是否期望每个派生类都需要验证。
让我们看看一个从我们的新基类派生出的类的示例:
using System;
namespace CompanyName.ApplicationName.DataModels
{
public class Product : BaseValidationModel
{
private Guid id = Guid.Empty;
private string name = string.Empty;
private decimal price = 0;
public Guid Id
{
get { return id; }
set { if (id != value) { id = value; NotifyPropertyChanged(); } }
}
public string Name
{
get { return name; }
set { if (name != value) { name = value; NotifyPropertyChanged(); } }
}
public decimal Price
{
get { return price; }
set { if (price != value) { price = value;
NotifyPropertyChanged(); } }
}
public override string this[string propertyName]
{
get
{
error = string.Empty;
if (propertyName == nameof(Name))
{
if (string.IsNullOrEmpty(Name))
error = "Please enter the product name.";
else if (Name.Length > 25) error = "The product name cannot be
longer than twenty-five characters.";
}
else if (propertyName == nameof(Price) && Price == 0)
error = "Please enter a valid price for the product.";
return error;
}
}
}
}
在这里,我们有一个基本的Product类,它扩展了我们的新基类。每个想要参与验证过程的派生类需要做的唯一工作就是重写类索引器,并提供有关其相关验证逻辑的详细信息。
在索引器中,我们首先将error字段设置为空字符串。请注意,这是此实现的一个基本部分,因为没有它,任何触发的验证错误永远不会被清除。有几种方法可以实现此方法,有几种不同的抽象是可能的。然而,所有实现都需要在调用此属性时运行验证逻辑。
在我们的特定示例中,我们简单地使用一个if语句来检查每个属性的错误,尽管在这里switch语句也适用。第一个条件检查propertyName输入参数的值,而每个属性的多个验证规则可以通过内部的if语句来处理。
如果propertyName输入参数等于Name,那么我们首先检查确保它有一些值,并在失败的情况下提供错误消息。如果属性值不是null或空,那么第二个验证条件检查长度是否不超过 25 个字符,这模拟了我们可能有的特定数据库约束。
如果propertyName输入参数等于Price,那么我们只需检查是否输入了一个有效且正的值,并在失败的情况下提供另一个错误消息。如果我们在这个类中有更多的属性,那么我们只需添加更多的if条件,检查它们的属性名,并进行进一步的验证检查。
现在我们已经有了可验证的类,让我们在App.xaml文件中添加一个新的视图和视图模型以及DataTemplate,以连接这两个组件,展示我们还需要做什么才能将验证逻辑连接到 UI 中的数据。让我们首先看看ProductViewModel类:
using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.ViewModels
{
public class ProductViewModel : BaseViewModel
{
private Product product = new Product();
public Product Product
{
get { return product; }
set { if (product != value) { product = value;
NotifyPropertyChanged(); } }
}
}
}
ProductViewModel类简单地定义了一个单个的Product对象,并通过Product属性公开它。现在让我们向应用程序资源文件中添加一些基本样式,我们将在相关的视图中使用这些样式:
<Style x:Key="LabelStyle" TargetType="{x:Type TextBlock}">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,10,10" />
</Style>
<Style x:Key="FieldStyle" TargetType="{x:Type TextBox}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="Padding" Value="1.5,2" />
</Style>
现在,让我们看看视图:
<UserControl x:Class="CompanyName.ApplicationName.Views.ProductView"
Width="320" FontSize="14">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock Text="Name" Style="{StaticResource LabelStyle}" />
<TextBox Grid.Column="1" Text="{Binding Product.Name,
UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Style="{StaticResource FieldStyle}" />
<TextBlock Grid.Row="1" Text="Price"
Style="{StaticResource LabelStyle}" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Product.Price,
UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Style="{StaticResource FieldStyle}" />
</Grid>
</UserControl>
在 XAML 中,我们有一个典型的两列Grid面板,有两行。两个TextBlock标签应用了LabelStyle样式,两个TextBox输入控件应用了FieldStyle样式。应用于每个TextBox.Text属性绑定的有两个重要属性被设置。
第一个是UpdateSourceTrigger属性,它控制数据源何时更新,因此也控制验证何时发生。如果你还记得,PropertyChanged的值会导致数据绑定属性值变化时立即发生更新。另一个值可以是LostFocus,它会在 UI 控件失去焦点时(例如,在切换到下一个控件时)导致更新。
这里另一个重要的属性是 ValidatesOnDataErrors 属性,没有它,我们的当前示例将无法工作。将此属性设置为 True 在绑定上会导致内置的 DataErrorValidationRule 元素隐式添加到 Binding.ValidationRules 集合中。
当数据绑定值发生变化时,此元素将检查由 IDataErrorInfo 接口引发的错误。它是通过每次数据源更新时调用我们数据模型中的索引器,并使用数据绑定属性名称来完成的。因此,在这个基本示例中,开发者将负责在每次绑定时将此属性设置为 True 以使验证生效。
在 .NET 4.5 中,微软对当 UpdateSourceTrigger 绑定设置为 PropertyChanged 时 TextBox 控件中输入数值数据的方式引入了一个破坏性更改。他们的更改阻止用户输入数值分隔符。请参阅本章后面的 与旧行为保持同步 部分,以了解原因和如何解决这个问题。
当使用 PropertyChanged 作为 UpdateSourceTrigger 属性的值,并且每次属性更改时都进行验证时,我们就有立即更新错误的优点。然而,这种验证方法是以预防性方式进行的,所有验证错误都会在用户有机会输入任何数据之前显示出来。这可能会让用户感到有些不快,所以让我们快速看一下示例程序启动时的样子:

如您所见,很明显存在一些问题,但尚不清楚它们是什么。到目前为止,我们还没有输出错误消息。我们可以使用的一个常见输出是各种表单控件的工具提示。
我们可以向我们的 FieldStyle 样式添加一个触发器,该触发器监听 Validation.HasError 附加属性,并在出现错误时将 TextBox 控件的工具提示设置为错误的 ErrorContent 属性。这就是微软在他们的网站上传统上展示如何做到这一点的方式:
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="ToolTip" Value="{Binding (Validation.Errors)[0].
ErrorContent, RelativeSource={RelativeSource Self}}" />
</Trigger>
</Style.Triggers>
注意,我们在 Validation.Errors 集合的绑定路径中使用方括号,因为它是一个附加属性,我们使用 RelativeSource.Self 实例,因为我们想针对 TextBox 控件本身的 Errors 集合。此外,请注意,此示例仅显示 Errors 集合中的第一个 ValidationError 对象。
在我们的数据绑定 TextBox 控件上使用此样式有助于在用户将鼠标光标置于相关控件上时提供更多信息:

然而,当没有要显示的验证错误时,Visual Studio 的输出窗口中会出现错误,因为我们正在尝试从 Validation.Errors 附加属性集合中查看第一个错误,但不存在:
System.Windows.Data Error: 17 : Cannot get 'Item[]' value (type 'ValidationError') from '(Validation.Errors)' (type 'ReadOnlyObservableCollection`1'). BindingExpression:
Path=(Validation.Errors)[0].ErrorContent; DataItem='TextBox' (Name='');
target element is 'TextBox' (Name=''); target property is 'ToolTip' (type 'Object') ArgumentOutOfRangeException: 'System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
Parameter name: index'
有许多方法可以避免这个错误,例如简单地显示整个集合,我们将在本章后面看到这个示例。然而,最简单的方法是利用隐式用于包装IEnumerable数据集合的ICollectionView对象的CurrentItem属性,这些数据集合绑定到ItemsControl元素。
这与ListBox隐式地将我们的数据绑定数据项包装在ListBoxItem元素中的方式类似。包装我们的数据集合的ICollectionView接口的实现主要用于启用排序、过滤和分组数据,而不影响实际数据,但在这个情况下,它的CurrentItem属性是一个额外的优势。
因此,我们可以替换掉在无验证错误时给我们造成问题的索引器。现在,当没有错误时,CurrentItem属性将返回null,而不是抛出异常,因此,尽管微软自己的示例展示了索引器的使用,但这仍然是一个更好的解决方案:
<Setter Property="ToolTip" Value="{Binding (Validation.Errors).
CurrentItem.ErrorContent, RelativeSource={RelativeSource Self}}" />
然而,如果最终用户不知道需要将鼠标光标放在控件上才能看到工具提示,那么情况仍然没有得到改善。因此,这种初始实现仍有改进的空间。这个界面的另一个缺点是它被设计成原子的,因此它一次只处理一个属性的单一错误。
在我们的Product类示例中,我们想要验证Name属性不仅被输入,而且具有有效的长度。按照我们为该属性声明的两个验证条件的顺序,当 UI 中的字段为空时,将首先引发第一个错误,如果输入的值过长,将引发第二个错误。由于输入的值不能同时不存在且过长,所以在这种特定情况下,一次只报告一个错误并不成问题。
然而,如果我们有一个具有多个验证条件(如最大长度和特定格式)的属性,那么使用通常的IDataErrorInfo接口实现,我们一次只能查看这些错误中的一个。然而,尽管有这个限制,我们仍然可以改进这个基本实现。让我们看看我们如何使用一个新的基类来做到这一点:
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
public abstract class BaseValidationModelExtended :
INotifyPropertyChanged, IDataErrorInfo
{
protected ObservableCollection<string> errors =
new ObservableCollection<string>();
protected ObservableCollection<string> externalErrors =
new ObservableCollection<string>();
protected BaseValidationModelExtended()
{
ExternalErrors.CollectionChanged += ExternalErrors_CollectionChanged;
}
public virtual ObservableCollection<string> Errors => errors;
public ObservableCollection<string> ExternalErrors => externalErrors;
public virtual bool HasError => errors != null && errors.Any();
#region IDataErrorInfo Members
public string Error
{
get
{
if (!HasError) return string.Empty;
StringBuilder errors = new StringBuilder();
Errors.ForEach(e => errors.AppendUniqueOnNewLineIfNotEmpty(e));
return errors.ToString();
}
}
public virtual string this[string propertyName] => string.Empty;
#endregion
#region INotifyPropertyChanged Members
public virtual event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(
params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
if (propertyName != nameof(HasError)) PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasError)));
}
}
protected virtual void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
if (PropertyChanged != null)
{
if (propertyName != nameof(HasError)) PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasError)));
}
}
#endregion
private void ExternalErrors_CollectionChanged(object sender,
NotifyCollectionChangedEventArgs e) =>
NotifyPropertyChanged(nameof(Errors));
}
}
在这个示例中,我们添加了两个集合来存储错误消息;Errors集合属性包含在派生类中生成的验证错误,而ExternalErrors集合属性包含外部生成的验证错误,通常来自父视图模型。
在构造函数中,我们将ExternalErrors_CollectionChanged事件处理程序附加到ExternalErrors集合属性的CollectionChanged事件上,以便在向其中添加或从中删除项目时得到通知。
在错误集合属性的声明之后,我们看到 HasError 表达式体的属性,该属性检查 Errors 集合是否包含任何错误。请注意,我们检查 errors 字段是否为 null,而不是 Errors 属性,因为调用 Errors 属性会重新生成错误消息,而我们不希望在每次调用 HasError 属性时都重新生成它们两次。
接下来,我们看到 IDataErrorInfo 接口的新实现。类索引器与之前实现中的相同,但我们看到 Error 属性的定义有所不同,现在它编译了一个完整的错误列表,而不是一次返回一个错误消息。
在其中,我们首先检查是否存在任何错误,如果不存在则返回一个空字符串。如果存在错误,我们初始化一个 StringBuilder 对象,并使用我们的 ForEach 扩展方法遍历 Errors 集合,并将它们中的每一个(如果它们尚未被包含)追加到其中。我们使用另一个扩展方法在返回输出之前这样做,所以现在让我们看看它是什么样子:
public static void AppendUniqueOnNewLineIfNotEmpty(
this StringBuilder stringBuilder, string text)
{
if (text.Trim().Length > 0 && !stringBuilder.ToString().Contains(text))
stringBuilder.AppendFormat("{0}{1}", stringBuilder.ToString().Trim().
Length == 0 ? string.Empty : Environment.NewLine, text);
}
在我们的 AppendUniqueOnNewLineIfNotEmpty 扩展方法中,我们首先检查输入值不是一个空字符串,并且它还没有存在于 StringBuilder 对象中。如果 text 输入参数有效,我们使用三元运算符来确定它是否是第一个要添加的值,以及是否需要在添加新的唯一值之前添加一个新行,然后再添加新的唯一值。
现在回到我们的验证基类,我们看到 INotifyPropertyChanged 接口的新实现。请注意,我们通过为任何其他属性的更改注册引发 PropertyChanged 事件来重复我们之前的 BaseSynchronizableDataModel 类示例,但与之前的示例不同,我们在这次中引发的是 HasError 属性,而不是 HasChanges 属性。
如果我们愿意,我们可以将这两个结合起来,并在接收到其他属性更改的通知时为这两个属性各自引发 PropertyChanged 事件。在这种情况下,目的是调用 HasError 属性,该属性将在 UI 中用于显示或隐藏显示错误消息的控件,因此它将在每次有效的属性更改后更新。
在我们班级的底部,我们可以看到表达式体的 ExternalErrors_CollectionChanged 方法,该方法为 Errors 集合属性调用 NotifyPropertyChanged 方法。这会通知绑定到该属性的数据绑定控件,其值已更改,并且它们应该检索新的值。
让我们看看现在的一个示例实现,使用我们 Product 类的扩展版本:
public class ProductExtended : BaseValidationModelExtended
{
...
public override ObservableCollection<string> Errors
{
get
{
errors = new ObservableCollection<string>();
errors.AddUniqueIfNotEmpty(this[nameof(Name)]);
errors.AddUniqueIfNotEmpty(this[nameof(Price)]);
errors.AddRange(ExternalErrors);
return errors;
}
}
...
}
因此,当外部错误被添加到 ExternalErrors 集合时,将调用 ExternalErrors_CollectionChanged 方法,并通知 Errors 属性的更改。这导致属性被调用,外部错误(错误)被添加到内部的 errors 集合中,以及任何内部错误。
为了使这个特定的 IDataErrorInfo 接口实现生效,每个数据模型类都需要重写这个 Errors 属性,以添加每个已验证属性的错误消息。我们提供了一些扩展方法来简化这项任务。正如其名称所暗示的,AddUniqueIfNotEmpty 方法在字符串已存在于集合中时不会添加它们:
public static void AddUniqueIfNotEmpty(
this ObservableCollection<string> collection, string text)
{
if (!string.IsNullOrEmpty(text) && !collection.Contains(text))
collection.Add(text);
}
AddRange 方法是另一个有用的扩展方法,它简单地遍历输入参数 range 集合,并将它们逐个添加到 collection 参数中:
public static void AddRange<T>(this ICollection<T> collection,
ICollection<T> range)
{
foreach (T item in range) collection.Add(item);
}
除了在派生类中实现这个新的 Errors 集合属性外,开发者还需要确保每次一个可验证的属性值发生变化时,都要通知对其的更改。我们可以通过使用接受多个值的 NotifyPropertyChanged 方法重载来完成此操作:
public string Name
{
get { return name; }
set { if (name != value) { name = value;
NotifyPropertyChanged(nameof(Name), nameof(Errors)); } }
}
public decimal Price
{
get { return price; }
set { if (price != value) { price = value;
NotifyPropertyChanged(nameof(Price), nameof(Errors)); } }
}
Errors 属性负责调用具有我们想要验证的每个属性的名称的类索引器。然后,将返回的错误消息(包括来自 ExternalErrors 集合属性的消息)添加到内部的 errors 集合中。
实际上,我们在数据模型中复制了 Validation 类和 DataErrorValidationRule 元素在 UI 中所做的工作。这意味着我们不再需要在每个绑定上设置 ValidatesOnDataErrors 属性为 True。当使用 MVVM 时,这是一个更好的解决方案,因为我们更愿意与数据而不是 UI 元素一起工作,现在我们也可以完全访问我们视图模型中的所有数据验证错误。
此外,我们现在有了通过 ExternalErrors 集合属性手动将错误消息从我们的视图模型传递到数据模型的能力。当我们需要跨一组数据模型对象进行验证时,这非常有用。
例如,如果我们需要确保每个数据模型对象的名字在相关对象集合中是唯一的,我们可以使用这个功能。现在让我们创建一个新的 ProductViewModelExtended 类来查看我们如何实现这一点:
using System;
using System.ComponentModel;
using System.Linq;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.DataModels.Collections;
namespace CompanyName.ApplicationName.ViewModels
{
public class ProductViewModelExtended : BaseViewModel
{
private ProductsExtended products = new ProductsExtended();
public ProductViewModelExtended()
{
Products.Add(new ProductExtended() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset", Price = 14.99m });
Products.Add(new ProductExtended() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset" });
Products.CurrentItemChanged += Products_CurrentItemChanged;
Products.CurrentItem = Products.Last();
ValidateUniqueName(Products.CurrentItem);
}
public ProductsExtended Products
{
get { return products; }
set { if (products != value) { products = value;
NotifyPropertyChanged(); } }
}
private void Products_CurrentItemChanged(
ProductExtended oldProduct, ProductExtended newProduct)
{
if (newProduct != null)
newProduct.PropertyChanged += Product_PropertyChanged;
if (oldProduct != null)
oldProduct.PropertyChanged -= Product_PropertyChanged;
}
private void Product_PropertyChanged(object sender,
PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Products.CurrentItem.Name))
ValidateUniqueName(Products.CurrentItem);
}
private void ValidateUniqueName(ProductExtended product)
{
string errorMessage = "The product name must be unique.";
if (!IsProductNameUnique(product))
product.ExternalErrors.Add(errorMessage);
else product.ExternalErrors.Remove(errorMessage);
}
private bool IsProductNameUnique(ProductExtended product) =>
!Products.Any(p => p.Id != product.Id &&
!string.IsNullOrEmpty(p.Name) && p.Name == product.Name);
}
}
与 ProductViewModel 类类似,我们的 ProductViewModelExtended 类也扩展了 BaseViewModel 类,但它声明了一个 ProductsExtended 集合,并在构造函数中添加了两个 ProductExtended 对象,而不是之前使用的单个 Product 实例。ProductsExtended 类简单地扩展了我们的 BaseCollection 类:
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class ProductsExtended : BaseCollection<ProductExtended> { }
}
在类构造函数中,我们首先向 ProductsExtended 集合中添加几个测试产品,然后将 Products_CurrentItemChanged 方法附加到其 CurrentItemChanged 代理上。为了将第二个项目设置为当前项目,我们在 ProductsExtended 集合上调用 Last 方法,并将其设置为 CurrentItem 属性。
这确保了在将第二个项目设置为当前项目时调用 Products_CurrentItemChanged 方法,并且 Product_PropertyChanged 处理器被附加到它上。之后,我们调用稍后描述的 ValidateUniqueName 方法,传入当前项目。
在 Products 属性声明之后,我们看到 Products_CurrentItemChanged 方法,该方法将在 CurrentItem 属性的值每次更改时被调用。在其中,我们将 Product_PropertyChanged 方法附加到新的、当前的 ProductExtended 对象的 PropertyChanged 事件上,并从先前的对象中分离出来。
Product_PropertyChanged 方法将在相关 ProductExtended 对象的任何属性发生变化时被调用。如果发生变化的属性是 Name 属性,我们将调用 ValidateUniqueName 方法,因为这是我们需要验证唯一性的属性。
ValidateUniqueName 方法负责从 product 输入参数的 ExternalErrors 集合属性中添加或删除错误。它是通过检查 IsProductNameUnique 方法的结果来实现的,该方法执行实际的唯一性检查。
在表达式体的 IsProductNameUnique 方法中,我们使用 LINQ 查询 Products 集合,以确定是否存在具有相同名称的现有项目。这是通过检查每个项目不具有相同的识别号,换句话说,不是正在编辑的对象,但确实具有相同的名称,并且该名称不是空字符串来实现的。
如果找到任何具有相同名称的其他产品,则该方法返回 false,并在 ValidateUniqueName 方法中将错误添加到产品的 ExternalErrors 集合中。请注意,如果发现名称是唯一的,我们必须手动删除此错误。
现在,让我们创建一个新的 ProductViewExtended 类,以更好地显示这些错误。首先,让我们向应用程序资源文件中添加另一个可重用资源:
<DataTemplate x:Key="WrapTemplate">
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
</DataTemplate>
此 DataTemplate 简单地显示一个 TextBlock 控件,其 Text 属性绑定到 DataTemplate 的数据上下文,并且其 TextWrapping 属性设置为 Wrap,这具有将不适应提供的宽度的文本包装起来的效果。现在,让我们看看使用此模板的新 ProductViewExtended 类:
<UserControl x:Class="CompanyName.ApplicationName.Views.ProductViewExtended"
Width="600" FontSize="14">
<Grid Margin="20">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding
Products.CurrentItem}" DisplayMemberPath="Name" Margin="0,0,20,0" />
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border Grid.ColumnSpan="2" BorderBrush="Red" BorderThickness="2"
Background="#1FFF0000" CornerRadius="5" Visibility="{Binding
Products.CurrentItem.HasError, Converter={StaticResource
BoolToVisibilityConverter}}" Margin="0,0,0,10" Padding="10">
<ItemsControl ItemsSource="{Binding Products.CurrentItem.Errors}"
ItemTemplate="{StaticResource WrapTemplate}" />
</Border>
<TextBlock Grid.Row="1" Text="Name"
Style="{StaticResource LabelStyle}" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding
Products.CurrentItem.Name, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldStyle}" />
<TextBlock Grid.Row="2" Text="Price"
Style="{StaticResource LabelStyle}" />
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price, Delay=250,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldStyle}" />
</Grid>
</Grid>
</UserControl>
在这个示例中,我们现在有一个具有两列的Grid面板。在左侧列中,我们有一个ListBox控件,而在右侧列中,我们有一个包含我们的表单字段的另一个Grid面板。ListBox控件的ItemsSource属性绑定到我们的视图模型中的Products集合属性,并且SelectedItem属性绑定到其CurrentItem属性。
我们将DisplayMemberPath属性设置为Name,以输出每个产品的名称,作为为我们的Product类创建DataTemplate的快捷方式。或者,我们也可以从Product类中的ToString方法返回Name属性的值以实现相同的视觉效果,尽管这样当属性值更改时,UI 界面不会更新。
在右侧的Grid面板中,我们声明了三行,在顶部的一行中,我们定义了一个包含ItemsControl对象的Border元素。它的ItemsSource属性绑定到设置在Products集合的CurrentItem属性上的项的Errors集合属性,并且其ItemTemplate属性设置为我们的新WrapTemplate数据模板。边框的Visibility属性通过应用程序资源中的BoolToVisibilityConverter实例绑定到项的HasError属性。
因此,当对项的验证属性进行更改并引发我们的验证基类中的错误时,PropertyChanged事件会为HasError属性引发,这会提醒此绑定检查最新值并通过应用的BoolToVisibilityConverter实例相应地更新其可见性值。
注意,我们在这里使用ItemsControl,因为使用这个集合,我们不需要ListBox控件提供的额外功能,例如边框或选中项的概念。错误输出下面的两行包含来自ProductView示例的表单字段。
当这个示例运行时,我们将在我们的ListBox控件中看到两个具有相同名称的项。因此,将已经显示一个验证错误,突出显示这一事实,并且这是通过在视图模型中的ExternalErrors集合中添加来实现的。
此外,我们还将看到一个错误,突出显示需要输入有效价格的事实:

由于字段绑定的UpdateSourceTrigger属性已被设置为PropertyChanged,并且数据绑定的属性会立即进行验证,因此当我们输入相关表单字段时,错误会立即消失或重新出现。这种设置,加上我们每次属性更改时都会进行验证的事实,使得我们的验证工作以预防性方式进行。
我们也可以通过将 UpdateSourceTrigger 属性设置为 Explicit 值来修改此行为,使其仅在用户按下提交按钮时生效。然而,这要求我们在代码背后文件中访问数据绑定控件,因此在使用 MVVM 方法时,我们倾向于避免这种方法:
BindingExpression bindingExpression =
NameOfTextBox.GetBindingExpression(TextBox.TextProperty);
bindingExpression.UpdateSource();
或者,如果我们想在 MVVM 中以这种方式进行验证,我们可以在绑定到提交或保存按钮的命令执行时简单地调用验证代码。现在,让我们看看 INotifyDataErrorInfo 接口,看看它与 IDataErrorInfo 接口有何不同。
介绍 INotifyDataErrorInfo 接口
INotifyDataErrorInfo 接口是在 .NET 4.5 中添加到 .NET Framework 中的,以解决对先前 IDataErrorInfo 接口的问题。与 IDataErrorInfo 接口一样,INotifyDataErrorInfo 接口也是一个简单的事情,我们只需要实现三个成员。
通过这个接口,我们现在有一个 HasErrors 属性,它表示相关数据模型实例是否有任何错误,一个 GetErrors 方法来检索对象的错误集合,以及一个 ErrorsChanged 事件,在实体错误更改时触发。我们可以立即看出,这个接口是为了处理多个错误而设计的,与 IDataErrorInfo 接口不同。现在,让我们看看这个接口的一个实现:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
public abstract class BaseNotifyValidationModel : INotifyPropertyChanged,
INotifyDataErrorInfo
{
protected Dictionary<string, List<string>> AllPropertyErrors { get; } =
new Dictionary<string, List<string>>();
public ObservableCollection<string> Errors =>
new ObservableCollection<string>(
AllPropertyErrors.Values.SelectMany(e => e).Distinct());
public abstract IEnumerable<string> this[string propertyName] { get; }
public void NotifyPropertyChangedAndValidate(
params string[] propertyNames)
{
foreach (string propertyName in propertyNames)
NotifyPropertyChangedAndValidate(propertyName);
}
public void NotifyPropertyChangedAndValidate(
[CallerMemberName]string propertyName = "")
{
NotifyPropertyChanged(propertyName);
Validate(propertyName);
}
public void Validate(string propertyName)
{
UpdateErrors(propertyName, this[propertyName]);
}
private void UpdateErrors(string propertyName,
IEnumerable<string> errors)
{
if (errors.Any())
{
if (AllPropertyErrors.ContainsKey(propertyName))
AllPropertyErrors[propertyName].Clear();
else AllPropertyErrors.Add(propertyName, new List<string>());
AllPropertyErrors[propertyName].AddRange(errors);
OnErrorsChanged(propertyName);
}
else
{
if (AllPropertyErrors.ContainsKey(propertyName))
AllPropertyErrors.Remove(propertyName);
OnErrorsChanged(propertyName);
}
}
#region INotifyDataErrorInfo Members
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
protected void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this,
new DataErrorsChangedEventArgs(propertyName));
NotifyPropertyChanged(nameof(Errors), nameof(HasErrors));
}
public IEnumerable GetErrors(string propertyName)
{
List<string> propertyErrors = new List<string>();
if (string.IsNullOrEmpty(propertyName)) return propertyErrors;
AllPropertyErrors.TryGetValue(propertyName, out propertyErrors);
return propertyErrors;
}
public bool HasErrors =>
AllPropertyErrors.Any(p => p.Value != null && p.Value.Any());
#endregion
#region INotifyPropertyChanged Members
public virtual event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(
params string[] propertyNames)
{
if (PropertyChanged != null) propertyNames.ForEach(
p => PropertyChanged(this, new PropertyChangedEventArgs(p)));
}
protected virtual void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
在我们的第一个实现中,我们看到只读的 AllPropertyErrors 自动属性声明,初始化为一个新的实例。对于这个集合,我们使用 Dictionary<string, List<string>> 类型,其中每个出错属性的名称用作字典键,该属性的错误可以存储在相关的 string 列表中。
接下来,我们看到只读的、表达式体的 Errors 属性,它将保存要在 UI 中显示的错误 string 集合。它被设置为返回来自 AllPropertyErrors 集合的唯一错误汇总。接下来,我们发现一个抽象的 string 索引器,它返回一个 IEnumerable 的 string 类型,负责从与 propertyName 输入参数指定的属性相关的派生类返回多个验证错误。我们将在稍后看到我们如何在派生类中实现此属性。
之后,我们添加了两个方便的 NotifyPropertyChangedAndValidate 方法,我们可以使用这两个方法在单个操作中既提供属性变更的通知,又对其进行验证。在这些方法中,我们调用我们的 NotifyPropertyChanged 方法实现,然后是我们的 Validate 方法,并将相关的属性名称传递给每个方法。
在 Validate 方法中,我们调用 UpdateErrors 方法,传入 propertyName 输入参数和指定属性的关联错误,这些错误是从 this 索引器属性返回的。在 UpdateErrors 方法中,我们首先检查 errors 输入参数指定的集合中是否有任何错误。
如果有错误,并且确实包含一些错误,我们从 AllPropertyErrors 集合中清除相关属性的错误,或者如果没有错误,为该属性初始化一个新的条目,然后向 AllPropertyErrors 集合中的相关属性添加传入的错误,并调用 OnErrorsChanged 方法来引发 ErrorsChanged 事件。
如果 errors 输入参数指定的集合中没有错误,我们将在验证相关属性存在之后,从 AllPropertyErrors 集合中移除所有之前的条目,以避免抛出异常。然后我们调用 OnErrorsChanged 方法来引发 ErrorsChanged 事件,以通知集合的变化。
接下来,我们看到所需的 INotifyDataErrorInfo 接口成员。我们仅为了内部使用声明 ErrorsChanged 事件,并使用空条件运算符调用相关的 OnErrorsChanged 方法来引发它,尽管这个方法在技术上不是接口的一部分,我们可以根据需要自由地引发事件。在引发事件后,我们通知系统 Errors 和 HasErrors 属性的变化,以刷新错误集合,并更新任何更改的 UI。
在 GetErrors 方法中,我们需要返回 propertyName 输入参数的错误。我们首先初始化 propertyErrors 集合,如果 propertyName 输入参数是 null 或空,我们立即返回该集合。否则,我们使用 TryGetValue 方法从 AllPropertyErrors 集合中填充与 propertyName 输入参数相关的错误到 propertyErrors 集合。然后我们返回 propertyErrors 集合。
然后是简化的 HasErrors 表达式属性,它简单地返回 true 如果 AllPropertyErrors 集合属性包含任何错误,否则返回 false。我们通过实现 INotifyPropertyChanged 接口的默认方法来完成类的定义。注意,如果我们打算这个基类扩展另一个具有该接口自己实现的类,我们可以简单地省略这一部分。
让我们复制我们之前定义的 Product 类,以便创建一个新的 ProductNotify 类,该类将扩展我们的新基类。除了类名和错误集合外,我们还需要进行一些修改。现在让我们来看看这些修改:
using System;
using System.Collections.Generic;
namespace CompanyName.ApplicationName.DataModels
{
public class ProductNotify : BaseNotifyValidationModel
{
...
public string Name
{
get { return name; }
set { if (name != value) { name = value;
NotifyPropertyChangedAndValidate(); } }
}
public decimal Price
{
get { return price; }
set { if (price != value) { price = value;
NotifyPropertyChangedAndValidate(); } }
}
public override IEnumerable<string> this[string propertyName]
{
get
{
List<string> errors = new List<string>();
if (propertyName == nameof(Name))
{
if (string.IsNullOrEmpty(Name))
errors.Add("Please enter the product name.");
else if (Name.Length > 25) errors.Add("The product name cannot
be longer than twenty-five characters.");
if (Name.Length > 0 && char.IsLower(Name[0])) errors.Add("The
first letter of the product name must be a capital letter.");
}
else if (propertyName == nameof(Price) && Price == 0)
errors.Add("Please enter a valid price for the product.");
return errors;
}
}
}
}
ProductNotify类和Product类之间的主要区别在于基类、使用的通知方法和处理多个并发错误的方式。我们首先扩展了我们的新BaseNotifyValidationModel基类。除了不需要验证的Id属性外,每个属性现在都调用新基类中的NotifyPropertyChangedAndValidate方法,而不是BaseValidationModel类中的NotifyPropertyChanged方法。
除了这些,this索引器属性现在可以同时报告多个错误,而不是像BaseValidationModel类可以处理的单个错误。因此,它现在声明了一个string列表来保存错误,每个有效的错误依次添加到其中。最大的不同之处在于我们还添加了一个新的错误,该错误验证产品名称的首字母应该以大写字母开头。
现在我们来看看我们的ProductNotifyViewModel类:
using System;
using System.Linq;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.DataModels.Collections;
namespace CompanyName.ApplicationName.ViewModels
{
public class ProductNotifyViewModel : BaseViewModel
{
private ProductsNotify products = new ProductsNotify();
public ProductNotifyViewModel()
{
Products.Add(new ProductNotify() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset", Price = 14.99m });
Products.Add(new ProductNotify() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset" });
Products.CurrentItem = Products.Last();
Products.CurrentItem.Validate(nameof(Products.CurrentItem.Name));
Products.CurrentItem.Validate(nameof(Products.CurrentItem.Price));
}
public ProductsNotify Products
{
get { return products; }
set { if (products != value) { products = value;
NotifyPropertyChanged(); } }
}
}
}
我们通过扩展我们常用的BaseViewModel基类来开始我们的ProductNotifyViewModel视图模型。我们声明了一个ProductsNotify集合,并在构造函数中用两个ProductNotify对象填充它,这些对象的属性值与ProductViewModelExtended类示例中使用的相同。我们再次在ProductsNotify集合上调用Last方法,并将该最后一个元素设置为它的CurrentItem属性以在 UI 中预选第二个项目。
然后,我们在设置为CurrentItem属性的Validate方法上调用两次,传入Name和Price属性,使用nameof运算符确保正确性。类以Products属性的常规声明结束。请注意,ProductsNotify类简单地扩展了我们的BaseCollection类,就像我们的Products类一样:
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class ProductsNotify : BaseCollection<ProductNotify> { }
}
还要注意的是,如果我们从构造函数中移除了对Validate方法的调用,这个实现将不再以预防的方式工作。它将最初隐藏任何现有的验证错误,例如空必填值,直到用户进行更改并且出现问题时。因此,空必填值永远不会引发错误,除非输入了一个值然后删除,再次变为空。
为了解决这个问题,我们可以声明一个ValidateAllProperties方法,我们的视图模型可以调用它来强制进行新的验证过程,要么在用户有机会输入任何数据之前预防性地进行,要么在所有字段都已填写后点击保存按钮时进行。我们将在本章后面看到这个示例,但现在让我们看看ProductNotifyView类的 XAML:
<UserControl x:Class="CompanyName.ApplicationName.Views.ProductNotifyView"
Width="600" FontSize="14">
<Grid Margin="20">
<Grid.Resources>
<DataTemplate x:Key="ProductTemplate">
<TextBlock Text="{Binding Name,
ValidatesOnNotifyDataErrors=False}" />
</DataTemplate>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ListBox ItemsSource="{Binding Products}"
SelectedItem="{Binding Products.CurrentItem}"
ItemTemplate="{StaticResource ProductTemplate}" Margin="0,0,20,0" />
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border Grid.ColumnSpan="2" BorderBrush="Red"
BorderThickness="2" Background="#1FFF0000" CornerRadius="5"
Visibility="{Binding Products.CurrentItem.HasErrors,
Converter={StaticResource BoolToVisibilityConverter}}"
Margin="0,0,0,10" Padding="10">
<ItemsControl ItemsSource="{Binding Products.CurrentItem.Errors}"
ItemTemplate="{StaticResource WrapTemplate}" />
</Border>
<TextBlock Grid.Row="1" Text="Name"
Style="{StaticResource LabelStyle}" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding Products.CurrentItem.Name,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True}"
Style="{StaticResource FieldStyle}" />
<TextBlock Grid.Row="2" Text="Price"
Style="{StaticResource LabelStyle}" />
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True, Delay=250}"
Style="{StaticResource FieldStyle}" />
</Grid>
</Grid>
</UserControl>
在Resources部分,我们声明了一个新的DataTemplate元素,命名为ProductTemplate。这个模板只显示Name属性的值,但重要的是,将绑定的ValidatesOnNotifyDataErrors属性设置为False,这样在ListBoxItem元素内不会显示任何错误模板。
另一点需要注意的是,全局错误显示边框的 Visibility 属性现在已更新,以与 INotifyDataErrorInfo 接口的新 HasErrors 属性一起工作,而不是我们之前的 BaseValidationModelExtended 类中的 HasError 属性。
唯一的其他更改是对两个 TextBox 控件的 Text 属性绑定进行的;当使用 INotifyDataErrorInfo 接口时,我们现在需要将 ValidatesOnNotifyDataErrors 属性设置为 True,而不是像之前那样将 ValidatesOnDataErrors 属性设置为 True。
我们将在不久之后再次更新这个示例,但在那之前,让我们探索另一种提供验证逻辑的方法。
数据标注
.NET 框架还在 System.ComponentModel.DataAnnotations 命名空间中为我们提供了一个基于属性的替代验证系统。它主要包含一系列属性类,我们可以用它们来装饰我们的数据模型属性,以便指定我们的验证规则。除了这些属性之外,它还包括一些验证类,我们将在稍后研究。
例如,让我们看看如何使用这些数据标注属性来复制我们的 ProductNotify 类中的当前验证规则。我们需要证实 Name 属性已被输入,并且长度不超过 25 个字符,以及 Price 属性大于零。对于 Name 属性,我们可以使用 RequiredAttribute 和 MaxLengthAttribute 属性:
using System.ComponentModel.DataAnnotations;
...
[Required(ErrorMessage = "Please enter the product name.")]
[MaxLength(25, ErrorMessage = "The product name cannot be longer than
twenty-five characters.")]
public string Name
{
get { return name; }
set { if (name != value) { name = value;
NotifyPropertyChangedAndValidate(); } }
}
与所有属性一样,当我们使用它们来装饰属性时,可以省略单词 Attribute。这些数据标注属性中的大多数都声明了一个或多个具有多个可选参数的构造函数。ErrorMessage 输入参数用于设置在指定的条件未满足时输出的消息。
RequiredAttribute 构造函数没有输入参数,它只是简单地检查数据绑定值是否不是 null 或空。MaxLengthAttribute 类的构造函数接受一个整数,指定数据绑定值的最大允许长度,如果输入值更长,它将引发一个 ValidationError 实例。
对于 Price 属性,我们可以使用具有非常高的最大值的 RangeAttribute,因为没有可用的 MinimumAttribute 类:
[Range(0.01, (double)decimal.MaxValue,
ErrorMessage = "Please enter a valid price for the product.")]
public decimal Price
{
get { return price; }
set { if (price != value) { price = value;
NotifyPropertyChangedAndValidate(); } }
}
RangeAttribute 类的构造函数接受两个 double 值,指定最小和最大有效值,在这个例子中,我们将最小值设置为一分钱,最大值设置为最大的 decimal 值,因为我们的 Price 属性是 decimal 类型。请注意,我们在这里不能使用 RequiredAttribute 类,因为数值数据绑定值永远不会是 null 或空。
有大量这些数据注释属性类,涵盖了最常见的验证场景,但当我们有一个没有预存在属性来帮助我们的需求时,我们可以通过扩展ValidationAttribute类来创建自己的自定义属性。让我们创建一个只验证最小值的属性:
using System.ComponentModel.DataAnnotations;
namespace CompanyName.ApplicationName.DataModels.Attributes
{
public class MinimumAttribute : ValidationAttribute
{
private double minimumValue = 0.0;
public MinimumAttribute(double minimumValue)
{
this.minimumValue = minimumValue;
}
protected override ValidationResult IsValid(object value,
ValidationContext validationContext)
{
if (value.GetType() != typeof(decimal) ||
(decimal)value < (decimal)minimumValue)
{
string[] memberNames =
new string[] { validationContext.MemberName };
return new ValidationResult(ErrorMessage, memberNames);
}
return ValidationResult.Success;
}
}
}
当我们扩展ValidationAttribute类时,我们只需要重写IsValid方法以返回true或false,这取决于我们的输入值,该值由value输入参数指定。在我们的简单示例中,我们首先声明minimumValue字段以存储在验证期间使用的目标最小允许值。
我们在类构造函数中填充这个字段,使用我们的类用户提供的值。接下来,我们重写返回ValidationResult实例的IsValid方法。在这个方法中,我们首先检查value输入参数的类型,并将其转换为decimal,以便与我们的minimumValue字段的值进行比较。
注意,我们将这个double类型硬编码为最小值的类型,因为尽管我们的Price属性是decimal类型,但decimal类型不被视为原始类型,因此不能用于属性。一个更好、更可重用的解决方案是声明多个构造函数,这些构造函数接受不同数值类型,可以在更广泛的场景中使用,并更新我们的IsValid方法,以便能够比较不同类型与输入值。
在我们的例子中,如果输入值是错误类型,或者转换值小于minimumValue字段的值,我们首先创建memberNames变量,并将validationContext输入参数中MemberName属性的值插入其中。然后,我们返回一个ValidationResult类的新实例,输入使用的错误消息和我们的memberNames集合。
如果输入值根据我们的特定验证逻辑有效,那么我们只需返回ValidationResult.Success字段以表示验证成功。现在让我们看看我们的新属性被用于ProductNotify类的Price属性:
[Minimum(0.01,
ErrorMessage = "Please enter a valid price for the product.")]
public decimal Price
{
get { return price; }
set { if (price != value) { price = value;
NotifyPropertyChangedAndValidate(); } }
}
实际上,我们的新属性将完全按照之前使用的RangeAttribute实例工作,但它清楚地展示了我们如何创建自己的自定义验证属性。在我们继续查看如何使用我们的代码读取这些错误之前,让我们首先看看如何从我们的属性中的数据模型访问第二个属性的值,因为这在验证时是一个常见的要求:
PropertyInfo propertyInfo =
validationContext.ObjectType.GetProperty(otherPropertyName);
if (propertyInfo == null) throw new ArgumentNullException(
$"Unknown property: {otherPropertyName}");
object otherPropertyValue =
propertyInfo.GetValue(validationContext.ObjectInstance);
这个例子假设我们已经添加了对System和System.Reflection命名空间的引用,并在构造函数中声明了一个名为otherPropertyName的string字段,该字段填充了其他属性名称。使用反射,我们尝试访问与指定属性名称相关的PropertyInfo对象。
如果PropertyInfo对象为null,我们抛出一个ArgumentNullException对象,提醒开发者他们使用了不存在的属性名称。否则,我们使用PropertyInfo对象的GetValue方法从其他属性中检索值。
现在我们已经了解了如何使用和创建我们自己的自定义验证属性,让我们看看我们如何使用它们来验证从其基类之一的数据模型实例:
ValidationContext validationContext = new ValidationContext(this);
List<ValidationResult> validationResults = new List<ValidationResult>();
Validator.TryValidateObject(this, validationContext, validationResults,
true);
我们首先初始化一个ValidationContext对象,传入基类中的数据模型实例。然后,上下文对象被传递到Validator类的TryValidateObject方法,以检索任何数据注释属性中的验证错误。
我们还初始化并传递一个ValidationResult类型的列表到TryValidateObject方法,该方法将填充当前数据对象的错误。请注意,此方法的第四个bool输入参数指定它是否将返回所有属性的错误,或者仅针对那些已用数据注释命名空间中的RequiredAttribute装饰的属性。
之后,我们将看到如何将此集成到我们的应用程序框架的验证基类中,但现在让我们研究如何在不同的场景中执行不同级别的验证。
多级验证
.NET 验证接口中未解决的问题之一是能够打开或关闭验证,或者设置不同的验证级别。这在几个不同的场景中可能很有用,例如,有不同视图来编辑数据模型对象的属性。
例如,可能有一个视图允许用户更新User对象的网络安全设置,我们希望验证每个属性都有值,但只针对当前在视图中显示的属性。毕竟,如果用户无法在当前视图中执行此操作,通知用户某个字段必须输入是没有意义的。
解决方案是定义多个验证级别,除了表示完全验证和无验证的级别之外。让我们看看一个简单的ValidationLevel枚举,它可能满足这个要求:
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum ValidationLevel
{
None, Partial, Full
}
}
如我们所见,在这个简单的例子中,我们只有三个验证级别,尽管我们可以添加更多。然而,在实践中,我们仍然可以用这个简单的枚举来管理。让我们看看我们如何使用它来实现验证基类中的多级验证:
private ValidationLevel validationLevel = ValidationLevel.Full;
public ValidationLevel ValidationLevel
{
get { return validationLevel; }
set { if (validationLevel != value) { validationLevel = value; } }
}
private void Validate(string propertyName, IEnumerable<string> errors)
{
if (ValidationLevel == ValidationLevel.None) return;
UpdateErrors(propertyName, this[propertyName]);
}
我们添加了一个ValidationLevel属性,其validationLevel后端字段默认为Full枚举成员,因为这通常是正常操作。然后,在Validate方法中,我们添加了一行代码,如果ValidationLevel属性设置为None枚举成员,则简单地退出该方法。
最后,使用我们的应用程序框架的开发者需要在数据模型类中验证属性时使用ValidationLevel属性。想象一下,用户可以直接在集合控件中编辑我们的产品名称,或者在单独的视图中编辑所有产品的属性。让我们看看我们的ProductNotify类索引器属性需要看起来像什么以展示这一点:
public override IEnumerable<string> this[string propertyName]
{
get
{
List<string> errors = new List<string>();
if (propertyName == nameof(Name))
{
if (string.IsNullOrEmpty(Name))
errors.Add("Please enter the product name.");
else if (Name.Length > 25) errors.Add("The product name cannot be
longer than twenty-five characters.");
if (Name.Length > 0 && char.IsLower(Name[0])) errors.Add("The first
letter of the product name must be a capital letter.");
}
else if (propertyName == nameof(Price) &&
ValidationLevel == ValidationLevel.Full && Price == 0)
errors.Add("Please enter a valid price for the product.");
return errors;
}
}
使用我们对INotifyDataErrorInfo接口的实现,我们首先初始化一个名为errors的string列表,然后检查propertyName输入参数的值。由于此实现使我们能够针对每个属性返回多个验证错误,我们需要注意我们的if和else语句。
例如,当propertyName输入参数等于Name时,我们有两个if语句和一个else语句。第一个if语句验证Name属性是否有值,而else语句检查其值是否不超过 25 个字符。
由于这两个条件不可能同时为真,我们使用if...else语句将它们结合起来。另一方面,产品名称可能超过 25 个字符,并且以小写字母开头,因此下一个条件有自己的if语句。在这个例子中,当ValidationLevel属性设置为Partial或Full成员时,将验证Name属性。
然而,Price属性的剩余条件仅在ValidationLevel属性设置为Full成员时才进行验证,因此这只是一个额外的条件。要触发数据模型变量的部分验证,我们可以简单地将其ValidationLevel属性设置为以下内容:
product.ValidationLevel = ValidationLevel.Partial;
现在,让我们研究如何结合我们迄今为止看到的不同的技术。
结合多种验证技术
现在我们已经仔细研究了两个验证接口、数据注释属性以及不同级别的验证能力,让我们看看我们如何将这些不同的技术结合起来。
让我们通过复制我们在BaseNotifyValidationModel类中的内容来创建一个BaseNotifyValidationModelExtended类,并包含以下新添加的内容。首先,我们需要添加一些额外的using指令到之前实现中使用的那些:
using System.Collections.Specialized;
using System.ComponentModel.DataAnnotations;
using CompanyName.ApplicationName.DataModels.Enums;
接下来,我们需要添加我们的validationLevel字段:
private ValidationLevel validationLevel = ValidationLevel.Full;
我们需要添加一个构造函数,在其中我们将ExternalErrors_CollectionChanged事件处理程序附加到ExternalErrors集合属性上的CollectionChanged事件,就像我们之前做的那样:
protected BaseNotifyValidationModelExtended()
{
ExternalErrors.CollectionChanged += ExternalErrors_CollectionChanged;
}
现在,让我们添加熟悉的ValidationLevel、Errors和ExternalErrors属性,以及抽象的ValidateAllProperties方法:
public ValidationLevel ValidationLevel
{
get { return validationLevel; }
set { if (validationLevel != value) { validationLevel = value; } }
}
public virtual ObservableCollection<string> Errors
{
get
{
ObservableCollection<string> errors = new ObservableCollection<string>
(AllPropertyErrors.Values.SelectMany(e => e).Distinct());
ExternalErrors.Where(
e => !errors.Contains(e)).ForEach(e => errors.Add(e));
return errors;
}
}
public ObservableCollection<string> ExternalErrors { get; } =
new ObservableCollection<string>();
public abstract void ValidateAllProperties();
注意,在这个实现中,我们的框架的用户将不再需要覆盖Errors属性以确保他们的可验证属性得到验证。虽然我们仍然将其声明为虚拟的,以便在必要时可以覆盖,但这个基类实现已经将所有验证错误编译到内部集合中,准备显示,并应替换我们从上一个基类中复制的那一个。
这次,我们使用AllPropertyErrors属性Dictionary对象中每个属性错误集合的所有唯一错误初始化一个新的局部errors集合。然后,如果它们尚未存在于errors集合中,我们添加ExternalErrors集合中的任何错误。这个Errors字符串集合主要用于它方便在 UI 中进行数据绑定。
在新的Errors属性之后,我们看到具有初始化器的ExternalErrors自动属性以及需要在派生类中实现并可以调用来强制进行新的验证遍历的抽象ValidateAllProperties方法,无论是主动还是点击保存按钮,一旦所有字段都已填写。我们很快将看到这个的示例实现。
返回到我们的基类,在ValidateAllProperties方法之后,我们需要声明几个Validate方法,以替换BaseNotifyValidationModel类中的那个。其中第一个是一个便利方法,它接受任意数量的属性名称输入参数,并为每个属性名称简单调用第二个方法一次:
public void Validate(params string[] propertyNames)
{
foreach (string propertyName in propertyNames)
Validate(propertyName);
}
public void Validate(string propertyName)
{
if (ValidationLevel == ValidationLevel.None) return;
ValidationContext validationContext = new ValidationContext(this);
List<ValidationResult> validationResults = new List<ValidationResult>();
Validator.TryValidateObject(this, validationContext, validationResults,
true);
IEnumerable<string> allErrors =
validationResults.Where(v => v.MemberNames.Contains(propertyName)).
Select(v => v.ErrorMessage).Concat(this[propertyName]);
UpdateErrors(propertyName, allErrors);
}
在Validate方法中,如果ValidationLevel属性设置为None成员,我们不执行任何验证并立即从方法返回。否则,我们检索与之前在数据注释部分中描述的数据注释相关的验证错误。
我们首先过滤与由propertyName输入参数指定的属性相关的错误,并将它们与this索引器属性返回的错误集合连接起来。最后,我们将包含所有错误的编译后的集合以及propertyName输入参数传递给我们的BaseNotifyValidationModel类中的未更改的UpdateErrors方法。
接下来,我们需要添加ExternalErrors_CollectionChanged方法,该方法现在在构造函数中被引用。它只是通知Errors集合属性和HasError属性的变化,以便每次添加或删除外部错误时,它们都会在 UI 中更新:
private void ExternalErrors_CollectionChanged(object sender,
NotifyCollectionChangedEventArgs e)
{
NotifyPropertyChanged(nameof(Errors), nameof(HasErrors));
}
可以使用HasErrors属性来设置 UI 中集合控制的可见性,以便它可以在存在任何错误时显示完整的错误集合,并在没有错误时隐藏它。我们需要做的最后一个更改是向HasErrors属性添加一个额外的条件,该条件监听外部错误以及内部生成的错误:
public bool HasErrors => ExternalErrors.Any() ||
allPropertyErrors.Any(p => p.Value != null && p.Value.Any());
现在,我们的基验证类将管理在索引器中定义的错误,以及可能装饰类属性的任何数据注释属性以及由外部视图模型生成的错误。现在让我们看看我们如何使用它。
让我们先复制我们的 ProductNotify 类,将其重命名为 ProductNotifyExtended,并使其扩展我们新的 BaseNotifyValidationModelExtended 基类。然后我们需要进行以下更改:
public class ProductNotifyExtended :
BaseNotifyValidationModelExtended
{
...
public override IEnumerable<string> this[string propertyName]
{
get
{
List<string> errors = new List<string>();
if (propertyName == nameof(Name))
{
...
}
else if (propertyName == nameof(Price) &&
ValidationLevel == ValidationLevel.Full && Price == 0)
errors.Add("Please enter a valid price for the product.");
return errors;
}
}
public override void ValidateAllProperties()
{
Validate(nameof(Name), nameof(Price));
}
}
这个新的数据模型除了名称、基类、ValidateAllProperties 方法和在前一小节中讨论的向 this 索引器添加额外条件之外,与复制的模型相同。
ValidateAllProperties 方法调用基类的 Validate 方法,传入 Name 和 Price 属性的名称,并且可以从视图模型中随时调用以验证这两个属性。this 索引器已根据前一小节的示例进行更新,以便 ValidationLevel 属性在验证过程中发挥作用。
现在,让我们通过复制并重命名 ProductNotifyViewModel 类来创建一个 ProductNotifyViewModelExtended 类,并做出以下更改:
public class ProductNotifyViewModelExtended : BaseViewModel
{
private ProductsNotifyExtended products =
new ProductsNotifyExtended();
public ProductNotifyViewModelExtended()
{
Products.Add(new ProductNotifyExtended() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset", Price = 14.99m });
Products.Add(new ProductNotifyExtended() { Id = Guid.NewGuid(),
Name = "super virtual reality headset", Price = 49.99m });
Products.CurrentItem = Products.Last();
Products.CurrentItem.Validate(nameof(Products.CurrentItem.Name));
Products.CurrentItem.Validate(nameof(Products.CurrentItem.Price));
}
public ProductsNotifyExtended Products
{
get { return products; }
set { if (products != value) { products = value;
NotifyPropertyChanged(); } }
}
}
首先,我们将所有 ProductNotify 类的实例替换为 ProductNotifyExtended 类,并将所有 ProductsNotify 类的实例替换为 ProductsNotifyExtended 类。
ProductsNotifyExtended 类是封装我们的 BaseCollection 类功能的标准包装器:
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class ProductsNotifyExtended :
BaseCollection<ProductNotifyExtended> { }
}
ProductNotifyViewModelExtended 类的最后一个更改是在构造函数中将第二个数据项的值更改为新示例中显示的值。让我们也通过简单地复制并重命名它来从我们的 ProductNotifyView 类创建一个新的 ProductNotifyViewExtended 类。在此阶段不需要对其做其他更改。
在 App.xaml 文件中连接视图和视图模型并运行此示例后,我们可以看到,就像我们的 BaseValidationModelExtended 示例一样,此实现也使我们能够在全局错误输出集合控件中为每个属性显示多个验证错误:

现在让我们看看我们如何自定义向用户突出显示这些验证错误的方式。
自定义错误模板
除了基本的 Errors 和 HasError 属性外,Validation 类还声明了一个 ControlTemplate 类型的附加属性 ErrorTemplate。分配给此属性的默认模板负责定义围绕与验证错误关联的 UI 字段的红色矩形。
然而,这个属性使我们能够更改这个模板,因此,我们可以定义如何将验证错误突出显示给应用程序用户。由于这个属性是一个附加属性,这意味着我们可以为 UI 中的每个控件应用不同的模板。然而,这并不推荐,因为这可能会使应用程序看起来不那么一致。
这个模板实际上使用了一个Adorner元素来在装饰层中渲染其图形,在错误的相关控件之上。因此,为了指定我们的错误视觉元素相对于相关控件的位置,我们需要在错误模板中声明一个AdornedElementPlaceholder元素。
让我们看看一个简单的例子,其中我们定义了一个比默认值稍厚的、非模糊的边框,并使用浅红色在相关控件背景上绘制,以增加强调。我们首先需要在合适的位置定义一个ControlTemplate对象:
<ControlTemplate x:Key="ErrorTemplate">
<Border BorderBrush="Red" BorderThickness="2" Background="#1FFF0000"
SnapsToDevicePixels="True">
<AdornedElementPlaceholder />
</Border>
</ControlTemplate>
在这个例子中,我们在Border元素内部声明了AdornedElementPlaceholder元素,这样边框就会渲染在相关控件的外侧。注意,如果没有声明这个AdornedElementPlaceholder元素,当发生错误时,我们的边框将类似于相关控件左上角的一个小红点。
现在,让我们看看我们如何应用这个模板,使用我们之前提到的绑定到Product.Price属性的控件示例:
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True, Delay=250}"
Style="{StaticResource FieldStyle}"
Validation.ErrorTemplate="{StaticResource ErrorTemplate}" />
现在,让我们看看渲染后的样子:

如果我们想要将错误突出显示元素定位在相对于错误的相关控件的不同位置,我们可以使用其中一个面板来定位它们。让我们看看我们可以使用的稍微复杂一些的错误模板。让我们首先在合适的位置声明一些资源:
<ToolTip x:Key="ValidationErrorsToolTip">
<ItemsControl ItemsSource="{Binding}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding ErrorContent}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ToolTip>
<ControlTemplate x:Key="WarningErrorTemplate">
<StackPanel Orientation="Horizontal">
<AdornedElementPlaceholder Margin="0,0,10,0" />
<Image Source="pack://application:,,,/CompanyName.ApplicationName;
component/Images/Warning_16.png" Stretch="None"
ToolTip="{StaticResource ValidationErrorsToolTip}" />
</StackPanel>
</ControlTemplate>
在这个例子中,我们声明了一个名为ValidationErrorsToolTip的ToolTip资源。在其中,我们声明了一个ItemsControl元素来显示所有的验证错误。我们在ItemTemplate属性中定义了一个DataTemplate元素,它将输出Validation.Errors集合中每个ValidationError对象的ErrorContent属性的值。这个集合将隐式设置为控件模板的数据上下文。
接下来,我们声明一个ControlTemplate元素并将其设置为ErrorTemplate属性,使用WarningErrorTemplate键。在其中,我们定义一个水平的StackPanel控件,并在其中声明所需的AdornedElementPlaceholder元素。随后是来自 Visual Studio 图标集的警告图标,这在第八章中讨论过,创建视觉吸引力的用户界面,并应用了ValidationErrorsToolTip资源到其ToolTip属性。
我们可以使用ErrorTemplate属性如下应用此模板:
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True, Delay=250}"
Style="{StaticResource FieldStyle}"
Validation.ErrorTemplate="{StaticResource WarningErrorTemplate}" />
当现在在这个TextBox控件上发生验证错误时,它将看起来像这样:

现在我们已经调查了显示验证错误的各种方法,让我们继续探讨如何完全避免基于 UI 的验证错误。
避免基于 UI 的验证错误
在上一节中的最后一个示例中,我们将整个Validation.Errors集合数据绑定到我们的TextBox控件的错误模板中的工具提示。我们还从我们的基类将我们自己的Errors集合数据绑定到表单字段上方的ItemsControl元素。
我们的Errors集合可以显示每个数据模型中所有属性的错误。然而,Validation.Errors集合可以访问基于 UI 的验证错误,这些错误永远不会返回到视图模型。看看以下示例:

基于 UI 的验证错误提示说值'0t'无法转换,这解释了为什么视图模型从未看到这个错误。数据绑定属性中期望的值类型是decimal,但输入了一个不可转换的值。因此,输入值无法转换为有效的decimal数字,所以数据绑定值永远不会更新。
然而,Validation.Errors集合是一个 UI 元素,每个数据绑定控件都有自己的集合,因此我们无法从我们的视图模型类中简单地访问它们。此外,ValidationError类位于System.Windows.Controls UI 程序集中,所以我们不希望将对该程序集的引用添加到我们的ViewModels项目中。
而不是试图从视图模型中控制基于 UI 的验证错误,我们可以扩展控件,或者定义附加属性来限制用户最初输入无效数据的能力,从而避免基于 UI 的验证的需要。让我们看看我们如何使用TextBoxProperties类修改标准的TextBox控件,使其只能接受数值输入:
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace CompanyName.ApplicationName.Views.Attached
{
public class TextBoxProperties : DependencyObject
{
#region IsNumericOnly
public static readonly DependencyProperty IsNumericOnlyProperty =
DependencyProperty.RegisterAttached("IsNumericOnly",
typeof(bool), typeof(TextBoxProperties),
new UIPropertyMetadata(default(bool), OnIsNumericOnlyChanged));
public static bool GetIsNumericOnly(DependencyObject dependencyObject)
{
return (bool)dependencyObject.GetValue(IsNumericOnlyProperty);
}
public static void SetIsNumericOnly(DependencyObject dependencyObject,
bool value)
{
dependencyObject.SetValue(IsNumericOnlyProperty, value);
}
private static void OnIsNumericOnlyChanged(DependencyObject
dependencyObject, DependencyPropertyChangedEventArgs e)
{
TextBox textBox = (TextBox)dependencyObject;
bool newIsNumericOnlyValue = (bool)e.NewValue;
if (newIsNumericOnlyValue)
{
textBox.PreviewTextInput += TextBox_PreviewTextInput;
textBox.PreviewKeyDown += TextBox_PreviewKeyDown;
DataObject.AddPastingHandler(textBox, TextBox_Pasting);
}
else
{
textBox.PreviewTextInput -= TextBox_PreviewTextInput;
textBox.PreviewKeyDown -= TextBox_PreviewKeyDown;
DataObject.RemovePastingHandler(textBox, TextBox_Pasting);
}
}
private static void TextBox_PreviewTextInput(object sender,
TextCompositionEventArgs e)
{
string text = GetFullText((TextBox)sender, e.Text);
e.Handled = !IsTextValid(text);
}
private static void TextBox_PreviewKeyDown(object sender,
KeyEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox.Text.Length == 1 &&
(e.Key == Key.Delete || e.Key == Key.Back))
{
textBox.Text = "0";
textBox.CaretIndex = 1;
e.Handled = true;
}
else if (textBox.Text == "0") textBox.Clear();
else e.Handled = e.Key == Key.Space;
}
private static void TextBox_Pasting(object sender,
DataObjectPastingEventArgs e)
{
if (e.DataObject.GetDataPresent(typeof(string)))
{
string text = GetFullText((TextBox)sender,
(string)e.DataObject.GetData(typeof(string)));
if (!IsTextValid(text)) e.CancelCommand();
}
else e.CancelCommand();
}
private static string GetFullText(TextBox textBox, string input)
{
return textBox.SelectedText.Length > 0 ?
string.Concat(textBox.Text.Substring(0, textBox.SelectionStart),
input, textBox.Text.Substring(textBox.SelectionStart +
textBox.SelectedText.Length)) :
textBox.Text.Insert(textBox. SelectionStart, input);
}
private static bool IsTextValid(string text)
{
return Regex.Match(text, @"^\d*\.?\d*$").Success;
}
#endregion
...
}
}
排除TextBoxProperties类中的其他现有成员,我们首先声明IsNumericOnly附加属性及其相关获取器和设置器方法,并附加OnIsNumericOnlyChanged处理器。
在OnIsNumericOnlyChanged方法中,我们首先将dependencyObject输入参数转换为TextBox元素,然后将DependencyPropertyChangedEventArgs类的NewValue属性转换为bool类型的newIsNumericOnlyValue变量。
如果newIsNumericOnlyValue变量为true,我们将为PreviewTextInput、PreviewKeyDown和DataObject.Pasting事件附加我们的事件处理器。如果newIsNumericOnlyValue变量为false,我们将断开处理器。
我们需要处理所有这些事件,以便创建一个只能输入数值的 TextBox 控件。当 TextBox 元素从任何设备接收文本输入时,会引发 UIElement.PreviewTextInput 事件,当按下键盘键时,会触发特定的 Keyboard.PreviewKeyDown 事件,当我们从剪贴板粘贴时,会引发 DataObject.Pasting 事件。
在 TextBox_PreviewTextInput 处理方法中的 TextCompositionEventArgs 对象仅通过其 Text 属性提供给我们最后输入的字符,以及 TextComposition 详细信息。在调用此隧道事件阶段,相关 TextBox 控件的 Text 属性尚未意识到这个最新字符。
因此,为了正确验证整个输入的文本值,我们需要将现有值与这个新字符组合起来。我们在 GetFullText 方法中这样做,并将返回值传递给 IsTextValid 方法。
然后,我们将 IsTextValid 方法的反转返回值设置到 TextCompositionEventArgs 输入参数的 Handled 属性。请注意,我们反转这个 bool 值,因为将 Handled 属性设置为 true 将停止事件进一步路由,并导致最新字符不被接受。因此,我们在输入值无效时执行此操作。
接下来,我们看到 TextBox_PreviewKeyDown 事件处理方法,在其中,我们再次将 sender 输入参数强制转换为 TextBox 实例。我们特别需要处理此事件,因为当按下键盘上的 空格键、删除 或 退格 键时,不会引发 PreviewTextInput 事件。
因此,如果按下的键是 空格键,或者输入文本的长度是单个字符并且按下了 删除 或 退格 键,我们将通过将 KeyEventArgs 输入参数的 Handled 属性设置为 true 来停止事件进一步路由;这阻止用户从 TextBox 控件中删除最后一个字符,这会导致基于 UI 的验证错误。
然而,如果用户尝试删除最后一个字符是因为它不正确,并且他们想用不同的值替换它,这可能会很尴尬。因此,在这种情况下,我们将最后一个字符替换为零,并将光标位置放置在其后,这样用户就可以输入不同的值。请注意,我们添加了一个额外条件,如果输入是 0,则清除文本,以便用输入的字符替换它。
在 TextBox_Pasting 处理方法中,我们检查从 DataObjectPastingEventArgs 输入参数访问的 DataObject 属性是否有可用的 string 数据,如果没有,则调用其 CancelCommand 方法来取消粘贴操作。
如果存在string数据,我们将sender输入参数转换为TextBox实例,然后将DataObject属性的数据传递给GetFullText方法以重建整个输入字符串。我们将重建的文本传递给IsTextValid方法,如果它无效,则调用CancelCommand方法以取消粘贴操作。
接下来是GetFullText方法,其中重建了从TextBox元素输入的文本。在这个方法中,如果TextBox控件中选择了任何文本,我们就通过连接选择前的文本部分、新输入或粘贴的文本以及选择后的文本部分来重建字符串。否则,我们使用String类的Insert方法以及TextBox控件的SelectionStart属性,将新字符插入到字符串的适当位置。
在课程结束时,我们看到IsTextValid方法,它简单地返回Regex.Match方法的Success属性值。我们验证的正则表达式如下:
@"^\d*\.?\d*$"
&(@)标记字符串为字面量,这在使用通常需要转义的字符时很有用,而^( caret)符号表示输入行的开始,\d*表示我们可以有零个或多个数字,\.?指定然后可以有一个或零个点,\d*再次表示我们可以有零个或多个数字,最后,$表示输入行的结束。
当附加到普通的TextBox控件时,我们现在只能输入数值,但整数和小数都是允许的。请注意,这个特定的实现不接受负号,因为我们不想允许负价格,但这可以很容易地更改。使用我们之前的ProductNotifyViewExtended示例,我们可以这样附加我们的新属性:
...
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True, Delay=250}"
Style="{StaticResource FieldStyle}"
Attached:TextBoxProperties.IsNumericOnly="True" />
保持与旧行为同步
那些尝试过我们各种Product相关示例的人可能会注意到,当尝试输入价格时发生了一些奇怪的事情。在.NET 4.5 中,微软决定对TextBox控件中数据输入的方式引入一个破坏性更改,当UpdateSourceTrigger绑定值设置为PropertyChanged时。
从.NET 4.5 开始,当我们将TextBox.Text属性绑定到float、double或decimal数据类型时,我们不能再输入数值分隔符,无论是点还是逗号。他们这样做的原因是,之前在用户输入非数字字符时,TextBox控件中显示的值会与绑定值不同步。
让我们调查这种情况;一个用户想要输入0.99,在第二个字符之后,输入值0.被发送回数据绑定的视图模型。但由于它不是一个有效的十进制值,因此它被解析为0,并将该值发送回数据绑定的Textbox元素以进行显示。因此,第二个字符,即小数点,被从Text字段中移除。
不幸的是,这个变更意味着当UpdateSourceTrigger属性设置为PropertyChanged时,用户不能再直接在TextBox控件中输入小数位数。这可以在我们的ProductView示例中看到,其中在标记为Price的TextBox控件中根本无法输入带有小数位的有效值。
有许多方法可以解决这个问题,但没有一个是完美的。一种简单的方法是将Binding元素上的Mode属性设置为OneWayToSource成员,以阻止从视图模型返回值,尽管这也会阻止任何初始默认值被发送。
当在.NET 4.5 中宣布这个重大变更时,随着变更引入了一个新的属性;KeepTextBoxDisplaySynchronizedWithTextProperty属性被添加到FrameworkCompatibilityPreferences类中,并指定了一个TextBox控件是否应该显示与它的数据绑定属性值相同。如果我们将其设置为false,它应该返回之前的行为:
FrameworkCompatibilityPreferences.
KeepTextBoxDisplaySynchronizedWithTextProperty = false;
注意,我们需要在应用程序生命周期非常早的时候设置这个属性,例如在App.xaml.cs文件的构造函数中。一旦设置,就不能更改。避免这个问题的另一种方法是将UpdateSourceTrigger属性设置为除PropertyChanged之外的其他任何值:
<TextBox Text="{Binding Products.CurrentItem.Price,
Style="{StaticResource FieldStyle}" UpdateSourceTrigger=LostFocus ... />
然而,如果我们想要提前验证,或者想要我们的数据源在每次按键时更新,这就没有用了。或者,我们可以简单地将一个string属性数据绑定到我们的TextBox控件,并在我们的视图模型中执行自己的数字解析。这可能是一个从用户角度来看的最佳解决方案,因为它将使用户能够轻松地输入他们的值。
另一个选项是利用我们在第四章中讨论的Binding类的Delay属性,精通数据绑定。如果我们将其设置为仅几百毫秒的数值,这将给用户足够的时间输入他们的数字,包括小数点和随后的数字,在值被解析为数据绑定类型之前:
<TextBox Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged, Delay=250}" ... />
这是我们示例中使用的选项,主要是因为它是一个快速且简单的修复方案。然而,在使用这种方法处理实际货币属性时,应该小心谨慎,因为如果用户输入速度慢且没有注意输入的值,错误很容易发生。
就像 WPF 一样,实现任何解决方案都有许多不同的方法。正如我们在上一节中看到的,也有其他方法可以阻止用户最初输入无效数据;我们可以构建或使用第三方数字上下控制,使用自定义时钟控制让用户输入时间值,或者甚至使用组合框来限制用户可以选择的值集合。
验证和视觉的融合
现在我们将利用我们在第八章,“创建视觉吸引人的用户界面”中讨论的一些技术,来设计一个视觉上吸引人的用户界面,以新颖的方式突出显示验证错误,使用我们的发光示例。对于这个示例,我们希望有知道数据何时发生变化的能力,因此我们需要扩展我们之前定义的BaseSynchronizableDataModel类,并创建一个新的基类。
让我们复制我们的BaseNotifyValidationModelExtended类,以便创建一个新的BaseNotifyValidationModelGeneric类,并使其扩展我们的可同步基类。在这个过程中,我们还需要使其泛型化,并将基类中T泛型类型参数的相同泛型约束添加到其声明中:
using CompanyName.ApplicationName.DataModels.Interfaces;
...
public abstract class BaseNotifyValidationModelGeneric<T> :
BaseSynchronizableDataModel<T>, INotifyPropertyChanged,
INotifyDataErrorInfo
where T : BaseDataModel, ISynchronizableDataModel<T>, new()
我们需要移除复制的INotifyPropertyChanged接口实现,并使用来自BaseSynchronizableDataModel类的现有实现。我们还需要在新的ProductNotifyGeneric类中实现新基类所需的新成员。让我们先复制ProductNotifyExtended类,将其重命名为ProductNotifyGeneric,然后向其末尾添加这些方法:
public class ProductNotifyGeneric :
BaseNotifyValidationModelGeneric<ProductNotifyGeneric>
{
...
public override void CopyValuesFrom(ProductNotifyGeneric product)
{
Id = product.Id;
Name = product.Name;
Price = product.Price;
}
public override bool PropertiesEqual(ProductNotifyGeneric otherProduct)
{
if (otherProduct == null) return false;
return Id == otherProduct.Id && Name == otherProduct.Name &&
Price == otherProduct.Price;
}
public override string ToString()
{
return $"{Name}: £{Price:N2}";
}
}
首先,我们扩展自我们的新泛型BaseNotifyValidationModelGeneric类,并实现基类中所有必需的成员;CopyValuesFrom方法用于创建数据对象的克隆副本,PropertiesEqual方法用于将其属性值与其他ProductNotifyGeneric实例进行比较,而ToString方法为该类提供了有用的文本输出。
现在我们已经从之前的BaseSynchronizableDataModel类扩展了我们的BaseNotifyValidationModelGeneric类,并且反过来,在ProductNotifyGeneric类中扩展了它,我们现在可以创建一个新的ProductsNotifyGeneric集合类来扩展我们之前的BaseSynchronizableCollection类:
public class ProductsNotifyGeneric :
BaseSynchronizableCollection<ProductNotifyGeneric> { }
现在我们为这个新示例创建一个视图模型,我们将使用这些新的模型。我们可以从复制ProductViewModelExtended视图模型开始,并将其重命名为ProductNotifyViewModelGeneric。我们需要将所有ProductExtended类的实例替换为我们的新ProductNotifyGeneric类,并将所有ProductsExtended集合类的实例替换为新的ProductsNotifyGeneric类。
在将之前视图模型中的两个未更改的产品添加到其中之后,我们现在可以在构造函数中调用新的ProductsNotifyGeneric集合上的Synchronize方法,以便设置所有包含数据项的未更改状态:
public ProductNotifyViewModelGeneric()
{
Products.Add(new ProductNotifyGeneric() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset", Price = 14.99m });
Products.Add(new ProductNotifyGeneric() { Id = Guid.NewGuid(),
Name = "Virtual Reality Headset" });
Products.Synchronize();
Products.CurrentItemChanged += Products_CurrentItemChanged;
Products.CurrentItem = Products.Last();
Products.CurrentItem.Validate(nameof(Products.CurrentItem.Name),
nameof(Products.CurrentItem.Price));
ValidateUniqueName(Products.CurrentItem);
}
构造函数中的唯一其他更改是我们现在在当前项上调用基类Validate方法,并传入Name和Price属性的名称,这样可以在用户有机会输入任何数据之前以预防性方式验证这些字段。
我们需要添加到这个类中的最后一件事是一系列处理来自 UI 的命令的方法:
using System.Windows.Input;
using CompanyName.ApplicationName.ViewModels.Commands;
...
public ICommand DeleteCommand
{
get { return new ActionCommand(action => Delete(action),
canExecute => CanDelete(canExecute)); }
}
private bool CanDelete(object parameter)
{
return Products.Contains((ProductNotifyGeneric)parameter);
}
private void Delete(object parameter)
{
Products.Remove((ProductNotifyGeneric)parameter);
}
在这里,我们使用我们的ActionCommand类创建一个ICommand实例,用户可以使用它从 UI 中的产品集合中删除所选项。在CanDelete方法中,我们验证要删除的项实际上存在于集合中,但这可以替换为您的自己的条件。例如,您可以检查项是否有任何更改,或者当前用户是否有删除对象的正确安全权限。在Delete方法中,我们简单地从集合中删除所选项。
现在我们已经准备好了视图模型,让我们将注意力转向配套的视图。为此,让我们创建一个新的视图,并将其命名为ProductNotifyViewGeneric。然后我们需要提供一些更多资源用于本例。让我们首先向应用程序资源文件中添加两个额外的发光画笔资源,包括来自第八章,创建视觉吸引人的用户界面的GreenGlow画笔资源:
<RadialGradientBrush x:Key="BlueGlow" Center="0.5,0.848"
GradientOrigin="0.5,0.818" RadiusX="-1.424" RadiusY="-0.622"
RelativeTransform="{StaticResource GlowTransformGroup}">
<GradientStop Color="#CF01C7FF" Offset="0.168" />
<GradientStop Color="#4B01C7FF" Offset="0.478" />
<GradientStop Color="#1101C7FF" Offset="1" />
</RadialGradientBrush>
<RadialGradientBrush x:Key="RedGlow" Center="0.5,0.848"
GradientOrigin="0.5,0.818" RadiusX="-1.424" RadiusY="-0.622"
RelativeTransform="{StaticResource GlowTransformGroup}">
<GradientStop Color="#CFFF0000" Offset="0.168" />
<GradientStop Color="#4BFF0000" Offset="0.478" />
<GradientStop Color="#00FF0000" Offset="1" />
</RadialGradientBrush>
现在我们来看看使用这些画笔资源的样式:
<Style x:Key="GlowStyle" TargetType="{x:Type Rectangle}">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Opacity" Value="1.0" />
<Setter Property="StrokeThickness" Value="0" />
<Setter Property="RadiusX" Value="2.5" />
<Setter Property="RadiusX" Value="2.5" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Fill" Value="{StaticResource BlueGlow}" />
</Style>
第一种样式是可重用的,可以在全局应用程序资源中声明,而以下样式扩展了第一种样式,是数据模型特定的,可以在我们新的ProductNotifyViewGeneric类中本地声明:
<Style x:Key="ProductGlowStyle" TargetType="{x:Type Rectangle}"
BasedOn="{StaticResource GlowStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding Products.CurrentItem.HasChanges,
FallbackValue=False, Mode=OneWay}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenGlow}" />
</DataTrigger>
<DataTrigger Binding="{Binding Products.CurrentItem.HasErrors,
FallbackValue=False, Mode=OneWay}" Value="True">
<Setter Property="Fill" Value="{StaticResource RedGlow}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ProductItemGlowStyle" TargetType="{x:Type Rectangle}"
BasedOn="{StaticResource GlowStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasChanges, FallbackValue=False,
Mode=OneWay}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenGlow}" />
</DataTrigger>
<DataTrigger Binding="{Binding HasErrors, FallbackValue=False,
Mode=OneWay}" Value="True">
<Setter Property="Fill" Value="{StaticResource RedGlow}" />
</DataTrigger>
</Style.Triggers>
</Style>
我们为表单矩形声明了ProductGlowStyle样式,并为Products集合中的数据项声明了ProductItemGlowStyle样式。这两个样式之间的唯一区别在于两个数据触发器的绑定路径。
在这些样式中,我们添加了一个DataTrigger元素,当Products集合中当前项的HasChanges属性为True时,将矩形的Fill属性设置为GreenGlow资源,另一个将Fill属性设置为RedGlow资源,当当前项的HasErrors属性为True时。由于声明突出显示错误的触发器在声明突出显示有效更改的触发器之后,如果两个条件都为True,这将覆盖第一个,这对于本例至关重要。
接下来,我们需要修改我们为第一个产品示例添加到应用程序资源中的默认样式。让我们将这些基于原始样式的新样式添加到我们的ProductNotifyViewGeneric类中,以便它们覆盖默认样式:
<Style x:Key="WhiteLabelStyle" TargetType="{x:Type TextBlock}"
BasedOn="{StaticResource LabelStyle}">
<Setter Property="Foreground" Value="White" />
</Style>
<Style x:Key="ErrorFreeFieldStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource FieldStyle}">
<Setter Property="Validation.ErrorTemplate" Value="{x:Null}" />
</Style>
由于这些新样式基于之前的样式,我们保留了相同的属性值,但为每个样式添加了一个额外的属性。WhiteLabelStyle 样式将 Foreground 属性设置为 White,而 ErrorFreeFieldStyle 样式将 Validation.ErrorTemplate 附加属性设置为 null,因为我们将在本例中用其他方式突出显示验证错误。
现在我们来看一下新 ProductNotifyGeneric 类的数据模板资源,它使用了我们的新 ProductItemGlowStyle 样式,首先确保我们为 DataModels 和 Views 项目添加了几个 XML 命名空间前缀:
xmlns:DataModels="clr-namespace:CompanyName.ApplicationName.DataModels;
assembly=CompanyName.ApplicationName.DataModels"
...
<DataTemplate DataType="{x:Type DataModels:ProductNotifyGeneric}">
<Border CornerRadius="3" BorderBrush="{StaticResource TransparentBlack}"
BorderThickness="1" Background="{StaticResource TransparentWhite}">
<Border Name="InnerBorder" CornerRadius="2" Margin="1"
Background="{StaticResource LayeredButtonBackground}">
<Grid>
<Rectangle IsHitTestVisible="False" RadiusX="2" RadiusY="2"
Style="{StaticResource ProductItemGlowStyle}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image Width="24" Height="24"
Source="pack://application:,,,/CompanyName.ApplicationName;
component/Images/Product.ico" VerticalAlignment="Center"
Margin="3,2,5,2" />
<TextBlock Grid.Column="1" HorizontalAlignment="Left"
VerticalAlignment="Center" Text="{Binding Name}"
TextWrapping="Wrap" Margin="0,1,5,3" Foreground="White"
FontSize="14" Validation.ErrorTemplate="{x:Null}" />
<Button Grid.Column="2"
Command="{Binding DataContext.DeleteCommand,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type Views:ProductNotifyViewGeneric}}}"
CommandParameter="{Binding}" Margin="0,2,4,2"
Width="20" Height="20">
<Image Width="16" Height="16"
Source="pack://application:,,,/CompanyName.ApplicationName;
component/Images/Delete_16.png"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
</Grid>
</Border>
</Border>
</DataTemplate>
在本例中,我们重用了来自第八章的“创建视觉吸引人的用户界面”的双边框技术,因此无需再次检查该代码。在边框内,我们声明了一个 Grid 面板,其中包含一个应用了我们的新 ProductItemGlowStyle 样式的 Rectangle 元素,以及另一个 Grid 面板来显示每个用户的名称和一些图像。
这些图像来自我们之前讨论过的 Visual Studio 图像库,我们使用其中的第一个来表示这些对象是产品。三个元素中的每个 VerticalAlignment 属性都设置为 Center,以确保它们在垂直方向上对齐,而 TextBlock 元素的 TextWrapping 属性设置为 Wrap,以防任何产品名称过长。
注意,这里将 Validation 类的 ErrorTemplate 属性设置为 null,是为了移除默认的错误模板,该模板通常以不吸引人的红色矩形的形式出现。由于当对象有错误时,我们将整个对象发光变红,因此不需要同时显示默认模板。
第二张图像指定了这些项目中的每个都可以被删除。请注意,它是在 Button 控件中声明的,尽管我们没有尝试对该按钮进行样式化,但它也可以被给予双边框处理或其他任何自定义样式。此按钮是可选的,但仅作为从视图模型到每个数据对象的命令链接的示例。
注意,按钮的 Command 属性中的绑定路径使用 RelativeSource 绑定来引用 ProductNotifyViewGeneric 类的祖先。特别是,它引用了视图的 DataContext 的 DeleteCommand 属性,在我们的情况下,这是一个 ProductNotifyViewModelGeneric 类的实例。
然后,CommandParameter 属性被绑定到每个数据模板的整个数据上下文,这意味着整个 ProductNotifyGeneric 数据模型对象将作为命令参数传递。使用我们的 ActionCommand 类,这通过 ProductNotifyViewModelGeneric 类中早期示例的 action 和 canExecute 字段来指定。
现在我们已经用这个数据模板在ListBox控件中设置了ProductNotifyGeneric项的样式,我们还可以做另外一件事来进一步改善外观;我们可以移除包裹我们的数据模型的ListBoxItem元素的默认选择矩形。在.NET 3.5 及之前版本中,我们只需向ListBoxItem类的样式添加一些资源,就可以完成这项工作:
<Style TargetType="{x:Type ListBoxItem}">
<Style.Resources>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
Color="Transparent" />
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}"
Color="Transparent" />
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}"
Color="Black" />
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}"
Color="Black" />
</Style.Resources>
</Style>
然而,从.NET 4.0 开始,这将不再工作。相反,我们现在需要为ListBoxItem类定义一个新的ControlTemplate对象,该对象在选中时或当用户的鼠标光标悬停在其上时不会突出显示其背景:
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="2,2,2,0" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Border x:Name="Bd" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
HorizontalAlignment="{TemplateBinding
HorizontalContentAlignment}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="TextElement.Foreground"
TargetName="Bd" Value="{DynamicResource
{x:Static SystemColors.GrayTextBrushKey}}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
要创建这种样式的ControlTemplate元素,我们首先访问了ListBoxItem类的默认模板,如第五章中“修改现有控件”部分所述,使用适合工作的控件,然后简单地移除了着色背景的触发器。接着,我们将它添加到一个没有x:Key指令的样式,这样它就会隐式地应用于作用域内的所有ListBoxItem元素。
接下来,我们有ErrorBorderStyle样式,它为我们的全局验证错误显示的边框设置样式,并使用我们的BoolToVisibilityConverter类设置Visibility属性,当Products集合中当前项的HasErrors属性为True时显示控件:
<Style x:Key="ErrorBorderStyle" TargetType="{x:Type Border}">
<Setter Property="BorderBrush" Value="#7BFF0000" />
<Setter Property="Background" Value="#FFFFDFE1" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="2.75" />
<Setter Property="Padding" Value="5,3" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Visibility"
Value="{Binding Products.CurrentItem.HasErrors,
Converter={StaticResource BoolToVisibilityConverter},
FallbackValue=Collapsed, Mode=OneWay}" />
</Style>
现在我们已经为我们的视图添加了所有必需的资源,让我们继续查看ProductNotifyViewGeneric类中的 XAML 文件,它使用了这些资源:
<Grid Margin="20">
<Grid.Resources>
...
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ListBox ItemsSource="{Binding Products}"
SelectedItem="{Binding Products.CurrentItem}" Margin="0,0,20,0"
HorizontalContentAlignment="Stretch" />
<Border Grid.Column="1" CornerRadius="3"
BorderBrush="{StaticResource TransparentBlack}" BorderThickness="1"
Background="{StaticResource TransparentWhite}">
<Border Name="InnerBorder" CornerRadius="2" Margin="1"
Background="{StaticResource LayeredButtonBackground}">
<Grid>
<Rectangle IsHitTestVisible="False" RadiusX="2" RadiusY="2"
Style="{StaticResource ProductGlowStyle}" />
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock Text="Name"
Style="{StaticResource WhiteLabelStyle}" />
<TextBox Grid.Column="1"
Text="{Binding Products.CurrentItem.Name,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ErrorFreeFieldStyle}" />
<TextBlock Grid.Row="1" Text="Price"
Style="{StaticResource WhiteLabelStyle}" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding Products.CurrentItem.Price,
UpdateSourceTrigger=PropertyChanged, Delay=250}"
Style="{StaticResource ErrorFreeFieldStyle}"
Attached:TextBoxProperties.IsNumericOnly="True" />
<Border Grid.Row="2" Grid.ColumnSpan="2" Style="{StaticResource
ErrorBorderStyle}" Margin="0,0,0,10" Padding="10">
<ItemsControl ItemsSource="{Binding Products.CurrentItem.Errors}"
ItemTemplate="{StaticResource WrapTemplate}" />
</Border>
</Grid>
</Grid>
</Border>
</Border>
</Grid>
我们使用与上一个例子相同的Grid面板,左侧有一个ListBox控件,右侧有一些表单控件。请注意,我们在ListBox控件上设置了HorizontalContentAlignment属性为Stretch,以确保其ListBoxItem元素可以拉伸以适应整个宽度。
在右侧,我们看到双边框和用我们创建的发光颜色资源着色的Rectangle元素,该资源在第八章中介绍,创建视觉吸引人的用户界面。而不是像之前那样硬编码一个特定的颜色资源,我们现在将我们的新ProductGlowStyle样式应用到它上面,这样它就会根据数据的有效性,通过其数据触发器改变颜色。
注意,我们添加了一个外部的Grid面板,它只包含发光矩形和原始的Grid面板,这现在为我们的表单添加了外部边距。原始面板与上一个例子相比变化不大,尽管错误显示边框现在使用我们新的ErrorBorderStyle样式,并且显示在表单字段下方,以承认一些用户不喜欢字段在错误出现和消失时移动。
表单字段也大多保持不变,尽管在使用我们新的实现时,我们不再需要在每个绑定上设置 ValidatesOnNotifyDataErrors 属性为 True。我们还应用了我们的新 WhiteLabelStyle 和 ErrorFreeFieldStyle 样式到表单标签和字段上,以便将标签前景色设置为白色,并在存在验证错误时隐藏默认的红色错误边框。
当现在运行此视图时,它将渲染以下视觉输出,表单和错误项目上都有红色发光效果:

在纠正错误后,我们会在表单和编辑的项目上看到绿色发光:

保存更改后,我们需要再次在 Products 集合上调用 Synchronize 方法,然后我们会看到以下截图,其中所有对象现在都涂上了默认的蓝色发光效果:

以这种方式,我们能够使用发光的颜色清楚地通知用户在任何给定时间控件的状态。
摘要
在本章中,我们全面了解了 .NET Framework 提供的数据验证选项,主要集中讨论了实现两个可用验证接口的多种方法。我们研究了数据注释验证属性的使用,探讨了提供自定义错误模板,并将我们新获得的知识与 第八章,创建视觉上吸引人的用户界面 中的知识相结合,以构建一个视觉上令人愉悦的验证示例。
在下一章中,我们将探讨多种方式,通过这些方式我们可以为我们的应用程序用户提供出色的用户体验,从异步编程到反馈机制。我们还将研究如何利用应用程序设置来提供用户偏好,并探索向应用程序用户提供应用程序内帮助的各种方式。我们将以进一步探讨为最终用户提供改进用户体验的额外方式结束。
第十章:完成卓越的用户体验
正如我们所见,向视图中添加表单字段并生成视觉上吸引人且功能足够的应用程序很容易。然而,要为最终用户提供一个真正满足所有要求的界面则需要更多的工作。例如,你有多少次点击应用程序中的按钮,而整个应用程序在执行某些工作时会冻结?
在本章中,我们将探讨通过使用异步编程以及许多其他提高最终用户体验的方法来解决此问题。例如,我们将研究启用用户使用他们自己的用户偏好设置自定义应用程序版本的方法。
我们将讨论通过提供用户反馈来保持用户知情,并通过添加反馈系统来更新我们的应用程序框架。我们将探讨提供应用程序内帮助文件和文档的几种替代方法,以及许多使应用程序更用户友好并使用户生活更加轻松的其他方法。
提供用户反馈
一个优秀应用程序的一个基本方面是让最终用户了解应用程序中的情况。如果他们点击一个功能按钮,他们应该被告知操作的进度或状态。如果没有足够的反馈,用户可能会感到困惑,不知道某个特定操作是否成功,并可能尝试多次运行它,这可能会导致错误。
因此,在我们的应用程序框架中实现一个反馈系统是至关重要的。到目前为止,在这本书中,我们在几个地方看到了 FeedbackManager 类的名称,尽管我们看到的实现非常少。现在让我们看看我们如何在应用程序框架中实现一个有效的反馈系统,从包含单个反馈消息的 Feedback 类开始:
using System;
using System.ComponentModel;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.DataModels.Interfaces;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
public class Feedback : IAnimatable, INotifyPropertyChanged
{
private string message = string.Empty;
private FeedbackType type = FeedbackType.None;
private TimeSpan duration = new TimeSpan(0, 0, 4);
private bool isPermanent = false;
private Animatable animatable;
public Feedback(string message, FeedbackType type, TimeSpan duration)
{
Message = message;
Type = type;
Duration = duration == TimeSpan.Zero ? this.duration : duration;
IsPermanent = false;
Animatable = new Animatable(this);
}
public Feedback(string message, bool isSuccess, bool isPermanent) :
this(message, isSuccess ? FeedbackType.Success :
FeedbackType.Error, TimeSpan.Zero)
{
IsPermanent = isPermanent;
}
public Feedback(string message, FeedbackType type) : this(message,
type, TimeSpan.Zero) { }
public Feedback(string message, bool isSuccess) : this(message,
isSuccess ? FeedbackType.Success : FeedbackType.Error,
TimeSpan.Zero) { }
public Feedback() : this(string.Empty, FeedbackType.None) { }
public string Message
{
get { return message; }
set { message = value; NotifyPropertyChanged(); }
}
public TimeSpan Duration
{
get { return duration; }
set { duration = value; NotifyPropertyChanged(); }
}
public FeedbackType Type
{
get { return type; }
set { type = value; NotifyPropertyChanged(); }
}
public bool IsPermanent
{
get { return isPermanent; }
set { isPermanent = value; NotifyPropertyChanged(); }
}
#region IAnimatable Members
public Animatable Animatable
{
get { return animatable; }
set { animatable = value; }
}
#endregion
#region INotifyPropertyChanged Members
...
#endregion
}
}
注意,我们的 Feedback 类实现了我们之前看到的 IAnimatable 接口,以及 INotifyPropertyChanged 接口。在声明私有字段之后,我们声明了多个有用的构造函数重载。
在此示例中,我们为 duration 字段硬编码了一个默认的反馈显示时长为四秒。在主构造函数中,我们根据 duration 输入参数的值设置 Duration 属性;如果输入参数是 TimeSpan.Zero 字段,则使用默认值,但如果输入参数是非零值,则使用该值。
Message 属性将保存反馈信息;Duration 属性指定了消息显示的时长;Type 属性使用我们之前看到的 FeedbackType 枚举来指定消息的类型,而 IsPermanent 属性决定了消息是否应该永久显示,直到用户手动关闭它为止。
我们IAnimatable类的实现显示在其他属性下方,它仅由Animatable属性组成,但为了简洁起见,我们省略了INotifyPropertyChanged接口的实现,因为我们正在使用之前看到的默认实现。
现在,让我们看看将包含单个Feedback实例的FeedbackCollection类:
using System.Collections.Generic;
using System.Linq;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class FeedbackCollection : BaseAnimatableCollection<Feedback>
{
public FeedbackCollection(IEnumerable<Feedback> feedbackCollection) :
base(feedbackCollection) { }
public FeedbackCollection() : base() { }
public new void Add(Feedback feedback)
{
if (!string.IsNullOrEmpty(feedback.Message) && (Count == 0 ||
!this.Any(f => f.Message == feedback.Message))) base.Add(feedback);
}
public void Add(string message, bool isSuccess)
{
Add(new Feedback(message, isSuccess));
}
}
}
FeedbackCollection类扩展了我们之前看到的BaseAnimatableCollection类,并将它的泛型类型参数设置为Feedback类。这是一个非常简单的类,声明了几个构造函数,将任何输入参数直接传递给基类构造函数。
此外,它声明了两个Add方法,第二个方法只是从其输入参数创建一个Feedback对象,并将其传递给第一个方法。第一个方法首先检查反馈消息不是null或空,并且相同的消息尚未包含在反馈集合中,然后再将新消息添加到集合中。
注意,我们当前的实现使用基类Add方法将新项目添加到反馈集合的末尾。我们也可以选择在这里使用基类的Insert方法,将新项目添加到集合的开头。
现在,让我们看看使用这两个类的FeedbackManager类:
using System.ComponentModel;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.DataModels.Collections;
namespace CompanyName.ApplicationName.Managers
{
public class FeedbackManager : INotifyPropertyChanged
{
private static FeedbackCollection feedback = new FeedbackCollection();
private static FeedbackManager instance = null;
private FeedbackManager() { }
public static FeedbackManager Instance =>
instance ?? (instance = new FeedbackManager());
public FeedbackCollection Feedback
{
get { return feedback; }
set { feedback = value; NotifyPropertyChanged(); }
}
public void Add(Feedback feedback)
{
Feedback.Add(feedback);
}
public void Add(string message, bool isSuccess)
{
Add(new Feedback(message, isSuccess));
}
#region INotifyPropertyChanged Members
...
#endregion
}
}
FeedbackManager类还实现了INotifyPropertyChanged接口,在其中我们看到静态的FeedbackCollection字段。接下来,我们看到静态的instance字段、私有构造函数以及类型为FeedbackManager的静态Instance属性,它首次使用时实例化instance字段,并告诉我们这个类遵循单例模式。
Feedback属性随后是类对FeedbackCollection字段的访问。之后,我们看到一系列方便的重载的Add方法,它允许开发者使用不同的参数添加反馈。在这里,为了简洁起见,我们再次省略了INotifyPropertyChanged接口的实现,但它使用我们之前看到的默认实现。
现在,让我们关注FeedbackControl对象的 XAML:
<UserControl
x:Class="CompanyName.ApplicationName.Views.Controls.FeedbackControl"
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
xmlns:DataModels="clr-namespace:CompanyName.ApplicationName.DataModels;
assembly=CompanyName.ApplicationName.DataModels"
>
<UserControl.Resources>
<Converters:FeedbackTypeToImageSourceConverter
x:Key="FeedbackTypeToImageSourceConverter" />
<Converters:BoolToVisibilityConverter
x:Key="BoolToVisibilityConverter" />
<ItemsPanelTemplate x:Key="AnimatedPanel">
<Panels:AnimatedStackPanel />
</ItemsPanelTemplate>
<Style x:Key="SmallImageInButtonStyle" TargetType="{x:Type Image}"
BasedOn="{StaticResource ImageInButtonStyle}">
<Setter Property="Width" Value="16" />
<Setter Property="Height" Value="16" />
</Style>
<DataTemplate x:Key="FeedbackTemplate" DataType="{x:Type
DataModels:Feedback}">
<Grid Margin="2,1,2,0" MouseEnter="Border_MouseEnter"
MouseLeave="Border_MouseLeave">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition />
<ColumnDefinition Width="24" />
</Grid.ColumnDefinitions>
<Image Stretch="None" Source="{Binding Type,
Converter={StaticResource FeedbackTypeToImageSourceConverter}}"
VerticalAlignment="Top" Margin="0,4,0,0" />
<TextBlock Grid.Column="1" Text="{Binding Message}"
MinHeight="22" TextWrapping="Wrap" Margin="5,2,5,0"
VerticalAlignment="Top" FontSize="14" />
<Button Grid.Column="2" ToolTip="Removes this message from the
list" VerticalAlignment="Top" PreviewMouseLeftButtonDown=
"DeleteButton_PreviewMouseLeftButtonDown">
<Image Source="pack://application:,,,/
CompanyName.ApplicationName;component/Images/Delete_16.png"
Style="{StaticResource SmallImageInButtonStyle}" />
</Button>
</Grid>
</DataTemplate>
<DropShadowEffect x:Key="Shadow" Color="Black" ShadowDepth="6"
Direction="270" Opacity="0.4" />
</UserControl.Resources>
<Border BorderBrush="{StaticResource TransparentBlack}"
Background="White" Padding="3" BorderThickness="1,0,1,1"
CornerRadius="0,0,5,5" Visibility="{Binding HasFeedback,
Converter={StaticResource BoolToVisibilityConverter},
RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type Controls:FeedbackControl}}}"
Effect="{StaticResource Shadow}">
<ListBox MaxHeight="89" ItemsSource="{Binding Feedback,
RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type Controls:FeedbackControl}}}"
ItemTemplate="{StaticResource FeedbackTemplate}"
ItemsPanel="{StaticResource AnimatedPanel}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto" BorderThickness="0"
HorizontalContentAlignment="Stretch" />
</Border>
</UserControl>
我们首先为我们的应用程序项目的一些部分添加了多个 XAML 命名空间前缀。使用Converters前缀,我们将之前看到的FeedbackTypeToImageSourceConverter和BoolToVisibilityConverter类的实例添加到UserControl.Resources部分。我们还重用了来自第七章,《精通实用动画》的AnimatedStackPanel类。
接下来,我们看到的是SmallImageInButtonStyle样式,它基于我们之前也看到过的ImageInButtonStyle样式,并添加了一些尺寸属性。之后,我们看到的是FeedbackStyle样式,它定义了在我们的反馈控制中每个反馈消息的外观。
每个Feedback对象将在三列中渲染:第一列包含一个指定反馈类型的图像,使用我们之前看到的FeedbackTypeToImageSourceConverter类;第二列显示消息,具有TextWrapping值为Wrap;第三列包含一个带有图像的按钮,使用我们的SmallImageInButtonStyle样式,用户可以使用它来删除消息。
注意,由于这是一个纯 UI 控件,其中没有业务逻辑,因此即使在使用 MVVM 的情况下,我们也能使用文件背后的代码。因此,我们将MouseEnter和MouseLeave事件的事件处理器附加到包含每个Feedback对象的Grid面板上,并将另一个用于PreviewMouseLeftButtonDown事件的处理器附加到删除按钮上。我们这里最后的资源是一个定义了小阴影效果的DropShadowEffect实例。
对于反馈控制,我们定义了一个使用半透明边框画笔,BorderThickness值为1,0,1,1,CornerRadius值为0,0,5,5的Border元素。这四个值就像Margin属性一样工作,使我们能够为四个侧面或CornerRadius属性的情况下的每个角落设置不同的值。这样,我们可以显示一个只有三边有边框,两个角落是圆角的矩形。
注意,这个边框上的Visibility属性是通过FeedbackControl类的HasFeedback属性以及我们BoolToVisibilityConverter类的一个实例来确定的。因此,当没有要显示的反馈对象时,边框将被隐藏。另外,请注意,我们的Shadow资源被应用到边框的Effect属性上。
在边框内部,我们声明了一个ListBox控件,其ItemsSource属性被设置为FeedbackControl类的Feedback属性,其高度限制为最多三个反馈项,之后将显示垂直滚动条。其ItemTemplate属性被设置为我们在资源部分定义的FeedbackTemplate。
其ItemsPanel属性被设置为我们在资源中声明的用于动画反馈项进入和退出的AnimatedPanel资源。接下来,我们通过将BorderThickness属性设置为0来移除ListBox的默认边框,并通过将HorizontalContentAlignment属性设置为Stretch来拉伸自动生成的ListBoxItem对象以适应ListBox控件宽度。
现在我们来看看反馈控制的代码:
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.DataModels.Collections;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class FeedbackControl : UserControl
{
private static List<DispatcherTimer> timers =
new List<DispatcherTimer>();
public FeedbackControl()
{
InitializeComponent();
}
public static readonly DependencyProperty FeedbackProperty =
DependencyProperty.Register(nameof(Feedback),
typeof(FeedbackCollection), typeof(FeedbackControl),
new UIPropertyMetadata(new FeedbackCollection(),
(d, e) => ((FeedbackCollection)e.NewValue).CollectionChanged +=
((FeedbackControl)d).Feedback_CollectionChanged));
public FeedbackCollection Feedback
{
get { return (FeedbackCollection)GetValue(FeedbackProperty); }
set { SetValue(FeedbackProperty, value); }
}
public static readonly DependencyProperty HasFeedbackProperty =
DependencyProperty.Register(nameof(HasFeedback), typeof(bool),
typeof(FeedbackControl), new PropertyMetadata(true));
public bool HasFeedback
{
get { return (bool)GetValue(HasFeedbackProperty); }
set { SetValue(HasFeedbackProperty, value); }
}
private void Feedback_CollectionChanged(object sender,
NotifyCollectionChangedEventArgs e)
{
if ((e.OldItems == null || e.OldItems.Count == 0) &&
e.NewItems != null && e.NewItems.Count > 0)
{
e.NewItems.OfType<Feedback>().Where(f => !f.IsPermanent).
ForEach(f => InitializeTimer(f));
}
HasFeedback = Feedback.Any();
}
private void InitializeTimer(Feedback feedback)
{
DispatcherTimer timer = new DispatcherTimer();
timer.Interval = feedback.Duration;
timer.Tick += Timer_Tick;
timer.Tag = new Tuple<Feedback, DateTime>(feedback, DateTime.Now);
timer.Start();
timers.Add(timer);
}
private void Timer_Tick(object sender, EventArgs e)
{
DispatcherTimer timer = (DispatcherTimer)sender;
timer.Stop();
timer.Tick -= Timer_Tick;
timers.Remove(timer);
Feedback feedback = ((Tuple<Feedback, DateTime>)timer.Tag).Item1;
Feedback.Remove(feedback);
}
private void DeleteButton_PreviewMouseLeftButtonDown(object sender,
MouseButtonEventArgs e)
{
Button deleteButton = (Button)sender;
Feedback feedback = (Feedback)deleteButton.DataContext;
Feedback.Remove(feedback);
}
private void Border_MouseEnter(object sender, MouseEventArgs e)
{
foreach (DispatcherTimer timer in timers)
{
timer.Stop();
Tuple<Feedback, DateTime> tag =
(Tuple<Feedback, DateTime>)timer.Tag;
tag.Item1.Duration = timer.Interval = tag.Item1.Duration.
Subtract(DateTime.Now.Subtract(tag.Item2));
}
}
private void Border_MouseLeave(object sender, MouseEventArgs e)
{
foreach (DispatcherTimer timer in timers)
{
Feedback feedback = ((Tuple<Feedback, DateTime>)timer.Tag).Item1;
timer.Tag = new Tuple<Feedback, DateTime>(feedback, DateTime.Now);
timer.Start();
}
}
}
}
我们首先声明了一个DispatcherTimer实例的集合,这些实例将负责根据每个反馈对象的Duration属性来计时,以确定何时从集合中移除每个反馈对象。然后我们看到Feedback和HasFeedback依赖属性的声明,以及它们的 CLR 包装和Feedback属性的CollectionChanged处理程序。
在附加的Feedback_CollectionChanged处理方法中,我们调用InitializeTimer方法,传入每个新的非永久反馈项。请注意,我们需要使用OfType LINQ 扩展方法将NotifyCollectionChangedEventArgs类的NewItems属性中的每个项从类型object转换为Feedback。在将控制权返回给调用者之前,我们相应地设置HasFeedback属性。
在InitializeTimer方法中,我们初始化一个DispatcherTimer实例,并将其间隔设置为feedback输入参数的Duration属性中的值。然后我们附加Timer_Tick事件处理程序,将当前时间和反馈对象添加到计时器的Tag属性中以便后续使用,启动计时器,并将其添加到timers集合中。
在Timer_Tick方法中,我们从sender输入参数访问计时器,并从其Tag属性访问Feedback实例。然后,反馈项从Feedback集合中移除,计时器停止并从timers集合中移除,并且Tick事件处理程序被断开连接。
在DeleteButton_PreviewMouseLeftButtonDown方法中,我们首先将删除按钮从sender输入参数转换。然后,我们将Feedback对象从按钮的DataContext属性转换,并将其从Feedback集合中移除。
在Border_MouseEnter方法中,我们遍历timers集合,并停止每个计时器。然后,每个计时器的间隔和每个相关联的Feedback对象的持续时间被设置为它们应该显示的剩余时间,实际上暂停了它们的持续时间。
最后,我们看到Border_MouseLeave方法,它重新初始化timers集合中每个计时器的Tag属性,使用相同的反馈项和当前日期和时间,并在用户将鼠标指针移出反馈控件时重新启动它。
这意味着如果用户将鼠标指针移到反馈控件上,临时反馈消息的显示时间可以延长。这个功能将保持反馈消息在控件中,直到用户将鼠标指针保持在控件上,从而给他们足够的时间阅读消息。现在让我们看看这个控件的样子:

如果你的视图顶部有菜单按钮,那么反馈可以出现在应用程序的底部,或者甚至从一侧滑入。此外,请注意,删除按钮尚未样式化,以缩短此示例,但在实际应用程序中,它们应该与其它控件保持一致的风格。
如果您还记得第三章中的内容,即编写自定义应用程序框架,那么我们所有的视图模型都将通过BaseViewModel类中的FeedbackManager属性访问我们新的FeedbackManager类,因此我们可以像这样从任何视图模型中复制前面的反馈图像:
FeedbackManager.Add(new Feedback("Here's some information for you",
FeedbackType.Information));
FeedbackManager.Add("Something was saved successfully", true);
FeedbackManager.Add("Something else went wrong", false);
FeedbackManager.Add("Something else went wrong too", false);
现在,让我们继续探索如何通过最大化 CPU 的利用率来使我们的应用程序更加响应。
利用多线程
传统上,所有应用程序都是作为单线程应用程序开发的。然而,当长时间运行的后台进程正在运行时,应用程序的用户界面会冻结并变得无响应,因为单个线程正忙于其他地方。这个问题以及其他性能瓶颈导致了当前异步编程和多线程应用程序的时代。
在过去的日子里,创建多线程应用程序是一件复杂的事情。随着.NET Framework 每个后续版本的推出,微软一直努力使这项任务变得更简单。最初,我们只有Thread类,然后在.NET 2.0 中引入了BackgroundWorker类,但在.NET 4.0 中,他们引入了Task类,而在.NET 4.5 中,他们引入了async和await关键字。
在本节中,我们将探讨多线程的后继方法,并为我们应用程序框架添加功能,使我们能够异步执行我们的数据检索和更新操作。让我们首先看看async和await关键字。
发现 Async 和 Await 关键字
与这些新关键字一起,微软还在.NET Framework 中添加了许多以Async后缀结尾的方法。正如后缀所暗示的,这些方法都是异步的,并且与新的关键字一起使用。让我们从基本规则开始。
首先,为了在方法中使用await关键字,方法签名必须用async关键字声明。async关键字使我们能够在方法中使用await关键字而不会出错,并且负责从异步方法返回仅T泛型类型参数,这些方法的签名声明了返回类型为Task<T>。修改了async关键字的方法被称为异步方法。
异步方法实际上是以同步方式执行的,直到它们达到一个await表达式。如果方法中没有await关键字,那么整个方法将以同步方式运行,编译器将输出一个警告。
虽然部分异步方法以异步方式运行,但实际上它们并不是在它们自己的线程上运行的。使用async和await关键字不会创建额外的线程。相反,它们通过使用当前的同步上下文来提供多线程的外观,但只有在方法活动时,而不是在方法暂停并运行await表达式时。
当执行到达一个await关键字时,方法将挂起,直到所等待的任务异步完成。在这段时间内,执行返回到方法调用者。当异步操作完成时,程序执行返回到方法,并同步运行其中的剩余代码。
异步方法需要具有特定的签名。它们都需要使用async修饰符关键字,并且除了这一点之外,异步方法的名称应该以Async后缀结尾,以清楚地表明它们是异步方法。声明异步方法的另一个要求是,它们不能包含任何ref或out输入参数。
最后一个要求是,异步方法只能使用三种返回类型之一:Task、泛型Task<TResult>或void。请注意,泛型类型参数TResult与T相同,可以被替换,但 Microsoft 将其称为TResult,因为它指定了返回类型。
所有返回一些有意义结果的异步方法都将使用类型Task<TResult>,其中实际返回值的类型将由TResult泛型类型参数指定。因此,如果我们想从我们的异步方法返回一个string,我们声明我们的异步方法返回一个类型为Task<string>的参数。让我们看看这个动作的例子:
using System;
using System.IO;
using System.Threading.Tasks;
...
public async Task<string> GetTextFileContentsAsync(string filePath)
{
string fileContents = string.Empty;
try
{
using (StreamReader streamReader = File.OpenText(filePath))
{
fileContents = await streamReader.ReadToEndAsync();
}
}
catch { /*Log error*/ }
return fileContents;
}
这里我们有一个简单的异步方法,它返回一个表示由filePath输入参数指定的文本文件内容的string。请注意,方法的实际返回类型实际上是Task<string>。在其中,我们首先初始化fileContents变量,然后尝试在using语句中从File.OpenText方法创建一个StreamReader实例。
在using语句内部,我们尝试通过等待StreamReader类的ReadToEndAsync方法的结果来填充fileContents变量。直到这一点,方法将以同步方式运行。将调用ReadToEndAsync方法,然后控制将立即返回到我们的异步方法的调用者。
当ReadToEndAsync方法的返回值准备好时,执行将返回到我们的异步方法,并从上次离开的地方继续。在我们的例子中,除了返回结果字符串之外,没有其他事情要做,尽管异步方法可以在await关键字之后包含任意数量的行,甚至多个await关键字。请注意,在实际应用中,我们会记录从这个方法抛出的任何异常。
如果我们的异步方法只是异步执行某些功能,但不返回任何内容,那么我们使用返回类型Task。也就是说,基于任务的异步方法将返回一个Task对象,使其能够与await关键字一起使用,但实际的方法不会向该方法的调用者返回任何内容。让我们看看这个例子:
using System.Text;
...
public async Task SetTextFileContentsAsync(string filePath,
string contents)
{
try
{
byte[] encodedFileContents = Encoding.Unicode.GetBytes(contents);
using (FileStream fileStream = new FileStream(filePath,
FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 4096, true))
{
await fileStream.WriteAsync(encodedFileContents, 0,
encodedFileContents.Length);
}
}
catch { /*Log error*/ }
}
在SetTextFileContentsAsync方法中,我们首先需要将我们的输入字符串转换为字节数组。因此,我们现在需要添加一个using指令来指定System.Text命名空间,除了最初指定的三个之外。请注意,在这个特定的例子中,我们使用的是Unicode编码,但你也可以使用任何其他的编码值。
在使用GetBytes方法从contents输入参数获取字节数组之后,我们在另一个using语句中初始化一个新的FileStream对象。除了bool类型的useAsync输入参数之外,在这个示例中用于FileStream构造函数的其他参数并不重要,你可以自由地用更适合你需求的值替换它们。
在using语句内部,我们看到await关键字与WriteAsync方法一起使用。到目前为止,此方法将以同步方式运行,并且在这一行上,它将开始执行WriteAsync方法,然后返回控制权给方法调用者。
当执行离开using语句时,FileStream实例将被关闭并释放。由于此方法没有返回值,异步方法的返回类型是Task,这使得它能够被调用代码挂起。再次提醒,我们通常会记录可能从这个方法抛出的任何异常,但在这里为了简洁省略了这一步骤。
当使用 MVVM 时,我们中的大多数人永远不会使用void的第三个返回类型选项,因为它主要用于事件处理方法。请注意,返回void的异步方法不能被挂起,并且调用代码不能捕获此类异步方法抛出的异常。
关于异步方法最常问的问题之一是“我如何从一个同步方法创建一个异步方法?”幸运的是,使用Task.Run方法有一个非常简单的解决方案,现在让我们快速看一下它:
await Task.Run(() => SynchronousMethod(parameter1, parameter2, etc));
在这里,我们使用 Lambda 表达式来指定在异步上下文中运行的同步方法。这就是我们运行同步方法异步所需做的全部。然而,对于相反的要求呢?现在让我们看看我们如何可以同步地运行异步方法。同样,Task类为我们提供了一个解决方案:
Task task = SetFileContentsAsync(filePath, contents);
task.RunSynchronously();
正如我们在第一章的结尾所看到的,使用 WPF 的更智能的工作方式,为了同步地运行异步方法,我们首先需要从我们的异步方法中实例化一个Task对象。然后,我们只需调用该实例上的RunSynchronously方法,它就会同步运行。
将异步性构建到我们的框架中
使用Task类,我们可以将功能添加到我们的应用程序框架中,使我们能够异步调用任何数据访问方法。此外,它还将使我们能够在应用程序运行时异步执行数据操作,在测试时同步执行。为了实现这一点,我们需要实现几个部分,它们共同提供这一功能。
让我们看看第一部分,它将包装每个数据操作,如果适用,则包含结果值、任何反馈消息或错误详情:
using System;
using System.Data.SqlClient;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
public abstract class DataOperationResult<T>
{
public DataOperationResult(string successText)
{
Description = string.IsNullOrEmpty(successText) ?
"The data operation was successful" : successText;
}
public DataOperationResult(Exception exception, string errorText)
{
Exception = exception;
if (Exception is SqlException)
{
if (exception.Message.Contains("The server was not found"))
Error = DataOperationError.DatabaseConnectionError;
else if (exception.Message.Contains("constraint"))
Error = DataOperationError.DatabaseConstraintError;
// else Description = Exception.Message;
}
if (Error != DataOperationError.None)
Description = Error.GetDescription();
else
{
Error = DataOperationError.UndeterminedDataOperationError;
Description = string.IsNullOrEmpty(errorText) ?
Error.GetDescription() : errorText;
}
}
public DataOperationResult(Exception exception) :
this(exception, string.Empty) { }
public string Description { get; set; }
public DataOperationError Error { get; set; } =
DataOperationError.None;
public Exception Exception { get; set; } = null;
public bool IsSuccess =>
Error == DataOperationError.None && Exception == null;
}
}
在我们的抽象DataOperationResult类中,我们有许多属性和构造函数重载。第一个构造函数用于成功的设置数据操作,仅接受successText输入参数,该参数用于填充Description属性,除非它是null或空,在这种情况下,将使用默认的成功操作消息。
第二个构造函数用于在数据操作过程中抛出异常时使用,它接受异常和错误信息作为输入参数。在其中,我们首先将Exception属性设置为exception输入参数指定的异常,然后我们有机会捕获常见的异常,并用简单的英语替换错误信息。
尽管我们在这个例子中只检查SqlException类型的异常,但我们很容易将其扩展以捕获其他已知或预期的异常,并使用通俗易懂的语言替换它们的消息,通过添加额外的else...if条件。
注意,这里使用枚举类型DataOperationError的Error属性来设置和输出预定义的错误信息,我们稍后会看到。如果异常不是我们预期的类型,那么我们可以选择输出实际的异常信息,尽管这对用户来说意义不大,可能会被认为是混淆的,甚至令人担忧。
相反,我们可以在数据库中记录异常,并从errorText输入参数输出消息。我们检查Error属性是否已设置,如果已设置,则调用我们的GetDescription扩展方法来检索与设置枚举成员相关的消息,并将其设置为Description属性。
否则,我们将Error属性设置为UndeterminedDataOperationError成员,如果errorText输入参数不是null或空,则将Description属性设置为errorText的值,如果是,则设置为所选枚举成员关联的文本。第三个构造函数也用于抛出异常的情况,但没有预定义的反馈信息。
在构造函数之后,我们看到 DataOperationResult 类的属性,其中大部分都是不言自明的。特别值得注意的是 IsSuccess 属性,它可以被调用代码用来确定如何处理结果。现在让我们看看用于保存错误描述的 DataOperationError 枚举类:
using System.ComponentModel;
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum DataOperationError
{
[Description("")]
None = 0,
[Description("A database constraint has not been adhered to, so this
operation cannot be completed")]
DatabaseConstraintError = 9995,
[Description("There was an undetermined data operation error")]
UndeterminedDataOperationError = 9997,
[Description("There was a problem connecting to the database")]
DatabaseConnectionError = 9998,
}
}
如您所见,我们利用 DescriptionAttribute 类将人性化错误消息与每个枚举成员相关联。我们可以使用之前看到的 GetDescription 扩展方法来访问属性中的文本值。
每个枚举成员都被分配了一个数字,如果你直接使用 SQL 存储过程或查询,这可以很好地与 SQL Server 错误号配合使用。例如,我们可以将 SQL 错误代码转换为特定的枚举成员,以获取每个错误的自定义消息。现在让我们看看扩展 DataOperationResult 类的两个类:
using System;
namespace CompanyName.ApplicationName.DataModels
{
public class GetDataOperationResult<T> : DataOperationResult<T>
{
public GetDataOperationResult(Exception exception, string errorText) :
base(exception, errorText)
{
ReturnValue = default(T);
}
public GetDataOperationResult(Exception exception) :
this(exception, string.Empty) { }
public GetDataOperationResult(T returnValue, string successText) :
base(successText)
{
ReturnValue = returnValue;
}
public GetDataOperationResult(T returnValue) :
this(returnValue, string.Empty) { }
public T ReturnValue { get; private set; }
}
}
我们从 GetDataOperationResult 类开始,该类用于返回获取数据操作的结果,或者在发生错误时返回异常详细信息。它添加了一个泛型类型 T 的 ReturnValue 属性来保存数据操作的返回值。除了这个单一成员外,它还简单地添加了多个构造函数,每个构造函数都调用基础类构造函数。
第一个用于抛出异常时,并将 ReturnValue 属性设置为默认值,而不是将其保留为 null。第二个构造函数也用于抛出异常时,但没有预定义的错误消息。
第三个构造函数用于成功的数据操作,并将 ReturnValue 属性设置为返回值。第四个也用于成功的数据操作,但没有预定义的成功消息。它调用第三个构造函数,传递返回值和一个空字符串作为成功消息。现在让我们看看扩展 DataOperationResult 类的另一个类:
using System;
namespace CompanyName.ApplicationName.DataModels
{
public class SetDataOperationResult : DataOperationResult<bool>
{
public SetDataOperationResult(Exception exception, string errorText) :
base(exception, errorText) { }
public SetDataOperationResult(string successText) :
base(successText) { }
}
}
SetDataOperationResult 类用于设置操作,因此没有返回值。与 GetDataOperationResult 类类似,它的两个构造函数调用相关的基础类构造函数。第一个用于抛出异常时,第二个用于成功的数据操作,并接受一个输入参数用于操作的成功消息。
我们需要在 FeedbackManager 类中添加一个新的方法,以便我们能够直接从 GetDataOperationResult 和 SetDataOperationResult 类添加反馈消息。我们还将包括一个参数,允许我们覆盖每个消息是否会在其设定的持续时间显示,或者直到用户手动关闭它。现在让我们看看这个:
public void Add<T>(DataOperationResult<T> result, bool isPermanent)
{
Add(new Feedback(result.Description, result.IsSuccess, isPermanent));
}
注意,我们在这里使用DataOperationResult基类作为输入参数,这样我们的任何派生类都可以与它一起使用。此方法简单地从DataOperationResult类的Description和IsSuccess属性初始化一个Feedback对象,并将其传递给实际将其添加到Feedback集合的Add方法。
如果我们将要对 UI 反馈控件进行异步调用,那么我们还需要确保它们在 UI 线程上执行,以避免常见的调用线程无法访问此对象,因为不同的线程拥有它异常。
为了启用此功能,我们需要将之前讨论过的UiThreadManager类引用添加到我们的FeedbackManager类中,尽管在这里我们添加了对IUiThreadManager接口的引用,以便在测试时使用不同的实现:
using System;
using CompanyName.ApplicationName.Managers.Interfaces;
...
private IUiThreadManager uiThreadManager = null;
...
public IUiThreadManager UiThreadManager
{
get { return uiThreadManager; }
set { uiThreadManager = value; }
}
...
public void Add(Feedback feedback)
{
UiThreadManager.RunOnUiThread((Action)delegate
{
Feedback.Add(feedback);
});
}
使用IUiThreadManager接口,我们只需用RunOnUiThread方法包装添加反馈到FeedbackManager.Feedback集合属性的单一调用,以在 UI 线程上运行它。然而,我们的uiThreadManager字段需要在显示任何反馈之前初始化,并且我们可以从BaseViewModel类的第一次使用中完成这一点:
public BaseViewModel()
{
if (FeedbackManager.UiThreadManager == null)
FeedbackManager.UiThreadManager = UiThreadManager;
}
...
public IUiThreadManager UiThreadManager
{
get { return DependencyManager.Instance.Resolve<IUiThreadManager>(); }
}
第一次实例化任何 ViewModel 时,将调用此基类构造函数,并且FeedbackManager类中的IUiThreadManager接口实例将被初始化。当然,为了在运行时正确解析我们的IUiThreadManager接口实例,我们首先需要在App.xaml.cs文件中注册它,以及其他注册项。
DependencyManager.Instance.Register<IUiThreadManager, UiThreadManager>();
让我们现在看看这个接口及其实现它的类:
using System;
using System.Threading.Tasks;
using System.Windows.Threading;
namespace CompanyName.ApplicationName.Managers.Interfaces
{
public interface IUiThreadManager
{
object RunOnUiThread(Delegate method);
Task RunAsynchronously(Action method);
Task<TResult> RunAsynchronously<TResult>(Func<TResult> method);
}
}
IUiThreadManager接口是一个非常简单的事情,只声明了三个方法。RunOnUiThread方法用于在 UI 线程上运行代码;第一个RunAsynchronously方法用于异步运行代码,第二个RunAsynchronously方法用于异步运行返回某些内容的方法。现在让我们看看实现它的类:
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CompanyName.ApplicationName.Managers.Interfaces;
namespace CompanyName.ApplicationName.Managers
{
public class UiThreadManager : IUiThreadManager
{
public object RunOnUiThread(Delegate method)
{
return Application.Current.Dispatcher.Invoke(
DispatcherPriority.Normal, method);
}
public Task RunAsynchronously(Action method)
{
return Task.Run(method);
}
public Task<TResult> RunAsynchronously<TResult>(Func<TResult> method)
{
return Task.Run(method);
}
}
}
在UiThreadManager类中,RunOnUiThread方法调用Application.Current.Dispatcher对象的Invoke方法,以确保传递给它的方法被排队在 UI 线程上运行。
基本上,分发器负责维护特定线程的工作项队列,每个线程都将有自己的分发器。Application.Current属性返回当前AppDomain对象的Application对象,其Dispatcher属性返回在应用程序启动时运行的线程的分发器——UI 线程。
如前所述,RunAsynchronously 方法只是将 method 输入参数指定的方法传递给 Task.Run 方法。我们还在 第一章 中看到了模拟 RunAsynchronously 方法的示例,一种更智能的 WPF 工作方式,但现在让我们看看整个 MockUiThreadManager 类,我们可以在测试我们的应用程序时使用它:
using System;
using System.Threading.Tasks;
using System.Windows.Threading;
using CompanyName.ApplicationName.Managers.Interfaces;
namespace Test.CompanyName.ApplicationName.Mocks.Managers
{
public class MockUiThreadManager : IUiThreadManager
{
public object RunOnUiThread(Delegate method)
{
return method.DynamicInvoke();
}
public Task RunAsynchronously(Action method)
{
Task task = new Task(method);
task.RunSynchronously();
return task;
}
public Task<TResult> RunAsynchronously<TResult>(Func<TResult> method)
{
Task<TResult> task = new Task<TResult>(method);
task.RunSynchronously();
return task;
}
}
}
在 RunOnUiThread 方法中,我们只是调用 Delegate 类的 DynamicInvoke 方法来运行由 method 输入参数指定的方法。正如我们之前看到的,RunAsynchronously 方法使用 Task 类的 RunSynchronously 方法来同步运行由 method 输入参数指定的方法,以避免在测试期间出现时间问题。
在其中,我们首先使用由 method 输入参数指定的方法创建一个新的 Task 对象,然后调用其上的 RunSynchronously 方法,最后返回该任务。当使用 await 关键字调用时,这实际上会返回方法的返回结果。
现在让我们看看这个功能可能最重要的部分,即使用 IUiThreadManager 接口的地方,DataOperationManager 类:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows.Threading;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Managers.Interfaces;
namespace CompanyName.ApplicationName.Managers
{
public class DataOperationManager
{
private const int maximumRetryCount = 2;
private IUiThreadManager uiThreadManager;
public DataOperationManager(IUiThreadManager uiThreadManager)
{
UiThreadManager = uiThreadManager;
}
private IUiThreadManager UiThreadManager
{
get { return uiThreadManager.Instance; }
set { uiThreadManager = value; }
}
private FeedbackManager FeedbackManager
{
get { return FeedbackManager.Instance; }
}
public GetDataOperationResult<TResult> TryGet<TResult>(
Func<TResult> method, string successText, string errorText,
bool isMessageSupressed)
{
Debug.Assert(method != null, "The method input parameter of the
DataOperationManager.TryGet<TResult>() method must not be null.");
for (int index = 0; index < maximumRetryCount; index++)
{
try
{
TResult result = method();
return WithFeedback(
new GetDataOperationResult<TResult>(result, successText),
isMessageSupressed);
}
catch (Exception exception)
{
if (index == maximumRetryCount - 1)
{
return WithFeedback(
new GetDataOperationResult<TResult>(exception, errorText),
isMessageSupressed);
}
Task.Delay(TimeSpan.FromMilliseconds(300));
}
}
return WithFeedback(
new GetDataOperationResult<TResult>(default(TResult), successText),
isMessageSupressed);
}
private GetDataOperationResult<TResult>WithFeedback<TResult>(
GetDataOperationResult<TResult> dataOperationResult, bool
isMessageSupressed)
{
if (isMessageSupressed && dataOperationResult.IsSuccess)
return dataOperationResult;
FeedbackManager.Add(dataOperationResult, false);
return dataOperationResult;
}
public Task<GetDataOperationResult<TResult>> TryGetAsync<TResult>(
Func<TResult> method, string successText, string errorText,
bool isMessageSupressed)
{
return UiThreadManager.RunAsynchronously(() =>
TryGet(method, successText, errorText, isMessageSupressed));
}
public SetDataOperationResult TrySet(Action method,
string successText, string errorText, bool isMessagePermanent,
bool isMessageSupressed)
{
Debug.Assert(method != null, "The method input parameter of the
DataOperationManager.TrySet<TResult>() method must not be null.");
for (int index = 0; index < maximumRetryCount; index++)
{
try
{
method();
return WithFeedback(new SetDataOperationResult(successText),
isMessagePermanent, isMessageSupressed);
}
catch (Exception exception)
{
if (index == maximumRetryCount - 1)
{
return WithFeedback(new SetDataOperationResult(exception,
errorText), isMessagePermanent, isMessageSupressed);
}
Task.Delay(TimeSpan.FromMilliseconds(300));
}
}
return WithFeedback(new SetDataOperationResult(successText),
isMessagePermanent, isMessageSupressed);
}
private SetDataOperationResult WithFeedback(
SetDataOperationResult dataOperationResult,
bool isMessagePermanent, bool isMessageSupressed)
{
if (isMessageSupressed && dataOperationResult.IsSuccess)
return dataOperationResult;
FeedbackManager.Add(dataOperationResult, isMessagePermanent);
return dataOperationResult;
}
public Task<SetDataOperationResult> TrySetAsync(Action method)
{
return TrySetAsync(method, string.Empty, string.Empty);
}
public Task<SetDataOperationResult> TrySetAsync(Action method,
string successText, string errorText)
{
return TrySetAsync(method, successText, errorText, false, false);
}
public Task<SetDataOperationResult> TrySetAsync(Action method,
string successText, string errorText, bool isMessagePermanent,
bool isMessageSupressed)
{
return UiThreadManager.RunAsynchronously(() => TrySet(method,
successText, errorText, isMessagePermanent, isMessageSupressed));
}
}
}
DataOperationManager 类开始于几个私有字段,这些字段代表在出现问题时重试每个数据操作的最大尝试次数,以及用于在运行应用程序时异步运行我们的函数的 IUiThreadManager 接口实例。
构造函数使我们能够将 IUiThreadManager 依赖项注入到类中,并将其设置为私有的 UiThreadManager 属性,该属性只能在类内部访问。同样,FeedbackManager 属性也是私有的,使我们能够将反馈信息传递给管理类以在 UI 中显示。
接下来,我们看到通用的 TryGet<TResult> 方法,它返回一个类型为 GetDataOperationResult<TResult> 的对象。更具体地说,它返回一个类型为 TResult 的泛型对象,被我们自己的 GetDataOperationResult 对象包装。它首先断言 method 输入参数不是 null,因为这个类是基于必需参数的。
在这个方法中,我们创建一个循环,其迭代次数由 maximumRetryCount 字段的值确定,并在循环中尝试运行由 method 输入参数指定的函数。如果数据操作成功,我们初始化一个 GetDataOperationResult 对象,传递返回值和成功反馈信息,并通过 WithFeedback 方法返回它。
如果发生错误且尚未达到最大尝试次数,那么我们使用异步的 Task.Delay 方法等待,然后再尝试再次运行该方法。如果达到最大错误次数,则将异常和错误反馈信息包装在 GetDataOperationResult 对象中,并通过 WithFeedback 方法返回。
我们可以在这里实现的一个改进是,每次重试数据操作时增加这个延迟时间。我们可以实现一个函数,该函数返回一个基于maximumRetryCount字段的指数增长数,表示将传递给Task.Delay方法的毫秒值。这将更有可能更好地处理短暂的网络中断。
WithFeedback方法允许开发者抑制成功反馈消息,因为他们可能并不总是需要用户接收反馈。例如,如果我们已经显示在屏幕上,或者即将显示在屏幕上,我们可能不需要通知他们他们的数据对象已成功从数据库中检索出来。
因此,如果数据操作成功并且isMessageSupressed输入参数为true,则直接返回数据操作结果,不提供反馈。否则,将dataOperationResult输入参数对象传递给FeedbackManager类以显示相关消息,使用我们之前添加的新方法。
接下来,我们看到异步的TryGetAsync方法,它只是通过UiThreadManager类的RunAsynchronously方法调用TryGet方法。之后,我们有TrySet方法,它负责运行所有设置数据操作,并返回类型为SetDataOperationResult的对象。
此方法与TryGet方法非常相似,除了它适用于设置数据操作。同样,它首先断言method输入参数不是null,然后在 for 循环中运行剩余的代码。这再次使我们的重试能力成为可能,并且受maximumRetryCount字段值的限制。
在该方法中,我们尝试运行由method输入参数指定的函数,如果数据操作成功,则初始化一个SetDataOperationResult对象,仅传递成功反馈消息,并通过WithFeedback方法返回它。
如果发生错误并且尚未达到由maximumRetryCount字段指定的尝试次数,那么我们将使用Task.Delay方法等待,然后再尝试再次运行该方法。如果达到最大错误数,则将异常和错误反馈信息封装在SetDataOperationResult对象中,并通过WithFeedback方法返回。
与SetDataOperationResult对象一起使用的WithFeedback方法与之前用于通用GetDataOperationResult对象的方法完全相同。最后,我们有一些重载的TrySetAsync方法,它们最终通过UiThreadManager类的RunAsynchronously方法异步调用TrySet方法。
这里有一点需要注意,目前这个类位于Managers项目中。如果我们有可能需要更换我们的数据访问技术,那么我们可能更愿意将这个类移动到数据访问项目中以便于移除。按照现状,我们没有这样的需求,所以它现在所在的位置是合适的。
我们可以在之前看到的DataController类中使用这个DataOperationManager类,只需进行一些修改。我们还可以用一些新的方法替换它之前的SetAuditCreateFields和SetAuditUpdateFields方法,这些新方法也会更新实现ISynchronizableDataModel接口的数据模型。让我们看看那里的新代码:
using System;
using System.Threading.Tasks;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.DataModels.Collections;
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.DataModels.Interfaces;
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.Models.Interfaces;
namespace CompanyName.ApplicationName.Models.DataControllers
{
public class DataController
{
...
private DataOperationManager dataOperationManager;
public DataController(IDataProvider dataProvider,
DataOperationManager dataOperationManager, User currentUser)
{
...
DataOperationManager = dataOperationManager;
CurrentUser = currentUser.Clone();
}
protected DataOperationManager DataOperationManager
{
get { return dataOperationManager; }
private set { dataOperationManager = value; }
}
...
public Task<SetDataOperationResult> AddProductAsync(Product product)
{
return DataOperationManager.TrySetAsync(() =>
DataProvider.AddProduct(InitializeDataModel(product)),
$"{product.Name} was added to the data source successfully", $"A
problem occurred and {product.Name} was not added to the data
source.");
}
public Task<SetDataOperationResult> DeleteProductAsync(
Product product)
{
return DataOperationManager.TrySetAsync(() =>
DataProvider.DeleteProduct(DeleteDataModel(product)),
$"{product.Name} has been deleted from the data source
successfully.", $"A problem occurred and {product.Name} was not
deleted from the data source.", true, false);
}
public Task<GetDataOperationResult<Products>> GetProductsAsync()
{
return DataOperationManager.TryGetAsync(() =>
DataProvider.GetProducts(), string.Empty, "A problem occurred when
trying to retrieve the products.", true);
}
public SetDataOperationResult UpdateProduct(Product product)
{
return DataOperationManager.TrySet(() =>
DataProvider.UpdateProduct(UpdateDataModel(product)),
$"{product.Name} was saved in the data source successfully.", $"A
problem occurred and {product.Name} was not updated in the data
source.", false, false);
}
private T InitializeDataModel<T>(T dataModel)
where T : class, IAuditable, new()
{
dataModel.Auditable = new Auditable(dataModel, CurrentUser);
if (dataModel is ISynchronizableDataModel<T>)
{
ISynchronizableDataModel<T> synchronisableDataModel =
(ISynchronizableDataModel<T>)dataModel;
synchronisableDataModel.ObjectState = ObjectState.Active;
}
return dataModel;
}
private T DeleteDataModel<T>(T dataModel)
where T : class, IAuditable, new()
{
dataModel.Auditable.UpdatedOn = DateTime.Now;
dataModel.Auditable.UpdatedBy = CurrentUser;
if (dataModel is ISynchronizableDataModel<T>)
{
ISynchronizableDataModel<T> synchronisableDataModel =
(ISynchronizableDataModel<T>)dataModel;
synchronisableDataModel.ObjectState = ObjectState.Deleted;
}
return dataModel;
}
private T UpdateDataModel<T>(T dataModel)
where T : class, IAuditable, new()
{
dataModel.Auditable.UpdatedOn = DateTime.Now;
dataModel.Auditable.UpdatedBy = CurrentUser;
return dataModel;
}
}
}
我们从这个类DataOperationManager的dataOperationManager字段开始,这个字段属于DataOperationManager类型。在这里我们不需要使用接口,因为这个类在测试期间是安全的。然而,它包含一个IUiThreadManager类型的成员,我们需要能够根据我们是在运行还是测试应用程序来使用这个成员的不同实现。
因此,我们仍然需要通过构造函数注入dataOperationManager字段的实例,以便在调用代码中解决其IUiThreadManager接口的实例。在构造函数之后,我们看到一个私有的DataOperationManager属性,它只能从类内部设置。
新方法中的第一个是AddProductAsync方法,作为一个集合操作,它返回一个类型为SetDataOperationResult的Task。内部,就像这里所有的异步集合操作一样,它调用DataOperationManager类的TrySetAsync方法。它传递要异步运行的方法以及要显示为用户反馈的成功和未指定的错误文本。
注意,我们在将product输入参数传递给IDataProvider实例的AddProduct方法之前,先传递给InitializeDataModel方法,以便在它存储到数据库之前初始化基类Auditable属性。
如果当前实例也扩展了ISynchronizableDataModel接口,那么它的ObjectState属性将被设置为ObjectState枚举中的Active成员。这个想法可以很容易地扩展;如果我们有一个带有单个标识属性的IIdentifiable接口,我们也可以在这里初始化它。
DeleteProductAsync方法也返回一个类型为SetDataOperationResult的Task,并调用DataOperationManager类的TrySetAsync方法,但它使用了一个不同的重载,这使得反馈信息可以永久显示或直到用户手动关闭它。在这个例子中,它被用来确保用户知道产品已被删除。
在此方法中,我们在将其传递给IDataProvider实例的DeleteProduct方法之前,将product输入参数传递给DeleteDataModel方法。这会将Auditable类的UpdatedOn属性设置为当前日期和时间,并将UpdatedBy属性设置为当前登录用户。如果当前实例扩展了ISynchronizableDataModel接口,则其ObjectState属性也将设置为Deleted状态。
下一个新方法是GetProductsAsync方法,它是一个获取操作,返回类型为Task<GetDataOperationResult<Products>>的Task。内部,就像所有异步获取操作一样,它调用DataOperationManager类的TryGetAsync方法。它传递异步运行的方法以及要显示为用户反馈的未指定错误文本。
特别值得注意的是它传递的bool参数,该参数会抑制任何成功的反馈信息显示。如果发生错误,则显示提供的错误信息或更明确的自定义错误信息,但由于没有显示成功信息,我们只需为该参数传递一个空字符串。
最后一个新数据操作方法是UpdateProduct方法,它不是异步的,并直接返回SetDataOperationResult。它不调用TrySetAsync方法,而是调用DataOperationManager类的TrySet方法,并将运行成功和错误消息的方法以及两个bool参数传递,表示应该正常显示反馈。
在内部,它将product输入参数传递给UpdateDataModel方法,然后再将其传递给IDataProvider实例的UpdateProduct方法。这会将Auditable类的UpdatedOn属性设置为当前日期和时间,并将UpdatedBy属性设置为当前登录用户。
这提供了一个示例,说明了我们可能构建我们的数据操作方法,主要使用异步访问方法,但并不限制必须这样做。当然,在应用程序中访问数据有许多方法,你应该尝试最适合你的方法。这种方法最适合大规模应用程序,因为创建此系统需要相当多的开销。
然而,仍然缺少一个拼图。现在我们已经更改了DataController类的构造函数,我们还需要更新我们的BaseViewModel类,它再次公开它:
protected DataController Model
{
get { return new DataController(
DependencyManager.Instance.Resolve<IDataProvider>(),
new DataOperationManager(UiThreadManager),
StateManager.CurrentUser); }
}
...
public IUiThreadManager UiThreadManager
{
get { return DependencyManager.Instance.Resolve<IUiThreadManager>(); }
}
现在,IDataProvider实现由DependencyManager实例解析,同时将IUiThreadManager实现注入到DataOperationManager对象中。此外,我们将StateManager.CurrentUser属性的值传递给DataController类构造函数,以便每次请求时都实例化它。
现在我们已经建立了一个系统,该系统可以同步或异步地运行我们的数据操作,如果数据操作失败,可以在最终向用户报告自定义反馈消息之前重试指定次数。
我们可以自定义这些消息在自动消失之前保持可见的时间长度,或者是否将自动消失,或者甚至是否一开始就显示。即使有这些选项,系统仍然保持轻量级,并且可以轻松添加。
走得更远
大多数私人开发的应用程序主要是功能性的,在设计关注点和可用性方面投入的时间和精力很少。我们有多少次看到当发生错误时,应用程序向最终用户抛出堆栈跟踪,或者用驼峰命名法突出显示字段错误的验证消息,而不是在 UI 中使用的标签?
在一个好的应用程序中,最终用户不应该遇到任何基于代码的术语。如果我们正在编写基于英语的应用程序,我们不会输出西班牙语的错误消息,那么为什么要输出 C#的错误消息呢?这可能会让用户困惑,甚至在某些情况下使他们感到惊慌。
你有多少次使用过执行每个涉及比必要的鼠标点击更多的任务的应用程序?本节致力于避免这些情况,并提出了一些改进我们应用程序可用性的方法。
生成应用程序内帮助
在一个理想的世界里,我们都会创建出如此直观的应用程序,以至于我们不需要提供应用程序内的帮助。然而,由于一些现代应用程序的复杂性,这并不总是可能的。因此,为我们的应用程序的最终用户提供一些形式上的帮助,以便他们在需要时可以参考,通常是有帮助的。
有多种方法可以实现这一点,其中第一种简单的方法是从应用程序提供指向单独的帮助文件的链接。如果我们有一个包含用户帮助的 PDF 或其他类型的文件,我们可以在 Visual Studio 中将它作为资源添加到我们的解决方案中。
要做到这一点,我们可以在我们的解决方案中添加一个资源文件夹,然后在新建文件夹的上下文菜单中选择“添加新项”选项。在“添加新项”对话框中导航到帮助文件并成功添加后,我们可以在解决方案资源管理器中选择它并按F4键查看其属性,或者右键单击它并从上下文菜单中选择“属性”。
属性显示后,我们可以验证文件是否已添加,其构建操作为内容,并将输出目录的值设置为“始终复制”或“如果较新则复制”,这确保我们的帮助文件及其资源文件夹将被复制到包含应用程序可执行文件的文件夹中,并且始终使用最新版本。
我们可以在我们的应用程序中添加一个菜单项或按钮,用户可以选择它来直接打开文档。在我们的数据绑定到这个控件的观点模型命令中,我们可以调用Process类的Start方法,传递帮助文件的路径,以在用户的计算机上使用默认应用程序打开文件:
System.Diagnostics.Process.Start(filePath);
我们可以使用以下代码获取应用程序可执行文件的文件夹路径:
string filePath = System.AppDomain.CurrentDomain.BaseDirectory;
因此,如果我们的Resources文件夹位于启动项目中,我们可以这样获取其文件夹路径:
string filePath = Path.Combine(
new DirectoryInfo(System.AppDomain.CurrentDomain.BaseDirectory).
Parent.Parent.FullName, "Resources");
这利用了DirectoryInfo类来访问可执行文件的上层文件夹,或者项目的根目录,以及Path类的Combine方法来创建一个文件路径,该路径将新的Resources文件夹与该路径合并。
如果我们没有我们应用程序的完整文档文件,一个快速简单的方法是在每个视图中添加一个信息图标。这个图像控件可以在用户将鼠标指针放在它上面时显示相关信息提示:

使用在第八章“创建视觉吸引力的用户界面”中讨论的 Visual Studio 图像库中的信息图标,我们可以这样创建这些帮助点:
<Image Source="pack://application:,,,/CompanyName.ApplicationName;
component/Images/Information_16.png" Stretch="None" ToolTip="Here is
some relevant information" />
无论哪种方式,我们的想法是向应用程序用户提供他们可能需要的任何帮助,直接从应用程序本身开始。这不仅提高了我们应用程序的可用性,还减少了用户错误并提高了数据质量。
启用用户偏好
我们应用程序的用户可能彼此非常不同,或者至少有他们各自的偏好。一个用户可能更喜欢以某种方式工作,而另一个可能有不同的偏好。提供他们能够根据他们工作的方式自定义应用程序的能力将提高他们对应用程序的可用性。
这可能与他们在应用程序启动时希望看到的视图有关,或者与每个视图中他们希望使用的特定选项有关,甚至可能与应用程序上次使用时的尺寸和位置有关。我们可以为每个用户提供无数种偏好设置。
幸运的是,我们可以通过最小的工作量提供这种自定义功能,因为.NET Framework 为我们提供了用于此目的的设置文件。这些设置可以是应用程序范围或用户范围,并且可以在每个设置文件中混合使用。
应用程序设置对每个用户都是相同的,适合存储配置设置,例如电子邮件服务器详情或凭据。用户设置对每个用户可能不同,适合于刚才讨论的个人定制。
通常,启动项目已经有一个名为 Settings.settings 的设置文件。可以在 Visual Studio 中的解决方案资源管理器中打开 Properties 文件夹来找到它,并通过双击它来打开。或者,您可以在解决方案资源管理器中右键单击项目,选择“属性”选项,然后选择“设置”选项卡:

设置文件也可以添加到其他项目中,尽管它们默认情况下通常不可用。为了将设置文件添加到其他项目,我们首先需要通过在解决方案资源管理器中右键单击项目并选择“属性”选项来打开项目属性。
在项目属性窗口中,选择“设置”选项卡,并单击显示“此项目不包含默认设置文件”的链接。点击此处创建一个。设置文件将在解决方案资源管理器中项目的 Properties 文件夹内创建。然后我们可以开始添加我们的用户偏好设置:

要添加我们的自定义设置,请在设置文件中点击一个空白行,并输入设置的名称、数据类型、作用域和默认值。名称将在代码中使用,因此它不能包含空格。我们可以选择自己的自定义数据类型,尽管我们选择的任何类型都必须是可序列化的。默认值是用户更改之前设置将具有的初始值。
设置通常在应用程序启动时加载,并在应用程序关闭前保存。因此,在 MainWindow.xaml.cs 文件中附加到 Loaded 和 Closed 事件是惯例,尽管如果我们已经配置应用程序使用它,我们也可以在 App.xaml.cs 文件中这样做。这里有一个典型的例子:
using System;
using System.Windows;
using CompanyName.ApplicationName.ViewModels;
namespace CompanyName.ApplicationName
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
Closed += MainWindow_Closed;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
MainWindowViewModel viewModel = new MainWindowViewModel();
viewModel.LoadSettings();
DataContext = viewModel;
}
private void MainWindow_Closed(object sender, EventArgs e)
{
MainWindowViewModel viewModel = (MainWindowViewModel)DataContext;
viewModel.SaveSettings();
}
}
}
我们在构造函数中附加这两个事件处理器,在组件初始化之后立即进行。在 MainWindow_Loaded 方法中,我们实例化 MainWindowViewModel 类的一个实例,调用它的 LoadSettings 方法,并将其设置为窗口的 DataContext 属性值。
在 MainWindow_Closed 方法中,我们从 DataContext 属性访问 MainWindowViewModel 类的实例,但这次调用它的 SaveSettings 方法。现在,让我们在 MainWindowViewModel.cs 文件中查看这些方法:
using CompanyName.ApplicationName.ViewModels.Properties;
...
public void LoadSettings()
{
Settings.Default.Reload();
StateManager.AreAuditFieldsVisible =
Settings.Default.AreAuditFieldsVisible;
StateManager.AreSearchTermsSaved = Settings.Default.AreSearchTermsSaved;
}
public void SaveSettings()
{
Settings.Default.AreAuditFieldsVisible =
StateManager.AreAuditFieldsVisible;
Settings.Default.AreSearchTermsSaved = StateManager.AreSearchTermsSaved;
Settings.Default.Save();
}
在 LoadSettings 方法中,我们首先需要做的是调用设置文件的默认实例上的 Reload 方法。这将从设置文件中加载设置到 Default 对象中。从那里,我们将每个设置属性设置为我们在 StateManager 类中创建的相应属性,以便在应用程序中使用。
注意,每个用户的个人设置值不是存储在Settings.settings文件中。相反,它们存储在他们的AppData文件夹中,该文件夹默认隐藏。确切文件路径可以使用ConfigurationManager类找到,但为了找到它,我们需要添加对System.Configuration DLL 的引用并使用以下代码:
using System.Configuration;
...
string filePath = ConfigurationManager.OpenExeConfiguration(
ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath;
在我的情况下,这对应以下文件路径:
C:\Users\Sheridan\AppData\Local\CompanyName\
CompanyName.ApplicationNa_Url_0nu0qp14li5newll2223u0ytheisf2gh\
1.0.0.0\user.config
注意,在CompanyName文件夹中的文件夹使用特定的识别号命名,该识别号与当前设置和应用程序版本相关。随着时间的推移和做出更改后,这里将出现带有新识别号的新文件夹,但这一切对用户来说都是完全透明的,因为他们的先前设置将被安全地迁移。
扩展常规礼仪
在应用开发领域,我们可以轻松地提高的一个方面是可用性。如今,许多应用在创建时很少或根本不考虑每天将使用该应用的用户。
我们可能都见过当发生错误时,应用程序会抛出异常堆栈跟踪的情况,虽然作为开发者,我们可能觉得这很有用,但对于最终用户来说,这可能会令人困惑,甚至令人恐慌。为了避免不必要地让最终用户感到担忧,我们可以将堆栈跟踪和任何其他与每个错误相关的信息输出到我们数据库中的Errors表中。
进一步扩展这个想法,完全避免在任何用户可以看到的应用程序中使用任何开发术语或短语是一个好的工作习惯。这包括所有 UI 标签以及任何额外的外部帮助文件和文档。使用这类术语会使应用程序更难使用,尤其是对于新用户。除了最知名的缩写外,也应避免使用。
通过关注细节,我们可以进一步使我们的应用程序人性化。你有多经常遇到一个显示类似“1 乘客”或“2 项目”标签的应用程序?虽然这是一个非常简单的问题,但在许多应用程序中都很常见。让我们创建一个新的扩展方法,将这个有用的功能封装在IntegerExtensions类中:
public static string Pluralize(this int input, string wordToAdjust)
{
return $"{wordToAdjust}{(input == 1 ? string.Empty : "s")}";
}
在这个例子中,我们简单地使用字符串插值将s追加到wordToAdjust输入参数的末尾,当this输入参数的值为1时。虽然这对我们可能使用的多数单词都适用,但值得注意的是,有一些单词组这种方法不适用。
例如,一些单词,如以“y”结尾的单数形式“Activity”,在复数形式时将以“ies”结尾。然而,这个问题可以通过添加我们Pluralize方法的新重载或一个额外的输入参数来解决,该参数允许我们的代码用户指定他们所需的转换。
使用此方法,我们现在有一个非常简单的方法来确保在处理数量时我们的拼写总是正确的。让我们看看我们如何使用此方法来复数化单词Ticket,但仅当Tickets集合中的票数不是1时:
public string TicketCountText => Tickets.Count.Pluralize("Ticket");
此方法的扩展可以将此功能与实际数字结合,例如输出6 Tickets。让我们看看这个新方法:
public static string Combine(this int input, string wordToAdjust)
{
return $"{input} {wordToAdjust}{(input == 1 ? string.Empty : "s")}";
}
Combine方法与Pluralize方法非常相似,不同之处在于它还包括input输入参数的值在文本输出中。我们也可以以相同的方式扩展此方法,就像我们可以扩展Pluralize方法来处理除了只需附加s以外的单词的复数化。我们也可以以相同的方式使用它:
public string TicketCountText => Tickets.Count.Combine("Ticket");
我们使文本输出更人性化的另一种方法是为集合控件提供一个选择摘要字段,该字段显示所选项目的逗号分隔列表。显然,对于只允许进行单选的控件来说,这并不是必需的;然而,对于使用多选集合控件的用户来说,这可能是一个有用的确认。现在让我们看看我们如何声明一个ToCommaSeparatedString方法:
using System.Text;
...
public static string ToCommaSeparatedString<T>(
this IEnumerable<T> collection)
{
StringBuilder stringBuilder = new StringBuilder();
int index = 0;
foreach (T item in collection)
{
if (index > 0)
{
if (index < collection.Count() - 1) stringBuilder.Append(", ");
else if (index == collection.Count() - 1)
stringBuilder.Append(" and ");
}
stringBuilder.Append(item.ToString());
index++;
}
return stringBuilder.ToString();
}
在这里,我们有一个可以在任何类型为或扩展IEnumerable<T>接口的集合上调用的方法,并返回一个包含每个包含元素的逗号分隔列表的字符串。我们可以用字符串集合调用它,或者在我们的类中实现object.ToString方法,因为这将针对每个元素被调用。
此方法使用StringBuilder类构建逗号分隔列表。由于StringBuilder类在初始化和导出构建的字符串时略有开销,测试表明,它只有在附加 10 个或更多字符串时,相对于基本字符串连接才能真正提供时间上的改进。
因此,你可能更喜欢重构此方法以删除StringBuilder对象,尽管你也可能发现毫秒级的差异微不足道。回到方法本身,在声明StringBuilder对象之后,我们初始化index变量,该变量用于指定将哪个分隔符与每个字符串连接。
当index变量等于零且尚未向StringBuilder对象添加任何字符串时,不会附加任何分隔符。之后,我们检查当前字符串是否是集合中的最后一个,如果是,则在它前面添加“ and ”;否则,在它前面添加一个逗号和一个空格。
在每次迭代之后,我们增加index变量,完成后,我们从StringBuilder对象返回输出。它可以用来显示用户所选产品的逗号分隔列表,如下所示:
SelectedProducts.Select(p => p.Name).ToCommaSeparatedString();
正如你所见,我们有多种方法可以使我们的输出对最终用户更人性化,使他们在使用我们的应用程序时感到更加轻松。现在让我们继续看看我们还可以提供哪些其他方式来为我们的用户提供出色的用户体验。
减轻最终用户的负担
我们可以做的很多事情来使最终用户的生活更加轻松。一个简单的例子就是在表单中设置焦点到第一个字段,这样用户就可以在加载视图后立即开始输入,而无需首先手动聚焦它。
我们在第四章中看到了使用附加属性来完成这一任务的方法,精通数据绑定,但我们也可以通过首先在我们的BaseViewModel类中添加一个新的bool属性来轻松实现这一点:
private bool isFocused = false;
...
public bool IsFocused
{
get { return isFocused; }
set { if (isFocused != value) { isFocused = value;
NotifyPropertyChanged(); } }
}
接下来,我们可以在App.xaml文件中的应用程序资源中添加一个样式资源:
<Style TargetType="{x:Type TextBox}">
<!-- Define default TextBox style here -->
</Style>
<Style x:Key="FocusableTextBoxStyle" TargetType="{x:Type TextBox}"
BasedOn="{StaticResource {x:Type TextBox}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsFocused}" Value="True">
<Setter Property="FocusManager.FocusedElement"
Value="{Binding RelativeSource={RelativeSource Self}}" />
</DataTrigger>
</Style.Triggers>
</Style>
这假设我们已经有了一个我们想要为我们的TextBox控件使用的默认样式,并且我们的新样式将基于这个样式,但添加了额外的可聚焦功能。它仅由一个使用FocusManager类的FocusedElement属性的数据触发器组成,当IsFocused属性设置为true时,将聚焦应用了此样式的TextBox元素。
因此,我们只需要在视图中应用此样式,并在相关的视图模型中适当的位置将BaseViewModel类的IsFocused属性设置为true,就可以聚焦特定的TextBox控件。
IsFocused = true;
注意,当属性变为true时,TextBox控件将获得焦点,因此如果属性已经是true,我们可能需要首先将其设置为false,然后再将其设置为true以使其工作。例如,如果属性在视图加载之前是true,那么TextBox控件将不会获得焦点。
使我们的应用程序用户的生活更加简单的一个简单例子可能是预先填充我们可能能够填充的任何表单字段。例如,如果我们的应用程序有一个使用用户 Windows 用户名的登录屏幕,我们可以在从WindowsIdentity类访问它后,像这样填写用户名字段:
UserName = WindowsIdentity.GetCurrent().Name;
这种情况的另一个例子可能是预先填充表单字段,使用最常用的值。我们可能会用今天的日期填充日期字段,或者将已支付金额字段填充为总金额,如果这是用户通常的做法。
然而,当我们这样做时,我们需要小心,因为如果我们得到默认值(们)错误,可能会适得其反,实际上让用户删除默认值并替换为想要的值比直接输入值花费的时间更长。记住,我们的想法是节省用户的时间并提高他们的生产力。
很经常,我们可以为我们应用程序的用户节省大量的时间。如果我们有机会了解他们具体做什么以及他们如何在日常使用中利用应用程序,那么我们通常可以将他们的许多操作编程到应用程序的功能中。
例如,如果任何用户需要反复编辑多个具有相同数据的文件,可能是为了添加、删除或更新特定字段,那么我们可以在应用程序中直接构建该功能。
我们可以提供一个视图,让他们设置要更改的字段或字段,以及新的值,同时具备选择多个记录的能力,从而为他们节省大量的时间和精力。
所有琐碎的或重复的任务都可以编程到函数中,因此编写一个好的应用程序不仅限于创建漂亮和异步的 UI,还要使其高度可用。此外,应用程序越有用,用户的生产力就越高,我们和我们的开发团队(如果适用)将获得的赞誉也越慷慨。
摘要
在本章中,我们讨论了进一步改进我们应用程序的方法,使它们尽可能对最终用户有用。我们研究了如何实现一个定制的用户反馈系统,以使用户了解他们执行的操作的状态。
我们还研究了如何使我们的应用程序异步,这样当应用程序执行长时间运行的操作时,我们的 UI 不会冻结。然后我们查看了一种将这种异步行为直接构建到我们的应用程序框架中的方法,这样我们就可以以最少的代码异步运行任何数据访问操作。
我们以一个简短的章节结束,该章节致力于改善我们的应用程序在最终用户眼中的形象。在其中,我们详细介绍了实现这一目标的各种方法,从提供应用程序内的帮助和用户偏好设置,到关注细节并实现工作密集型功能,以节省用户手动执行相同操作的时间。
在下一章中,我们将探讨多种提高我们应用程序性能的方法,从利用已安装的显卡到编写更高效的代码。我们还将研究如何提高我们的数据绑定和资源效率,并调查其他技术,如数据虚拟化。
第十一章:提升应用程序性能
通常来说,Windows Presentation Foundation(WPF)应用程序的性能是其最大的问题之一。我们的渲染数据对象和 UI 包含的视觉层越多,渲染它们所需的时间就越长,因此我们通常需要在使应用程序视觉吸引力和提高性能之间保持平衡。
通过在更强大的计算机上运行我们的 WPF 应用程序,可以改善这种状况。这也解释了为什么这些应用程序在金融行业中最为普遍。然而,并不是每个人都能承担起为所有用户更新计算机的费用。
幸运的是,我们有多种方法可以提升我们的 WPF 应用程序的性能,我们将在下面探讨这些方法。提升应用程序性能的技巧实际上归结于进行许多小的改进,这些改进累积起来会产生明显的效果。
在本章中,我们将探讨如何更好地利用计算机图形卡的图形渲染能力,并更有效地声明我们的资源。我们将研究如何通过选择使用更轻量级的 UI 控件、更高效的数据绑定模式以及采用其他技术(如虚拟化)来提高应用程序的性能。
利用硬件渲染的强大功能
正如我们已经学到的,WPF 可以输出的视觉效果虽然美丽,但可能非常 CPU 密集型,我们在设计视图时需要牢记这一点。然而,我们不必牺牲设计,可以将密集的渲染过程卸载到宿主计算机的图形处理单元(GPU)上。
虽然 WPF 默认会使用其软件渲染管道,但它也能利用硬件渲染管道。这个硬件管道利用了 Microsoft DirectX 的特性,只要宿主 PC 安装了 DirectX 7 或更高版本。此外,如果安装的 DirectX 版本为 9 或更高,性能提升会更加明显。
WPF 框架会查看运行在其上的计算机上安装的图形硬件,并根据其特性(如视频 RAM、着色器和多纹理支持)将其分为三类。如果它不支持 DirectX 7 或更高版本,则被归类为渲染层级 0,并且完全不会用于硬件渲染。
然而,如果它支持 DirectX 7 或更高版本,但低于版本 9,则被归类为渲染层级 1,并将用于部分硬件渲染。然而,由于几乎所有新的显卡都支持高于 9 版本的 DirectX,它们都会被归类为渲染层级 2,并用于完全硬件渲染。
由于在渲染过程中 UI 将会冻结,因此应尽量减少需要渲染的视觉层数量。因此,对于将在具有渲染等级 0 的图形硬件上运行且使用软件渲染的 WPF 应用程序,我们需要格外小心。
然而,如果我们的应用程序可能需要在较旧的计算机上运行,或者具有较旧图形硬件的计算机上运行,我们可以通过渲染等级来检测这一点,并在这些情况下运行更高效的代码。我们可以使用RenderCapability类的静态Tier属性来找出主机计算机图形硬件的渲染等级。
不幸的是,这个属性的类型并不是某种有用的枚举,而实际上是一个整数,其中只有高位字表示等级的值,可以是0、1或2。我们可以通过在整数中移位位来获取它,只从最后两个字节读取值:
using System.Windows.Media;
...
int renderingTier = RenderCapability.Tier >> 16;
一旦我们知道主机计算机图形硬件的渲染等级,我们就可以相应地编写代码。例如,让我们想象我们有一个处理器密集型的视图,其中包含大量视觉元素,每个元素都由集合中的每个项目组成。我们可以将等级值设置到一个属性中,并将其数据绑定到视图上,这样我们就可以根据主机计算机的处理能力选择不同的数据模板。让我们通过首先创建缺失的枚举来检查这个示例:
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum RenderingTier
{
Zero = 0,
One = 1,
Two = 2
}
}
接下来,我们需要在我们的StateManager类中添加一个RenderingTier类型的属性,该类来自第三章,编写自定义应用程序框架:
public RenderingTier RenderingTier { get; set; }
我们不需要通知INotifyPropertyChanged接口关于此属性任何更改,因为它将在应用程序启动时只设置一次。让我们调整我们之前的示例:
public App()
{
StateManager.Instance.RenderingTier =
(RenderingTier)(RenderCapability.Tier >> 16);
}
在将移位后的整数值转换为我们的RenderingTier枚举并将其设置到StateManager类中的新RenderingTier属性之后,我们就可以开始在视图中使用它,以确定我们可以使用多少可视化级别:
<ListBox ItemsSource="{Binding Products}">
<ListBox.Style>
<Style TargetType="{x:Type ListBox}">
<Setter Property="ItemTemplate"
Value="{StaticResource SimpleDataTemplate}" />
<Style.Triggers>
<DataTrigger Binding="{Binding
StateManager.Instance.RenderingTier}" Value="One">
<Setter Property="ItemTemplate"
Value="{StaticResource MoreComplexDataTemplate}" />
</DataTrigger>
<DataTrigger Binding="{Binding
StateManager.Instance.RenderingTier}" Value="Two">
<Setter Property="ItemTemplate"
Value="{StaticResource MostComplexDataTemplate}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ListBox.Style>
</ListBox>
在这个示例中,我们有一个ListBox控件,它正在显示一组产品。我们的想法是可以声明三个不同的数据模板来定义每个产品将看起来是什么样子。我们有一个SimpleDataTemplate模板,它可能只提供基于文本的输出,一个MoreComplexDataTemplate模板,它可能包含一些基本视觉元素,以及一个MostComplexDataTemplate模板,它可能包含多层视觉元素。
在应用于列表框的样式中,我们将默认的SimpleDataTemp``late模板设置为它的ItemTemplate属性的值。然后,使用StateManager类的RenderingTier属性,我们声明了一些数据触发器,根据主机计算机的渲染等级将ItemTemplate属性的值切换到更复杂的模板之一。
制作更高效资源
当我们引用资源时,我们可以使用StaticResource或DynamicResource。如果你还记得第五章中的内容,即为工作选择正确的控件,StaticResource将只查找资源值一次,这相当于编译时查找。而DynamicResource每次请求资源时都会重复查找资源值,无论其是否已更改,就像运行时查找一样。
因此,我们只有在真正需要时才应使用DynamicResource,因为我们可以通过使用StaticResource类来获得更好的性能。如果我们发现我们需要使用大量的DynamicResource引用来访问资源,那么我们可以重构我们的代码,将数据绑定到StateManager类中的属性,而不是资源,以提高性能。
提高资源性能的另一种简单方法是将它们重用。我们不应在 XAML 中它们被使用的位置内联声明它们,而应在合适的资源部分声明它们并引用它们。
这样,每个资源只创建一次并共享。为了进一步扩展这个想法,我们可以在App.xaml文件中的应用程序资源中定义所有共享资源,并在所有应用程序视图中共享它们。
想象一下这样的情况:一些画笔资源在DataTemplate元素内的 XAML 中被内联声明。现在想象这个模板被设置为ItemsControl对象的ItemTemplate,并且绑定到其ItemsSource属性集合包含了一千个元素。
因此,应用程序将为每个在数据模板中局部声明的具有相同属性的画笔创建一千个画笔对象。现在将此与另一种情况进行比较,在这种情况下,我们只需在资源部分声明一次所需的画笔,并从模板中引用它。这种方法的好处和可以节省的计算机资源是显而易见的。
此外,这个想法也影响了我们的视图的Resources部分,特别是如果我们同时显示多个视图时。如果我们声明一个视图来定义集合中每个对象应该如何渲染,那么在集合中的每个元素上都会初始化一次在视图中声明的所有资源。在这种情况下,在应用程序级别声明它们会更好。
冻结对象
在 WPF 中,某些资源对象,如动画、几何形状、画笔和笔,可以被设置为Freezable。这提供了可以帮助提高我们 WPF 应用程序性能的特殊功能。Freezable对象可以是冻结的或非冻结的。在非冻结状态下,它们的行为就像任何其他对象一样;然而,当冻结时,它们变得不可变,并且不能再被修改。
冻结对象的主要好处是它可以提高应用程序的性能,因为冻结的对象在监控和发布更改通知时不再需要消耗资源。另一个好处是,冻结的对象也可以安全地在线程之间共享,这与未冻结的对象不同。
许多与 UI 相关的对象扩展了 Freezable 类以提供此功能,并且大多数 Freezable 对象都与图形子系统相关,因为渲染视觉是性能提升最需要的领域之一。
例如,Brush、Geometry 和 Transform 类等类包含非托管资源,系统必须监控它们的变化。通过冻结这些对象并使它们不可变,系统能够释放其监控资源,并在其他地方更好地利用它们。此外,冻结对象的内存占用也远小于其未冻结的对应物。
因此,为了获得最大的性能提升,我们应该习惯在所有 Resource 部分中冻结所有资源,只要我们没有修改它们的计划。由于大多数资源通常保持未修改状态,我们通常能够冻结绝大多数资源,并通过这样做获得显著和明显的性能提升。
在第八章,创建视觉吸引人的用户界面中,我们学习了如何通过调用其 Freeze 方法在代码中冻结 Freezable 对象。现在,让我们看看我们如何在 XAML 中冻结我们的资源。首先,我们需要向表示选项命名空间添加一个 XAML 命名空间前缀,以便访问其 Freeze 属性:
xmlns:PresentationOptions=
"http://schemas.microsoft.com/winfx/2006/xaml/presentation/options
"
mc:Ignorable="PresentationOptions"
注意,我们还包括另一个 XAML 命名空间前缀,以便能够访问 Ignorable 属性,并将我们的 PresentationOptions 前缀设置为它的值。这是因为 Freeze 属性主要只被 WPF XAML 处理器识别,并且为了与其他 XAML 读取器保持兼容性,我们需要指定该属性可以被忽略。
我们将在即将到来的得出结论部分找到一个完整的示例,但现在,让我们使用早期示例中的一个资源,来探讨如何在 XAML 中冻结一个 Freezable 对象:
<DropShadowEffect x:Key="Shadow" BlurRadius="10" Direction="270"
ShadowDepth="7" Opacity="0.5" PresentationOptions:Freeze="True" />
一些 Freezable 对象,例如动画和几何对象,可以包含其他 Freezable 对象。当一个 Freezable 对象被冻结时,其子对象也会被冻结。然而,也有一些情况下 Freezable 对象无法被冻结。
如果它有任何可能因动画、数据绑定或 DynamicResource 引用而改变值的属性,则会出现一种情况。另一种情况发生在 Freezable 对象有任何无法冻结的子对象时。
例如,如果我们正在自定义控件的代码后部冻结资源类型对象,那么我们可以调用 Freezable 类的 CanFreeze 属性来检查每个 Freezable 对象是否可以冻结,然后再尝试冻结它们:
EllipseGeometry ellipseGeometry =
new EllipseGeometry(new Rect(0, 0, 500, 250));
if (ellipseGeometry.CanFreeze) ellipseGeometry.Freeze();
Path.Data = ellipseGeometry;
一旦Freezable对象被冻结,它就不能再被修改,尝试修改它将引发InvalidOperationException异常。请注意,Freezable对象不能被解冻;因此,为了避免这种情况,我们可以在尝试修改对象之前检查IsFrozen属性的值。如果它被冻结,我们可以使用它的Clone方法制作一个副本并对其进行修改:
if (ellipseGeometry.IsFrozen)
{
EllipseGeometry ellipseGeometryClone = ellipseGeometry.Clone();
ellipseGeometryClone.RadiusX = 400;
ellipseGeometryClone.Freeze();
Path.Data = ellipseGeometryClone;
}
else ellipseGeometry.RadiusX = 400;
如果克隆了一个Freezable对象,它可能拥有的任何Freezable子对象也将被复制以允许修改。当一个冻结对象被动画化时,动画系统会以这种方式创建它的克隆副本,以便它可以修改它们。但是,由于这会增加性能开销,因此如果预期对象将被动画化,建议不要冻结Freezable对象。
使用适合性能的控件
如前所述,在使用 WPF 时,通常有几种不同的方法可以实现相同的功能或 UI 显示。有些方法比其他方法提供更好的性能。例如,我们了解到一些面板执行更密集的布局工作,因此比其他面板消耗更多的 CPU 周期和/或 RAM。
因此,这是我们可以在其中进行调查以改进性能的一个领域。如果我们不需要Grid面板的复杂布局和调整大小能力,那么我们可以通过利用更高效的StackPanel或Canvas面板来获得性能提升。
另一个例子可能是,如果我们不需要在集合控件中选择的能力,那么我们应该使用ItemsControl元素而不是ListBox。虽然交换一个控件本身不会对性能产生太大的改善,但在将此相同的交换应用于将显示数千次的项目的DataTemplate中,将会产生明显的差异。
正如我们在第五章“使用适合工作的控件”中发现的,每次渲染 UI 元素时,布局系统必须完成两个遍历,一个测量遍历和一个排列遍历,这共同被称为布局遍历。如果元素有子元素和/或孙元素,它们都需要完成布局遍历。这个过程很复杂,遍历次数越少,我们的视图渲染速度就越快。
如前所述,我们需要小心确保不会不必要地触发布局系统的额外遍历,因为这可能导致性能下降。这种情况可能发生在向或从面板添加或删除项目、对元素应用转换或调用UIElement.UpdateLayout方法时,后者会强制进行新的布局遍历。
由于 UI 元素的变化会使其子元素无效并强制进行新的布局遍历,因此在代码中构建层次化数据时,我们需要特别小心。如果我们首先创建子元素,然后是它们的父对象,然后是这些对象的父对象,依此类推,我们将因为现有的子项被迫执行多次布局遍历而遭受巨大的性能损失。
为了解决这个问题,我们需要始终确保从上到下构建我们的树,而不是像刚才描述的那样从上到下。如果我们首先添加父元素(们),然后添加它们的子元素,如果有,再添加这些子元素的父元素,等等,我们就可以避免额外的布局遍历。使用自上而下的方法可以提升性能,大约快五倍,因此这一点不容忽视。让我们看看我们可以采用的更多与控制相关的性能优势。
绘制结论
当我们需要在 UI 中绘制形状时,例如在 第八章 的示例中,即“创建视觉上吸引人的用户界面”,我们倾向于使用抽象的 Shape 类,或者更准确地说,使用其一或多个派生类。
Shape 类扩展了 FrameworkElement 类,因此可以利用布局系统,可以进行样式化,可以访问一系列的描边和填充属性,其属性可以进行数据绑定和动画处理。这使得它易于使用,并且通常是 WPF 应用程序中绘制形状的首选方法。
然而,WPF 也提供了更底层的类,这些类可以以更高效的方式实现相同的结果。扩展抽象 Drawing 类的五个类具有更小的继承层次结构,因此与基于 Shape 对象的对应类相比,它们的内存占用更小。
最常用的两个类包括用于绘制几何形状的 GeometryDrawing 类,以及用于将多个绘图对象组合成单个复合绘图的 DrawingGroup 类。
此外,Drawing 类还被 GlyphRunDrawing 类扩展,用于渲染文本;ImageDrawing 类用于显示图像;以及 VideoDrawing 类,它使我们能够播放视频文件。由于 Drawing 类扩展了 Freezable 类,如果这些实例之后不需要修改,通过冻结其实例可以进一步节省效率。
在 WPF 中绘制形状还有另一种方法,可能甚至更高效。DrawingVisual 类不提供事件处理或布局功能,因此与其它绘图方法相比,其性能得到了提升。然而,这是一个仅限代码的解决方案,没有基于 XAML 的 DrawingVisual 选项。
此外,它缺乏布局能力意味着,为了显示它,我们需要创建一个扩展提供 UI 布局支持的类(如 FrameworkElement 类)的类。然而,为了更高效,我们也可以扩展 Visual 类,因为这是在 UI 中可以渲染的最轻量级的类,具有最少的属性和无需处理的事件。
这个类将负责维护一个要渲染的 Visual 元素集合,创建一个或多个 DrawingVisual 对象以添加到集合中,并重写一个属性和一个方法,以便参与渲染过程。它还可以选择性地提供事件处理和点击测试功能,如果需要用户交互的话。
这完全取决于我们想要绘制的内容。通常,绘图越高效,其灵活性就越低。例如,如果我们只是绘制一些静态的剪贴画、背景图像,或者可能是标志,我们可以利用更高效的绘图方法。然而,如果我们需要我们的绘图随着应用程序窗口大小的变化而增长和缩小,那么我们就需要使用灵活性更高的、效率较低的方法,或者使用另一个提供该功能的类。
让我们探索一个示例,使用三种不同的绘图方法创建相同的图形图像。我们将定义一些笑脸表情符号,从左侧的基于 Shape 的方法开始,中间是基于 Drawing 对象的方法,右侧是基于 DrawingVisual 的方法。让我们首先看看视觉输出:

现在,让我们检查一下 XAML:
<UserControl x:Class="CompanyName.ApplicationName.Views.DrawingView"
xmlns:Controls=
"clr-namespace:CompanyName.ApplicationName.Views.Controls"
xmlns:PresentationOptions=
"http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
Width="450" Height="150">
<Grid>
<Grid.Resources>
<RadialGradientBrush x:Key="RadialBrush" RadiusX="0.8" RadiusY="0.8"
PresentationOptions:Freeze="True">
<GradientStop Color="Orange" Offset="1.0" />
<GradientStop Color="Yellow" />
</RadialGradientBrush>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="3*" />
<RowDefinition Height="2*" />
<RowDefinition Height="2*" />
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Ellipse Grid.RowSpan="5" Grid.ColumnSpan="5"
Fill="{StaticResource RadialBrush}" Stroke="Black"
StrokeThickness="5" />
<Ellipse Grid.Row="1" Grid.Column="1" Fill="Black" Width="20"
HorizontalAlignment="Center" />
<Ellipse Grid.Row="1" Grid.Column="3" Fill="Black" Width="20"
HorizontalAlignment="Center" />
<Path Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" Stroke="Black"
StrokeThickness="10" StrokeStartLineCap="Round"
StrokeEndLineCap="Round" Data="M0,10 A10,25 0 0 0 12.5,10"
Stretch="Fill" HorizontalAlignment="Stretch" />
</Grid>
<Canvas Grid.Column="1">
<Canvas.Background>
<DrawingBrush PresentationOptions:Freeze="True">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="{StaticResource RadialBrush}">
<GeometryDrawing.Geometry>
<EllipseGeometry Center="50,50" RadiusX="50"
RadiusY="50" />
</GeometryDrawing.Geometry>
<GeometryDrawing.Pen>
<Pen Thickness="3.5" Brush="Black" />
</GeometryDrawing.Pen>
</GeometryDrawing>
<GeometryDrawing Brush="Black">
<GeometryDrawing.Geometry>
<EllipseGeometry Center="29.5,33" RadiusX="6.75"
RadiusY="8.5" />
</GeometryDrawing.Geometry>
</GeometryDrawing>
<GeometryDrawing Brush="Black">
<GeometryDrawing.Geometry>
<EllipseGeometry Center="70.5,33" RadiusX="6.75"
RadiusY="8.5" />
</GeometryDrawing.Geometry>
</GeometryDrawing>
<GeometryDrawing>
<GeometryDrawing.Geometry>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure StartPoint="23,62.5">
<ArcSegment Point="77,62.5" Size="41 41" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</GeometryDrawing.Geometry>
<GeometryDrawing.Pen>
<Pen Thickness="7" Brush="Black" StartLineCap="Round"
EndLineCap="Round" />
</GeometryDrawing.Pen>
</GeometryDrawing>
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</Canvas.Background>
</Canvas>
<Canvas Grid.Column="2">
<Canvas.Background>
<VisualBrush>
<VisualBrush.Visual>
<Controls:SmileyFace />
</VisualBrush.Visual>
</VisualBrush>
</Canvas.Background>
</Canvas>
</Grid>
</UserControl>
从这个例子中,我们可以立即看到的第一件事是,基于 Shape 对象的绘图方法要简单得多,用更少的 XAML 代码行就能达到与基于更冗长的 Drawing 对象的方法相同的输出。现在让我们来研究一下代码。
在定义了 PresentationOptions XAML 命名空间之后,我们声明了一个 RadialGradientBrush 资源,并通过使用本章前面讨论过的 Freeze 属性来冻结它,从而优化其效率。请注意,如果我们打算同时多次使用这个控件,那么我们可以通过在应用程序资源中声明所有的 Brush 和 Pen 对象,并使用 StaticResource 引用来引用它们,来进一步提高效率。
然后,我们声明一个外部的 Grid 面板,它有两个列。在左侧列中,我们声明另一个 Grid 面板,有五行和五列。这个内部面板用于定位构成第一个笑脸的各种 Shape 元素。请注意,我们在这个面板的行定义上使用星号大小,以略微增加顶部和底部行的大小,以便更好地定位眼睛和嘴巴。
在面板内部,我们定义一个Ellipse对象来创建脸的整体形状,用资源中的画笔填充它,并用黑色画笔添加轮廓。然后我们使用两个进一步填充黑色画笔的Ellipse元素来绘制眼睛,以及一个Path元素来绘制微笑。请注意,我们没有填充Path元素,因为这看起来更像是一个张开的嘴巴而不是微笑。
需要注意的两个其他重要点是,我们必须将Stretch属性设置为Fill,以便让Path元素填充我们提供的可用空间,并且我们必须将StrokeStartLineCap和StrokeEndLineCap属性设置为Round,以产生微笑的圆润末端。
我们指定Path元素应使用的形状,使用其Data属性和之前使用的内联迷你语言。现在让我们将这个值分解成各种迷你语言命令:
M0,10 A10,25 0 0 0 12.5,10
与前面的例子一样,我们首先使用指定为M和随后的坐标对的移动命令,这决定了线的起点。其余部分由椭圆弧命令组成,该命令由A和随后的五个参数指定。
按顺序,椭圆弧命令的五个参数与弧的大小有关,或者说是其x和y半径,旋转角度,一个位字段用于指定弧的角度是否应该大于 180 度,另一个位字段用于指定弧应该顺时针还是逆时针绘制,最后是弧的终点。
这个路径迷你语言语法的完整细节可以在微软网站上找到。请注意,我们可以将绘制方向的位字段更改为1,以绘制一个皱眉:
M0,10 A10,25 0 0 1 12.5,10
现在,让我们转向外部Grid面板的第二列。在这一列中,我们重新创建相同的笑脸,但使用更高效的基于Drawing对象的对象。由于它们不能像Shape类那样自行渲染,我们需要利用其他元素来完成这项工作,因此我们定义它们在DrawingBrush元素内部,并使用它来绘制Canvas对象的背景。
这里有两个需要注意的重要事项。首先,我们本可以使用DrawingBrush元素来绘制任何扩展FrameworkElement类的类,例如Rectangle元素或另一种类型的面板。
第二点是,由于我们使用Freeze属性冻结了DrawingBrush元素,所有扩展Freezable类型的内部元素也将被冻结。在这种情况下,这包括GeometryDrawing对象、EllipseGeometry和PathGeometry对象,甚至用于绘制它们的Brush和Pen元素。
当使用DrawingBrush对象来渲染我们的绘图时,我们必须使用Drawing属性来定义它们。由于我们希望从多个基于Drawing的对象构建我们的图像,我们需要将它们全部包裹在一个DrawingGroup对象中。
为了重新创建脸的整体形状,我们从一个GeometryDrawing元素开始,并指定一个EllipseGeometry对象作为其Geometry属性值。使用这个GeometryDrawing元素,我们通过设置我们的RadialGradientBrush资源引用到其Brush属性来绘制背景,并在其Pen属性中定义一个新的Pen实例以指定其轮廓。
与所有Geometry对象一样,我们指定其尺寸,使它们相互成比例,而不是使用确切的像素大小。例如,我们的视图高度为 150 像素;然而,我们并没有将这个EllipseGeometry对象的Center属性设置为 75,即高度的一半,而是将其设置为 50。
由于两个半径属性也设置为 50,它们与中心的相对位置保持一致,因此生成的图像按比例缩放以适应其渲染的容器。我们使用的比例取决于我们的偏好。例如,我们可以在我们的绘图示例中将所有坐标、半径、画笔和笔触厚度都除以相同的数值,最终得到相同的面视觉。
接下来,我们为脸上的两个眼睛各自添加另一个具有EllipseGeometry对象指定在其Drawing属性中的GeometryDrawing元素。这些没有轮廓,因此Pen属性没有分配任何内容,并且仅使用黑色Brush设置其Brush属性进行着色。最后的GeometryDrawing元素承载了一个绘制脸上微笑的PathGeometry对象。
注意,在 XAML 中定义PathGeometry对象比使用路径迷你语言语法更为冗长。在其中,我们需要在PathFigures集合属性中指定每个PathFigure元素,尽管实际上在 XAML 中声明周围集合是可选的。在我们的笑脸示例中,我们只需要定义一个包含ArcSegment对象的单个PathFigure元素。
PathFigure元素的StartPoint属性决定了弧应该开始的位置,ArcSegment对象的Size属性与弧的大小相关,或者其x和y半径,而其Point属性指定了弧的终点。
为了定义与之前笑脸相同的圆滑端点,我们指定给这个PathGeometry对象的Pen元素必须将其StartLineCap和EndLineCap属性设置为PenLineCap枚举的Round成员。这完成了绘制笑脸的第二种方法。
第三种方法在代码内部使用DrawingVisual对象,并生成一个Visual对象。由于Grid面板的Children集合中的项是UIElement类型,我们无法直接将其添加到其中。相反,我们可以将其设置为VisualBrush元素的Visual属性,并使用它来绘制一个高效容器(如Canvas控件)的背景。
现在我们来看看SmileyFace类中的代码:
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Views.Controls
{
public class SmileyFace : Visual
{
private VisualCollection visuals;
public SmileyFace()
{
visuals = new VisualCollection(this);
visuals.Add(GetFaceDrawingVisual());
}
private DrawingVisual GetFaceDrawingVisual()
{
RadialGradientBrush radialGradientBrush =
new RadialGradientBrush(Colors.Yellow, Colors.Orange);
radialGradientBrush.RadiusX = 0.8;
radialGradientBrush.RadiusY = 0.8;
radialGradientBrush.Freeze();
Pen outerPen = new Pen(Brushes.Black, 5.25);
outerPen.Freeze();
DrawingVisual drawingVisual = new DrawingVisual();
DrawingContext drawingContext = drawingVisual.RenderOpen();
drawingContext.DrawEllipse(radialGradientBrush, outerPen,
new Point(75, 75), 72.375, 72.375);
drawingContext.DrawEllipse(Brushes.Black, null,
new Point(44.25, 49.5), 10.125, 12.75);
drawingContext.DrawEllipse(Brushes.Black, null,
new Point(105.75, 49.5), 10.125, 12.75);
ArcSegment arcSegment =
new ArcSegment(new Point(115.5, 93.75), new Size(61.5, 61.5), 0,
false, SweepDirection.Counterclockwise, true);
PathFigure pathFigure = new PathFigure(new Point(34.5, 93.75),
new List<PathSegment>() { arcSegment }, false);
PathGeometry pathGeometry =
new PathGeometry(new List<PathFigure>() { pathFigure });
pathGeometry.Freeze();
Pen smilePen = new Pen(Brushes.Black, 10.5);
smilePen.StartLineCap = PenLineCap.Round;
smilePen.EndLineCap = PenLineCap.Round;
smilePen.Freeze();
drawingContext.DrawGeometry(null, smilePen, pathGeometry);
drawingContext.Close();
return drawingVisual;
}
protected override int VisualChildrenCount
{
get { return visuals.Count; }
}
protected override Visual GetVisualChild(int index)
{
if (index < 0 || index >= visuals.Count)
throw new ArgumentOutOfRangeException();
return visuals[index];
}
}
}
我们可以从几个类中扩展我们的SmileyFace类,以便在 UI 中显示它。正如我们在第五章中看到的,使用适合工作的正确控件,大多数 UI 控件都有丰富的继承层次结构,每个扩展类都提供了一些特定的功能。
为了使我们的DrawingVisual最有效的容器,我们希望扩展一个类,使其能够参与布局过程,但通过未使用的属性和不必要的事件处理尽可能减少额外的开销。因此,我们选择了Visual类,它不能直接在 XAML 中用作 UI 元素,但它可以作为VisualBrush元素的视觉并用于绘制表面。
为了在SmileyFace类中生成一个或多个DrawingVisual元素,我们需要声明并维护一个VisualCollection实例,该实例将包含我们想要显示的Visual元素。在构造函数中,我们初始化这个集合,并通过GetFaceDrawingVisual方法将我们想要渲染的单个DrawingVisual元素添加到其中。
在GetFaceDrawingVisual方法中,我们首先使用RadialGradientBrush类和一个Pen元素声明我们RadialBrush资源的新版本,并使用它们的Freeze方法将其冻结。接下来,我们初始化一个单独的DrawingVisual元素,并从其RenderOpen方法中访问一个DrawingContext对象,以便绘制我们的形状。
我们使用DrawingContext对象首先绘制作为脸部背景的椭圆。它使用冻结的Brush和pen元素着色。请注意,由于Visual类没有Stretch属性或大小概念,我们在这里使用的维度是精确的设备无关像素维度,而不是像之前绘图方法中使用的那种相对值。
在这个例子中,我们的笑脸宽度为 150 像素,高度为 150 像素,因此中心位置将是其一半。因此,这些精确的像素值可以通过将之前基于Drawing的示例中的相对值乘以1.5来计算。
然而,我们还需要考虑这样一个事实,轮廓将有一半在绘制内部,一半在绘制外部。因此,我们需要调整这个椭圆的两个半径,将它们减少到轮廓大小的一半。由于用于这个椭圆的笔的厚度为5.25设备无关像素,我们需要将每个半径减少2.625。
接下来,我们再次调用DrawEllipse方法来绘制每个眼睛,传入一个黑色画刷和没有Pen元素,以及它们新计算的位置和大小。对于微笑,我们首先需要创建一个ArcSegment元素并将其添加到PathSegment类型的集合中,同时初始化一个PathFigure对象。
然后,我们将 PathFigure 对象添加到一个集合中,并将其传递给 PathGeometry 对象的构造函数以初始化它。接下来,我们定义将用于绘制笑脸的 Pen 对象,确保我们将它的 StartLineCap 和 EndLineCap 属性设置为 PenLineCap 枚举的 Round 成员,就像之前的例子一样。
然后,我们将这个 Pen 对象冻结,并将其与 PathGeometry 对象一起传递给 DrawingContext 对象的 DrawGeometry 方法来绘制它。最后,我们使用其 Close 方法关闭绘图上下文,并返回我们刚刚创建的单个 DrawingVisual 元素。
虽然我们现在已经处理了绘制笑脸的代码,但在 UI 中我们仍然看不到任何东西。为了参与渲染过程,我们需要覆盖 Visual 类的几个成员,即 VisualChildrenCount 属性和 GetVisualChild 方法。
当覆盖这些成员时,我们需要通知 Visual 类它为我们渲染的视觉元素。因此,我们只需从 VisualChildrenCount 属性返回我们内部 VisualCollection 对象中的项目数量,并从 GetVisualChild 方法返回与指定 index 输入参数相关的集合中的项目。
在这个例子中,我们添加了对 index 输入参数无效值的检查,尽管如果我们最初从 VisualChildrenCount 属性输出正确数量的项目,这种情况永远不会发生。
因此,现在我们已经看到了三种不同的绘图方法来创建相同的视觉输出,每个方法都比前一个更高效。然而,除了效率差异之外,我们还应该意识到这些绘图方法在元素操作和多功能性方面的差异。
例如,让我们调整我们的 DrawingView 类的 Width,将其 ClipToBounds 属性设置为 true,并查看其新的输出:
Width="225" Height="150" ClipToBounds="True">
现在我们再次运行应用程序并查看输出:

如前一个截图所示,这些绘图方法在调整大小时表现不同。第一个方法在当前大小下重新绘制,每条绘制线的厚度保持不变,尽管这个脸的宽度已经被提供给它的父 Grid 面板的空隙所缩小。
然而,第二个和第三个笑脸实际上看起来像被压扁的图像,其中每条线的厚度不再静态;线条越垂直,现在它就越细。这些脸的整体宽度也已经被父 Grid 面板调整。
然而,第三个脸部却只被用于显示它的 VisualBrush 对象进行了缩放。如果我们不是扩展 Visual 类,而是从 UIElement 类派生以利用其一些功能,或者可能使我们能够直接在 XAML 中显示我们的 SmileyFace 控件,那么我们会看到不同的输出。让我们对我们的类声明做一些轻微的调整:
public class SmileyFace : UIElement
现在我们也直接在 XAML 中显示它,替换掉之前显示它的 Canvas 和 VisualBrush 对象:
<Controls:SmileyFace Grid.Column="2" />
现在,如果我们再次运行应用程序并查看输出,它看起来会非常不同:

由于我们为绘图指定了精确的值,我们的 SmileyFace 控件没有扩展任何能够实现调整大小或缩放的类,而且我们也不再拥有 VisualBrush 对象来调整它的大小。也就是说,绘图将保持完整的大小,除了它现在不再适合从父 Grid 面板提供的空间。
为了将绘制不同大小形状的能力构建到我们的类中,我们需要从提供额外属性和功能性的类中派生它。FrameworkElement 类为我们提供了可以使用来绘制所需大小形状的尺寸属性,以及一个 Loaded 事件,我们可以使用它来延迟构建我们的形状,直到布局系统计算出相关的大小。
让我们检查我们需要做出的更改以实现这一点:
public class SmileyFace : FrameworkElement
{
...
public SmileyFace()
{
visuals = new VisualCollection(this);
Loaded += SmileyFace_Loaded;
}
private void SmileyFace_Loaded(object sender, RoutedEventArgs e)
{
visuals.Add(GetFaceDrawingVisual());
}
private DrawingVisual GetFaceDrawingVisual()
{
...
DrawingVisual drawingVisual = new DrawingVisual();
DrawingContext drawingContext = drawingVisual.RenderOpen();
drawingContext.DrawEllipse(radialGradientBrush, outerPen,
new Point(ActualWidth / 2, ActualHeight / 2), (ActualWidth -
outerPen.Thickness) / 2, (ActualHeight - outerPen.Thickness) / 2);
drawingContext.DrawEllipse(Brushes.Black, null, new Point(
ActualWidth / 3.3898305084745761, ActualHeight / 3.0303030303030303),
ActualWidth / 14.814814814814815, ActualHeight / 11.764705882352942);
drawingContext.DrawEllipse(Brushes.Black, null, new Point(
ActualWidth / 1.4184397163120568, ActualHeight / 3.0303030303030303),
ActualWidth / 14.814814814814815, ActualHeight / 11.764705882352942);
ArcSegment arcSegment = new ArcSegment(new Point(ActualWidth /
1.2987012987012987, ActualHeight / 1.6), new Size(ActualWidth /
2.4390243902439024, ActualHeight / 2.4390243902439024), 0, false,
SweepDirection.Counterclockwise, true);
PathFigure pathFigure = new PathFigure(new Point(ActualWidth /
4.3478260869565215, ActualHeight / 1.6), new List<PathSegment>() {
arcSegment }, false);
PathGeometry pathGeometry =
new PathGeometry(new List<PathFigure>() { pathFigure });
...
return drawingVisual;
}
...
}
第一个更改是我们需要将生成形状的调用从构造函数移动到 SmileyFace_Loaded 处理方法。如果我们没有移动这个调用,我们的形状将没有大小,因为用于定义其大小的 ActualWidth 和 ActualHeight 属性在那个时间点还没有被布局系统设置。
接下来,在 GetFaceDrawingVisual 方法中,我们需要将硬编码的值替换为控件尺寸的分割。绘制整个脸部的椭圆计算简单,其位置为控件宽度和高度的一半,半径为控件宽度和高度的一半减去绘制轮廓的 Pen 元素厚度的一半。
然而,如果你想知道所有剩余的长小数除数值是从哪里来的,答案是基本的数学。原始绘图宽度和高度都是 150 像素,因此我们可以将这个值除以之前示例中绘制的线条的各种位置和大小。
例如,绘制第一个眼睛的椭圆之前是居中的,X 位置为 44.25。因此,为了计算我们所需的宽度除数,我们只需将 150 除以 44.25,结果为 3.3898305084745761。因此,当控件提供 150 像素的空间时,它将在 X 位置 44.25 处绘制左眼,并且现在将在所有其他大小上正确缩放。
每个绘制形状的位置和大小除数都是使用这种方法计算的,以确保它们能够适当地适应提供给我们的控件的空间。请注意,我们同样可以改变画笔和笔的粗细,但在这个例子中为了简洁我们没有这样做。
当现在运行这个示例时,我们再次得到了一个略微不同的输出:

现在,第一面和第三面的外观更相似,它们绘制的线条的厚度在长度上是静态且不变的,与第二面不同。因此,我们可以看到在创建自定义绘图时有很多选择,我们需要在效率的需求与绘图方法的易用性之间取得平衡,同时也要考虑最终图像的使用。
在进入本章的下一个主题之前,我们在绘制复杂形状时还可以做出一些额外的效率提升。如果我们的代码使用了大量的PathGeometry对象,那么我们可以通过使用StreamGeometry对象来替换它们。
StreamGeometry类专门优化了处理多个路径几何形状,并且比使用多个PathGeometry实例的性能更好。实际上,我们已经在无意中使用StreamGeometry类了,因为当 XAML 读取器解析绑定路径迷你语言语法时,它内部使用的就是这个类。
它可以与StringBuilder类类似地考虑,即它比使用多个PathGeometry类的实例绘制复杂形状更有效率,但它也有一些开销,因此只有在替换相当数量的它们时才会对我们有益。
最后,而不是使用VisualBrush来显示我们的DrawingVisual,VisualBrush在每次布局过程中都会刷新,如果我们的绘图在 UI 中永远不会被操作,那么从它们创建实际图像并显示这些图像将更加高效。
RenderTargetBitmap类为我们提供了一个简单的方法,通过其Render方法从Visual实例创建图像。让我们看看一个这样的例子:
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
...
RenderTargetBitmap renderTargetBitmap = new RenderTargetBitmap(
(int)ActualWidth, (int)ActualHeight, 96, 96, PixelFormats.Pbgra32);
renderTargetBitmap.Render(drawingVisual);
renderTargetBitmap.Freeze();
PngBitmapEncoder image = new PngBitmapEncoder();
image.Frames.Add(BitmapFrame.Create(renderTargetBitmap));
using (Stream stream = File.Create(filePath))
{
image.Save(stream);
}
我们首先初始化一个RenderTargetBitmap对象,指定要创建的图像所需的尺寸、分辨率和像素格式。请注意,静态PixelFormats类的Pbgra32成员指定了一个遵循 sRGB 格式的像素格式,每个像素使用 32 位,每个 alpha、红色、绿色和蓝色通道每个像素接收 8 位。
接下来,我们将我们的DrawingVisual元素或任何扩展了Visual类的其他元素传递给RenderTargetBitmap类的Render方法以渲染它。为了使操作更加高效,我们随后调用它的Freeze方法来冻结对象。
为了保存 PNG 图像文件,我们首先初始化一个 PngBitmapEncoder 对象,并通过 BitmapFrame 类的 Create 方法将 renderTargetBitmap 变量添加到其 Frames 集合中。最后,我们使用 File.Create 方法初始化一个 Stream 对象,传入所需的文件名和路径,并调用其 Save 方法将文件保存到计算机的硬盘上。或者,可以使用 JpegBitmapEncoder 类来创建 JPG 图像文件。
现在我们来探讨如何更有效地使用图像。
更高效地使用图像
当在 WPF 应用程序中显示图像时,它默认以全尺寸加载和解码。如果你的应用程序显示来自原始图像的多个缩略图,那么通过复制你的全尺寸图像并将它们调整到缩略图正确的尺寸,而不是让 WPF 为你完成,你可以获得更好的性能。
或者,你可以要求 WPF 将你的图像解码到缩略图所需的大小,尽管如果你想要显示全尺寸图像,你实际上需要分别解码每个全尺寸图像。让我们看看如何通过使用 BitmapImage 对象作为 Image 控件的源来实现这一点:
<Image Width="64">
<Image.Source>
<BitmapImage DecodePixelWidth="64" UriSource="pack://application:,,,/
CompanyName.ApplicationName;component/Images/Image1.png" />
</Image.Source>
</Image>
这个示例中的重要部分是 BitmapImage 类的 DecodePixelWidth 属性,它指定了解码到图像的实际大小。在这个示例中,这将导致更小的内存占用以及更快的渲染速度。
注意,如果 BitmapImage 类的 DecodePixelHeight 和 DecodePixelWidth 属性都设置了,将根据它们的值计算一个新的宽高比。然而,如果只设置其中一个属性,则将使用图像的原始宽高比。因此,通常只设置其中一个属性,以便将图像解码到与原始大小不同的尺寸,同时保持其宽高比。
通常,当在 WPF 应用程序中使用图像时,它们在加载时都会被缓存到内存中。如果在上述场景中使用代码,还可以获得的一个好处是将 BitmapImage 类的 CacheOption 属性设置为 OnDemand 枚举成员,这将推迟相关图像的缓存,直到实际请求显示图像。
这可以在加载时节省大量的资源,尽管每个图像在首次显示时将稍微慢一点。然而,一旦图像被缓存,它将完全以默认方式创建的图像相同的方式工作。
在BitmapImage类中还有一个额外的属性,可以在加载多个图像文件时提高性能。CreateOptions属性是BitmapCreateOptions枚举类型,使我们能够指定与图像加载相关的初始化选项。此枚举可以使用位组合设置,因为它在其声明中指定了FlagsAttribute属性。
可以使用DelayCreation成员来延迟每个图像的初始化,直到实际需要时,从而加快加载相关视图的过程,同时在实际需要时请求每个图像的过程会增加微小的成本。
这将有利于例如照片画廊类型的应用程序,例如,每个完整尺寸图像的初始化可以延迟到用户点击相应的缩略图时。只有在那时,图像才会被创建,但由于那时只需创建单个图像,因此初始化时间可以忽略不计。
虽然可以使用位或运算符(|)将多个这些成员设置为CreateOptions属性,但应注意不要同时设置PreservePixelFormat成员,除非确实需要,因为这可能会导致性能降低。当未设置时,系统将默认选择具有最佳性能的像素格式。让我们来看一个简短的例子:
private Image CreateImageEfficiently(string filePath)
{
Image image = new Image();
BitmapImage bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.CacheOption = BitmapCacheOption.OnDemand;
bitmapImage.CreateOptions = BitmapCreateOptions.DelayCreation;
bitmapImage.UriSource = new Uri(filePath, UriKind.Absolute);
bitmapImage.Freeze();
bitmapImage.EndInit();
image.Source = bitmapImage;
return image;
}
在代码中创建图像时,我们需要初始化BitmapImage类的一个实例,将其用作将在 UI 中显示的实际Image对象的数据源。在这样做时,我们需要在对其进行更改之前调用其BeginInit方法,然后在其之后调用EndInit方法。请注意,初始化之后所做的所有更改都将被忽略。
在初始化过程中,我们将CacheOption属性设置为OnDemand成员,将CreateOptions属性设置为DelayCreation成员。请注意,我们在此处没有设置DecodePixelWidth或DecodePixelHeight属性,因为此代码示例是为初始化我们画廊示例中的完整尺寸图像而设置的。
此外,请注意,在这个特定的例子中,我们使用绝对文件路径初始化Uri对象,通过将UriKind枚举的Absolute成员传递给构造函数。如果您更喜欢使用相对文件路径,可以将此行更改为指定相对文件路径,通过将UriKind枚举的Relative成员传递给构造函数:
bitmapImage.UriSource = new Uri(filePath, UriKind.Relative);
现在回到示例的末尾,我们可以看到对Freeze方法的调用,这确保了BitmapImage对象将是不可修改的,并且处于其最有效状态。如果稍后需要修改图像,则可以省略此行。
最后,我们调用 EndInit 方法来表示 BitmapImage 对象初始化的结束,将 BitmapImage 对象设置为返回的 Image 对象的 Source 属性值,然后返回 Image 对象给方法调用者。
现在我们已经看到了一些关于如何更有效地显示我们的图像的提示,让我们来调查一下我们如何为我们的应用程序的文本输出做同样的事情。
提升文本输出的性能
WPF 提供了与绘制形状类似的选择来创建文本;输出方法越灵活,使用起来就越容易,但效率就越低,反之亦然。我们中的绝大多数人选择最简单但效率最低的方法,即使用高级的 TextBlock 或 Label 元素。
虽然在典型表单中使用时这通常不会给我们带来任何问题,但在数据网格或其他集合控件中显示成千上万的文本块时,肯定有改进的空间。如果我们需要格式化文本,我们可以利用更高效的 FormattedText 对象;否则,我们可以使用最低级的方法和最有效的 Glyphs 元素。
让我们看看一个例子:
<UserControl x:Class="CompanyName.ApplicationName.Views.TextView"
xmlns:Controls=
"clr-namespace:CompanyName.ApplicationName.Views.Controls"
Height="250" Width="325">
<Grid ShowGridLines="True">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Label Content="Quite Efficient" FontFamily="Times New Roman"
FontSize="50" FontWeight="Bold" FontStyle="Italic"
Foreground="Red" Margin="10,0,0,0" Padding="0" />
<TextBlock Grid.Row="1" Text="More Efficient"
FontFamily="Times New Roman" FontSize="50" FontWeight="Bold"
FontStyle="Italic" Foreground="Black" Margin="10,0,0,0" />
<Controls:FormattedTextOutput Grid.Row="2" Text="More Efficient" />
<Glyphs Grid.Row="3" UnicodeString="Most Efficient"
FontUri="C:\WINDOWS\Fonts\timesbi.TTF" FontRenderingEmSize="50"
Fill="Black" OriginX="10" OriginY="45" />
</Grid>
</UserControl>
在这里,我们有一个包含四个行的 Grid 面板的视图。第一行包含一个 Label 控件,尽管相当高效,但在这里显示的文本输出方法中效率最低,并且正如我们很快就会看到的,它只应在非常特定的情况下使用。在其上,我们指定 FontFamily、FontSize、FontWeight、FontStyle 和 Foreground 属性来定义其文本的外观。
第二行包含一个 TextBlock 元素,它稍微高效一些,并且像 Label 元素一样,我们直接在其上指定 FontFamily、FontSize、FontWeight、FontStyle 和 Foreground 属性。值得注意的是,为了产生相同的视觉输出,我们不需要将其 Padding 属性设置为 0,这在 Label 控件中是必需的。
在第三行,我们有一个自定义的 FormattedTextOutput 控件,它内部使用 FormattedText 对象,并且仍然稍微高效一些。正如我们很快就会看到的,我们需要在代码中指定这个文本对象的相关属性。
最后,我们在第四行看到一个 Glyphs 元素,这代表了在 WPF 应用程序中输出文本的最有效方法。请注意,当使用这种文本输出方法时,我们不是通过名称指定字体族,而是将其精确的字体文件路径设置为 FontUri 属性。
由于我们想要匹配 Times New Roman 字体的粗体斜体版本,我们需要特别设置文件路径到那个确切的文件。因此,我们需要指定 timesbi.ttf 文件,而不是正常的 times.ttf 版本。除了将字体大小设置为 FontRenderingEmSize 属性以及将边距设置为 OriginX 和 OriginY 属性外,这个类相当直观。
在继续之前,让我们首先看看这个视图的视觉输出:

现在我们来看看FormattedTextOutput类内部的代码:
using System.Globalization;
using System.Windows;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Views.Controls
{
public class FormattedTextOutput : FrameworkElement
{
public static readonly DependencyProperty TextProperty =
DependencyProperty.Register(nameof(Text), typeof(string),
typeof(FormattedTextOutput), new FrameworkPropertyMetadata(
string.Empty, FrameworkPropertyMetadataOptions.AffectsRender));
public string Text
{
get { return (string)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
protected override void OnRender(DrawingContext drawingContext)
{
DpiScale dpiScale = VisualTreeHelper.GetDpi(this);
FormattedText formattedText = new FormattedText(Text,
CultureInfo.GetCultureInfo("en-us"), FlowDirection.LeftToRight,
new Typeface("Times New Roman"), 50, Brushes.Red,
dpiScale.PixelsPerDip);
formattedText.SetFontStyle(FontStyles.Italic);
formattedText.SetFontWeight(FontWeights.Bold);
drawingContext.DrawText(formattedText, new Point(10, 0));
}
}
}
FormattedTextOutput类相对简单,只有一个依赖属性及其关联的 CLR 包装和一个重写的基类方法。一个非常重要的一点是,我们使用FrameworkPropertyMetadataOptions枚举的AffectsRender成员来指定更改此属性需要引起新的渲染过程。
通常,Text属性将在UIElement基类调用OnRender方法之后从任何数据绑定中更新。如果不指定此选项,我们的类将永远不会输出任何数据绑定的值。通过指定此选项,我们实际上是在告诉框架每次此属性值更改时都再次调用OnRender方法。
在重写的OnRender方法中,我们首先使用基本属性初始化一个FormattedText对象,例如要渲染的文本、当前的区域设置以及要使用的字体颜色、大小和类型。可以通过类公开的各种设置方法设置额外的样式属性。最后,我们调用由drawingContext输入参数指定的DrawingContext对象的DrawText方法,传入FormattedText对象和渲染位置。
注意,我们可以使用数据绑定与所有这些文本渲染方法,所以现在让我们更新之前的示例来演示这一点:
...
<Label Content="{Binding Text}" FontFamily="Times New Roman"
FontSize="50" FontWeight="Bold" FontStyle="Italic" Foreground="Red"
Margin="10,0,0,0" Padding="0" />
<TextBlock Grid.Row="1" Text="{Binding Text}"
FontFamily="Times New Roman" FontSize="50" FontWeight="Bold"
FontStyle="Italic" Foreground="Red" Margin="10,0,0,0" />
<Controls:FormattedTextOutput Grid.Row="2" Text="{Binding Text}" />
<Glyphs Grid.Row="3" UnicodeString="{Binding Text}" FontUri=
"C:\WINDOWS\Fonts\timesbi.TTF" FontRenderingEmSize="50"
Fill="Black" OriginX="10" OriginY="45" />
...
对于这个示例,我们可以在我们的视图模型中简单地硬编码一个值:
namespace CompanyName.ApplicationName.ViewModels
{
public class TextViewModel : BaseViewModel
{
public string Text { get; set; } = "Efficient";
}
}
虽然我们可以在使用所有这些文本输出方法时进行数据绑定,但也有一些需要注意的事项。我们刚刚了解到与我们自定义的FormattedTextOutput类中的Text属性所需的元数据相关的一个问题,还有一个与Glyphs类相关的问题。
它有一个要求,即如果表示提供要渲染的文本的替代方法的Indicies属性也为空,则UnicodeString属性不能为空。遗憾的是,由于这个要求,尝试像我们在扩展示例中所做的那样将数据绑定到UnicodeString属性,将导致编译错误:
Glyphs Indices and UnicodeString properties cannot both be empty.
为了解决这个问题,我们可以简单地为Binding类的FallbackValue属性提供一个值,这样Glyphs类就可以确信,即使没有数据绑定的值,其UnicodeString属性也将有一个非空值。
注意,将FallbackValue属性设置为空字符串将导致抛出相同的错误:
<Glyphs Grid.Row="3" UnicodeString="{Binding Text, FallbackValue='Data
Binding Not Working'}" FontUri="C:\WINDOWS\Fonts\timesbi.TTF"
FontRenderingEmSize="50" Fill="Black" OriginX="10" OriginY="45" />
关于数据绑定还有一个进一步的问题;然而,这次它涉及到Label类的Content属性。因为string类型是不可变的,每次数据绑定的值更新Content属性时,之前的string类型将被丢弃并替换为新的类型。
此外,如果使用默认的ContentTemplate元素,它将在每次替换属性字符串时生成一个新的TextBlock元素并丢弃之前的元素。因此,更新数据绑定的TextBlock比更新Label控件快大约四倍。因此,如果我们需要更新我们的数据绑定的文本值,我们不应使用Label控件。
实际上,每种渲染文本的方法都有其特定的目的。Label控件应专门用于在表单中标记文本字段,并且通过这样做,我们可以利用其访问键功能及其引用目标控件的能力。TextBlock元素是一种通用的文本输出方法,应该大多数时候使用。
FormattedText对象应该只在我们需要以特定方式格式化某些文本时使用。它提供了输出具有广泛效果的文本的能力,例如能够独立绘制文本的轮廓和填充,以及格式化渲染文本字符串中的特定字符范围。
Glyphs类直接扩展了FrameworkElement类,因此它非常轻量级,并且在我们需要比使用替代方法更有效地重新创建文本输出时应该使用它。尽管FormattedText类可以利用较低级别的核心类来渲染其输出,但渲染文本最有效的方法是使用Glyphs对象。
喜欢链接
正如您已经看到的,我们在视图中使用的每个 UI 元素都需要时间来渲染。简单来说,我们使用的元素越少,视图显示得就越快。那些在我们视图中使用过Hyperlink元素的人可能已经知道,我们不能单独显示它们,而必须将它们包裹在一个TextBlock元素中。
然而,由于每个Hyperlink元素都是自包含的,拥有自己的导航 URI、内容和属性选项,我们实际上可以在单个TextBlock元素中显示多个它们。这将减少渲染时间;因此,我们可以移除的TextBlock元素越多,它就会变得越快。让我们看一个例子:
<ListBox ItemsSource="{Binding Products}" FontSize="14"
HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type DataModels:Product}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Name}" />
<TextBlock Grid.Column="1"
Text="{Binding Price, StringFormat=C}" Margin="10,0" />
<StackPanel Grid.Column="2" TextElement.FontSize="14"
Orientation="Horizontal">
<TextBlock>
<Hyperlink Command="{Binding ViewCommand,
RelativeSource={RelativeSource
AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">View</Hyperlink>
</TextBlock>
<TextBlock Text=" | " />
<TextBlock>
<Hyperlink Command="{Binding EditCommand,
RelativeSource={RelativeSource
AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">Edit</Hyperlink>
</TextBlock>
<TextBlock Text=" | " />
<TextBlock>
<Hyperlink Command="{Binding DeleteCommand,
RelativeSource={RelativeSource
AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">Delete</Hyperlink>
</TextBlock>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
在这里,我们有一个Product对象的集合,这些对象绑定到ListBox上,每个项目都显示其名称、价格以及以Hyperlink对象形式呈现的三个命令。在继续之前,让我们看看它是什么样子:

现在专注于链接,我们的示例在每个项目中使用九个 UI 元素来渲染这三个链接。StackPanel元素将它们全部组合在一起,每个Hyperlink对象都有自己的TextBlock元素和另外两个TextBlock元素来显示管道分隔符字符。
Hyperlink 对象绑定到视图模型中的命令,而 CommandParameter 属性绑定到整个 Product 对象,该对象被设置为每个项目的数据源。这样,当点击链接时,我们将能够访问视图模型中的相关 Product 实例。
虽然这个 XAML 没有什么问题,但如果我们需要更高效,那么我们可以用以下 TextBlock 元素替换 StackPanel 中的所有内容以及面板本身:
<TextBlock Grid.Column="2" TextElement.FontSize="14" Foreground="White">
<Hyperlink Command="{Binding ViewCommand, RelativeSource={
RelativeSource AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">View</Hyperlink>
<Run Text=" | " />
<Hyperlink Command="{Binding EditCommand, RelativeSource={
RelativeSource AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">Edit</Hyperlink>
<Run Text=" | " />
<Hyperlink Command="{Binding DeleteCommand, RelativeSource={
RelativeSource AncestorType={x:Type Views:TextView}}}"
CommandParameter="{Binding}">Delete</Hyperlink>
</TextBlock>
如你所见,我们现在将所有三个 Hyperlink 对象都放在一个单独的 TextBlock 元素中,并用 Run 对象替换了显示管道字符的两个 TextBlock 元素。使用 Run 类比在另一个 TextBlock 元素中使用一个 TextBlock 元素要高效得多。
现在,我们只需要为每个项目渲染六个元素来生成链接,包括使用两个更高效的元素,每个项目渲染的元素减少三个。然而,如果我们有 1,000 个产品,我们最终将渲染 3,000 个更少的 UI 元素,用 2,000 个更高效的替换,所以很容易看出这可以很快地积累成一些真正的效率节省。
在这个例子中,我们可以通过简单地删除每个链接下的行来进一步改进。令人惊讶的是,如果我们删除它们的下划线,我们可以将渲染我们的 Hyperlink 元素所需的时间减少高达 25%。我们可以通过将它们的 TextDecorations 属性设置为 None 来做到这一点:
<Hyperlink ... TextDecorations="None">View</Hyperlink>
我们可以通过仅在用户的鼠标光标悬停在链接上时显示下划线来进一步扩展这个想法。这样,我们仍然提供了视觉确认,表明链接实际上是一个链接,但我们节省了初始渲染时间:
<Style TargetType="{x:Type Hyperlink}">
<Setter Property="TextDecorations" Value="None" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="TextDecorations" Value="Underline" />
</Trigger>
</Style.Triggers>
</Style>
现在,让我们将注意力转向在应用程序中的数据绑定时可以做出的多项性能改进。
数据绑定
当数据绑定时,性能的最简单改进可以通过正确设置 Binding.Mode 属性来获得。为了使数据绑定成为可能,框架会附加处理程序来监听我们数据绑定属性的变化。
对于双向绑定,事件处理程序将被附加到 INotifyPropertyChanged 接口的 PropertyChanged 事件,以监听我们数据模型对象或视图模型中的变化,以及相关绑定目标控件中的各种其他 XxxChanged 事件,以监听基于 UI 的属性变化。
当我们只需要单向绑定时,我们可以通过将 Binding 类的 Mode 属性设置为 BindingMode 枚举的适当成员来节省一些计算资源。如果你还记得,当一个数据绑定属性仅用于显示目的时,我们应该将其 Mode 属性设置为 OneWay,而当我们不需要从视图模型更新可编辑字段时,我们应该将其 Mode 属性设置为 OneWayToSource 成员。
在这样做的时候,我们减少了监听变化的处理程序数量,因此可以释放资源以供实际需要的地方使用。再次强调,仅对单个绑定执行此操作的影响将微乎其微,但如果我们对每个相关的绑定都这样做,那么效率的提高将开始产生差异。
另一个良好的实践是,为每个我们声明的绑定设置Binding类的FallbackValue属性。如第四章中提到的精通数据绑定,这样做将阻止 WPF 框架在数据绑定错误时查找目标依赖属性的默认值,并防止生成和输出跟踪语句。
同样,设置TargetNullValue属性与设置FallbackValue属性相似,因为它比不设置它稍微高效一些。再次强调,在单个绑定上这样做的影响可以忽略不计;然而,如果我们对每个绑定都这样做,它将为渲染或其他所需过程释放 CPU 周期。
实际上,提高我们应用程序性能的最佳绑定相关方法是简单地修复我们可能有的任何数据绑定错误。每次绑定无法解析时,框架都会执行一系列检查,消耗宝贵的资源,正如本节前面所提到的。因此,当涉及到性能时,保持输出窗口没有绑定错误是必须的。
注册依赖属性
正如我们在本章前面的使用正确的控件以获得性能部分所看到的,在设置我们的依赖属性元数据时,我们需要小心。在注册依赖属性时错误地指定框架元数据可能会降低性能,迫使布局系统进行不必要的额外布局遍历。
尤其是在指定FrameworkPropertyMetadataOptions枚举中的任何AffectsMeasure、AffectsArrange、AffectsParentMeasure、AffectsParentArrange或AffectsRender成员时,我们需要格外小心,并确保它们确实是必需的。
同样,如果我们注册依赖属性时指定了FrameworkPropertyMetadataOptions枚举的Inherits成员,那么实际上是在增加属性无效化所需的时间长度。因此,我们应该确保这个特定的元数据成员仅在真正需要时使用。
最后一个可以改善应用程序性能的元数据选项是SubPropertiesDoNotAffectRender成员。如果我们的依赖属性类型是引用类型,我们可以指定这个枚举成员以阻止布局系统检查对象的所有子属性的变化,否则它将默认这样做。
虽然我们可能需要调用DependencyProperty类的OverrideMetadata方法来覆盖.NET Framework 中现有属性的元数据,但这会带来轻微的性能影响。当我们为我们的自定义依赖属性设置元数据时,我们应该始终使用适当的Register或RegisterAttached方法来指定我们的要求,因为这提供了更好的性能。
同样,当注册我们的自定义依赖属性时,我们也应该使用相关的Register或RegisterAttached方法设置它们的默认值,因为它们被创建时,而不是在构造函数中逐个实例化,或者使用其他方法。
绑定到集合
如你所可能意识到的,当在 WPF 应用程序中处理将要更新的集合时,我们倾向于使用泛型的ObservableCollection<T>类。原因在于这个类实现了INotifyCollectionChanged接口,该接口会通知监听者集合的变化,例如添加、删除或清除项目。
我们可能没有意识到,使用此类来保存我们的数据集合可以带来惊人的性能提升。例如,与泛型的List<T>类相比,我们注意到它不会自动引发任何集合更改事件。为了使视图能够显示更新的集合,我们需要将其重置为相关集合控件的相关ItemsSource属性值。
然而,每次设置ItemsSource属性时,数据绑定集合控件都会清除其当前的项目列表并完全重新生成它们,这可能会是一个耗时的过程。因此,向ObservableCollection<T>添加单个项目大约需要 20 毫秒来渲染,但重置ItemsSource属性值可能需要超过 1.5 秒。
然而,如果我们的集合是不可变的,并且我们不会以任何方式更改它,我们就不需要使用泛型的ObservableCollection<T>类,因为我们不需要它的更改处理程序。与其浪费资源在未使用的更改处理程序上,我们可以使用不同类型的集合类。
当将不可变集合绑定到 UI 控件时,虽然没有首选的集合类型,但我们应尽量避免使用IEnumerable类作为集合容器。此类不能直接由ItemsControl类使用,并且当它被使用时,WPF 框架将生成一个泛型的IList<T>集合来包装IEnumerable实例,这也可能对性能产生负面影响。
在接下来的几节中,我们将探讨其他我们可以高效显示大量集合的方法。
缩小数据对象
很常见,我们的应用程序将拥有相当大的数据对象,拥有数十个甚至数百个属性。如果我们有数千个这样的数据对象,并且为每个数据对象加载所有属性,我们的应用程序会变慢,甚至可能耗尽内存。
我们可能会认为,如果我们简单地不填充所有属性值,就可以节省 RAM;然而,如果我们使用相同的类,我们很快就会发现,即使是这些属性的默认值或空值也可能消耗太多的内存。一般来说,除了少数例外,未设置的属性与已设置的属性占用相同的 RAM。
如果我们的数据模型对象具有非常多的属性,一个解决方案是将它分解成更小的部分。例如,我们可以创建多个较小的子产品类,如ProductTechnicalSpecification、ProductDescription、ProductDimension、ProductPricing等。
而不是构建一个巨大的视图来编辑整个产品,我们还可以提供多个较小的视图,可能甚至可以从同一视图的不同标签页中访问。这样,我们就可以只加载ProductDescription对象供用户选择,然后在每个子视图中加载产品的各个部分。
通过这种方法可以获得显著的性能提升,因为绑定到一个具有许多属性的单一对象可能需要比绑定到具有较少属性的许多对象多四倍的时间。
将我们的数据对象拆分成更小的部分的一个替代方案是使用瘦数据对象的概念。例如,假设我们的Product类有数十个属性,而我们有成千上万的产品。我们可以创建一个只包含用于识别在选中时加载的完整数据对象以及显示在产品集合中的属性的ThinProduct类。
在这种情况下,我们可能只需要在ThinProduct类中设置两个属性,一个唯一的标识属性和一个显示名称属性。这样,我们可以将产品的内存占用减少 10 倍甚至更多。这意味着它们可以从数据库中加载并在极短的时间内显示,比完整的Product对象快得多。
为了方便在Product和ThinProduct类之间进行数据传输,我们可以在每个类中添加构造函数,该构造函数接受另一个类型并更新相关属性:
using System;
namespace CompanyName.ApplicationName.DataModels
{
public class ThinProduct : BaseDataModel
{
private Guid id = Guid.Empty;
private string name = string.Empty;
public ThinProduct(Product product)
{
Id = product.Id;
Name = product.Name;
}
public Guid Id
{
get { return id; }
set { if (id != value) { id = value;
NotifyPropertyChanged(); } }
}
public string Name
{
get { return name; }
set { if (name != value) { name = value;
NotifyPropertyChanged(); } }
}
public override string ToString()
{
return Name;
}
}
}
在这个ThinProduct类中的属性基本上反映了我们之前看到的Product类中的属性,但只是用于识别每个实例的属性。添加了一个构造函数,它接受一个类型为Product的输入参数,以实现两个类之间的轻松转换。类似的构造函数也添加到Product类中,但它接受一个类型为ThinProduct的输入参数:
public Product(ThinProduct thinProduct) : this()
{
Id = thinProduct.Id;
Name = thinProduct.Name;
}
理念是我们有一个视图模型,它显示大量产品,在代码中,我们实际上加载了大量这些更轻的 ThinProduct 实例。当用户选择一个产品进行查看或编辑时,我们使用所选项目的标识号来加载与该标识符相关的完整 Product 对象。
给定一个名为 Products 的属性中的这些 ThinProduct 实例的基础集合,我们可以这样实现。首先,让我们将我们的集合绑定到一个 ListBox 控件上:
<ListBox ItemsSource="{Binding Products}"
SelectedItem="{Binding Products.CurrentItem}" ... />
当用户从列表中选择一个产品时,集合的 CurrentItem 属性将持有所选项目的引用。如果我们在一开始加载集合时将其 CurrentItemChanged 代理附加到一个处理程序上,我们就可以在项目被选中时得到通知。
在这一点上,我们可以使用所选 ThinProduct 实例的标识符来加载完整的 Product 对象,并将相关的反馈输出给用户:
private void Products_CurrentItemChanged(ThinProduct oldProduct,
ThinProduct newProduct)
{
GetDataOperationResult<Product> result =
await Model.GetProductAsync(newProduct.Id);
if (result.IsSuccess) Product = result.ReturnValue;
else FeedbackManager.Add(result, false);
}
在下一节中,我们将了解如何使用集合控件更有效地显示我们的大量集合,而不是将我们的大型类拆分成更小的类或创建相关联的轻量级数据对象。
虚拟化集合
当我们在集合控件中显示大量项目时,可能会对应用程序的性能产生负面影响。这是因为布局系统将为数据绑定集合中的每个项目创建一个布局容器,例如,在 ComboBox 的情况下,创建一个 ComboBoxItem。由于在任何时候只显示完整项目数的一小部分,我们可以利用虚拟化来改善这种情况。
UI 虚拟化将生成和布局这些项目容器推迟到每个项目实际上在相关集合控件中可见时,通常可以节省大量资源。如果我们使用 ListBox 或 ListView 控件来显示我们的集合,我们可以利用虚拟化而不必做任何事情,因为它们默认使用它。
在 ComboBox、ContextMenu 和 TreeView 控件中也可以启用虚拟化,尽管这需要手动完成。当使用 TreeView 控件时,我们可以通过将 VirtualizingStackPanel.IsVirtualizing 附加属性设置为 True 来启用虚拟化:
<TreeView ItemsSource="{Binding Items}"
VirtualizingStackPanel.IsVirtualizing="True" />
对于内部使用 StackPanel 类的其他控件,例如 ComboBox 和 ContextMenu 控件,我们可以通过将一个包含 VirtualizingStackPanel 类实例的 ItemsPanelTemplate 元素设置为 ItemsPanel 属性,并将它的 IsVirtualizing 属性设置为 True 来启用虚拟化:
<ComboBox ItemsSource="{Binding Items}">
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsVirtualizing="True" />
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
</ComboBox>
除了将 IsVirtualizing 属性设置为 False 之外,还有其他一些原因可能导致 UI 虚拟化不起作用。一种情况是项目容器已被手动添加到 ItemsControl 对象或其派生控件中。另一种情况是项目容器具有不同的类型。
虚拟化可能不起作用的最后一个原因并不那么明显,它与ScrollViewer类的CanContentScroll属性有关。这是一个有趣的属性,它指定了集合控件中的ScrollViewer是否将以逻辑单位或物理单位滚动其项。默认值是False,这意味着以物理单位平滑滚动。
物理单位与 WPF 工作的设备无关像素相关,而逻辑单位与集合项的宽度或高度相关,这取决于控件的方向。由于CanContentScroll属性的默认值是False,因此需要将其设置为True以启用虚拟化。这样,滚动是逐项进行的,而不是逐像素进行的。
当在扩展ItemsControl类的集合控件中使用虚拟化,并且用户滚动时,会为新的可见项创建新的项容器,并且不再可见的项的容器将被销毁。
在.NET Framework 3.5 版本中,引入了对虚拟化系统的优化。容器回收使集合控件能够重用项容器,而不是在用户滚动时创建新的容器并丢弃旧的容器。这提供了额外的性能优势,可以通过将VirtualizationMode附加属性设置为Recycling值来启用:
<TreeView ItemsSource="{Binding Items}"
VirtualizingStackPanel.IsVirtualizing="True" />
VirtualizingStackPanel.VirtualizationMode="Recycling" />
WPF 为我们提供的另一个进一步优化是延迟滚动。通常,在集合控件中滚动会持续更新 UI。然而,如果我们的数据项或它们的项容器有多个定义它们的视觉层,并且滚动速度较慢,我们可以选择将 UI 更新延迟到滚动完成。
为了在集合控件上启用延迟滚动,我们需要将ScrollViewer.IsDeferredScrollingEnabled附加属性设置为True。尽管我们通常不在 XAML 中直接使用ScrollViewer元素,但我们也可以将此属性附加到在其控件模板中包含ScrollViewer元素的集合控件上:
<ListBox ItemsSource="{Binding Items}"
ScrollViewer.IsDeferredScrollingEnabled="True" />
我们现在已经调查了我们可以通过计算机硬件、资源、正确的控件选择、绘制和显示图像的方法、输出文本、链接、数据绑定、最小化内存占用以及数据虚拟化来实现的性能改进。只有一个关键领域需要考虑,那就是事件,所以让我们看看下一个。
处理事件
应用程序中出现内存泄漏的最常见原因之一是未能移除不再需要的处理程序。当我们以常规方式将事件处理程序附加到对象的某个事件上时,我们实际上是在向该对象传递处理程序的引用,并为其创建了一个硬引用。
当对象不再需要且可能被释放时,引发事件的那个对象中的引用将阻止这种情况发生。这是因为垃圾收集器无法收集可以从应用程序代码的任何部分访问的对象。在最坏的情况下,保持对象存活可能包含许多其他对象,因此无意中也将它们保持存活。
这个问题在于,在对象不再需要后继续保持对象存活将无谓地增加应用程序的内存占用,在某些情况下,会有戏剧性和不可逆转的后果,导致抛出 OutOfMemoryException。因此,在尝试释放它们之前,从我们不再使用的对象中移除我们订阅的事件的事件处理器是至关重要的。
然而,有一个替代方法可以用来避免这种情况。在 .NET Framework 中,有一个 WeakReference 类,它可以用来移除使用传统方法将事件处理器附加到事件所引起的硬引用。
基本的想法是,引发事件的类应该维护一个 WeakReference 实例的集合,并在另一个类将事件处理器附加到事件时将其添加到该集合中。现在,让我们从之前创建的 ActionCommand 类中创建一个新的 WeakReferenceActionCommand 类,以使用这个 WeakReference 类:
using System;
using System.Collections.Generic;
using System.Windows.Input;
namespace CompanyName.ApplicationName.ViewModels.Commands
{
public class WeakReferenceActionCommand : ICommand
{
private readonly Action<object> action;
private readonly Predicate<object> canExecute;
private List<WeakReference> eventHandlers = new List<WeakReference>();
public WeakReferenceActionCommand(Action<object> action) :
this(action, null) { }
public WeakReferenceActionCommand(Action<object> action,
Predicate<object> canExecute)
{
if (action == null) throw new ArgumentNullException("The action
input parameter of the WeakReferenceActionCommand constructor
cannot be null.");
this.action = action;
this.canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add
{
eventHandlers.Add(new WeakReference(value));
CommandManager.RequerySuggested += value;
}
remove
{
if (eventHandlers == null) return;
for (int i = eventHandlers.Count - 1; i >= 0; i--)
{
WeakReference weakReference = eventHandlers[i];
EventHandler handler = weakReference.Target as EventHandler;
if (handler == null || handler == value)
{
eventHandlers.RemoveAt(i);
}
}
CommandManager.RequerySuggested -= value;
}
}
public void RaiseCanExecuteChanged()
{
eventHandlers.ForEach(
r => (r.Target as EventHandler)?.Invoke(this, new EventArgs()));
}
public bool CanExecute(object parameter)
{
return canExecute == null ? true : canExecute(parameter);
}
public void Execute(object parameter)
{
action(parameter);
}
}
}
我们首先将我们的新集合的声明添加到现有的字段中,该集合包含 WeakReference 类型的对象。两个构造函数保持不变,但在 CanExecuteChanged 事件中附加处理器时,我们现在将事件处理委托包装在一个 WeakReference 对象中,并将其添加到集合中。我们仍然需要像之前一样将附加到处理器上的引用传递到 CommandManager 类的 RequerySuggested 事件。
当移除事件处理器时,我们首先检查我们的 WeakReference 集合是否不是 null,如果是,则简单地返回控制权给调用者。如果不是,我们使用一个 for 循环以相反的顺序遍历集合,这样我们就可以在不影响循环索引的情况下移除项目。
我们依次尝试从每个 WeakReference 对象的 Target 属性中访问实际的事件处理器,使用 as 关键字将其转换为 EventHandler 基类型。然后,如果其事件处理器引用是 null 或与正在移除的处理程序匹配,我们就移除该 WeakReference 实例。
注意,Target 属性中的 null 引用将是垃圾收集器已处理掉的一个类的事件处理器的结果。和之前一样,我们随后也会从 CommandManager.RequerySuggested 事件中移除该事件处理器。
最后,我们需要更新我们的RaiseCanExecuteChanged方法,以便使用我们新的WeakReference对象集合。在其中,我们再次使用我们的ForEach扩展方法遍历集合中的每个实例,并使用空条件运算符检查其Target属性是否为null,然后通过委托的Invoke方法调用它。
因此,这里的想法是我们不再直接持有任何对附加事件处理程序的引用,因此我们可以随时自由地处置这些类,而不用担心它们被不必要地保留。
摘要
在本章中,我们探讨了我们可以使用的多种选项来提高我们的 WPF 应用程序的性能。正如我们所看到的,这更多的是通过进行大量的小改动来获得整体明显的性能提升。
我们看到我们可以利用计算机图形卡的图形渲染能力,并更有效地声明资源。我们研究了使用更轻量级的 UI 控件和更高效的渲染绘图、图像和文本的方法来提高我们应用程序的性能。我们还学习了如何进行数据绑定、显示大型对象和集合,以及以改进的性能处理事件。
在下一章中,我们将探讨所有专业应用最终的需求,即部署。在其中,我们首先将介绍较老的方法,即使用 Windows Installer 软件,然后进一步研究更常见且更新的方法,即使用 ClickOnce 功能。
第十二章:部署您的杰作应用程序
因此,我们已经设计和构建了我们的应用程序框架、资源和管理者,添加了我们的模型、视图和视图模型,在完成应用程序的开发后,现在是部署的时候了。在本章中,我们将概述部署 WPF 应用程序的三个主要方法。
我们将首先研究原始的 Windows 设置项目方法,然后继续了解更新的 InstallShield Limited Edition 项目方法,最后将检查推荐的 ClickOnce 技术。
安装 Windows 应用程序
在过去的日子里,在 Visual Studio 中创建设置和部署项目是一个令人困惑且复杂的过程。然而,正如.NET 中的几乎所有事情一样,多年的连续更新导致了这些项目创建方法的持续改进。
介绍设置项目
最近的部署技术使用起来更简单,提供了一种易于理解的方法来执行与早期技术相同的步骤。然而,在 Visual Studio 的旧版本中,我们可能只能访问旧的 Visual Studio Installer 项目类型,所以让我们首先研究标准的设置项目:

在解决方案中添加一个设置项目后,会打开一个页面,显示目标计算机上的文件系统。在这个文件系统编辑器页面上,我们可以指定我们想要安装的内容以及安装位置。页面分为两部分,左侧是用户计算机上要安装的文件夹树视图,右侧是它们的文件夹内容。默认情况下,左侧面板包含应用程序、桌面和程序文件文件夹。
如果我们希望使用其他预定义的位置,例如字体、收藏夹或公共文件文件夹,例如,那么我们可以右键单击这些面板的背景,并选择添加特殊文件夹选项。通常,我们会将带有我们公司名称的标准文件夹添加到用户程序菜单文件夹中,并在其中添加一个以我们的应用程序命名的文件夹。
然而,如果我们想要将我们的应用程序作为 64 位应用程序安装,那么我们需要使用此选项将 64 位的程序文件文件夹添加到安装位置。为此,我们需要在树视图顶部的目标计算机文件系统上右键单击,选择添加特殊文件夹选项,然后选择程序文件(64 位)文件夹项。
注意,我们只有在想要进行 64 位安装的情况下才应执行此步骤。然后我们需要将启动项目的项目输出设置为左侧面板中的文件夹,该文件夹代表我们的安装文件夹,无论是 32 位还是 64 位。
我们需要右键单击该文件夹,从上下文菜单中选择添加选项,然后选择与我们的CompanyName.ApplicationName项目相关的项目输出选项。完成此操作后,我们将看到从其bin文件夹中的可执行文件和其他依赖文件被包含在我们的所选应用程序文件夹中。
接下来,我们可以通过在右侧窗格中右键单击项目输出的图标并从菜单中选择从 CompanyName.ApplicationName(活动)创建快捷方式到主要输出选项来在安装了应用程序的机器上创建应用程序的快捷方式。
我们需要给它与我们的应用程序相同的名称,并为其设置一个图标,这可以在其属性窗口中完成。然后我们可以点击并拖动,或者复制并粘贴到用户桌面文件夹,或者我们希望快捷方式出现的任何文件夹。
除了可执行文件和快捷方式文件外,我们还可以在左侧窗格中右键单击一个文件夹,从上下文菜单中选择添加选项,然后选择文件夹和/或文件选项,并选择我们可能需要在用户的计算机上安装的任何其他文件。一旦我们完成配置文件系统编辑器,我们可以在解决方案资源管理器中右键单击项目节点,并从视图菜单中选择另一个页面进行编辑。
注册表编辑器页面接下来,它使我们能够在宿主计算机的 Windows 注册表中进行条目。左侧窗口区域充当目标计算机的注册表视图,我们可以像使用注册表编辑器一样使用它来添加新键。此页面还允许我们在右键单击空白区域并选择导入时,从.reg文件导入注册表键。
文件类型编辑器页面位于视图菜单中,它使我们能够将我们可能创建的任何自定义文件类型与我们的应用程序关联起来。在这样做之后,安装完成后,每当点击此页面上指定类型之一的文件时,Windows 都会打开我们的应用程序。
安装项目使我们能够在安装过程中显示多个默认对话框,例如欢迎、确认和完成对话框。它还提供了重新排序或删除这些默认对话框的能力,或者从预定义列表中添加新的对话框。每个对话框都提供图像字段和不同的选项,例如是否应显示进度条,或在安装的不同阶段显示什么文本。这是在用户界面页面上实现的。
自定义操作编辑器页面使我们能够指定包含特定形式代码的程序集,这些代码可以在应用程序安装后运行。这些操作可以是任何事情,例如弹出一个小表单并提供用户一些配置选项,或者简单地安装完成后打开特定的网页。
设置项目视图菜单中的最后一个选项打开启动条件编辑器页面。在这里,我们可以指定必须满足的先决条件,以便应用程序可以被安装。例如,我们可能需要安装特定版本的.NET Framework,或者主机计算机具有特定的注册表键设置。
一旦所有项目页面都适当完成,我们只需构建设置和部署项目以生成设置文件。然而,我们需要确保我们正确构建它,这取决于我们在文件系统编辑器页面上的选择。
例如,如果我们想要有多个设置项目,比如说包括 32 位和 64 位安装,那么我们只需要在 32 位解决方案平台上构建设置项目的 32 位版本,并在 64 位解决方案平台上只构建 64 位版本。
我们可以在 Visual Studio 的配置管理器中完成此操作,我们可以从解决方案配置或解决方案平台下拉控件中的最后一个选项打开它。如果x86和x64解决方案平台尚未存在,我们可以通过在配置管理器对话框窗口中的解决方案平台下拉控件中选择<新建...>选项来添加它们。
要在打开的新解决方案平台对话框中添加新的解决方案平台,请在类型或选择新平台字段中键入x86或x64,从复制设置下拉控件中选择<空>选项,并确保已勾选创建新项目平台复选框。
一旦我们有了这两个解决方案平台,我们可以在配置管理器对话框窗口中的活动解决方案平台下拉控件中逐个选择它们,并勾选和取消勾选相关的设置项目。
这里是选择x86解决方案的截图:

这里是选择x64解决方案的截图:

注意,我们必须在解决方案配置下拉菜单中选择发布,然后构建我们的项目以生成设置文件。如果我们正确设置了构建配置,那么构建x86解决方案平台将生成 32 位设置文件,而构建x64解决方案平台将生成 64 位设置文件。
当活动解决方案配置设置为调试时,取消勾选所有解决方案平台上的部署项目构建复选框可能很有用。这样做将阻止在调试时每次构建解决方案时重新生成部署文件,因此可以在未来的开发中节省时间。
使用 InstallShield Limited Edition 项目
在 Visual Studio 的现代版本中添加设置项目时,我们需要从左侧窗格中添加新项目对话框窗口的“其他项目类型”类别中的“设置和部署”项目类型中选择 InstallShield 有限版项目:

注意,此项目类型已包含在所有付费版本的 Visual Studio 中,但使用免费版本的用户在选择项目类型后可能会被引导到网站下载此功能。
一旦安装完成并且项目已成功添加,Visual Studio 中将打开一个帮助向导,或者像 InstallShield 喜欢称呼的那样,打开项目助理窗口,以协助配置安装项目的流程。它将我们引导通过创建安装程序时可能需要执行的各种任务,一页一页地进行:

每一页都被分为两个窗口面板;右侧面板包含我们将要编辑的各种字段,以设置部署所需的规格,而左侧面板包含与每一页相关的附加选项和上下文帮助主题。
项目助理的第一页是申请信息页面,在这里我们可以提供关于申请的一般信息,例如公司名称和网站、申请名称和版本,以及与申请一起显示的图标。
安装要求页面使我们能够选择一个或多个与我们的应用程序兼容的特定操作系统。除此之外,我们还可以指定我们的应用程序依赖于一个现有的第三方软件列表,例如 Adobe Reader、各种版本的.NET Framework 以及许多 Microsoft 产品。
虽然这个列表很短,但它确实包含了最可能需要的先决软件名称。然而,还有一些额外的选项,其中之一允许我们创建自定义安装要求。点击此选项后,系统搜索向导打开,并允许我们通过文件夹路径、注册表键或通过.ini 文件值来搜索额外的安装要求,并允许我们选择在安装过程中如果新要求未满足时会发生什么。
下一个页面是应用程序文件页面,在这里我们可以将任何必需的应用程序文件添加到安装中。页面分为两部分,左侧是用户计算机上要安装的文件夹树视图,右侧是文件夹内容。左侧面板包含最常用的预定义文件夹列表,例如App Data、Common和Program Files文件夹。
如果我们需要使用其他预定义的位置,例如“桌面”、“收藏夹”或“我的图片”文件夹等,那么我们可以在这个面板上右键单击一个项目并选择显示预定义文件夹选项。实际上,如果我们想将我们的应用程序作为 64 位应用程序安装,那么我们需要使用这个选项来添加 64 位的“程序文件”文件夹,类似于设置项目。
为了做到这一点,我们可以在树视图的顶部右键单击“目标计算机”项,选择显示预定义文件夹选项,然后选择ProgramFiles64Folder项。然后我们需要将启动项目的项目输出设置为左侧面板中代表我们的安装文件夹的文件夹。请注意,它将附加[INSTALLDIR]后缀,在我们的情况下,将被命名为ApplicationName。
我们应该点击“添加项目输出”按钮并选择与我们的CompanyName.ApplicationName项目相关的“主要输出”选项,以便在部署中包含其bin文件夹中的 DLL 和其他依赖文件。如果需要,我们可以右键单击添加的输出项以选择进一步属性,或者如果我们正在使用应用程序中的 COM 对象。
接下来是应用程序快捷方式页面,在这里我们可以控制安装将在用户的计算机上包含哪些自定义快捷方式。请注意,默认快捷方式将自动添加到我们指定的可执行文件中,但这一页使我们能够删除这些快捷方式,以及添加新的快捷方式,甚至指定卸载快捷方式或替代图标。
应用程序注册表页面紧随其后,使我们能够在我们应用程序安装的计算机的 Windows 注册表中进行条目。左侧窗口面板反映了目标计算机的注册表视图,我们可以以相同的方式使用它来添加新键。此页面还允许我们从.reg文件导入注册表键并打开源计算机的注册表编辑器。
最后一个页面是安装访谈页面,在这里我们可以指定在安装过程中向用户显示哪些对话框屏幕。在这里,我们可以选择性地上传一个富文本格式(RTF)格式的最终用户许可协议文件,要求用户同意。
此外,我们可以提示用户输入他们的用户名和公司名称,并为他们提供选择安装位置以及安装完成后是否打开应用程序的选项。我们还可以从这一页指定在这些对话框窗口中显示的自定义图片。
一旦所有项目助手页面都适当完成,我们只需构建设置和部署项目以生成设置文件。然而,我们需要确保正确构建它,这取决于我们在项目助手中所做的选择。
当在 Visual Studio 的解决方案资源管理器中使用并聚焦于 InstallShield Limited Edition 项目时,我们会得到一个额外的 InstallShield LE 菜单项,在其中我们可以找到一个打开发布文件夹...选项。点击此选项将打开一个文件夹窗口,显示设置项目文件夹,在其中我们可以找到要分发给用户的安装文件:

利用 ClickOnce 功能
ClickOnce 是一种应用程序部署技术,它使我们能够部署那些可以通过最小化与最终用户的交互来安装、运行和更新的应用程序。实际上,ClickOnce 这个名字来源于一个理想场景,即每个应用程序都可以通过单次点击来安装。
每个 ClickOnce 应用程序都会部署到主机计算机上的一个独立区域,而不是使用其他部署技术所使用的标准程序文件文件夹中。此外,它们只授予应用程序所需的精确安全权限,因此通常也可以由非管理员用户安装。
使用 ClickOnce 的另一个好处是它使应用程序可以从网页、网络文件夹或物理媒体安装。我们还可以指定使用 ClickOnce 安装的应用程序应定期检查更新,并且可以由最终用户轻松更新,无需管理员在场。
ClickOnce 部署包含应用程序清单和部署清单。应用程序清单包含有关应用程序的详细信息,例如其依赖项、所需的安全权限以及更新将可用的位置。部署清单包含有关部署的详细信息,例如应用程序清单的位置和应用程序的目标版本。
ClickOnce 现在是部署应用程序的首选方法,并且已经直接集成到我们的启动项目属性中。我们可以通过在解决方案资源管理器中右键单击 CompanyName.ApplicationName 项目并选择属性选项,通过打开项目节点并双击属性项,或者通过选择项目节点并按键盘上的 Alt + Enter 键来打开属性窗口。
在项目属性窗口中,我们可以在发布选项卡中找到 ClickOnce 配置字段。在这个选项卡中,我们可以设置发布文件夹的位置为网络共享文件夹或 FTP 服务器。这表示文件将被发布到的位置。可选地,我们还可以指定用户将从中安装应用程序的位置,如果这将是不同的。
我们可以指定安装模式应使应用程序仅在网络上可用,就像一个 Web 应用程序,或者也可以离线使用,就像一个典型的桌面应用程序。在此部分,我们还有“应用程序文件”按钮,点击它将打开一个对话框窗口,我们可以指定要包含在部署中的哪些附加文件。
默认情况下,所有当前构建到bin文件夹中的文件都将被包含,但如果我们愿意,我们可以排除它们。或者,我们可以通过在属性窗口中将它们的构建操作设置为“内容”来从解决方案资源管理器添加新文件。我们还可以指定任何可执行文件是否是先决条件,或者任何其他文件类型是否是数据文件。然而,这些设置会自动进行,除非我们有具体要求,否则我们不需要在此处进行更改。
接下来,我们看到“先决条件”按钮,点击它将打开一个对话框窗口,使我们能够创建一个设置程序来安装我们可能需要的任何先决组件,例如.NET 框架和 Windows Installer 软件。如果用户的计算机尚未安装所需的先决条件,我们可以指定安装程序应从何处获取它们。此对话框也会根据应用程序的要求自动填充。
为了指定安装的应用程序应检查更新,我们可以在“发布”选项卡中点击“更新”按钮后打开的对话框中勾选“应用程序应检查更新”复选框。我们还可以指定这是在应用程序启动之前还是之后,或者是在一定时间之后发生。
在“应用程序更新”对话框窗口中,我们还可以规定应用程序必须更新到特定版本,通过勾选“为此应用程序指定最小所需版本”复选框并设置版本。此外,如果我们希望更新位置与发布位置不同,我们还可以指定一个进一步的位置来获取更新。
最后,在“安装模式和设置”部分,我们来到了“选项”按钮,点击它将打开“发布选项”对话框窗口,在这里我们可以指定诸如出版商和产品名称、部署和清单设置等详细信息,并将我们的应用程序与自定义文件类型关联起来,以便在点击这些文件类型时打开。
部署选项使我们能够指定一个网页,用户可以使用该网页下载和安装我们的 ClickOnce 应用程序,尽管如果我们输入default.html,我们可以使用为我们生成的默认页面。我们还可以指定网页是否应自动打开,或者是否在发布应用程序后验证上传的文件。
发布选项卡中的最后一节是发布版本节,我们可以在此指定应用程序的当前版本。而不是每次发布时都手动更新,我们可以可选地勾选“每次发布自动递增修订版”复选框以自动更新修订版。
在本节中,我们有两种发布选项。发布向导按钮打开一个多页对话框窗口,它引导我们通过之前描述的许多更重要的选项,并以发布应用程序结束。虽然这对于我们第一次发布应用程序很有用,但我们通常在之后使用另一个选项,即“立即发布”按钮,它只是简单地发布应用程序。
保护部署
在项目属性窗口的安全选项卡上,我们可以指定应用程序所需的权限。为此,我们可以勾选“启用 ClickOnce 安全设置”复选框并选择我们的应用程序是完全信任还是部分信任应用程序。
对于典型的桌面应用程序,通常指定它是一个完全信任的应用程序,但否则我们可以指定所需的信任级别。请注意,除非应用程序发布者被设置为最终用户计算机上的受信任发布者,否则他们可能需要在安装过程中授予任何所需的权限。
如果我们指定我们的应用程序是部分信任应用程序,那么我们可以从包含特定权限组的预配置区域中选择,或者选择自定义权限,在这种情况下,我们可以在应用程序清单文件中直接手动指定所需的权限。
注意,即使我们已经将我们的应用程序指定为部分信任应用程序,我们在开发时通常具有完全信任。为了使用与应用程序所需的相同权限进行开发并因此看到与用户相同的错误,我们可以点击“高级”按钮并勾选“使用所选权限集调试此应用程序”复选框。
在项目属性窗口的签名选项卡上,我们可以可选地通过勾选“签名 ClickOnce 清单”复选框来对 ClickOnce 清单进行数字签名。如果我们有一个保存在计算机证书存储中的有效证书,那么我们可以通过点击“从存储选择”按钮来选择它,以使用它来签名 ClickOnce 清单。
或者,如果我们有一个个人信息交换(PFX)文件,我们可以通过点击“从文件选择”按钮并在打开的文件资源管理器中选择它来使用它来签署清单。如果我们目前没有有效的证书,我们可以通过点击“创建测试证书”按钮来可选地创建一个用于测试目的的证书。
然而,请注意,测试证书不应该与生产应用程序一起部署,因为它们不包含关于发布者的可验证信息。当使用测试证书安装 ClickOnce 应用程序时,用户将被告知发布者无法验证,并要求确认他们是否真的想要安装该应用程序。为了确保最终用户安心,应使用真实证书,并将副本存储在他们的受信任发布者证书存储中。
我们还可以通过勾选“签名程序集”复选框并从关联的下拉控件中选择一个强名称密钥(SNK)文件来可选地签名程序集。如果我们之前没有选择一个,我们可以从同一个下拉控件中添加一个新的。
这就完成了用于 ClickOnce 部署的配置页面的总结。它们提供了与其他部署技术几乎相同的设置,除了与已安装文件的位置和可能需要安装的安全权限有关的设置。现在让我们看看我们如何在非完全信任应用程序中安全地存储文件在宿主计算机上。
隔离存储
ClickOnce 可以直接由最终用户安装而无需管理员协助的原因之一是它被安装到一个自包含的生态系统中,该生态系统与其他所有程序都分开,并且通常与用户的其余计算机隔离。
当我们需要在本地存储数据时,如果我们没有将我们的应用程序指定为完全信任的应用程序,我们可能会遇到安全问题。在这些情况下,我们可以利用隔离存储,这是一种抽象硬盘上数据实际位置的数据存储机制,对用户和开发者来说都是未知的。
当我们使用隔离存储时,实际存储数据的数据隔室是由每个应用的某些方面生成的,因此它是唯一的。数据隔室包含一个或多个称为存储的隔离存储文件,它们引用实际数据存储的位置。每个存储中可以存储的数据量可以通过应用程序中的代码进行限制。
文件的实际物理位置将根据用户计算机上运行的操作系统以及存储是否启用了漫游而有所不同。对于自 Vista 以来的所有操作系统,位置在用户的个人用户文件夹中的隐藏AppData文件夹中。在此文件夹中,它将位于Local或Roaming文件夹中,具体取决于存储的设置:
<SYSTEMDRIVE>\Users\<username>\AppData\Local
<SYSTEMDRIVE>\Users\<username>\AppData\Roaming
我们可以在隔离存储中存储任何类型的文件,但作为一个例子,让我们看看我们如何利用它来存储文本文件。让我们首先看看我们将使用的界面:
namespace CompanyName.ApplicationName.Managers.Interfaces
{
public interface IHardDriveManager
{
void SaveTextFile(string filePath, string fileContents);
string ReadTextFile(string filePath);
}
}
现在让我们看看接口的具体实现:
using CompanyName.ApplicationName.Managers.Interfaces;
using System.IO;
using System.IO.IsolatedStorage;
namespace CompanyName.ApplicationName.Managers
{
public class HardDriveManager : IHardDriveManager
{
private IsolatedStorageFile GetIsolatedStorageFile()
{
return IsolatedStorageFile.GetStore(IsolatedStorageScope.User |
IsolatedStorageScope.Assembly | IsolatedStorageScope.Domain,
null, null);
}
public void SaveTextFile(string filePath, string fileContents)
{
try
{
IsolatedStorageFile isolatedStorageFile = GetIsolatedStorageFile();
using (IsolatedStorageFileStream isolatedStorageFileStream =
new IsolatedStorageFileStream(filePath, FileMode.OpenOrCreate,
isolatedStorageFile))
{
using (StreamWriter streamWriter =
new StreamWriter(isolatedStorageFileStream))
{
streamWriter.Write(fileContents);
}
}
}
catch { /*Log error*/ }
}
public string ReadTextFile(string filePath)
{
string fileContents = string.Empty;
try
{
IsolatedStorageFile isolatedStorageFile = GetIsolatedStorageFile();
if (isolatedStorageFile.FileExists(filePath))
{
using (IsolatedStorageFileStream isolatedStorageFileStream =
new IsolatedStorageFileStream(filePath, FileMode.Open,
isolatedStorageFile))
{
using (StreamReader streamReader =
new StreamReader(isolatedStorageFileStream))
{
fileContents = streamReader.ReadToEnd();
}
}
}
}
catch { /*Log error*/ }
return fileContents;
}
}
}
与其他管理类一样,我们在CompanyName.ApplicationName.Managers命名空间中声明HardDriveManager类。在私有的GetIsolatedStorageFile方法中,我们通过调用IsolatedStorageFile类的GetStore方法来获取与我们将要保存用户数据的隔离存储存储相关的IsolatedStorageFile对象。
此方法有多个重载版本,使我们能够指定用于生成唯一隔离存储文件的范围、应用程序标识、证据和证据类型。在这个例子中,我们使用了一个重载,它接受IsolatedStorageScope枚举成员的位组合以及域和程序集证据类型,我们简单地传递null。
此处的范围输入参数很有趣,需要一些解释。隔离存储始终限制在创建存储时登录并使用应用程序的用户。然而,它也可以限制到程序集的身份,或者到程序集和应用域的组合。
当我们调用GetStore方法时,它获取与传递的输入参数相对应的存储。当我们传递User和Assembly``IsolatedStorageScope枚举成员时,这会获取一个可以在相同用户使用的情况下共享同一程序集的应用程序之间的存储。通常,这在内部网络安全区域是允许的,但在互联网区域则不允许。
当我们传递User、Assembly和Domain``IsolatedStorageScope枚举成员时,这会获取一个只能由创建存储的应用程序运行的用户访问的存储。这是大多数应用程序的默认和最常见的选择,因此这些枚举成员被用于我们的示例中。
注意,如果我们想启用用户使用漫游配置文件但仍能从他们的隔离存储文件中访问数据,那么我们可以另外包含Roaming枚举成员与其他成员一起。
现在回到HardDriveManager类,在SaveTextFile方法中,我们首先调用GetIsolatedStorageFile方法来获取IsolatedStorageFile对象。然后,我们使用由filePath输入参数指定的文件名、FileMode枚举的OpenOrCreate成员以及存储文件对象来初始化一个IsolatedStorageFileStream对象。
接下来,我们使用IsolatedStorageFileStream变量初始化一个StreamWriter对象,并使用StreamWriter类的Write方法将fileContents输入参数中的数据写入流中指定的文件。同样,我们将此操作放在try...catch块中,通常会记录可能从这个方法抛出的任何异常,但在这里为了简洁省略了这一步骤。
在ReadTextFile方法中,我们将fileContents变量初始化为空字符串,然后从GetIsolatedStorageFile方法中获取IsolatedStorageFile对象。在尝试访问之前,我们验证由filePath输入参数指定的文件实际上是否存在。
然后,我们使用filePath输入参数指定的文件名、FileMode枚举的Open成员以及隔离存储文件初始化一个IsolatedStorageFileStream对象。
接下来,我们使用IsolatedStorageFileStream变量初始化一个StreamReader对象,并使用StreamReader对象的Read方法将流中指定的文件数据读取到fileContents输入参数中。同样,这一切都包含在一个try...catch块中,最后,我们返回包含文件数据的fileContents变量。
为了使用它,我们必须首先使用我们的DependencyManager实例将接口和我们的运行时实现之间的连接注册:
DependencyManager.Instance.Register<IHardDriveManager, HardDriveManager>();
然后,我们可以从我们的BaseViewModel类中公开对新的IHardDriveManager接口的引用,并使用DependencyManager实例解决它:
public IHardDriveManager HardDriveManager
{
get { return DependencyManager.Instance.Resolve<IHardDriveManager>(); }
}
然后,我们可以使用它将文件保存到隔离存储中,或从任何视图模型中读取文件。
HardDriveManager.SaveTextFile("UserPreferences.txt", "AutoLogIn:True");
...
string preferences = HardDriveManager.ReadTextFile("UserPreferences.txt");
实际上,如果我们以这种方式保存用户偏好,它们通常会存储在一个 XML 文件中,或者存储在一种更容易解析的格式中。然而,为了本例的目的,一个普通的字符串就足够了。
除了在隔离存储中保存和加载文件外,我们还可以删除它们,添加或删除文件夹以更好地组织数据。我们可以在HardDriveManager类和IHardDriveManager接口中添加更多方法,以便我们能够从用户的隔离存储中操作文件和文件夹。现在让我们看看我们如何做到这一点:
public void DeleteFile(string filePath)
{
try
{
IsolatedStorageFile isolatedStorageFile = GetIsolatedStorageFile();
isolatedStorageFile.DeleteFile(filePath);
}
catch { /*Log error*/ }
}
public void CreateFolder(string folderName)
{
try
{
IsolatedStorageFile isolatedStorageFile = GetIsolatedStorageFile();
isolatedStorageFile.CreateDirectory(folderName);
}
catch { /*Log error*/ }
}
public void DeleteFolder(string folderName)
{
try
{
IsolatedStorageFile isolatedStorageFile = GetIsolatedStorageFile();
isolatedStorageFile.DeleteDirectory(folderName);
}
catch { /*Log error*/ }
}
简单来说,DeleteFile方法通过GetIsolatedStorageFile方法访问IsolatedStorageFile对象,然后调用其DeleteFile方法,传入要删除的文件名称,该名称由filePath输入参数指定,并在另一个try...catch块中执行。
同样,CreateFolder方法通过GetIsolatedStorageFile方法获取IsolatedStorageFile对象,然后调用其CreateDirectory方法,传入要创建的文件夹名称,该名称由folderName输入参数指定,并在一个try...catch块中执行。
同样,DeleteFolder方法通过调用GetIsolatedStorageFile方法获取IsolatedStorageFile对象,然后调用其DeleteDirectory方法,传入要删除的文件夹名称,该名称由folderName输入参数指定,并在另一个try...catch块中执行。
现在,让我们调整之前的示例,以展示我们如何使用这项新功能:
HardDriveManager.CreateFolder("Preferences");
HardDriveManager.SaveTextFile("Preferences/UserPreferences.txt",
"AutoLogIn:True");
...
string preferences =
HardDriveManager.ReadTextFile("Preferences/UserPreferences.txt");
...
HardDriveManager.DeleteFile("Preferences/UserPreferences.txt");
HardDriveManager.DeleteFolder("Preferences");
在这个扩展示例中,我们首先在隔离存储存储中创建一个名为Preferences的文件夹,然后通过在文件名前加上文件夹名称并用正斜杠分隔来保存该文件夹中的文本文件。
在稍后的阶段,我们可以通过将相同的文件路径传递给ReadTextFile方法来读取文件的全部内容。如果我们需要在之后清理存储,或者如果文件是临时的,我们可以通过将相同的文件路径传递给DeleteFile方法来删除它。注意,我们必须首先删除存储中文件夹的内容,然后才能删除该文件夹本身。
还要注意,我们可以在隔离存储存储中通过在文件路径中链接它们的名称来创建子目录。例如,我们可以在名为Preferences的文件夹中创建一个Login文件夹,只需将子目录名称追加到父文件夹名称的末尾,并用正斜杠再次分隔即可:
HardDriveManager.CreateFolder("Preferences");
HardDriveManager.CreateFolder("Preferences/Login");
HardDriveManager.SaveTextFile("Preferences/Login/UserPreferences.txt",
"AutoLogIn:True");
这就结束了我们对.NET 中隔离存储文件的探讨。但在结束这一章之前,让我们简要地关注一下如何访问我们的各种应用程序版本,以及它们之间到底有什么联系。
访问应用程序版本
在.NET 中,一个应用程序有多个不同的版本,因此我们有多种不同的方式来访问它们。我们之前讨论的版本号,可以在项目属性的发布选项卡中的发布版本部分找到,可以使用System.Deployment DLL 中的ApplicationDeployment类来找到:
using System.Deployment.Application;
...
private string GetPublishedVersion()
{
if (ApplicationDeployment.IsNetworkDeployed)
{
return
ApplicationDeployment.CurrentDeployment.CurrentVersion.ToString();
}
return "Not network deployed";
}
注意,在我们能够访问ApplicationDeployment类的CurrentVersion属性之前,我们需要验证应用程序实际上已经被部署,否则将抛出InvalidDeploymentException异常。这意味着我们无法在调试我们的 WPF 应用程序时获取已发布的版本,因此在这些情况下,我们应该返回其他一些值。
为了查看剩余的应用程序版本,我们首先需要访问我们想要知道版本的程序集。我们用来访问程序集的代码将取决于我们在代码中的当前位置。例如,我们通常想显示启动程序集的版本,但我们可能想从ViewModels项目中的视图模型访问它。
我们有多种方式访问程序集,这取决于它们与调用代码的相对位置。如果我们想从启动项目访问启动程序集,那么在添加以下命名空间的using语句后,我们可以使用Assembly.GetExecutingAssembly方法:
using System.Diagnostics;
using System.Reflection;
要从不同的项目访问相同的程序集,我们可以使用Assembly.GetEntryAssembly方法。或者,我们可以使用Assembly.GetCallingAssembly方法从不同的项目访问启动项目的程序集(如果该项目是从启动程序集调用的)。对于这里剩余的示例,我们将使用GetEntryAssembly方法。
除了已发布的版本外,我们可能还需要访问应用程序的组件或文件版本。我们可以在项目属性窗口的应用程序选项卡中访问的组件信息对话框中设置的组件版本,可以通过以下代码从组件中访问:
string assemblyVersion =
Assembly.GetEntryAssembly().GetName().Version.ToString();
组件版本由.NET Framework 用于在构建和运行时加载和链接对其他组件的引用。这是在 Visual Studio 中将引用添加到我们的项目时嵌入的版本,如果在构建过程中发现版本不正确,则会引发错误。
注意,我们还可以使用项目AssemblyInfo.cs文件中的组件级别AssemblyVersionAttribute类设置此值,该文件可以在解决方案资源管理器中的项目属性节点中找到。
与直接将返回的Version对象转换为string相比,我们可能更喜欢访问构成版本号的各个组件。它们包括主版本、次要版本、构建和修订版本值。
然后,我们可以选择只输出主版本和次要版本,以及产品名称。以下是一个示例:
Version assemblyVersion = Assembly.GetEntryAssembly().GetName().Version;
string productName = FileVersionInfo.GetVersionInfo( Assembly.GetEntryAssembly().Location).ProductName;
string output = $"{productName}: Version {version.Major}.{version.Minor}";
如果我们需要文件版本,该版本用于非 ClickOnce 部署,我们可以将组件的位置传递给FileVersionInfo类的GetVersionInfo方法,如产品名称示例中所示的前面代码,但访问FileVersion属性:
string fileVersion = FileVersionInfo.GetVersionInfo(
Assembly.GetEntryAssembly().Location).FileVersion;
注意,我们还可以在组件信息对话框中设置此值,或者使用项目AssemblyInfo.cs文件中的组件级别AssemblyFileVersionAttribute类。此版本可以在 Windows 资源管理器的文件属性对话框的详细信息选项卡中看到:

与组件一起分发的产品版本可以通过类似的方式访问:
string productVersion = FileVersionInfo.GetVersionInfo(
Assembly.GetEntryAssembly().Location).ProductVersion;
注意,此版本也可以在 Windows 资源管理器的文件属性对话框的详细信息选项卡中看到,包括我们之前访问的产品名称。另外,请注意,在 WPF 应用程序中,此值通常来自组件文件版本。
摘要
在本章中,我们探讨了多种部署我们的 WPF 应用程序的方法。我们回顾了较旧的安装项目类型和 InstallShield Limited Edition 项目类型,但主要关注较新的 ClickOnce 技术。我们研究了 ClickOnce 部署是如何进行的,以及我们如何在隔离存储中安全地存储和访问数据。最后,我们探讨了在.NET 中访问我们可用的各种应用程序版本的方法。
在本书的最后一章中,我们将回顾本书涵盖的内容的摘要,并探讨您接下来可以做什么来继续这段旅程。我们将建议一些可能的扩展我们的应用程序框架的方法,以及您如何推进您的一般应用程序开发。
第十三章:接下来是什么?
在这本书中,我们发现了 MVVM 架构模式,并探讨了利用该模式的优势开发 WPF 应用程序的过程,同时遵循其关注点分离的原则。我们调查了各种在不同应用程序层之间进行通信和构建我们的代码库的方法。
重要的是,我们考虑了多种调试我们的 WPF 应用程序和追踪编码问题的方法。特别是,我们揭示了一些技巧和窍门,帮助我们识别数据绑定错误的根源。此外,我们还学习了如何通过查看跟踪信息来帮助我们检测问题,即使是在我们的应用程序部署之后。
我们继续研究利用应用程序框架的好处,并开始设计和开发我们自己的框架。我们以不将我们的框架绑定到任何特定功能或技术的方式构建它,并尝试了多种封装所需功能的方法。
我们专门用了一章来探讨数据绑定这一基本艺术,详细研究了依赖属性和附加属性的创建。我们研究了设置依赖属性元数据,并介绍了关键的依赖属性设置优先级列表。然后我们涵盖了标准和层次数据模板,并研究了几个有趣的数据绑定示例。
调查内置 WPF 控件丰富的继承层次结构使我们能够看到它们的功能是如何从层次结构中的每个后续基类构建起来的。这反过来又使我们能够看到在某些情况下某些控件比其他控件更好用。我们还了解了如何自定义内置控件,并考虑了如何最好地创建我们自己的控件。
虽然在 WPF 应用程序中动画的可能性几乎是无限的,但我们调查了更实用的选项,主要关注 XAML 中使用的语法。然后我们直接在我们的应用程序框架中添加了动画功能,这样开发者就可以轻松使用。
在将注意力转向我们应用程序的外观之后,我们调查了多种技术,例如无边框窗口以及为更高级的方法添加阴影和发光效果,以使我们的应用程序在众多应用程序中脱颖而出。我们还把动画融入我们的日常控件中,以便为我们的应用程序带来一种独特感。
我们彻底调查了.NET Framework 为我们提供的数据验证选项,主要集中在前两个可用的验证接口,并探索了多种实现方式。我们探讨了高级技术,如多级验证和使用数据注释属性,然后在我们的应用程序框架中添加了一个完整的验证系统。
我们进一步扩展了我们的应用程序框架,加入了一个异步数据操作系统,该系统结合了一个完整的用户反馈组件,包括一个动画反馈显示机制。我们继续研究如何提供应用程序内的帮助和用户偏好,并实现耗时功能以节省用户的时间和精力。
我们还探索了多种可以用来提高我们的 WPF 应用程序性能的选项,从更有效地声明资源到使用更轻量级的控件和更高效的渲染绘图、图像和文本的方法。我们看到了更高效的数据绑定方法,并发现了解除事件处理程序的重要性。
最后,我们研究了任何专业应用程序开发中的最后一个任务,即部署。我们考虑了多种替代方法,但主要关注最流行的 ClickOnce 技术。我们研究了 ClickOnce 部署是如何进行的,以及我们如何在隔离存储中安全地存储和访问数据。我们以多种方式结束,这些方式可以让我们访问.NET 中可用的各种应用程序版本。
总体来说,我们涵盖了大量的信息,这些信息共同将使我们能够创建高效、视觉上吸引人、高度可用和高度生产力的 WPF 应用程序。更重要的是,我们现在有了自己的应用程序框架,我们可以为每个新创建的应用程序重复使用它。那么,接下来是什么?
将注意力转向未来的项目
你可以将这本书中的概念和想法应用到其他领域,并继续在这些新领域中实验和探索它们的效果。例如,我们学习了关于Adorner对象的知识,因此你可以利用这一新发现的知识在主窗口的 adorner 层中实现一些视觉反馈,用于常见的拖放功能。
然后,你可以进一步扩展这个想法,使用你关于附加属性所发现的知识,完全封装这个拖放功能,使利用你的应用程序框架的开发者能够以属性方式使用这个功能。
例如,你可以创建一个DragDropProperties类,声明附加属性,如IsDragSource、IsDragTarget、DragEffects、DragDropType和DropCommand,并且它可以由你的相关附加属性类扩展,例如ListBoxProperties类。
然后,你可以在DragDropProperties类中声明一个BaseDragDropManager类,用于将一切连接起来,通过附加和移除适当的事件处理程序,启动拖放过程,通过拖放效果更新光标,并在光标在屏幕上移动时执行分配给DropCommand属性的ICommand对象。
这导致了一个可以进一步扩展的领域。我们不仅可以在附加属性中处理 UI 事件,还可以将它们组合起来执行更复杂的功能。例如,假设我们有一个类型为string的附加属性,名为Label。
当此属性被设置时,它可以将资源中的特定ControlTemplate元素应用到当前TextBox对象的Template属性。此模板可以显示此属性中的文本在一个次要文本元素中,因此充当内部标签。当TextBox对象有值时,标签文本元素可以通过扩展我们的BaseVisibilityConverter类的IValueConverter实现来隐藏:
<TextBlock Text="{Binding (Attached:TextBoxProperties.Label),
RelativeSource={RelativeSource AncestorType=TextBox}, FallbackValue=''}"
Foreground="{Binding (Attached:TextBoxProperties.LabelColor),
RelativeSource={RelativeSource AncestorType=TextBox},
FallbackValue=#FF000000}" Visibility="{Binding Text,
RelativeSource={RelativeSource AncestorType=TextBox},
Converter={StaticResource StringToVisibilityConverter},
FallbackValue=Collapsed}" ... />
如前例所示,我们还可以声明另一个附加属性,名为LabelColor,类型为Brush,它指定了当设置Label附加属性时要使用的颜色。请注意,如果未设置LabelColor属性,则它将使用默认值,如果设置了,或者使用FallbackValue属性中指定的值。
改进我们的应用程序框架
你还可以继续在自定义应用程序框架方面进行工作,并使其适应你的个别需求。考虑到这一点,你可以在外部资源文件中构建一个完整的自定义控件集合,具有特定的外观和感觉,以便在所有应用程序中使用。
本书还提供了许多其他示例,这些示例可以轻松扩展。例如,你可以更新我们的DependencyManager类,以使每个接口可以注册多个具体类。
而不是使用Dictionary<Type, Type>对象来存储我们的注册信息,你可以定义新的自定义对象。你可以声明一个具有Type属性和object数组以存储可能需要的任何构造函数输入参数的ConcreteImplementation结构体:
public ConcreteImplementation(Type type,
params object[] constructorParameters)
{
Type = type;
ConstructorParameters = constructorParameters;
}
你可以声明一个DependencyRegistration类,用于将接口类型与具体实现集合配对:
public DependencyRegistration(Type interfaceType,
IEnumerable<ConcreteImplementation> concreteImplementations)
{
if (!concreteImplementations.All(c =>
interfaceType.IsAssignableFrom(c.Type)))
throw new ArgumentException("The System.Type object specified by the
ConcreteImplementation.Type property must implement the interface type
specified by the interfaceType input parameter.",
nameof(interfaceType));
ConcreteImplementations = concreteImplementations;
InterfaceType = interfaceType;
}
在我们的DependencyManager类中,你可以将registeredDependencies字段的类型更改为DependencyRegistration类型集合。然后,当前的Register和Resolve方法也可以更新为使用此新集合类型。
或者,你可以包含其他常见的功能,这些功能包含在流行的依赖注入和控制反转容器中,例如在程序集级别自动注册具体类到接口。为此,你可以使用一些基本的反射:
using System.Reflection;
...
public void RegisterAllInterfacesInAssemblyOf<T>() where T : class
{
Assembly assembly = typeof(T).Assembly;
IEnumerable<Type> interfaces =
assembly.GetTypes().Where(p => p.IsInterface);
foreach (Type interfaceType in interfaces)
{
IEnumerable<Type> implementingTypes = assembly.GetTypes().
Where(p => interfaceType.IsAssignableFrom(p) && !p.IsInterface);
ConcreteImplementation[] concreteImplementations = implementingTypes.
Select(t => new ConcreteImplementation(t, null)).ToArray();
if (concreteImplementations != null && concreteImplementations.Any())
registeredDependencies.Add(interfaceType, concreteImplementations);
}
}
此方法首先访问包含泛型类型参数的组件,然后获取该组件中的接口集合。然后它遍历接口集合,找到实现每个接口的类集合,并为每个匹配项实例化一个ConcreteImplementation元素。每个匹配项都被添加到registeredDependencies集合中,并与其相关的接口类型相关联。
以这种方式,你可以将我们的Models、Managers和ViewModels项目中的任何接口类型传递过去,以自动注册它们组件中找到的所有接口和具体类。在更大的应用程序中这样做有明显的优势,因为它意味着你不必手动注册每个类型:
private void RegisterDependencies()
{
DependencyManager.Instance.ClearRegistrations();
DependencyManagerAdvanced.Instance.
RegisterAllInterfacesInAssemblyOf<IDataProvider>();
DependencyManagerAdvanced.Instance.
RegisterAllInterfacesInAssemblyOf<IUiThreadManager>();
DependencyManagerAdvanced.Instance.
RegisterAllInterfacesInAssemblyOf<IUserViewModel>();
}
此外,你可以声明另一个方法,用于注册由泛型类型参数T指定的类型的组件中找到的所有类型,其中找到实现接口的匹配项。这可以在测试期间使用,这样你就可以在测试期间只需传递模拟项目中的任何类型,再次节省时间和精力:
DependencyManager.Instance.
RegisterAllConcreteImplementationsInAssemblyOf<MockUiThreadManager>();
就像所有严肃的开发项目一样,我们需要测试构成代码库的代码。这样做显然有助于减少应用程序中的错误数量,但也会在添加新代码的同时提醒我们现有功能是否被破坏。它们还为重构提供了一个安全网,使我们能够持续改进我们的设计,同时确保现有功能不会被破坏。
因此,你可以在应用程序中改进的一个领域是实施一个完整的测试套件。这本书已经解释了在测试期间替换代码的多种方法,并且这种模式可以很容易地扩展。如果一个管理类使用某种在测试期间不能使用的资源,那么你可以为它创建一个接口,添加一个模拟类,并在运行时和测试期间使用DependencyManager类来实例化相关的具体实现。
书中可以扩展的另一个领域与我们的AnimatedStackPanel类有关。你可以从这个类中提取可重用的属性和动画代码到一个AnimatedPanel基类,以便它可以服务于多种不同类型的动画面板。
如第七章《精通实用动画》中建议的,你可以进一步扩展基类,通过公开额外的动画属性,使用户能够对提供的动画有更多的控制。例如,你可以添加对齐、方向、持续时间以及/或动画类型属性,以便框架的用户能够使用广泛的动画选项。
这些属性可以在进入和退出动画之间分配,以实现对其的独立控制。通过在基类中提供广泛的这些附加属性,你可以极大地简化添加新动画面板的过程。
例如,你可以通过简单地扩展基类来添加一个新的AnimatedWrapPanel,或者可能是一个AnimatedColumnPanel,只需要在新面板中实现两个MeasureOverride和ArrangeOverride方法。
记录错误
在本书的代码示例中,你可能在多个地方看到了Log error注释。一般来说,记录错误不仅是良好的实践,而且还可以帮助你追踪错误并提高你应用程序用户的整体用户体验。
记录错误最简单的地方是Errors数据库,你想要存储的最少有用信息字段应包括当前用户的详细信息、错误发生的时间、异常消息、堆栈跟踪以及它发生的组件或区域。这个字段可以在异常的TargetSite属性的Module属性中找到:
public Error(Exception exception, User createdBy)
{
Id = Guid.NewGuid();
Message = FlattenInnerExceptions(exception);
StackTrace = exception.StackTrace;
Area = exception.TargetSite.Module.ToString();
CreatedOn = DateTime.Now;
CreatedBy = createdBy;
}
注意使用自定义的FlattenInnerExceptions方法,该方法还会输出抛出异常可能包含的任何内部异常的消息。构建自己的FlattenInnerExceptions方法的另一个选择是简单地保存异常的ToString输出,这将包含任何内部异常的详细信息,尽管它也会包含堆栈跟踪和其他信息。
使用在线资源
作为最后的注意事项,如果你还不熟悉Microsoft Docs网站,你真的应该熟悉一下。它是为 Microsoft 开发者社区维护的,包括从各种语言的详细 API、教程演练和代码示例,到软件下载等所有内容。
它可以在docs.microsoft.com上找到,并且当出现关于.NET 中各种类成员的问题时,应该是你首先查看的地方。如果你在它们的 API 中没有找到所需的信息,你可以在它们的论坛中提问,并迅速从社区和 Microsoft 员工那里获得答案。
另一个伟大的开发者资源是Stack Overflow开发专业人士问答网站,我在有时间的时候仍然会回答问题。它可以在stackoverflow.com/上找到,社区成员通常在几秒钟内提供答案,这确实很难超越,并且是周围最好的开发论坛之一。
对于更多教程,请访问www.wpftutorial.net/网站,在那里你可以找到从基础到复杂的丰富教程。对于有趣和创新的可下载自定义控件和额外教程,请尝试访问 Code Project 网站的 WPF 部分www.codeproject.com/kb/wpf/。
现在剩下的只是祝愿你在未来的应用程序开发以及你蒸蒸日上的职业生涯中一切顺利。


浙公网安备 33010602011771号