WPF——样式与模板

24 篇文章 21 订阅

一、引言

每次写blog一定是我遇到什么实际问题了。而这次的问题,和文章标题没有特别大的关系。因为上一篇文我讲到了自定义日历控件(Calendar)的事,在我改CalendarDayButtonStyle的过程中,我发现默认的Style中出现了大量的VisualState标签。但正如上文中所说,一个自身的WPF程序员或许对VisualState相关类也会感到陌生,更何况我呢。于是我决定,对VisualState相关类进行学习。

我打开微软官网,搜了下VisualState,首先在Windows App SDK的内容中看到了它,但是我现在是WPF中遇到的它丫。虽然从内容上看起来差不多,但是我还是想在WPF的相关条目里找到它。于是我又看了几篇文。在WPF的《Styles and Templates》章节中看到了VisualState的出现。但是,这个章节主要是介绍样式与模板的,VisualState的内容只有一点点。那怎么办?我想了想,反正样式与模板也没系统学过,用倒是已经在用了,不如借此机会,也好好学习一下。

二、样式与模板

WPF的样式与模板是一系列让开发和设计人员为他们的产品创建视觉上引人注目效果和一致外观的特性。当定制一个程序的外观时,你想要一个强大的样式与模板模型,它们能支持在程序内和程序间维护和共享外观。WPF就提供了这样的模型。

两个长难句,意思很简单,样式(style)和模板(Template)就是通用化外观的工具。

WPF样式模型的另一个特性是表示(Presentation)与逻辑(logic)的分离。

表示可以认为是程序的UI,逻辑可以认为是业务逻辑(后台逻辑)。
在传统WinForms中,在界面上拖拽控件,控件后台处理逻辑,而逻辑中有时也会有更改控件外观的代码,这就是把表示与逻辑给混起来用了。这样混合的方式在小项目或demo中,确实很方便,但是对于分工、对于可维护性,对于整个程序的结构来讲,却大打折扣了。所以将表示和逻辑分离是一种进步的体现。

设计人员可以只使用XAML来处理应用程序的外观,而开发人员可以使用C#或VB来处理编程逻辑。(这种想法在国内是挺理想化的,反正据我了解,大部分公司WPF开发前后台还是同一批人做的。但是使程序的分层更清晰确实是达到了。)

这篇文集中在程序的样式与模板方面,不讨论任何数据绑定的概念。(所以你最好要有一定的WPF基础,至少对Binding有个大概的概念)

还有学之前,最好把资源(Resource)了解一下,因为资源可以重用样式和模板。

1. 示例程序 Sample

本文中示例是一个基于简单照片的浏览程序,如下图所示:
在这里插入图片描述
这个简单的照片示例程序使用样式和模板创建视觉上良好的用户体验。该示例有两个TextBlock元素和一个绑定到图像列表的ListBox控件。

2. 样式 Style

你可以将Style(样式)视作一组属性值,它是一种能应用到多个元素上的便捷方法。你可以在派生自FrameworkElement或FrameworkContentElement的任何元素上使用Style,比如Window或Button。

声明一个样式最常见的方式是在XAML文件中的Resources段中用作资源。因为样式是资源,所以它们遵循适用于资源的所有规则。简单说,在哪里声明样式就会影响那里的应用样式。例如,如果你在程序(app)定义XAML文件的根元素中声明了一个Style,该Style就能在程序的任何地方使用。

例如,下面的XAML代码为TextBlock声明了两种样式,一种自动应用于所有TextBlock元素,另一种必须显式引用。

<!--在Window元素的资源中添加样式,并指定作用目标,这是一种很常用的方式-->
<Window.Resources>
    <!-- .... other resources .... -->

    <!--A Style that affects all TextBlocks-->
    <Style TargetType="TextBlock">
        <Setter Property="HorizontalAlignment" Value="Center" />
        <Setter Property="FontFamily" Value="Comic Sans MS"/>
        <Setter Property="FontSize" Value="14"/>
    </Style>
    
    <!--A Style that extends the previous TextBlock Style with an x:Key of TitleText-->
    <!--BaseOn有继承的意味,就是在原样式的基础上做修改-->
    <Style BasedOn="{StaticResource {x:Type TextBlock}}"
           TargetType="TextBlock"
           x:Key="TitleText">
        <Setter Property="FontSize" Value="26"/>
        <Setter Property="Foreground">
            <Setter.Value>
                <LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1">
                    <LinearGradientBrush.GradientStops>
                        <GradientStop Offset="0.0" Color="#90DDDD" />
                        <GradientStop Offset="1.0" Color="#5BFFFF" />
                    </LinearGradientBrush.GradientStops>
                </LinearGradientBrush>
            </Setter.Value>
        </Setter>
    </Style>
</Window.Resources>

下面是一个使用上文声明样式的例子:

<StackPanel>
    <TextBlock Style="{StaticResource TitleText}" Name="textblock1">My Pictures</TextBlock>
    <TextBlock>Check out my new pictures!</TextBlock>
</StackPanel>

在这里插入图片描述

3. 控件模板 ControlTemplates

在WPF中,控件的ControlTemplate定义了控件的外观。你可以通过定义新的ControlTemplate并将它分配给指定控件来改变控件的结构和外观。在许多情况下,模板为你提供了足够的灵活性,以至于你不需要编写自定义控件。

每个控件都有分配给该控件的Control.Template的默认模板。模板将控件的视觉外观与控件的功能连接起来。因为是在XAML中定义模板的,所以不需要编写任何代码就可以改变控件的外观。每个模板都是为特定控件设计的,比如Button。

通常,你可以在XAML文件的Resources部分将模板声明为资源。它所适用的规则就和所有资源一样。

控件模板比样式要复杂许多。这是因为控件模板重写了整个控件的视觉外观(visual appearance),而样式只是将现有控件的属性更改。不过,因为控件的模板是过设置Control.Template属性来应用的,所以你可以使用样式来定义或设置一个模板。

尽管Style是针对控件的属性的,
而Template是重写整个控件结构的,
就这一点来讲模板显然比样式复杂,但是模板本身也是控件的一个属性,所以Style也可以指定更改Control.Template,这个角度来看,好像也没有谁一定比谁复杂了?

Designers(设计器)通常允许你创建现有模板的副本并修改它。例如。在Visual Studio WPF设计器中,选择一个CheckBox控件,然后右键单击并选择编辑模板>创建一个副本。一顿操作下来就会生成定义模板的样式。

这种方法常用于修改一些自带的或第三方的控件,因为这些控件通常比较复杂,要从零开始实现并做修改比较花时间,所以你可以通过上面操作来生成它们的样式,然后更改其中部分既可。

<Style x:Key="CheckBoxStyle1" TargetType="{x:Type CheckBox}">
    <Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual1}"/>
    <Setter Property="Background" Value="{StaticResource OptionMark.Static.Background1}"/>
    <Setter Property="BorderBrush" Value="{StaticResource OptionMark.Static.Border1}"/>
    <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type CheckBox}">
                <Grid x:Name="templateRoot" Background="Transparent" SnapsToDevicePixels="True">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <Border x:Name="checkBoxBorder" Background="{TemplateBinding Background}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="1" VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
                        <Grid x:Name="markGrid">
                            <Path x:Name="optionMark" Data="F1 M 9.97498,1.22334L 4.6983,9.09834L 4.52164,9.09834L 0,5.19331L 1.27664,3.52165L 4.255,6.08833L 8.33331,1.52588e-005L 9.97498,1.22334 Z " Fill="{StaticResource OptionMark.Static.Glyph1}" Margin="1" Opacity="0" Stretch="None"/>
                            <Rectangle x:Name="indeterminateMark" Fill="{StaticResource OptionMark.Static.Glyph1}" Margin="2" Opacity="0"/>
                        </Grid>
                    </Border>
                    <ContentPresenter x:Name="contentPresenter" Grid.Column="1" Focusable="False" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" RecognizesAccessKey="True" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                </Grid>
                <ControlTemplate.Triggers>
                    <Trigger Property="HasContent" Value="true">
                        <Setter Property="FocusVisualStyle" Value="{StaticResource OptionMarkFocusVisual1}"/>
                        <Setter Property="Padding" Value="4,-1,0,0"/>

... content removed to save space ...

编辑模板的副本是了解模板工作方式的好方法。与创建新的空白模板相比,编辑模板和更改视觉表示的一部分显然更容易。

很多控件看起来很难实现,你生成它的副本后一看便知它内部结构,所以该方法也是学习控件实现思路的好方法。

3.1. TemplateBinding

你可能已经注意到,在上一节中定义的模板资源使用了TemplateBinding标记拓展。TemplateBinding是针对模板场景的一种优化形式的绑定,类似于使用

{Binding RelativeSource = {RelativeSource TemplatedParent}}

构造的绑定。TemplateBinding用于将模板的部分绑定到控件的属性。例如,每个控件都有一个BorderThickness属性。使用TemplateBinding来管理模板中受该控件设置影响的元素。

3.2. ContentControl和ItemsControl

如果一个ContentPresenter被声明在ContentControl的ControlTemplate中,那么该ContentPresenter会自动绑定到ContentTemplate和Content属性。同样,ItemsControl的ControlTemplate中的ItemsPresenter会自动绑定到ItemTemplate和Items属性。

4. 数据模板 DataTemplates

在本例的程序中,有一个ListBox控件,它绑定了一个照片的列表。

<ListBox ItemsSource="{Binding Source={StaticResource MyPhotos}}"
         Background="Silver" Width="600" Margin="10" SelectedIndex="0"/>

该ListBox现在看起来如下:
在这里插入图片描述
大部分控件都有某种类型的内容,而这些内容通常来自你绑定的数据。在本例中,数据是照片列表。在WPF中,使用DataTemplate来定义数据的视觉表示(即外观)。基本地,你放入DataTemplate的内容就决定了数据在渲染程序中的样子。

在示例程序中,每个自定义的Photo对象都有一个string类型的Source属性,用于指定图像文件的路径。现在,照片对象作为文件路径而呈现。

public class Photo
{
    public Photo(string path)
    {
        Source = path;
    }

    public string Source { get; }

    public override string ToString() => Source;
}

为了让照片以图像的形式显示,你需要创建一个DataTemplate作为资源。

<Window.Resources>
    <!-- .... other resources .... -->

    <!--DataTemplate to display Photos as images
    instead of text strings of Paths-->
    <DataTemplate DataType="{x:Type local:Photo}">
        <Border Margin="3">
            <Image Source="{Binding Source}"/>
        </Border>
    </DataTemplate>
</Window.Resources>

注意,DataType属性类似于Style的TargetType属性。如果你的DataTemplate在Resources中,当你为某个类型指定DataType属性并省略x:Key时,只要该类型出现,就会应用DataTemplate。当然,你始终可以选择使用x:Key来分配DataTemplate,然后将它设置给StaticResource(用于接受DataTemplate类型的属性,例如,ItemTemplate属性或ContentTemplate属性)。

本质上来讲,上面示例中的DataTemplate定义了“只要有Photo对象出现,它就应该表现为Border中的Image”。在该DataTemplate中,程序现在看起来是这样的:
在这里插入图片描述
数据模板(DataTemplate)模型提供了其他特性。例如,如果你正在使用HeaderedItemsControl类型(如Menu或TreeView)来显示包含其他集合的集合数据,则会有HierarchicalDataTemplate。另一个数据模板特性是DataTemplateSelector,它允许你根据自定义逻辑来选择使用的DataTemplate。

5. 触发器 Triggers

当属性值发生变化或引发事件时,trigger(触发器)会设置属性或启动动作,例如动画。Style、ControlTemplate和DataTemplate都有一个可以包含一组触发器的属性Triggers。触发器有多种类型。

5.1. PropertyTriggers

根据属性的值来设置属性值或启动动作(action)的触发器称为属性触发器,它就用Trigger来表示。

要演示如何使用属性触发器,可以让ListBoxItem部分透明(除非它被选中)。下面样式将ListBoxItem的Opacity(不透明度,1为不透明,0为全透明)设置为0.5;当IsSelected属性为true时,不透明度设为1.0。

<Window.Resources>
    <!-- .... other resources .... -->

    <Style TargetType="ListBoxItem">
        <Setter Property="Opacity" Value="0.5" />
        <Setter Property="MaxHeight" Value="75" />
        <Style.Triggers>
            <Trigger Property="IsSelected" Value="True">
                <Trigger.Setters>
                    <Setter Property="Opacity" Value="1.0" />
                </Trigger.Setters>
            </Trigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

本例使用一个Trigger来设置属性值,但是要注意,Trigger类还有EnterActions和ExitActions属性,这些属性使能触发器来执行操作。

注意,ListBoxItem的MaxHeight属性被设置为75。在下图中,第三项是被选中的。

在这里插入图片描述

5.2. EventTriggers和Storyboards

另一种类型的触发器是EventTrigger(事件触发器),它会根据事件的发生来启动一组动作。例如,下面的EventTrigger对象指定了当鼠标指针进入ListBoxItem区域时,MaxHeight属性在0.2s内会发生动画,值变为90。当鼠标离开选项时,属性会在1s内变回初始值。注意,不需要为MouseLeave动画指定目标值,因为动画能够自行跟踪到原始值。

<Style.Triggers>
    <Trigger Property="IsSelected" Value="True">
        <Trigger.Setters>
            <Setter Property="Opacity" Value="1.0" />
        </Trigger.Setters>
    </Trigger>
    <EventTrigger RoutedEvent="Mouse.MouseEnter">
        <EventTrigger.Actions>
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation
                        Duration="0:0:0.2"
                        Storyboard.TargetProperty="MaxHeight"
                        To="90"  />
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger.Actions>
    </EventTrigger>
    <EventTrigger RoutedEvent="Mouse.MouseLeave">
        <EventTrigger.Actions>
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation
                        Duration="0:0:1"
                        Storyboard.TargetProperty="MaxHeight"  />
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger.Actions>
    </EventTrigger>
</Style.Triggers>

在下图中,鼠标指向了第三项:
在这里插入图片描述

5.3. MultiTriggers、DataTriggers和MultiDataTriggers

除了Trigger和EventTrigger,还有其他类型的触发器。MultiTrigger允许你基于多个条件来设置属性值。当条件的属性是数据绑定时(data-bound),可以使用DataTrigger和MultiDataTrigger。

⭐6. 视觉状态 Visual States

控件始终是处于特定状态(specific state)中的。例如,当鼠标移至控件表面时,该控件就被认为处于MouseOver的常见状态。没有特定状态的控件被认为处于普通Normal状态。状态可以分组,前面提到的状态都是CommonStates这个状态组的一部分。大部分控件有两个状态组:CommonStates和FocusStates。在应用于控件的每个状态组中,控件始终处于每个组中的一个状态,例如CommonStates.MouseOver和FocusStates.Unfocused。一个控件不能在同一个组中处于两个不同的状态,例如不能处在CommonStates.Normal和CommonStates.Disabled中。下面是大多数控件能识别和使用的状态表:

VisualState NameVisualStateGroup Name描述
NormalCommonStates默认状态
MouseOverCommonStates鼠标指针在控件上
PressedCommonStates控件被点击
DisabledCommonStates控件被禁用
FocusedFocusStates控件有焦点
UnfocusedFocusStates控件无焦点

通过在控件模板的根元素上定义System.Windows.VisualStateManager,可以在控件进入特定状态时触发动画。VisualStateManager声明了要监视的VisualStateGroup和VisualState的组合。当控件进入监视状态时,VisualStateManager定义的动画就会启动。

例如,下面的XAML代码监视CommonStates.MouseOver状态,使得名为backgroundElement的元素的填充色发生动画。当控件回到CommonStates.Normal状态时,恢复backgroundElement元素的填充色。

<ControlTemplate x:Key="roundbutton" TargetType="Button">
    <Grid>
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup Name="CommonStates">
                <VisualState Name="Normal">
                    <ColorAnimation Storyboard.TargetName="backgroundElement"
                                    Storyboard.TargetProperty="(Shape.Fill).(SolidColorBrush.Color)"
                                    To="{TemplateBinding Background}"
                                    Duration="0:0:0.3"/>
                </VisualState>
                <VisualState Name="MouseOver">
                    <ColorAnimation Storyboard.TargetName="backgroundElement"
                                    Storyboard.TargetProperty="(Shape.Fill).(SolidColorBrush.Color)"
                                    To="Yellow"
                                    Duration="0:0:0.3"/>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>

        ...

7. 共享资源与主体 Shared resources和themes

一个典型的WPF程序可能有多个应用于整个程序的UI资源。总的来说,这组资源可以被认为是程序的主体。WPF通过使用封装为ResourceDictionary类的资源词典,来支持将UI资源打包为主题。

WPF主题是通过使用样式和模板机制来定义的,WPF公开了这些机制来定制任何元素的视觉效果。

WPF主体资源存储在嵌入式资源词典中。这些资源词典必须嵌入到已签名的程序集中,并且可以嵌入到与代码本身相同的程序集中,也可以嵌入到并列(side-by-side)的程序集中。PresentationFramework.dll,该程序集包含了WPF控件,主题资源位于一系列并列的程序集中。

当搜索元素的样式时,主题将是最后查找的位置。通常情况下,搜索将沿着元素树向上爬行以找到适当的资源,若找不到,则再查找程序的资源集,最后再查找系统。这给了程序开发者一个机会,使得搜索在到达主题之前,可以为树上的或程序上的任何对象重新定义样式。

你可以将资源词典定义为单独的文件,这样就能在多个程序中重用一个主题了。你还可以通过定义多个资源词典来创建可切换的主题,这些资源词典提供相同类型的资源,但是值不同。在应用程序的层级重新定义样式或其他资源是程序换肤推荐的方法。

要跨程序共享一组包含样式和模板的资源,可以创建一个XAML文件,并定义一个ResourceDictionary,该资源词典包含对share.xaml的引用。

<ResourceDictionary.MergedDictionaries>
  <ResourceDictionary Source="Shared.xaml" />
</ResourceDictionary.MergedDictionaries>

它是share.xaml的共享,它本身定义了一个包含一组样式和笔刷资源的ResourceDictionary,使程序的控件有着一致的外观。


三、结尾

通过本文的学习,重温了样式、控件模板、数据模板和触发器,同时对视觉状态、共享资源和主题的概念进行了初步了解。

  • 5
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值