每个控件都有自己的视觉外观,比如我们一眼就能分清楚 Button 和 CheckBox 两个按钮。为什么?因为这两者呈现出来的外观完全不同。WPF 为每一种控件都提供了一个默认的视觉外观,同时支持开发者去重写这个视觉外观,只需要将重写的视觉外观赋值到 Template 属性即可——这就是 Template(模板) 的由来。模板定义了控件的视觉外观。

WPF 的控件大致可以分为好几种,比如以 Panel 为基类的布局控件,以 ContentControl 为基类的内容控件,以 ItemsControl 为基类的集合控件。这些不同种类的控件都有各自的视觉外观,也意味着它们都有不同的模板。

以面向对象(OOP)的思想,这些不同的模板肯定会继承同一个基类。WPF 的模板基类叫 FrameworkTemplate,它是一个抽象类,有三个核心子类:

  1. ControlTemplate(控件模板) :用于定义控件的外观(Control 基类的 Template 属性)。
  2. DataTemplate(数据模板) :数据的“外衣”,用于从一个对象中提取数据并在内容控件或列表控件中显示数据(如 ContentTemplate 或 ItemTemplate)。
  3. ItemsPanelTemplate(元素面板模板) :用于 ItemsControl 及其子类控件,定义集合中各个元素之间的布局方式(ItemsPanel 属性)。

一、 FrameworkTemplate 基类

我们先来看一下 FrameworkTemplate 的源码定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class FrameworkTemplate : DispatcherObject, INameScope, ISealable, IHaveResources, IQueryAmbient
{
protected FrameworkTemplate();

public bool IsSealed { get; }
public FrameworkElementFactory VisualTree { get; set; }
public TemplateContent Template { get; set; }
public ResourceDictionary Resources { get; set; }
public bool HasContent { get; }

public object FindName(string name, FrameworkElement templatedParent);
public DependencyObject LoadContent();
public void RegisterName(string name, object scopedElement);
public void Seal();
public bool ShouldSerializeResources(XamlDesignerSerializationManager manager);
public bool ShouldSerializeVisualTree();
public void UnregisterName(string name);
protected virtual void ValidateTemplatedParent(FrameworkElement templatedParent);
}

在 FrameworkTemplate 基类中有一个 VisualTree 属性,这是我们首次看到“视觉树”这个关键词。实际上,WPF 拥有两棵树:逻辑树(Logical Tree) 和 视觉树(Visual Tree) ,并提供了两个帮助类:LogicalTreeHelper 和 VisualTreeHelper。

  • LogicalTreeHelper:提供用于查询逻辑树中的对象的静态帮助器方法。
  • VisualTreeHelper:提供用于执行涉及可视化树节点的常规任务的实用工具方法。

二、 LogicalTree 逻辑树

WPF 使用了若干树结构形式来定义程序元素之间的关系。要了解逻辑树与视觉树,需要先了解两个关键基类:FrameworkElement 和 Visual。

  • FrameworkElement:主要实现控件的布局、逻辑树、支持数据绑定和动态资源引用、控件样式定义和动画。它与 WPF 控件“更靠近一些”。
  • Visual:更关注控件的命中测试、坐标转换和边界框计算,提供更基础的视觉呈现支持。

由控件组成的 XAML 代码本质上就是一棵逻辑树。逻辑树的主要用途之一是在后端代码中查找前端 XAML 的某个控件,以便进行操作。

遍历逻辑树示例

1. 前端 XAML

1
2
3
4
5
6
7
8
9
10
11
12
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Border Background="LightCyan" Grid.Column="0" Width="188" x:Name="_LeftBorder">
<Button Click="Button_Click" Content="当前逻辑树" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Grid.Column="1" x:Name="_RightBorder">
<TreeView Margin="5" x:Name="_TreeView"/>
</Border>
</Grid>

2. 后端 C# 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void Button_Click(object sender, RoutedEventArgs e)
{
TreeViewItem item = new TreeViewItem() { Header = "逻辑树根" };
LogicalTree(item, this);
_TreeView.Items.Add(item);
}

private void LogicalTree(TreeViewItem item, object element)
{
if (!(element is DependencyObject)) return;

TreeViewItem treeViewItem = new TreeViewItem { Header = element.GetType().Name };
item.Items.Add(treeViewItem);

var elements = LogicalTreeHelper.GetChildren(element as DependencyObject);

foreach (object child in elements)
{
LogicalTree(treeViewItem, child);
}
}

运行后,TreeView 中展开的节点结构与 XAML 代码完全一致。但逻辑树并没有展示控件内部的细节结构,若要看到控件内部是如何构成的,就需要用到视觉树(VisualTree)。

三、 VisualTree 可视化树

可视化树描述由 Visual 基类表示的可视化对象的完整结构。为控件编写模板时,实际上就是在定义或重新定义适用于该控件的可视化树。

在 WPF 编程中,可视化树的一个重要应用是:路由事件的事件路由大多遍历可视化树而非逻辑树。

遍历可视化树示例

使用与上文相同的 XAML 结构,后端替换为 VisualTreeHelper 进行遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void Button_Click(object sender, RoutedEventArgs e)
{
TreeViewItem item = new TreeViewItem() { Header = "可视化树根" };
VisualTree(item, this);
_TreeView.Items.Add(item);
}

private void VisualTree(TreeViewItem item, object element)
{
if (!(element is DependencyObject)) return;

TreeViewItem treeViewItem = new TreeViewItem { Header = element.GetType().Name };
item.Items.Add(treeViewItem);

for (int i = 0; i < VisualTreeHelper.GetChildrenCount(element as DependencyObject); i++)
{
VisualTree(treeViewItem, VisualTreeHelper.GetChild(element as DependencyObject, i));
}
}

运行结果会比逻辑树丰富很多。除了 XAML 显式声明的控件外,树中还会包含 Window 的外壳模板(如 AdornerDecorator、ContentPresenter)、Button 内部的 ButtonChrome / Border 等细粒度组件。

核心结论:可视化树粒度更细,包含控件内部模板结构(可视化树范围 > 逻辑树范围)。

四、 ControlTemplate 控件模板

4.1 控件模板概述

界面由多个控件构成逻辑树,而每个控件内部内部由控件模板构成可视化树。

模板与样式的区别

  • Style(样式) :相当于人的肤色、身高、字号大小、内外边距。改变的是控件已有的属性。
  • ControlTemplate(模板) :相当于人的骨骼结构。如果你想把一个矩形的 Button 变成圆形按钮、或者带图标的圆角按钮,就需要替换 ControlTemplate。

FrameworkElement 的 Template 属性允许重新定义控件的外观。需要注意:不能仅替换可视化树的一部分;若要更改控件的结构,必须将 Template 设置为新的完整 ControlTemplate。

4.2 查看控件的默认模板

在 Visual Studio 或 Blend 的设计视图中,右键控件 -> 编辑模板 -> 编辑副本,系统会在资源字典(如 Window.Resources)中生成该控件的默认 Style 与 ControlTemplate。

以标准的 Button 模板简化版为例:

1
2
3
4
5
<ControlTemplate TargetType="{x:Type Button}">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" SnapsToDevicePixels="true" x:Name="border">
<ContentPresenter Focusable="False" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" RecognizesAccessKey="True" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" x:Name="contentPresenter"/>
</Border>
</ControlTemplate>

4.3 什么是 ContentPresenter 对象?

ContentPresenter 继承于 FrameworkElement,常用于 ContentControl 内容控件的模板中。它是一个“占位控件”,专门负责承载和呈现控件的 Content 内容。在 ControlTemplate 中放置 ContentPresenter,就相当于指定了用户写入 Content="..." 里的内容到底渲染在模板的什么位置。

4.4 控件模板的 4 种设置方式

方式一:直接定义在控件内部(内联定义)

1
2
3
4
5
6
7
8
9
<Button Content="内联模板" Foreground="#747787" Height="40" Width="280">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" BorderBrush="#C9CCD5" BorderThickness="1" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Button.Template>
</Button>

方式二:定义在 Window/Page 资源字典中

1
2
3
4
5
6
7
8
9
<Window.Resources>
<ControlTemplate TargetType="Button" x:Key="ButtonTemplate">
<Border Background="#C6D2FC" BorderBrush="#545BAD" BorderThickness="1" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Window.Resources>

<Button Content="资源模板" Height="40" Template="{StaticResource ButtonTemplate}" Width="280"/>

方式三:结合 Style 定义在样式中(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<Button Content="Style组合模板" Foreground="White" Height="40" Width="280">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="#7AAB7D" CornerRadius="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock FontSize="18" Grid.Column="0" Margin="3" Text="☻" VerticalAlignment="Center"/>
<ContentPresenter Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>

方式四:定义在独立资源字典文件中(模块化/全局复用)

新建 ButtonTemplates.xaml 资源字典:

1
2
3
4
5
6
7
<ResourceDictionary xmlns="[http://schemas.microsoft.com/winfx/2006/xaml/presentation](http://schemas.microsoft.com/winfx/2006/xaml/presentation)" xmlns:x="[http://schemas.microsoft.com/winfx/2006/xaml](http://schemas.microsoft.com/winfx/2006/xaml)">
<ControlTemplate TargetType="Button" x:Key="RoundButtonTemplate">
<Border Background="#FF6B6B" BorderBrush="#C92A2A" BorderThickness="1" CornerRadius="20">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</ResourceDictionary>

在 App.xaml 中合并:

1
2
3
4
5
6
7
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="ButtonTemplates.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>

方式对比总结

方式 作用域 复用性 适用场景
1. 定义在控件中 单个控件 不可复用 临时测试、极特殊定制
2. 定义在资源中 当前窗口/容器 窗口内复用 单窗口内多个相同控件
3. 定义在 Style 中 当前窗口/全局 高(样式+模板联动) 规范化 UI 库开发
4. 定义在独立资源字典 全局 最高(跨项目) 大型项目、模块化皮肤系统

五、 ControlTemplate 的触发器

ControlTemplate 内部拥有 Triggers 集合,允许我们在模板内部捕捉交互事件(如鼠标移入、按下)并动态修改可视化树中元素的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Button Height="40" Width="280">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="#E0E0E0" CornerRadius="5" x:Name="border">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" x:Name="contentPresenter"/>
</Border>
<ControlTemplate.Triggers>
<!-- 鼠标移入时修改 Border 背景和 ContentPresenter 文本 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" TargetName="border" Value="#4A90E2"/>
<Setter Property="TextBlock.Foreground" TargetName="contentPresenter" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>

六、 TemplateBinding 模板绑定

TemplateBinding 是 ControlTemplate 内部专用的轻量化绑定方式。它将模板内部元素的属性与宿主控件(TemplatedParent)的依赖属性进行单向关联。

1
2
3
4
5
6
public class TemplateBindingExtension : MarkupExtension
{
public TemplateBindingExtension(DependencyProperty property);
public DependencyProperty Property { get; set; }
// ...
}

示例:绘制一个属性可调的圆形按钮

1
2
3
4
5
6
7
8
9
10
11
12
<Window.Resources>
<ControlTemplate TargetType="Button" x:Key="CircleButtonTemplate">
<Grid>
<!-- 将 Ellipse 的填充色和边框颜色绑定到 Button 本身的 Background 和 BorderBrush -->
<Ellipse Fill="{TemplateBinding Background}" Stroke="{TemplateBinding BorderBrush}" StrokeThickness="2"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</ControlTemplate>
</Window.Resources>

<!-- 使用模板并直接在外部指定属性 -->
<Button Background="LightCoral" BorderBrush="Red" Content="圆按钮" Height="80" Template="{StaticResource CircleButtonTemplate}" Width="80"/>

七、 DataTemplate 数据模板(数据的外衣)

如果说 ControlTemplate 解决的是“控件长什么样” ,那么 DataTemplate 解决的就是“数据对象怎么呈现”。

DataTemplate 适用于 ContentControl.ContentTemplate 或 ItemsControl.ItemTemplate。

7.1 DataTemplate 示例

假设有一个 C# 数据模型:

1
2
3
4
5
public class UserInfo
{
public string Name { get; set; }
public string Role { get; set; }
}

在 XAML 中定义 DataTemplate 来决定 UserInfo 对象在 UI 上的长相:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Window.Resources>
<DataTemplate x:Key="UserInfoDataTemplate">
<Border Background="White" BorderBrush="#DEE2E6" BorderThickness="1" CornerRadius="6" Margin="4" Padding="8">
<StackPanel Orientation="Horizontal">
<Ellipse Fill="#4ECDC4" Height="30" Margin="0,0,10,0" Width="30"/>
<StackPanel VerticalAlignment="Center">
<TextBlock FontWeight="Bold" Text="{Binding Name}"/>
<TextBlock FontSize="11" Foreground="Gray" Text="{Binding Role}"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</Window.Resources>

<!-- 应用到 ListBox 的 ItemTemplate -->
<ListBox ItemTemplate="{StaticResource UserInfoDataTemplate}" ItemsSource="{Binding UserList}"/>

7.2 DataType 隐式数据模板

当不指定 x:Key,而是指定 DataType 时,WPF 会自动在作用域内寻找对应数据类型的渲染模板:

1
2
3
4
5
6
7
8
9
<Window.Resources>
<!-- 隐式 DataTemplate:只要遇到 UserInfo 类型数据,自动应用此渲染样式 -->
<DataTemplate DataType="{x:Type local:UserInfo}">
<TextBlock Foreground="DarkBlue" Text="{Binding Name, StringFormat='用户:{0}'}"/>
</DataTemplate>
</Window.Resources>

<!-- 无需显式指定 ItemTemplate -->
<ListBox ItemsSource="{Binding UserList}"/>

八、 ItemsPanelTemplate 元素面板模板(集合的容器)

ItemsControl(如 ListBox、ListView)默认以垂直 StackPanel 布局。如果需要改变集合元素的排布规则(如横向排列、网格布局),就需要使用 ItemsPanelTemplate。

1
2
3
4
5
6
7
8
9
<ListBox ItemsSource="{Binding UserList}">
<!-- 替换默认的布局容器 -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<!-- 替换为横向网格流式布局 -->
<WrapPanel IsItemsHost="True" Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>

注意:根面板中的 IsItemsHost="True" 告诉 WPF:“把集合子项放入该容器中排布”。在 ItemsPanelTemplate 内部此属性默认为 true。

九、 终极融合:ControlTemplate + DataTemplate + ItemsPanelTemplate

在企业级项目中,这三种模板通常结合使用。以下构建一个高度解耦的卡片列表控件:

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
<Window.Resources>
<!-- 1. DataTemplate:定义单张卡片的数据渲染 -->
<DataTemplate x:Key="CardItemDataTemplate">
<Border Background="#F8F9FA" BorderBrush="#DEE2E6" BorderThickness="1" CornerRadius="8" Margin="5" Padding="12">
<StackPanel HorizontalAlignment="Center">
<TextBlock FontSize="16" FontWeight="SemiBold" Text="{Binding Name}"/>
<TextBlock FontSize="12" Foreground="#6C757D" Margin="0,4,0,0" Text="{Binding Role}"/>
</StackPanel>
</Border>
</DataTemplate>

<!-- 2. ItemsPanelTemplate:定义网格布局容器 -->
<ItemsPanelTemplate x:Key="GridItemsPanelTemplate">
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>

<!-- 3. Style + ControlTemplate:重构 ItemsControl 外壳容器 -->
<Style TargetType="ItemsControl" x:Key="CustomCardListStyle">
<Setter Property="ItemsPanel" Value="{StaticResource GridItemsPanelTemplate}"/>
<Setter Property="ItemTemplate" Value="{StaticResource CardItemDataTemplate}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ItemsControl">
<Border Background="White" BorderBrush="#CED4DA" BorderThickness="1" CornerRadius="12" Padding="16">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<!-- ItemsPresenter 代表 ItemsPanel 挂载的占位符 -->
<ItemsPresenter/>
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>

<!-- 页面消费 -->
<Grid Padding="20">
<ItemsControl ItemsSource="{Binding UserList}" Style="{StaticResource CustomCardListStyle}"/>
</Grid>

十、 三大模板职责对比图

模板类型 继承基类 核心作用 常见挂载属性 核心占位元素/技术
ControlTemplate FrameworkTemplate 定义控件的外观与视觉结构(如按钮变圆、边框交互) Control.Template ContentPresenter / ItemsPresenter
DataTemplate FrameworkTemplate 定义数据对象的渲染方式(把 C# Class 转为 UI) ContentTemplate / ItemTemplate 属性数据绑定{Binding}
ItemsPanelTemplate FrameworkTemplate 定义集合控件中子项的布局容器(如纵向变网格/横排) ItemsControl.ItemsPanel 面板属性IsItemsHost="True"