二十四、WPF 和 XAML 简介
当 1.0 版的。NET 平台发布后,需要构建图形桌面应用的程序员使用了两个名为 Windows Forms 和 GDI+的 API,它们主要打包在System.Windows.Forms.dll
和System.Drawing.dll
程序集中。虽然 Windows 窗体和 GDI+仍然是构建传统桌面 GUI 的可行 API,但从发布开始,Microsoft 提供了一个名为 Windows Presentation Foundation(WPF)的替代 GUI 桌面 API。NET 3.0。WPF 和 Windows 窗体加入了。NET 核心系列的发布。网芯 3.0。
WPF 的第一章从研究这个新的 GUI 框架背后的动机开始,这将帮助你了解 Windows 窗体/GDI+和 WPF 编程模型之间的区别。接下来,您将了解几个重要类的作用,包括Application
、Window
、ContentControl
、Control
、UIElement
和FrameworkElement
。
本章将向你介绍一种基于 XML 的语法,名为可扩展应用标记语言(XAML;发音为“zammel”)。在这里,您将学习 XAML 的语法和语义(包括附加属性语法以及类型转换器和标记扩展的角色)。
本章通过构建您的第一个 WPF 应用来研究 Visual Studio 的集成 WPF 设计器。在此期间,您将学习截取键盘和鼠标活动,定义应用范围的数据,以及执行其他常见的 WPF 任务。
WPF 背后的动机
多年来,微软创造了许多图形用户界面工具包(原始 C/C++/Windows API 开发,VB6,MFC 等。)来构建桌面可执行文件。这些 API 中的每一个都提供了一个代码库来表示 GUI 应用的基本方面,包括主窗口、对话框、控件、菜单系统等。的初始版本。NET 平台,Windows Forms API 很快成为 UI 开发的首选模型,因为它具有简单而强大的对象模型。
虽然许多功能齐全的桌面应用已经使用 Windows 窗体成功地创建出来,但事实是这种编程模型是相当不对称的。简而言之,System.Windows.Forms.dll
和System.Drawing.dll
没有为构建功能丰富的桌面应用所需的许多附加技术提供直接支持。为了说明这一点,考虑一下 WPF 发布之前 GUI 桌面开发的特殊性质(见表 24-1 )。
表 24-1。
针对所需功能的 WPF 前解决方案
|期望的功能
|
技术
|
| — | — |
| 用控件构建窗口 | Windows 窗体 |
| 2D 图形支持 | GDI+ ( System.Drawing.dll
) |
| 3D 图形支持 | directx apis |
| 支持流式视频 | Windows Media Player APIs |
| 支持流样式的文档 | PDF 文件的编程操作 |
如您所见,Windows 窗体开发人员必须从几个不相关的 API 和对象模型中引入类型。虽然使用这些不同的 API 在语法上看起来确实相似(毕竟只是 C# 代码),但您可能也同意每种技术需要完全不同的思维方式。例如,使用 DirectX 创建 3D 渲染动画所需的技能与将数据绑定到网格所需的技能完全不同。当然,Windows 窗体程序员很难掌握每个 API 的多样性。
统一不同的 API
WPF 的创建是为了将这些以前不相关的编程任务合并到一个统一的对象模型中。因此,如果您需要创作一个 3D 动画,您不需要针对 DirectX API 手动编程(尽管您可以这样做),因为 3D 功能是直接嵌入到 WPF 中的。为了了解事情已经清理得有多好,考虑一下表 24-2 ,它展示了从。NET 3.0。
表 24-2。
。针对所需功能的. NET 3.0+解决方案
|期望的功能
|
技术
|
| — | — |
| 使用控件构建窗体 | 数据绑定 |
| 2D 图形支持 | 数据绑定 |
| 3D 图形支持 | 数据绑定 |
| 支持流式视频 | 数据绑定 |
| 支持流样式的文档 | 数据绑定 |
这里明显的好处是。NET 程序员现在有一个单一的、对称的 API 来满足所有常见的 GUI 桌面编程需求。当你熟悉了关键的 WPF 程序集的功能和 XAML 的语法后,你会惊奇地发现你可以如此快速地创建复杂的 ui。
通过 XAML 提供关注点分离
也许最引人注目的好处之一是 WPF 提供了一种方法,将 GUI 应用的外观和感觉与驱动它的编程逻辑完全分开。使用 XAML,可以通过 XML 标记定义应用的 UI。这种标记(理想情况下使用 Microsoft Visual Studio 或 Blend for Visual Studio 等工具生成)可以连接到相关的 C# 代码文件,以提供程序功能的核心。
Note
XAML 不限于 WPF 应用。任何应用都可以用 XAML 来描述一棵。NET 对象,即使它们与可见的用户界面无关。
当你深入研究 WPF 时,你可能会惊讶于这种“桌面标记”所提供的灵活性。XAML 不仅允许你定义简单的用户界面元素(按钮、网格、列表框等)。)以及交互式 2D 和 3D 图形、动画、数据绑定逻辑和多媒体功能(如视频回放)。
XAML 还使得自定义控件如何呈现其视觉外观变得容易。例如,定义一个使公司徽标生动的圆形按钮控件只需要几行标记。如第二十七章所示,WPF 控件可以通过样式和模板来修改,这允许你用最少的麻烦和麻烦来改变应用的整体外观。与 Windows 窗体开发不同,从头构建自定义 WPF 控件的唯一令人信服的理由是,如果您需要更改控件的行为(例如,添加自定义方法、属性或事件;子类化现有控件以重写虚拟成员)。如果你仅仅需要改变一个控件的外观和感觉(比如一个圆形的动画按钮),你可以完全通过标记来完成。
提供优化的渲染模型
GUI 工具包(如 Windows 窗体、MFC 或 VB6)使用低级的、基于 C 的 API (GDI)来执行所有图形呈现请求(包括按钮和列表框等 UI 元素的呈现),该 API 多年来一直是 Windows 操作系统的一部分。GDI 为典型的商业应用或简单的图形程序提供了足够的性能;然而,如果一个 UI 应用需要利用高性能图形,DirectX 是必需的。
WPF 编程模型非常不同,在渲染图形数据时使用的是 GDI而不是。所有渲染操作(例如,2D 图形、3D 图形、动画、控件渲染等。)现在利用 DirectX API。第一个明显的好处是,您的 WPF 应用将自动利用硬件和软件优化。此外,WPF 应用可以利用丰富的图形服务(模糊效果、抗锯齿、透明度等)。)而没有直接针对 DirectX API 编程的复杂性。
Note
尽管 WPF 确实将所有渲染请求都推送到 DirectX 层,但我并不想暗示 WPF 应用会像直接使用非托管 C++和 DirectX 构建应用一样快。尽管 WPF 的每个版本都取得了显著的性能提升,但是如果您打算构建一个需要尽可能快的执行速度的桌面应用(如 3D 视频游戏),非托管 C++和 DirectX 仍然是最好的方法。
简化复杂的 UI 编程
概括一下到目前为止的故事,Windows Presentation Foundation(WPF)是一个用于构建桌面应用的 API,它将各种桌面 API 集成到单个对象模型中,并通过 XAML 提供了关注点的清晰分离。除了这些要点之外,WPF 应用还受益于一种将服务集成到程序中的简单方法,这在历史上是相当复杂的。以下是 WPF 核心功能的简要概述:
-
多个布局管理器(比 Windows 窗体多得多)为内容的放置和重新定位提供了极其灵活的控制。
-
使用增强的数据绑定引擎以多种方式将内容绑定到 UI 元素。
-
一个内置的样式引擎,允许你为 WPF 应用定义“主题”。
-
使用矢量图形,允许内容自动调整大小,以适应应用所在屏幕的大小和分辨率。
-
支持 2D 和三维图形,动画,视频和音频播放。
-
丰富的排版 API,例如支持 XML 纸张规范(XPS)文档、固定文档(WYSIWYG)、流文档和文档注释(例如,便笺 API)。
-
支持与传统 GUI 模型(例如,Windows 窗体、ActiveX 和 Win32 HWNDs)的互操作。例如,您可以将自定义 Windows 窗体控件合并到 WPF 应用中,反之亦然。
现在您已经对 WPF 带来了什么有了一些了解,让我们看看可以使用这个 API 创建的各种类型的应用。这些特性中的许多将在后面的章节中详细探讨。
调查 WPF 议会
WPF 最终不过是捆绑在其中的类型的集合。NET 核心程序集。表 24-3 描述了用于构建 WPF 应用的关键组件,每个组件在创建新项目时都必须被引用。正如您所希望的,Visual Studio WPF 项目会自动引用这些必需的程序集。
表 24-3。
核心 WPF 组件
|装配
|
生命的意义
|
| — | — |
| PresentationCore
| 该程序集定义了许多名称空间,这些名称空间构成了 WPF GUI 层的基础。例如,该程序集包含对 WPF 墨迹 API、动画原语和许多图形呈现类型的支持。 |
| PresentationFramework
| 该程序集包含大多数 WPF 控件、Application
和Window
类、对交互式 2D 图形的支持以及数据绑定中使用的许多类型。 |
| System.Xaml.dll
| 此程序集提供了允许您在运行时针对 XAML 文档进行编程的命名空间。总的来说,这个库只有在创作 WPF 支持工具或者需要在运行时绝对控制 XAML 时才有用。 |
| WindowsBase.dll
| 这个程序集定义了构成 WPF API 基础设施的类型,包括那些代表 WPF 线程类型、安全类型、各种类型转换器以及对依赖属性和路由事件的支持(在第二十七章中描述)。 |
这四个程序集共同定义了新的命名空间和。NET 核心类、接口、结构、枚举和委托。表 24-4 描述了一些(但肯定不是全部)重要名称空间的角色。
表 24-4。
核心 WPF 命名空间
|命名空间
|
生命的意义
|
| — | — |
| System.Windows
| 这是 WPF 的根命名空间。在这里,您会发现任何 WPF 桌面项目都需要的核心类(比如Application
和Window
)。 |
| System.Windows.Controls
| 它包含了所有预期的 WPF 小部件,包括构建菜单系统、工具提示和许多布局管理器的类型。 |
| System.Windows.Data
| 这包含使用 WPF 数据绑定引擎的类型,以及对数据绑定模板的支持。 |
| System.Windows.Documents
| 它包含使用 documents API 的类型,允许您通过 XML Paper Specification (XPS)协议将 PDF 样式的功能集成到您的 WPF 应用中。 |
| System.Windows.Ink
| 这提供了对 Ink API 的支持,它允许您捕获来自手写笔或鼠标的输入,响应输入手势,等等。这对平板电脑编程很有用;然而,任何 WPF 都可以使用这个 API。 |
| System.Windows.Markup
| 该命名空间定义了几种类型,允许以编程方式解析和处理 XAML 标记(以及等效的二进制格式 BAML)。 |
| System.Windows.Media
| 这是几个以媒体为中心的命名空间的根命名空间。在这些命名空间中,您可以找到处理动画、3D 呈现、文本呈现和其他多媒体原语的类型。 |
| System.Windows.Navigation
| 此命名空间提供了一些类型,用于说明 XAML 浏览器应用(XBAPs)以及需要导航页面模型的标准桌面应用所采用的导航逻辑。 |
| System.Windows.Shapes
| 这定义了一些类,允许您呈现自动响应鼠标输入的交互式 2D 图形。 |
为了开始您的 WPF 编程模型之旅,您将研究任何传统桌面开发工作中常见的两个名称空间成员:Application
和Window
。
Note
如果您已经使用 Windows 窗体 API 创建了桌面用户界面,请注意System.Windows.Forms.*
和System.Drawing.*
程序集与 WPF 无关。这些库代表了原始的。NET GUI 工具包,Windows Forms/GDI+。
应用类的角色
System.Windows.Application
类代表一个正在运行的 WPF 应用的全局实例。这个类提供了一个Run()
方法(启动应用),一系列你可以处理的事件,以便与应用的生命周期交互(比如Startup
和Exit
)。表 24-5 详细列出了一些关键属性。
表 24-5。
应用类型的关键属性
|财产
|
生命的意义
|
| — | — |
| Current
| 这个静态属性允许您从代码中的任何地方访问正在运行的Application
对象。当一个窗口或对话框需要访问创建它的Application
对象时,这是很有帮助的,通常是访问应用范围的变量和功能。 |
| MainWindow
| 此属性允许您以编程方式获取或设置应用的主窗口。 |
| Properties
| 此属性允许您建立和获取可在 WPF 应用的所有方面(窗口、对话框等)访问的数据。). |
| StartupUri
| 此属性获取或设置一个 URI,它指定应用启动时自动打开的窗口或页面。 |
| Windows
| 该属性返回一个WindowCollection
类型,它提供对从创建Application
对象的线程创建的每个窗口的访问。当您想要迭代应用的每个打开的窗口并改变其状态(例如最小化所有窗口)时,这可能很有帮助。 |
构造应用类
任何 WPF 应用都需要定义一个扩展Application
的类。在这个类中,您将定义程序的入口点(Main()
方法),它创建这个子类的一个实例,并且通常处理Startup
和Exit
事件(如果需要的话)。这里有一个例子:
// Define the global application object
// for this WPF program.
class MyApp : Application
{
[STAThread]
static void Main(string[] args)
{
// Create the application object.
MyApp app = new MyApp();
// Register the Startup/Exit events.
app.Startup += (s, e) => { /* Start up the app */ };
app.Exit += (s, e) => { /* Exit the app */ };
}
}
在Startup
处理程序中,您通常会处理任何传入的命令行参数,并启动程序的主窗口。如您所料,在Exit
处理程序中,您可以为程序编写任何必要的关闭逻辑(例如,保存用户首选项,写入 Windows 注册表)。
Note
WPF 应用的Main()
方法必须具有[STAThread]
属性,这确保了应用使用的任何遗留 COM 对象都是线程安全的。如果你不以这种方式注释Main()
,你将会遇到一个运行时异常。即使在 C# 9.0 中引入了顶级语句,您仍然希望在您的 WPF 应用中使用更传统的Main()
方法。事实上,Main()
方法是自动为您生成的。
枚举 Windows 集合
Application
公开的另一个有趣的属性是Windows
,它提供了对一个集合的访问,该集合代表当前 WPF 应用加载到内存中的每个窗口。当您创建新的Window
对象时,它们会自动添加到Application.Windows
集合中。下面是一个最小化应用每个窗口的示例方法(可能是为了响应终端用户触发的给定键盘手势或菜单选项):
static void MinimizeAllWindows()
{
foreach (Window wnd in Application.Current.Windows)
{
wnd.WindowState = WindowState.Minimized;
}
}
您将很快构建一些 WPF 应用,但在此之前,让我们检查一下Window
类型的核心功能,并在此过程中了解一些重要的 WPF 基类。
窗口类的作用
System.Windows.Window
类(位于PresentationFramework.dll
汇编中)代表由Application
派生类拥有的单个窗口,包括主窗口显示的任何对话框。毫不奇怪,Window
有一系列的父类,每个父类都为表带来了更多的功能。考虑图 24-1 ,它显示了通过 Visual Studio 对象浏览器看到的System.Windows.Window
的继承链(和实现的接口)。
图 24-1。
窗口类的层次结构
随着本章和后续章节的学习,你会逐渐理解这些基类所提供的功能。然而,为了激起您的兴趣,下面几节将对每个基类提供的功能进行细分(请参考。NET 5 文档以获得完整的详细信息)。
系统的作用。窗口.控件.内容控件
Window
的直接父类是ContentControl
,它很可能是所有 WPF 类中最吸引人的。这个基类为派生类型提供了承载单个内容的能力,简单地说,就是通过Content
属性引用放置在控件表面区域内部的可视数据。WPF 内容模型使得定制内容控件的基本外观变得非常简单。
例如,当您想到一个典型的“按钮”控件时,您倾向于假设内容是一个简单的字符串(OK、Cancel、Abort 等)。).如果您使用 XAML 来描述一个 WPF 控件,并且您想要分配给属性Content
的值可以被捕获为一个简单的字符串,那么您可以在元素的开始定义中这样设置Content
属性(此时不要担心确切的标记):
<!-- Setting the Content value in the opening element -->
<Button Height="80" Width="100" Content="OK"/>
Note
也可以在 C# 代码中设置Content
属性,这允许你在运行时改变控件的内部。
然而,内容几乎可以是任何东西。例如,假设您想要一个比简单字符串更有趣的“按钮”,可能是一个自定义图形和一个文本广告。在其他 UI 框架(如 Windows 窗体)中,您可能需要构建一个自定义控件,这可能需要维护相当多的代码和一个全新的类。对于 WPF 内容模型,没有必要这样做。
当您想要将Content
属性赋给一个不能被捕获为简单字符数组的值时,您不能使用控件的开始定义中的属性来分配它。相反,您必须在元素的范围内隐式定义内容数据*。例如,下面的<Button>
包含一个<StackPanel>
作为内容,它本身包含一些唯一的数据(确切地说是一个<Ellipse>
和<Label>
)😗
<!-- Implicitly setting the Content property with complex data -->
<Button Height="80" Width="100">
<StackPanel>
<Ellipse Fill="Red" Width="25" Height="25"/>
<Label Content ="OK!"/>
</StackPanel>
</Button>
你也可以利用 XAML 的属性元素语法来设置复杂的内容。考虑下面的功能等价的<Button>
定义,它使用属性元素语法显式地设置了Content
属性(同样,在本章的后面你会找到更多关于 XAML 的信息,所以现在还不要过多考虑细节):
<!-- Setting the Content property using property-element syntax -->
<Button Height="80" Width="100">
<Button.Content>
<StackPanel>
<Ellipse Fill="Red" Width="25" Height="25"/>
<Label Content ="OK!"/>
</StackPanel>
</Button.Content>
</Button>
请注意,不是每个 WPF 元素都是从ContentControl
派生的,因此,不是所有的控件都支持这种独特的内容模型(然而,大多数都支持)。此外,一些 WPF 控件对您刚刚检查过的基本内容模型进行了一些改进。第二十五章将会更详细的讨论 WPF 内容的作用。
系统的作用。窗口.控件.控件
与ContentControl
不同,所有的 WPF 控件共享Control
基类作为一个公共的父类。这个基类提供了许多核心成员,这些成员负责基本的 UI 功能。例如,Control
定义属性来建立控件的大小、不透明度、tab 键顺序逻辑、显示光标、背景颜色等等。此外,这个父类提供了对模板服务的支持。正如第二十七章所解释的,WPF 控件可以使用模板和样式完全改变它们的外观。表 24-6 记录了Control
类型的一些关键成员,按相关功能分组。
表 24-6。
控制类型的关键成员
|成员
|
生命的意义
|
| — | — |
| Background
、Foreground
、BorderBrush
、BorderThickness
、Padding
、HorizontalContentAlignment
、VerticalContentAlignment
| 这些属性允许您设置有关如何呈现和定位控件的基本设置。 |
| FontFamily
、FontSize
、FontStretch
、FontWeight
| 这些属性控制各种字体居中设置。 |
| IsTabStop
,TabIndex
| 这些属性用于在窗口上的控件之间建立 tab 键顺序。 |
| MouseDoubleClick
,PreviewMouseDoubleClick
| 这些事件处理双击小部件的行为。 |
| Template
| 此属性允许您获取和设置控件的模板,该模板可用于更改小部件的呈现输出。 |
系统的作用。Windows.FrameworkElement
这个基类提供了许多在整个 WPF 框架中使用的成员,例如对故事板(在动画中使用)和数据绑定的支持,以及命名成员(通过Name
属性)的能力,获取由派生类型定义的任何资源,以及建立派生类型的整体维度。表 24-7 击中亮点。
表 24-7。
FrameworkElement 类型的关键成员
|成员
|
生命的意义
|
| — | — |
| ActualHeight
、ActualWidth
、MaxHeight
、MaxWidth
、MinHeight
、MinWidth
、Height
、Width
| 这些属性控制派生类型的大小。 |
| ContextMenu
| 获取或设置与派生类型关联的弹出菜单。 |
| Cursor
| 获取或设置与派生类型关联的鼠标光标。 |
| HorizontalAlignment
,VerticalAlignment
| 获取或设置类型在容器(如面板或列表框)中的定位方式。 |
| Name
| 允许您为类型指定一个名称,以便在代码文件中访问其功能。 |
| Resources
| 提供对由类型定义的任何资源的访问(参见第二十九章检查 WPF 资源系统)。 |
| ToolTip
| 获取或设置与派生类型关联的工具提示。 |
系统的作用。Windows.UIElement
在Window
的继承链中的所有类型中,UIElement
基类提供了最多的功能。UIElement
的主要任务是为派生类型提供大量的事件,以允许派生类型接收焦点和处理输入请求。例如,该类提供了许多事件来解释拖放操作、鼠标移动、键盘输入、手写笔输入和触摸。
第二十五章详细挖掘 WPF 事件模型;然而,许多核心事件看起来都很熟悉(MouseMove
、KeyUp
、MouseDown
、MouseEnter
、MouseLeave
等)。).除了定义几十个事件之外,这个父类还提供了几个属性来说明控件焦点、启用状态、可见性和点击测试逻辑,如表 24-8 所示。
表 24-8。
UIElement
类型的主要成员
成员
|
生命的意义
|
| — | — |
| Focusable
,IsFocused
| 这些属性允许您将焦点设置在给定的派生类型上。 |
| IsEnabled
| 此属性允许您控制是启用还是禁用给定的派生类型。 |
| IsMouseDirectlyOver
,IsMouseOver
| 这些属性提供了一种执行点击测试逻辑的简单方法。 |
| IsVisible
,Visibility
| 这些属性允许您使用派生类型的可见性设置。 |
| RenderTransform
| 此属性允许您建立将用于呈现派生类型的转换。 |
系统的作用。Windows.Media.Visual
Visual
类类型在 WPF 中提供核心渲染支持,包括图形数据的点击测试、坐标转换和边界框计算。实际上,Visual
类与底层 DirectX 子系统交互,在屏幕上绘制数据。正如你将在第二十六章中看到的,WPF 提供了三种可能的方式来呈现图形数据,每种方式在功能和性能上都有所不同。使用Visual
类型(及其子类型,如DrawingVisual
)提供了最轻量级的方式来呈现图形数据,但它也需要大量的手动代码来处理所有需要的服务。同样,更多细节将在第二十八章中介绍。
系统的作用。Windows . DependencyObject 对象
WPF 支持一种特殊的口味。NET 属性称为依赖属性。简而言之,这种属性样式提供了额外的代码,以允许属性响应多种 WPF 技术,如样式、数据绑定、动画等。对于支持这个新属性方案的类型,它需要从DependencyObject
基类派生。虽然依赖属性是 WPF 开发的一个关键方面,但是大部分时间它们的细节是隐藏的。第二十五章进一步深入依赖属性的细节。
系统的作用。windows . threading . dispatch object
Window
类型的最后一个基类是DispatcherObject
(在System.Object
之后,我认为在这本书的这一点上不需要进一步解释)。这个类型提供了一个感兴趣的属性,Dispatcher
,它返回相关的System.Windows.Threading.Dispatcher
对象。Dispatcher
类是 WPF 应用事件队列的入口点,它提供了处理并发和线程的基本构造。第十五章探讨了Dispatcher
职业。
理解 WPF·XAML 的句法
生产级 WPF 应用通常会利用专用工具来生成必要的 XAML。尽管这些工具很有帮助,但是理解 XAML 标记的整体结构是一个好主意。为了帮助你的学习过程,请允许我介绍一个流行的(免费的)工具,它可以让你轻松地体验 XAML。
Kaxaml 简介
当你第一次学习 XAML 语法时,使用一个名为 Kaxaml 的免费工具会很有帮助。你可以从 https://github.com/punker76/kaxaml
获得这个流行的 XAML 编辑器/解析器。
Note
对于这本书的许多版本,我都将用户指向 www.kaxaml.com
,但不幸的是,那个网站已经被停用了。Jan Karger ( https://github.com/punker76
)继承了旧代码,并做了一些改进工作。你可以在 GitHub https://github.com/punker76/kaxaml/releases
上找到他版本的工具。非常尊重和感谢 Kaxaml 的最初开发者和 Jan 让它保持活力;这是一个很棒的工具,帮助了无数开发者学习 XAML。
Kaxaml 很有帮助,因为它对 C# 源代码、事件处理程序或实现逻辑一无所知。与使用完整的 Visual Studio WPF 项目模板相比,这是一种更直接的测试 XAML 代码片段的方法。此外,Kaxaml 有几个集成的工具,如颜色选择器,xaml 片段管理器,甚至还有一个“XAML 洗涤器”选项,可以根据您的设置格式化您的 XAML。当您第一次打开 Kaxaml 时,您会发现一个<Page>
控件的简单标记,如下所示:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
</Grid>
</Page>
像Window
一样,Page
包含各种布局管理器和控件。然而,与Window
不同,Page
对象不能作为独立实体运行。相反,它们必须放在合适的主机中,如NavigationWindow
或Frame
。好消息是,您可以在<Page>
或<Window>
范围内键入相同的标记。
Note
如果您将 Kaxaml 标记窗口中的<Page>
和</Page>
元素更改为<Window>
和</Window>
,您可以按 F5 键将一个新窗口加载到屏幕上。
作为初始测试,在工具底部的 XAML 窗格中输入以下标记:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<!-- A button with custom content -->
<Button Height="100" Width="100">
<Ellipse Fill="Green" Height="50" Width="50"/>
</Button>
</Grid>
</Page>
现在你应该看到你的页面呈现在 Kaxaml 编辑器的上部(见图 24-2 )。
图 24-2。
Kaxaml 是一个有用的(免费的)工具,用来学习 xaml 的语法
当您使用 Kaxaml 时,请记住该工具不允许您创作任何需要代码编译的标记(但是,允许使用x:Name
)。这包括定义一个x:Class
属性(用于指定代码文件),在标记中输入事件处理程序名称,或者使用任何需要代码编译的 XAML 关键字(比如FieldModifier
或者ClassModifier
)。任何这样做的尝试都将导致标记错误。
XAML XML 名称空间和 XAML“关键词”
WPF XAML 文档的根元素(如<Window>
、<Page>
、<UserControl>
或<Application>
定义)几乎总是引用以下两个预定义的 XML 名称空间:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
</Grid>
</Page>
第一个 XML 名称空间, http://schemas.microsoft.com/winfx/2006/xaml/presentation
,映射了一系列 WPF。NET 命名空间供当前的*.xaml
文件使用(System.Windows
、System.Windows.Controls
、System.Windows.Ink
、System.Windows.Media
、System.Windows.Navigation
等)。).
这种一对多的映射是在 WPF 程序集(WindowsBase.dll
、PresentationCore.dll
和PresentationFramework.dll
)中使用程序集级的[XmlnsDefinition]
属性硬编码的。例如,如果您打开 Visual Studio 对象浏览器并选择PresentationCore.dll
程序集,您将看到如下清单,它实际上导入了System.Windows
:
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation",
"System.Windows")]
第二个 XML 名称空间, http://schemas.microsoft.com/winfx/2006/xaml
,用于包含特定于 XAML 的“关键字”(因为缺少更好的术语)以及System.Windows.Markup
名称空间,如下所示:
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml",
"System.Windows.Markup")]
任何格式良好的 XML 文档的一个规则(记住,XAML 是一种基于 XML 的语法)是,开始的根元素指定一个 XML 名称空间作为主名称空间,它是包含最常见项的名称空间。如果根元素需要包含额外的辅助名称空间(如此处所示),则必须使用惟一的标记前缀来定义它们(以解决任何可能的名称冲突)。按照惯例,前缀简单来说就是x
;然而,这可以是您需要的任何唯一令牌,比如XamlSpecificStuff
。
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:XamlSpecificStuff="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<!-- A button with custom content -->
<Button XamlSpecificStuff:Name="button1" Height="100" Width="100">
<Ellipse Fill="Green" Height="50" Width="50"/>
</Button>
</Grid>
</Page>
定义冗长的 XML 名称空间前缀的明显缺点是,每次 XAML 文件需要引用这个以 XAML 为中心的 XML 名称空间中定义的一个项目时,您都需要键入XamlSpecificStuff
。鉴于XamlSpecificStuff
需要许多额外的击键,只需坚持使用x
。
在任何情况下,除了x:Name
、x:Class
和x:Code
关键字之外, http://schemas.microsoft.com/winfx/2006/xaml
XML 名称空间还提供了对其他 XAML 关键字的访问,其中最常见的如表 24-9 所示。
表 24-9。
XAML 关键词
|XAML 关键词
|
生命的意义
|
| — | — |
| x:Array
| 表示 XAML 中的. NET 数组类型。 |
| x:ClassModifier
| 允许您定义由关键字Class
表示的 C# 类(内部或公共)的可见性。 |
| x:FieldModifier
| 允许您为根的任何命名子元素(例如,<Window>
元素中的<Button>
)定义类型成员(内部、公共、私有或受保护)的可见性。使用关键字Name
XAML 定义了一个命名元素。 |
| x:Key
| 允许您为将放入 dictionary 元素中的 XAML 项建立一个键值。 |
| x:Name
| 允许您指定给定 XAML 元素的生成 C# 名称。 |
| x:Null
| 代表一个null
引用。 |
| x:Static
| 允许您引用某个类型的静态成员。 |
| x:Type
| C# typeof
操作符的 XAML 等价物(它将基于所提供的名称产生一个System.Type
)。 |
| x:TypeArguments
| 允许您将元素建立为具有特定类型参数的泛型类型(例如,List<int>
与List<bool>
)。 |
除了这两个必要的 XML 名称空间声明之外,在 XAML 文档的开始元素中定义额外的标记前缀是可能的,有时也是必要的。每当您需要在 XAML 中描述一个在外部程序集中定义的. NET 核心类时,您通常会这样做。
例如,假设您已经构建了一些定制的 WPF 控件,并将它们打包在一个名为MyControls.dll
的库中。现在,如果您想创建一个使用这些控件的新的Window
,您可以使用clr-namespace
和assembly
标记建立一个映射到您的库的定制 XML 名称空间。以下是创建名为myCtrls
的标签前缀的一些示例标记,该标签前缀可用于访问库中的控件:
<Window x:Class="WpfApplication1.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:myCtrls="clr-namespace:MyControls;assembly=MyControls"
Title="MainWindow" Height="350" Width="525">
<Grid>
<myCtrls:MyCustomControl />
</Grid>
</Window>
clr-namespace
标记被分配给。NET 核心命名空间,而assembly
标记被设置为外部*.dll
程序集的友好名称。您可以将此语法用于任何外部。NET 核心库你想操纵的标记。虽然目前还不需要这样做,但以后的章节将要求您定义自定义的 XML 名称空间声明来描述标记中的类型。
Note
如果需要在标记中定义一个类,该标记是当前程序集的一部分,但在不同的。NET 核心命名空间中,您的xmlns
标记前缀是在没有assembly=
属性的情况下定义的,比如:xmlns:myCtrls="clr-namespace:SomeNamespaceInMyApp"
。
控制类和成员变量的可见性
在接下来的章节中,你会在需要的地方看到很多这样的关键词;然而,作为一个简单的例子,考虑下面的 XAML <Window>
定义,它使用了ClassModifier
和FieldModifier
关键字,以及x:Name
和x:Class
(记住kaxaml.exe
不允许您使用任何需要代码编译的 XAML 关键字,比如x:Code
、x:FieldModifier
或x:ClassModifier
):
<!-- This class will now be declared internal in the *.g.cs file -->
<Window x:Class="MyWPFApp.MainWindow" x:ClassModifier ="internal"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- This button will be public in the *.g.cs file -->
<Button x:Name ="myButton" x:FieldModifier ="public" Content = "OK"/>
</Window>
默认情况下,所有 C#/XAML 类型定义都是public
,而成员默认为internal
。然而,基于您的 XAML 定义,最终自动生成的文件包含一个带有公共Button
变量的内部类类型。
internal partial class MainWindow :
System.Windows.Window,
System.Windows.Markup.IComponentConnector
{
public System.Windows.Controls.Button myButton;
...
}
XAML 元素、XAML 属性和类型转换器
在建立了根元素和任何所需的 XML 名称空间之后,下一个任务是用一个子元素填充根元素。在现实世界的 WPF 应用中,这个孩子将是一个布局管理器(比如一个Grid
或StackPanel
),它依次包含任意数量的描述用户界面的附加 UI 元素。下一章将详细研究这些布局管理器,所以现在假设您的<Window>
类型将包含一个Button
元素。
正如你在本章已经看到的,XAML 元素映射到一个给定的类或结构类型。NET 核心名称空间,而开始元素标记中的属性映射到该类型的属性或事件。举例来说,在 Kaxaml 中输入下面的<Button>
定义:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<!-- Configure the look and feel of a Button -->
<Button Height="50" Width="100" Content="OK!"
FontSize="20" Background="Green" Foreground="Yellow"/>
</Grid>
</Page>
请注意,分配给每个属性的值已经被捕获为一个简单的文本值。这看起来像是完全不匹配的数据类型,因为如果你在 C# 代码中使用这个Button
,你将而不是给这些属性分配字符串对象,而是使用特定的数据类型。例如,下面是用代码创作的同一个按钮:
public void MakeAButton()
{
Button myBtn = new Button();
myBtn.Height = 50;
myBtn.Width = 100;
myBtn.FontSize = 20;
myBtn.Content = "OK!";
myBtn.Background = new SolidColorBrush(Colors.Green);
myBtn.Foreground = new SolidColorBrush(Colors.Yellow);
}
事实证明,WPF 附带了几个类型转换器类,用于将简单的文本值转换成正确的底层数据类型。这个过程透明地(并且自动地)发生。
虽然这很好,但是很多时候您需要为 XAML 属性分配一个更复杂的值,而这个值不能作为一个简单的字符串被捕获。例如,假设您想要构建一个自定义画笔来设置Button
的Background
属性。如果你在代码中构建画笔,这是非常简单的,如下所示:
public void MakeAButton()
{
...
// A fancy brush for the background.
LinearGradientBrush fancyBruch =
new LinearGradientBrush(Colors.DarkGreen, Colors.LightGreen, 45);
myBtn.Background = fancyBruch;
myBtn.Foreground = new SolidColorBrush(Colors.Yellow);
}
如何将复杂的画笔表示为字符串?你不能!幸运的是,XAML 提供了一种特殊的语法,当你需要给一个复杂的对象赋值时,可以使用这种语法,称为属性元素语法。
理解 XAML 属性元素语法
属性元素语法允许你将复杂的对象分配给一个属性。下面是一个对使用一个LinearGradientBrush
来设置其Background
属性的Button
的 XAML 描述:
<Button Height="50" Width="100" Content="OK!"
FontSize="20" Foreground="Yellow">
<Button.Background>
<LinearGradientBrush>
<GradientStop Color="DarkGreen" Offset="0"/>
<GradientStop Color="LightGreen" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
</Button>
注意,在<Button>
和</Button>
标记的范围内,您已经定义了一个名为<Button.Background>
的子范围。在这个范围内,您已经定义了一个自定义的<LinearGradientBrush>
。(不要担心画笔的确切代码;你会在第二十八章中了解到 WPF 图形。)
任何属性都可以使用属性元素语法来设置,该语法通常分为以下模式:
<DefiningClass>
<DefiningClass.PropertyOnDefiningClass>
<!-- Value for Property here! -->
</DefiningClass.PropertyOnDefiningClass>
</DefiningClass>
虽然任何属性都可以使用这个语法进行设置,但是如果您可以将一个值捕获为一个简单的字符串,那么您将节省自己的输入时间。例如,这里有一个更详细的方法来设置您的Button
的Width
:
<Button Height="50" Content="OK!"
FontSize="20" Foreground="Yellow">
...
<Button.Width>
100
</Button.Width>
</Button>
了解 XAML 附加属性
除了属性元素语法之外,XAML 还定义了一种特殊的语法,用于为附加到属性的设置一个值。本质上,附加属性允许子元素为父元素中定义的属性设置值。要遵循的通用模板如下所示:
<ParentElement>
<ChildElement ParentElement.PropertyOnParent = "Value">
</ParentElement>
附加属性语法最常见的用法是将 UI 元素放置在 WPF 布局管理器的一个类中(Grid
、DockPanel
等)。).下一章将详细介绍这些面板;现在,在 Kaxaml 中输入以下内容:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Canvas Height="200" Width="200" Background="LightBlue">
<Ellipse Canvas.Top="40" Canvas.Left="40" Height="20" Width="20" Fill="DarkBlue"/>
</Canvas>
</Page>
这里,您已经定义了一个包含一个Ellipse
的Canvas
布局管理器。注意,Ellipse
可以使用附加的属性语法通知其父节点(Canvas
)在哪里放置它的顶部/左侧位置。
关于附加属性,有一些事项需要注意。首先也是最重要的,这不是一个可以应用于 any 父节点的 any 属性的通用语法。例如,以下 XAML 无法正确解析:
<!-- Error! Set Background property on Canvas via attached property? -->
<Canvas Height="200" Width="200">
<Ellipse Canvas.Background="LightBlue"
Canvas.Top="40" Canvas.Left="90"
Height="20" Width="20" Fill="DarkBlue"/>
</Canvas>
附加属性是 WPF 特有概念的一种特殊形式,称为依赖属性。除非属性是以特定方式实现的,否则不能使用附加属性语法设置其值。你将在第二十五章中详细探究依赖属性。
Note
Visual Studio 具有 IntelliSense,它将向您显示可由给定元素设置的有效附加属性。
了解 XAML 标记扩展
如前所述,属性值通常使用简单的字符串或通过属性元素语法来表示。然而,还有另一种方法来指定 XAML 属性的值,使用标记扩展。标记扩展允许 XAML 解析器从专用的外部类获取属性值。考虑到一些属性值需要执行几个代码语句来计算值,这可能是有益的。
标记扩展提供了一种用新功能干净地扩展 XAML 语法的方法。标记扩展在内部表示为从MarkupExtension
派生的类。请注意,您需要构建自定义标记扩展的机会微乎其微。然而,XAML 关键字的子集(如x:Array
、x:Null
、x:Static
和x:Type
)是伪装的标记扩展!
标记扩展夹在大括号之间,就像这样:
<Element PropertyToSet = "{MarkUpExtension}"/>
要查看一些正在运行的标记扩展,请将以下代码编写到 Kaxaml 中:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:CorLib="clr-namespace:System;assembly=mscorlib">
<StackPanel>
<!-- The Static markup extension lets us obtain a value
from a static member of a class -->
<Label Content ="{x:Static CorLib:Environment.OSVersion}"/>
<Label Content ="{x:Static CorLib:Environment.ProcessorCount}"/>
<!-- The Type markup extension is a XAML version of
the C# typeof operator -->
<Label Content ="{x:Type Button}" />
<Label Content ="{x:Type CorLib:Boolean}" />
<!-- Fill a ListBox with an array of strings! -->
<ListBox Width="200" Height="50">
<ListBox.ItemsSource>
<x:Array Type="CorLib:String">
<CorLib:String>Sun Kil Moon</CorLib:String>
<CorLib:String>Red House Painters</CorLib:String>
<CorLib:String>Besnard Lakes</CorLib:String>
</x:Array>
</ListBox.ItemsSource>
</ListBox>
</StackPanel>
</Page>
首先,注意到<Page>
定义有一个新的 XML 名称空间声明,它允许您访问mscorlib.dll
的System
名称空间。建立了这个 XML 名称空间后,首先使用x:Static
标记扩展,并从System.Environment
类的OSVersion
和ProcessorCount
中获取值。
x:Type
标记扩展允许您访问指定项目的元数据描述。这里,您只是简单地指定了 WPF Button
和System.Boolean
类型的完全限定名。
这个标记中最有趣的部分是ListBox
。这里,您将ItemsSource
属性设置为完全在标记中声明的字符串数组!注意这里的x:Array
标记扩展如何允许你在它的范围内指定一组子项:
<x:Array Type="CorLib:String">
<CorLib:String>Sun Kil Moon</CorLib:String>
<CorLib:String>Red House Painters</CorLib:String>
<CorLib:String>Besnard Lakes</CorLib:String>
</x:Array>
Note
前面的 XAML 例子只是用来说明标记扩展的作用。正如你将在第二十五章中看到的,填充ListBox
控件有更简单的方法!
图 24-3 显示了这个<Page>
在 Kaxaml 中的标记。
图 24-3。
标记扩展允许您通过专用类的功能来设置值
现在,您已经看到了许多展示 XAML 语法每个核心方面的例子。你可能会同意,XAML 是有趣的,因为它允许你描述一个树。NET 对象。虽然这在配置图形用户界面时非常有用,但请记住,XAML 可以描述来自任何程序集的任何类型,只要它是包含默认构造函数的非抽象类型。
使用 Visual Studio 构建 WPF 应用
让我们看看 Visual Studio 如何简化 WPF 程序的构造。虽然您可以使用 Visual Studio 代码生成 WPF 应用,但 Visual Studio 代码没有任何用于生成 WPF 应用的设计器支持。Visual Studio 具有丰富的 XAML 支持,在构建 WPF 应用时是一个更高效的 IDE。
Note
在这里,我将指出使用 Visual Studio 构建 WPF 应用的一些关键特性。接下来的章节将在必要的地方说明 IDE 的其他方面。
WPF 项目模板
Visual Studio 的新建项目对话框定义了一组 WPF 项目模板,包括 WPF App、WPF 自定义控件库、WPF 用户控件库。创建新的 WPF 应用(。NET)名为 WpfTesterApp 的项目。
Note
当从 Visual Studio“添加新项目”屏幕中选择 WPF 项目时,请确保选择具有“(”的 WPF 项目模板。NET)“在标题中,而不是”(。NET 框架)。”的当前版本。NET Core 已经被简单地重命名为。净 5。如果选择带有“(”的模板。NET Framework)”在标题中,您将使用。NET Framework 4.x。
除了将项目 SDK 设置为Microsoft.NET.Sdk
之外,还将为您提供初始的Window
和Application
派生类,每一个都使用 XAML 和 C# 代码文件来表示。
工具箱和 XAML 设计器/编辑器
Visual Studio 提供了一个工具箱(可以通过“视图”菜单打开),其中包含许多 WPF 控件。面板的顶部包含最常用的控件,底部包含所有控件(参见图 24-4 )。
图 24-4。
工具箱包含可以放置在设计器图面上的 WPF 控件
使用标准的拖放操作,您可以将这些控件中的任何一个放置在窗口的设计器图面上,或者将控件拖动到设计器底部的 XAML 标记编辑器中。当你这样做的时候,最初的 XAML 将会以你的名义被创作。用鼠标将一个Button
控件和一个Calendar
控件拖动到设计器表面上。完成后,请注意如何重新定位和调整控件的大小(并确保检查基于编辑生成的 XAML)。
除了通过鼠标和工具箱构建 UI 之外,您还可以使用集成的 XAML 编辑器手动输入标记。正如你在图 24-5 中看到的,你得到了智能感知支持,这可以帮助简化标记的创作。例如,尝试将Background
属性添加到开始的<Window>
元素中。
图 24-5。
WPF 橱窗设计师
花点时间在 XAML 编辑器中直接添加一些属性值。确保你花时间去适应使用 WPF 设计器的这个方面。
使用“属性”窗口设置属性
将一些控件放置到设计器上(或在编辑器中手动定义它们)后,可以利用“属性”窗口来设置所选控件的属性值,以及装配所选控件的事件处理程序。通过一个简单的测试,在设计器上选择您的Button
控件。现在,使用属性窗口通过集成笔刷编辑器改变Button
的Background
颜色(见图 24-6;在你检查 WPF 图形的时候,你会在第二十六章学到更多关于笔刷编辑器的知识。
图 24-6。
“属性”窗口可用于配置 WPF 控件的用户界面
Note
“属性”窗口的顶部提供了一个搜索文本区域。键入要设置的属性的名称,以便快速找到有问题的项目。
在您完成了对画笔编辑器的修改之后,检查生成的标记。它可能看起来像这样:
<Button Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75">
<Button.Background>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="Black" Offset="0"/>
<GradientStop Color="#FFE90E0E" Offset="1"/>
<GradientStop Color="#FF1F4CE3"/>
</LinearGradientBrush>
</Button.Background>
</Button>
使用“属性”窗口处理事件
如果您想要处理给定控件的事件,也可以利用“属性”窗口,但是这一次您需要单击“属性”窗口右上角的“事件”按钮(寻找闪电图标)。确保在您的设计器上选择了按钮,并定位到Click
事件。完成后,直接双击Click
事件条目。这将导致 Visual Studio 自动生成一个事件处理程序,该处理程序采用以下常规形式:
NameOfControl_NameOfEvent
因为你没有重命名你的按钮,属性窗口显示它生成了一个名为Button_Click
的事件处理程序(见图 24-7 )。
图 24-7。
使用“属性”窗口处理事件
同样,Visual Studio 在窗口的代码文件中生成了相应的 C# 事件处理程序。在这里,您可以添加任何类型的代码,这些代码必须在按钮被单击时执行。要进行快速测试,只需输入以下代码语句:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("You clicked the button!");
}
在 XAML 编辑器中处理事件
您也可以直接在 XAML 编辑器中处理事件。举个例子,将鼠标放在<Window>
元素中,输入MouseMove
事件,后面跟着等号。一旦你这样做了,你会看到 Visual Studio 在你的代码文件中显示任何兼容的处理程序(如果它们存在的话),以及创建一个新的事件处理程序的选项(见图 24-8 )。
图 24-8。
使用 XAML 编辑器处理事件
让 IDE 创建MouseMove
事件处理程序,输入以下代码,然后运行应用以查看结果:
private void MainWindow_MouseMove (object sender, MouseEventArgs e)
{
this.Title = e.GetPosition(this).ToString();
}
Note
第二十八章讲述了 MVVM 和命令模式,这是在企业应用中处理点击事件的一种更好的方式。但是如果你只需要一个简单的应用,用一个直接的事件处理器来处理点击事件是完全可以接受的。
“文档大纲”窗口
当你处理任何基于 XAML 的项目时,你肯定会使用大量的标记来表示你的用户界面。当您开始处理更复杂的 XAML 时,可视化标记以快速选择要在 Visual Studio 设计器上编辑的项会很有用。
目前,您的标记相当平淡,因为您只在初始的<Grid>
中定义了几个控件。但是,在 IDE 中找到文档大纲窗口,默认情况下,该窗口安装在 Visual Studio 的左侧(如果找不到,只需使用“查看➤其他窗口”菜单选项激活它)。现在,确保你的 XAML 设计器是 IDE 中的活动窗口(而不是 C# 代码文件),你会注意到文档大纲窗口显示了嵌套的元素(见图 24-9 )。
图 24-9。
通过文档大纲窗口可视化您的 XAML
此工具还提供了一种在设计器上临时隐藏给定项(或一组项)以及锁定项以防止发生额外编辑的方法。在下一章中,您将看到文档大纲窗口如何还提供了许多其他功能来将所选项目分组到新的布局管理器中(以及其他功能)。
启用或禁用 XAML 调试器
当您运行应用时,您会在屏幕上看到MainWindow
。您还会看到交互式调试器,如图 24-10 所示。
图 24-10。
XAML 用户界面调试
如果你想关闭它,你可以在工具➤选项➤调试➤热重装下找到 XAML 调试的条目。取消选择顶部的框,以防止调试器窗口覆盖您的窗口。图 24-11 显示了条目。
图 24-11。
XAML 用户界面调试
检查 App.xaml 文件
项目如何知道启动哪个窗口?更有趣的是,如果您检查应用中的代码文件,您还会发现到处都找不到Main()
方法。你已经通过这本书了解到应用必须有一个入口点,那么。不知道如何启动你的应用?幸运的是,这两个管道项目都是通过 Visual Studio 模板和 WPF 框架来处理的。
为了解决启动哪个窗口的难题,App.xaml
文件通过标记定义了一个应用类。除了名称空间定义,它还定义了应用属性,如StartupUri
、应用范围的资源(在第二十七章中介绍)以及应用事件的特定处理程序,如Startup
和Exit
。StartupUri
指示启动时加载哪个窗口。打开App.xaml
文件并检查标记,如下所示:
<Application x:Class="WpfTesterApp.App"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfTesterApp"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>
使用 XAML 设计器和 Visual Studio 代码完成功能,为Startup
和Exit
事件添加处理程序。更新后的 XAML 应该是这样的(注意以粗体显示的变化):
<Application x:Class="WpfTesterApp.App"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfTesterApp"
StartupUri="MainWindow.xaml" Startup="App_OnStartup" Exit="App_OnExit">
<Application.Resources>
</Application.Resources>
</Application>
如果您查看App.xaml.cs
文件,它应该是这样的:
public partial class App : Application
{
private void App_OnStartup(object sender, StartupEventArgs e)
{
}
private void App_OnExit(object sender, ExitEventArgs e)
{
}
}
请注意,该类被标记为分部类。事实上,XAML 文件的所有代码隐藏窗口都被标记为分部窗口。这是解开Main()
方法存在于何处之谜的关键。但是首先,您需要检查当msbuild.exe
处理 XAML 文件时发生了什么。
将窗口 XAML 标记映射到 C# 代码
当msbuild.exe
处理您的*.csproj
文件时,它为您项目中的每个 XAML 文件生成三个文件,格式为*.g.cs
(其中g
表示自动生成)、*.g.i.cs
(其中 i 表示智能感知)、以及*.baml
(用于二进制应用标记语言)。这些文件被保存到\obj\Debug
目录中(可以通过点击 Show All Files 按钮在 Solution Explorer 中查看)。您可能必须点击解决方案资源管理器中的刷新按钮才能看到它们,因为它们不是实际项目的一部分,而是构建工件。
为了更好地理解该过程,为控件提供名称会很有帮助。继续为Button
和Calendar
控件提供名称,如下所示:
<Button Name="ClickMe" Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0"
VerticalAlignment="Top" Width="75" Click="Button_Click">
//omitted for brevity
</Button>
<Calendar Name="MyCalendar" HorizontalAlignment="Left" Margin="10,41,0,0" VerticalAlignment="Top"/>
现在重新生成您的解决方案(或项目)并刷新解决方案资源管理器中的文件。如果你在文本编辑器中打开MainWindow.g.cs
文件,你会发现一个名为MainWindow
的类,它扩展了Window
基类。这个类的名字是由<Window>
开始标签中的x:Class
属性直接产生的。
这个类定义了一个类型为bool
(名为_contentLoaded
)的私有成员变量,它在 XAML 标记中没有被直接考虑。这个数据成员用于确定(并确保)窗口的内容只被分配一次。这个类还包含一个类型为System.Windows.Controls.Button
的成员变量,名为ClickMe
。控件的名称基于开始的<Button>
声明中的x:Name
(或者简写为form Name
)属性值。你看不到的是Calendar
控件的变量。这是因为msbuild.exe
为 XAML 中每个名为的控件创建了一个变量,该控件在代码隐藏中有相关的代码。如果没有任何代码,就不需要变量。更令人困惑的是,如果您没有命名Button
控件,它也不会有变量。这是 WPF 魔力的一部分,并且与IComponentConnector
接口实现紧密相连。
编译器生成的类还显式实现了在System.Windows.Markup
名称空间中定义的 WPF IComponentConnector
接口。该接口定义了一个名为Connect()
的方法,该方法已经被实现来准备标记中定义的每个控件,并按照原始MainWindow.xaml
文件中指定的那样装配事件逻辑。您可以看到为ClickMe
点击事件设置的处理程序。在该方法完成之前,_contentLoaded
成员变量被设置为true
。这是该方法的关键:
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target)
{
switch (connectionId)
{
case 1:
this.ClickMe = ((System.Windows.Controls.Button)(target));
#line 11 "..\..\MainWindow.xaml"
this.ClickMe.Click += new System.Windows.RoutedEventHandler(this.Button_Click);
#line default
#line hidden
return;
}
this._contentLoaded = true;
}
要用代码显示未命名控件的效果,请为日历上的SelectedDatesChanged
事件添加一个事件处理程序。重新构建应用,刷新文件,并重新加载MainWindow.g.cs
文件。在Connect()
方法中,您现在可以看到下面的代码块:
#line 20 "..\..\MainWindow.xaml"
this.MyCalendar.SelectedDatesChanged += new
System.EventHandler<System.Windows.Controls.SelectionChangedEventArgs>(
this.MyCalendar_OnSelectedDatesChanged);
这告诉框架 XAML 文件第 20 行的控件分配了SelectedDatesChanged
事件处理程序,如前面的代码所示。
最后,MainWindow
类定义并实现了一个名为InitializeComponent()
的方法。您可能希望这个方法包含通过设置各种属性(Height
、Width
、Content
等)来设置每个控件的外观和感觉的代码。).然而事实并非如此!那么控件如何呈现正确的用户界面呢?带有InitializeComponent()
的逻辑解析与原始*.xaml
文件同名的嵌入式汇编资源的位置,如下所示:
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/WpfTesterApp;component/mainwindow.xaml",
System.UriKind.Relative);
#line 1 "..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
此时,问题变成了*“*这个嵌入式资源到底是什么?”
BAML 的角色
正如您可能从名称中猜到的那样,二进制应用标记语言(BAML)是原始 XAML 数据的一种紧凑的二进制表示。这个*.baml
文件作为资源(通过一个生成的*.g.resources
文件)嵌入到编译后的程序集中。这个 BAML 资源包含了建立 UI 小部件的外观所需的所有数据(同样,比如Height
和Width
属性)。
这里重要的一点是理解 WPF 应用本身包含标记的二进制表示(BAML)。在运行时,这个 BAML 将从资源容器中提取出来,用于确保所有的窗口和控件都被初始化为正确的外观。
另外,请记住,这些二进制资源的名称与您创作的独立*.xaml
文件的名称相同。然而,这并不意味着您必须将松散的*.xaml
文件与您编译的 WPF 程序一起分发。除非您构建了一个 WPF 应用,可以在运行时动态加载和解析*.xaml
文件,否则您永远不需要发送原始标记。
解开 Main 之谜()
现在您已经知道了 MSBuild 过程是如何工作的,打开App.g.cs
文件。在这里,您将找到自动生成的Main()
方法,它初始化并运行您的应用对象。
public static void Main() {
WpfTesterApp.App app = new WpfTesterApp.App();
app.InitializeComponent();
app.Run();
}
InitializeComponent()
方法配置应用属性,包括StartupUri
以及Startup
和Exit
事件的事件处理程序。
public void InitializeComponent() {
#line 5 "..\..\App.xaml"
this.Startup += new System.Windows.StartupEventHandler(this.App_OnStartup);
#line default
#line hidden
#line 5 "..\..\App.xaml"
this.Exit += new System.Windows.ExitEventHandler(this.App_OnExit);
#line default
#line hidden
#line 5 "..\..\App.xaml"
this.StartupUri = new System.Uri("MainWindow.xaml", System.UriKind.Relative);
#line default
#line hidden
}
与应用级数据交互
回想一下,Application
类定义了一个名为Properties
的属性,它允许您通过类型索引器定义一组名称-值对。因为这个索引器被定义为在类型System.Object
上操作,所以您可以在这个集合中存储任何种类的项(包括您的自定义类),以便以后使用友好的名字对象进行检索。使用这种方法,在 WPF 应用的所有窗口之间共享数据变得很简单。
举例来说,您将更新当前的Startup
事件处理程序,以检查名为/GODMODE
(许多 PC 视频游戏的常见欺骗代码)的值的传入命令行参数。如果找到这个标记,您将在同名的 properties 集合中建立一个设置为true
的bool
值(否则,您将把该值设置为false
)。
这听起来很简单,但是如何将传入的命令行参数(通常从Main()
方法获得)传递给Startup
事件处理程序呢?一种方法是调用静态的Environment.GetCommandLineArgs()
方法。然而,这些相同的参数被自动添加到传入的StartupEventArgs
参数中,并且可以通过Args
属性来访问。以下是对当前代码库的首次更新:
private void App_OnStartup(object sender, StartupEventArgs e)
{
Application.Current.Properties["GodMode"] = false;
// Check the incoming command-line arguments and see if they
// specified a flag for /GODMODE.
foreach (string arg in e.Args)
{
if (arg.Equals("/godmode",StringComparison.OrdinalIgnoreCase))
{
Application.Current.Properties["GodMode"] = true;
break;
}
}
}
可以从 WPF 应用中的任何位置访问应用范围的数据。您需要做的就是获得一个全局应用对象的访问点(通过Application.Current
)并研究这个集合。例如,您可以这样更新Button
的Click
事件处理程序:
private void Button_Click(object sender, RoutedEventArgs e)
{
// Did user enable /godmode?
if ((bool)Application.Current.Properties["GodMode"])
{
MessageBox.Show("Cheater!");
}
}
这样,如果您在项目属性的 Debug 选项卡中输入/godmode
命令行参数,然后运行程序,您将会感到羞愧,程序将会退出。您也可以通过输入以下命令从命令行运行该程序(打开命令提示符并导航到bin/debug
目录):
WpfAppAllCode.exe /godmode
当终止应用时,你会看到一个不光彩的消息框。
Note
回想一下,您可以在 Visual Studio 中提供命令行参数。只需双击解决方案资源管理器中的属性图标,在结果编辑器中单击 Debug 选项卡,然后在“命令行参数”编辑器中输入/godmode
。
处理窗口对象的关闭
最终用户可以通过使用许多内置的系统级技术(例如,单击窗口框架上的 x 关闭按钮)或通过间接调用Close()
方法来响应一些用户交互元素(例如,文件➤退出)来关闭窗口。在这两种情况下,WPF 都提供了两个事件,您可以截取它们来确定用户是否真的准备好关闭窗口并从内存中删除它。第一个触发的事件是Closing
,它与CancelEventHandler
委托一起工作。
该委托期望目标方法将System.ComponentModel.CancelEventArgs
作为第二个参数。CancelEventArgs
提供了Cancel
属性,当设置为true
时,将阻止窗口实际关闭(当您询问用户是否真的想关闭窗口,或者他是否想先保存他的工作时,这很方便)。
如果用户确实想关闭窗口,可以将CancelEventArgs.Cancel
设置为false
(默认设置)。这将导致Closed
事件被触发(与System.EventHandler
委托一起工作),使它成为窗口将要被永久关闭的点。
通过将这些代码语句添加到当前构造函数中,更新MainWindow
类来处理这两个事件,如下所示:
public MainWindow()
{
InitializeComponent();
this.Closed+=MainWindow_Closed;
this.Closing += MainWindow_Closing;
}
现在,实现相应的事件处理程序,如下所示:
private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
// See if the user really wants to shut down this window.
string msg = "Do you want to close without saving?";
MessageBoxResult result = MessageBox.Show(msg,
"My App", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (result == MessageBoxResult.No)
{
// If user doesn't want to close, cancel closure.
e.Cancel = true;
}
}
private void MainWindow_Closed(object sender, EventArgs e)
{
MessageBox.Show("See ya!");
}
现在,运行您的程序并尝试关闭窗口,方法是单击窗口右上角的 X 图标或单击按钮控件。您应该会看到确认对话框,询问您是否真的要离开。如果您回答是,您将会看到告别信息。单击“否”按钮会将该窗口保留在内存中。
拦截鼠标事件
WPF API 提供了几个事件,您可以捕捉这些事件来与鼠标交互。具体来说,UIElement
基类定义了以鼠标为中心的事件,比如MouseMove
、MouseUp
、MouseDown
、MouseEnter
、MouseLeave
等等。
例如,考虑处理MouseMove
事件的行为。该事件与System.Windows.Input.MouseEventHandler
委托协同工作,该委托期望其目标将一个System.Windows.Input.MouseEventArgs
类型作为第二个参数。使用MouseEventArgs
,可以提取鼠标的(x,y)位置和其他相关细节。考虑以下部分定义:
public class MouseEventArgs : InputEventArgs
{
...
public Point GetPosition(IInputElement relativeTo);
public MouseButtonState LeftButton { get; }
public MouseButtonState MiddleButton { get; }
public MouseDevice MouseDevice { get; }
public MouseButtonState RightButton { get; }
public StylusDevice StylusDevice { get; }
public MouseButtonState XButton1 { get; }
public MouseButtonState XButton2 { get; }
}
Note
XButton1
和XButton2
属性允许您与“扩展鼠标按钮”交互(例如一些鼠标控件上的“下一个”和“上一个”按钮)。这些通常用于与浏览器的历史列表交互,以便在访问过的页面之间导航。
方法允许你获得相对于窗口中 UI 元素的(x,y)值。如果您对捕捉相对于激活窗口的位置感兴趣,只需传入this
。在你的MainWindow
类的构造函数中处理MouseMove
事件,就像这样:
public MainWindow(string windowTitle, int height, int width)
{
...
this.MouseMove += MainWindow_MouseMove;
}
这里有一个MouseMove
的事件处理程序,它将在窗口的标题区域显示鼠标的位置(注意,您正在通过ToString()
将返回的Point
类型转换为文本值):
private void MainWindow_MouseMove(object sender,
System.Windows.Input.MouseEventArgs e)
{
// Set the title of the window to the current (x,y) of the mouse.
this.Title = e.GetPosition(this).ToString();
}
截取键盘事件
为聚焦窗口处理键盘输入也很简单。UIElement
定义您可以捕获的事件,以截取活动元素上键盘的按键(例如KeyUp
、KeyDown
)。KeyUp
和KeyDown
事件都与System.Windows.Input.KeyEventHandler
委托一起工作,委托期望目标的第二个事件处理程序是KeyEventArgs
类型,它定义了几个感兴趣的公共属性,如下所示:
public class KeyEventArgs : KeyboardEventArgs
{
...
public bool IsDown { get; }
public bool IsRepeat { get; }
public bool IsToggled { get; }
public bool IsUp { get; }
public Key Key { get; }
public KeyStates KeyStates { get; }
public Key SystemKey { get; }
}
为了说明如何在MainWindow
的构造函数中处理KeyDown
事件(就像您对前面事件所做的那样),实现下面的事件处理程序,它用当前按下的键来改变按钮的内容:
private void MainWindow0s_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
// Display key press on the button.
ClickMe.Content = e.Key.ToString();
}
在本章的这一点上,WPF 可能看起来只不过是另一个 GUI 框架,提供(或多或少)与 Windows 窗体、MFC 或 VB6 相同的服务。如果事实上是这样的话,您可能会质疑是否还需要另一个 UI 工具包。要真正了解 WPF 的独特之处,你需要理解基于 XML 的语法,XAML。
摘要
windows Presentation Foundation(WPF)是随发行版推出的用户界面工具包。NET 3.0。WPF 的主要目标是集成和统一以前不相关的桌面技术(2D 图形、3D 图形、窗口和控件开发等)。)集成到一个统一的编程模型中。除此之外,WPF 程序通常使用 XAML,它允许你通过标记声明 WPF 元素的外观。
回想一下 XAML 允许你描述。NET 对象使用声明性语法。在本章对 XAML 的研究中,您接触到了一些新的语法,包括属性元素语法和附加属性,以及类型转换器和 XAML 标记扩展的作用。
XAML 是任何生产级 WPF 应用的一个关键方面。本章的最后一个例子让你有机会构建一个 WPF 应用,展示本章中讨论的许多概念。接下来的章节将会深入这些概念,并引入更多的概念。*
二十五、WPF 控件、布局、事件和数据绑定
第二十四章为 WPF 编程模型提供了基础,包括对Window
和Application
类的检查,XAML 的语法,以及代码文件的使用。第二十四章还向您介绍了使用 Visual Studio 的设计器构建 WPF 应用的过程。在本章中,您将使用几个新的控件和布局管理器深入研究更复杂的图形用户界面的构造,同时了解 Visual Studio 的 XAML 版 WPF 可视化设计器的其他功能。
本章还将研究一些重要的相关 WPF 控制主题,如数据绑定编程模型和控制命令的使用。您还将学习如何使用 Ink 和 Documents APIs,这两个 API 分别允许您捕获手写笔(或鼠标)输入和使用 XML Paper 规范构建富文本文档。
WPF 核心控制措施调查
除非您对构建图形用户界面的概念不熟悉(这很好),否则主要 WPF 控件的一般用途应该不会引起太多问题。不管你过去可能使用过哪种 GUI 工具包(例如 VB6、MFC、Java AWT/Swing、Windows Forms、macOS 或 GTK+/GTK #[等等]),表 25-1 中列出的核心 WPF 控件可能看起来很熟悉。
表 25-1。
核心 WPF 控件
|WPF 控制类别
|
成员示例
|
生命的意义
|
| — | — | — |
| 核心用户输入控件 | Button
,RadioButton
,ComboBox
,CheckBox
,Calendar
,DatePicker
,Expander
,DataGrid
,ListBox
,ListView
,ToggleButton
,TreeView
,ContextMenu
,ScrollBar
,Slider
,TabControl
,TextBlock
,TextBox
,RepeatButton
,RichTextBox
,Label
| WPF 提供了一个完整的控件家族,你可以用它来构建用户界面的核心。 |
| 窗口和控件装饰 | Menu
、ToolBar
、StatusBar
、ToolTip
、ProgressBar
| 您使用这些 UI 元素来装饰带有输入设备(如Menu
)和用户信息元素(如StatusBar
和ToolTip
)的Window
对象的框架。 |
| 媒体控制 | Image
、MediaElement
、SoundPlayerAction
| 这些控件支持音频/视频回放和图像显示。 |
| 布局控件 | Border
、Canvas
、DockPanel
、Grid
、GridView
、GridSplitter
、GroupBox
、Panel
、TabControl
、StackPanel
、Viewbox
、WrapPanel
| WPF 提供了许多控件,允许您分组和组织其他控件以进行布局管理。 |
Note
本章的目的是而不是介绍每个 WPF 控件的每个成员。相反,您将获得各种控件的概述,重点是大多数 WPF 控件通用的基础编程模型和关键服务。
WPF 油墨控制
除了表 25-1 中列出的常见 WPF 控件,WPF 还定义了用于数字墨水 API 的附加控件。WPF 开发的这一方面在 Tablet PC 开发过程中非常有用,因为它允许您从手写笔捕获输入。然而,这并不是说标准的桌面应用不能利用 Ink API,因为相同的控件可以使用鼠标捕获输入。
PresentationCore.dll
的System.Windows.Ink
名称空间包含各种 Ink API 支持类型(如Stroke
和StrokeCollection
);然而,大多数的 Ink API 控件(例如,InkCanvas
和InkPresenter
)都与通用的 WPF 控件一起打包在PresentationFramework.dll
汇编中的System.Windows.Controls
名称空间下。在本章的后面,您将使用 Ink API。
WPF 文件控制
WPF 还提供了高级文档处理控件,允许您构建包含 Adobe PDF 样式功能的应用。使用System.Windows.Documents
名称空间中的类型(也在PresentationFramework.dll
汇编中),您可以创建支持缩放、搜索、用户注释(便笺)和其他富文本服务的打印就绪文档。
然而,在封面下,文档控件不使用 Adobe PDF APIs 相反,他们使用 XML 纸张规范(XPS) API。对最终用户来说,看起来真的没有区别,因为 PDF 文档和 XPS 文档具有几乎相同的外观和感觉。事实上,您可以找到许多免费的实用程序,允许您在两种文件格式之间进行动态转换。由于篇幅限制,这些控件将不在本版中讨论。
WPF 通用对话框
WPF 还为您提供了一些常用的对话框,如OpenFileDialog
和SaveFileDialog
。这些对话框是在PresentationFramework.dll
程序集的Microsoft.Win32
名称空间中定义的。使用这些对话框都是创建一个对象并调用ShowDialog()
方法,就像这样:
using Microsoft.Win32;
//omitted for brevity
private void btnShowDlg_Click(object sender, RoutedEventArgs e)
{
// Show a file save dialog.
SaveFileDialog saveDlg = new SaveFileDialog();
saveDlg.ShowDialog();
}
正如您所希望的,这些类支持各种成员,这些成员允许您建立文件过滤器和目录路径,并获得对用户选择的文件的访问。您将在后面的示例中使用这些文件对话框;您还将学习如何构建自定义对话框来收集用户输入。
Visual Studio WPF 设计器简评
这些标准 WPF 控件中的大部分都被打包在PresentationFramework.dll
程序集的System.Windows.Controls
名称空间中。当您使用 Visual Studio 构建 WPF 应用时,如果您有一个作为活动窗口打开的 WPF 设计器,您会发现工具箱中包含大多数这些常用控件。
类似于用 Visual Studio 创建的其他 UI 框架,你可以将这些控件拖到 WPF 窗口设计器上,并使用属性窗口配置它们(你在第二十四章中学到了)。虽然 Visual Studio 会为您生成大量的 XAML,但您自己手动编辑标记的情况并不少见。我们来复习一下基础知识。
使用 Visual Studio 处理 WPF 控件
你可能还记得第二十四章中的,当你把一个 WPF 控件放到 Visual Studio 设计器上时,你想通过属性窗口(或者直接通过 XAML)设置x:Name
属性,因为这允许你访问相关 C# 代码文件中的对象。您可能还记得,可以使用“属性”窗口的“事件”选项卡为选定的控件生成事件处理程序。因此,您可以使用 Visual Studio 为一个简单的Button
控件生成以下标记:
<Button x:Name="btnMyButton" Content="Click Me!" Height="23" Width="140" Click="btnMyButton_Click" />
这里,您将Button
的Content
属性设置为一个简单的值为"Click Me!"
的string
。然而,由于 WPF 控件内容模型,您可以设计一个包含以下复杂内容的Button
:
<Button x:Name="btnMyButton" Height="121" Width="156" Click="btnMyButton_Click">
<Button.Content>
<StackPanel Height="95" Width="128" Orientation="Vertical">
<Ellipse Fill="Red" Width="52" Height="45" Margin="5"/>
<Label Width="59" FontSize="20" Content="Click!" Height="36" />
</StackPanel>
</Button.Content>
</Button>
您可能还记得,ContentControl
派生类的直接子元素是隐含的内容;因此,在指定复杂内容时,您不需要明确定义一个Button.Content
范围。您可以简单地编写以下内容:
<Button x:Name="btnMyButton" Height="121" Width="156" Click="btnMyButton_Click">
<StackPanel Height="95" Width="128" Orientation="Vertical">
<Ellipse Fill="Red" Width="52" Height="45" Margin="5"/>
<Label Width="59" FontSize="20" Content="Click!" Height="36" />
</StackPanel>
</Button>
在这两种情况下,都要将按钮的Content
属性设置为相关项目的StackPanel
。您还可以使用 Visual Studio 设计器创作这种复杂的内容。为内容控件定义布局管理器后,可以在设计器上选择它作为内部控件的放置目标。此时,您可以使用“属性”窗口编辑每个属性。如果您要使用“属性”窗口来处理Button
控件的Click
事件(如前面的 XAML 声明所示),IDE 将生成一个空的事件处理程序,您可以向其中添加自己的自定义代码,如下所示:
private void btnMyButton_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("You clicked the button!");
}
使用文档大纲编辑器
您应该还记得,在前一章中,Visual Studio 的“文档大纲”窗口(可以使用“查看➤其他窗口”菜单打开)在设计包含复杂内容的 WPF 控件时非常有用。为您正在构建的Window
显示 XAML 的逻辑树,如果您单击这些节点中的任何一个,它将在可视设计器和 XAML 编辑器中被自动选中进行编辑。
在当前版本的 Visual Studio 中,“文档大纲”窗口有一些您可能会觉得有用的附加功能。在任何节点的右边,你会发现一个看起来像眼球的图标。当您切换此按钮时,您可以选择隐藏或显示设计器上的一个项目,这在您想要聚焦于要编辑的特定片段时会很有帮助(注意,这将而不是在运行时隐藏项目;这只隐藏设计器图面上的项)。
紧挨着“眼球图标”的是第二个开关,允许您锁定设计器上的一个项目。正如您可能猜到的,当您希望确保您(或您的同事)不会意外更改给定项目的 XAML 时,这可能会很有帮助。实际上,锁定一个项会使它在设计时成为只读的(但是,您可以在运行时更改对象的状态)。
使用面板控制内容布局
WPF 应用总是包含大量的 UI 元素(例如,用户输入控件、图形内容、菜单系统和状态栏),这些元素需要在不同的窗口中进行良好的组织。放置 UI 元素后,您需要确保当最终用户调整窗口大小或可能调整窗口的一部分时(如拆分窗口的情况),它们的行为符合预期。为了确保你的 WPF 控件在托管窗口中保持它们的位置,你可以利用大量的面板类型(也称为布局管理器)。
默认情况下,用 Visual Studio 创建的新 WPF Window
将使用类型Grid
的布局管理器(稍后会有更多细节)。然而,现在假设一个没有声明布局管理器的Window
,如下所示:
<Window x:Class="MyWPFApp.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="285" Width="325">
</Window>
当您直接在不使用面板的窗口中声明控件时,该控件位于容器的正中央。考虑下面这个简单的窗口声明,它包含一个Button
控件。无论您如何调整窗口大小,UI 小部件始终与客户区的四边等距。Button
的大小由分配给Button
的Height
和Width
属性决定。
<!- This button is in the center of the window at all times ->
<Window x:Class="MyWPFApp.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="285" Width="325">
<Button x:Name="btnOK" Height = "100" Width="80" Content="OK"/>
</Window>
您可能还记得,如果您试图将多个元素直接放在一个Window
的范围内,您将会收到标记和编译时错误。这些错误的原因是一个窗口(或者任何一个ContentControl
的后代)只能分配一个对象给它的Content
属性。因此,下面的 XAML 会产生标记和编译时错误:
<!- Error! Content property is implicitly set more than once! ->
<Window x:Class="MyWPFApp.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="285" Width="325">
<!- Error! Two direct child elements of the <Window>! ->
<Label x:Name="lblInstructions" Width="328" Height="27" FontSize="15" Content="Enter Information"/>
<Button x:Name="btnOK" Height = "100" Width="80" Content="OK"/>
</Window>
显然,只能包含单个控件的窗口用处不大。当一个窗口需要包含多个元素时,这些元素必须排列在任意数量的面板中。面板将包含代表窗口的所有 UI 元素,之后面板本身被用作分配给Content
属性的单个对象。
System.Windows.Controls
名称空间提供了许多面板,每个面板控制如何维护子元素。如果最终用户调整了窗口的大小,如果控件保持在设计时放置的位置,如果控件从左到右水平重排或从上到下垂直重排,等等,都可以使用面板来确定控件的行为。
您还可以在其他面板中混合面板控件(例如,包含其他项目的StackPanel
的DockPanel
),以提供大量的灵活性和控制。表 25-2 记录了一些常用 WPF 面板控件的作用。
表 25-2。
核心 WPF 面板控制
|面板控制
|
生命的意义
|
| — | — |
| Canvas
| 提供内容放置的经典模式。项目会停留在设计时放置它们的地方。 |
| DockPanel
| 将内容锁定到面板的指定一侧(Top
、Bottom
、Left
或Right
)。 |
| Grid
| 在表格网格中维护的一系列单元格内排列内容。 |
| StackPanel
| 按照Orientation
属性的指示,以垂直或水平方式堆叠内容。 |
| WrapPanel
| 从左到右放置内容,在包含框的边缘将内容换行。根据Orientation
属性的值,后续排序从上到下或从右到左依次进行。 |
在接下来的几节中,您将通过将一些预定义的 XAML 数据复制到您在第二十四章中安装的kaxaml.exe
应用中来学习如何使用这些常用的面板类型。你可以在你的章节 25 代码下载文件夹的PanelMarkup
子文件夹中找到所有这些松散的 XAML 文件。使用 Kaxaml 时,要模拟调整窗口大小,请在标记中更改Page
元素的高度或宽度。
在画布面板中定位内容
如果你来自 WinForms 背景,你可能会觉得使用Canvas
面板最舒服,因为它允许 UI 内容的绝对定位。如果最终用户调整窗口的大小,使其小于由Canvas
面板维护的布局,那么直到容器被拉伸到等于或大于Canvas
区域的大小时,内部内容才可见。
要向Canvas
添加内容,首先要在开始和结束Canvas
标记的范围内定义所需的控件。接下来,指定每个控件的左上角;这是使用Canvas.Top
和Canvas.Left
属性开始渲染的地方。您可以通过设置控件的Height
和Width
属性来间接指定每个控件的右下角区域,或者通过使用Canvas.Right
和Canvas.Bottom
属性来直接指定。
要查看Canvas
的运行,使用kaxaml.exe
打开提供的SimpleCanvas.xaml
文件。您应该会看到下面的Canvas
定义(如果将这些例子加载到 WPF 应用中,您会希望将Page
标签改为Window
标签):
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="285" Width="325">
<Canvas Background="LightSteelBlue">
<Button x:Name="btnOK" Canvas.Left="212" Canvas.Top="203" Width="80" Content="OK"/>
<Label x:Name="lblInstructions" Canvas.Left="17" Canvas.Top="14" Width="328" Height="27" FontSize="15"
Content="Enter Car Information"/>
<Label x:Name="lblMake" Canvas.Left="17" Canvas.Top="60" Content="Make"/>
<TextBox x:Name="txtMake" Canvas.Left="94" Canvas.Top="60" Width="193" Height="25"/>
<Label x:Name="lblColor" Canvas.Left="17" Canvas.Top="109" Content="Color"/>
<TextBox x:Name="txtColor" Canvas.Left="94" Canvas.Top="107" Width="193" Height="25"/>
<Label x:Name="lblPetName" Canvas.Left="17" Canvas.Top="155" Content="Pet Name"/>
<TextBox x:Name="txtPetName" Canvas.Left="94" Canvas.Top="153" Width="193" Height="25"/>
</Canvas>
</Page>
您应该会在屏幕的上半部分看到如图 25-1 所示的窗口。
图 25-1。
画布布局管理器允许内容的绝对定位
请注意,您在Canvas
中声明内容的顺序并不用于计算位置;相反,放置是基于控件的大小和Canvas.Top
、Canvas.Bottom
、Canvas.Left
和Canvas.Right
属性。
Note
如果Canvas
中的子元素没有使用附加属性语法定义特定的位置(例如Canvas.Left
和Canvas.Top
,它们会自动附加到Canvas
的左上角。
使用Canvas
类型似乎是安排内容的首选方式(因为感觉很熟悉),但是这种方法有一些限制。首先,Canvas
中的项目在应用样式或模板时不会自动调整大小(例如,它们的字体大小不受影响)。其次,当最终用户将窗口调整到更小的表面时,Canvas
不会试图保持元素可见。
也许Canvas
类型的最佳用途是定位图形内容。例如,如果您使用 XAML 构建自定义图像,您肯定希望线条、形状和文本保持在相同的位置,而不是在用户调整窗口大小时看到它们动态地重新定位!当你研究 WPF 的图形渲染服务时,你会在第二十六章中重温Canvas
。
在 WrapPanel 面板中定位内容
一个WrapPanel
允许你定义当窗口调整大小时在面板上流动的内容。当在WrapPanel
中定位元素时,不需要像通常使用Canvas
那样指定顶部、底部、左侧和右侧的停靠值。但是,每个子元素可以自由定义一个Height
和Width
值(以及其他属性值)来控制它在容器中的总大小。
因为WrapPanel
中的内容不停靠在面板的给定侧,所以声明元素的顺序很重要(内容从第一个元素到最后一个元素呈现)。如果您要加载在SimpleWrapPanel.xaml
文件中找到的 XAML 数据,您会发现它包含以下标记(包含在Page
定义中):
<WrapPanel Background="LightSteelBlue">
<Label x:Name="lblInstruction" Width="328" Height="27" FontSize="15" Content="Enter Car Information"/>
<Label x:Name="lblMake" Content="Make"/>
<TextBox x:Name="txtMake" Width="193" Height="25"/>
<Label x:Name="lblColor" Content="Color"/>
<TextBox x:Name="txtColor" Width="193" Height="25"/>
<Label x:Name="lblPetName" Content="Pet Name"/>
<TextBox x:Name="txtPetName" Width="193" Height="25"/>
<Button x:Name="btnOK" Width="80" Content="OK"/>
</WrapPanel>
当你加载这个标记时,内容看起来是乱序的,因为它从左到右流过窗口(见图 25-2 )。
图 25-2。
一个 WrapPanel 中的内容表现得很像一个传统的 HTML 页面
默认情况下,WrapPanel
中的内容从左向右排列。但是,如果您将Orientation
属性的值更改为Vertical
,您可以让内容以自顶向下的方式换行。
<WrapPanel Background="LightSteelBlue" Orientation ="Vertical">
您可以通过指定ItemWidth
和ItemHeight
值来声明一个WrapPanel
(以及其他一些面板类型),这两个值控制每个项目的默认大小。如果一个子元素确实提供了它自己的Height
和/或Width
值,那么它将相对于面板确定的大小进行定位。考虑以下标记:
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="100" Width="650">
<WrapPanel Background="LightSteelBlue" Orientation ="Horizontal" ItemWidth ="200" ItemHeight ="30">
<Label x:Name="lblInstruction" FontSize="15" Content="Enter Car Information"/>
<Label x:Name="lblMake" Content="Make"/>
<TextBox x:Name="txtMake"/>
<Label x:Name="lblColor" Content="Color"/>
<TextBox x:Name="txtColor"/>
<Label x:Name="lblPetName" Content="Pet Name"/>
<TextBox x:Name="txtPetName"/>
<Button x:Name="btnOK" Width ="80" Content="OK"/>
</WrapPanel>
</Page>
呈现的代码如图 25-3 (注意Button
控件的大小和位置,它有一个指定的唯一Width
值)。
图 25-3。
一个 WrapPanel 可以建立给定项目的宽度和高度
看了图 25-3 后,你可能会同意,WrapPanel
通常不是直接在窗口中排列内容的最佳选择,因为当用户调整窗口大小时,它的元素会变得混乱。在大多数情况下,WrapPanel
将是另一个面板类型的子元素,允许窗口的一小部分区域在调整大小时包装其内容(例如,ToolBar
控件)。
在堆栈面板中定位内容
与WrapPanel
一样,StackPanel
控件根据分配给Orientation
属性的值,将内容排列成水平或垂直方向的单行(默认)。然而,不同之处在于,当用户调整窗口大小时,StackPanel
将而不是尝试包装内容。相反,StackPanel
中的项目将简单地伸展(基于它们的方向)以适应StackPanel
本身的大小。例如,SimpleStackPanel.xaml
文件包含以下标记,其输出如图 25-4 所示:
图 25-4。
内容的垂直堆叠
<Page
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fun with Panels!" Height="200" Width="400">
<StackPanel Background="LightSteelBlue" Orientation ="Vertical">
<Label Name="lblInstruction"
FontSize="15" Content="Enter Car Information"/>
<Label Name="lblMake" Content="Make"/>
<TextBox Name="txtMake"/>
<Label Name="lblColor" Content="Color"/>
<TextBox Name="txtColor"/>
<Label Name="lblPetName" Content="Pet Name"/>
<TextBox Name="txtPetName"/>
<Button Name="btnOK" Width ="80" Content="OK"/>
</StackPanel>
</Page>
如果您将Orientation
属性分配给Horizontal
,如下所示,渲染输出将与图 25-5 所示相匹配:
图 25-5。
内容的水平堆叠
<StackPanel Background="LightSteelBlue" Orientation="Horizontal">
同样,与使用WrapPanel
的情况一样,您很少会想要使用StackPanel
来直接在窗口中排列内容。相反,你应该使用StackPanel
作为主面板的子面板。
在网格面板中定位内容
在 WPF API 提供的所有面板中,Grid
无疑是最灵活的。像 HTML 表格一样,Grid
可以被分割成一组单元格,每个单元格都提供内容。当定义一个Grid
时,你执行这三个步骤:
-
定义和配置每个列。
-
定义和配置每一行。
-
使用附加属性语法将内容分配给网格的每个单元格。
Note
如果您没有定义任何行或列,Grid
默认为填充整个窗口表面的单个单元格。此外,如果您没有为Grid
中的子元素分配单元格值(列和行),它会自动附加到列 0,行 0。
您可以通过使用Grid.ColumnDefinitions
和Grid.RowDefinitions
元素来实现前两步(定义列和行),这两个元素分别包含一个ColumnDefinition
和RowDefinition
元素的集合。网格中的每个单元格都是真实的。NET 对象,因此您可以根据自己的需要配置每个单元格的外观和行为。
这里有一个Grid
定义(你可以在SimpleGrid.xaml
文件中找到)安排你的 UI 内容,如图 25-6 所示:
图 25-6。
行动中的Grid
面板
<Grid ShowGridLines ="True" Background ="LightSteelBlue">
<!-- Define the rows/columns -->
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<!-- Now add the elements to the grid's cells -->
<Label x:Name="lblInstruction" Grid.Column ="0" Grid.Row ="0"
FontSize="15" Content="Enter Car Information"/>
<Button x:Name="btnOK" Height ="30" Grid.Column ="0"
Grid.Row ="0" Content="OK"/>
<Label x:Name="lblMake" Grid.Column ="1"
Grid.Row ="0" Content="Make"/>
<TextBox x:Name="txtMake" Grid.Column ="1"
Grid.Row ="0" Width="193" Height="25"/>
<Label x:Name="lblColor" Grid.Column ="0"
Grid.Row ="1" Content="Color"/>
<TextBox x:Name="txtColor" Width="193" Height="25"
Grid.Column ="0" Grid.Row ="1" />
<!-- Just to keep things interesting, add some color to the pet name cell -->
<Rectangle Fill ="LightGreen" Grid.Column ="1" Grid.Row ="1" />
<Label x:Name="lblPetName" Grid.Column ="1" Grid.Row ="1" Content="Pet Name"/>
<TextBox x:Name="txtPetName" Grid.Column ="1" Grid.Row ="1"
Width="193" Height="25"/>
</Grid>
注意,每个元素(包括一个浅绿的Rectangle
元素)使用Grid.Row
和Grid.Column
附加属性将自己连接到网格中的一个单元格。默认情况下,网格中单元格的排序从左上角开始,这是使用Grid.Column="0" Grid.Row="0"
指定的。假设您的网格总共定义了四个单元格,您可以使用Grid.Column="1" Grid.Row="1"
来标识右下角的单元格。
调整网格列和行的大小
网格中的列和行可以用三种方法之一来调整大小。
-
绝对尺寸(例如,100)
-
自动尺寸监控
-
相对规模(例如,3 倍)
绝对大小正是您所期望的;列(或行)的大小被调整为特定数量的与设备无关的单元。根据列或行中包含的控件自动调整每列或行的大小。相对大小相当于 CSS 中的百分比大小。相对大小的列或行中的数字总数除以可用空间总量。
在以下示例中,第一行获得 25%的空间,第二行获得 75%的空间:
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="3*" />
</Grid.ColumnDefinitions>
具有 GridSplitter 类型的网格
Grid
对象也可以支持拆分器。您可能知道,拆分器允许最终用户调整网格类型的行或列的大小。完成后,每个可调整大小的单元格内的内容将根据项目的包含方式调整自身的形状。向Grid
添加分割器很容易做到;您只需定义GridSplitter
控件,使用附加的属性语法来确定它影响的行或列。
请注意,您必须指定一个Width
或Height
值(取决于垂直或水平拆分),以便拆分器在屏幕上可见。考虑下面这个简单的Grid
类型,在第一列有一个分割器(Grid.Column = "0"
)。提供的GridWithSplitter.xaml
文件的内容如下:
<Grid Background ="LightSteelBlue">
<!-- Define columns -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width ="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<!-- Add this label to cell 0 -->
<Label x:Name="lblLeft" Background ="GreenYellow"
Grid.Column="0" Content ="Left!"/>
<!-- Define the splitter -->
<GridSplitter Grid.Column ="0" Width ="5"/>
<!-- Add this label to cell 1 -->
<Label x:Name="lblRight" Grid.Column ="1" Content ="Right!"/>
</Grid>
首先,请注意将支持拆分器的列有一个Auto
的Width
属性。接下来,请注意,GridSplitter
使用附加的属性语法来建立它正在处理的列。如果您要查看这个输出,您会发现一个五像素的分割器,它允许您调整每个Label
的大小。请注意,内容填满了整个单元格,因为您没有为任何一个Label
指定Height
或Width
属性(参见图 25-7 )。
图 25-7。
Grid
包含拆分器的类型
在 DockPanel 面板中定位内容
DockPanel
通常用作容纳任意数量的附加面板的容器,用于对相关内容进行分组。使用附加属性语法(如Canvas
或Grid
类型所示)来控制每个条目在DockPanel
中的停靠位置。
SimpleDockPanel.xaml
文件定义了以下简单的DockPanel
定义,其输出如图 25-8 所示:
图 25-8。
一个简单的DockPanel
<DockPanel LastChildFill ="True" Background="AliceBlue">
<!-- Dock items to the panel -->
<Label DockPanel.Dock ="Top" Name="lblInstruction" FontSize="15" Content="Enter Car Information"/>
<Label DockPanel.Dock ="Left" Name="lblMake" Content="Make"/>
<Label DockPanel.Dock ="Right" Name="lblColor" Content="Color"/>
<Label DockPanel.Dock ="Bottom" Name="lblPetName" Content="Pet Name"/>
<Button Name="btnOK" Content="OK"/>
</DockPanel>
Note
如果将多个元素添加到DockPanel
的同一侧,它们将按照声明的顺序沿着指定的边缘堆叠。
使用DockPanel
类型的好处是,当用户调整窗口大小时,每个元素保持连接到面板的指定边(通过DockPanel.Dock
)。还要注意,本例中开始的DockPanel
标签将LastChildFill
属性设置为true
。鉴于Button
控件确实是容器中的“最后一个子控件”,因此它将在剩余的空间内被拉伸。
启用面板类型的滚动
值得指出的是,WPF 提供了一个ScrollViewer
类,为面板对象中的数据提供自动滚动行为。SimpleScrollViewer.xaml
文件定义了以下内容:
<ScrollViewer>
<StackPanel>
<Button Content ="First" Background = "Green" Height ="50"/>
<Button Content ="Second" Background = "Red" Height ="50"/>
<Button Content ="Third" Background = "Pink" Height ="50"/>
<Button Content ="Fourth" Background = "Yellow" Height ="50"/>
<Button Content ="Fifth" Background = "Blue" Height ="50"/>
</StackPanel>
</ScrollViewer>
你可以在图 25-9 中看到之前 XAML 定义的结果(注意右边的滚动条,因为窗口的大小没有调整到显示所有五个按钮)。
图 25-9。
使用ScrollViewer
类型
如您所料,每个面板都提供了许多成员,允许您微调内容的位置。与此相关的是,许多 WPF 控件支持两个相关属性(Padding
和Margin
),这两个属性允许控件本身通知面板它希望如何处理。具体来说,Padding
属性控制内部控件周围应该有多少额外空间,而Margin
控制控件外部周围的额外空间。
这就结束了本章对 WPF 主要面板类型的介绍,以及它们放置内容的各种方式。接下来,您将学习如何使用 Visual Studio 设计器创建布局。
使用 Visual Studio 设计器配置面板
现在,您已经大致了解了用于定义一些常见布局管理器的 XAML,您会很高兴地知道 Visual Studio 为构造布局提供了非常好的设计时支持。这样做的关键在于本章前面描述的文档大纲窗口。为了说明一些基础知识,创建一个名为VisualLayoutTester
的新 WPF 应用项目。
注意你的初始Window
是如何默认使用Grid
布局的,如下所示:
<Window x:Class="VisualLayoutTester.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:VisualLayoutTesterApp"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
</Grid>
</Window>
如果你喜欢使用Grid
布局系统,请注意在图 25-10 中,你可以很容易地使用可视化布局来切割和调整网格单元的大小。为此,首先在文档大纲窗口中选择Grid
组件,然后单击网格的边界来创建新的行和列。
图 25-10。
使用 IDE 的设计器可以将Grid
控件直观地切割成单元格
现在,假设您已经定义了一个包含一定数量单元格的网格。然后,您可以将控件拖放到布局系统的给定单元格中,IDE 将自动设置相关控件的Grid.Row
和Grid.Column
属性。下面是将Button
拖动到预定义的单元格中后,IDE 可能生成的一些标记:
<Button x:Name="button" Content="Button" Grid.Column="1" HorizontalAlignment="Left" Margin="21,21.4,0,0" Grid.Row="1" VerticalAlignment="Top" Width="75"/>
现在,让我们假设你宁愿根本不使用Grid
。如果你右击文档大纲窗口中的任何布局节点,你会发现一个菜单选项,允许你将当前容器改变为另一个(见图 25-11 )。请注意,当您这样做时,您将(很可能)从根本上改变控件的位置,因为控件将符合新面板类型的规则。
图 25-11。
“文档大纲”窗口允许您转换到新的面板类型
另一个方便的技巧是能够在可视化设计器上选择一组控件,并将它们分组到一个新的嵌套布局管理器中。假设您有一个包含一组随机对象的Grid
。现在,通过按住 Ctrl 键并用鼠标左键单击每一项来选择设计器上的一组项。如果你右击选择,你可以将选择的项目分组到一个新的子面板中(见图 25-12 )。
图 25-12。
将项目分组到新的子面板中
完成后,再次检查“文档大纲”窗口以验证嵌套布局系统。当您构建功能全面的 WPF 窗口时,您很可能总是需要利用嵌套布局系统,而不是简单地为所有的 UI 显示选择一个面板(事实上,本文中剩余的 WPF 示例通常会这样做)。最后,文档大纲窗口中的节点都是可拖放的。例如,如果你想将一个当前在 DockPanel 中的控件移动到父面板中,你可以如图 25-13 所示那样做。
图 25-13。
通过文档大纲窗口重新定位项目
当你阅读完剩余的 WPF 章节时,我会尽可能指出额外的布局快捷方式。然而,你绝对值得花时间亲自试验和测试各种特性。为了让你朝着正确的方向前进,本章的下一个例子将说明如何为一个定制的文本处理应用构建一个嵌套的布局管理器(带拼写检查!).
使用嵌套面板构建窗口的框架
如上所述,典型的 WPF 窗口不会使用单个面板控件,而是将面板嵌套在其他面板中,以获得所需的布局系统。首先创建一个名为 MyWordPad 的新 WPF 应用。
你的目标是构造一个布局,其中主窗口有一个最上面的菜单系统,一个工具栏在菜单系统下面,一个状态栏安装在窗口的底部。状态栏将包含一个窗格来保存当用户选择菜单项(或工具栏按钮)时显示的文本提示,而菜单系统和工具栏将提供 UI 触发器来关闭应用并在Expander
小部件中显示拼写建议。图 25-14 显示你拍摄的初始布局;它还展示了 WPF 内部的拼写检查功能。
图 25-14。
使用嵌套面板建立窗口的用户界面
要开始构建这个 UI,请更新您的Window
类型的初始 XAML 定义,以便它使用一个DockPanel
子元素,而不是默认的Grid
,如下所示:
<Window x:Class="MyWordPad.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:MyWordPad"
mc:Ignorable="d"
Title="My Spell Checker" Height="450" Width="800">
<!-- This panel establishes the content for the window -->
<DockPanel>
</DockPanel>
</Window>
建立菜单系统
WPF 中的菜单系统由Menu
类表示,它维护一个MenuItem
对象的集合。在 XAML 构建菜单系统时,你可以让每个MenuItem
处理各种事件。这些事件中最值得注意的是Click
,它在最终用户选择一个子项时发生。在本例中,首先构建两个最顶层的菜单项(文件和工具;您将在本示例的后面构建 Edit 菜单),它分别公开 Exit 和拼写提示子项。
除了处理每个子项的Click
事件,您还需要处理MouseEnter
和MouseExit
事件,您将在后面的步骤中使用它们来设置状态栏文本。在您的DockPanel
范围内添加以下标记:
<!-- Dock menu system on the top -->
<Menu DockPanel.Dock ="Top"
HorizontalAlignment="Left" Background="White" BorderBrush ="Black">
<MenuItem Header="_File">
<Separator/>
<MenuItem Header ="_Exit" MouseEnter ="MouseEnterExitArea"
MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header ="_Spelling Hints"
MouseEnter ="MouseEnterToolsHintsArea"
MouseLeave ="MouseLeaveArea" Click ="ToolsSpellingHints_Click"/>
</MenuItem>
</Menu>
注意,您将菜单系统停靠在DockPanel
的顶部。此外,您使用Separator
元素在菜单系统中插入一条细水平线,直接在退出选项之前。还要注意每个MenuItem
的Header
值包含一个嵌入的下划线标记(例如,_Exit
)。您使用这个令牌来建立当最终用户按下 Alt 键(对于键盘快捷键)时哪个字母将被加下划线。这与 Windows 窗体中使用的&字符有所不同,因为 XAML 是基于 XML 的,而&字符在 XML 中有意义。
到目前为止,您已经实现了完整的菜单系统定义;接下来,您需要实现各种事件处理程序。首先,您有一个文件退出处理程序,FileExit_Click()
,它简单地关闭窗口,然后终止应用,因为这是您的最顶层窗口。每个子项的MouseEnter
和MouseExit
事件处理程序将最终更新你的状态栏;然而,现在,您将简单地提供 shells。最后,Tools 拼写提示菜单项的ToolsSpellingHints_Click()
处理程序暂时仍将是一个 shell。以下是您的代码隐藏文件的最新更新(包括更新后的using
语句):
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Win32;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected void FileExit_Click(object sender, RoutedEventArgs args)
{
// Close this window.
this.Close();
}
protected void ToolsSpellingHints_Click(object sender, RoutedEventArgs args)
{
}
protected void MouseEnterExitArea(object sender, RoutedEventArgs args)
{
}
protected void MouseEnterToolsHintsArea(object sender, RoutedEventArgs args)
{
}
protected void MouseLeaveArea(object sender, RoutedEventArgs args)
{
}
}
可视化地构建菜单
虽然知道如何在 XAML 中手动定义项目总是好的,但这可能有点乏味。Visual Studio 支持对菜单系统、工具栏、状态栏和许多其他 UI 控件的可视化设计支持。如果右键单击Menu
控件,您会注意到一个 Add MenuItem 选项。顾名思义,这为Menu
控件添加了一个新的菜单项。添加了一组最上面的项目后,您可以添加子菜单项和分隔符,展开或折叠菜单本身,并通过第二次右键单击执行其他以菜单为中心的操作。
正如您在当前 MyWordPad 示例的剩余部分所看到的,我将典型地向您展示最终生成的 example 然而,一定要花时间与视觉设计者一起试验,以简化手头的任务。
构建工具栏
工具栏(由 WPF 的ToolBar
类表示)通常提供了激活菜单选项的另一种方式。直接在您的Menu
定义的结束范围之后添加以下标记:
<!-- Put Toolbar under the Menu -->
<ToolBar DockPanel.Dock ="Top" >
<Button Content ="Exit" MouseEnter ="MouseEnterExitArea"
MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
<Separator/>
<Button Content ="Check" MouseEnter ="MouseEnterToolsHintsArea"
MouseLeave ="MouseLeaveArea" Click ="ToolsSpellingHints_Click"
Cursor="Help" />
</ToolBar>
您的ToolBar
控件由两个Button
控件组成,这两个控件恰好处理相同的事件,并且在您的代码文件中由相同的方法处理。使用这种技术,您可以将处理程序加倍,以服务于菜单项和工具栏按钮。虽然这个工具栏使用的是典型的按钮,但是你应该意识到ToolBar
类型的“is-a”ContentControl
;因此,您可以自由地将任何类型嵌入其表面(例如,下拉列表、图像和图形)。这里另一个有趣的地方是,复选按钮通过Cursor
属性支持自定义鼠标光标。
Note
您可以选择将ToolBar
元素包装在ToolBarTray
元素中,后者控制一组ToolBar
对象的布局、停靠和拖放操作。
构建状态栏
一个StatusBar
控件将停靠在DockPanel
的下部,并包含一个单独的TextBlock
控件,在本章的这一点之前,您还没有使用过这个控件。您可以使用TextBlock
来保存支持大量文本注释的文本,比如粗体文本、下划线文本、换行符等等。在前面的ToolBar
定义后直接添加以下标记:
<!-- Put a StatusBar at the bottom -->
<StatusBar DockPanel.Dock ="Bottom" Background="Beige" >
<StatusBarItem>
<TextBlock Name="statBarText" Text="Ready"/>
</StatusBarItem>
</StatusBar>
最终确定用户界面设计
UI 设计的最后一个方面是定义一个 splittable Grid
,它定义了两列。在左边,放置一个Expander
控件,它将显示一个拼写建议列表,包裹在一个StackPanel
中。在右边,放置一个支持多行和滚动条并支持拼写检查的TextBox
控件。你将整个Grid
挂载到父DockPanel
的左边。直接在StatusBar
标记下添加以下 XAML 标记,以完成窗口 UI 的定义:
<Grid DockPanel.Dock ="Left" Background ="AliceBlue">
<!-- Define the rows and columns -->
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<GridSplitter Grid.Column ="0" Width ="5" Background ="Gray" />
<StackPanel Grid.Column="0" VerticalAlignment ="Stretch" >
<Label Name="lblSpellingInstructions" FontSize="14" Margin="10,10,0,0">
Spelling Hints
</Label>
<Expander Name="expanderSpelling" Header ="Try these!"
Margin="10,10,10,10">
<!-- This will be filled programmatically -->
<Label Name ="lblSpellingHints" FontSize ="12"/>
</Expander>
</StackPanel>
<!-- This will be the area to type within -->
<TextBox Grid.Column ="1"
SpellCheck.IsEnabled ="True"
AcceptsReturn ="True"
Name ="txtData" FontSize ="14"
BorderBrush ="Blue"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto">
</TextBox>
</Grid>
实现 MouseEnter/MouseLeave 事件处理程序
至此,你的窗口的 UI 就完成了。剩下的唯一任务是为剩下的事件处理程序提供一个实现。首先更新您的 C# 代码文件,以便每个MouseEnter
、MouseLeave
和MouseExit
处理程序用合适的消息设置状态栏的文本窗格,以帮助最终用户,如下所示:
public partial class MainWindow : System.Windows.Window
{
...
protected void MouseEnterExitArea(object sender, RoutedEventArgs args)
{
statBarText.Text = "Exit the Application";
}
protected void MouseEnterToolsHintsArea(object sender, RoutedEventArgs args)
{
statBarText.Text = "Show Spelling Suggestions";
}
protected void MouseLeaveArea(object sender, RoutedEventArgs args)
{
statBarText.Text = "Ready";
}
}
此时,您可以运行您的应用了。你应该看到你的状态栏会根据你鼠标悬停在哪个菜单项/工具栏按钮上来改变它的文本。
实现拼写检查逻辑
WPF API 附带了内置的拼写检查支持,它独立于 Microsoft Office 产品。这意味着您不需要使用 COM 互操作层来使用 Microsoft Word 的拼写检查器;相反,您只需几行代码就可以轻松添加相同类型的支持。
您可能还记得,当您定义TextBox
控件时,您将SpellCheck.IsEnabled
属性设置为true
。当您这样做时,拼写错误的单词会带有红色的下划线,就像它们在 Microsoft Office 中一样。更好的是,底层编程模型允许您访问拼写检查器引擎,该引擎允许您获得拼写错误单词的建议列表。将以下代码添加到您的ToolsSpellingHints_Click()
方法中:
protected void ToolsSpellingHints_Click(object sender, RoutedEventArgs args)
{
string spellingHints = string.Empty;
// Try to get a spelling error at the current caret location.
SpellingError error = txtData.GetSpellingError(txtData.CaretIndex);
if (error != null)
{
// Build a string of spelling suggestions.
foreach (string s in error.Suggestions)
{
spellingHints += $"{s}\n";
}
// Show suggestions and expand the expander.
lblSpellingHints.Content = spellingHints;
expanderSpelling.IsExpanded = true;
}
}
前面的代码非常简单。您只需通过使用CaretIndex
属性提取一个SpellingError
对象来计算出插入符号在文本框中的当前位置。如果在所述位置有错误(意味着值不是null
),您使用恰当命名的Suggestions
属性遍历建议列表。在获得拼写错误单词的所有建议后,将数据连接到Expander
中的Label
。
所以你有它!只有几行程序代码(和适量的 XAML),你就有了一个功能性文字处理器的雏形。理解控制命令可以帮助你增加一点活力。
理解 WPF 命令
WPF 通过命令架构为被认为是控制不可知事件提供支持。典型的。NET Core event 在特定的基类中定义,只能由该类或其派生类使用。所以,正常。NET 核心事件与定义它们的类紧密相关。
相比之下,WPF 命令是独立于特定控件的类似事件的实体,在许多情况下,可以成功地应用于许多(看似不相关的)控件类型。举例来说,WPF 支持复制、粘贴和剪切命令,这些命令可以应用于各种 UI 元素(例如,菜单项、工具栏按钮和自定义按钮),以及键盘快捷键(例如,Ctrl+C 和 Ctrl+V)。
虽然其他 UI 工具包(如 Windows 窗体)为此提供了标准事件,但使用它们通常会留下冗余且难以维护的代码。在 WPF 模式下,您可以使用命令作为替代方法。最终结果通常会产生一个更小、更灵活的代码库。
内在命令对象
WPF 附带了许多内建的控制命令,您可以使用相关的键盘快捷键(或其他输入手势)来配置所有这些命令。从编程角度来说,WPF 命令是支持属性(通常称为Command
)的任何对象,该属性返回实现ICommand
接口的对象,如下所示:
public interface ICommand
{
// Occurs when changes occur that affect whether
// or not the command should execute.
event EventHandler CanExecuteChanged;
// Defines the method that determines whether the command
// can execute in its current state.
bool CanExecute(object parameter);
// Defines the method to be called when the command is invoked.
void Execute(object parameter);
}
WPF 提供了各种命令类,开箱即用,暴露了近 100 个命令对象。这些类定义了许多公开特定命令对象的属性,每个属性都实现了ICommand
。表 25-3 记录了一些可用的标准命令对象。
表 25-3。
固有的 WPF 控制命令对象
|WPF 级
|
命令对象
|
生命的意义
|
| — | — | — |
| ApplicationCommands
| Close
、Copy
、Cut
、Delete
、Find
、Open
、Paste
、Save
、SaveAs
、Redo
、Undo
| 各种应用级命令 |
| ComponentCommands
| MoveDown
、MoveFocusBack
、MoveLeft
、MoveRight
、ScrollToEnd
、ScrollToHome
| UI 组件通用的各种命令 |
| MediaCommands
| BoostBase
、ChannelUp
、ChannelDown
、FastForward
、NextTrack
、Play
、Rewind
、Select
、Stop
| 各种以媒体为中心的命令 |
| NavigationCommands
| BrowseBack
、BrowseForward
、Favorites
、LastPage
、NextPage
、Zoom
| 与 WPF 导航模型相关的各种命令 |
| EditingCommands
| AlignCenter
、CorrectSpellingError
、DecreaseFontSize
、EnterLineBreak
、EnterParagraphBreak
、MoveDownByLine
、MoveRightByWord
| 与 WPF 文档 API 相关的各种命令 |
将命令连接到命令特性
如果您想将任何 WPF 命令属性连接到支持Command
属性的 UI 元素(例如Button
或MenuItem
),您只需做很少的工作。您可以通过更新当前的菜单系统来了解如何做到这一点,这样它就支持一个名为 Edit 的新的最顶层菜单项和三个子菜单项,用于复制、粘贴和剪切文本数据,如下所示:
<Menu DockPanel.Dock ="Top" HorizontalAlignment="Left" Background="White" BorderBrush ="Black">
<MenuItem Header="_File" Click ="FileExit_Click" >
<MenuItem Header ="_Exit" MouseEnter ="MouseEnterExitArea" MouseLeave ="MouseLeaveArea"
Click ="FileExit_Click"/>
</MenuItem>
<!-- New menu item with commands! -->
<MenuItem Header="_Edit">
<MenuItem Command ="ApplicationCommands.Copy"/>
<MenuItem Command ="ApplicationCommands.Cut"/>
<MenuItem Command ="ApplicationCommands.Paste"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header ="_Spelling Hints"
MouseEnter ="MouseEnterToolsHintsArea"
MouseLeave ="MouseLeaveArea"
Click ="ToolsSpellingHints_Click"/>
</MenuItem>
</Menu>
注意,编辑菜单上的每个子项都有一个分配给Command
属性的值。这样做意味着菜单项在菜单项 UI 中自动接收正确的名称和快捷键(例如,对于剪切操作,Ctrl+C );这也意味着应用现在可以复制、剪切和粘贴了,不需要任何程序代码!
如果您运行应用并选择一些文本,您就可以开箱即用地使用新菜单项。另外,您的应用还可以响应标准的右键单击操作,为用户提供相同的选项。
将命令连接到任意动作
如果您想要将一个命令对象连接到一个任意的(特定于应用的)事件,您将需要下拉到过程代码。这样做并不复杂,但它涉及的逻辑比你在 XAML 看到的要多一点。例如,假设您希望整个窗口都响应 F1 键,这样当最终用户按下该键时,他将激活相关的帮助系统。此外,假设主窗口的代码文件定义了一个名为SetF1CommandBinding()
的新方法,在调用InitializeComponent()
之后,在构造函数中调用该方法。
public MainWindow()
{
InitializeComponent();
SetF1CommandBinding();
}
这个新方法将以编程方式创建一个新的CommandBinding
对象,当您需要将命令对象绑定到应用中的给定事件处理程序时,就可以使用这个对象。在这里,您配置您的CommandBinding
对象来使用ApplicationCommands.Help
命令操作,它自动感知 F1:
private void SetF1CommandBinding()
{
CommandBinding helpBinding = new CommandBinding(ApplicationCommands.Help);
helpBinding.CanExecute += CanHelpExecute;
helpBinding.Executed += HelpExecuted;
CommandBindings.Add(helpBinding);
}
大多数CommandBinding
对象想要处理CanExecute
事件(允许您根据程序的操作指定命令是否发生)和Executed
事件(在这里您可以创作命令发生时应该发生的内容)。将以下事件处理程序添加到您的Window
派生类型中(根据相关委托的要求,注意每个方法的格式):
private void CanHelpExecute(object sender, CanExecuteRoutedEventArgs e)
{
// Here, you can set CanExecute to false if you want to prevent the command from executing.
e.CanExecute = true;
}
private void HelpExecuted(object sender, ExecutedRoutedEventArgs e)
{
MessageBox.Show("Look, it is not that difficult. Just type something!", "Help!");
}
在前面的代码片段中,您实现了CanHelpExecute()
,因此它总是允许 F1 帮助启动;您只需返回true
就可以做到这一点。但是,如果在某些情况下,帮助系统不应该显示,您可以说明这一点,并在必要时返回false
。您在HelpExecuted()
中显示的“帮助系统”只不过是一个消息框。此时,您可以运行您的应用了。当您按下键盘上的 F1 键时,您将看到消息框出现。
使用打开和保存命令
为了完成当前示例,您将添加将文本数据保存到外部文件并打开*.txt
文件进行编辑的功能。如果您想走长路,您可以手动添加编程逻辑,根据您的TextBox
中是否有数据来启用或禁用新的菜单项。然而,您可以再次使用命令来减轻您的负担。
首先,通过添加下面两个使用Save
和Open ApplicationCommands
对象的新子菜单,更新代表最顶层文件菜单的MenuItem
元素:
<MenuItem Header="_File">
<MenuItem Command ="ApplicationCommands.Open"/>
<MenuItem Command ="ApplicationCommands.Save"/>
<Separator/>
<MenuItem Header ="_Exit"
MouseEnter ="MouseEnterExitArea"
MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
</MenuItem>
同样,记住所有的命令对象都实现了ICommand
接口,该接口定义了两个事件(CanExecute
和Executed
)。现在,您需要启用整个窗口,以便它可以检查当前是否可以启动这些命令;如果是这样,您可以定义一个事件处理程序来执行自定义代码。
您可以通过填充由窗口维护的CommandBindings
集合来做到这一点。在 XAML 这样做需要使用属性元素语法来定义一个Window.CommandBindings
作用域,在其中放置两个CommandBinding
定义。像这样更新你的Window
:
<Window x:Class="MyWordPad.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MySpellChecker" Height="331" Width="508"
WindowStartupLocation ="CenterScreen" >
<!-- This will inform the Window which handlers to call,
when testing for the Open and Save commands. -->
<Window.CommandBindings>
<CommandBinding Command="ApplicationCommands.Open"
Executed="OpenCmdExecuted"
CanExecute="OpenCmdCanExecute"/>
<CommandBinding Command="ApplicationCommands.Save"
Executed="SaveCmdExecuted"
CanExecute="SaveCmdCanExecute"/>
</Window.CommandBindings>
<!-- This panel establishes the content for the window -->
<DockPanel>
...
</DockPanel>
</Window>
现在右键单击 XAML 编辑器中的每个Executed
和CanExecute
属性,并选择导航到事件处理程序菜单选项。你可能还记得第二十四章中的内容,这将自动为事件本身生成存根代码。此时,窗口的 C# 代码文件中应该有四个空处理程序。
CanExecute
事件处理程序的实现将告诉窗口,通过设置传入的CanExecuteRoutedEventArgs
对象的CanExecute
属性,可以随时触发相应的Executed
事件。
private void OpenCmdCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = true;
}
private void SaveCmdCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = true;
}
相应的Executed
处理程序执行显示打开和保存对话框的实际工作;他们还将你的TextBox
中的数据发送到一个文件中。首先确保将System.IO
和Microsoft.Win32
名称空间导入到代码文件中。以下完整的代码非常简单:
private void OpenCmdExecuted(object sender, ExecutedRoutedEventArgs e)
{
// Create an open file dialog box and only show XAML files.
var openDlg = new OpenFileDialog { Filter = "Text Files |*.txt"};
// Did they click on the OK button?
if (true == openDlg.ShowDialog())
{
// Load all text of selected file.
string dataFromFile = File.ReadAllText(openDlg.FileName);
// Show string in TextBox.
txtData.Text = dataFromFile;
}
}
private void SaveCmdExecuted(object sender, ExecutedRoutedEventArgs e)
{
var saveDlg = new SaveFileDialog { Filter = "Text Files |*.txt"};
// Did they click on the OK button?
if (true == saveDlg.ShowDialog())
{
// Save data in the TextBox to the named file.
File.WriteAllText(saveDlg.FileName, txtData.Text);
}
}
Note
第二十八章将会对 WPF 的指挥系统进行更深入的研究。在其中,您将创建基于ICommand
和RelayCommands
的定制命令。
这就结束了这个例子,以及您对使用 WPF 控件的初步了解。在这里,您学习了如何使用基本命令、菜单系统、状态栏、工具栏、嵌套面板和一些基本的 UI 控件,如TextBox
和Expander
。下一个示例将使用一些更奇特的控件,同时检查几个重要的 WPF 服务。
了解路由事件
您可能已经注意到了前面代码示例中的参数RoutedEventArgs
而不是EventArgs
。路由事件模型是标准 CLR 事件模型的改进,旨在确保事件能够以适合 XAML 的对象树描述的方式进行处理。假设您有一个名为 WpfRoutedEvents 的新 WPF 应用项目。现在,通过添加下面的Button
控件来更新初始窗口的 XAML 描述,该控件定义了一些复杂的内容:
<Window ...
<Grid>
<Button Name="btnClickMe" Height="75" Width = "250"
Click ="btnClickMe_Clicked">
<StackPanel Orientation ="Horizontal">
<Label Height="50" FontSize ="20">
Fancy Button!</Label>
<Canvas Height ="50" Width ="100" >
<Ellipse Name = "outerEllipse" Fill ="Green"
Height ="25" Width ="50" Cursor="Hand"
Canvas.Left="25" Canvas.Top="12"/>
<Ellipse Name = "innerEllipse" Fill ="Yellow"
Height = "15" Width ="36"
Canvas.Top="17" Canvas.Left="32"/>
</Canvas>
</StackPanel>
</Button>
</Grid>
</Window>
注意在Button
的开始定义中,您已经通过指定当事件被引发时要调用的方法的名称来处理了Click
事件。Click
事件与RoutedEventHandler
委托一起工作,该委托期望一个事件处理程序将object
作为第一个参数,将System.Windows.RoutedEventArgs
作为第二个参数。按如下方式实现该处理程序:
public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
{
// Do something when button is clicked.
MessageBox.Show("Clicked the button");
}
如果您运行您的应用,您将看到这个消息框显示,不管您单击按钮内容的哪一部分(绿色的Ellipse
、黄色的Ellipse
、Label
或Button
的表面)。这是一件好事。想象一下,如果您被迫为这些子元素中的每一个处理一个Click
事件,那么 WPF 事件处理会有多乏味。不仅为Button
的每个方面创建单独的事件处理程序需要耗费大量的劳动,而且最终还会有一些令人讨厌的代码需要维护。
幸运的是,WPF 路由事件负责确保无论按钮的哪个部分被自动点击,您的单个Click
事件处理程序都会被调用。简单地说,路由事件模型自动地沿着对象树向上(或向下)传播事件,寻找合适的处理程序。
具体来说,一个路由事件可以利用三种路由策略。如果一个事件从原点向上移动到对象树中的其他定义范围,该事件被称为冒泡事件。相反,如果事件从最外面的元素(例如,a Window
)向下移动到原点,则该事件被称为隧道事件。最后,如果一个事件仅由发起元素引发和处理(这可以被描述为一个普通的 CLR 事件),则称之为直接事件。
路由冒泡事件的角色
在当前的例子中,如果用户点击内部的黄色椭圆,Click
事件会冒泡到下一级作用域(??),然后到StackPanel
,最后到Button
,在那里处理Click
事件处理程序。同样,如果用户点击Label
,事件会冒泡到StackPanel
,最后到Button
元素。
考虑到这种冒泡路由事件模式,您不必担心为复合控件的所有成员注册特定的Click
事件处理程序。但是,如果您想要为同一个对象树中的多个元素执行定制的单击逻辑,您可以这样做。
举例来说,假设您需要以一种独特的方式处理outerEllipse
控件的点击。首先,处理这个子元素的MouseDown
事件(图形呈现类型,如Ellipse
不支持Click
事件;但是,他们可以通过MouseDown
、MouseUp
等监控鼠标按键活动。).
<Button Name="btnClickMe" Height="75" Width = "250"
Click ="btnClickMe_Clicked">
<StackPanel Orientation ="Horizontal">
<Label Height="50" FontSize ="20">Fancy Button!</Label>
<Canvas Height ="50" Width ="100" >
<Ellipse Name = "outerEllipse" Fill ="Green"
Height ="25" MouseDown ="outerEllipse_MouseDown"
Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>
<Ellipse Name = "innerEllipse" Fill ="Yellow" Height = "15" Width ="36"
Canvas.Top="17" Canvas.Left="32"/>
</Canvas>
</StackPanel>
</Button>
然后实现一个适当的事件处理程序,为了便于说明,它将简单地改变主窗口的Title
属性,如下所示:
public void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
{
// Change title of window.
this.Title = "You clicked the outer ellipse!";
}
这样,您现在可以根据最终用户点击的位置(归结为外部椭圆和按钮范围内的任何地方)采取不同的操作过程。
Note
路由冒泡事件总是从原点移动到下一个定义范围的*。因此,在本例中,如果您单击innerEllipse
对象,事件将冒泡到Canvas
,而不是到outerEllipse
,因为它们都是Canvas
范围内的Ellipse
类型。*
继续或停止冒泡
目前,如果用户点击outerEllipse
对象,它将触发这个Ellipse
对象的注册的MouseDown
事件处理程序,此时事件冒泡到按钮的Click
事件。如果您想通知 WPF 停止冒泡对象树,您可以将参数EventArgs
的Handled
属性设置为true
,如下所示:
public void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
{
// Change title of window.
this.Title = "You clicked the outer ellipse!";
// Stop bubbling!
e.Handled = true;
}
在这种情况下,您会发现窗口的标题发生了变化,但是您不会看到由Button
的Click
事件处理程序显示的MessageBox
。简而言之,路由冒泡事件使得一组复杂的内容既可以作为单个逻辑元素(例如一个Button
)也可以作为离散的项目(例如Button
中的一个Ellipse
)。
路由隧道事件的角色
严格地说,路由事件本质上可以是冒泡(如前所述)或隧道。隧道事件(都以Preview
后缀开始;例如PreviewMouseDown
)从最顶端的元素向下钻至对象树的内部范围。总的来说,WPF 基类库中的每个冒泡事件都与一个相关的隧道事件成对出现,该事件在冒泡事件之前触发。例如,在冒泡MouseDown
事件触发之前,隧道PreviewMouseDown
事件首先触发。
处理隧道事件看起来就像处理任何其他事件一样;只需在 XAML 中指定事件处理程序名称(或者,如果需要,在代码文件中使用相应的 C# 事件处理语法)并在代码文件中实现该处理程序。为了说明隧道和冒泡事件的相互作用,首先处理outerEllipse
对象的PreviewMouseDown
事件,如下所示:
<Ellipse Name = "outerEllipse" Fill ="Green" Height ="25"
MouseDown ="outerEllipse_MouseDown"
PreviewMouseDown ="outerEllipse_PreviewMouseDown"
Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>
接下来,通过更新每个事件处理程序(针对所有对象)来改进当前的 C# 类定义,使用传入的事件args
对象将关于当前事件的数据追加到名为mouseActivity
的string
成员变量中。这将允许您观察在后台触发的事件流。
public partial class MainWindow : Window
{
string _mouseActivity = string.Empty;
public MainWindow()
{
InitializeComponent();
}
public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
{
AddEventInfo(sender, e);
MessageBox.Show(_mouseActivity, "Your Event Info");
// Clear string for next round.
_mouseActivity = "";
}
private void AddEventInfo(object sender, RoutedEventArgs e)
{
_mouseActivity += string.Format(
"{0} sent a {1} event named {2}.\n", sender,
e.RoutedEvent.RoutingStrategy,
e.RoutedEvent.Name);
}
private void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
{
AddEventInfo(sender, e);
}
private void outerEllipse_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
AddEventInfo(sender, e);
}
}
请注意,您没有停止任何事件处理程序的事件冒泡。如果您运行这个应用,您将看到一个独特的消息框,它基于您单击按钮的位置而显示。图 25-15 显示了点击外部Ellipse
对象的结果。
图 25-15。
先挖隧道,后冒泡
那么,为什么 WPF 事件通常成对出现(一个隧穿,一个冒泡)?答案是,通过预览事件,您可以执行任何特殊的逻辑(数据验证、禁用冒泡操作等。)在冒泡的对应物火起来之前。举例来说,假设您有一个应该只包含数字数据的TextBox
。您可以处理PreviewKeyDown
事件,如果您看到用户输入了非数字数据,您可以通过将Handled
属性设置为true
来取消冒泡事件。
正如您所猜测的,当您构建一个包含自定义事件的自定义控件时,您可以以这样一种方式创作事件,使其能够冒泡(或隧道)穿过 XAML 树。出于本章的目的,我将不研究如何构建自定义路由事件(然而,该过程与构建自定义依赖属性没有太大的不同)。如果您有兴趣,请查看。NET Framework 4.7 SDK 文档。在这本书里,你会找到很多对你有帮助的教程。
深入了解 WPF API 和控件
本章的剩余部分将让你有机会使用 Visual Studio 构建一个新的 WPF 应用。目标是创建一个由包含一组选项卡的TabControl
小部件组成的 UI。每个选项卡将说明一些新的 WPF 控件和有趣的 API,您可能希望在您的软件项目中使用它们。在此过程中,您还将了解 Visual Studio WPF 设计器的其他功能。
使用 TabControl
首先,创建一个名为 WpfControlsAndAPIs 的新 WPF 应用。如上所述,您的初始窗口将包含一个带有三个不同选项卡的TabControl
,每个选项卡显示一组相关的控件和/或 WPF API。将窗口的Width
更新为 800,将Height
更新为 350。
在 Visual Studio 工具箱中找到TabControl
控件,将其放到您的设计器上,并将标记更新为以下内容:
<TabControl Name="MyTabControl" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<TabItem Header="TabItem">
<Grid Background="#FFE5E5E5"/>
</TabItem>
<TabItem Header="TabItem">
<Grid Background="#FFE5E5E5"/>
</TabItem>
</TabControl>
您会注意到,系统会自动为您提供两个选项卡项目。要添加额外的选项卡,您只需右键单击文档大纲窗口中的TabControl
节点,并选择添加TabItem
菜单选项(您也可以右键单击设计器上的TabControl
来激活相同的菜单选项),或者只需在 XAML 编辑器中开始键入。使用任一方法添加一个额外的选项卡。
现在,通过 XAML 编辑器更新每个TabItem
控件,并更改每个选项卡的Header
属性,将它们命名为Ink API
、Data Binding
和DataGrid
。此时,你的窗口设计器应该如图 25-16 所示。
图 25-16。
标签系统的初始布局
请注意,当您选择一个选项卡进行编辑时,该选项卡将成为活动选项卡,您可以通过从“工具箱”窗口中拖动控件来设计该选项卡。既然已经定义了核心内容TabControl
,您可以一个标签一个标签地研究细节,并在此过程中了解 WPF API 的更多特性。
构建 Ink API 选项卡
第一个选项卡将用于显示 WPF 的数字墨水 API 的整体作用,它允许您轻松地将绘画功能合并到程序中。当然,该应用并不一定是绘画应用;您可以将这个 API 用于多种用途,包括捕获手写输入。
Note
对于这一章的其余大部分(以及接下来的 WPF 章节),我将直接编辑 XAML,而不是使用各种设计器窗口。虽然控件的拖放工作正常,但通常布局不是您想要的(Visual Studio 会根据您放置控件的位置添加边距和填充),而且您无论如何都要花费大量时间来清理 XAML。
首先将 Ink API TabItem
下的Grid
标签改为StackPanel
,并添加一个结束标签(确保从开始标签中删除"/"
)。您的标记应该如下所示:
<TabItem Header="Ink API">
<StackPanel Background="#FFE5E5E5">
</StackPanel>
</TabItem>
设计工具栏
添加一个新的ToolBar
控件到 StackPanel(使用 XAML 编辑器),名为InkToolbar
,高度为60
.<ToolBar Name="InkToolBar" Height="60">
</ToolBar>
将三个RadioButton
控件添加到ToolBar
的WrapPanel
控件和Border
控件内,如下所示:
<Border Margin="0,2,0,2.4" Width="280" VerticalAlignment="Center">
<WrapPanel>
<RadioButton x:Name="inkRadio" Margin="5,10" Content="Ink Mode!" IsChecked="True" />
<RadioButton x:Name="eraseRadio" Margin="5,10" Content="Erase Mode!" />
<RadioButton x:Name="selectRadio" Margin="5,10" Content="Select Mode!" />
</WrapPanel>
</Border>
当RadioButton
控件不在父面板控件中时,它将呈现与Button
控件相同的 UI!这就是为什么我把RadioButton
控件包在WrapPanel
里的原因。
接下来,添加一个Separator
,然后添加一个ComboBox
,其Width
为 175,Margin
为 10,0,0,0。添加三个内容为Red
、Green
和Blue
的ComboBoxItem
标签,并在整个ComboBox
之后添加另一个Separator
控件,如下所示:
<Separator/>
<ComboBox x:Name="comboColors" Width="175" Margin="10,0,0,0">
<ComboBoxItem Content="Red"/>
<ComboBoxItem Content="Green"/>
<ComboBoxItem Content="Blue"/>
</ComboBox>
<Separator/>
单选按钮控件
在本例中,您希望这三个RadioButton
控件互斥。在其他 GUI 框架中,要确保一组相关的控件(比如单选按钮)是互斥的,就需要将它们放在同一个分组框中。在 WPF 时代,你不需要这么做。相反,你可以简单地将它们分配到同一个组名。这是有帮助的,因为相关的项目不需要被物理地收集在相同的区域中,而是可以在窗口中的任何地方。
RadioButton
类包含一个IsChecked
属性,当最终用户点击 UI 元素时,该属性在true
和false
之间切换。此外,RadioButton
提供了两个事件(Checked
和Unchecked
),您可以使用它们来拦截这种状态变化。
添加保存、加载和删除按钮
ToolBar
控件中的最终控件将是一个拥有三个Button
控件的Grid
。在最后一个Separator
控件后添加以下标记:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" x:Name="btnSave" Margin="10,10" Width="70" Content="Save Data"/>
<Button Grid.Column="1" x:Name="btnLoad" Margin="10,10" Width="70" Content="Load Data"/>
<Button Grid.Column="2" x:Name="btnClear" Margin="10,10" Width="70" Content="Clear"/>
</Grid>
添加 InkCanvas 控件
TabControl
的最终控制是InkCanvas
控制。在结束ToolBar
标签之后和结束StackPanel
标签之前添加以下标记,如下所示:
<InkCanvas x:Name="MyInkCanvas" Background="#FFB6F4F1" />
预览窗口
此时,您已经准备好测试程序了,这可以通过按 F5 键来完成。你现在应该看到三个互斥的单选按钮,一个有三个选项的组合框,和三个按钮(见图 25-17 )。
图 25-17。
Ink API 选项卡的完整布局
处理 Ink API 选项卡的事件
Ink API 选项卡的下一步是处理每个RadioButton
控件的Click
事件。正如您在本书的其他 WPF 项目中所做的那样,只需单击 Visual Studio 属性编辑器的闪电图标,输入事件处理程序的名称。使用这种方法,将每个按钮的Click
事件路由到同一个处理程序,名为RadioButtonClicked
。处理完所有三个Click
事件后,使用名为ColorChanged()
的处理程序处理ComboBox
的SelectionChanged
事件。完成后,您应该会看到下面的 C# 代码:
public partial class MainWindow : Window
{
public MainWindow()
{
this.InitializeComponent();
// Insert code required on object creation below this point.
}
private void RadioButtonClicked(object sender,RoutedEventArgs e)
{
// TODO: Add event handler implementation here.
}
private void ColorChanged(object sender,SelectionChangedEventArgs e)
{
// TODO: Add event handler implementation here.
}
}
您将在后面的步骤中实现这些处理程序,所以暂时让它们为空。
向工具箱添加控件
您通过直接编辑 XAML 添加了一个InkCanvas
控件。如果你想使用 UI 来添加它,Visual Studio 工具箱默认情况下不会而不是向你显示每一个可能的 WPF 组件。但是您可以更新工具箱中显示的项。
为此,右键单击工具箱区域中的任意位置,然后选择“选择项”菜单选项。过一会儿,您会看到一个可能要添加到工具箱的组件列表。出于您的目的,您对添加InkCanvas
控件感兴趣(参见图 25-18 )。
图 25-18。
向 Visual Studio 工具箱添加新组件
Note
墨迹控件与 16.8.3 版(撰写本文时的当前版本)或 Visual Studio 16.9 预览版 2 中的 Visual Studio XAML 设计器不兼容。这些控件仍然可以使用,只是不能通过设计器使用。
InkCanvas 控件
只需添加InkCanvas
就可以在你的窗口中绘图。您可以使用鼠标,或者如果您有支持触摸的设备,也可以使用手指或数字化笔。运行应用并绘制到框中(参见图 25-19 )。
图 25-19。
InkCanvas
在行动
InkCanvas
不仅仅是画鼠标(或手写笔)的笔画;它还支持许多独特的编辑模式,由EditingMode
属性控制。您可以从相关的InkCanvasEditingMode
枚举中为该属性赋值。对于这个例子,你感兴趣的是Ink
模式,这是你刚才目睹的默认选项;Select
模式,允许用户用鼠标选择一个区域进行移动或调整大小;以及EraseByStoke
,将删除之前的鼠标笔画。
Note
一个笔划是在单个鼠标按下/鼠标抬起操作期间发生的渲染。InkCanvas
将所有笔画存储在一个StrokeCollection
对象中,您可以使用Strokes
属性访问该对象。
根据所选的RadioButton
,用以下逻辑更新您的RadioButtonClicked()
处理程序,将InkCanvas
置于正确的模式:
private void RadioButtonClicked(object sender,RoutedEventArgs e)
{
// Based on which button sent the event, place the InkCanvas in a unique
// mode of operation.
this.MyInkCanvas.EditingMode = (sender as RadioButton)?.Content.ToString() switch
{
// These strings must be the same as the Content values for each
// RadioButton.
"Ink Mode!" => InkCanvasEditingMode.Ink,
"Erase Mode!" => InkCanvasEditingMode.EraseByStroke,
"Select Mode!" => InkCanvasEditingMode.Select,
_ => this.MyInkCanvas.EditingMode
};
}
此外,在窗口的构造函数中默认设置模式为Ink
。同时,为ComboBox
设置一个默认选择(下一节将详细介绍这个控件),如下所示:
public MainWindow()
{
this.InitializeComponent();
// Be in Ink mode by default.
this.MyInkCanvas.EditingMode = InkCanvasEditingMode.Ink;
this.inkRadio.IsChecked = true;
this.comboColors.SelectedIndex = 0;
}
现在,按 F5 再次运行您的程序。进入墨迹模式,画一些数据。接下来,进入擦除模式,删除之前输入的鼠标笔划(您会注意到鼠标图标自动看起来像橡皮擦)。最后进入选择模式,用鼠标当套索选择一些笔画。
圈出项目后,您可以在画布上移动它并调整其尺寸。图 25-20 显示了您工作时的编辑模式。
图 25-20。
动作中的InkCanvas
,带编辑模式!
ComboBox 控件
在您填充了一个ComboBox
控件(或者一个ListBox
)之后,您有三种方法来确定所选择的项目。首先,如果你想找到所选项目的数字索引,你可以使用SelectedIndex
属性(它是从零开始的;值-1
表示没有选择)。第二,如果您想获得列表中已被选中的对象,SelectedItem
属性符合要求。第三,SelectedValue
允许您获取所选对象的值(通常通过调用ToString()
来获取)。
您需要为这个选项卡添加最后一点代码,以更改在InkCanvas
上输入的笔画的颜色。InkCanvas
的DefaultDrawingAttributes
属性返回一个DrawingAttributes
对象,允许您配置笔尖的许多方面,包括它的大小和颜色(以及其他设置)。用这个ColorChanged()
方法的实现更新你的 C# 代码:
private void ColorChanged(object sender, SelectionChangedEventArgs e)
{
// Get the selected value in the combo box.
string colorToUse =
(this.comboColors.SelectedItem as ComboBoxItem)?.Content.ToString();
// Change the color used to render the strokes.
this.MyInkCanvas.DefaultDrawingAttributes.Color =
(Color)ColorConverter.ConvertFromString(colorToUse);
}
现在回想一下,ComboBox
有一个ComboBoxItems
的集合。如果查看生成的 XAML,您会看到以下定义:
<ComboBox x:Name="comboColors" Width="100" SelectionChanged="ColorChanged">
<ComboBoxItem Content="Red"/>
<ComboBoxItem Content="Green"/>
<ComboBoxItem Content="Blue"/>
</ComboBox>
当你调用SelectedItem
时,你抓取选中的ComboBoxItem
,它被存储为一个通用的Object
。将Object
转换为ComboBoxItem
后,取出Content
的值,它将是字符串Red
、Green
或Blue
。然后使用便利的ColorConverter
实用程序类将这个string
转换成一个Color
对象。现在再次运行你的程序。渲染图像时,您应该能够在颜色之间进行切换。
注意,ComboBox
和ListBox
控件也可以包含复杂的内容,而不是文本数据列表。您可以通过打开您窗口的 XAML 编辑器并更改您的ComboBox
的定义来了解一些可能的事情,因此它包含一组StackPanel
元素,每个元素包含一个Ellipse
和一个Label
(注意ComboBox
的Width
是175
)。
<ComboBox x:Name="comboColors" Width="175" Margin=”10,0,0,0” SelectionChanged="ColorChanged">
<StackPanel Orientation ="Horizontal" Tag="Red">
<Ellipse Fill ="Red" Height ="50" Width ="50"/>
<Label FontSize ="20" HorizontalAlignment="Center"
VerticalAlignment="Center" Content="Red"/>
</StackPanel>
<StackPanel Orientation ="Horizontal" Tag="Green">
<Ellipse Fill ="Green" Height ="50" Width ="50"/>
<Label FontSize ="20" HorizontalAlignment="Center"
VerticalAlignment="Center" Content="Green"/>
</StackPanel>
<StackPanel Orientation ="Horizontal" Tag="Blue">
<Ellipse Fill ="Blue" Height ="50" Width ="50"/>
<Label FontSize ="20" HorizontalAlignment="Center"
VerticalAlignment="Center" Content="Blue"/>
</StackPanel>
</ComboBox>
请注意,每个StackPanel
都为它的Tag
属性赋值,这是一种简单、快速、方便的方法来发现用户选择了哪一堆项目(有更好的方法可以做到这一点,但这只是暂时的)。通过这种调整,您需要更改您的ColorChanged()
方法的实现,就像这样:
private void ColorChanged(object sender, SelectionChangedEventArgs e)
{
// Get the Tag of the selected StackPanel.
string colorToUse = (this.comboColors.SelectedItem
as StackPanel).Tag.ToString();
...
}
现在再次运行你的程序并记录下你的独特的ComboBox
(见图 25-21 )。
图 25-21。
自定义ComboBox
,感谢 WPF 内容模型
保存、加载和清除 InkCanvas 数据
该选项卡的最后一部分将使您能够保存和加载画布数据,以及通过为工具栏中的按钮添加事件处理程序来清除所有内容。通过为单击事件添加标记来更新按钮的 XAML,如下所示:
<Button Grid.Column="0" x:Name="btnSave" Margin="10,10" Width="70" Content="Save Data" Click="SaveData"/>
<Button Grid.Column="1" x:Name="btnLoad" Margin="10,10" Width="70" Content="Load Data" Click="LoadData"/>
<Button Grid.Column="2" x:Name="btnClear" Margin="10,10" Width="70" Content="Clear" Click="Clear"/>
接下来,将System.IO
和System.Windows.Ink
名称空间导入到代码文件中。实现处理程序,如下所示:
private void SaveData(object sender, RoutedEventArgs e)
{
// Save all data on the InkCanvas to a local file.
using (FileStream fs = new FileStream("StrokeData.bin", FileMode.Create))
this.MyInkCanvas.Strokes.Save(fs);
fs.Close();
MessageBox.Show("Image Saved","Saved");
}
private void LoadData(object sender, RoutedEventArgs e)
{
// Fill StrokeCollection from file.
using(FileStream fs = new FileStream("StrokeData.bin", FileMode.Open, FileAccess.Read))
StrokeCollection strokes = new StrokeCollection(fs);
this.MyInkCanvas.Strokes = strokes;
}
private void Clear(object sender, RoutedEventArgs e)
{
// Clear all strokes.
this.MyInkCanvas.Strokes.Clear();
}
现在,您应该能够将您的数据保存到一个文件中,从文件中加载它,并清除所有数据的InkCanvas
。这就结束了TabControl
的第一个选项卡,以及您对 WPF 数字墨水 API 的检查。可以肯定的是,关于这项技术还有更多要说的;然而,如果你对这个话题感兴趣的话,你应该能够更深入地挖掘。接下来,您将学习如何使用 WPF 数据绑定。
介绍 WPF 数据绑定模型
控件通常是各种数据绑定操作的目标。简而言之,数据绑定是将控件属性连接到数据值的行为,这些数据值可能会在应用的生命周期中发生变化。这样做可以让用户界面元素显示代码中变量的状态。例如,您可以使用数据绑定来完成以下任务:
-
基于给定对象的布尔属性检查
CheckBox
控件。 -
显示来自关系数据库表的
DataGrid
对象中的数据。 -
将一个
Label
连接到一个表示文件夹中文件数量的整数。
当您使用固有的 WPF 数据绑定引擎时,您必须注意绑定操作的源和目的地之间的区别。如您所料,数据绑定操作的源是数据本身(例如,布尔属性或关系数据),而目的地(目标)是使用数据内容的 UI 控件属性(例如,CheckBox
或TextBox
控件上的属性)。
除了绑定到传统数据,WPF 还支持元素绑定,如前面的示例所述。这意味着您可以根据复选框的 checked 属性绑定(例如)属性的可见性。您当然可以在 WinForms 中做到这一点,但这必须通过代码来完成。WPF 框架提供了一个丰富的数据绑定生态系统,几乎可以完全用标记来处理。这也使您能够确保源和目标在它们的任何一个值发生变化时保持同步。
构建数据绑定选项卡
使用文档大纲编辑器,将第二个选项卡的Grid
更改为StackPanel
。现在,使用 Visual Studio 的工具箱和属性编辑器构建以下初始布局:
<TabItem x:Name="tabDataBinding" Header="Data Binding">
<StackPanel Width="250">
<Label Content="Move the scroll bar to see the current value"/>
<!-- The scrollbar's value is the source of this data bind. -->
<ScrollBar x:Name="mySB" Orientation="Horizontal" Height="30"
Minimum = "1" Maximum = "100" LargeChange="1" SmallChange="1"/>
<!-- The label's content will be bound to the scroll bar! -->
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
BorderThickness="2" Content = "0"/>
</StackPanel>
</TabItem>
注意,ScrollBar
对象(此处命名为mySB
)被配置了一个介于1
和100
之间的范围。目标是确保当你重新定位滚动条(或者点击左箭头或右箭头)时,Label
会自动更新当前值。目前,Label
控件的Content
属性被设置为值"0"
;但是,您将通过数据绑定操作来更改这一点。
建立数据绑定
使在 XAML 定义绑定成为可能的粘合剂是{Binding}
标记扩展。虽然可以通过 Visual Studio 定义绑定,但直接在标记中定义也同样容易。将名为labelSBThumb
的Label
的Content
属性编辑如下:
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
Content = "{Binding Path=Value, ElementName=mySB}"/>
注意分配给Label
的Content
属性的值。{Binding}
语句表示数据绑定操作。ElementName
值表示数据绑定操作的源(ScrollBar
对象),而Path
表示被绑定的属性,在本例中是滚动条的Value
。
如果您再次运行您的程序,您会发现当您移动滑块时,标签的内容会根据滚动条的值进行更新!
DataContext 属性
您可以使用另一种格式在 XAML 中定义数据绑定操作,在这种格式中,可以通过将DataContext
属性显式设置为绑定操作的源来分解由{Binding}
标记扩展指定的值,如下所示:
<!-- Breaking object/value apart via DataContext -->
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
DataContext = "{Binding ElementName=mySB}" Content = "{Binding Path=Value}" />
在当前示例中,如果您以这种方式修改标记,输出将是相同的。考虑到这一点,您可能想知道何时需要显式设置DataContext
属性。这样做很有帮助,因为子元素可以在标记树中继承它的值。
通过这种方式,您可以轻松地将同一个数据源设置为一系列控件,而不必将一堆冗余的"{Binding ElementName=X, Path=Y}"
XAML 值重复给多个控件。例如,假设您已经将以下新的Button
添加到该选项卡的StackPanel
中(您马上就会明白为什么它如此之大):
<Button Content="Click" Height="200"/>
您可以使用 Visual Studio 为多个控件生成数据绑定,但可以尝试使用 XAML 编辑器手动输入修改后的标记,如下所示:
<!-- Note the StackPanel sets the DataContext property. -->
<StackPanel Background="#FFE5E5E5" DataContext = "{Binding ElementName=mySB}">
...
<!-- Now both UI elements use the scrollbar's value in unique ways. -->
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
Content = "{Binding Path=Value}"/>
<Button Content="Click" Height="200" FontSize = "{Binding Path=Value}"/>
</StackPanel>
这里,您直接在StackPanel
上设置DataContext
属性。因此,当你移动拇指时,你不仅会看到Label
上的当前值,还会看到Button
的字体大小根据相同的值相应地增大和缩小(图 25-22 显示了一个可能的输出)。
图 25-22。
将ScrollBar
值绑定到Label
和Button
格式化绑定数据
ScrollBar
类型使用double
来表示 thumb 的值,而不是期望的整数(例如,整数)。因此,当你拖动拇指时,你会发现在Label
中显示各种浮点数(如 61.066923076923)。最终用户会发现这相当不直观,因为他很可能期望看到整数(例如,61、62 和 63)。
如果想要格式化数据,可以添加一个ContentStringFormat
属性,传入一个自定义字符串和一个. NET 核心格式说明符,如下所示:
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
BorderThickness="2" Content = "{Binding Path=Value}" ContentStringFormat="The value is: {0:F0}"/>
如果在格式规范中没有任何文本,那么需要以一组空的大括号开始,这是 XAML 的转义序列。例如,这让处理器知道接下来的字符是文字,而不是绑定语句。以下是更新后的 XAML:
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
BorderThickness="2" Content = "{Binding Path=Value}" ContentStringFormat="{}{0:F0}"/>
Note
如果您正在绑定一个控件的Text
属性,您可以在 binding 语句中添加一个StringFormat
名称-值对。只需要为Content
属性单独设置即可。
使用 IValueConverter 进行数据转换
如果您需要做的不仅仅是格式化数据,您可以创建一个自定义类来实现名称空间System.Windows.Data
的IValueConverter
接口。此接口定义了两个成员,允许您在目标和目的地之间执行转换(在双向数据绑定的情况下)。定义该类后,可以用它来进一步限定数据绑定操作的处理。
除了使用 format 属性,您还可以使用值转换器在Label
控件中显示整数。为此,向项目类添加一个新类(名为MyDoubleConverter
)。接下来,添加以下内容:
using System;
using System.Windows.Data;
namespace
namespace WpfControlsAndAPIs
{
public class MyDoubleConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
// Convert the double to an int.
double v = (double)value;
return (int)v;
}
public object ConvertBack(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
// You won't worry about "two-way" bindings here, so just return the value.
return value;
}
}
}
当值从源(ScrollBar
)传输到目的地(TextBox
的Text
属性)时,调用Convert()
方法。您将收到许多传入的参数,但是您只需要操作传入的object
进行转换,这是当前double
的值。您可以使用此类型将类型转换为整数并返回新的数字。
当值从目的地传递到源时,将调用ConvertBack()
方法(如果您启用了双向绑定模式)。这里,您只需直接返回值。这样做可以让您在TextBox
(例如99.9
)中键入一个浮点值,并让它在用户关闭控件时自动转换成一个整数值(例如99
)。这种“自由”转换的发生是因为在调用了ConvertBack()
之后,再次调用了Convert()
方法。如果你只是简单地从ConvertBack()
返回null
,你的绑定会看起来不同步,因为文本框仍然会显示一个浮点数。
要在标记中使用这个转换器,首先必须创建一个本地资源来表示您刚刚构建的自定义类。不要担心添加资源的机制;接下来的几章将深入探讨这个问题。在开始的Window
标签后添加以下内容:
<Window.Resources>
<local:MyDoubleConverter x:Key="DoubleConverter"/>
</Window.Resources>
接下来,将Label
控件的绑定语句更新为:
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
BorderThickness="2" Content = "{Binding Path=Value,Converter={StaticResource DoubleConverter}}" />
现在,当你运行应用时,你只能看到整数。
在代码中建立数据绑定
您也可以在代码中注册数据转换类。首先清理数据绑定选项卡中的Label
控件的当前定义,使其不再使用{Binding}
标记扩展。
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2" />
确保有对System.Windows.Data
的使用;然后在你的窗口的构造函数中,调用一个名为SetBindings()
的新的私有帮助函数。在此方法中,添加以下代码(并确保从构造函数中调用它):
using System.Windows.Data;
...
namespace WpfControlsAndAPIs
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
SetBindings();
}
...
private void SetBindings()
{
// Create a Binding object.
Binding b = new Binding
{
// Register the converter, source, and path.
Converter = new MyDoubleConverter(),
Source = this.mySB,
Path = new PropertyPath("Value")
// Call the SetBinding method on the Label.
this.labelSBThumb.SetBinding(Label.ContentProperty, b);
}
}
}
}
这个函数唯一看起来有点不正常的部分是对SetBinding()
的调用。注意,第一个参数调用了名为ContentProperty
的Label
类的一个静态只读字段。正如你将在本章后面学到的,你正在指定所谓的依赖属性。目前,只需知道当您在代码中设置绑定时,第一个参数几乎总是要求您指定需要绑定的类的名称(在本例中为Label
),然后调用带有Property
后缀的底层属性。无论如何,运行应用说明Label
只打印出整数。
构建数据网格选项卡
前面的数据绑定示例阐释了如何配置两个(或更多)控件来参与数据绑定操作。虽然这很有帮助,但是也可以绑定来自 XML 文件、数据库数据和内存中对象的数据。为了完成这个示例,您将设计选项卡控件的最后一个选项卡,以便它显示从AutoLot
数据库的Inventory
表中获得的数据。
与其他选项卡一样,首先将当前的Grid
更改为StackPanel
。为此,可以使用 Visual Studio 直接更新 XAML。现在在名为gridInventory
的新StackPanel
中定义一个DataGrid
控件,如下所示:
<TabItem x:Name="tabDataGrid" Header="DataGrid">
<StackPanel>
<DataGrid x:Name="gridInventory" Height="288"/>
</StackPanel>
</TabItem>
使用 NuGet 包管理器将以下包添加到项目中:
-
Microsoft.EntityFrameworkCore
-
Microsoft.EntityFrameworkCore.SqlServer
-
Microsoft.Extensions.Configuration
-
Microsoft.Extensions.Configuration.Json
如果您喜欢使用。NET Core 命令行界面(CLI)要添加包,请输入以下命令(从解决方案目录中):
dotnet add WpfControlsAndAPIs package Microsoft.EntityFrameworkCore
dotnet add WpfControlsAndAPIs package Microsoft.EntityFrameworkCore.SqlServer
dotnet add WpfControlsAndAPIs package Microsoft.Extensions.Configuration
dotnet add WpfControlsAndAPIs package Microsoft.Extensions.Configuration.Json
接下来,右键单击该解决方案,选择“添加➤现有项目”,然后添加 AutoLot。达尔和奥托洛特。Dal .从第章到第二十三章对项目建模,并对这些项目进行项目引用。您还可以使用 CLI 通过以下命令添加引用(您需要根据项目的位置和计算机的操作系统调整路径):
dotnet sln .\Chapter25_AllProjects.sln add ..\Chapter_23\AutoLot.Models
dotnet sln .\Chapter25_AllProjects.sln add ..\Chapter_23\AutoLot.Dal
dotnet add WpfControlsAndAPIs reference ..\Chapter_23\AutoLot.Models
dotnet add WpfControlsAndAPIs reference ..\Chapter_23\AutoLot.Dal
确认来自 AutoLot 的项目引用。达尔到奥特洛特。Dal.Models 还在。将以下名称空间添加到MainWindow.xaml.cs
:
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Repos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
在MainWindow.cs
中添加两个模块级属性来保存IConfiguration
和ApplicationDbContext
的实例。
private IConfiguration _configuration;
private ApplicationDbContext _context;
添加一个名为GetConfigurationAndContext()
的新方法来创建这些实例,并从构造函数中调用它。下面列出了整个方法:
private void GetConfigurationAndDbContext()
{
_configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", true, true)
.Build();
var optionsBuilder =
new DbContextOptionsBuilder<ApplicationDbContext>();
var connectionString =
_configuration.GetConnectionString("AutoLot");
optionsBuilder.UseSqlServer(connectionString,
sqlOptions => sqlOptions.EnableRetryOnFailure());
_context = new ApplicationDbContext(optionsBuilder.Options);
}
将名为appsettings.json
的新 JSON 文件添加到项目中,并将其构建状态设置为 copy always。这可以通过在解决方案资源管理器中右击文件,选择 Properties,然后输入 Copy always 作为 Copy To Output Directory 设置来完成。您也可以将它添加到项目文件中来完成相同的任务:
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
将 JSON 文件更新为以下内容(更新连接字符串以匹配您的环境):
{
"ConnectionStrings": {
"AutoLotFinal": "server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;"
}
}
打开MainWindow.xaml.cs
,添加一个名为ConfigureGrid()
的最终 helper 函数,配置好ApplicationDbContext
后从你的构造函数中调用。您需要做的只是添加几行代码,就像这样:
private void ConfigureGrid()
{
using var repo = new CarRepo(_context);
gridInventory.ItemsSource = repo
.GetAllIgnoreQueryFilters()
.ToList()
.Select(x=> new {
x.Id,
Make=x.MakeName,
x.Color,
x.PetName
});
}
现在,当您运行项目时,您会看到数据填充了网格。如果您想让网格看起来更漂亮,可以使用 Visual Studio 属性窗口编辑网格,使其更有吸引力。
这就结束了当前的例子。在后面的章节中,你会看到一些其他的控件在起作用;然而,在这一点上,您应该对在 Visual Studio 中构建 ui 以及手动使用 XAML 和 C# 代码的过程感到舒适。
了解依赖项属性的作用
像其他人一样。NET 核心 API,WPF 利用每个成员的。NET 核心类型系统(类、结构、接口、委托、枚举)和每个类型成员(属性、方法、事件、常量数据、只读字段等。)在其实现中。然而,WPF 也支持一个独特的编程概念,称为依赖属性。
像个“正常人”。NET 核心属性(在 WPF 文献中通常称为 CLR 属性),依赖属性可以使用 XAML 以声明方式设置,也可以在代码文件中以编程方式设置。此外,依赖属性(如 CLR 属性)最终是为了封装类的数据字段而存在的,并且可以配置为只读、只写或读写。
更有趣的是,几乎在每种情况下,您都不会意识到您实际上设置了(或访问了)一个依赖属性,而不是 CLR 属性!例如,WPF 控件从FrameworkElement
继承的Height
和Width
属性,以及从ControlContent
继承的Content
成员,实际上都是依赖属性。
<!-- Set three dependency properties! -->
<Button x:Name = "btnMyButton" Height = "50" Width = "100" Content = "OK"/>
鉴于所有这些相似之处,为什么 WPF 要为这样一个熟悉的概念定义一个新的术语呢?答案在于依赖属性是如何在类中实现的。一会儿你会看到一个编码的例子。但是,从高层次来看,所有依赖关系属性都是以下列方式创建的:
-
首先,定义依赖属性的类在其继承链中必须有
DependencyObject
。 -
单个依赖属性在类型为
DependencyProperty
的类中被表示为一个公共的、静态的、只读的字段。按照惯例,这个字段是通过在 CLR 包装器的名称后面加上单词Property
来命名的(参见最后一点)。 -
通过对
DependencyProperty.Register()
的静态调用来注册DependencyProperty
变量,这通常发生在静态构造函数中,或者在声明变量时内联。 -
最后,该类将定义一个 XAML 友好的 CLR 属性,该属性调用由
DependencyObject
提供的方法来获取和设置值。
一旦实现,依赖属性提供了许多强大的功能,这些功能被各种 WPF 技术使用,包括数据绑定、动画服务、样式、模板等等。简而言之,依赖属性的动机是提供一种基于其他输入的值来计算属性值的方法。以下是一些关键优势的列表,这些优势远远超出了使用 CLR 属性进行简单数据封装的优势:
-
依赖属性可以从父元素的 XAML 定义中继承它们的值。例如,如果在
Window
的开始标记中为FontSize
属性定义了一个值,那么在默认情况下,Window
中的所有控件将具有相同的字体大小。 -
依赖属性支持由包含在它们的 XAML 范围内的元素设置值的能力,例如一个
Button
设置一个DockPanel
父的Dock
属性。(回想一下附加属性做这件事,因为附加属性是依赖属性的一种形式。) -
依赖属性允许 WPF 根据多个外部值计算一个值,这对动画和数据绑定服务很重要。
-
依赖属性为 WPF 触发器提供基础结构支持(在处理动画和数据绑定时也经常使用)。
现在请记住,在许多情况下,您将以与普通 CLR 属性相同的方式与现有的依赖属性进行交互(多亏了 XAML 包装器)。在前面讨论数据绑定的部分中,您看到了如果您需要在代码中建立数据绑定,您必须在作为操作目的地的对象上调用SetBinding()
方法,并指定它将操作的依赖属性,如下所示:
private void SetBindings()
{
Binding b = new Binding
{
// Register the converter, source, and path.
Converter = new MyDoubleConverter(),
Source = this.mySB,
Path = new PropertyPath("Value")
};
// Specify the dependency property!
this.labelSBThumb.SetBinding(Label.ContentProperty, b);
}
当你在第二十七章中研究如何用代码启动动画时,你会看到类似的代码。
// Specify the dependency property!
rt.BeginAnimation(RotateTransform.AngleProperty, dblAnim);
只有在创作自定义 WPF 控件时,才需要构建自己的自定义依赖项属性。例如,如果您正在构建一个定义了四个定制属性的UserControl
,并且您希望这些属性能够很好地集成到 WPF API 中,那么您应该使用依赖属性逻辑来创作它们。
具体来说,如果您的属性需要成为数据绑定或动画操作的目标,如果属性必须在更改时广播,如果它必须能够作为 WPF 风格的Setter
工作,或者如果它必须能够从父元素接收它们的值,那么普通的 CLR 属性将而不是就足够了。如果你使用一个普通的 CLR 属性,其他程序员可能真的能够获得和设置一个值;然而,如果他们试图在 WPF 服务的上下文中使用您的属性,事情将不会像预期的那样工作。因为你永远不知道其他人可能想要如何与你的定制UserControl
类的属性交互,你应该养成在构建定制控件时总是定义依赖属性的习惯。
检查现有的依赖属性
在您学习如何构建一个定制的依赖属性之前,让我们来看看FrameworkElement
类的Height
属性是如何在内部实现的。相关代码如下所示(包括我的注释):
// FrameworkElement is-a DependencyObject.
public class FrameworkElement : UIElement, IFrameworkInputElement,
IInputElement, ISupportInitialize, IHaveResources, IQueryAmbient
{
...
// A static read-only field of type DependencyProperty.
public static readonly DependencyProperty HeightProperty;
// The DependencyProperty field is often registered
// in the static constructor of the class.
static FrameworkElement()
{
...
HeightProperty = DependencyProperty.Register(
"Height",
typeof(double),
typeof(FrameworkElement),
new FrameworkPropertyMetadata((double) 1.0 / (double) 0.0,
FrameworkPropertyMetadataOptions.AffectsMeasure,
new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));
}
// The CLR wrapper, which is implemented using
// the inherited GetValue()/SetValue() methods.
public double Height
{
get { return (double) base.GetValue(HeightProperty); }
set { base.SetValue(HeightProperty, value); }
}
}
正如您所看到的,依赖属性需要普通 CLR 属性的相当多的额外代码!实际上,依赖关系可能比您在这里看到的更复杂(幸运的是,许多实现比Height
更简单)。
首先,记住如果一个类想要定义一个依赖属性,它必须在继承链中有DependencyObject
,因为这是定义 CLR 包装器中使用的GetValue()
和SetValue()
方法的类。因为FrameworkElement
是-a DependencyObject
,这个要求就满足了。
接下来,回想一下将保存属性实际值的实体(在Height
的情况下是一个double
)被表示为一个DependencyProperty
类型的公共、静态、只读字段。按照惯例,这个字段的名称应该总是通过在相关 CLR 包装器的名称后面加上单词Property
来命名,就像这样:
public static readonly DependencyProperty HeightProperty;
假设依赖属性被声明为静态字段,它们通常在类的静态构造函数中创建(和注册)。通过调用静态的DependencyProperty.Register()
方法来创建DependencyProperty
对象。此方法已被重载多次;然而,在Height
的情况下,调用DependencyProperty.Register()
如下:
HeightProperty = DependencyProperty.Register(
"Height",
typeof(double),
typeof(FrameworkElement),
new FrameworkPropertyMetadata((double)0.0,
FrameworkPropertyMetadataOptions.AffectsMeasure,
new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));
DependencyProperty.Register()
的第一个参数是类的普通 CLR 属性的名称(本例中为Height
),而第二个参数是它封装的底层数据类型的类型信息(??)。第三个参数指定该属性所属的类的类型信息(在本例中为FrameworkElement
)。虽然这看起来有些多余(毕竟,HeightProperty
字段已经在FrameworkElement
类中定义了),但这是 WPF 的一个巧妙之处,因为它允许一个类在另一个类上注册属性(即使类定义已经被密封了!).
在这个例子中,传递给DependencyProperty.Register()
的第四个参数真正赋予了依赖属性自己独特的味道。在这里,传递了一个FrameworkPropertyMetadata
对象,该对象描述了关于 WPF 应该如何处理该属性的各种细节,这些细节涉及回调通知(如果该属性需要在值改变时通知其他人)和各种选项(由FrameworkPropertyMetadataOptions
枚举表示),这些选项控制所讨论的属性所影响的内容。(它和数据绑定一起工作吗?能遗传吗?)在这种情况下,FrameworkPropertyMetadata
的构造函数参数分解如下:
new FrameworkPropertyMetadata(
// Default value of property.
(double)0.0,
// Metadata options.
FrameworkPropertyMetadataOptions.AffectsMeasure,
// Delegate pointing to method called when property changes.
new PropertyChangedCallback(FrameworkElement.OnTransformDirty)
)
因为FrameworkPropertyMetadata
构造函数的最后一个参数是一个委托,注意它的构造函数参数指向了FrameworkElement
类上一个名为OnTransformDirty()
的静态方法。我不会费心展示这个方法背后的代码,但是请注意,任何时候您构建一个定制的依赖属性,您都可以指定一个PropertyChangedCallback
委托来指向一个方法,当您的属性值被更改时,这个方法将被调用。
这就把我带到了传递给DependencyProperty.Register()
方法的最后一个参数,类型为ValidateValueCallback
的第二个委托,它指向FrameworkElement
类上的一个方法,调用这个方法是为了确保分配给属性的值是有效的。
new ValidateValueCallback(FrameworkElement.IsWidthHeightValid)
这个方法包含了您通常期望在属性的 set 块中找到的逻辑(在下一节中有关于这一点的更多信息)。
private static bool IsWidthHeightValid(object value)
{
double num = (double) value;
return ((!DoubleUtil.IsNaN(num) && (num >= 0.0))
&& !double.IsPositiveInfinity(num));
}
在注册了DependencyProperty
对象之后,最后的任务是将该字段包装在一个普通的 CLR 属性中(在本例中是Height
)。但是,请注意,get
和set
作用域并不简单地返回或设置类级别的双成员变量,而是使用来自System.Windows.DependencyObject
基类的GetValue()
和SetValue()
方法间接这样做,如下所示:
public double Height
{
get { return (double) base.GetValue(HeightProperty); }
set { base.SetValue(HeightProperty, value); }
}
关于 CLR 属性包装的重要说明
因此,简单回顾一下到目前为止的故事,当您在 XAML 或代码中获取或设置它们的值时,依赖属性看起来就像普通的日常属性,但是在幕后,它们是用更复杂的编码技术实现的。请记住,完成此过程的全部原因是为了构建一个自定义控件,该控件具有需要与 WPF 服务集成的自定义属性,这些服务需要与依赖属性(例如,动画、数据绑定和样式)进行通信。
即使依赖项属性的部分实现包括定义 CLR 包装,也不应该将验证逻辑放在 set 块中。就此而言,依赖属性的 CLR 包装除了调用GetValue()
或SetValue()
之外不应该做任何事情。
原因在于,WPF 运行时的构造方式使得当您编写 XAML 时,它似乎设置了一个属性,例如
<Button x:Name="myButton" Height="100" .../>
运行时会完全绕过Height
属性的 set 块,直接调用SetValue()
!这种奇怪行为的原因与一种简单的优化技术有关。如果 WPF 运行时要调用Height
属性的 set 块,它将不得不执行运行时反射来找出DependencyProperty
字段(由SetValue()
的第一个参数指定)的位置,在内存中引用它,等等。如果您要编写检索属性Height
的值的 XAML,那么同样的故事也是成立的— GetValue()
将被直接调用。
既然是这样,为什么还需要构建这个 CLR 包装器呢?WPF·XAML 不允许你在标记中调用函数,所以下面的标记是错误的:
<!-- Nope! Can't call methods in WPF XAML! -->
<Button x:Name="myButton" this.SetValue("100") .../>
实际上,当您使用 CLR 包装在标记中设置或获取一个值时,可以把它看作是告诉 WPF 运行时“嘿!去帮我调用GetValue()
/ SetValue()
,因为我不能直接用标记来做!”现在,如果您用如下代码调用 CLR 包装器会怎么样:
Button b = new Button();
b.Height = 10;
在这种情况下,如果Height
属性的 set 块包含对SetValue()
的调用之外的代码,它将执行,因为不涉及 WPF·XAML 解析器优化。
要记住的基本规则是,当注册一个依赖属性时,使用一个ValidateValueCallback
委托来指向一个执行数据验证的方法。这确保了无论您是使用 XAML 还是代码来获取/设置依赖项属性,都会发生正确的行为。
构建自定义依赖项属性
如果你在这一章的这一点上有点头疼,这是完全正常的反应。构建依赖属性可能需要一些时间来适应。然而,无论好坏,这是构建许多自定义 WPF 控件过程的一部分,所以让我们来看看如何构建依赖属性。
首先创建一个名为 CustomDependencyProperty 的新 WPF 应用。现在,使用项目菜单,激活添加用户控件(WPF)菜单选项,并创建一个名为ShowNumberControl.xaml
的控件。
Note
你会在第二十七章中了解到更多关于 WPF UserControl
的细节,所以现在就按照图中所示进行吧。
就像一个窗口,WPF UserControl
类型有一个 XAML 文件和一个相关的代码文件。更新用户控件的 XAML,在Grid
中定义一个Label
控件,如下所示:
<UserControl x:Class="CustomDepProp.ShowNumberControl"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace: CustomDependencyProperty"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Grid>
<Label x:Name="numberDisplay" Height="50" Width="200" Background="LightBlue"/>
</Grid>
</UserControl>
在该自定义控件的代码文件中,创建一个正常的、日常的。NET Core 属性,它包装了一个int
,并用新值设置了Label
的Content
属性,如下所示:
public partial class ShowNumberControl : UserControl
{
public ShowNumberControl()
{
InitializeComponent();
}
// A normal, everyday .NET property.
private int _currNumber = 0;
public int CurrentNumber
{
get => _currNumber;
set
{
_currNumber = value;
numberDisplay.Content = CurrentNumber.ToString();
}
}
}
现在,更新MainWindow.xml
中的 XAML 定义,在StackPanel
布局管理器中声明自定义控件的一个实例。因为您的自定义控件不是核心 WPF 程序集堆栈的一部分,所以您需要定义一个映射到您的控件的自定义 XML 命名空间。以下是所需的标记:
<Window x:Class="CustomDepPropApp.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:myCtrls="clr-namespace: CustomDependencyProperty"
xmlns:local="clr-namespace: CustomDependencyProperty"
mc:Ignorable="d"
Title="Simple Dependency Property App" Height="450" Width="450"
WindowStartupLocation="CenterScreen">
<StackPanel>
<myCtrls:ShowNumberControl HorizontalAlignment="Left" x:Name="myShowNumberCtrl" CurrentNumber="100"/>
</StackPanel>
</Window>
如您所见,Visual Studio 设计器似乎正确地显示了您在CurrentNumber
属性中设置的值(参见图 25-23 )。
图 25-23。
看起来你的房子像预期的那样工作
但是,如果您想将一个动画对象应用到CurrentNumber
属性,使其值在10
秒内从100
变为200
,该怎么办呢?如果你想在标记中这样做,你可以这样更新你的myCtrls:ShowNumberControl
范围:
<myCtrls:ShowNumberControl x:Name="myShowNumberCtrl" CurrentNumber="100">
<myCtrls:ShowNumberControl.Triggers>
<EventTrigger RoutedEvent = "myCtrls:ShowNumberControl.Loaded">
<EventTrigger.Actions>
<BeginStoryboard>
<Storyboard TargetProperty = "CurrentNumber">
<Int32Animation From = "100" To = "200" Duration = "0:0:10"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger.Actions>
</EventTrigger>
</myCtrls:ShowNumberControl.Triggers>
</myCtrls:ShowNumberControl>
如果运行应用,动画对象将找不到合适的目标,并引发异常。原因是CurrentNumber
属性没有注册为依赖属性!若要解决问题,请返回自定义控件的代码文件,并完全注释掉当前的属性逻辑(包括私有支持字段)。
现在,添加以下代码来创建CurrentNumber
作为依赖属性:
public int CurrentNumber
{
get => (int)GetValue(CurrentNumberProperty);
set => SetValue(CurrentNumberProperty, value);
}
public static readonly DependencyProperty CurrentNumberProperty =
DependencyProperty.Register("CurrentNumber",
typeof(int),
typeof(ShowNumberControl),
new UIPropertyMetadata(0));
这与您在Height
属性的实现中看到的类似;但是,代码片段以内联方式注册属性,而不是在静态构造函数中注册(这很好)。还要注意,UIPropertyMetadata
对象用于定义整数的默认值(0
),而不是更复杂的FrameworkPropertyMetadata
对象。这是作为依赖属性的CurrentNumber
的最简单版本。
添加数据验证例程
尽管您现在有了一个名为CurrentNumber
的依赖属性(并且不再抛出异常),但是您仍然看不到您的动画。您可能要做的下一个调整是指定一个要调用的函数来执行一些数据验证逻辑。对于这个例子,假设您需要确保CurrentNumber
的值在0
和500
之间。
为此,向类型为ValidateValueCallback
的DependencyProperty.Register()
方法添加一个最终参数,该参数指向一个名为ValidateCurrentNumber
的方法。
ValidateValueCallback
是一个委托,它只能指向返回bool
的方法,并将object
作为唯一的参数。这个object
代表正在被分配的新值。如果输入值在预期范围内,执行ValidateCurrentNumber
返回true
或false
。
public static readonly DependencyProperty CurrentNumberProperty =
DependencyProperty.Register("CurrentNumber",
typeof(int),
typeof(ShowNumberControl),
new UIPropertyMetadata(100),
new ValidateValueCallback(ValidateCurrentNumber));
// Just a simple rule. Value must be between 0 and 500.
public static bool ValidateCurrentNumber(object value) =>
Convert.ToInt32(value) >= 0 && Convert.ToInt32(value) <= 500;
响应属性更改
所以,现在你有一个有效的数字,但仍然没有动画。您需要做的最后一个更改是为UIPropertyMetadata
的构造函数指定第二个参数,这是一个PropertyChangedCallback
对象。这个委托可以指向任何一个将DependencyObject
作为第一个参数,将DependencyPropertyChangedEventArgs
作为第二个参数的方法。首先,更新代码,如下所示:
// Note the second param of UIPropertyMetadata constructor.
public static readonly DependencyProperty CurrentNumberProperty =
DependencyProperty.Register("CurrentNumber", typeof(int), typeof(ShowNumberControl),
new UIPropertyMetadata(100, new PropertyChangedCallback(CurrentNumberChanged)),
new ValidateValueCallback(ValidateCurrentNumber));
在CurrentNumberChanged()
方法中,您的最终目标是将Label
的Content
更改为由CurrentNumber
属性分配的新值。然而,你有一个大问题:CurrentNumberChanged()
方法是静态的,因为它必须与静态的DependencyProperty
对象一起工作。那么,对于当前的ShowNumberControl
实例,如何获得对Label
的访问呢?该引用包含在第一个DependencyObject
参数中。您可以使用传入的事件参数找到新值。下面是更改Label
的Content
属性的必要代码:
private static void CurrentNumberChanged(DependencyObject depObj, DependencyPropertyChangedEventArgs args)
{
// Cast the DependencyObject into ShowNumberControl.
ShowNumberControl c = (ShowNumberControl)depObj;
// Get the Label control in the ShowNumberControl.
Label theLabel = c.numberDisplay;
// Set the Label with the new value.
theLabel.Content = args.NewValue.ToString();
}
咻!仅仅改变一个标签的输出就有很长的路要走。好处是您的CurrentNumber
依赖属性现在可以成为 WPF 风格的目标、动画对象、数据绑定操作的目标等等。如果您再次运行您的应用,您现在应该会看到在执行过程中值发生了变化。
这就结束了您对 WPF 依赖属性的了解。虽然我希望您对这些构造允许您做什么有一个更好的了解,并且对如何创建自己的构造有一个更好的了解,但是请注意,这里还有许多我没有涉及的细节。
如果您发现自己正在构建许多支持自定义属性的自定义控件,请在。NET Framework 4.7 SDK 文档。在本文中,您将发现更多构建依赖属性、附加属性的示例,配置属性元数据的各种方法,以及许多其他细节。
摘要
本章研究了 WPF 控件的几个方面,首先概述了控件工具包和布局管理器(面板)的作用。第一个示例让您有机会构建一个简单的文字处理应用,演示 WPF 的集成拼写检查功能,以及如何构建一个包含菜单系统、状态栏和工具栏的主窗口。
更重要的是,您研究了如何使用 WPF 命令。回想一下,您可以将这些与控件无关的事件附加到 UI 元素或输入手势,以自动继承现成的服务(例如,剪贴板操作)。
你也学到了很多关于在 XAML 构建复杂 UI 的知识,同时你也学到了 WPF Ink API。您还了解了 WPF 数据绑定操作,包括如何使用 WPF DataGrid
类显示自定义AutoLot
数据库中的数据。
最后,你调查了 WPF 是如何对传统文化进行独特的诠释的。NET 核心编程原语,特别是属性和事件。如您所见,依赖属性允许您构建一个可以集成到 WPF 服务集(动画、数据绑定、样式等)中的属性。).与此相关的是,路由事件为事件提供了一种沿标记树向上或向下流动的方式。