5.1 控件到底是什么
GUI 是程序界面的优胜者,但在 Windows 上实现图形化界面却有多种方法,每种方法又拥有自己的一套开发理念和工具共同组成,常见的有:
- Windows API(Win API):调用 Windows 底层绘图函数,使用 C 语言,最原始也最基础。
- Microsoft Foundation Class(MFC):使用 C++ 语法将原始的 Win32 API 函数封装成控件类。
- Visual Component Library(VCL):Delphi 和 C++ Builder 使用的与 MFC 相近的控件类库。
- Visual Basic + ActiveX 控件(VB6):使用组件化的思想把 WinAPI 封装成 UI 控件,以期多语言共用。
- Java Swing/AWT:Java SDK 中用于跨平台开发 GUI 程序的控件类库。
- Windows Form:.NET 平台上进行 GUI 开发的老牌劲旅,完全组件化但需要 .NET 运行时支持。
- Windows Presentation Foundation(WPF):后起之秀,使用全新的数据驱动 UI 的理念。
纵览 Windows GUI 开发历史,可以把上述这些方法论分为四代:
- Win API 时代;函数调用 + Windows 消息处理。
- 封装时代:使用面向对象理念把 Win API 封装成类;由来自 UI 的消息驱动程序处理数据。
- 组件化时代:使用面向组件理念在类的基础上封装成组件;消息被封装成事件,变成事件驱动。
- WPF 时代:在组件化的基础上,使用专门的 UI 设计语言并引入由数据驱动的UI 理念。
WPF 之所以能够称得上是新的一代关键在于两点:
- WPF 具由转呢用于 UI 设计的 XAML,之前几代只能使用编程语言进行 UI 设计;
- WPF 在事件驱动的基础上引入了数据驱动界面的理念。
UI 的功能是让用户观察和操作数据,为了让用户观察数据,需要用 UI 元素来显示数据;为了让用户操作数据,我们需要用 UI 元素响应用户的操作。WPF 把那些能够展示数据、响应用户操作的 UI 元素称为控件(Control)。在 WPF 中谈控件,我们关注的应该是抽象的数据和行为而不是控件具体的形象。
粗略而言,日常工作中我们打交道最多的控件无外乎 6 类,即:
- 布局控件:可以容纳多个控件或嵌套其他布局控件,用于在 UI 上组织和排列控件。它们拥有共同的父类 Panel。
- 内容控件:只能容纳一个其他控件或布局控件作为它的内容。Window、Button 等控件属于此类,因为只能容纳一个控件作为其内容,所以经常需要借助布局控件来规划期内容。它们的共同父类是 ContentControl。
- 带标题的内容控件:系那个的那个鱼一个内容控件,但可以加一个标题(Header),标题部分可容纳一个控件或布局。GroupBox、TabItem 等是这类控件的典型代表。它们的共同父类是 HeaderedContentControl。
- 条目控件:可以显示一列数据,一般情况下这列数据的类型相同。此类控件包括 ListBox、ComboBox等。它们的共同基类是 ItemsControl。
- 带标题条目控件:相当于一个条目控件加上一个标题显示区:TreeViewItem、MenuItem 都属于此类控件。这类控件往往用于显示层级关系数据,结点显示在其 Header 区域,子级结点则显示在其条目控件区域。此类控件的共同基类是 HeaderedItemsContorl。
- 特殊内容控件:比如 TextBox 容纳的是字符串、Image 容纳的图片类型数据……这类控件相对比较独立。
6 类控件的派生关系如图所示。
(图5-1)
为什么要在 FrameworkElement 处放置一条分割线呢?
FrameworkElement 的 Framework 与 .Net Framework 的 Framework 是什么关系?问题的答案是:WPF 是构建在 .NET Framework 上的一个子系统,它也是一个用于开发应用程序的框架系统(Framework),FrameworkElement 的 Framework 指的就是 WPF Framework。而 FrameworkElement 类在 UIElement 类的基础上添加了很多专门用于 WPF 开发的 API(比如 SetBinding 方法),所以这个类开始才算是进入 WPF 开发框架。
5.2 WPF 的内容类型
WPF 的 UI 元素可以分为如下表所示的这些类型。
| 名称 | 注释 |
|---|---|
| ContentControl | 单一内容控件 |
| HeaderedContentControl | 带标题的单一内容控件 |
| ItemsControl | 以条目集合为内容的控件 |
| HeaderedItemsControl | 带标题的以条目集合为内容的控件 |
| Decorator | 控件装饰元素 |
| Panel | 面板类元素 |
| Adorner | 文字点缀元素 |
| Flow Text | 流式文本元素 |
| TextBox | 文本输入框 |
| TextBlocl | 静态文字 |
| Shape | 图形元素 |
因为允许控件嵌套,所以 WPF 的 UI 会形成一个树形结构,这棵树被称为逻辑树(Logical Tree);WPF 控件往往是由更基本的控件构成的,即控件本身就是一棵树,如果连这棵树也考虑在内,则这棵比逻辑树更“繁茂”的树称为“可视元素树”(Visual Tree)。
控件是内存中的对象,控件的内容也是内存中的对象。控件通过自己的某个属性引用着作为其内容的对象,这个属性称为内容属性(Content Property)。“内容属性”是个统称,具体到每种控件上,内容属性都有自己确切的名字——有的直接就叫做 Content,有的叫 Child;有些控件的内容可以是集合,其内容属性叫 Items 或 Children 的。
XAML 在表达 UI 元素和元素内容的语法非常灵活,于情于理都说得过去。
所谓“于理”,就是说我们严格按照语法来行事。控件不是有内容属性吗?那在 XAML 里我们就应该能够使用 Attribute=Value 或属性标签的形式来为内容赋值。比如想把字符串“OK”作为内容赋值给一个 Button,下面这两种写法都是正确的:
<Button Content="OK" />
或者
<Button>
<Button.Content>
<sys:String>OK</sys:String>
</Button.Content>
</Button>
所谓“于情”,是指如果说得通就不必要非按照冗长的语法一板一眼来行事。控件对应到 XAML 文档里就是标签,按照大家对标签语言的理解,控件的内容就应该是标签的内容、子级控件就应该是标签的子级元素。标签的内容夹在其实标签和节数标签的代码,因此,上面的代码可以写成这样:
<Button>
<sys:String>OK</sys:String>
</Button>
换句话说,XAML 标签的内容区域专门映射了控件的内容属性。
有些控件的内容是一个集合,如 StakPanel 的内容是 Children、ListBox 的内容是 Items,为这个类空间添加内容时一样可以省略内容属性的标签。以 StackPanel 为例,当为一个 StackPanel 添加三个 TextBox 和一个 Button 时,完整的语法应该是这样:
<StackPanel Background="Gray">
<StackPanel.Children>
<TextBox Margin="5" />
<TextBox Margin="5" />
<Button Content="OK" Margin="5" />
</StackPanel.Children>
</StackPanel>
简化后的代码是:
<StackPanel Background="Gray">
<TextBox Margin="5" />
<TextBox Margin="5" />
<Button Content="OK" Margin="5" />
</StackPanel>
5.3 各类内容模型详解
我们把符合某类内容模型的 UI 元素称为一个族,每个族用它们的共同基类命名。
5.3.1 ContentControl 族
本族元素的特点如下:
- 均派生自 ContentControl 类。
- 它们都是控件(Control)。
- 内容属性的名称为 Content。
- 只能由单一元素充当其内容。
怎样理解“只能由单一元素充当其内容”这句话呢?让我们看一个例子。
Button 控件属于这一族,所以,下面两个 Button 的代码都是正确的。
<StackPanel>
<Button Margin="5">
<TextBox Text="Hello"></TextBox>
</Button>
<Button Margin="5">
<Image Source=".\smile.jpg" Width="30" Height="30"/>
</Button>
</StackPanel>
但是如果你想让 Button 的内容既包含文字又包含图片是不行的:
<Button Margin="5">
<TextBox Text="Hello" />
<Image Source="C:\Users\lenovo\Pictures\wallpaper.jpg" />
</Button>
编译器报错说 “The object 'Button' already has a child and cannot add 'Image'. 'Button' can accept only one child.”。
可如果这女的需要一个带图标的 Button 我们应该怎么办呢?只需要先用一个可以包含多个元素的布局空间作为 Button 的内容就好了。
ContentControl 族包含的控件如下:
- Button
- ButtonBase
- CheckBox
- ComboBoxItem
- ContentControl
- Frame
- GridViewColumnHeader
- GroupItem
- Label
- ListBoxItem
- ListViewItem
- NavigationWindow
- RadioButton
- RepeatButton
- ScrollViewer
- StatusBarItem
- ToggleButton
- ToolTip
- UserControl
- Window
5.3.2 HeaderedContentControl 族
本族元素的特点如下:
- 它们都派生自 HeaderedContentControl 类,HeaderedContentControl 是 ContentControl 的派生类。
- 它们都是控件,用于显示带标题的数据。
- 除了用于显示主体内容的区域外,控件还具有一个显示标题(Header)的区域。
- 内容属性为 Content 和 Headered。
- 无论是 Content 还是 Header 都只能容纳一个元素作为其内容。
HeaderedContentControl 族包含的控件如下表所示。
- Expander
- GroupBox
- HeaderedCotentControl
- TabItem
下面是一个例子。
<Grid>
<GroupBox Margin="10" Background="Gray">
<GroupBox.Header>
<Image Source=".\smile.jpg" Width="20" Height="20"/>
</GroupBox.Header>
<TextBox TextWrapping="WrapWithOverflow" Margin="10" Text="一棵树,一匹马"/>
</GroupBox>
</Grid>
5.3.3 ItemsControl 族
本族元素的特点如下:
- 均派生自 ItemsControl 类。
- 它们都是控件,用于显示列表化的数据。内容属性为 Items 或 ItemsSource。
- 每种 ItemsControl 都对应有自己的条目容器(Items Container)。
本族包含的控件如下表所示。
- Menu
- MenuBase
- ContextMenu
- ComboBox
- ItemsControl
- ListBox
- ListView
- TabControl
- TreeView
- Selector
- StatusBar
注意:本族控件最有特色的一点就是会自动使用条目容器对提交给它的内容进行包装。合法的 ItemsControl 内容一定是个集合,当我们把这个集合作为提交给 ItemsControl 时,ItemsControl 不会把这个集合直接拿来用,而是使用自己对应的条目容器把集合中工单条目逐个包装,然后再把包装好的条目序列当做自己的内容。这种自动包装的好处就是允许程序员向 ItemsControl 提交各种数据类型的集合,程序员在思考问题时会自然而然地感觉到 ItemsControl 控件直接装载这数据,如果需要进行增加、删除、更新或者排序,那么直接去操作数据集合就可以,UI 会自动将改变展现出来。
ListBox 是个典型的 ItemsControl,下面将以它为例,研究 ItemsControl。
<Grid>
<ListBox>
<CheckBox x:Name="CheckBoxTim" Content="Tim"/>
<CheckBox x:Name="CheckBoxTom" Content="Tom"/>
<CheckBox x:Name="CheckBoxBruce" Content="Bruce"/>
<Button x:Name="ButtonMess" Content="Mess"/>
<Button x:Name="ButtonOwen" Content="Owen"/>
<Button x:Name="ButtonVictor" Content="Victor"/>
</ListBox>
</Grid>
表面上看 ListBox 直接包含了一些 CheckBox 和 Button,实际上并非这样。我们为 Victor 这个按钮添加 Click 事件的响应,看看它的父级容器是什么。
private void ButtonVictor_OnClick(object sender, RoutedEventArgs e)
{
Button btn = sender as Button;
DependencyObject level1 = VisualTreeHelper.GetParent(btn);
DependencyObject level2 = VisualTreeHelper.GetParent(level1);
DependencyObject level3 = VisualTreeHelper.GetParent(level2);
MessageBox.Show(level3.GetType().ToString());
}
单击按钮后,弹出消息“System.Windows.Controls.ListBoxItem”。
我们沿着被单击的 Button 一层一层向上找,找到第三层发现是一个 ListBoxItem。ListBoxItem 就是 ListBox 对应的 ItemContainer,也就是说,无论把什么样的数据集合交给 ListBox,它都会以这种方式进行自动包装。所以完全没有必要这样写:
<ListBox>
<ListBoxItem>
<Button x:Name="ButtonMess" Content="Mess"/>
</ListBoxItem>
<ListBoxItem>
<Button x:Name="ButtonOwen" Content="Owen"/>
</ListBoxItem>
<ListBoxItem>
<Button x:Name="ButtonVictor" Content="Victor" Click="ButtonVictor_OnClick"/>
</ListBoxItem>
</ListBox>
实际工作中,交给 ItemsControl 的往往都是程序逻辑中的数据而非控件。
假设程序中定义有 Employee 类:
public class Employee
{
public int Id { get; set; }
public string Name { get; set; } =string.Empty;
public int Age { get; set; }
}
并且有一个 Employee 类型的集合:
List<Employee> employList = new List<Employee>()
{
new Employee(){Id = 1, Name = "Tim", Age = 30},
new Employee(){Id = 2, Name = "Tom", Age = 30},
new Employee(){Id = 3, Name = "Guo", Age = 30},
new Employee(){Id = 4, Name = "Yan", Age = 30},
new Employee(){Id = 5, Name = "Owen", Age = 30},
new Employee(){Id = 6, Name = "Victor", Age = 30},
};
在程序的主界面上有一个名为 ListBoxEmployee 的 ListBox。我们只需要这样写:
this.ListBoxEmployee.DisplayMemberPath = "Name";
this.ListBoxEmployee.SelectedValuePath = "Id";
this.ListBoxEmployee.ItemsSource = employList;
DisplayMemberPath 这个属性告诉 ListBox 显示每条数据的哪个属性,换句话说,ListBox 回去调用这个属性值的 ToString() 方法,把得到的字符串放入一个 TextBlock(最简单的文本控件),然后再按前面说的办法把 TextBlock 包装仅一个 ListBoxItem 里。
ListBox 的 SelectedValuePath 属性将与其 SelectedValue 属性配合使用。当你调用 SelectedValue 属性时,ListBox 先找到选中的 Item 所对应的数据对象,然后把 SelectedValuePath 的值当做数据对象的属性名称并把这个属性的值取出来。
DisplayMemberPath 和 SelectedValuePath 是两个相当简化的属性。DisplayMemeberPath 只能显示简单的字符串,想用更加复杂的形式显示数据需要使用 DataTemplate。SelectedValuePath 也只能返回单一的值,如果想进行一些复杂的操作,不妨直接使用 ListBox 的 SelectedItem 和 SelectedItems 属性,这两个属性返回的就是数据集合中的对象,得到原始的数据对象后就任由程序员操作了。
理解了 ListBox 的自动包装机制后,我们把全部的 ItemsControl 对应的 Item Container 列在下面。
| ItemsControl 名称 | 对应的 Item Container |
|---|---|
| ComboBox | ComboBoxItem |
| ContextMenu | MenuItem |
| ListBox | ListBoxItem |
| ListView | ListViewItem |
| Menu | MenuItem |
| StatusBar | StatusBarItem |
| TabControl | TabItem |
| TreeView | TreeViewItem |
5.3.4 HeaderedItemsControl 族
顾名思义,本族控件除了具有 ItemsControl 的特性外,还具有显示标题的能力。
本族元素的特点如下:
- 均派生自 HeaderedItemsControl 类。
- 它们都是控件,用于显示列表化的数据,同时可以显示一个标题。
- 内容属性为 Items、ItemsSource 和 Header。
本族控件只有 3 个:MenuItem、TreeViewItem 和 ToolBar。
5.3.5 Decorator 族
本族中的元素是在 UI 上起装饰效果的。如可以使用 Border 元素为一些组织在一起的内容加个边框。如果需要组织在一起的内容能够自由缩放,则可以使用 ViewBox 元素。
本族元素的特点如下:
- 均派生自 Decorator 类。
- 起 UI 装饰作用。
- 内容属性为 Child。
- 只能由单一元素充当内容。
本族元素包含:
- ButtonChrome
- ClassicBorderDecorator
- ListBoxChrome
- SystemDropShadowChrome
- Border
- InkPresenter
- BulletDecorator
- Viewbox
- AdornerDecorator
5.3.6 TextBlock 和 TextBox
这两个控件最主要的功能是显示文本。TextBlock 只能显示文本,不能编辑,所以称为静态文本。TextBox则允许用户编辑其中的内容。TextBlock 虽然不能编辑,但是可以使用丰富的印刷级的格式控制标记显示专业的排版效果。
TextBox 不需要太多的格式显示,所以它的内容是简单的字符串,内容属性为 Text。
TextBlock 由于需要操作格式,所以内容属性是 inlines(印刷中的“行”),同时,TextBlock也保留一个名为 Text 属性,当简单地显示一个字符串时,可以使用这个属性。
5.3.7 Shape 族元素
友好的用户界面离不开各种图形的搭配,Shape 族元素(它们只是简单的视觉元素,不是控件)就是专门用来在 UI 上绘制图形的一类元素。这类元素没有自己的内容,我们可以使用 Fill 属性为它们设置填充效果,还可以使用 Stroke 属性为它们设置边线效果。
本族元素的特点如下:
- 均派生自 Shape 类。
- 用于 2D 图形绘制。
- 无内容属性。
- 使用 Fill 属性设置填充,使用 Stroke 属性设置边线。
5.3.8 Panel 族元素
所有用于 UI 布局的元素都属于这一族。
本族元素的特点如下:
- 均派生自 Panel 抽象类。
- 主要功能是控制 UI 布局。
- 内容属性为 Children。
- 内容可以是多个元素,Panel 元素将控制它们的布局。
本族的元素包含:
- Canvas、DockPanel
- Grid、TabPanel
- ToolBarOverflowPanel
- StackPanel
- ToolBarPanel
- UniformGrid
- VirtualizingPanel
- VirtualizingStackPanel
- WrapPanel