一、控件到底是什么
控件的本质是“数据+算法”——用户输入原始数据,算法处理原始数据并得到结果数据。问题就在于程序如何将结果数据展示给用户。同样一组数据,你可以使用LED阵列显示出来,或者是以命令行模式借助各种控制字符(如Tab)对其并输出,但这些都不如图形化用户界面(Graphics User Interface ,GUI)来的友好和方便。GUI是程序界的优胜者,但在Windows上实现图形化界面有很多中方法。每种方法又拥有自己的一套开发理念和工具。每种GUI开发与它的里理念和工具共同组成一种方法论。常见的有:
- Windows API (Win API):调用Windows底层绘图函数,使用C语言,最原始也最基础。
- Microsoft Foundation Class(MFC):使用C++语法将原始的Win32 API封装成控件类。
- Visual Component Library(VCL):Delphi 和C++Builder使用的与MFC相近的控件类库
- Vistal Basic+ActiveX控件(VB6):使用组件化的思想把Win API 封装成UI控件,已其多语言共用
- Java Swing/AWT:Java SDK中用于跨平台开发GUI程序的控件类库
- Windows Form:.NET平台上进行GUI开发的老牌劲旅,完全组件化但需要.NET运行时支持。
- Windows Presentation Foundation(WPF):后起之秀,使用全新的数据驱动UI理念。
我们可以对以上方法论分为四代:
- Win API时代:函数调用+Windows消息处理。
- 封装时代:使用面向对象理念把Win API 封装成类:由来自UI的消息驱动程序处理数据。
- 组件化时代:使用面向组件理念在类的基础上封装成组件;消息被封装成事件,变成事件驱动。
- WPF时代:在组件化的基础上,使用专门的UI设计语言并引入数据驱动UI的理念。
WPF之所以能够称上最新的一代在于两点:第一,之前几代GUI方法论只能使用编程语言进行UI设计,而WPF具有专门的UI设计的XAML;第二,前几代在UI与数据交互方面是由Windows消息到控件事件一脉相承,始终是把UI控件放在主导地位而把数据放在被动地位,用UI来驱动数据的改变,WPF在事件驱动的基础上引入了数据驱动界面的理念,让数据重回核心地位而让UI回归数据表达者的位置
在从Winform转到WPF的学习过程中,心中一定要树立起这样一个概念——WPF中是数据驱动UI,数据是核心、是主动的。UI从属于数据并表达数据、是被动的。也可以这么理解,当我们想改变控件上的显示内容时,只需要改变该控件绑定的数据源内容即可。
UI的功能始让用户观察和操作数据,为了让用户观察数据,我们需要用UI元素来显示数据;为了让用户操作数据,我们需要用UI元素响应用户的操作。WPF把那些能够展示数据、响应用户操作的UI元素称为控件(Control)。控件所展示的数据,我们称之为控件的“数据内容”,控件在响应用户的操作后会执行自己的一些方法以事件(Event)的形式通知应用程序(开发人员就可以决定如何处理这些事件),我们称之为控件的“行为”或“算法”内容。可见,WPF中的控件扮演者双重角色、是个非常抽象的概念——Control是数据和行为的载体,而无需具有固定的形象。换句话说,Button之所以是Button不是因为它长得方方正正、显示一串文字并且能够响应用户单击,而是应该倒过来想——凡是符合“能显示一些提示文字(可以是文字、也可以是图片、动画甚至是视频)并能响应用户单击”这一抽象概念的UI元素都可以是Button,至于Button具体长成什么样子(是方是圆、是显示文字还是显示动画)完全由它的风格(Style)和模板(Template)来决定。
在日常的开发工作中我们打交道的控件大致分为6类,即:
- 布局控件:可以容纳多个控件或嵌套其他布局控件,用于在UI上组织和排列控件,Grid、StackPanel、DockPanel等控件都属于此类,他们拥有共同的父类Panel。
- 内容控件:只能容纳一个其他控件或布局控件作为他的内容。Window、Button等控件属于此类,因为只能容纳一个控件作为其内容,所以经常需要借助布局控件来规划其内容。它们的共同父类是ContentControl。
- 带标题内容控件:相当于一个内容控件,但是可加标题(Header),标题部分亦可容纳一个控件或布局。GroupBox、TabItem等是这类控件的典型代表。它们的共同父类是HeaderedContentControl
- 条目控件:可以显示一列数据,一把情况下这列数据类型相同。此类控件包括ListBox、Combobox等。它们的共同基类是ItemsControl。此类控件在显示集合类型数据方面功能非常强大。
- 带标题条目控件:相当于在一个条目控件加上一个标题显示区。TreeViewItem、MenuItem都属于此类控件。这类控件往往用于显示层级关系数据,结点显示在其Header区域,子级节点则显示在其条目控件区域。此类控件的共同基类是HeaderedItemsControl。
- 特殊内容控件:比如TextBox容纳的是字符串、Textblock可以容纳可自由控制格式的文本,Image容纳图片类型数据....这类控件相对比较独立
二、WPF的内容模型
WPF可以分为如下几类
名称 | 注释 |
ContentControl | 单一内容控件 |
HeaderedContentControl | 带标题的单一内容控件 |
ItemsControl | 以条目集合为内容的控件 |
HeaderedItemsControl | 带标题的以条目集合为内容的控件 |
Decorator | 控件装饰的元素 |
Panel | 面板类元素 |
Adorner | 文字点缀元素 |
Flow Text | 流式文本元素 |
TextBox | 文本输入框 |
TextBlock | 静态文字 |
Shape | 图形元素 |
下面我们逐一剖析这些元素的内部结构,了解内容与内容属性。
我们可以把控件想象成一个容器,容器里面装的东西就是它的内容,控件的内容可以直接是数据,也可以是控件。当控件的内容还是控件的时候就形成了控件的嵌套,所以WPF的UI会形成一个树形结构。如果不考虑控件内部的组成结构,只观察由控件组成的“树”,那么这棵树称为“逻辑树(Logicol Tree)”;WPF控件往往是由更基本的控件构成的。即控件本身就是一棵树,如果连控件本身的树也考虑在内,则这颗比逻辑树更“繁茂”的树称为可视元素树(Visual Tree)。
控件时内存中的对象,控件的内容也是内存中的对象。控件通过自己的某个属性引用着作为其控件的对象,这个属性称为内容属性(Content Property)。“内容属性”是个统称,具体到每种控件上,内容属性都有自己确切的名字——有的直接叫Content,有的叫Child;有些控件的内容可以是集合,其内容属性叫Items或Children的。
三、各种内容模型详解
我们把符合某类内容模型的UI元素称为一个族,每个族用它们共同的基类来命名
3.1、ContentControl族
本类元素的特点如下:
- 均派生自ContentControl类。
- 它们都是控件(Control)。
- 内容属性的名称为Content。
- 只能由单一元素充当其内容。
怎么理解“只能由单一元素充当其内容”这句话呢?看下例子。
Button控件属于ContentControl族,所以下面两个Button代码都是正确的——第一个Button的内容是一个静态文字,第二个Button的内容是一张图片。
<StackPanel> <Button> <TextBlock>Hello World</TextBlock> </Button> <Button> <Image Source=".1.jpg" Height="30" Width="30"></Image> </Button> </StackPanel>
但如果你想让Button的内容即包含文字又包含图片是不行的:
<StackPanel> <Button> <TextBlock>Hello World</TextBlock> <Image Source=".1.jpg" Height="30" Width="30"></Image> </Button> </StackPanel>
编译器会报错“对象“Button”已经具有子级且无法添加“Image”。“Button”只能接受一个子级。”可是如果我们真的需要一个带图标的Button那怎么办呢?我们只需要先用一个可以包含多个元素的布局控件把图片和文字包装起来,再把这个布局控件作为Button的内容就好了
ContentControl族包含的控件如下
Button | ButtonBase | CheckBox | ComboboxItem |
ContentControl | Frame | GridViewColumnHeader | GroupItem |
Label | ListBoxItem | ListViewItem | NavigationWindow |
RadioButton | RepeatButton | ScrollViewer | StatusBarItem |
ToggleButton | ToolTip | UserControl | Window |
3.2、HeaderedContentControl族
本族元素的特点如下:
- 它们都派生自HeaderedContentControl类,HeaderedContentControl是ContentControl类的派生类。
- 它们都是控件,用于显示带标题的数据。
- 除了用于显示主体内容的区域外,控件还具有一个显示标题(Header)的区域。
- 内容属性为Content和Header
- 无论是Content还是Header都只能容纳一个元素作为其内容。
HeaderedContentControl族包含的控件如下
Expander | GroupBox | HeaderedContentControl | TabItem |
下面这个例子是一个以图标为Header、以文字为内容主体的GroupBox
<StackPanel> <GroupBox Margin="10"> <GroupBox.Header> <Image Source=".1.jpg" Height="30"></Image> </GroupBox.Header> <TextBlock TextWrapping="WrapWithOverflow" Margin="10"> 愿你慢慢长大,愿你有好运,如果没有,希望你在不幸中学会慈悲;愿你被很多人爱,如果没有,希望你在寂寞中学会宽容。 </TextBlock> </GroupBox> </StackPanel>
3.3、ItemsControl族
本族元素特点如下:
- 均派生自ItemsControl类。
- 它们都是控件,用于显示列表化的数据。
- 内容属性为Items或ItemsSource。
- 每种ItemsControl都对应有自己的条目容器(Item Container)。
本族的包含控件如下所示
Menu | MenuBase | ContextMenu | Combobox |
ItemsControl | ListBox | ListView | TabControl |
TreeView | Selector | StatusBar |
本族控件具有特色的一点就是会自动使用条目容器对提交给它的内容进行包装。合法的ItemsControl内容一定是个集合,当我们把这个集合作为内容提交给ItemsControl时,ItemsControl不会把这个集合直接拿来用,而是使用自己对应的条目容器把集合中的条目逐个包装,然后再把包装好的条目序列当作自己的内容。这种自动包装的好处就是允许程序员向ItemsControl提交各种数据类型的集合,程序员在思考问题时会自然而然的感觉到ItemsControl控件直接装载着数据,如果需要进行增加、删除、更新或者排序,那么直接去操作数据集合就可以,UI会自动将改变展现出来,这正体现了在WPF开发时数据直接驱动UI再进行显示。
ListBox是典型的ItemsControl,下面将以它为例,研究一下ItemsControl。
首先,我们看看ListBox的自动包装。WPF的ListBox在显示功能上比Windows Form或者ASP.NET的ListBox要强大的多。传统的ListBox只能将条目以字符串的形式显示,而WPF的ListBox除了可以显示中规中矩的字符串条目还能显示更多的元素,如CheckBox、RadioButton、TextBox等,这样一来,我们就能制作出更加丰富的UI,代码如下
<ListBox> <CheckBox x:Name="ckBoxTim" Content="Tim"/> <CheckBox x:Name="ckBoxTom" Content="Tom"/> <CheckBox x:Name="ckBoxSimple" Content="Simple"/> <Button x:Name="Mess" Content="Mess"/> <Button x:Name="Ownen" Content="Ownen"/> <Button x:Name="Victor" Content="Victor"/> </ListBox>
运行效果如下
表面看上去是ListBox直接包含了一些CheckBox和Button,实际并非这样。我们为Ownen这个按钮添加一个Click事件,看看它的父容器是什么。
private void Ownen_Click(object sender, RoutedEventArgs e) { var invoker = sender as Button; var parent = VisualTreeHelper.GetParent( VisualTreeHelper.GetParent(VisualTreeHelper.GetParent(invoker))); MessageBox.Show(parent.GetType().ToString()); }
VisualTreeHelper类是帮助我们在这颗由可视化元素构成的树上进行导航的辅助类。我们沿着被单击的Button一层一层向上找,找到第三层发现它是一个ListBoxItem。ListBoxItem就是ListBox对应的Container,也就是说,无论你把什么样的数据集合交给ListBox,他都会以这种方式进行自动包装。
上面这个例子就是单纯的为了说明ItemsControl能够使用对应的Item Container自动包装数据。实际工作中,除非列表里的元素自始至终都是固定的我们才使用这种直接把UI元素作为ItemControl内容的方法,比如一年由十二个月、一周有七天等。大多数情况下,UI上的列表会用于显示动态的后台数据,这时候我们交给ItemsControl的就是程序逻辑中的数据而非控件了。
假设程序中定义有Person类:
public class Person { public int ID { get; set; } public string Name { get; set; } public int Age { get; set; } // ... }
并且有一个Person类型的集合:
var lstPerson = new List<Person>() { new Person() { Age = 20, Name = "Simple", ID = 1 }, new Person() { Age = 22, Name = "Tim", ID = 2 }, new Person() { Age = 22, Name = "Tom", ID = 3 }, new Person() { Age = 22, Name = "jrrey", ID = 3 } };
在主程序中有一个名为lsbPerson的ListBox,我们只需要这样写:
var lstPerson = new List<Person>() { new Person() { Age = 20, Name = "Simple", ID = 1 }, new Person() { Age = 22, Name = "Tim", ID = 2 }, new Person() { Age = 22, Name = "Tom", ID = 3 }, new Person() { Age = 22, Name = "jrrey", ID = 3 } }; lsbPerson.DisplayMemberPath = "Name"; lsbPerson.SelectedValuePath = "ID"; lsbPerson.ItemsSource = lstPerson;
DisplayMemberPath这个属性告诉ListBox显示每条数据的哪个属性,换句话说,ListBox会去调用这个属性的ToString()方法,把得到的字符串放入一个TextBlock(最简单的文本控件),然后再按前面说的办法把TextBlock包装进一个ListBoxItem里。
ListBox的SelectedValuePath属性将与其SelectedValue属性配合使用。当你调用SelectedValue属性是,ListBox先找到选中的Item所对应的数据对象,然后把SelectedValuePath的值当作数据对象的属性名称并把这个属性的值取出来。
DisplayMemberPath和SelectedValuePath是两个相当简化的属性。DisplayMemberPath只能显示简单的字符串,想用更加复杂的形式显示数据需要使用DataTemplate。SelectedValuePath也只能返回单一的值,如果想进行一些复杂的操作,不妨直接使用ListBox的SelectedItem和SelectedItems属性,这两个属性返回的就是数据集中的对象,得到原始的数据对象后就任由程序员操作了。
理解了ListBox的自动包装机制,我把全部ItemsControl对应的Item Container列在下面
Items名称 | 对应的Item Container |
ComboBox | ComboBoxItem |
ContextMenu | MenuItem |
ListBox | ListBoxItem |
ListView | ListViewItem |
Menu | MenuItem |
StatusBar | StatusBarItem |
TabControl | TabItem |
TreeView | TreeViewItem |
3.4、HeaderedItemsControl族
顾名思义,本族控件除了具有ItemsControl的特性外,还具显示标题的能力。
本族元素的特点如下
- 均派生自HeaderedItemsControl类
- 它们都是控件,用于显示列表化的数据,同时可以显示一个标题。
- 内容属性为Items、ItemsSource和Header
- 因为它与ItemsControl非常相似,在这就不做演示了
3.5、Decorator族
在本族中的元素,在UI上是其装饰作用的。如可以使用Border元素为一些组织在一起的内容加个边框。如果需要组织在一起的内容能够自由缩放,则可以使用ViewBox元素。
本元素的特点如下:
- 均派生自Decorator类。
- 起UI装饰作用。
- 内容属性为Child。
- 只能由单一元素充当内容。
本族元素如下
ButtonChrome | ClassicBorderDecorator | ListBoxChrome | SystemDropShadowChrome |
Border | InkPresenter | BulletDecorator | ViewBox |
AdornerDectorator |
3.6、TextBlock和TextBox
这两个控件最主要的功能就是显示文本。TextBlock只能显示文本,不能编辑,所以又称静态文本。TextBox则允许用户编辑其中的内容。TextBlock虽然不能编辑内容,但可以使用丰富的印刷级的格式控制标记显示专业的排版效果。
TextBox不需要太多的显示格式,所以它的内容是简单的字符串,内容属性为Text。
TextBlock由于需要操纵格式,所以内容属性是InLines(印刷中的“行”),同时TextBlock也保留一个名为Text的属性,当简单的显示一个字符串时,可以使用这个属性。
3.7、Shape族元素
友好的界面离不开各种图形的搭配,Shape族元素(它们只是简单的视觉元素,不是控件)就是专门用来在UI上绘制图形的一类元素。这类元素没有自己的内容,我们可以使用Fill属性为它们设置填充效果,还可以使用Stroke属性为它们设置边线效果。
本族的元素特点如下:
- 均派生自Shape类。
- 用于2D图形绘制。
- 无内容属性。
- 使用Fill属性设置填充,使用Stroke属性设置边线。
3.8、Panel族元素
之所以把Panel元素放在最后是因为这一族控件实在是太重要了——所有用于UI布局的元素都属于这一族。
本族元素的特点如下:
- 均派生自Panel抽象类
- 主要功能是控制UI布局。
- 内容属性为Children。
- 内容可以是多个元素,Panel元素将控制它们的布局。
对比ItemsControl和Panel元素,虽然内容都可以是多个元素,但ItemsControl强调以列表的形式来展现数据而Panel则强调对包含元素进行布局。所以ItemsControl的内容属性是Items和ItemsSource而Panel的内容属性名为Children。
本族元素如下所示
Canvas | DockPanel | Grid | TabPanel |
ToolBarOverflowPanel | StackPanel | ToolBarPanel | UniformGrid |
VirtualizingPanel | VirtualizingStackPanel | WrapPanel |
四、UI布局(Layout)
WPF作为专门的用户界面技术,布局功能是它的核心功能之一。有好的用户界面和良好的用户体验离不开设计精良的布局。日常工作中,WPF设计师工作量最大的两个部分就是布局和动画,除了点缀性的动画外,大部分动画也是布局间的转换,UI布局的重要性可见一斑。布局是静态的,动画是动态的,用户体验就是用户在这动静之间与软件功能产生交互时的感受。
值得注意的是,WPF布局时依靠公众布局元素实现的,布局元素中,既有像传统的Windows Form和ASP.NET那样使用绝对坐标进行定位的元素,也有像HTML页面中那样使用行列定位的元素,只有对各个布局元素了如指掌才能使用最简介的XAML和C#代码实现让用户上赏心悦目的静态界面和动画。
4.1、布局元素
传统的Windows Form或ASP.NET开发中,一般是把窗体或页面当作一个以左上角为原点的坐标系。窗体或页面上的控件一开这个坐标系来布局。布局的办法就是调整控件在这个坐标系中的横纵坐标值。这样一来,控件与控件之间的关系要么就是相邻要么就是叠压。
WPF的控件有了Content的概念,所以控件与控件之间又多了一种关系——包含。也正是这种以窗体为根的包含关系,整个WPF的UI才形成树形结构,我们称之为可视化树(Visual Tree)
这个几个摞在一起的Button,当看到这个UI时,Windows Form程序员会想到把这几个Button叠加在一起,而WPF程序员除了可以使用与Windows Form程序员一样的办法外还多了一个选择——把一个Button作为另一个Button的Content。代码如下
<Grid Margin="10"> <Button Margin="10"> <Button Margin="10"> <Button Margin="10"> <Button Margin="10">OK</Button> </Button> </Button> </Button> </Grid>
但WPF程序员会遇到这样一个问题:用于构成UI的重要控件,如Window、UserControl、GroupBox、Button、Label等,都集中在ContentControl和HeaderedContentControl族里,但这两族只能接受一个元素作为自己的Content,如果想把这些控件里包含多个控件怎么办呢?这就用到了布局元素了。布局元素属于Panel族,这一个元素的内容属性时Children,即可以接受多个控件作为自己的内容并对这些控件进行布局控制。WPF的布局理念就是把一个布局元素作为ContentControl或HeaderedContentControl族控件的Content,再在布局元素里添加要被布局的子级控件,如果UI局部需要更复杂的布局,那就在这个区域放置一个子级的布局元素,形成布局元素的嵌套。
WPF中的布局元素有如下几个:
- Grid:网格,可以自定义行和列并通过行列的数量,行高和列宽来调整控件的布局。近似于HTML中的Tabel。
- StackPanel:栈式面板,可以包含的元素在竖直或水平方向上排成一条直线,当移除一个元素后,后面的元素会自定向前移动以填充空缺。
- Canvas:画布。内部元素可以使用以像素为单位的绝对坐标进行定位,类似于Windows Form编程的布局方式。
- DockPanel:泊靠式面板,内部元素可以选择泊靠方向,类似于Windows Form编程中的设置控件的Dock属性。
- WrapPanel:自动折行面板。内部元素在排满一行后能够自动折行,类似于HTML中的流式布局。
4.2、Grid
Grid元素会以网格的形式对内容元素们(即它的Children)进行布局。
Grid的特点如下:
- 可以定义任意数量的行和列,非常灵活。
- 行的高度和列的宽度可以使用绝对值、相对比例或自动调节的方式进行精确设定,并可以设置最大或最小值。
- 内部元素可以设置自己的行和列,还可以设置自己纵向跨几行、横向跨几列。
- 可以设置Children元素的对齐方向。
基于这些特点Grid适用的场合有:
- UI布局的大框架设计。
- 大量UI元素需要成行或者成列对齐的情况。
- UI整体尺寸改变时,元素需要保持固有的高度和宽度比例。
- UI后期可能有较大变更或扩展。
4.2.1、定义Grid的行与列
Grid类具有ColumnDefinitions和RowDefinitions两个属性,它们分别时ColumnDefinition和RowDefinition的集合,表示Grid定义了多少行和多少列。
<Grid Margin="10" x:Name="gridMain"> <Grid.RowDefinitions> <RowDefinition/> <RowDefinition/> <RowDefinition/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition/> <ColumnDefinition/> <ColumnDefinition/> </Grid.ColumnDefinitions> </Grid>
对应后台创建的C#代码
private void Window_Loaded(object sender, RoutedEventArgs e) { // 创建三行 gridMain.RowDefinitions.Add(new RowDefinition()); gridMain.RowDefinitions.Add(new RowDefinition()); gridMain.RowDefinitions.Add(new RowDefinition()); // 创建三列 gridMain.ColumnDefinitions.Add(new ColumnDefinition()); gridMain.ColumnDefinitions.Add(new ColumnDefinition()); gridMain.ColumnDefinitions.Add(new ColumnDefinition()); }
它的功能是将Grid定义为3列3行,在窗体设计器里你能看到这样的预览。
Grid可接受的宽度和高度单位
英文名称 | 中文名称 | 简写 | 换算 |
Pixel | 像素 | px(默认单位,可省略) | 图形基本单位 |
Inch | 英寸 | in | linch=96pixel |
Centimeter | 厘米 | cm | 1cm=(96/2.54)pixel |
Point | 点 | pt | 1pt=(96/72)pixel |
实际工作中使用什么单位要看程序具体功能,如果UI只用于显示在计算机屏幕上,那么像素最合适不过;如果程序设计打印输出,则公制单位选择厘米、英制单位使用英寸比较合适。
对于Grid的行高和列宽,我们可以设置三类值:
- 绝对值:double数值加单位后缀,特点是一经设置就不会改变,所以又称固定值
- 比例值:double数值后加一个星号(“*”),解析器会把所有比例值加起来作为分母、把每个比例值的数值作为分子,再用这个分数值乘以未被占用空间的像素值,得到的结果就是分配给这个比例值的最终像素数。比如一个总高度为150px的Grid,它包含5行,其中两行采用绝对值25px,其他三行分别时2*、1*、2*,使用上面的计算方法、这三行分配的像素应该是40px、20px和40px。比例值最大的特点是当UI的整体尺寸改变后,它会保持固有的比例。1*又可以简写为*。
- 自动值:字符串auto。行高或列宽的实际值是由行列内空间的高度和宽度决定的,也就是说控件会把行列“撑”到合适的宽度和高度。如果行列中没有控件,则行高列宽均为0。