WPF 你真的会写 XAML 吗?浅谈 ControlTemplate 、DataTemplate 和其它 Template
本文希望从写死的代码慢慢引入 WPF 的一些机制。
一、Button 难题
我们想要修改 Button 的背景色但是效果非常不理想,默认的 Button 样式是完全无法给大家看的,改造 Button 的方法是借助 Style 在 Template 中自定义 ControlTemplate(Style 并不关键)。
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid Width="100" Height="40"><Border Background="Aqua" /><TextBlock Text="Hello, WPF!" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
Style 在自定义控件的部分并不关键,实际上你完全可以使用 Button.Template 属性展开然后实现同样的效果,但是缺少复用性。
二、猿之手/猴爪难题与依赖属性
虽然它现在变得比较好看了,但是这个 Button 样式完全是一个植物人,成了一个赛博手办,好看固然是好看,甚至是可以执行 Click 事件来进行交互的,但是我们本来可以设置 <Button Background="Red"/>
不能用了。
之所以不能用,原因是因为你在 ControlTemplate 的地方确实没有让 Button 的 Background 发挥作用。
这种行为就好像下面的代码一样:
private void SayHello(string name)
{Console.WriteLine("hello, world!");
}
请问,在这个代码片段,一个名为 SayHello 的函数中,参数 name 的意义是什么?
为此,WPF 引入了依赖属性 DependencyProperty 的机制来让它就像函数的参数意义,能够为 ControlTemplate 里面的东西带来意义。
在 WPF 的控件中,大部分属性都属于依赖属性,对于 Button 来说,Background 是依赖属性,Content 也是。
我们是必不可少要学习自己创建自定义控件和自定义的依赖属性的,但是在这一部分,我们来看一下如何使用自带的依赖属性为原生控件进行自定义。
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid Width="100" Height="40"><Border Background="{TemplateBinding Background}" /><TextBlock Text="Hello, WPF!" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
总而言之,使用 {TemplateBinding XXX}
对控件的依赖属性进行使用,有的时候直接使用 TemplateBinding 可能无法生效,所以你可以使用下面的平替,下面这种的泛用性会更强,但是没有 TemplateBinding 的写法那么方便,属于是比较 Hack 的写法,我们在后面的介绍中有一处只有它才能实现的效果。
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid Width="100" Height="40"><Border Background="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Background}" /><TextBlock Text="Hello, WPF!" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
三、更好看的样子
当你知道了 Background 和 Content 都是依赖属性之后,我们目前没有做 TextBlock 呈现内容的参数化模板绑定,但是我想你也应该知道怎么做了。
你在进行编写的时候,可能会遇到智能提示的问题,你会发现你在为 TextBlock 的 Text 进行绑定的时候,可能会发现你在 VS 的智能提示的小窗里并没有办法找到 Content,这并不是 VS 出现了 BUG,VS 的消极反应也并不是在否定你,我们打算在美化完 UI 后,再来细讲为什么 VS 会如此的不配合,为什么在 TextBlock 的 Text 中绑定 Content 是一个不算对也不算错的行为。
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid><Border Background="{TemplateBinding Background}" CornerRadius="5" /><TextBlockHorizontalAlignment="Center"VerticalAlignment="Center"Text="{TemplateBinding Content}" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
你可以这样使用:
<ButtonWidth="100"Height="40"Background="LightGreen"Content="Hello WPF!"Style="{StaticResource Button_Test_Style}" />
四、我们的 IDE 到底在抗拒什么,会不配合我们的 XAML 编写?
在 VS 的 WPF 编辑环境中对于 控件模板 ControlTemplate 和 控件绑定的智能提示来自于 TargetType="Button"
这边对控件的指定,如果没有指定类型,智能提示会完全没有办法给你补全什么有效代码。
我们在写 Background 的时候很顺利但是在 写 Text 的时候遇到了 VS 的阻挠,即便如此我们硬写还是写出来了。
效果还不错是吧?
你知道吗,WPF 的很多控件是支持嵌套的,就像下面这样:
代码就像这样:
<Button Width="100" Height="100"><TextBlock Text="hello, world!" />
</Button>
你会发现你无法为它再赋予 Content 了,编辑器会告诉你属性重复,也就是说,你在 xaml 里面写的 Text 属性为"hello, world!" 的文本块 TextBlock 控件,已经是 Button 的 Content 属性了。
所以,我想要说什么?
Content 这个依赖属性能描述的不仅是一个字符串,它实际上能描述一个对象,它的类型其实是 object,我们去看它的定义就可以知道:
public object Content { get; set; }
正是因为 Content 是 object 类型,而 Text 属性接收的要求是 string 字符串,所以 VS 在智能提示的时候并不认为它们俩合适,所以我们在智能提示的时候根本找不到它。
那,为什么我们直接写还是能够生效?
对于 Text 这种字符串类型来说,我们恰好传的是 string 字符串这个对象,瞎猫碰上死耗子,自然就没有发现问题。
所以,如果我们的自定义样式有内部嵌套的对象,它在使用 TemplateBinding 写法的我们的样式里,是完全没有反应的。
如果说你真的要让只能接收到 string 的 Text 依赖属性,被迫吃下那么一坨,不就是 object 吗,只要是个 object ,使用 ToString() 转成字符串不就好了么。于是,你可以使用上文提到的那种非常冗长的写法。
于是就会有这样的效果:
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid><Border Background="{TemplateBinding Background}" CornerRadius="5" /><TextBlockHorizontalAlignment="Center"VerticalAlignment="Center"Text="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Content}" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
<ButtonWidth="100"Height="100"Background="LightGreen"Style="{StaticResource Button_Test_Style}"><TextBlock Name="PART_TextBlock" Text="hello, world!" />
</Button>
五、真正的 Button
为了实现 Button 内部控件的嵌套和对象的嵌套呈现,用我们目前的 TextBlock 来呈现内容是完全不可取的。
实际上一个标准的 Button 实现会使用 ContentPresenter 来呈现,ContentPresenter 本身具备的 Content 依赖属性才是 Button 的 Content 的依赖属性最终的去处。
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid><Border Background="{TemplateBinding Background}" CornerRadius="5" /><ContentPresenterHorizontalAlignment="Center"VerticalAlignment="Center"Content="{TemplateBinding Content}" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
这个样式的效果是这样的:
代码:
<ButtonWidth="100"Height="100"Background="LightGreen"Style="{StaticResource Button_Test_Style}"><TextBlock Name="PART_TextBlock" Text="hello, world!" />
</Button>
六、Content object 的样子
1. 若我掏出自定义对象,你该如何应对?
我们现在能知道的是,对于字符串类型的 Content 会显示一串文字,如果填入的是控件内容,它会显示控件 UI 的样子,用来为按钮创建图标等相关需求的时候会非常有用。
可是,object 也就意味着是所有类型,我们自己的 class 实例对象给它会是什么样子?
让我们在 Button 初始化的时候在 C# Code-Behind 代码的部分创建一些内容吧!
这是我们自定义的类:
public class Person
{public string Name { get; set; }public int Age { get; set; }
}
请注意 xaml 中关于 Loaded="Button_Loaded"
的部分。
<ButtonWidth="100"Height="100"Background="LightGreen"Loaded="Button_Loaded"Style="{StaticResource Button_Test_Style}" />
这是事件订阅后要执行的事情。
private void Button_Loaded(object sender, RoutedEventArgs e)
{var button = sender as Button;if (button is null) return;// 虽然可以写成 button!.Content 但是怕各位看不懂button.Content = new Person() { Name = "小明", Age = 18 };
}
这是效果:
因为我们的项目叫做 WPFPlayground,所以 Person 这个对象被 ToString()
后得到的结果是 WPFPlayground.Person
因为尺寸有限所以目前呈现的是这样。
我们希望把信息显示出来,你可以这样完善 Person 的内容:
public class Person
{public string Name { get; set; }public int Age { get; set; }public override string ToString(){return $"{Name}: {Age} !!!!!";}
}
效果就会变成这样:
2. 若我想要给这个数据自定义外观,你又该如何应对?
但是有的时候,我们希望呈现的数据也是有布局和 UI 的。
因为自定义数据的属性并不是依赖属性,所以上面在控件模板中介绍的那些方法,TemplateBinding 之类的做法在这边就完全失效了。
面对这个数据的外观定义,你要使用 ContentTemplate 来做。
当然,ContentTemplate 内容模板和 ControlTemplate 控件模板长得很像,但是不一样的概念,ContentTemplate 和 Content 是一对内容,相互配合才能实现最好的效果,作为 Content 的好兄弟,ContentTemplate 自然也是依赖属性,在 ContentPresenter 中发挥作用。
我们说了这么多话,到底想要说什么?
我想说的是,光写 Button 中的 ContentTemplate 是没有用的,因为实际承担工作的是 你的 ControlTemplate 控件模板的 ContentPresenter,你需要把这个依赖属性作为参数传递进去,写成这个样子:
<Style x:Key="Button_Test_Style" TargetType="Button"><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Grid><Border Background="{TemplateBinding Background}" CornerRadius="5" /><ContentPresenterHorizontalAlignment="Center"VerticalAlignment="Center"Content="{TemplateBinding Content}"ContentTemplate="{TemplateBinding ContentTemplate}" /></Grid></ControlTemplate></Setter.Value></Setter>
</Style>
然后,我们将开始面对 DataTemplate 了。
我们为 Button 在更新了 Style 后,编写了对应的 ContentTemplate。
ContentTemplate 的类型是 DataTemplate,集合容器所采用的 ItemTemplate 类型也是 DataTemplate 其实也是 DataTemplate,因为其中的原理其实就是每一项套了一个 ContentPresenter 内容呈现器。
<ButtonWidth="100"Height="100"Background="LightGreen"Loaded="Button_Loaded"Style="{StaticResource Button_Test_Style}"><Button.ContentTemplate><DataTemplate DataType="local:Person"><StackPanel><TextBlock Background="Yellow" Text="{Binding Name}" /><TextBlockBackground="Aqua"FontSize="20"Text="{Binding Age}" /></StackPanel></DataTemplate></Button.ContentTemplate></Button>
会变成这样的效果:
于是,在控件和主题上,你可以为 ControlTemplate 方面打一个坚实的底子用于项目风格的复用,而在自定义数据特别是业务数据的呈现上,以 ContentTemplate 为代表的 DataTemplate,会为你带来业务上的拓展性。
七、所谓集合面板 ItemsControl、ListBox
1. 每一项的外观样子使用 ItemTemplate 定义
我们来创建一个毫无通知能力,只是好看的 ViewModel,并不实现 INotifyPropertyChanged。
public class MainViewModel
{public List<Person> Persons { get; set; }public MainViewModel(){Persons = new List<Person>(){new Person(){ Name = "小明", Age = 18},new Person(){ Name = "小红", Age = 17},new Person(){ Name = "小黄", Age = 16},new Person(){ Name = "小亮", Age = 11},new Person(){ Name = "小军", Age = 19},new Person(){ Name = "小帅", Age = 30},new Person(){ Name = "小马", Age = 6},};}
}
我相信你知道怎么绑定上下文,所以这边只做了绑定的部分:
<ItemsControl ItemsSource="{Binding Persons}" />
我们来看一下效果:
你可以注意到的是,集合容器控件中呈现的样子,和我们刚才描述的关于 ContentPresenter 和 Content 的机制是完全一致的。
让我们把上面写的 ContentTemplate 中的 DataTemplate 交给 ItemsControl 的 ItemTemplate 中去。
<ItemsControl ItemsSource="{Binding Persons}"><ItemsControl.ItemTemplate><DataTemplate DataType="local:Person"><StackPanel><TextBlock Background="Yellow" Text="{Binding Name}" /><TextBlockBackground="Aqua"FontSize="20"Text="{Binding Age}" /></StackPanel></DataTemplate></ItemsControl.ItemTemplate>
</ItemsControl>
现在的效果就是这样的:
具体 ItemTemplate 生效的原因来自于 ContentPresenter,参看源码:https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/ItemsControl.cs,a32a4ab17d3998f0,references
2. 容器的外观
你可能会对 ItemsControl 和 ListBox 的默认的 StackPanel 纵向布局感到不满。
这个时候你可以使用 ItemsPanel 创建 ItemsPanelTemplate 对象,它是属于 ControlTemplate 相似的,和数据无关的 Template。
默认情况如果写出来是这样的:
<ItemsControl ItemsSource="{Binding Persons}"><ItemsControl.ItemTemplate><DataTemplate DataType="local:Person"><StackPanel><TextBlock Background="Yellow" Text="{Binding Name}" /><TextBlockBackground="Aqua"FontSize="20"Text="{Binding Age}" /></StackPanel></DataTemplate></ItemsControl.ItemTemplate><ItemsControl.ItemsPanel><ItemsPanelTemplate><StackPanel /></ItemsPanelTemplate></ItemsControl.ItemsPanel></ItemsControl>
我如果在使用它,一般我会使用它的 WrapPanel 和 Horizontal StackPanel。
2.1 WrapPanel 效果
效果:
WrapPanel 的可折叠性。
代码:
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
2.2
效果:
代码:
<ItemsControl.ItemsPanel><ItemsPanelTemplate><StackPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
八、所谓 DataGrid 和 CellTemplate
这是 DataGrid 的默认样子:
<DataGrid ItemsSource="{Binding Persons}" />
除了显示一些栏位字段属性之外,你可能希望能本来的数据源中显示一些别的控件,比如我们上面定义的 DataTemplate,来帮助我们更加可视化的看到数据。
你可以实现这样的效果:
代码如下:
<DataGrid ItemsSource="{Binding Persons}"><DataGrid.Columns><DataGridTemplateColumn Header="可视化的样子"><DataGridTemplateColumn.CellTemplate><DataTemplate DataType="local:Person"><StackPanel><TextBlock Background="Yellow" Text="{Binding Name}" /><TextBlockBackground="Aqua"FontSize="20"Text="{Binding Age}" /></StackPanel></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn></DataGrid.Columns>
</DataGrid>
推荐用法:可以用来显示重要程度比如红色绿色、进度百分比等等效果,具体想要可视化什么取决于业务需求。
九、总结
属性名 | 类型 | 用法 |
---|---|---|
Template | ControlTemplate | 在 Control 控件中的 Template 属性,是 WPF 最为基础的内容,是所有控件的可复用性的保障,通常和 Style 搭配,编写 ControlTemplate 就好比在 xaml 中编写函数一样,具有非常重要的工程意义。 |
ContentTemplate | DataTemplate | 所有 ContentControl(如 Button)实现,会在控件模板 ControlTemplate 中的某些 ContentPresenter 将会承担解析和呈现它们的任务,前提是你需要传递过去。 |
ItemTemplate | DataTemplate | 集合容器之所以能够呈现内容就是因为它每一项都是一个 ContentPresenter,容器将会把定义的 ItemTemplate 信息交给每一个 ContentPresenter 的 ContentTemplate 中,把每一项的数据信息交给 ContentPresenter 的 Content 中,最后实现列表项的呈现,具体会有 ItemsPresenter 的参与 |
ItemsPanel | ItemsPanelTemplate | 属于 ItemsControl 和 ListBox 等数据呈呈现容器,用于描述每一项应该如何排布,默认是 StackPanel Vertical 布局,你完全可以改成 WrapPanel,Canvas,StackPanel Horizontal,Grid 等等的布局容器 |
CellTemplate | DataTemplate | WPF 出于 DataGrid 更好可视化的角度为你提供的办法 |