说明:本系列基本上是《WPF揭秘》的读书笔记。在结构安排与文章内容上参照《WPF揭秘》的编排,对内容进行了总结并加入一些个人理解。
模板允许用任何东西完全替换一个元素的可视树,而不影响其他功能,WPF中每个控件的默认外观都定义于模板中(针对不同的Windows主题有不同的默认模板,详见皮肤与主题一节),这个特性对于开发自定义控件也是很重要。控件的功能代码与可视树代码是相分离的,基于这个出发点WPF中控件的大部分属性是控制功能的,而控制外观的属性尽可能的少,因为这个需要放到模板(包括默认模板中)去处理(也只有这样模板的设计才会更灵活)。
WPF提供了几种不同类型的模板,他们都派生自FrameworkTemplate抽象类。前面的章节介绍过DataTemplate(见数据绑定)及ItemsPanelTemplate类(见Item控件)
数据模板 - DataTemplate负责定制任何一个.NET对象的外观,这对于非UIElement控件很有用。因为非UIElement控件的默认模板仅仅为一个TextBlock,其中的内容是ToString()方法返回的字符串。
ItemsPanelTemplate用于ItemControl的ItemPanel来改变ItemControl中项的布局方式。
模板可以看作是一个复杂的样式,如在介绍样式的文章中我们看到过一个设置了较复杂内容的按钮控件:
<Button x:Name="btn" Width="60" Height="80">
<Button.Content>
<StackPanel Orientation="Vertical">
<Image Source="icon.jpg"/>
<TextBlock Text="Click!" Style="TextBlockStyle"/>
</StackPanel>
</Button.Content>
</Button>
如果我们想要把按钮包括Content中的内容都抽象出来,以便可以直接应用在其他按钮上,就可以借助模板技术实现。
模板也是定义于各级<Resource>中,同样也是使用<Style>标签来定义,甚至x:Key与TargetType的作用都相同。不同的是在模板中<setter>用来指定模板,后文代码中
<Setter Property="Template">
就表明了这是一个模板,而<Setter.Value>这个属性元素中,通过<ControlTemplate>完成了模板的定制。模板内容很简单,与上文<Button>几乎一致,ControlTemplate中最重要的部分就是粗体展示的部分,本质上它们是ControlTemplate的VisualTree内容属性的定义 - 这正应了前文所述,模板就是一个自定义的可视树:
<Style x:Key="ImageButton" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Button>
<Button.Content>
<StackPanel Orientation="Vertical">
<Image Source="icon.jpg" Height="48" Width="48"></Image>
<TextBlock Style="{StaticResource TextBlockStyle}" Text="Click!"></TextBlock>
</StackPanel>
</Button.Content>
</Button>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
这其中的TextBlockStyle如样式一文中所定义的那样:
<Style TargetType="TextBlock" x:Key="TextBlockStyle">
<Setter Property="FontFamily" Value="Comic Sans Ms"></Setter>
<Setter Property="Text" Value="Click!"></Setter>
<Setter Property="Foreground" Value="MediumBlue"></Setter>
<Setter Property="FontSize" Value="20"></Setter>
</Style>
现在定义一个按钮,并应用这个名为ImageButton的控件,就可以很容易的让按钮达到既定外观:
<Button x:Name="btn1" Style="{StaticResource ImageButton}" Canvas.Top="20"></Button>
这样我们可以很容易的生成相同外观的按钮:
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource ImageButton}"></Button>
<Button Style="{StaticResource ImageButton}"></Button>
<Button Style="{StaticResource ImageButton}"></Button>
</StackPanel>
效果图:
模板还有一个很重要的特性,我们由下面这段代码说起:
<Style x:Key="ImageButton" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<StackPanel Orientation="Vertical">
<Image Source="icon.jpg" Height="48" Width="48"></Image>
<TextBlock Style="{StaticResource TextBlockStyle}" Text="Click!"></TextBlock>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
与上文代码对比,你可能一眼就会看出,上面这段代码中模板不包含<Button>控件。但<style>中的TargetType表明这个模板是被应用于Button控件。同样是上文引用模板的XAML,如果换上旧的模板,就会呈现下面这种比较特殊的效果:
外观上看这不是一个按钮(因为模板中没有定义),而其属性与行为又是一个标准的按钮(虽然应用了不同的模板,但本质是按钮),如,在IDE中操作这个对象相关属性或事件时,会有完整的智能感知支持,毕竟这仍然是Button的一个实例。
通过模板的这个特性,我们可以*的将一个控件变成任意我们想要的样子。这个WPF/Silverlight一个著名的特性。
ControlTemplate的应用方法是将其设置给任何一个Control或Page的Template属性。
最后需要说,模板作用域与样式完全一致,不再赘述。
在样式的基础上,Template的Triggers集合属性中也可以包含各类触发器。下面的代码展示了如何在模板中定义一个触发器:
<ControlTemplate x:Key="buttonTemplate">
<Grid>
<Ellipse x:Name="outerCircle" Width="100" Height="100">
<Ellipse.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0" Color="Blue"/>
<GradientStop Offset="1" Color="Red"/>
</LinearGradientBrush>
</Ellipse.Fill>
</Ellipse>
<Ellipse Width="80" Height="80">
<Ellipse.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0" Color="White"/>
<GradientStop Offset="1" Color="Transparent"/>
</LinearGradientBrush>
</Ellipse.Fill>
</Ellipse>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Button.IsMouseOver" Value="True">
<Setter TargetName="outerCircle" Property="Fill" Value="Orange"/>
</Trigger>
<Trigger Property="Button.IsPressed" Value="True">
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX=".9" ScaleY=".9"/>
</Setter.Value>
</Setter>
<Setter Property="RenderTransformOrigin" Value=".5,.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
这样就以声明方式响应鼠标事件,并且可以批量应用到所有应用此模板的控件上。值得注意的是,触发器中第一个Setter使用TargetName属性显式指定了元素的名称。这里TargetName不能省略,因为如果省略此属性将使Setter作用到模板的目标元素-Button上,不但这不是我们期待的结果,而且Button也没有Fill属性,因此Setter会导致一个错误。(注意,这也要求模板中相应的控件使用x:Name定义了名称,可见代码中粗体部分)
提示:Trigger(另外包括EventTrigger与Condition)提供了SourceName属性用于将触发条件指定到模板中特定的子元素而不是整个模板上,从而可以根据某个子元素的变化定义效果。
注意:模板中使用x:Name命名的元素,主要目的是在触发器XAML中来引用它们(如上文所述),它们并不会成为能以编程方式访问的成员,这是因为同一时间这个模板会应用到多个不同的元素(除非将模板应用到一个目标后,使用模板的FindName方法来查找特定元素)。
模板目标类型
类似Style,设置ControlTemplate的Target也可以限制模板可以被应用的元素,也可以移除掉针对模板目标元素的Trigger的Property名称前的元素名(这听起来很拗口,例如对于前文的示例,如果我们指定了ControlTemplate的Target,则我们可以把<Trigger Property="Button.IsMouseOver" Value="True">使用<Trigger Property="IsMouseOver" Value="True">替换)以及模板的Setter中Property前的元素名(当然这个Setter也得是设置模板目标元素中属性的)。
下面是重写后的前文的XAML(粗体部分是TargetType带来的改进):
<ControlTemplate x:Key="buttonTemplate" TargetType="{x:Type Button}">
<Grid>
… …
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="outerCircle" Property="Fill" Value="Orange"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX=".9" ScaleY=".9"/>
</Setter.Value>
</Setter>
<Setter Property="RenderTransformOrigin" Value=".5,.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
如果上述代码不指定TargetType,目标类型会隐式设为Control。
与类型化Style不同的是,使用TargetType时也必须设置x:Key属性,由于没有默认控件模板的概念,但我们可以借助类型化样式,在其中设置模板来提供类似默认模板的功能。
更通用的模板
之前我们创建的模板应用到目标控件后,所有目标控件会有相同的内容。为了让控件模板的可重用性更高,我们需要让目标控件的属性可以有不同的值,我们分如下两种情况讨论实现。
-
对于目标控件是ContentControl,且我们需要让Content属性有独立的设置:
从本质上看,这其实是要将目标元素的值插入模板中的相应属性中。WPF提供了TemplateBindingExtension(当然使用时一般简写为TemplateBinding)大大简化了这个工作。
TemplateBinding是一个简化的Binding,其中数据源总是目标元素,而"路径"是目标元素中任何一个依赖属性,TemplateBinding的Property属性用于指定这个依赖属性。
对于上一个模板的例子,我们可以在模板中添加一个TextBlock,并为其Text属性指定一个数据绑定。如下:
<TextBlock Text="{TemplateBinding Property=Button.Content}"/>
这样每个目标元素的Content属性都会应用到模板的TextBlock中。
另外,由于TemplateBinding提供了一个重载的构造函数,可以接受依赖属性(为Property设置的值)作为参数,所以上面的示例也可简写为<TextBlock Text="{TemplateBinding Button.Content}"/>
当模板使用TargetType指定目标类型为Button时,代码还可以进一步简写为:
<TextBlock Text="{TemplateBinding Content}"/>
<ContentControl Content="{TemplateBinding Content}"/>
注意:这里更好的做法是使用ContentPresenter元素,而不是ContentControl控件,前者是专门为控件模板设计,更轻量。另外ContentPresenter会隐式将其Content属性设置为{TemplateBinding Content},因此,以上代码可进一步简化为<ContentPresenter />(当前这仅在模板显示指定TargetType为一个内容时有效)
注意:TemplateBinding仅可用于模板部分的可视树上,且绑定的依赖属性不能为可冻结对象(Freezable)的属性。对于大多数情况可以使用Binding(TemplateBinding只是一个使用起来方便但功能简单的Binding)。在一个模板中使用Binding的大致代码如:
<TextBlock Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Content}" />其中TemplatedParant即应用模板的目标元素,而Panel中指定任意想要绑定的属性。
- 对于其他属性
下面我们讨论下Content之外的Height,Width,Background或Padding等属性怎样在模板中重用。
下面我们看一段示例代码,粗体部分演示了如何将一些目标元素属性嵌入模板中:
<Ellipse x:Name="outerCircle">
<Ellipse.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0"
Color="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=Background.Color}"/>
<GradientStop Offset="1" Color="Red"/>
</LinearGradientBrush>
</Ellipse.Fill>
</Ellipse>
<Viewbox>
<ContentPresenter Margin="{TemplateBinding Padding}"/>
</Viewbox>
下面是3个应用了上述模板的按钮:
<Button Template="{StaticResource buttonTemplate}"
Height="100" Width="100" FontSize="80" Background="Black"
Padding="20" Margin="5">1</Button>
<Button Template="{StaticResource buttonTemplate}"
Height="150" Width="250" FontSize="80" Background="Yellow"
Padding="20" Margin="5">2</Button>
<Button Template="{StaticResource buttonTemplate}"
Height="200" Width="200" FontSize="80" Background="White"
Padding="20" Margin="5">3</Button>
这其中Background,Padding与Content属性使用数据绑定嵌入模板中,Width与Height会隐式应用到模板上,而FontSize由ContentPresenter隐式获得。
提示:TemplateBinding也支持值转换器,其提供Converter与ConverterParameter两个属性来支持这个特性。
我们再次回到前文模板的XAML中,模板中定义了一个触发器使按钮在鼠标悬停状态下呈现Orange,而这是一个硬编码的颜色,由于在目标元素中没有对应的属性,所以设法使用前文已经介绍的数据绑定的方式来实现。这里提供一个小技巧:
我们在Button中选一个没有使用到的与想要替换的属性相同类型的属性 - 如BorderBrush。我们将这个属性的值定义为想要再触发器中使用的颜色,然后我们修改触发器代码为如下这样:
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="outerCircle" Property="Fill" Value="{Binding RelativeSource={RelativeSource TemplatedParent},Path=BorderBrush}"/>
</Trigger>
注意,Trigger位于可视树之外,所以只能使用Binding而不是TemplateBinding。
提示:另外的实现方式;自定义一个继承自Button的控件,在其中添加一个新的属性。对应这个悬停颜色,或者定义多个模板,每个模板中硬编码不同的颜色。
模板与可视状态
在设计控件模板时,应该考虑一个控件所有的可视状态,并为每种可视状态定义不同的属性(可能是定义于模板上,也可能是定义于模板的触发器上按条件绑定)。对于按钮(包括大多数控件)可视状态包括像IsEnable,IsDefault等。对于前文的Button模板没有定义这个可视状态可能还不算太糟,但如果是CheckBox或ToggleButton,则必须为它们的Checked、UnChecked或中间状态定义模板,否则这个模板就没有任何意义,下面我们看一个有趣的例子:
我们给ProgressBar自定义一个模板,而这个模板必须反应ProgressBar不同的状态,如不同的进度状态,或IsEnable及IsIndeterminate等状态。前者可以通过绑定到应用模板的目标元素中表示进度的属性实现,而后者可以通过触发器在进入相应状态时改变模板的外观。这个自定义模板中我们以饼状图表示进度,下面是其XAML:
<ControlTemplate x:Key="progressPie" TargetType="{x:Type ProgressBar}"> <!-- Resources -->
<ControlTemplate.Resources>
<local:ValueMinMaxToPointConverter x:Key="converter1"/>
<local:ValueMinMaxToIsLargeArcConverter x:Key="converter2"/>
</ControlTemplate.Resources> <!-- Visual Tree -->
<Viewbox>
<Grid Width="20" Height="20">
<Ellipse x:Name="background" Stroke="{TemplateBinding BorderBrush}"
StrokeThickness="{TemplateBinding BorderThickness}"
Width="20" Height="20" Fill="{TemplateBinding Background}"/>
<Path x:Name="pie" Fill="{TemplateBinding Foreground}">
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="10,10" IsClosed="True">
<LineSegment Point="10,0"/>
<ArcSegment Size="10,10" SweepDirection="Clockwise">
<ArcSegment.Point>
<MultiBinding Converter="{StaticResource converter1}">
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum"/>
</MultiBinding>
</ArcSegment.Point>
<ArcSegment.IsLargeArc>
<MultiBinding Converter="{StaticResource converter2}">
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum"/>
</MultiBinding>
</ArcSegment.IsLargeArc>
</ArcSegment>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
</Grid>
</Viewbox> <!-- Triggers -->
<ControlTemplate.Triggers>
<Trigger Property="IsIndeterminate" Value="True">
<Setter TargetName="pie" Property="Visibility" Value="Hidden"/>
<Setter TargetName="background" Property="Fill">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="Yellow"/>
<GradientStop Offset="1" Color="Brown"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="pie" Property="Fill">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="Gray"/>
<GradientStop Offset="1" Color="White"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
如上面代码,我们把模板的可视树放入一个<viewbox>中,这种做法很常见,ControlTemplate(实际上所有的FrameworkTemplate)中也可以定义Resource,如上面代码中在<Resource>内定义了转换器(如之前所述,这个级别的Resource也可以避免可能出现的资源引用问题,同时这很好的使模板保持独立性)。
比较值得一提的,XAML中使用了带有转换器的MultiBinding,使Path可以根据ProgressBar的Value,Mininum和MaxNum三个值来更新状态,这其中的转换器定义如下:
public class ValueMinMaxToIsLargeArcConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter,
CultureInfo culture)
{
double value = (double)values[];
double minimum = (double)values[];
double maximum = (double)values[]; // 当值达到或超过范围的50%时返回true
return ((value * ) >= (maximum - minimum));
} public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
CultureInfo culture)
{
throw new NotSupportedException();
}
} public class ValueMinMaxToPointConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter,
CultureInfo culture)
{
double value = (double)values[];
double minimum = (double)values[];
double maximum = (double)values[]; // 将值等比转换为0到360之间的一个值
double current = (value / (maximum - minimum)) * ; // 调正弧线(ArcSegment)的结束为止以绘制一个整圆
if (current == )
current = 359.999; // 将当前位置逆时针移动90度,从而使0在圆圈的顶部开始
current = current - ; // 将角度转换为弧度
current = current * 0.017453292519943295; // 计算圆圈上的点
double x = + * Math.Cos(current);
double y = + * Math.Sin(current); return new Point(x, y);
} public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
CultureInfo culture)
{
throw new NotSupportedException();
}
}
根据可视化部分的介绍我们知道,在饼图超过半个圆时,ArcSegment的IsLargeArc属性须设为true,反之为false。所以第一个转换器就是根据ProgessBar的三个值来得到ArcSegment的IsLargeArc的值,而第二个转换器通过ProgressBar的三个值计算绘制饼图的Path在圆弧上的那一点。
最后是应用这个模板的ProgressBar的XAML:
<ProgressBar Foreground="{StaticResource foregroundBrush}" Width="100"
Height="100" Value="10" Template="{StaticResource progressPie}" Margin="10"/>
下面这个指明了ProgressBar处于Indeterminate状态:
<ProgressBar Foreground="{StaticResource foregroundBrush}" Width="100"
Height="100" Value="10" IsIndeterminate="True" Template="{StaticResource progressPie}" Margin="10"/>
这其中Foreground引用的资源的定义如:
<LinearGradientBrush x:Key="foregroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="LightGreen"/>
<GradientStop Offset="1" Color="DarkGreen"/>
</LinearGradientBrush>
运行后的应用了模板的Progress的效果如下:
提示:WPF中部分控件与定义给他们的控件模板有一些内定的沟通机制来实现一些行为,这些机制有些通过寻找模板中指定名称的元素("PART_XXX形式"),有些通过寻找指定类型元素的实现。像TextBox和PasswordBox都提供了这样的机制,当给它们提供了含有名为PART_Content的元素的模板时,这些控件可以开启一些内置机制从而使你的模板不必重新实现全部外观逻辑,反之你需要完成全部这些工作。
设置给ProgressBar控件的模板中如果存在名为PART_Indicator和PART_Track的元素,则控件会限制PART_Indicator的宽度(或高度,取决于ProgressBar的Orientation)是在PART_Track的宽度(或高度)的百分比范围内。这样在我们自定义ProgressBar模板时恰当的使用这个特性可以节省很多代码。
ComboBox对控件模板中名为PART_Popup和PART_EditableTextBox的控件也进行了特殊处理。对于Popup,当其关闭时会自动触发DropDownClosed事件。对于TextBox会自动与ComboBox整合在一起。
将模板与样式混合使用
在上文介绍模板的例子中,我们都是直接将资源中定义的模板直接应用到控件的Template属性上。另一种更常见的做法是在一个Style内部设置Control(即Style目标控件)的Template属性,并将该样式应用到指定的元素上。
在样式中设置模板如:
<Style TargetType="{x:Type Button}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
… …
</ControlTemplate>
</Setter.Value>
</Setter>
… …
</Style>
这种方式有如下几种好处:
- 将模板与其他需要设置的属性组合在一起设置。
- 实现默认模板的效果,如在一个类型化Style中包含一个自定义控件模板,借助Style,此模板会被应用到所有这种类型的控件上。
- 我们可以在样式中为模板需要的属性提供默认值,同时,我们又可以在控件中使用显式设置的属性覆盖样式中同名属性,从而使模板的属性达到一种定制的效果。我们看下面这个例子:
代码中模板内一个元素的Fill属性通过TemplateBinding标记扩展绑定到目标控件的Foreground,同时我们在Style中设置了Foreground属性作为默认值。(这样我们把Style中模板绑定到Style中的属性)而如上文所述,这里最值得注意的部分在于这个Style的使用:
<Style x:Key="pieStyle" TargetType="{x:Type ProgressBar}">
<Setter Property="Foreground">
<Setter.Value>
<LinearGradientBrush x:Uid="foregroundBrush" StartPoint="0.0" EndPoint="1,1">
<GradientStop Offset="0" Color="LightGreen"/>
<GradientStop Offset="1" Color="DarkGreen"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Path x:Name="pie" Fill="{TemplateBinding Foreground}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
提示:样式和模板的关系
在一个样式中包含一个模板时,可能会在不同的位置看到相同的属性设置。他们的优先级(大到小)依次为:Style中的触发器,Style中模板的触发器,Style中的Setter。
提示:对于模板没有像Style中BaseOn那样的属性让你可以用继承的方式快速得到一个模板。
我们可以使用下面的方法研究WPF框架模板的使用:
string xaml = XamlWriter.Save(control.Template);这个方法需要放在控件布局后调用,保证模板已经被应用。
通过下面这种方式可以获得整个元素的Style,该方法的核心是名为DefaultStyleKey的依赖属性:
//获得目标元素默认样式的键名
object defaultStyleKey = button.GetValue(FrameworkElement.DefaultStyleKeyProperty);
//在资源中通过用键名获得样式
Style style = (Style) Application.Current.FindResource(defaultStyleKey);
//序列化XAML到一个字符串
string xaml = System.Windows.Markup.XamlWriter.Save(style);而如果存在类型化样式可以用FindResource(typeof(Button))这样的代码得到样式(因为前文介绍过类型化样式使用类型名作为样式的key)。
另外Windows SDK中包含了WPF控件中使用的所有主题样式的XAML。
本文完
参考:
《WPF揭秘》