WPF 学习笔记(十一):自定义控件

写在前面

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

写 WPF 到一定程度,你会遇到"现有控件不够用"的场景——可能是想要一个 NumericUpDown、一个圆形进度环、一个带占位符的 TextBox。这时你需要在三种"自定义控件"路线里选一种:UserControl、CustomControl、重写 FrameworkElement。

本篇把三条路线讲透,并实战一个完整的 NumericUpDown 控件——这是 WPF 自定义控件的"Hello World"。

本文要回答:

UserControl、CustomControl、OnRender 怎么选?Generic.xaml 是什么?PART_xxx 命名约定干什么用?


一、三条路线

1.1 概览

1
2
3
4
5
路线                       实现方式                  适用场景
──────────────────────────────────────────────────────────────────────
1. UserControl             组合现有控件              视图级复用(登录框、列表项)
2. CustomControl           继承 Control + 模板       控件库、跨项目复用
3. 重写 OnRender            直接 DrawingContext       高性能绘图、完全自定义

1.2 选型决策树

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
你的需求?
需要重用一组 UI(登录框、地址卡)?
  → UserControl

需要"标准控件"但 WPF 没有(NumericUpDown、Timeline、ColorPicker)?
  → CustomControl

需要高性能绘图(折线图、热力图、自定义动画)?
  → 重写 OnRender(或 SkiaSharp)

只要"小修补"现有控件?
  → Style + ControlTemplate(详见第 8 篇,不要写新控件)

二、UserControl

2.1 特点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
UserControl = "组合控件"
  - 继承 UserControl(间接 FrameworkElement)
  - XAML + code-behind
  - 把现有控件组合成一个"块"
  - 不可换皮肤(XAML 是固定的)

适用:
  - 重复使用的 UI 块(如登录框、地址卡)
  - 视图局部(如 Sidebar、TopBar)
  - 项目内复用(不跨项目)

2.2 创建

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!-- LoginControl.xaml -->
<UserControl x:Class="MyApp.Controls.LoginControl"
             xmlns="..."
             xmlns:x="...">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0" x:Name="userBox" Margin="0,0,0,8"/>
        <PasswordBox Grid.Row="1" x:Name="pwdBox" Margin="0,0,0,8"/>
        <Button Grid.Row="2" Content="登录" Click="Login_Click"/>
    </Grid>
</UserControl>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// LoginControl.xaml.cs
public partial class LoginControl : UserControl
{
    public event EventHandler<string> LoginSubmitted;

    public LoginControl()
    {
        InitializeComponent();
    }

    private void Login_Click(object sender, RoutedEventArgs e)
    {
        LoginSubmitted?.Invoke(this, $"{userBox.Text}:{pwdBox.Password}");
    }
}
1
2
3
4
<!-- 使用 -->
<Window xmlns:ctrl="clr-namespace:MyApp.Controls">
    <ctrl:LoginControl LoginSubmitted="Login_LoginSubmitted"/>
</Window>

2.3 UserControl 的局限

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
❌ 不能换 ControlTemplate
   UserControl 的 XAML 是写死的,外部不能改

❌ 难做"主题"
   要主题切换只能改内部属性

❌ 不适合做"标准控件"
   派生、扩展不灵活

✅ 简单复用 OK
   视图局部、业务相关

三、CustomControl

3.1 特点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CustomControl = "真正的自定义控件"
  - 继承 Control(不是 UserControl)
  - 外观由 ControlTemplate 决定(外部可换)
  - 默认模板在 Themes/Generic.xaml
  - 可以暴露依赖属性、路由事件
  - 适合控件库

适用:
  - 控件库(开源、商业)
  - 跨项目复用
  - 需要"换肤"的控件

3.2 创建步骤

1
2
3
4
5
1. 新建 WPF Custom Control Library 项目(或现有项目添加)
2. 添加 CustomControl(继承 Control)
3. 注册依赖属性 + 路由事件
4. 在 Themes/Generic.xaml 写默认 ControlTemplate
5. 应用 TemplatePart / TemplateVisualState 元数据

3.3 Themes/Generic.xaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
约定路径:项目根 / Themes / Generic.xaml

  MyControlLibrary/
  ├── MyButton.cs
  ├── NumericUpDown.cs
  └── Themes/
      └── Generic.xaml   ← 默认模板集中在这

WPF 自动加载这个文件(assembly 级 ThemeInfo)
[assembly: ThemeInfo(ResourceDictionaryLocation.None,
                     ResourceDictionaryLocation.SourceAssembly)]
                                            ↑ 这里指定在 SourceAssembly 找 Generic.xaml

3.4 完整 NumericUpDown

控件类

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace MyApp.Controls;

public class NumericUpDown : Control
{
    static NumericUpDown()
    {
        // 重写默认样式 key(让 Generic.xaml 找到)
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(NumericUpDown),
            new FrameworkPropertyMetadata(typeof(NumericUpDown)));
    }

    // 依赖属性:Value
    public static readonly DependencyProperty ValueProperty =
        DependencyProperty.Register(
            nameof(Value),
            typeof(int),
            typeof(NumericUpDown),
            new FrameworkPropertyMetadata(
                defaultValue: 0,
                propertyChangedCallback: OnValueChanged,
                coerceValueCallback: CoerceValue),
            validateValueCallback: ValidateValue);

    public int Value
    {
        get => (int)GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    // 依赖属性:Minimum / Maximum
    public static readonly DependencyProperty MinimumProperty =
        DependencyProperty.Register(nameof(Minimum), typeof(int), typeof(NumericUpDown),
            new FrameworkPropertyMetadata(0, OnMinMaxChanged));

    public int Minimum
    {
        get => (int)GetValue(MinimumProperty);
        set => SetValue(MinimumProperty, value);
    }

    public static readonly DependencyProperty MaximumProperty =
        DependencyProperty.Register(nameof(Maximum), typeof(int), typeof(NumericUpDown),
            new FrameworkPropertyMetadata(100, OnMinMaxChanged));

    public int Maximum
    {
        get => (int)GetValue(MaximumProperty);
        set => SetValue(MaximumProperty, value);
    }

    // 依赖属性:SmallChange(步长)
    public static readonly DependencyProperty SmallChangeProperty =
        DependencyProperty.Register(nameof(SmallChange), typeof(int), typeof(NumericUpDown),
            new PropertyMetadata(1));

    public int SmallChange
    {
        get => (int)GetValue(SmallChangeProperty);
        set => SetValue(SmallChangeProperty, value);
    }

    // 路由事件:ValueChanged
    public static readonly RoutedEvent ValueChangedEvent =
        EventManager.RegisterRoutedEvent(
            nameof(ValueChanged),
            RoutingStrategy.Bubbling,
            typeof(RoutedPropertyChangedEventHandler<int>),
            typeof(NumericUpDown));

    public event RoutedPropertyChangedEventHandler<int> ValueChanged
    {
        add => AddHandler(ValueChangedEvent, value);
        remove => RemoveHandler(ValueChangedEvent, value);
    }

    // TemplatePart:声明模板里需要的命名元素
    [TemplatePart(Name = "PART_UpButton", Type = typeof(ButtonBase))]
    [TemplatePart(Name = "PART_DownButton", Type = typeof(ButtonBase))]
    [TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
    public class NumericUpDown { ... }

    // 元数据回调:值变化
    private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (NumericUpDown)d;
        var oldVal = (int)e.OldValue;
        var newVal = (int)e.NewValue;

        // 更新内部 TextBox
        if (control._textBox != null)
            control._textBox.Text = newVal.ToString();

        // 触发路由事件
        control.RaiseEvent(new RoutedPropertyChangedEventArgs<int>(oldVal, newVal, ValueChangedEvent));
    }

    // 强制约束:value 必须在 [Minimum, Maximum]
    private static object CoerceValue(DependencyObject d, object baseValue)
    {
        var control = (NumericUpDown)d;
        var value = (int)baseValue;
        if (value < control.Minimum) return control.Minimum;
        if (value > control.Maximum) return control.Maximum;
        return value;
    }

    private static bool ValidateValue(object value)
        => value is int;

    private static void OnMinMaxChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (NumericUpDown)d;
        // 重新强制当前值(Min/Max 变了,Value 可能超界)
        control.CoerceValue(ValueProperty);
    }

    // 内部元素引用
    private ButtonBase? _upButton;
    private ButtonBase? _downButton;
    private TextBox? _textBox;

    // 模板应用时被调用
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        // 取消旧元素的订阅
        if (_upButton != null) _upButton.Click -= UpButton_Click;
        if (_downButton != null) _downButton.Click -= DownButton_Click;
        if (_textBox != null) _textBox.TextChanged -= TextBox_TextChanged;

        // 用 GetTemplateChild 拿新元素
        _upButton = GetTemplateChild("PART_UpButton") as ButtonBase;
        _downButton = GetTemplateChild("PART_DownButton") as ButtonBase;
        _textBox = GetTemplateChild("PART_TextBox") as TextBox;

        // 重新订阅
        if (_upButton != null) _upButton.Click += UpButton_Click;
        if (_downButton != null) _downButton.Click += DownButton_Click;
        if (_textBox != null)
        {
            _textBox.Text = Value.ToString();
            _textBox.TextChanged += TextBox_TextChanged;
        }
    }

    private void UpButton_Click(object sender, RoutedEventArgs e)
        => Value += SmallChange;

    private void DownButton_Click(object sender, RoutedEventArgs e)
        => Value -= SmallChange;

    private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        if (int.TryParse(_textBox!.Text, out int result))
            Value = result;
    }
}

注:上面 TemplatePart 特性应该放在主类声明上(不是嵌套类),整理示意。实际写法:

1
2
3
4
[TemplatePart(Name = "PART_UpButton", Type = typeof(ButtonBase))]
[TemplatePart(Name = "PART_DownButton", Type = typeof(ButtonBase))]
[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
public class NumericUpDown : Control { ... }

Themes/Generic.xaml

 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
<ResourceDictionary xmlns="..."
                    xmlns:x="..."
                    xmlns:local="clr-namespace:MyApp.Controls">

    <Style TargetType="{x:Type local:NumericUpDown}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:NumericUpDown}">
                    <Border Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="Auto"/>
                            </Grid.ColumnDefinitions>

                            <TextBox Grid.Column="0" x:Name="PART_TextBox"
                                     VerticalContentAlignment="Center"
                                     BorderThickness="0"
                                     Background="Transparent"/>

                            <StackPanel Grid.Column="1" Orientation="Vertical">
                                <Button x:Name="PART_UpButton" Content="▲" Width="20"/>
                                <Button x:Name="PART_DownButton" Content="▼" Width="20"/>
                            </StackPanel>
                        </Grid>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

使用

1
2
3
4
<Window xmlns:ctrl="clr-namespace:MyApp.Controls">
    <ctrl:NumericUpDown Value="50" Minimum="0" Maximum="100" SmallChange="5"
                        ValueChanged="NumericUpDown_ValueChanged"/>
</Window>

3.5 关键设计点

TemplatePart 与 PART_ 命名约定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
TemplatePart attribute:
  - 声明控件"期望"模板里有哪些命名元素
  - 命名约定:PART_ 开头
  - 类型约束(如 PART_TextBox 必须是 TextBox)

作用:
  - 给设计器/Blend 提示(哪些是"必需"元素)
  - 给写模板的人提示(哪些名字必须有)
  - 是"控件与模板"的契约

代码侧:
  GetTemplateChild("PART_TextBox") 拿元素
  OnApplyTemplate 时调用(不是构造函数!)

OnApplyTemplate

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
OnApplyTemplate 的角色:
  - 模板应用时被调用
  - 此时 GetTemplateChild 才能拿到命名元素
  - 类似"模板就绪"事件

  必须重写:
    1. 拿新元素
    2. 取消旧元素的订阅(避免泄漏)
    3. 订阅新元素事件
    4. 初始化新元素状态(如设 TextBox.Text)

默认模板可替换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- 用户可以替换默认模板 -->
<ctrl:NumericUpDown>
    <ctrl:NumericUpDown.Template>
        <ControlTemplate TargetType="{x:Type ctrl:NumericUpDown}">
            <!-- 完全自定义外观,但必须保留 PART_ 命名元素 -->
            <Grid>
                <RepeatButton x:Name="PART_UpButton" Content="↑"/>
                <TextBox x:Name="PART_TextBox"/>
                <RepeatButton x:Name="PART_DownButton" Content="↓"/>
            </Grid>
        </ControlTemplate>
    </ctrl:NumericUpDown.Template>
</ctrl:NumericUpDown>
1
2
3
4
5
关键观察:
  - 控件代码完全不知道"模板长什么样"
  - 只依赖 PART_xxx 契约
  - 用户可以彻底换肤
  - 这就是 CustomControl 的核心价值

四、重写 OnRender

最自由也最重的方案。

4.1 适用场景

1
2
3
4
5
6
7
8
9
重写 FrameworkElement.OnRender:
  - 自定义绘图(折线图、雷达图)
  - 大量重复元素(每帧渲染几千条线)
  - 性能极致(绕开视觉树)

代价:
  - 没有 XAML
  - 没有 ControlTemplate(不可换肤)
  - 自己处理布局、命中测试

4.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
27
28
29
30
31
32
33
34
35
36
37
38
public class LineChart : FrameworkElement
{
    public static readonly DependencyProperty PointsProperty =
        DependencyProperty.Register(
            nameof(Points),
            typeof(PointCollection),
            typeof(LineChart),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.AffectsRender));

    public PointCollection Points
    {
        get => (PointCollection)GetValue(PointsProperty);
        set => SetValue(PointsProperty, value);
    }

    public Brush LineBrush { get; set; } = Brushes.DodgerBlue;
    public double LineThickness { get; set; } = 2;

    protected override void OnRender(DrawingContext dc)
    {
        base.OnRender(dc);

        if (Points == null || Points.Count < 2) return;

        var geometry = new StreamGeometry();
        using (var ctx = geometry.Open())
        {
            ctx.BeginFigure(Points[0], false, false);
            for (int i = 1; i < Points.Count; i++)
                ctx.LineTo(Points[i], true, false);
        }

        var pen = new Pen(LineBrush, LineThickness);
        dc.DrawGeometry(null, pen, geometry);
    }
}
1
<local:LineChart Points="{Binding ChartPoints}" LineBrush="OrangeRed" LineThickness="3"/>

4.3 DrawingContext

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
DrawingContext 的方法:
  DrawLine(pen, point1, point2)
  DrawRectangle(brush, pen, rect)
  DrawRoundedRectangle(...)
  DrawEllipse(brush, pen, center, radiusX, radiusY)
  DrawGeometry(brush, pen, geometry)
  DrawText(formattedText, origin)
  DrawImage(imageSource, rect)
  PushTransform(transform) / Pop()
  PushClip(geom) / Pop()
  PushOpacity(opacity) / Pop()

  类似 GDI+ 的 Graphics 对象
  但不是立即渲染(WPF 内部收集指令,统一提交)

五、自定义依赖属性

CustomControl 离不开依赖属性。第 2 篇已详细讲过,这里聚焦"控件场景"。

5.1 控件属性的元数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static readonly DependencyProperty ValueProperty =
    DependencyProperty.Register(
        nameof(Value), typeof(int), typeof(NumericUpDown),
        new FrameworkPropertyMetadata(
            0,
            OnValueChanged,           // 值变化回调
            CoerceValue),             // 强制回调
        ValidateValue);

// 关键元数据选项(FrameworkPropertyMetadataOptions)
FrameworkPropertyMetadataOptions.AffectsMeasure   // 改了要重新 Measure
FrameworkPropertyMetadataOptions.AffectsArrange   // 改了要重新 Arrange
FrameworkPropertyMetadataOptions.AffectsRender    // 改了要重新渲染
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault  // 默认双向绑定
FrameworkPropertyMetadataOptions.Inherits              // 可继承

5.2 路由事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public static readonly RoutedEvent ValueChangedEvent =
    EventManager.RegisterRoutedEvent(
        nameof(ValueChanged),
        RoutingStrategy.Bubbling,
        typeof(RoutedPropertyChangedEventHandler<int>),
        typeof(NumericUpDown));

public event RoutedPropertyChangedEventHandler<int> ValueChanged
{
    add => AddHandler(ValueChangedEvent, value);
    remove => RemoveHandler(ValueChangedEvent, value);
}

// 触发
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown)d;
    control.RaiseEvent(new RoutedPropertyChangedEventArgs<int>(
        (int)e.OldValue, (int)e.NewValue, ValueChangedEvent));
}

六、控件库项目结构

6.1 多项目组织

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
MyCompany.MyApp/
├── MyCompany.MyApp.csproj          ← 主应用
├── MyCompany.MyApp.Controls/        ← 控件库
│   ├── MyCompany.MyApp.Controls.csproj
│   ├── Themes/
│   │   └── Generic.xaml
│   ├── NumericUpDown.cs
│   ├── CircularProgress.cs
│   └── ...
└── MyCompany.MyApp.Services/        ← 服务库

6.2 csproj 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!-- 控件库 csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <AssemblyInfo>true</AssemblyInfo>
  </PropertyGroup>

  <!-- ThemeInfo 必须有(让 WPF 找到 Generic.xaml) -->
  <ItemGroup>
    <AssemblyAttribute Include="System.Windows.ThemeInfoAttribute">
      <_Parameter1>System.Windows.ResourceDictionaryLocation.None</_Parameter1>
      <_Parameter2>System.Windows.ResourceDictionaryLocation.SourceAssembly</_Parameter2>
    </AssemblyAttribute>
  </ItemGroup>
</Project>

6.3 Generic.xaml 注意事项

1
2
3
4
5
6
7
8
9
Generic.xaml 必须满足:
  - 路径:项目根 / Themes / Generic.xaml
  - Build Action:Page(默认)
  - 包含程序集的 ThemeInfo(自动或手动)

调试技巧:
  - Generic.xaml 改了不生效 → 重新生成(不是热重载)
  - 默认模板找不到 → 检查 DefaultStyleKeyProperty.OverrideMetadata
  - PART_ 拿不到 → 检查模板里的 x:Name 拼写

七、设计时支持

让设计器(VS/Blend)友好。

7.1 Category / Description

1
2
3
[Category("Common")]
[Description("当前数值")]
public int Value { ... }

设计器 Properties 面板会分类显示。

7.2 ToolboxBitmap

1
2
[ToolboxBitmap(typeof(NumericUpDown), "Resources.numericupdown.ico")]
public class NumericUpDown : Control { ... }

工具箱里显示自定义图标。

7.3 DefaultEvent

1
2
[DefaultEvent("ValueChanged")]
public class NumericUpDown : Control { ... }

设计器双击控件自动生成 ValueChanged 事件处理器。


八、实战:完整 NumericUpDown(精简版)

8.1 控件类(最终版)

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
[TemplatePart(Name = "PART_UpButton", Type = typeof(ButtonBase))]
[TemplatePart(Name = "PART_DownButton", Type = typeof(ButtonBase))]
[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
[DefaultEvent("ValueChanged")]
public class NumericUpDown : Control
{
    static NumericUpDown()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(NumericUpDown),
            new FrameworkPropertyMetadata(typeof(NumericUpDown)));
    }

    #region Value
    public static readonly DependencyProperty ValueProperty =
        DependencyProperty.Register(nameof(Value), typeof(int), typeof(NumericUpDown),
            new FrameworkPropertyMetadata(0,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                OnValueChanged, CoerceValue),
            ValidateValue);

    public int Value
    {
        get => (int)GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var c = (NumericUpDown)d;
        if (c._textBox != null) c._textBox.Text = e.NewValue.ToString();
        c.RaiseEvent(new RoutedPropertyChangedEventArgs<int>(
            (int)e.OldValue, (int)e.NewValue, ValueChangedEvent));
    }

    private static object CoerceValue(DependencyObject d, object v)
    {
        var c = (NumericUpDown)d;
        var value = (int)v;
        return value < c.Minimum ? c.Minimum
             : value > c.Maximum ? c.Maximum
             : value;
    }

    private static bool ValidateValue(object v) => v is int;
    #endregion

    #region Minimum / Maximum / SmallChange
    public int Minimum { get => (int)GetValue(MinimumProperty); set => SetValue(MinimumProperty, value); }
    public static readonly DependencyProperty MinimumProperty =
        DependencyProperty.Register(nameof(Minimum), typeof(int), typeof(NumericUpDown),
            new FrameworkPropertyMetadata(0, (d, e) => ((NumericUpDown)d).CoerceValue(ValueProperty)));

    public int Maximum { get => (int)GetValue(MaximumProperty); set => SetValue(MaximumProperty, value); }
    public static readonly DependencyProperty MaximumProperty =
        DependencyProperty.Register(nameof(Maximum), typeof(int), typeof(NumericUpDown),
            new FrameworkPropertyMetadata(100, (d, e) => ((NumericUpDown)d).CoerceValue(ValueProperty)));

    public int SmallChange { get => (int)GetValue(SmallChangeProperty); set => SetValue(SmallChangeProperty, value); }
    public static readonly DependencyProperty SmallChangeProperty =
        DependencyProperty.Register(nameof(SmallChange), typeof(int), typeof(NumericUpDown), new PropertyMetadata(1));
    #endregion

    #region ValueChanged event
    public static readonly RoutedEvent ValueChangedEvent =
        EventManager.RegisterRoutedEvent(nameof(ValueChanged), RoutingStrategy.Bubbling,
            typeof(RoutedPropertyChangedEventHandler<int>), typeof(NumericUpDown));

    public event RoutedPropertyChangedEventHandler<int> ValueChanged
    {
        add => AddHandler(ValueChangedEvent, value);
        remove => RemoveHandler(ValueChangedEvent, value);
    }
    #endregion

    private ButtonBase? _upButton;
    private ButtonBase? _downButton;
    private TextBox? _textBox;

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        if (_upButton != null) _upButton.Click -= (_, _) => Value += SmallChange;
        if (_downButton != null) _downButton.Click -= (_, _) => Value -= SmallChange;
        if (_textBox != null) _textBox.TextChanged -= OnTextBoxTextChanged;

        _upButton = GetTemplateChild("PART_UpButton") as ButtonBase;
        _downButton = GetTemplateChild("PART_DownButton") as ButtonBase;
        _textBox = GetTemplateChild("PART_TextBox") as TextBox;

        if (_upButton != null) _upButton.Click += (_, _) => Value += SmallChange;
        if (_downButton != null) _downButton.Click += (_, _) => Value -= SmallChange;
        if (_textBox != null)
        {
            _textBox.Text = Value.ToString();
            _textBox.TextChanged += OnTextBoxTextChanged;
        }
    }

    private void OnTextBoxTextChanged(object sender, TextChangedEventArgs e)
    {
        if (int.TryParse(_textBox!.Text, out int result))
            Value = result;
    }
}

8.2 Generic.xaml(见第 3.4 节)

8.3 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
✅ DefaultStyleKeyProperty.OverrideMetadata
✅ 依赖属性注册(含 Coerce + Validate + PropertyChanged)
✅ FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
✅ 路由事件注册 + RaiseEvent
✅ TemplatePart attribute 声明契约
✅ OnApplyTemplate + GetTemplateChild
✅ 设计器特性(DefaultEvent)

观察:
  - 控件类完全没写"长什么样"
  - Generic.xaml 描述外观
  - 用户可彻底换肤
  - Value 变化触发路由事件,可被父级监听

九、常见陷阱

9.1 DefaultStyleKey 没设

1
2
3
4
5
// ❌ 忘了 OverrideMetadata
public class MyControl : Control { }

// 后果:WPF 用 Control 的默认样式(空白)
// 控件不显示任何东西

9.2 Generic.xaml 路径错

1
2
3
❌ Themes/generic.xaml(小写)
❌ theme/Generic.xaml
✅ Themes/Generic.xaml(大小写敏感)

9.3 GetTemplateChild 时机错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ❌ 在构造函数调
public MyControl()
{
    var btn = GetTemplateChild("PART_UpButton");  // 返回 null
    // 此时模板还没应用
}

// ✅ 在 OnApplyTemplate
public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    var btn = GetTemplateChild("PART_UpButton");
}

9.4 事件订阅不取消

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 旧元素订阅没取消
public override void OnApplyTemplate()
{
    var newBtn = GetTemplateChild("PART_UpButton") as ButtonBase;
    newBtn.Click += ClickHandler;
    // 如果之前已经有 _upButton,旧的事件没取消 → 重复触发
}

// ✅ 先取消,再订阅
if (_upButton != null) _upButton.Click -= ClickHandler;
_upButton = GetTemplateChild("PART_UpButton") as ButtonBase;
if (_upButton != null) _upButton.Click += ClickHandler;

9.5 CLR 包装器里写逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌(详见第 2 篇)
public int Value
{
    get => (int)GetValue(ValueProperty);
    set
    {
        SetValue(ValueProperty, value);
        UpdateTextBox();  // ❌ 不会执行(绑定/动画绕过 wrapper)
    }
}

// ✅ 把副作用放 PropertyChangedCallback

十、与 ASP.NET Core / 前端的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
ASP.NET Core                WPF CustomControl
──────────────────────────────────────────────
Tag Helper                  MarkupExtension
Razor Component             UserControl
Partial View                UserControl
Custom Tag Helper           CustomControl
_Layout.cshtml              Generic.xaml(默认模板)

前端 React/Vue              WPF
──────────────────────────────────────────────
Component                   UserControl / CustomControl
Props                       Dependency Property
Slot (children)             ContentPresenter
Custom Hook                 附加属性
Style Library               Themes/Generic.xaml

后端视角:
  CustomControl = "可换皮的控件"
  TemplatePart 契约 = "接口约定"(PART_xxx 是 method 名)
  Generic.xaml = "默认实现"

十一、小结

本文深入 WPF 的自定义控件:

  • 三条路线(UserControl / CustomControl / OnRender)的选型
  • UserControl:组合现有控件,XAML + code-behind
  • CustomControl:继承 Control + Generic.xaml + TemplatePart
  • Themes/Generic.xaml 的约定与角色
  • TemplatePart 与 PART_ 命名约定
  • OnApplyTemplate + GetTemplateChild 的契约
  • 重写 OnRender(DrawingContext 绘图)
  • 自定义依赖属性 + 路由事件
  • 控件库项目结构
  • 设计时支持(DefaultEvent / Category / ToolboxBitmap)
  • 实战:完整 NumericUpDown
1
2
3
4
记住三句话:
  1. 视图复用 UserControl,控件库用 CustomControl,绘图用 OnRender
  2. CustomControl 的核心契约是 PART_xxx——控件代码只依赖名字,外观可彻底换
  3. OnApplyTemplate 是"模板就绪"事件——所有 GetTemplateChild 必须在这里调

下一篇是系列收尾——性能与发布:Freezable、虚拟化、绑定优化、self-contained、Trimming、MSIX 打包。

Licensed under CC BY-NC-SA 4.0