WPF 学习笔记(八):样式与模板——WPF 的"换肤"机制

写在前面

版本说明:基于 .NET 8 LTS。

WPF 最让人惊艳的特性之一——同一个 Button,可以看起来像 Material 按钮、像 iOS 按钮、像 Office 按钮、甚至像一颗星星。所有这些"换肤"都靠本篇讲的三层机制:Style、ControlTemplate、DataTemplate

本篇要把它们各自的语义、适用场景、相互关系讲清楚。理解之后,你就能从"用别人写好的控件"升级到"自己定义控件外观"。

本文要回答:

Style / ControlTemplate / DataTemplate 三者到底有什么区别?StaticResource 和 DynamicResource 选哪个?Trigger 为什么"不用写代码就能改外观"?


一、资源系统:ResourceDictionary

样式、模板、画刷都存在"资源"里。

1.1 资源的定义和使用

1
2
3
4
5
6
7
8
<Window.Resources>
    <SolidColorBrush x:Key="AccentBrush" Color="DodgerBlue"/>
    <Style x:Key="MyButton" TargetType="Button">
        <Setter Property="Background" Value="{StaticResource AccentBrush}"/>
    </Style>
</Window.Resources>

<Button Content="OK" Style="{StaticResource MyButton}"/>
1
2
3
4
5
6
资源定义:放在 .Resources 字典里
  - x:Key 是资源名(必须)
  - 任意对象都可以是资源(Brush / Style / Template / 数字 / 字符串)

资源使用:{StaticResource key} 或 {DynamicResource key}
  - 通过 key 查找

1.2 资源查找链

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
WPF 资源查找顺序(从内到外):

  元素自身.Resources
    ↓(找不到)
  父元素.Resources
  Window.Resources
  App.Resources
  Theme(系统主题样式)
  抛 XamlParseException

类似变量作用域链
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<Window.Resources>
    <SolidColorBrush x:Key="bg" Color="Red"/>   <!-- 全局 -->
</Window.Resources>

<Grid>
    <Grid.Resources>
        <SolidColorBrush x:Key="bg" Color="Blue"/>  <!-- 局部覆盖 -->
    </Grid.Resources>
    <Button Background="{StaticResource bg}"/>  <!-- 用蓝色(局部优先) -->
</Grid>

1.3 ResourceDictionary 合并

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- App.xaml 合并多个资源字典 -->
<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Styles/Colors.xaml"/>
            <ResourceDictionary Source="Styles/Buttons.xaml"/>
            <ResourceDictionary Source="Styles/TextBoxes.xaml"/>
        </ResourceDictionary.MergedDictionaries>

        <!-- 这里仍可定义本地资源 -->
        <SolidColorBrush x:Key="bg" Color="White"/>
    </ResourceDictionary>
</Application.Resources>
1
2
3
4
5
6
7
8
组织方式:
  - Colors.xaml:颜色/画刷
  - Sizes.xaml:尺寸
  - Buttons.xaml:按钮样式
  - Brushes.xaml:渐变、画刷
  - ThemeDark.xaml / ThemeLight.xaml:主题切换

  按需合并,可切换

二、StaticResource vs DynamicResource

2.1 区别

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
StaticResource:
  - 编译时(XAML 加载时)解析一次
  - 之后不再查
  - 性能好(一次性)
  - 改资源字典不会更新引用

DynamicResource:
  - 运行时解析
  - 资源变化时自动更新引用
  - 性能稍差
  - 支持主题切换、运行时换肤

2.2 何时用 DynamicResource

1
2
3
4
5
<!-- 主题切换:换主题时所有引用自动更新 -->
<Button Background="{DynamicResource AccentBrush}"/>

<!-- 系统颜色:用户改系统主题时跟随 -->
<TextBlock Foreground="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
1
2
3
4
5
6
常见 DynamicResource 场景:
  - 系统颜色 / 字体(用户改系统设置时跟随)
  - 主题切换(运行时换 ResourceDictionary)
  - 跨程序集的资源引用

其他场景用 StaticResource 即可(性能更好)

2.3 主题切换实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void SwitchTheme(string themeName)
{
    var newDict = new ResourceDictionary
    {
        Source = new Uri($"Themes/{themeName}.xaml", UriKind.Relative)
    };

    // 替换合并字典
    Application.Current.Resources.MergedDictionaries.Clear();
    Application.Current.Resources.MergedDictionaries.Add(newDict);
}
1
所有 {DynamicResource ...} 引用都会自动更新

三、Style(样式)

3.1 基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<Window.Resources>
    <Style x:Key="PrimaryButton" TargetType="Button">
        <Setter Property="Background" Value="DodgerBlue"/>
        <Setter Property="Foreground" Value="White"/>
        <Setter Property="FontWeight" Value="Bold"/>
        <Setter Property="Padding" Value="20,8"/>
        <Setter Property="BorderThickness" Value="0"/>
    </Style>
</Window.Resources>

<Button Content="保存" Style="{StaticResource PrimaryButton}"/>
<Button Content="取消" Style="{StaticResource PrimaryButton}"/>

3.2 隐式样式(不写 x:Key)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<Window.Resources>
    <Style TargetType="Button">   <!-- 注意没 x:Key -->
        <Setter Property="Background" Value="DodgerBlue"/>
        <Setter Property="Foreground" Value="White"/>
    </Style>
</Window.Resources>

<!-- 所有 Button 自动应用 -->
<Button Content="保存"/>
<Button Content="取消"/>
1
2
3
4
5
6
7
8
9
隐式样式的规则:
  - TargetType 必填,x:Key 不写
  - 自动应用到该类型的所有元素
  - 但只对"声明样式的作用域内"生效
  - 派生类不自动应用(RepeatButton 不会用 Button 的隐式样式)

适用:
  - 全局按钮风格统一
  - 全局文本样式

3.3 样式继承(BasedOn)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<Style x:Key="BaseButton" TargetType="Button">
    <Setter Property="FontWeight" Value="Bold"/>
    <Setter Property="Padding" Value="20,8"/>
</Style>

<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource BaseButton}">
    <Setter Property="Background" Value="DodgerBlue"/>
    <Setter Property="Foreground" Value="White"/>
</Style>

<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource BaseButton}">
    <Setter Property="Background" Value="Red"/>
    <Setter Property="Foreground" Value="White"/>
</Style>
1
2
BasedOn 类似 CSS 的继承
子样式继承父样式的所有 Setter,可覆盖

四、Trigger(触发器)

不用写代码,“条件改变时改属性”。

4.1 PropertyTrigger(属性触发器)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<Style TargetType="Button">
    <Setter Property="Background" Value="DodgerBlue"/>
    <Setter Property="Foreground" Value="White"/>

    <Style.Triggers>
        <!-- 鼠标悬停 -->
        <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" Value="LightBlue"/>
        </Trigger>

        <!-- 禁用 -->
        <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Background" Value="Gray"/>
            <Setter Property="Foreground" Value="LightGray"/>
        </Trigger>
    </Style.Triggers>
</Style>
1
2
3
4
5
效果:
  - 默认蓝色
  - 鼠标悬停浅蓝(IsMouseOver=true 时)
  - 禁用灰色(IsEnabled=false 时)
  - 离开/恢复时自动回退(不用写代码!)

4.2 MultiTrigger(多条件)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Style TargetType="Button">
    <Style.Triggers>
        <MultiTrigger>
            <MultiTrigger.Conditions>
                <Condition Property="IsMouseOver" Value="True"/>
                <Condition Property="IsEnabled" Value="True"/>
            </MultiTrigger.Conditions>
            <Setter Property="Background" Value="LightGreen"/>
        </MultiTrigger>
    </Style.Triggers>
</Style>

4.3 EventTrigger(事件触发器)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<Style TargetType="Button">
    <Style.Triggers>
        <EventTrigger RoutedEvent="MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="0.7" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="1.0" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Style.Triggers>
</Style>
1
2
EventTrigger 触发动画(详见第 10 篇)
不是简单改属性

4.4 DataTrigger(数据触发器)

1
2
3
4
5
6
7
8
<Style TargetType="TextBlock">
    <Style.Triggers>
        <DataTrigger Binding="{Binding IsUrgent}" Value="True">
            <Setter Property="Foreground" Value="Red"/>
            <Setter Property="FontWeight" Value="Bold"/>
        </DataTrigger>
    </Style.Triggers>
</Style>
1
2
DataTrigger 监听"任意绑定的数据"
比 PropertyTrigger(只能监听自身属性)更强大

4.5 MultiDataTrigger

1
2
3
4
5
6
7
<MultiDataTrigger>
    <MultiDataTrigger.Conditions>
        <Condition Binding="{Binding IsUrgent}" Value="True"/>
        <Condition Binding="{Binding IsOverdue}" Value="True"/>
    </MultiDataTrigger.Conditions>
    <Setter Property="Background" Value="DarkRed"/>
</MultiDataTrigger>

五、ControlTemplate(控件模板)

Style 改的是"属性",ControlTemplate 改的是"外观结构"。

5.1 默认 Button 的视觉树

1
2
3
4
Button(默认模板展开)
  └─ ButtonChrome(主题边框)
      └─ ContentPresenter(内容容器)
          └─ (你的 Content)
1
2
3
ControlTemplate 的作用:
  完全替换上面的视觉树
  → 你可以让 Button 看起来像任何东西

5.2 自定义 Button 模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<Style TargetType="Button">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="Button">
                <Grid>
                    <!-- 边框 -->
                    <Border x:Name="border"
                            Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            CornerRadius="6">
                        <!-- 内容 -->
                        <ContentPresenter HorizontalAlignment="Center"
                                          VerticalAlignment="Center"
                                          Content="{TemplateBinding Content}"/>
                    </Border>
                </Grid>

                <ControlTemplate.Triggers>
                    <Trigger Property="IsMouseOver" Value="True">
                        <Setter TargetName="border" Property="Opacity" Value="0.8"/>
                    </Trigger>
                    <Trigger Property="IsPressed" Value="True">
                        <Setter TargetName="border" Property="Opacity" Value="0.6"/>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

5.3 TemplateBinding

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TemplateBinding = 在 ControlTemplate 里"接通"原控件的属性

  Background="{TemplateBinding Background}"
  → 把模板内 Border.Background 接到 Button.Background
  → 你设置 Button.Background="Red",模板里的 Border 也是红

  等价于:
  Background="{Binding Background, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}"

  TemplateBinding 是它的语法糖(且 OneWay)

5.4 ContentPresenter

1
2
3
4
5
6
7
ContentPresenter = "原控件的内容放这里"

  <ContentPresenter Content="{TemplateBinding Content}"/>
  → Button.Content 会被渲染到这个位置

  类似 ASP.NET Razor 的 RenderBody()
  → 模板留个洞,外面填充

六、DataTemplate(数据模板)

ControlTemplate 决定"控件长什么样" DataTemplate 决定"数据长什么样"

6.1 问题引入

1
2
3
4
<ListBox ItemsSource="{Binding Items}"/>
<!-- Items 是 List<TodoItem> -->
<!-- ListBox 不知道怎么显示 TodoItem -->
<!-- 默认调用 .ToString(),丑爆 -->

6.2 用 DataTemplate

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<ListBox ItemsSource="{Binding Items}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <Grid Margin="4">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <CheckBox Grid.Column="0" IsChecked="{Binding Completed}"/>
                <TextBlock Grid.Column="1" Text="{Binding Title}" Margin="8,0"/>
                <TextBlock Grid.Column="2" Text="{Binding CreatedAt, StringFormat=yyyy-MM-dd}"/>
            </Grid>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>
1
2
3
4
5
效果:
  每个 TodoItem 不再是 ToString
  而是按 DataTemplate 渲染(复选框 + 标题 + 日期)

  DataContext 自动是当前 Item

6.3 隐式 DataTemplate(推荐)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- App.xaml 全局注册 -->
<Application.Resources>
    <DataTemplate DataType="{x:Type models:TodoItem}">
        <Grid Margin="4">
            <TextBlock Text="{Binding Title}"/>
        </Grid>
    </DataTemplate>

    <DataTemplate DataType="{x:Type models:User}">
        <StackPanel Orientation="Horizontal">
            <Image Source="{Binding Avatar}" Width="32"/>
            <TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
        </StackPanel>
    </DataTemplate>
</Application.Resources>
1
2
3
<!-- 任意 ItemsControl 自动按数据类型选模板 -->
<ListBox ItemsSource="{Binding Items}"/>
<!-- 不同类型的 Item 显示不同样式 -->
1
2
3
4
5
6
7
8
隐式 DataTemplate 的优势:
  - 不需要在每个 ItemsControl 写 ItemTemplate
  - 同一类型在全应用一致显示
  - 异构列表(多种类型混合)自动选模板

后端类比:
  DataTemplate ≈ Razor 的 DisplayTemplate
  隐式 DataTemplate ≈ EditorTemplates/DisplayTemplates 按类型选

七、ControlTemplate vs DataTemplate 对比

维度ControlTemplateDataTemplate
应用对象控件(Control)数据(任意对象)
决定什么控件外观结构数据呈现方式
触发位置Style.TemplateItemTemplate / ContentTemplate
内部绑定TemplateBinding(绑控件属性)普通绑定(绑数据)
隐式主题样式(Generic.xaml)DataType 隐式模板
1
2
3
4
5
6
7
记忆点:
  ControlTemplate 改"控件"(如 Button 怎么画)
  DataTemplate 改"数据"(如 TodoItem 怎么显示)

  一个控件同时可有两者:
  <ListBox ItemTemplate="..."/>    <!-- 数据呈现 -->
  <ListBox.Template="..."/>        <!-- 控件结构 -->

八、VisualStateManager(VSM)

Trigger 的"现代替代品"。

8.1 VSM 概念

1
2
3
4
5
6
7
VisualStateManager:
  - 把控件状态抽象成"组"
  - 每个状态对应一组动画/属性
  - 状态间过渡用动画

例:Button 有 CommonStates 组
  Normal / MouseOver / Pressed / Disabled

8.2 用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<ControlTemplate TargetType="Button">
    <Grid>
        <Border x:Name="border" Background="DodgerBlue" CornerRadius="6"/>
        <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>

        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="CommonStates">
                <VisualState x:Name="Normal"/>
                <VisualState x:Name="MouseOver">
                    <Storyboard>
                        <ColorAnimation Storyboard.TargetName="border"
                                        Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                        To="LightBlue" Duration="0:0:0.2"/>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="Pressed">
                    <Storyboard>
                        <ColorAnimation Storyboard.TargetName="border"
                                        Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                        To="DarkBlue" Duration="0:0:0.1"/>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
    </Grid>
</ControlTemplate>

8.3 Trigger vs VSM

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Trigger(属性触发器):
  - 简单属性变化
  - 立即生效
  - 在 ControlTemplate.Triggers 里
  - 适合"非动画"的简单切换

VSM:
  - 状态机模型
  - 内置动画过渡
  - 适合 CustomControl(自定义控件的标准做法)
  - 微软推荐用 VSM

实践:
  - 简单 Style(如颜色变化)用 Trigger
  - 自定义控件用 VSM
  - 大量动画用 VSM

九、实战:Material 风格 Button + 卡片 ListBox

9.1 Material Button

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!-- App.xaml -->
<Application.Resources>
    <Style x:Key="MaterialButton" TargetType="Button">
        <Setter Property="Background" Value="#2196F3"/>
        <Setter Property="Foreground" Value="White"/>
        <Setter Property="FontWeight" Value="Medium"/>
        <Setter Property="FontSize" Value="14"/>
        <Setter Property="Padding" Value="24,12"/>
        <Setter Property="BorderThickness" Value="0"/>
        <Setter Property="Cursor" Value="Hand"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="Button">
                    <Border x:Name="root"
                            Background="{TemplateBinding Background}"
                            CornerRadius="4">
                        <ContentPresenter HorizontalAlignment="Center"
                                          VerticalAlignment="Center"
                                          Margin="{TemplateBinding Padding}"/>
                    </Border>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsMouseOver" Value="True">
                            <Setter TargetName="root" Property="Background" Value="#1976D2"/>
                        </Trigger>
                        <Trigger Property="IsPressed" Value="True">
                            <Setter TargetName="root" Property="Background" Value="#0D47A1"/>
                        </Trigger>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter TargetName="root" Property="Background" Value="#BDBDBD"/>
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <!-- 危险变体(继承) -->
    <Style x:Key="MaterialDangerButton" TargetType="Button"
           BasedOn="{StaticResource MaterialButton}">
        <Setter Property="Background" Value="#F44336"/>
    </Style>
</Application.Resources>

9.2 卡片 ListBox

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<Application.Resources>
    <Style x:Key="CardListBox" TargetType="ListBox">
        <Setter Property="BorderThickness" Value="0"/>
        <Setter Property="Background" Value="Transparent"/>
        <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
        <Setter Property="ItemsPanel">
            <Setter.Value>
                <ItemsPanelTemplate>
                    <WrapPanel/>
                </ItemsPanelTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <!-- 卡片 DataTemplate -->
    <DataTemplate x:Key="CardTemplate" DataType="{x:Type models:TodoItem}">
        <Border Background="White" CornerRadius="6" Padding="16" Margin="8"
                Width="240">
            <Border.Effect>
                <DropShadowEffect BlurRadius="8" ShadowDepth="2" Opacity="0.2"/>
            </Border.Effect>

            <StackPanel>
                <Grid>
                    <TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="16"/>
                    <Border HorizontalAlignment="Right"
                            Background="{Binding Completed,
                                                Converter={StaticResource StatusToColor}}"
                            CornerRadius="8" Padding="6,2">
                        <TextBlock Text="{Binding Completed,
                                                   Converter={StaticResource StatusToText}}"
                                   Foreground="White" FontSize="11"/>
                    </Border>
                </Grid>

                <TextBlock Text="{Binding Description}"
                           Foreground="Gray" Margin="0,8,0,0"
                           TextTrimming="CharacterEllipsis"/>
                <TextBlock Text="{Binding CreatedAt, StringFormat=yyyy-MM-dd HH:mm}"
                           Foreground="LightGray" FontSize="11" Margin="0,8,0,0"/>
            </StackPanel>
        </Border>
    </DataTemplate>
</Application.Resources>
1
2
3
4
<!-- 使用 -->
<ListBox Style="{StaticResource CardListBox}"
         ItemTemplate="{StaticResource CardTemplate}"
         ItemsSource="{Binding Todos}"/>

9.3 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
✅ Style + Setter + 隐式触发器
✅ ControlTemplate + TemplateBinding + ContentPresenter
✅ ControlTemplate.Triggers
✅ Style 继承(BasedOn)
✅ DataTemplate 隐式 + 显式
✅ ItemTemplate 应用
✅ ItemsPanel 改变布局(WrapPanel)
✅ DropShadowEffect(特效)
✅ Converter 在模板里(StatusToColor / StatusToText)

观察:
  - 改 Style 不改业务逻辑
  - 同一个 ViewModel + 不同 Style = 完全不同 UI
  - 这是 WPF "设计 vs 开发"分离的体现

十、Theme 与 Generic.xaml

10.1 主题样式

1
2
3
4
5
6
每个 WPF 控件都有"默认主题样式"
  - 来自 PresentationFramework.Aero2.dll(Windows 10 默认主题)
  - 包含 ControlTemplate、Trigger
  - 是"没写 Style 时的样子"

  你写 Style TargetType="Button"(隐式)会覆盖主题样式

10.2 Generic.xaml(自定义控件的主题)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
自定义 CustomControl 时:
  - 创建 Themes/Generic.xaml
  - 里面写控件的默认 ControlTemplate
  - WPF 自动加载(约定)

文件结构:
  MyControlLibrary/
  ├── MyButton.cs
  └── Themes/
      └── Generic.xaml   ← 约定路径
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Generic.xaml -->
<ResourceDictionary xmlns="...">
    <Style TargetType="{x:Type local:MyButton}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:MyButton}">
                    <!-- 模板 -->
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

十一、常见陷阱

11.1 StaticResource 引用顺序

1
2
3
4
5
6
7
8
<!-- ❌ 资源定义在使用之后 -->
<Button Background="{StaticResource bg}"/>
<SolidColorBrush x:Key="bg" Color="Red"/>
<!-- 报错:找不到 bg -->

<!-- ✅ 先定义后使用 -->
<SolidColorBrush x:Key="bg" Color="Red"/>
<Button Background="{StaticResource bg}"/>
1
2
3
4
5
StaticResource 是"前向查找",定义必须在前面
但同一 Resources 字典内不分先后(解析时全收集)
跨字典才要求顺序

DynamicResource 没此限制(运行时查)

11.2 隐式样式对派生类不生效

1
2
3
4
5
6
7
<Style TargetType="Button">
    <Setter Property="Background" Value="Red"/>
</Style>

<RepeatButton Content="..."/>
<!-- RepeatButton 继承自 ButtonBase,不是 Button -->
<!-- 不会应用上面的隐式样式 -->

11.3 ControlTemplate 缺 ContentPresenter

1
2
3
4
5
<ControlTemplate TargetType="Button">
    <Border Background="Red"/>
    <!-- ❌ 没 ContentPresenter -->
    <!-- Button.Content 没地方放,不显示 -->
</ControlTemplate>

11.4 Trigger 和本地值冲突

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Style TargetType="Button">
    <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" Value="Red"/>
        </Trigger>
    </Style.Triggers>
</Style>

<!-- 本地值(XAML 直接设置)覆盖 Style.Trigger -->
<Button Background="Blue"/>
<!-- 鼠标悬停不会变红! -->
1
2
3
4
5
6
原因:
  本地值 > Style.Trigger(详见第 2 篇优先级)
  Trigger 永远赢不了本地值

解决:
  把 Background="Blue" 也写到 Style 里

11.5 大量 Trigger 性能问题

1
2
3
4
5
<!-- ❌ 每个 Item 都有自己的 Trigger + 复杂 ControlTemplate -->
<ListBox ItemsSource="{Binding Items}"/>
<!-- 1000 个 Item × 复杂模板 = 慢 -->

<!-- ✅ 简化模板 + 虚拟化 -->

十二、与 CSS / Razor 的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CSS                       WPF Style
──────────────────────────────────────────
.class { color: red }     <Style TargetType><Setter Property="Foreground" Value="Red"/>
:hover                    <Trigger Property="IsMouseOver" Value="True">
@media (theme)            DynamicResource + 切换 ResourceDictionary
inheritance               BasedOn

Razor Layout              WPF ControlTemplate
──────────────────────────────────────────
_Layout.cshtml            ControlTemplate
@RenderBody()             <ContentPresenter/>
@RenderSection()          ItemPresenter / ContentPresenter

Razor DisplayTemplate     WPF DataTemplate
──────────────────────────────────────────
DisplayTemplates/         <DataTemplate DataType=...>
按类型选模板              隐式 DataTemplate
@Html.DisplayForModel()   ItemsControl 自动用 DataTemplate

十三、小结

本文深入 WPF 的样式与模板系统:

  • ResourceDictionary 资源系统与查找链
  • StaticResource vs DynamicResource(编译期 vs 运行期)
  • Style(属性批处理)+ 隐式样式 + BasedOn 继承
  • Trigger 全家桶(Property / Multi / Event / Data / MultiData)
  • ControlTemplate(彻底重画控件外观)
    • TemplateBinding + ContentPresenter
  • DataTemplate(决定数据呈现)
    • 显式 ItemTemplate + 隐式 DataType
  • VisualStateManager(VSM)vs Trigger
  • 实战:Material Button + 卡片 ListBox
  • Theme 与 Generic.xaml(自定义控件主题)
1
2
3
4
记住三句话:
  1. Style 改属性,ControlTemplate 改结构,DataTemplate 改数据呈现
  2. ContentPresenter 是 ControlTemplate 的"洞",TemplateBinding 是"接原控件属性"
  3. DynamicResource + 切换 ResourceDictionary = 一键换肤

下一篇进入 WPF 的多线程模型——Dispatcher 与 UI 线程。这是后端工程师最容易踩坑的地方(async/await 行为和 ASP.NET Core 不同)。

Licensed under CC BY-NC-SA 4.0