文章目录
一、前言
图形用户界面(GUI,Graphic User Interface)应用较之控制台界面(CUI,Command User Interface)应用程序最大的好处就是界面友好、数据显示直观。CUI程序中数据只能以文本的形式线性显示,GUI程序则允许数据以文本、列表、图形等多种形式立体显示。
用户体验在GUI程序设计中起着举足轻重的作用——用户界面设计成什么样子看上去才够漂亮?控件如何安排才简单易用并且少犯错误?(控件并不是越复杂越好)这些都是设计师需要考虑的问题。WPF系统不但支持传统Windows Forms(简称WinForm)编程的用户界面和用户体验设计,更支持使用专门的设计工具Microsoft Expression Blend进行专业设计,同时还推出了以模板为核心的新一代设计理念(这是2010年左右的书,在那时是新理念,放现在较传统.NET开发也还行,不属于落后的技术)。
本章我们就一同来领略WPF强大的模板功能的风采。
二、模板的内涵
从字面上来看,模板(Template)就是“具有一定规格的样板”,有了模板,就可以依照它制造很多一样的实例。我们常把看起来一样的东西称为“一个模子里刻出来的”就是这个道理。然而,WPF中模板的内涵远比这个要深刻。
Binding和基于Binding的数据驱动界面是WPF的核心部分,依我看,WPF最精彩的部分就是模板。
从哲学来谈,“形而上者谓之道,形而下者谓之器”,大意是说世间万物的形象上抽象的结果就是思维,形象之下掩盖的则是本质。显然,古人已经注意到“形”是连接本质和思维的枢纽,让我们把这句话引入计算机世界。
- “形而上者谓之道”指的就是基于现实世界对万物进行抽象封装、理顺它们之间的关系,这个“道”不就是面向对象的思想吗!如果把面向对象思想进一步提升、总结出对象之间的组合关系,“道”就继续上升为设计模式思想。(可见设计模式层次之高,程序员在动手实现功能前一定要理清将要实现功能所涉及类的关系)
- “形而下者谓之器”指的是我们能观察到的时间万物都是物质本质内容的表现形式,“本质与表现”或者说“内容与形式”是哲学范畴内的一对矛盾统一体。(如果没有好好学马原的哲学部分,这段应该看起来比较痛苦吧)
软件开发之“道”并非本书研究的主要内容,本书研究的是WPF。WPF全称Windows Presentation Foundation,而WPF的核心是P-Presentation,它的意思就是外观、呈现,也就是说在WindowsGUI程序这个尺度上,WPF扮演的是“形”的角色、是程序的外在“形式” (所以WPF是个图形框架),而程序的“内容”仍然是数据结构&算法构成的业务逻辑。与WPF类似,WinForms和ASP.NET都是程序的表现形式。(通俗说就是这三者都是做界面的)
让我们把尺度缩小到WPF系统内部。这个系统与程序内容(业务逻辑)的边界是Binding,Binding把数据源源不断地从程序内部送出来、交由界面元素来显示,又把从界面元素收集来的数据传送回程序内部。界面元素间的沟通依靠路由事件来完成,有时候路由事件和附加事件也会加入到数据的传输中。让我们思考一个问题:WPF作为Windows程序的表示方式,它究竟在表示什么?换句话说,WPF作为一种“形式”,它要表现的“内容”究竟是什么?答案是:程序的数据结构和算法——Binding传递的是数据,事件参数携带的也是数据;方法和委托(Delegate,可以理解为一串要执行的方法)的调用是算法,事件传递消息也是算法…数据在内存里就是一串串数字或字符,算法是一组组看不见摸不着地抽象逻辑,如何恰如其分地把他们展现给用户呢?
假如想表达一个bool类型数据,同时还想表达用户可以在这两个值之间自由切换这样一个算法,你会怎么做?你一定会想到使用一个CheckBox控件来满足要求;再比如颜色值实际上是一串数字,而用户基本不可能只看这串数字就能想象出真正的颜色,而且用户也不希望只能靠输入字符来设置颜色值,这时,颜色值这一“数据内容”的恰当表现形式就是一个填充着真实颜色的色块,而用户既可以输入值又可以用取色管取色来设置颜色的“算法内容”,恰当的表达方式是创建一个ColorPicker(取色器)控件。相信你已经发现,控件(Control)是数据内容表现形式和算法内容表现形式的双重载体。换句话说,控件既是数据的表现形式让用户可以直观地看到数据,又是算法的表现形式让用户方便地操作逻辑。
作为“表现形式”,每个控件都是为了实现某种用户操作算法和直观显示某种数据而生,一个控件看上去是什么样子由它的“算法内容”和“数据内容”决定,这就是内容决定形式。这里,我们引入两个概念:
- 控件的“算法内容”:指控件能展示哪些数据、具有哪些方法、能响应哪些操作、能激发什么事件,简而言之就是控件的功能,它们是一组相关的算法逻辑。
- 控件的“数据内容”:控件所展示的具体数据是什么。
以往GUI开发技术(如WinForms和ASP.NET)中,控件内部的逻辑和数据是固定的,程序员不能改变;对于控件的外观,程序员能做的改变也非常有限,一般也就是设置控件的属性,想改变控件的内部结构是不可能的。如果想扩展一个控件的功能或者更改其外观让其更适应业务逻辑,哪怕只有一丁点改变,也需要创建控件的子类或者创建用户控件(UserControl)。造成这个局面的根本原因就是数据和算法的“形式”和“内容”耦合的太紧了。(简单讲,就是显示和功能难以拆分)
在WPF中,通过引入模板(Template)微软将数据和算法的“内容”与“形式”解耦了。WPF中的Template分为两大类:
- ControlTemplate是算法内容的表现形式(表现的内部逻辑是一样的,不过是如何表现的问题,bool类型变量用CheckBox还是ToggleButton的例子非常好理解,它展示的是逻辑,而不是强调数据的值),一个控件怎样组织其内部结构才能让它更符合业务逻辑、让用户操作起来更舒服就是由它来控制的。它决定了控件“长成什么样子”,并让程序员有机会在控件原有的内部逻辑基础上扩展自己的逻辑。
- DataTemplate是数据内容的表现形式,一条数据显示成什么样子,是简单的文本还是直观的图形动画就由它来决定。
下面让我们欣赏例子。
WPF中的控件不再具有固定的形象,仅仅是算法内容或数据内容的载体。你可以把控件理解为一组操作逻辑穿上了一套衣服,换套衣服它就变成了另外一个模样。你看到的控件默认形象实际上就是出厂时微软为它穿上的默认服装。看到下面图中的温度计,你是不是习惯性地猜想这是一个由若干控件和图形拼凑起来的UserControl呢?实际上它是一个ProgressBar控件,只是设计师为它设计了一套新衣服——这套衣服改变了一些颜色、添加了一些装饰品和刻度线并移除了脉搏动画,
~~~~
WPF中的数据显示成什么样子也可以自由设定。比如下面这张图,只是为数据条目准备了一个DataTemplate,在这个DataTemplate中用Binding把一个TextBlock的Text属性关联到数据对象的Year属性上、把一个Rectangle地Width属性和另一个TextBlock的Text属性关联到数据对象的Price属性上,并使用StackPanel和Grid对这几个控件布局。一旦应用这个DataTemplate,单调的数据就变成了直观的柱状图。以往这项工作不但需要先创建用于显示数据的UserControl,还要为UserControl添加显示/回写数据的代码。
~~~~
如果别的项目组也喜欢这个柱状图,你要做的事情仅仅是把DataTemplate地XAML代码发给他们。
<DataTemplate>
<Grid>
<StackPanel Orientation="Horizontal">
<Grid>
<Rectangle Stroke="Yellow" Fill="Orange" Width="{Binding Price}"/>
<TextBlock Text="{Binding Year}"/>
</Grid>
</StackPanel>
</Grid>
</DataTemplate>
~~~~ 我想,尽管你还没有学习什么是DataTemplate,但借着前面学习的基础也一样能看个八九不离十。
三、数据外衣DataTemplate
~~~~
同样一条数据,比如具有Id、Name、PhoneNumber、Address等属性的Student实例,放在GridView里有时可能就是简单的文本、每个单元格只显示一个属性;放在ListBox里有时为了避免单调可以在最左端显示64*64的头像,再将其他属性分两行排列在后面;如果是单独显示一个学生的则可以用类似简历的复杂格式来展现学生的全部数据。一样的内容可以用不同的形式来展现,软件设计称之为“数据-视图”(Data-View)模式。以往的开发技术,如MFC、WinForms、ASP.NET等,视图要靠UserControl(用已有的控件元素组装成新的控件)来实现,WPF不但支持UserControl还支持用DataTemplate为数据形成视图。别以为DataTemplate有多难,从UserControl升级到DataTemplate一般就是复制、粘贴一下再改几个字符的事儿。
~~~~
DataTemplate常用的地方有3处,分别是:
- ContentControl的ContentTemplate属性,相当于给ContentControl的内容穿衣服
- ItemControl的ItemTemplate属性,相当于给ItemsControl的数据条目穿衣服
- GridViewColumn的CellTemplate属性,相当于给GridViewColumn单元格里的数据穿衣服
~~~~
让我们用一个例子对比UserControl与DataTemplate的使用。例子实现的需求是这样的:有一列怪兽数据,这列数据显示在一个ListBox里,要求ListBox的条目显示怪兽的图标和简要参数,单击某个条目后在窗体的详细内容区域显示怪兽的照片和详细参数。
~~~~
无论是使用UserControl还是DataTemplate,怪兽种族的Logo和怪兽的照片都是要用到的,所以现在项目中建立资源管理目录并把图片添加进来。Logo的文件名与怪兽种族名一致,照片的文件名则与怪兽名一致。组织结构如下图。
~~~~ 首先创建Monster数据类型:
public class Monster
{
public string Name { get; set; }
public string Power { get; set; }
public string Race { get; set; }
public float Weight { get; set; }
}
~~~~ 为了在ListBox里显示Monster类型数据,我们需要准备一个UserControl,命名为MonsterListItemView。这个UserControl由一个Monster类型实例在背后支持,当设置这个实例的时候,界面元素将实例的属性值显示在各个控件里。MonsterListItemView地XAML部分代码如下:
<UserControl x:Class="WPFDataTemplateDemo.MonsterListItemView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WPFDataTemplateDemo"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Grid Margin="2">
<StackPanel Orientation="Horizontal">
<Image x:Name="imageLogo" Grid.RowSpan="3" Width="64" Height="64"/>
<StackPanel Margin="5,10">
<TextBlock x:Name="textBlockName" FontSize="16" FontWeight="Bold"/>
<TextBlock x:Name="textBlockWeight" FontSize="14"/>
</StackPanel>
</StackPanel>
</Grid>
</UserControl>
~~~~ MonsterListItemView用于支持前台显示属性的C#代码如下:
private Monster _monster;
public Monster Monster
{
get => _monster;
set
{
_monster = value;
this.textBlockName.Text = _monster.Name;
this.textBlockWeight.Text = _monster.Weight.ToString();
string strUri = string.Format(@"/Resources/Logos/{0}.png", _monster.Race);
this.imageLogo.Source = new BitmapImage(new Uri(strUri, UriKind.Relative));
}
}
~~~~ 类似的原理,我们要为Monster类型数据准备一个详细信息的视图。UserControl名称为MonsterDetailView,XAML部分代码如下:
<UserControl x:Class="WPFDataTemplateDemo.MonsterDetailView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WPFDataTemplateDemo"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Border BorderBrush="Black" BorderThickness="1" CornerRadius="6">
<StackPanel Margin="5">
<Image x:Name="imagePhoto" Width="400" Height="250"/>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Name:" FontWeight="Bold" FontSize="20"/>
<TextBlock x:Name="textBlockName" FontSize="20" Margin="5,0"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Race:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockRace" Margin="5,0"/>
<TextBlock Text="Weight:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockWeight" Margin="5,0"/>
<TextBlock Text="Power:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockPower" Margin="5,0"/>
</StackPanel>
</StackPanel>
</Border>
</UserControl>
~~~~ 后台支持数据大同小异:
private Monster _monster;
public Monster Monster
{
get => _monster;
set
{
_monster = value;
this.textBlockName.Text = _monster.Name;
this.textBlockPower.Text = _monster.Power;
this.textBlockRace.Text = _monster.Race;
this.textBlockWeight.Text = _monster.Weight.ToString();
string strUri = string.Format(@"/Resources/Images/{0}.png", _monster.Name);
this.imagePhoto.Source = new BitmapImage(new Uri(strUri, UriKind.Relative));
}
}
~~~~ 最后把它们组装到主窗体上:
<Window x:Class="WPFDataTemplateDemo.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFDataTemplateDemo"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="650">
<StackPanel Orientation="Horizontal" Margin="5">
<local:MonsterDetailView x:Name="detailView"/>
<ListBox x:Name="listBoxMonster" Width="180" Margin="5,0"
SelectionChanged="ListBoxMonsters_SelectionChanged"/>
</StackPanel>
</Window>
~~~~ 窗体的后台代码如下:
public MainWindow()
{
InitializeComponent();
InitialMonsterList();
}
// 初始化ListBox
private void InitialMonsterList()
{
List<Monster> listMonster = new List<Monster>()
{
new Monster(){Race = "Slime", Name = "Slime", Weight = 10, Power = "5"},
new Monster(){Race = "Slime", Name = "grassSlime", Weight = 15, Power = "8"},
new Monster(){Race = "Slime", Name = "seedSlime", Weight = 2, Power = "10"},
new Monster(){Race = "Material", Name = "candle", Weight = 8, Power = "12"},
};
foreach(Monster monster in listMonster)
{
MonsterListItemView view = new MonsterListItemView();
view.Monster = monster;
this.listBoxMonster.Items.Add(view);
}
}
// 选项变化事件的处理器
private void ListBoxMonsters_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
MonsterListItemView view = e.AddedItems[0] as MonsterListItemView;
if (view != null)
{
this.detailView.Monster = view.Monster;
}
}
~~~~
运行程序并单击ListBox里的条目,效果如下图:
~~~~
很难说这样做是错的,但在WPF里如此实现需求真是浪费了数据驱动界面这一重要功能。我们常说的“把WPF当做WinForms来用”指的就是这种实现办法。这种方法对WPF最大的曲解在于没有借助Binding实现数据驱动界面,并且认为ListBox.Items属性里放置的是控件——这种曲解迫使数据在界面元素间交换并且程序员只能使用事件驱动方式来实现逻辑——程序员必须借助处理ListBox的SelectionChanged事件来推动MonsterDetailView来显示数据,而数据又是由MonsterListItemView控件转交给MonsterDetailView控件的,之间还做了一次类型转换。下图说明目前的事件驱动模式与期望中数据驱动界面模式的不同:
~~~~
事件驱动站在程序员的角度来看,就是用户操作控件(在控件上输入数据),然后控件会产生事件(触发事件处理器来)去改变自身或者其他控件。(流动在控件之间的是事件)
~~~~
数据驱动,则是用户通过控件去改变控件绑定的数据,因为数据变了,与该数据绑定的控件都会发生变化。(通过绑定让控件与数据建立联系,然后数据在控件间流动)
~~~~
显然,事件驱动是控件与控件之间的沟通或者说形式与形式之间的沟通,数据驱动则是数据于控件之间的沟通、是内容决定形式(符合哲学思想)。使用DataTemplate就可以很方便地把事件驱动模式升级为数据驱动模式。
~~~~
你是不是在担心前面写的代码会被删掉呢?不会的!由UserControl升级为DataTemplate时90%的代码可以原样拷贝,另10%可以放心删除,再做一点点改动即可。让我们开始吧!
~~~~
首先把两个UserControl的“芯”剪切出来,用<DataTemplate>标签封装,再放进主窗体地资源词典里。最重要的一点是为DataTemplate里的每个控件设置Binding,告诉各个控件应该关注数据的哪个属性。因为使用Binding在控件与数据间建立关联,免去了在C#代码中访问界面元素,所以XAML代码中的大多数x:Name都可以去掉,代码看上去也简洁不少。
~~~~
有些属性的值不能直接拿来用,比如怪兽的种族和名字不能直接用作图片的路径,这时就要使用Converter。有两种办法可以在XAML代码中使用Converter:
- 把Converter以资源的形式放在资源词典里(本例使用的方法)
- 为Converter准备一个静态属性,形成单件模式(单例模式),在XAML代码里使用{x:Static}标签扩展来访问。
~~~~ 我们的两个Converter代码如下:
// 怪兽种族转换为Logo图片路径
public class RaceToLogoPathConverter : IValueConverter
{
// 正向转换
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
string strUri = string.Format(@"/Resources/Logos/{0}.png", (string)value);
return new BitmapImage(new Uri(strUri, UriKind.Relative));
}
// 未被用到
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
// 怪兽名字转换为图片路径
public class NameToPhotoPathConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
string strUri = string.Format(@"/Resources/Images/{0}.png", (string)value);
return new BitmapImage(new Uri(strUri, UriKind.Relative));
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
~~~~ 有了这两个Converter之后我们就可以设计DataTemplate了。窗体完整的XAML如下:
<Window x:Class="WPFDataTemplateDemo.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFDataTemplateDemo"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="650">
<Window.Resources>
<!--Converters-->
<!--资源就两个要素,标签+资源关键字-->
<local:RaceToLogoPathConverter x:Key="r2l"/>
<local:NameToPhotoPathConverter x:Key="m2p"/>
<!--DaTaTemplate for Detail View-->
<DataTemplate x:Key="monsterDetailViewTemplate">
<Border BorderBrush="Black" BorderThickness="1" CornerRadius="6">
<StackPanel Margin="5">
<Image x:Name="imagePhoto" Width="400" Height="250"
Source="{Binding Name, Converter={StaticResource m2p}}"/>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Name:" FontWeight="Bold" FontSize="20"/>
<TextBlock x:Name="textBlockName" FontSize="20" Margin="5,0"
Text="{Binding Name}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Race:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockRace" Margin="5,0"
Text="{Binding Race}"/>
<TextBlock Text="Weight:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockWeight" Margin="5,0"
Text="{Binding Weight}"/>
<TextBlock Text="Power:" FontWeight="Bold"/>
<TextBlock x:Name="textBlockPower" Margin="5,0"
Text="{Binding Power}"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
<!--DataTemplate for Item View-->
<DataTemplate x:Key="monsterListItemTemplate">
<Grid Margin="2">
<StackPanel Orientation="Horizontal">
<Image x:Name="imageLogo" Grid.RowSpan="3" Width="64" Height="64"
Source="{Binding Race, Converter={StaticResource r2l}}"/>
<StackPanel Margin="5,10">
<TextBlock x:Name="textBlockName" FontSize="16" FontWeight="Bold"
Text="{Binding Name}"/>
<TextBlock x:Name="textBlockWeight" FontSize="14"
Text="{Binding Weight}"/>
</StackPanel>
</StackPanel>
</Grid>
</DataTemplate>
</Window.Resources>
<StackPanel Orientation="Horizontal" Margin="5">
<UserControl ContentTemplate="{StaticResource monsterDetailViewTemplate}"
Content="{Binding SelectedItem, ElementName=listBoxMonsters}"/>
<ListBox x:Name="listBoxMonsters" Width="180" Margin="5,0"
ItemTemplate="{StaticResource monsterListItemTemplate}"/>
</StackPanel>
</Window>
代码对于初学者稍微长了点但结构非常简单。其中最重要的两句是:
- ContentTemplate=“{StaticResource monsterDetailTemplate}”,相当于给一个普通的UserControl的数据内容穿上了一件外衣、让Monster类型数据以图文并茂的形式展现出来。这件外衣就是以x:Key="monsterDetailViewTemplate"标记的DataTemplate资源
感悟:其实学习之初,我觉得DataTemplate不是外衣,而应该是内容,原本的UserControl就像一个空壳,填充了DataTemplate之后,才有了数据,有了外观。但是回过头一想,原本的UserControl又是没有外观的,而正是通过它填充了ContentTemplate之后,才有了前端的图形,而且整个图形本质上还是一个UserControl,所以外衣这种说法也没有问题。这边说是数据的外衣,那就没有问题了。 - ItemTemplate=“{StaticResource monsterListItemTemplate}”,是把一件数据的外衣交给ListBox,当ListBox.ItemSource被赋值时,ListBox会为每个条目穿上这件外衣。这件外衣是以x:Key="monsterListItemTemplate"标记的DataTemplate资源。
~~~~ 因为不再使用事件驱动,而且给数据穿衣服的事儿也已自动完成,所以后台的C#代码就非常简单了。窗体的C#代码就只剩下这些:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
InitialMonsterList();
}
// 初始化ListBox
private void InitialMonsterList()
{
List<Monster> listMonster = new List<Monster>()
{
new Monster(){Race = "Slime", Name = "Slime", Weight = 10, Power = "5"},
new Monster(){Race = "Slime", Name = "grassSlime", Weight = 15, Power = "8"},
new Monster(){Race = "Slime", Name = "seedSlime", Weight = 2, Power = "10"},
new Monster(){Race = "Material", Name = "candle", Weight = 8, Power = "12"},
};
// 只用到了少量的前端代码,赋个数据源即可
this.listBoxMonsters.ItemsSource = listMonster;
}
}
~~~~
运行程序,效果如下,
与原先UserControl实现的没有任何区别。用户永远不知道程序员在后台用了什么技术和模式,但作为程序员,我们可以清楚地体会到使用DataTemplate可以让程序结构更清晰、代码更简洁、维护更方便。不夸张地说,是DataTemplate帮助彻底完成了“数据驱动界面”,让Binding和数据关联渗透到用户界面的每一个细胞中。(后台不再大量改变前台元素,前台只管绑定数据,后台只管改变数据,耦合度大大降低)
四、控件的外衣ControlTemplate
~~~~
每每提到ControlTemplate,我都会想起“披着羊皮的狼”这句话——披上羊皮之后,虽然上去看上去像只羊,但其行为仍然是匹狼。狼的行为指的是它会做吃别的动物、对着满月嚎叫等事情,控件也有自己的行为,比如显示数据、执行方法、激发事件等。控件的行为要靠编程逻辑来实现,所以也可以把控件的行为称为控件的算法内容。举个例子,WPF中的CheckBox与其基类ToggleButton在功能上几乎完全一样,但在外观上区别却非常大,这就是更换ControlTemplate的结果。经过更换ControlTemplate,我们不但可以制作出披着CheckBox外衣的ToggleButton,还能制作出披着温度计外衣的ProgressBar控件(进度条控件)。
Notes:
实际项目中,ControlTemplate主要有两大用武之地:
1. 通过更换ControlTemplate改变控件外观,使之具有更优的用户使用体验及外观。
2. 借助ControlTemplate,程序员与设计师可以并行工作,程序员可以先用WPF标准控件进行编程,等设计师的工作完成后,只需把ControlTemplate应用到程序中就可以了。
~~~~
较之传统的GUI开发,这两点都能极大地提高工作效率。第一点让程序更换皮肤变得非常容易,第二点则解决了团队分工与合作的问题。比如程序员A在开发一个物理实验仿真程序时需要一个温度计组件他请程序员B来制作这个组件,程序员B和设计师C共同完成组件开发。A可以要求B在实现这个组件时暴露的接口与ProgressBar保持一致并先用ProgressBar替代,这需要B使用装饰者模式小心编程,A还需要冒点小风险,万一B实现的接口与ProgressBar有出入,替换控件的时候就麻烦了(替换控件需要添加程序集引用、名称空间引用,本身就已经够麻烦了)。A也可以不要求B一定按照ProgressBar的接口来编程,A可以先去写别的部分,等B的工作完成后再读一读新控件的文档然后继续这部分工作,而实际工作中,有没有文档是一回事(相信大部分中小厂是没有文档的),读别人的文档或代码本身就挺浪费时间。使用ControlTemplate情况会好很多,A可以直接用ProgressBar、读着MSDN文档来编程,并请设计师C来完成一个让ProgressBar看起来像是温度计的ControlTemplate,C的工作完成后只需要把一段XAML代码拷贝到程序中并应用新的ControlTemplate,工作就完成了——省人、省时、省力、省心。
~~~~
那么如何为控件设计ControlTemplate呢?首先需要你了解控件的内部结构(这当然也是成本,你得多了解一些东西)。“在哪里可以查到控件的内部结构呢?”没有文档可以查,想知道一个控件的内部结构就必须把控件“打碎”了看一看。用于打碎控件、查看内部结构的工具就是MicroSoft Expression套装中的Blend。
4.1. 庖丁解牛看控件
~~~~ 柿子得找软的捏,剖析控件也得从结构简单的入手。TextBox和Button最简单,我们从这两个控件开始。运行Blend,新建一个WPF项目,先把窗体的背景色改为线性渐变,再在窗体的主容器Grid里面画上两个TextBox和一个Button。对于程序员来说,完全可以把Blend理解为一个功能更强大的窗体设计器,对于设计师来说,可以把Blend理解为会写XAML代码的Photoshop或者Fireworks。XAML代码:
<Window x:Class="WPFControlTemplateBlend.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFControlTemplateBlend"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.Background>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="Black"/>
<GradientStop Color="White" Offset="1"/>
</LinearGradientBrush>
</Grid.Background>
<TextBox Text="TextBox" Grid.Row="0" Margin="10"/>
<TextBox Text="TextBox" Grid.Row="1" Margin="10"/>
<Button Content="Button" Grid.Row="2" Margin="10"/>
</Grid>
</Window>
程序运行结果如下图:
~~~~
现在的TextBox方方正正、有棱有角,怎样让它的边框变为圆角矩形呢?传统的方法可能是创建一个UserControl并在TextBox外套上一个Border,然后还要声明一些属性和方法暴露封装在UserControl里的TextBox上。我们的办法是在TextBox上右击,在弹出菜单中选择Edit Template(编辑模板)->Edit a Copy(编辑副本)…,如图:
~~~~
之所以不选择Create Empty(创建空白页)是因为Create Empty是从头开始设计一个控件的ControlTemplate,新做衣服哪有改衣服快呀!单击菜单项后弹出资源对话框,尽管可以用C#代码来创建ControlTemplate,但绝大多数情况下ControlTemplate是由XAML代码编写的并放在资源词典里,所以才会弹出对话框询问你资源的x:Key(关键字)是什么、打算把资源放在哪里。作为资源,ControlTemplate可以放在三个地方:Application的资源词典里、某个界面元素的资源词典里,或者放在外部XAML文件中。我们选择把它放在Application的资源词典里以便统一管理,并命名为RoundCornerTextBoxStyle,如下图所示:
~~~~
单击OK(确定)(图中因为已经添加了,所以确定按钮是不可用状态)按钮便进入了控件的模板编辑状态。在Objects and Timeline(对象和时间线)面板中观察已经解剖开的TextBox控件,发现它是由一个border套着一个名为PART_ContentHost的ScrollViewer组成的。为了显示圆角矩形边框,我们只需要将最外层的Border,设置它的圆角弧度即可。
~~~~
更改后的核心代码如下:
<Style x:Key="RoundCornerTextBoxStyle" TargetType="{x:Type TextBox}">
<Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
<Setter Property="BorderBrush" Value="{StaticResource TextBox.Static.Border}"/>
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="KeyboardNavigation.TabNavigation" Value="None"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="AllowDrop" Value="true"/>
<Setter Property="ScrollViewer.PanningMode" Value="VerticalFirst"/>
<Setter Property="Stylus.IsFlicksEnabled" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBox}">
<Border x:Name="border" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" SnapsToDevicePixels="True" CornerRadius="5,5,5,5">
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Opacity" TargetName="border" Value="0.56"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="true">
<Setter Property="BorderBrush" TargetName="border" Value="{StaticResource TextBox.MouseOver.Border}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="true">
<Setter Property="BorderBrush" TargetName="border" Value="{StaticResource TextBox.Focus.Border}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsInactiveSelectionHighlightEnabled" Value="true"/>
<Condition Property="IsSelectionActive" Value="false"/>
</MultiTrigger.Conditions>
<Setter Property="SelectionBrush" Value="{DynamicResource {x:Static SystemColors.InactiveSelectionHighlightBrushKey}}"/>
</MultiTrigger>
</Style.Triggers>
</Style>
~~~~
这段代码又如下几个看点:
~~~~
看点一,作为资源的不是单纯的ControlTemplate而是Style,说是编辑ControlTemplate但实际上是把ControlTemplate包含在Style里,不知道微软以后会不会更正这个小麻烦(目前十几年了没改)。Style是什么?简单讲就是一组<Setter>,也就是一组属性设置器。回想一下WinForms编程的时候,窗体设计器不是能生成这样的代码吗:
private void InitializeComponent()
{
// ...
// textBox1
this.textBox1.Location = new System.Drawing.Point(12, 12);
this.textBox1.Name = "textBox1";
this.textBox1.Size = new System.Drawing.Size(100, 20);
this.textBox1.TabIndex = 0;
// ...
}
~~~~ 同样的逻辑如果在XAML里出现就变成了这样:
<Style>
<Setter Property="pName1" Value="value"/>
<Setter Property="pName2" Value="value"/>
<Setter Property="pName3">
<Setter.Value>
<!--Object Value-->
</Setter.Value>
</Setter>
</Style>
~~~~
使用Style时,如果Value的值比较简单,那就直接用Attribute值(Property=“xxx”)来表示,如果Value值不能用一个简单的字符串描述就需要使用XAML的属性对象语法。例子中,TextBox的Template属性是一个ControlTemplate对象,如此复杂的值只能使用属性对象的语法来描述。对于Style,后面会有专门的章节来介绍。
~~~~
看点二,Border添加了CornerRadius=“5”。
~~~~
看点三,TemplateBinding。ControlTemplate最终被应用到一个控件上,我们称这个控件为模板目标控件或模板化控件(Templated Control),ControlTemplate里的控件可以使用TemplateBinding(一般外部控件的绑定是Binding,可见内部控件的绑定与外部控件还是做了一定区分的)将自己的属性值关联在目标控件的某个属性上,必要的时候还可以添加Converter。例如Background="{TemplateBinding Background}"这句,意思是让Border的Background与模板目标控件保持一致。产生的效果就是你为模板的目标控件设置Background属性,Border的Background也会跟着变。你会发现TemplateBinding的功能与{Binding RelativeSource={RelativeSource TemplatedParent}}一致(其实就是填写它父亲的值)。
~~~~
好了!把我们设计的圆角Style应用到两个TextBox上,代码如下:
<Window x:Class="WPFControlTemplateBlend.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFControlTemplateBlend"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.Background>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="Black"/>
<GradientStop Color="White" Offset="1"/>
</LinearGradientBrush>
</Grid.Background>
<TextBox Style="{DynamicResource RoundCornerTextBoxStyle}" Text="TextBox" Grid.Row="0" Margin="10"/>
<TextBox Style="{DynamicResource RoundCornerTextBoxStyle}" Text="TextBox" Grid.Row="1" Margin="10"/>
<Button Content="Button" Grid.Row="2" Margin="10"/>
</Grid>
</Window>
~~~~
程序的运行效果如下图,是不是感觉圆角的TextBox更和谐呢?
~~~~
以同样的方法“打碎”Button,你会发现Button的内部结构与TextBox的差不多。但如果“打碎”一个ProgressBar,你会发现它的内部结构就复杂得多了(相比于书上少了许多结构,不知道是不是因为十几年来,微软将ProgressBar的结构改变了),如下图所示:
~~~~
在Blend里你可以通过控件后面的“眼睛”图表控制控件的显隐,这样就能区分出每个子控件的用途,这也是学习控件设计的好方法。如果把这个ProgressBar改造成一个温度计,只需要在此基础上添加一个背景、更改进度指示器控件的前景色、再在合适的控件外面套上一个画出刻度的Grid(刻度可以根据要求计算出来也可以是固定的)。
~~~~
不知道大家意识到没有,其实每个控件本身就是一棵UI元素树。之前说过,WPF的UI元素可以看做两棵树——逻辑树(LogicalTree)和可视元素树(VisualTree),这两棵树的交点就是ControlTemplate。如果把界面上的控件元素看作是一个节点,那这些元素构成的就是逻辑树,如果把控件元素内部由ControlTemplate生成的控件也算上,那构成的就是可视元素树。换句话说,在逻辑树上导航不会进入到控件内部,而在可视化元素树上导航则可检索到控件内部由ControlTemplate生成的子级控件。
4.2. ItemControl的PanelTemplate
~~~~
ItemsControl(条目控件)具有一个名为ItemsPanel的属性,它的数据类型为ItemsPanelTemplate。ItemsPanelTemplate也是一种控件Template,它的作用就是让程序员有机会控制ItemsControl的条目容器。
~~~~
举例而言,我们的印象中ListBox中的条目都是自上而下排列的,如果客户要求我们制作一个条目水平排列的ListBox怎么办呢?WPF之前,我们只能重写控件比较底层的方法和属性(这也是以前想要使用一些高级特性,动不动就要改底层代码,使得这种技能的门槛很高),而现在我们只需要调整ListBox的ItemsPanel属性,请看下面代码。
~~~~
这是一个没有经过调整的ListBox,条目纵向排列。
<Grid Margin="6">
<ListBox>
<TextBlock Text="Allan"/>
<TextBlock Text="Kevin"/>
<TextBlock Text="Drew"/>
<TextBlock Text="Timothy"/>
</ListBox>
</Grid>
~~~~ 如果我把代码改成这样:
<Grid Margin="6">
<ListBox>
<!--ItemsPanel-->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<TextBlock Text="Allan"/>
<TextBlock Text="Kevin"/>
<TextBlock Text="Drew"/>
<TextBlock Text="Timothy"/>
</ListBox>
</Grid>
~~~~
条目就会包装在一个水平排列的StackPanel中,从而横向排列,如下图:
题外话:说实话在XAML中第一次用ItemsPanel改属性也还是觉得有点麻烦,这种多层嵌套去访问内部元素的属性。不过相比重写控件底层代码来讲,确实简单太多,你都不需要懂那么多语法和控件元素间的关系,直接通过博客或者官方文档,访问到属性修改即可。
五、DataTemplate与ControlTemplate的关系与应用
5.1. DataTemplate与ControlTemplate的关系
Notes:
这节的学习一定要注意多关注几个类的关系(Control/ContentControl/ContentPresenter等),以及属性的类型(Template/ContentTemplate)
~~~~
学习过DataTemplate和ControlTemplate,你应该已经体会到,控件只是个数据和行为的载体、是个抽象的概念,至于它本身会长成什么样子(控件内部结构)、它的数据会长成什么样子(数据显示结构)都是靠Template生成的。决定控件外观的是ControlTemplate,决定数据外观的是DataTemplate,它们正是Control类的Template(类型是ControlTemplate)和ContentTemplate(类型是DataTemplate,在ContentControl类中)两个属性的值(所以在命名上也是很符合哲学思想的,Content是内容,那就是与数据&算法这个程序的核心相关的;而控件更有本身外在形式的感觉)。
它们的作用范围如下图:
(上文先讲的DataTemplate,给人感觉好像是控件属性绑定对象数据后再组合一样,而ControlTemplate就是把控件敲碎后改内部属性,这样一看DataTemplate反而像是一个外部的概念-控件之间的组合,更大一些。但实际上DataTemplate关联的是控件的某个属性,作用的是数据,关心的是控件的内容(比如 textbox的text)。ControlTemplate管的是控件本身外观性的内容(Background、border))。从下图可以看出来,两者不存在内外之分,只是关注的部分不同,ControlTemplate关注控件本身,DataTemplate关注控件的内容。
~~~~
凡是Template,最终都是要作用在控件上的,这个控件就是Template的目标控件,也叫模板化控件(Templated Control)(Template属性的类型是ControlTemplate,是控件模板,自然是作用在控件上的)。你可能会问:“DataTemplate的目标应该是数据啊,怎么会是控件?”DataTemplate给人的感觉的确是施加在了数据对象上,但施加在数据对象上生成的一组控件总得有个载体吧?这个载体一般是落实在一个ContentPresenter(内容呈现者)对象上。ContentPresenter类只有一个ContentTemplate属性、没有Template属性,这就证明了承载由DataTemplate生成的一组控件是它的专门用途。
~~~~
至此我们可以看出,由ContentTemplate生成的控件树其树根就是ContentTemplate的目标控件,此模板化控件(TemplatedControl)的Template属性值就是这个ContentTemplate实例;与之相仿,由DataTemplate生成的控件树其树根是一个ContentPresenter控件,此模板化控件的ContentTemplate属性值就是这个DataTemplate实例。因为ContentPresenter控件是ControlTemplate控件树上的一个节点,所以DataTemplate控件树是ControlTemplate控件树的一棵子树(这边就理解为这个控件也不只是由DataTemplate构成的,还有其他的组成元素)。它们的关系如下图所示:
~~~~
既然Template生成的控件树都有根,那么如何找到树根呢?办法很简单,每个控件都有名为TemplatedParent的属性,如果它的值不为null,说明这个控件是由Template自动生成的,而属性值就是应用了模板的控件(模板的目标,模板化控件)。如果由Template生成的控件使用了TemplateBinding获取属性值,则TemplateBinding的数据源就是应用了这个模板的目标控件。(你组合出来的模板树依附的对象)
~~~~
回顾一下本章开头的DataTemplate实例代码:
<DataTemplate>
<Grid>
<StackPanel Orientation="Horizontal">
<Grid>
<Rectangle Stroke="Yellow" Fill="Orange" Width="{Binding Price}"/>
<TextBlock Text="{Binding Year}"/>
</Grid>
<TextBlock Text="{Binding Price}" Margin="5,0"/>
</StackPanel>
</Grid>
</DataTemplate>
~~~~ 这里用到的是普通Binding而不是TemplateBinding,那数据源又是谁呢?不知大家还是否记得,当为一个Binding只指定Path不指定Source时,Binding会沿着逻辑树一直向上找、查看每个节点的DataContext属性,如果DataContext引用的对象具有Path指定的属性名,Binding就会把这个对象当作自己的数据源。显然,如果把数据对象赋值给ContentPresenter的DataContext属性,由DataTemplate生成的控件自然会找到这个数据对象并把它当做自己的数据源。(ContentPresenter是DataTemplate的父亲,所以向上找的时候就会找到ContentPresenter的DataContext)
5.2. DataTemplate与ControlTemplate的应用
~~~~
为Template设置其应用目标有两种方法,一种是逐个设置控件的Template/Contemplate/ItemsTemplate/CellTemplate等属性,不想应用Template的控件不设置;另一种是整体应用,即把Template应用在某个类型的控件或数据上。
~~~~
把ControlTemplate应用在所有目标上需要借助Style来实现,但Style不能标记x:Key,例如下面代码:
<Window x:Class="WPFControlTemplateUse.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFControlTemplateUse"
mc:Ignorable="d"
Title="MainWindow" Height="250" Width="400">
<Window.Resources>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBox}">
<Border x:Name="Bd" SnapsToDevicePixels="True"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5">
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="Margin" Value="5"/>
<Setter Property="BorderBrush" Value="Black"/>
<Setter Property="Height" Value="25"/>
</Style>
</Window.Resources>
<StackPanel>
<TextBox/>
<TextBox/>
<TextBox Style="{x:Null}" Margin="5"/>
</StackPanel>
</Window>
~~~~
Style没有x:Key标记,默认为应用到所有由x:Type指定的控件上,如果不想应用则需把控件的Style标记为{x:Null}。运行效果如下图。
~~~~
把DataTemplate应用在某个数据类型上的方法是设置DataTemplate的的DataType属性,并且DataTemplate作为资源时也不能带有x:Key标记。例如下面的代码:
<Window x:Class="WPFDataTemplateUse.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFDataTemplateUse"
xmlns:c="clr-namespace:System.Collections;assembly=mscorlib"
mc:Ignorable="d"
Title="MainWindow" Height="300" Width="300">
<Window.Resources>
<!--DataTemplate-->
<DataTemplate DataType="{x:Type local:Unit}">
<Grid>
<StackPanel Orientation="Horizontal">
<Grid>
<Rectangle Stroke="Yellow" Fill="Orange" Width="{Binding Price}"/>
<TextBlock Text="{Binding Year}"/>
</Grid>
<TextBlock Text="{Binding Price}" Margin="5,0"/>
</StackPanel>
</Grid>
</DataTemplate>
<!--数据源-->
<c:ArrayList x:Key="ds">
<local:Unit Year="2001年" Price="100"/>
<local:Unit Year="2002年" Price="120"/>
<local:Unit Year="2003年" Price="140"/>
<local:Unit Year="2004年" Price="160"/>
<local:Unit Year="2005年" Price="180"/>
<local:Unit Year="2006年" Price="200"/>
</c:ArrayList>
</Window.Resources>
<StackPanel>
<ListBox ItemsSource="{StaticResource ds}"/>
<ComboBox ItemsSource="{StaticResource ds}" Margin="5"/>
</StackPanel>
</Window>
~~~~ 代码中DataTemplate的目标数据类型和ListBox的条目类型都是Unit:
public class Unit
{
public int Price { get; set; }
public string Year { get; set; }
}
~~~~
此时DataTemplate会自动加载到所有Unit类型对象上,尽管我并没有为ListBox和ComboBox指定ItemsTemplate,一样会得到下图效果:
~~~~
很多时候数据是以XML形式存储的,如果把XML节点转换成CLR数据类型再应用DataTemplate就太麻烦了。DataTemplate很智能,具有直接把XML数据节点当做目标对象的功能——XML数据中的元素名(标签名)可以作为DataType,元素的子节点和Attribute可以使用XPath来访问。下面的代码使用XmlDataProvider作为数据源(其XPath指出的必须是一组节点),请注意细节之处的变化:
<Window x:Class="WPFDataTemplateUseXML.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFDataTemplateUseXML"
mc:Ignorable="d"
Title="DataTemplate"
SizeToContent="WidthAndHeight">
<Window.Resources>
<!--DataTemplate-->
<DataTemplate DataType="Unit">
<Grid>
<StackPanel Orientation="Horizontal">
<Grid>
<Rectangle Stroke="Yellow" Fill="Orange"
Width="{Binding XPath=@Price}"/>
<TextBlock Text="{Binding XPath=@Year}"/>
</Grid>
<TextBlock Text="{Binding XPath=@Price}" Margin="5,0"/>
</StackPanel>
</Grid>
</DataTemplate>
<!--数据源-->
<XmlDataProvider x:Key="ds" XPath="Units/Unit">
<x:XData>
<Units xmlns="">
<Unit Year="2001" Price="100"/>
<Unit Year="2002" Price="120"/>
<Unit Year="2003" Price="140"/>
<Unit Year="2004" Price="160"/>
<Unit Year="2005" Price="180"/>
</Units>
</x:XData>
</XmlDataProvider>
</Window.Resources>
<StackPanel>
<ListBox ItemsSource="{Binding Source={StaticResource ds}}"/>
<ComboBox ItemsSource="{Binding Source={StaticResource ds}}" Margin="5"/>
</StackPanel>
</Window>
~~~~ XML最大的优势是可以方便地表示带有层级的数据,比如“年纪->班级->小组”或“主菜单->次级菜单->三级菜单”。同时,WPF准备了TreeView和MenuItem控件来显示层级数据。能够帮助层级控件显示层级数据的模板是HierarchicalDataTemplate。这部分例子单独分篇讲(因为本质上是一样的,属于应用了,本文的目的是对整个模板有框架性理解,而不是细致入微)。
5.3. 寻找失落的控件
~~~~
“井水不犯河水”常用来形容两个组织之间界限分明、互不相干,逻辑树与控件内部这棵小树之间就保持着这样的关系。换句话说,如果UI元素树上有个x:Name="TextBox1"的控件,某个控件内部也有一个由Template生成的x:Name="TextBox1"的控件,它们并不冲突,逻辑树不会看到控件内部的细节,控件内部元素也不会去理会控件外面有什么。你可能会想:“这样一来,万一我想从外界访问Template内部的控件、获取它的属性值,岂不是做不到了?”放心,WPF为我们准备了访问控件内部小世界的入口,现在就让我们出发去寻找那些失落的控件!
题外话:丢失的控件Lost Controls,我想到了war3中的一张图 Lost Temple-失落的神庙。
~~~~ 由ControlTemplate或DataTemplate生成的控件都是“由Template生成的控件”。ControlTemplate和DataTemplate两个类派生自 FrameWorkTemplate 类,这个类有个名为FindName(template.FindName)的方法供我们检索其内部控件。也就是说,只要我们能拿到Template,找到其内部控件就不成问题。对于ControlTemplate对象,访问其目标控件的Template属性就能拿到,但想拿到DataTemplate对象就要费一番周折了。千万不要以为ListBoxItem或者ComboBoxItem容器就是DataTemplate的目标控件哦!因为控件的Template属性和ContentTemplate属性可是两码事。
~~~~
我们先来寻找由ControlTemplate生成的控件。首先设计一个ControlTemplate并把它应用在一个UserControl上。界面上还有一个Button,在它的Click事件处理器中我们检索由ControlTemplate生成的代码。
~~~~
程序的XAML代码如下:
<Window x:Class="WPFLostControl_1.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFLostControl_1"
mc:Ignorable="d"
Title="MainWindow" Height="172" Width="300">
<Window.Resources>
<ControlTemplate x:Key="cTmp">
<StackPanel Background="Orange">
<TextBox x:Name="textBox1" Margin="6"/>
<TextBox x:Name="textBox2" Margin="6,0"/>
<TextBox x:Name="textBox3" Margin="6"/>
</StackPanel>
</ControlTemplate>
</Window.Resources>
<StackPanel Background="Yellow">
<UserControl x:Name="uc" Template="{StaticResource cTmp}" Margin="5"/>
<Button Content="Find by Name" Width="120" Height="30" Click="Button_Click"/>
</StackPanel>
</Window>
~~~~ Button的Click事件处理器代码如下:
private void Button_Click(object sender, RoutedEventArgs e)
{
TextBox tb = this.uc.Template.FindName("textBox1", this.uc) as TextBox;
tb.Text = "Hello WPF";
StackPanel sp = tb.Parent as StackPanel;
(sp.Children[1] as TextBox).Text = "Hello ControlTemplate";
(sp.Children[2] as TextBox).Text = "I can find you!";
}
~~~~
运行程序并单击按钮,效果如下:
~~~~
接下来我们来寻找由DataTemplate生成的控件。不过在正式开始之前,请大家先思考一个问题:寻找一个由DataTemplate生成的控件后,我们想从中获取哪些数据,如果单纯想获得与用户界面相关的数据(比如控件的宽度、高度等),这么做是正确的;但如果想获得与业务逻辑相关的数据,那就要考虑程序的设计是不是出问题了——因为WPF采用数据驱动UI逻辑,获取业务逻辑数据的事情在底层就能做到,一般不会跑到表层来找。
~~~~
先来看一个简单的例子。作为业务逻辑数据的类如下:
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public string Skill { get; set; }
public bool HasJob { get; set; }
}
~~~~ 界面XAML代码如下:
<Window x:Class="WPFLostControl_2.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFLostControl_2"
mc:Ignorable="d"
Title="DataTemplate_2" Height="175" Width="220">
<Window.Resources>
<!--数据对象-->
<local:Student x:Key="stu" Id="1" Name="Timothy" Skill="WPF" HasJob="True"/>
<!--DataTemplate-->
<!--每次写数据模板的时候都会觉得它里面不是一些绑定了属性的控件么,确实就是将数据以各种控件展现出来-->
<DataTemplate x:Key="stuDT">
<Border BorderBrush="Orange" BorderThickness="2" CornerRadius="5">
<StackPanel>
<TextBlock Text="{Binding Id}" Margin="5"/>
<TextBlock x:Name="textBlockName" Text="{Binding Name}" Margin="5"/>
<TextBlock Text="{Binding Skill}" Margin="5"/>
</StackPanel>
</Border>
</DataTemplate>
</Window.Resources>
<!--主体布局-->
<StackPanel>
<!--这边思考Content和ContentTemplate关系-->
<ContentPresenter x:Name="cp" Content="{StaticResource stu}"
ContentTemplate="{StaticResource stuDT}" Margin="5"/>
<Button Content="Find" Margin="5,0" Click="Button_Click"/>
</StackPanel>
</Window>
~~~~ Button的Click事件处理器代码如下:
private void Button_Click(object sender, RoutedEventArgs e)
{
TextBlock tb = this.cp.ContentTemplate.FindName("textBlockName", this.cp) as TextBlock;
MessageBox.Show(tb.Text);
// Student stu = this.cp.Content as Student;
// MessageBox.Show(stu.Name);
}
~~~~
未被注释的代码是使用DataTemplate的FindName方法获取由DataTemplate生成的控件并访问其属性,被注释的代码是直接使用底层数据。显然,如果为了获取Student的某个属性,应该使用被注释的代码而不必绕到表层控件上来,除非你想得到的上控件的长度、宽度等与业务逻辑无关的纯UI属性。
~~~~
下面再来看一个复杂的例子。DataTemplate的一个常用之处是GridViewColumn的CellTemplate属性。把GridViewColumn放置在GridView控件里就可以生成表格了。GridViewColumn的默认CellTemplate是使用TextBlock只读性地显示数据,如果我们想让用户能修改数据或者使用CheckBox显示bool类型数据的话就需要自定义DataTemplate。
~~~~
还是先定义名为Student的类:
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public string Skill { get; set; }
public bool HasJob { get; set; }
}
~~~~ 准备数据集合、呈现数据的工作全部由XAML代码来完成:
<Window x:Class="WPFLostControl_3.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:c="clr-namespace:System.Collections;assembly=mscorlib"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFLostControl_3"
mc:Ignorable="d"
Title="MainWindow" Height="250" Width="400">
<Window.Resources>
<!--数据集合-->
<c:ArrayList x:Key="stuList">
<local:Student Id="1" Name="Suika Re" Skill="WPF" HasJob="True"/>
<local:Student Id="2" Name="Tom Zhang" Skill="BI/SQL" HasJob="True"/>
<local:Student Id="3" Name="Zhong guan" Skill="Writing" HasJob="False"/>
<local:Student Id="4" Name="ShanShan" Skill="C#/Java" HasJob="False"/>
<local:Student Id="5" Name="Ping Zhang" Skill="Writing" HasJob="False"/>
</c:ArrayList>
<!--DataTemplate-->
<DataTemplate x:Key="nameDT">
<TextBox x:Name="textBoxName" Text="{Binding Name}"/>
</DataTemplate>
<DataTemplate x:Key="skillDT">
<TextBox x:Name="textBoxSkill" Text="{Binding Skill}"/>
</DataTemplate>
<DataTemplate x:Key="hjDT">
<CheckBox x:Name="checkBoxJob" IsChecked="{Binding HasJob}"/>
</DataTemplate>
</Window.Resources>
<!--主体布局-->
<Grid Margin="5">
<ListView x:Name="listViewStudent" ItemsSource="{StaticResource stuList}">
<ListView.View>
<GridView>
<GridViewColumn Header="ID" DisplayMemberBinding="{Binding Id}"/>
<GridViewColumn Header="姓名" CellTemplate="{StaticResource nameDT}"/>
<GridViewColumn Header="技术" CellTemplate="{StaticResource skillDT}"/>
<GridViewColumn Header="已工作" CellTemplate="{StaticResource hjDT}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</Window>
~~~~
程序的运行效果如下图:
~~~~
然后,我们为显示姓名的TextBox添加GotFocus事件的处理器:
<DataTemplate x:Key="nameDT">
<TextBox x:Name="textBoxName" Text="{Binding Name}" GotFocus="TextBoxName_GotFocus"/>
</DataTemplate>
~~~~ 因为我们是在DataTemplate里添加事件处理器,所以界面上任何一个由此DataTemplate生成的TextBox都会在获得焦点时调用TextBoxName_GotFocus这个事件处理器。TextBoxName_GotFocus的代码如下:
private void TextBoxName_GotFocus(object sender, RoutedEventArgs e)
{
// 访问业务逻辑数据
TextBox tb = e.OriginalSource as TextBox;
ContentPresenter cp = tb.TemplatedParent as ContentPresenter;
Student stu = cp.Content as Student;
this.listViewStudent.SelectedItem = stu;
// 访问界面元素
ListViewItem lvi = this.listViewStudent.
ItemContainerGenerator.ContainerFromItem(stu) as ListViewItem;
CheckBox chb = this.FindVisualChild<CheckBox>(lvi);
MessageBox.Show(chb.Name);
}
private ChildType FindVisualChild<ChildType>(DependencyObject obj)
where ChildType : DependencyObject
{
for(int i = 0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
{
DependencyObject child = VisualTreeHelper.GetChild(obj, i);
if (child != null && child is ChildType)
return child as ChildType;
else
{
ChildType childOfChild = FindVisualChild<ChildType>(child);
if (childOfChild != null)
return childOfChild;
}
}
return null;
}
}
~~~~
当使用GridView作为ListView的View属性时,如果某一列使用TextBox作为CellTemplate,那么即使这列中的TextBox被鼠标单击并获得了焦点ListView也不会把此项作为自己的SelectedItem。所以,TextBoxName_GotFocus的前半部分就是先获得事件的最初源头(TextBox),然后沿UI元素树上溯到DataTemplate的目标控件(ContentPresenter)并获取它的内容,它的内容一定是一个Student实例。
~~~~
TextBoxName_GotFocus的后半部分则借助VisualTreeHelper类检索由DataTemplate生成的控件。前面说过,每个ItemsControl的派生类(如ListBox、ComboBox、ListView)都具有自己独特的条目容器,使用ItemsContainerGenerator.ContainerFromItem方法就能获得包装着指定条目数据的容器,本例中是一个包装着Student对象的ListViewItem(注意:此ListViewItem对象的Content也是Student对象)。可以把这个ListViewItem控件视为一棵子树的根,使用VisualTreeHelper类就能遍历它的各个节点。本例中是把遍历算法封装在了FindVisualChild泛型方法里。
~~~~
运行程序,并单击某个显示姓名的TextBox,效果如下:
~~~~
由本例可以看出,无论是从事件源头“自上而下”地找,还是使用ItemContainerGenerator.ContainerFromItem方法找到条目容器再“自上而下”地找,总之,找到业务逻辑数据(Student实例)并不难,而工作中大多数时候是操作业务逻辑数据。如果真的要寻找由DataTemplate生成的控件,对于结构简单的控件,可以使用DataTemplate对象的FindName方法;对于结构复杂的控件,则需要借助VisualTreeHelper来实现。
六、参考
《深入浅出WPF》,刘铁猛