WPF-UserControl 之 Templated Control(一个demo)
目录
这是一个数字输入控件,包含一个显示数字的文本框,以及左右两个“+”和“-”的按钮
核心知识点:
- 逻辑与外观分离:C# 负责加减逻辑,XAML 负责长什么样。
Themes/Generic.xaml:这是 WPF 约定俗成的“藏经阁”,自定义控件的默认皮肤必须放在这里。PART_命名约定:代码需要操作模板里的按钮,所以我们要给按钮起约定的名字。
第一步:创建项目与文件结构
- 新建一个 WPF 项目(假设叫
WpfCustomControlDemo)。 - 在项目中新建一个文件夹叫
Themes(必须叫这个名字,不能错)。 - 在
Themes里新建一个 资源字典 (Resource Dictionary),命名为Generic.xaml(必须叫这个名字)。 - 在根目录新建一个类文件
NumberBox.cs。
文件结构应该是这样的:
WpfCustomControlDemo
│ MainWindow.xaml
│ NumberBox.cs <-- 逻辑代码
│
└─Themes
Generic.xaml <-- 外观代码 (Style & Template)第二步:编写 C# 逻辑 (NumberBox.cs)
这里我们只关心属性和交互逻辑,不写任何界面代码。
using System.Windows;
using System.Windows.Controls;
namespace WpfCustomControlDemo
{
// 1. 继承自 Control,这是自定义控件的基石
// TemplatePart 特性是为了告诉使用者:如果你要改皮肤,记得由于这两个名字的部件,否则功能会失效
[TemplatePart(Name = "PART_DecreaseButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_IncreaseButton", Type = typeof(Button))]
public class NumberBox : Control
{
// 2. 静态构造函数:告诉 WPF,去 Generic.xaml 里找这个控件的默认样式
static NumberBox()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(NumberBox), new FrameworkPropertyMetadata(typeof(NumberBox)));
}
// 3. 定义依赖属性 (Dependency Property) - Value
public int Value
{
get { return (int)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(int), typeof(NumberBox), new PropertyMetadata(0));
// 4. 重写 OnApplyTemplate
// 当 WPF 把 XAML 的皮肤应用到这个类上时,会调用这个方法。
// 我们就在这里通过名字找到那个按钮,并加上点击事件。
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// 在 Template 中查找名为 "PART_DecreaseButton" 的按钮
var btnDecrease = GetTemplateChild("PART_DecreaseButton") as Button;
if (btnDecrease != null)
{
// 先解绑防止内存泄漏(虽然在简单场景不明显,但这是好习惯)
btnDecrease.Click -= BtnDecrease_Click;
btnDecrease.Click += BtnDecrease_Click;
}
var btnIncrease = GetTemplateChild("PART_IncreaseButton") as Button;
if (btnIncrease != null)
{
btnIncrease.Click -= BtnIncrease_Click;
btnIncrease.Click += BtnIncrease_Click;
}
}
// 业务逻辑:减
private void BtnDecrease_Click(object sender, RoutedEventArgs e)
{
Value--;
}
// 业务逻辑:加
private void BtnIncrease_Click(object sender, RoutedEventArgs e)
{
Value++;
}
}
}第三步:编写默认外观 (Themes/Generic.xaml)
这里我们决定它长什么样。注意看我们是如何把 C# 里的属性绑定的。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfCustomControlDemo">
<!-- 定义 NumberBox 的默认样式 -->
<Style TargetType="{x:Type local:NumberBox}">
<!-- 默认属性值 -->
<Setter Property="Background" Value="White"/>
<Setter Property="BorderBrush" Value="Gray"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<!-- 核心:Template (控件模板) -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:NumberBox}">
<!-- 外边框,绑定控件本身的 BorderBrush 等属性 -->
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="30"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<!-- 减号按钮:注意 x:Name 必须和 C# 里的 PART_ 名称一致 -->
<Button x:Name="PART_DecreaseButton"
Grid.Column="0"
Content="-"
Background="LightGray"
BorderThickness="0"/>
<!-- 显示数字:内容绑定到 Value 属性 -->
<TextBlock Grid.Column="1"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="{TemplateBinding FontSize}"/>
<!-- 加号按钮 -->
<Button x:Name="PART_IncreaseButton"
Grid.Column="2"
Content="+"
Background="LightGray"
BorderThickness="0"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>注意:如果你的项目没有自动生成
AssemblyInfo.cs中的ThemeInfo特性,可能会导致样式加载失败。但在现代 .NET 项目(.NET 6/8+)中,这通常是默认配置好的。如果样式不显示,检查项目属性或 AssemblyInfo。
第四步:在界面中使用 (MainWindow.xaml)
现在你可以像使用原生 TextBox 一样使用你的 NumberBox 了。
<Window x:Class="WpfCustomControlDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfCustomControlDemo"
Title="自定义控件Demo" Height="200" Width="300">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="下面的控件是我们手搓的 Templated Control:"
HorizontalAlignment="Center"/>
<!-- 使用控件,甚至可以覆盖默认属性 -->
<local:NumberBox Value="10"
BorderBrush="Red"
BorderThickness="2" />
</StackPanel>
</Window>这里的关键点(为了验证你的理论)
- 没有
.xaml和.xaml.cs的耦合:
UserControl 是两个文件绑死的。而这个NumberBox.cs只是一个纯类。你可以把Themes/Generic.xaml里的 Style 删掉,程序不会报错,只是控件会变成透明(因为没有渲染树)。 - 可换肤性:
你完全可以在MainWindow.xaml的<Window.Resources>里重新写一个Style,TargetType 设为NumberBox,把 Template 改成一个圆形,把 Button 换成图标。这时候,C# 里的加减逻辑不需要改动一行代码,这就是“第二类控件”的强大之处。 - 它不是从点画出来的:
你看Generic.xaml里面,我们依然使用了Grid,Button,TextBlock。我们只是在编排现有的元素。这就是它和“第三类”(OnRender)的区别。
总结
这个 Demo 完美展示了 WPF 自定义控件的标准开发范式。
- 定义的
NumberBox是一个“只有灵魂(逻辑+属性),没有肉体(界面)”的类。 Generic.xaml赋予了它默认的肉体。