面向刚接触 WPF、已经会写基本 XAML 和事件处理,准备学习“怎样把界面封装成可复用控件”的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,文中涉及的 WPF API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-22

目录

  1. 用户控件应用场景
  2. DateTimePicker布局
  3. DateTimePicker控制逻辑
  4. DateTimePicker数据关联
  5. DateTimePicker显示优化
  6. 自定义控件处理与数字框实例

写在前面

这份笔记解决什么问题

刚开始写 WPF 时,很容易把所有控件都堆进一个窗口:

1
2
3
4
5
窗口里放文本框、按钮、日历
每个页面复制一份相似布局
改颜色时到处查找
改日期规则时每处都要重新写
控件值和业务数据互相不同步

这一篇不追求一次做出“大而全”的控件,而是从最简单的组合开始:

1
2
3
4
5
6
先判断什么时候值得做 UserControl
再把日期选择器的布局拆清楚
接着统一它的输入与值变化逻辑
然后接入双向绑定
再补齐显示状态
最后比较 UserControl 与 CustomControl

六章知识地图

图 1:六章从组合控件走到默认模板。

推荐学习顺序

  1. 先运行随附示例,依次点击六个页签。
  2. 第 1 章观察同一控件怎样出现在两个区域。
  3. 第 2 章先看布局,不急着写复杂事件。
  4. 第 3 章依次测试文本提交、日历选择、清空和范围修正。
  5. 第 4 章用按钮从 ViewModel 一侧改日期,观察控件同步。
  6. 第 5 章输入错误日期,观察边框与提示。
  7. 第 6 章设置数字框为 -10,确认它被修正为 0。

随附示例位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfUserControlDemo

运行:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfUserControlDemo"
dotnet build
dotnet run

Visual Studio 中:

  1. 打开 WpfUserControlDemo.csproj。
  2. 确认启动项目是 WpfUserControlDemo。
  3. 按 F5。
  4. 依次操作六个页签。

创建自己的跟做项目

在任意练习目录执行:

1
2
3
dotnet new wpf -n WpfUserControlPractice -o .\WpfUserControlPractice
cd .\WpfUserControlPractice
dotnet run

确认默认窗口能打开后,把项目文件中的目标框架改为:

1
<TargetFramework>net9.0-windows</TargetFramework>

如果本机使用 .NET 10,也可以写:

1
<TargetFramework>net10.0-windows</TargetFramework>

跟做完成后的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
WpfUserControlPractice
├─ App.xaml
├─ App.xaml.cs
├─ ControlRules.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ Controls
│ ├─ DateTimePicker.xaml
│ ├─ DateTimePicker.xaml.cs
│ └─ NumericBox.cs
├─ Models
│ └─ AppOptions.cs
├─ Themes
│ ├─ Generic.xaml
│ └─ Themes.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

本文使用 WpfUserControlDemo 作为命名空间。你自己练习时,可以把命名空间换成项目名,但同一文件内必须保持一致。

先准备公共规则

新建 ControlRules.cs:

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
namespace WpfUserControlDemo;

public static class ControlRules
{
public static DateTime? ClampDate(
DateTime? value,
DateTime? minimum,
DateTime? maximum)
{
if (value is null)
{
return null;
}

if (minimum is { } min && value < min)
{
return min.Date;
}

if (maximum is { } max && value > max)
{
return max.Date;
}

return value.Value.Date;
}

public static int ClampValue(
int value,
int minimum,
int maximum)
{
return Math.Clamp(value, minimum, maximum);
}

public static string FormatDate(
DateTime? value,
string format)
{
return value?.ToString(format) ?? "未选择";
}
}

这段代码只做纯计算,不依赖窗口。把规则集中起来后,日期控件和数字框都能复用,也更容易单独测试。

先准备 ViewModel

新建 ViewModels\ObservableObject.cs:

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
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfUserControlDemo.ViewModels;

public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

protected bool SetProperty<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}

field = value;
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
return true;
}
}

新建 ViewModels\MainViewModel.cs:

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
using System.Collections.ObjectModel;

namespace WpfUserControlDemo.ViewModels;

public sealed class MainViewModel : ObservableObject
{
private DateTime? _selectedDate = new(2026, 10, 22);
private string _selectedText = "2026-10-22";
private string _selectionSource = "初始值";
private string _validationText = "日期格式正确";
private int _quantity = 12;
private string _quantityText = "12";
private string _numericStatus = "数值框已就绪";

public ObservableCollection<string> UsageLog { get; } = new();
public ObservableCollection<string> LayoutLog { get; } = new();
public ObservableCollection<string> ControlLog { get; } = new();
public ObservableCollection<string> BindingLog { get; } = new();
public ObservableCollection<string> DisplayLog { get; } = new();
public ObservableCollection<string> NumericLog { get; } = new();

public DateTime? SelectedDate
{
get => _selectedDate;
set
{
if (SetProperty(ref _selectedDate, value))
{
SelectedText = value?.ToString("yyyy-MM-dd") ?? "(空)";
SelectionSource = "控件选择";
}
}
}

public string SelectedText
{
get => _selectedText;
set => SetProperty(ref _selectedText, value);
}

public string SelectionSource
{
get => _selectionSource;
set => SetProperty(ref _selectionSource, value);
}

public string ValidationText
{
get => _validationText;
set => SetProperty(ref _validationText, value);
}

public int Quantity
{
get => _quantity;
set
{
if (SetProperty(ref _quantity, value))
{
QuantityText = value.ToString();
}
}
}

public string QuantityText
{
get => _quantityText;
set => SetProperty(ref _quantityText, value);
}

public string NumericStatus
{
get => _numericStatus;
set => SetProperty(ref _numericStatus, value);
}

public void AddUsage(string line) => Add(UsageLog, line);
public void AddLayout(string line) => Add(LayoutLog, line);
public void AddControl(string line) => Add(ControlLog, line);
public void AddBinding(string line) => Add(BindingLog, line);
public void AddDisplay(string line) => Add(DisplayLog, line);
public void AddNumeric(string line) => Add(NumericLog, line);

private static void Add(
ObservableCollection<string> lines,
string line)
{
lines.Insert(0, line);

while (lines.Count > 30)
{
lines.RemoveAt(lines.Count - 1);
}
}
}

先理解两个关键词

这篇笔记会反复出现两个词:

1
2
3
4
5
6
7
UserControl
用 XAML 把几个已有控件组合起来。
页面能直接看到它的内部结构。

CustomControl
写一个控件类,再给这个类提供默认模板。
控件逻辑与视觉外观分开。

两者不是“初级”和“高级”的关系。选错会增加维护成本,选对会让界面更容易复用。


1. 用户控件应用场景

1.1 本节目标

完成本节后,你应该能回答:

1
2
3
4
什么时候应该新建 UserControl?
什么时候只是普通布局,不需要新建控件?
UserControl 能解决哪些重复工作?
什么情况下应该考虑 CustomControl?

本节还会创建第一个日期选择器,并在两个不同表单中复用它。

1.2 先判断是否真的需要封装

下面这些情况很适合 UserControl:

1
2
3
4
5
同一组控件在多个页面重复出现
组合内部有固定交互
希望页面只关心一个业务值
希望把复杂布局从主窗口拆出去
希望团队其他页面按统一方式使用

下面这些情况不一定要新建控件:

1
2
3
4
只在一个窗口出现一次
只是几个控件放在一起,没有独立行为
用 Style 或 ControlTemplate 就能解决
封装后反而需要暴露十几个内部控件

判断标准可以写成一句话:

如果“这组控件怎样配合”本身就是一段稳定逻辑,就值得封装。

UserControl 与 CustomControl 的取舍

图 2:先看复用目标,再选择控件类型。

1.3 UserControl 与 CustomControl 怎样选

对比项 UserControl CustomControl
主要用途 组合已有控件 提供可替换外观的控件
外观来源 固定 XAML 默认模板,通常放在 Themes/Generic.xaml
换皮肤 通常要改 XAML 或重做控件 可通过替换模板实现
内部元素 页面能看到命名元素 通过 PART_ 约定查找模板元素
适合场景 表单片段、业务小组件 通用输入控件、可发布控件库

本篇的 DateTimePicker 用 UserControl,因为它就是把文本框、日历和按钮组合起来。

NumericBox 用 CustomControl,因为要演示“控件逻辑在类里,外观在默认模板里”。

1.4 跟做:创建 UserControl

在项目中新建文件夹:

1
Controls

在 Controls 上右键,选择“添加 → 新建项 → WPF 用户控件”,命名为:

1
DateTimePicker.xaml

Visual Studio 会同时创建:

1
2
Controls\DateTimePicker.xaml
Controls\DateTimePicker.xaml.cs

先不要急着写全部功能。第一步只做一个能显示日期的静态外壳。

打开 Controls\DateTimePicker.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
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
<UserControl x:Class="WpfUserControlDemo.Controls.DateTimePicker"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root"
MinWidth="280"
MinHeight="38"
FontFamily="Microsoft YaHei UI">
<Border x:Name="OuterBorder"
Background="White"
BorderBrush="#C7D4E8"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<TextBlock Grid.Column="0"
Margin="12,0,8,0"
VerticalAlignment="Center"
Text="&#xE787;"
FontFamily="Segoe MDL2 Assets"
FontSize="16"
Foreground="#2456A6" />

<TextBox x:Name="DateTextBox"
Grid.Column="1"
MinWidth="128"
Padding="0,8"
VerticalContentAlignment="Center"
BorderThickness="0"
Background="Transparent"
Text="2026-10-22" />

<Button Grid.Column="2"
Width="34"
Height="30"
Margin="2,0"
Padding="0"
Background="Transparent"
BorderThickness="0"
Foreground="#2456A6"
FontFamily="Segoe MDL2 Assets"
Content="&#xE70D;"
ToolTip="打开日历" />

<Button Grid.Column="3"
Width="34"
Height="30"
Margin="2,0"
Padding="0"
Background="Transparent"
BorderThickness="0"
Foreground="#2456A6"
FontFamily="Segoe MDL2 Assets"
Content="&#xE711;"
ToolTip="清空日期" />
</Grid>
</Border>
</UserControl>

此时先不处理事件,确认 XAML 能正常显示。

1.5 跟做:在主窗口放两个实例

打开 MainWindow.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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<Window x:Class="WpfUserControlDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:WpfUserControlDemo.Controls"
Title="用户控件练习"
Width="980"
Height="560"
WindowStartupLocation="CenterScreen"
Background="#F4F7FC"
FontFamily="Microsoft YaHei UI">
<Grid Margin="40">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<Border Margin="0,0,14,0"
Padding="24"
Background="#EAF1FF"
BorderBrush="#B9CBEA"
BorderThickness="1"
CornerRadius="12">
<StackPanel>
<TextBlock Text="订单日期"
FontSize="20"
FontWeight="Bold"
Foreground="#2456A6" />
<TextBlock Margin="0,8,0,20"
Text="选择发货日期"
Foreground="#617089" />
<controls:DateTimePicker />
</StackPanel>
</Border>

<Border Grid.Column="1"
Margin="14,0,0,0"
Padding="24"
Background="#E8F6EF"
BorderBrush="#B9DDCB"
BorderThickness="1"
CornerRadius="12">
<StackPanel>
<TextBlock Text="报表日期"
FontSize="20"
FontWeight="Bold"
Foreground="#176B4D" />
<TextBlock Margin="0,8,0,20"
Text="选择统计截止日期"
Foreground="#617089" />
<controls:DateTimePicker />
</StackPanel>
</Border>
</Grid>
</Window>

创建 MainWindow.xaml.cs:

1
2
3
4
5
6
7
8
9
10
11
using System.Windows;

namespace WpfUserControlDemo;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

运行后应当看到两个外观相同的日期输入区域。

1.6 运行截图

用户控件应用场景

图 3:同一个 DateTimePicker 出现在订单和报表两个区域。

1.7 跟做检查点

  • 主窗口能正常打开,没有 XAML 解析错误。
  • 两个日期选择器的布局一致。
  • 文本框可以输入文字,但此时还没有提交逻辑。
  • 日历按钮和清空按钮还没有事件,这是正常的。
  • 右侧日历图标能显示,不出现方框或缺字。

如果图标显示成方块,通常是字体名称写错,或者系统没有 Segoe MDL2 Assets。Windows 10 和 Windows 11 默认都带有这个字体。

1.8 常见错误

错误一:忘记 XML 命名空间

主窗口使用自定义控件前必须有:

1
xmlns:controls="clr-namespace:WpfUserControlDemo.Controls"

否则 controls:DateTimePicker 会提示找不到类型。

错误二:把 UserControl 当成窗口

UserControl 没有 Show()。它是放进窗口或页面里的界面片段。

错误三:只复制 XAML,不复制代码后置

x:Class 指定了类名,XAML 和代码后置必须属于同一个类。

1.9 小练习

  1. 把两个 DateTimePicker 分别改成 Width="320" 和 Width="220",观察文本区域的变化。
  2. 在第二个实例上增加 Margin="0,20,0,0",观察它仍然只影响自己的位置。
  3. 想一想:如果两个实例应该显示同一个日期,值应该放在哪里?

2. DateTimePicker布局

2.1 本节目标

完成本节后,你应该能解释:

1
2
3
4
5
为什么使用四列 Grid?
Auto 和 * 分别负责什么?
为什么外层 Border 单独负责边框?
为什么按钮不参与文本输入区?
怎样让控件宽度变化时仍保持稳定?

2.2 四个区域各做什么

DateTimePicker 四列布局

图 4:四列布局把图标、输入、下拉和清空分开。

四列的顺序是:

1
2
3
4
第 0 列:日历图标,Auto
第 1 列:日期文本输入,*
第 2 列:打开日历,Auto
第 3 列:清空日期,Auto

为什么不是所有列都用 *?

1
2
3
图标和按钮宽度固定,用 Auto 更自然。
文本输入需要占满剩余空间,所以用 *。
如果按钮也使用 *,窗口变宽时按钮会被拉得很大。

2.3 跟做:完成正式布局

把 Controls\DateTimePicker.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
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
<UserControl x:Class="WpfUserControlDemo.Controls.DateTimePicker"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root"
MinWidth="280"
MinHeight="38"
FontFamily="Microsoft YaHei UI">
<UserControl.Resources>
<Style x:Key="PickerButton" TargetType="Button">
<Setter Property="Width" Value="34" />
<Setter Property="Height" Value="30" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#2456A6" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</UserControl.Resources>

<Border x:Name="OuterBorder"
Background="White"
BorderBrush="#C7D4E8"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<TextBlock Grid.Column="0"
Margin="12,0,8,0"
VerticalAlignment="Center"
Text="&#xE787;"
FontFamily="Segoe MDL2 Assets"
FontSize="16"
Foreground="#2456A6" />

<TextBox x:Name="DateTextBox"
Grid.Column="1"
MinWidth="128"
Padding="0,8"
VerticalContentAlignment="Center"
BorderThickness="0"
Background="Transparent" />

<Popup x:Name="CalendarPopup"
PlacementTarget="{Binding ElementName=OuterBorder}"
Placement="Bottom"
StaysOpen="False"
AllowsTransparency="True"
PopupAnimation="Fade">
<Border Margin="0,5,0,0"
Padding="10"
Background="White"
BorderBrush="#C7D4E8"
BorderThickness="1"
CornerRadius="10">
<Calendar x:Name="CalendarPart"
SelectionMode="SingleDate"
IsTodayHighlighted="True" />
</Border>
</Popup>

<Button Grid.Column="2"
Style="{StaticResource PickerButton}"
Content="&#xE70D;"
FontFamily="Segoe MDL2 Assets"
ToolTip="打开日历" />

<Button Grid.Column="3"
Style="{StaticResource PickerButton}"
Content="&#xE711;"
FontFamily="Segoe MDL2 Assets"
ToolTip="清空日期" />
</Grid>
</Border>
</UserControl>

注意:Popup 放在 Grid 里时不会占普通布局位置,它会在打开时浮到目标下方。这就是下拉日历不会把整页撑高的原因。

2.4 跟做:添加下拉绑定

给 Popup 增加:

1
IsOpen="{Binding IsDropDownOpen, ElementName=Root, Mode=TwoWay}"

IsDropDownOpen 是我们马上要注册的依赖属性。这里先保留 ElementName=Root,因为 Popup 的内容不在普通可视树中,用祖先查找不如直接绑定命名元素稳妥。

同时给两个按钮增加点击事件名:

1
Click="OnDropDownClick"

和:

1
Click="OnClearClick"

给文本框增加:

1
2
3
4
GotKeyboardFocus="OnTextBoxGotFocus"
LostKeyboardFocus="OnTextBoxLostFocus"
KeyDown="OnDateTextBoxKeyDown"
TextChanged="OnDateTextChanged"

给日历增加:

1
SelectedDatesChanged="OnCalendarSelectedDatesChanged"

此时不要直接运行,因为事件方法还没有写。下一章会一次补齐。

2.5 跟做:添加多种宽度示例

在主窗口中放三个不同宽度的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<StackPanel Margin="40">
<TextBlock Text="430 宽度"
Margin="0,0,0,12"
FontWeight="Bold" />
<controls:DateTimePicker Width="430"
HorizontalAlignment="Left" />

<TextBlock Text="两个 220 宽度"
Margin="0,28,0,12"
FontWeight="Bold" />
<StackPanel Orientation="Horizontal">
<controls:DateTimePicker Width="220" />
<controls:DateTimePicker Width="220"
Margin="16,0,0,0" />
</StackPanel>
</StackPanel>

运行时应看到三个控件的按钮都保持紧凑,文本框跟着宽度变化。

2.6 运行截图

DateTimePicker 布局

图 5:不同宽度下,按钮没有被拉宽,文本输入区承担剩余空间。

2.7 跟做检查点

  • Grid.ColumnDefinitions 正好有四列。
  • 第 1 列是 *,不是固定像素。
  • 两个按钮都有 ToolTip。
  • 文本框没有自己的外边框,边框由外层 Border 统一负责。
  • 弹层打开后能覆盖在其他内容上方。

2.8 常见错误

错误一:给文本框和按钮都加边框

结果会像两层盒子套在一起。日期选择器的边框应该由外层统一处理。

错误二:把 ​​Popup​​ ** 当成普通 Grid 子元素**

Popup 是浮层,不是普通的行内控件。不要把它的布局位置和文本框混在一起理解。

错误三:固定整个控件宽度

只在示例中固定宽度方便观察。正式页面应优先让控件跟随容器,必要时再设置 MinWidth 或 MaxWidth。

2.9 小练习

  1. 把第 1 列临时改成 Width="180",观察窗口变宽时会发生什么。
  2. 给 OuterBorder 增加 Padding="4",观察图标和按钮的位置。
  3. 把下拉按钮的 Width 改成 50,比较它和文本输入区的视觉比例。

3. DateTimePicker控制逻辑

3.1 本节目标

完成本节后,你应该能理解:

1
2
3
4
5
为什么控件要有 SelectedDate
文本框提交、日历选择、清空按钮怎样统一入口
依赖属性回调在什么时候执行
CoerceValueCallback 和普通属性回调有什么区别
怎样把日期范围限制在 2020 到 2035

3.2 先画出一条逻辑线

统一值入口

图 6:三种交互最终都修改同一个 SelectedDate。

不要写成三份日期:

1
2
3
文本框自己保存一个 DateText
日历自己保存一个 SelectedDate
清空按钮又保存一个 IsEmpty

更稳的结构是:

1
2
3
4
SelectedDate:唯一的业务值
DateTextBox:只负责显示和输入
CalendarPart:只负责选择
ValidationMessage:只负责提示

3.3 跟做:声明依赖属性

把 Controls\DateTimePicker.xaml.cs 替换为下面完整版本:

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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace WpfUserControlDemo.Controls;

public partial class DateTimePicker : UserControl
{
public static readonly DependencyProperty SelectedDateProperty =
DependencyProperty.Register(
nameof(SelectedDate),
typeof(DateTime?),
typeof(DateTimePicker),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedDateChanged,
CoerceSelectedDate));

public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(
nameof(Minimum),
typeof(DateTime?),
typeof(DateTimePicker),
new PropertyMetadata(null));

public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(
nameof(Maximum),
typeof(DateTime?),
typeof(DateTimePicker),
new PropertyMetadata(null));

public static readonly DependencyProperty DateFormatProperty =
DependencyProperty.Register(
nameof(DateFormat),
typeof(string),
typeof(DateTimePicker),
new PropertyMetadata(
"yyyy-MM-dd",
OnDateFormatChanged));

public static readonly DependencyProperty IsDropDownOpenProperty =
DependencyProperty.Register(
nameof(IsDropDownOpen),
typeof(bool),
typeof(DateTimePicker),
new PropertyMetadata(false));

public static readonly DependencyProperty ValidationMessageProperty =
DependencyProperty.Register(
nameof(ValidationMessage),
typeof(string),
typeof(DateTimePicker),
new PropertyMetadata(string.Empty));

private bool _isUpdatingText;

public DateTimePicker()
{
InitializeComponent();
Loaded += OnLoaded;
}

public event EventHandler<DateTimeSelectedEventArgs>? DateSelected;

public DateTime? SelectedDate
{
get => (DateTime?)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}

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

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

public string DateFormat
{
get => (string)GetValue(DateFormatProperty);
set => SetValue(DateFormatProperty, value);
}

public bool IsDropDownOpen
{
get => (bool)GetValue(IsDropDownOpenProperty);
set => SetValue(IsDropDownOpenProperty, value);
}

public string ValidationMessage
{
get => (string)GetValue(ValidationMessageProperty);
private set => SetValue(ValidationMessageProperty, value);
}

private void OnLoaded(object sender, RoutedEventArgs e)
{
UpdateTextFromValue();
}

private void OnDropDownClick(
object sender,
RoutedEventArgs e)
{
IsDropDownOpen = !IsDropDownOpen;
}

private void OnClearClick(
object sender,
RoutedEventArgs e)
{
SelectedDate = null;
ValidationMessage = string.Empty;
IsDropDownOpen = false;
DateTextBox.Focus();
}

private void OnCalendarSelectedDatesChanged(
object? sender,
SelectionChangedEventArgs e)
{
if (CalendarPart.SelectedDate is { } date)
{
SelectedDate = date.Date;
ValidationMessage = string.Empty;
IsDropDownOpen = false;
RaiseDateSelected(date.Date, "Calendar");
}
}

private void OnDateTextBoxKeyDown(
object sender,
KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
CommitText();
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
UpdateTextFromValue();
e.Handled = true;
}
}

private void OnDateTextChanged(
object sender,
TextChangedEventArgs e)
{
if (_isUpdatingText)
{
return;
}

var text = DateTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(text))
{
ValidationMessage = "日期不能为空";
SetBorderState(isError: true);
return;
}

if (DateTime.TryParseExact(
text,
DateFormat,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _))
{
ValidationMessage = string.Empty;
SetBorderState(isError: false);
}
else
{
ValidationMessage = "请输入正确日期,例如 2026-10-22";
SetBorderState(isError: true);
}
}

private void OnTextBoxGotFocus(
object sender,
KeyboardFocusChangedEventArgs e)
{
SetBorderState(isFocused: true);
}

private void OnTextBoxLostFocus(
object sender,
KeyboardFocusChangedEventArgs e)
{
SetBorderState(isFocused: false);
CommitText();
}

private void CommitText()
{
if (DateTime.TryParseExact(
DateTextBox.Text.Trim(),
DateFormat,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
SelectedDate = parsed.Date;
ValidationMessage = string.Empty;
SetBorderState(isError: false);
RaiseDateSelected(parsed.Date, "Text");
}
else
{
ValidationMessage = "日期格式不正确";
SetBorderState(isError: true);
}
}

private void UpdateTextFromValue()
{
_isUpdatingText = true;
DateTextBox.Text = SelectedDate?.ToString(DateFormat) ?? string.Empty;
_isUpdatingText = false;
}

private void SetBorderState(
bool? isFocused = null,
bool? isError = null)
{
if (isError == true)
{
OuterBorder.BorderBrush = new SolidColorBrush(
Color.FromRgb(155, 48, 48));
return;
}

if (isFocused == true)
{
OuterBorder.BorderBrush = new SolidColorBrush(
Color.FromRgb(36, 86, 166));
return;
}

OuterBorder.BorderBrush = new SolidColorBrush(
Color.FromRgb(199, 212, 232));
}

private static object? CoerceSelectedDate(
DependencyObject dependencyObject,
object? baseValue)
{
if (dependencyObject is not DateTimePicker picker)
{
return baseValue;
}

return ControlRules.ClampDate(
baseValue as DateTime?,
picker.Minimum,
picker.Maximum);
}

private static void OnSelectedDateChanged(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
if (dependencyObject is not DateTimePicker picker)
{
return;
}

picker.UpdateTextFromValue();
picker.CalendarPart.SelectedDate = picker.SelectedDate;
picker.ValidationMessage = string.Empty;
picker.SetBorderState(isError: false);
}

private static void OnDateFormatChanged(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
if (dependencyObject is DateTimePicker picker)
{
picker.UpdateTextFromValue();
}
}

private void RaiseDateSelected(
DateTime date,
string source)
{
DateSelected?.Invoke(
this,
new DateTimeSelectedEventArgs(date, source));
}
}

public sealed class DateTimeSelectedEventArgs : EventArgs
{
public DateTimeSelectedEventArgs(
DateTime date,
string source)
{
Date = date;
Source = source;
}

public DateTime Date { get; }
public string Source { get; }
}

3.4 依赖属性为什么需要注册

这段声明不是普通字段:

1
2
3
4
5
6
7
8
9
10
public static readonly DependencyProperty SelectedDateProperty =
DependencyProperty.Register(
nameof(SelectedDate),
typeof(DateTime?),
typeof(DateTimePicker),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedDateChanged,
CoerceSelectedDate));

把它拆开看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
nameof(SelectedDate)
属性名,供 XAML 和 WPF 查找

typeof(DateTime?)
值类型,允许“未选择”

typeof(DateTimePicker)
属性属于哪个控件类型

BindsTwoWayByDefault
页面绑定它时默认双向

OnSelectedDateChanged
值变化后的回调

CoerceSelectedDate
值写入前先做范围修正

声明之后还要有普通属性包装:

1
2
3
4
5
public DateTime? SelectedDate
{
get => (DateTime?)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}

不要把值存进普通字段。否则绑定、样式、动画和属性系统都无法正常工作。

3.5 跟做:绑定日期范围

在 XAML 的 Calendar 上增加:

1
2
3
DisplayDateStart="{Binding Minimum, ElementName=Root}"
DisplayDateEnd="{Binding Maximum, ElementName=Root}"
SelectedDate="{Binding SelectedDate, ElementName=Root, Mode=TwoWay}"

然后在主窗口中设置:

1
2
3
<controls:DateTimePicker Width="430"
Minimum="2020-01-01"
Maximum="2035-12-31" />

运行后尝试输入:

1
2
2019-01-01
2040-01-01

控件会把它们修正到最近的有效边界。

3.6 跟做:给页面增加按钮

在主窗口中增加三个按钮:

1
2
3
4
5
6
7
8
9
10
<WrapPanel Margin="0,18,0,0">
<Button Content="设为今天"
Click="OnSetTodayClick"
Margin="0,0,8,0" />
<Button Content="清空日期"
Click="OnClearDateClick"
Margin="0,0,8,0" />
<Button Content="读取当前值"
Click="OnReadDateClick" />
</WrapPanel>

代码后置:

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
using System.Windows;
using WpfUserControlDemo.Controls;

namespace WpfUserControlDemo;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}

private void OnSetTodayClick(object sender, RoutedEventArgs e)
{
LogicPicker.SelectedDate = DateTime.Today;
}

private void OnClearDateClick(object sender, RoutedEventArgs e)
{
LogicPicker.SelectedDate = null;
}

private void OnReadDateClick(object sender, RoutedEventArgs e)
{
MessageBox.Show(
LogicPicker.SelectedDate?.ToString("yyyy-MM-dd") ?? "未选择");
}

private void OnLogicDateSelected(
object? sender,
DateTimeSelectedEventArgs e)
{
MessageBox.Show($"{e.Date:yyyy-MM-dd},来源:{e.Source}");
}
}

并给控件加名字和事件:

1
2
3
4
5
<controls:DateTimePicker x:Name="LogicPicker"
Width="430"
Minimum="2020-01-01"
Maximum="2035-12-31"
DateSelected="OnLogicDateSelected" />

3.7 运行截图

控制逻辑

图 7:输入、日历和按钮共用一个 SelectedDate,范围信息清楚显示。

3.8 跟做检查点

  • 在文本框中输入合法日期并按下 Enter,文本框保持该日期。
  • 按 Escape,文本框恢复为原来的值。
  • 打开日历并选择日期,文本同步更新。
  • 点击清空按钮,日期变为空,SelectedDate 为 null。
  • 输入超出范围的日期时,结果被修正到边界。
  • DateSelected 事件只在用户主动选择时触发,不由普通显示刷新触发。

3.9 常见错误

错误一:在回调里再次给同一个属性赋相同值

如果没有判断值是否变化,可能形成无意义的重复回调。WPF 依赖属性会处理一部分重复赋值,但业务代码仍应避免循环。

错误二:把 ​​CoerceValueCallback​​ ** 当成验证**

强制回调会修改值。它适合范围修正,不适合“发现错误就阻止输入并提示”。

错误三:忽略可空类型

日期控件常常需要表示“还没选”。使用 DateTime? 比用 DateTime.MinValue 表达空值更清楚。

错误四:用文化格式随意解析

示例使用固定 DateFormat 和 InvariantCulture,是为了让用户输入格式稳定。面向多语言应用时,应明确产品要求的输入格式。

3.10 小练习

  1. 把 Minimum 改为 DateTime.Today,观察清空后重新选择日期时的行为。
  2. 增加一个“设为本月最后一天”的按钮。
  3. 修改 DateSelected 事件,让它同时报告是哪个控件触发的。

4. DateTimePicker数据关联

4.1 本节目标

完成本节后,你应该能解释:

1
2
3
4
5
控件值和 ViewModel 值为什么要分开
BindsTwoWayByDefault 做了什么
页面为什么可以显式写 Mode=TwoWay
控件改变时怎样通知业务对象
业务对象改变时怎样通知控件

4.2 双向绑定的两条方向

双向绑定

图 8:控件负责选择,ViewModel 负责保存和参与业务。

两条方向分别是:

1
2
3
4
5
6
7
8
9
控件 → ViewModel
用户选日期
SelectedDate 改变
绑定把新值写入 MainViewModel.SelectedDate

ViewModel → 控件
业务代码设置 SelectedDate
绑定把新值写回控件
文本框和日历同步刷新

4.3 跟做:在 ViewModel 中保存日期

如果前面已经完成 MainViewModel,可以直接使用。关键是这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private DateTime? _selectedDate = new(2026, 10, 22);

public DateTime? SelectedDate
{
get => _selectedDate;
set
{
if (SetProperty(ref _selectedDate, value))
{
SelectedText = value?.ToString("yyyy-MM-dd") ?? "(空)";
SelectionSource = "控件选择";
}
}
}

这里有一个容易忽略的点:ViewModel 的 setter 默认把来源写成“控件选择”。如果外部代码主动改 ViewModel,演示代码会在后面覆盖来源文字。真实项目中建议把“值”和“来源说明”分开管理。

4.4 跟做:页面使用双向绑定

1
2
3
<controls:DateTimePicker x:Name="BindingPicker"
Width="430"
SelectedDate="{Binding SelectedDate, Mode=TwoWay}" />

页面的 DataContext:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Windows;
using WpfUserControlDemo.ViewModels;

namespace WpfUserControlDemo;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
ViewModel = new MainViewModel();
DataContext = ViewModel;
}

public MainViewModel ViewModel { get; }
}

4.5 跟做:从 ViewModel 一侧改值

增加两个按钮:

1
2
3
4
<Button Content="ViewModel 设为明天"
Click="OnSetTomorrowClick" />
<Button Content="ViewModel 设为 null"
Click="OnSetNullClick" />

代码后置:

1
2
3
4
5
6
7
8
9
10
11
private void OnSetTomorrowClick(object sender, RoutedEventArgs e)
{
ViewModel.SelectedDate = DateTime.Today.AddDays(1);
ViewModel.SelectionSource = "ViewModel 设置明天";
}

private void OnSetNullClick(object sender, RoutedEventArgs e)
{
ViewModel.SelectedDate = null;
ViewModel.SelectionSource = "ViewModel 设置 null";
}

运行后依次点击两个按钮,观察:

1
2
3
控件文本
ViewModel 当前值
来源说明

三者应同步变化。

4.6 运行截图

数据关联

图 9:控件值与业务对象值保持一致,来源说明帮助排查。

4.7 跟做检查点

  • 用户从日历选择日期后,ViewModel 的 SelectedDate 更新。
  • 点击“ViewModel 设为明天”,控件文本和日历同步。
  • 点击“ViewModel 设为 null”,控件清空。
  • 页面不需要直接操作 DateTextBox。
  • 绑定失败时不会抛异常,而是静默使用回退值或显示绑定错误;调试时要查看输出窗口。

4.8 常见错误

错误一:ViewModel 没有实现 ​​INotifyPropertyChanged​

代码改了值,但控件不知道值变了。界面仍然显示旧值。

错误二:绑定路径写错

例如写成:

1
SelectedDate="{Binding Date}"

但 ViewModel 只有 SelectedDate。WPF 通常不会让程序崩溃,只会在调试输出中报告绑定错误。

错误三:控件自己又保存了一份值

如果控件内部另外维护 _date 字段,绑定和用户输入会产生两个事实来源,很容易不同步。

错误四:默认双向就完全不用写 Mode

默认双向能减少 XAML 重复,但在阅读代码时,显式写出 Mode=TwoWay 更能说明意图。示例保留显式写法,便于初学者观察。

4.9 小练习

  1. 把 Mode=TwoWay 改成 Mode=OneWay,观察从 ViewModel 改值时控件是否更新,再从控件改值时 ViewModel 是否更新。
  2. 在 ViewModel 中增加 LastChangedTime,每次日期改变时更新时间。
  3. 用两个日期控件绑定同一个 ViewModel 属性,观察它们是否同步。

5. DateTimePicker显示优化

5.1 本节目标

完成本节后,你应该能处理:

1
2
3
4
5
6
7
日期格式
空值提示
焦点边框
错误边框
错误说明
清空按钮的提示
按 Escape 撤销编辑

5.2 显示状态不是单一颜色

显示优化

图 10:正常、焦点、错误是三种不同状态。

可以把状态理解为:

1
2
3
4
正常:浅蓝边框,显示格式化日期
焦点:蓝色边框,提示正在输入
错误:红色边框,同时显示说明文字
空值:文本框为空,显示水印提示

示例用代码设置边框颜色,是为了让初学者看清状态切换。真实项目中更推荐把颜色放到资源或样式里,方便统一换肤。

5.3 跟做:增加日期格式属性

在 DateTimePicker.xaml.cs 中已经有:

1
2
3
4
5
6
7
8
public static readonly DependencyProperty DateFormatProperty =
DependencyProperty.Register(
nameof(DateFormat),
typeof(string),
typeof(DateTimePicker),
new PropertyMetadata(
"yyyy-MM-dd",
OnDateFormatChanged));

页面中这样使用:

1
2
<controls:DateTimePicker Width="430"
DateFormat="yyyy年MM月dd日" />

yyyy 是四位年份,MM 是两位月份,dd 是两位日期。格式变化只影响显示,不改变实际保存的日期。

5.4 跟做:增加水印提示

示例中保留了 Watermark 依赖属性:

1
2
3
4
5
6
public static readonly DependencyProperty WatermarkProperty =
DependencyProperty.Register(
nameof(Watermark),
typeof(string),
typeof(DateTimePicker),
new PropertyMetadata("请选择日期"));

在 XAML 中设置:

1
<controls:DateTimePicker Watermark="请选择业务日期" />

如果要让水印真正显示在文本框里,可以在文本框上方叠加一个 TextBlock,并绑定“当前文本为空”和 Watermark。示例为了把重点放在依赖属性与事件上,先保留属性并在说明区域展示提示文字。

5.5 跟做:显示验证消息

在控件旁边增加:

1
2
3
4
<TextBlock Margin="0,10,0,0"
Text="{Binding ElementName=DisplayPicker, Path=ValidationMessage}"
Foreground="#9B3030"
FontWeight="Bold" />

输入一个非法日期,例如:

1
2026/13/40

观察:

1
2
3
边框变红
验证消息出现
按 Escape 后恢复原值

5.6 跟做:改善键盘体验

控件已经支持:

1
2
3
Enter:提交文本
Escape:撤销当前编辑
失去焦点:尝试提交

对应代码:

1
2
3
4
5
6
7
8
9
10
if (e.Key == Key.Enter)
{
CommitText();
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
UpdateTextFromValue();
e.Handled = true;
}

e.Handled = true 告诉后续控件“这个按键已经处理”,避免 Enter 又触发页面上的默认按钮。

5.7 运行截图

显示优化

图 11:格式、焦点、提示和错误状态一起构成可用的日期输入体验。

5.8 跟做检查点

  • 修改 DateFormat 后,显示立即变化。
  • 输入非法日期时,边框变红且说明出现。
  • 按 Escape 能恢复到当前有效值。
  • 清空后文本为空,控件仍然可以继续输入。
  • 下拉按钮和清空按钮都有可读的 ToolTip。

5.9 常见错误

错误一:把格式字符串当成业务值

显示 yyyy年MM月dd日 不代表 ViewModel 里存的是字符串。ViewModel 仍然保存 DateTime?,格式化只在显示层发生。

错误二:输入过程中每按一个键就弹窗

频繁弹窗会打断输入。更好的做法是保留输入,更新边框和提示,等提交时再决定是否阻止。

错误三:错误边框永远不恢复

成功解析、清空或按 Escape 后都应恢复到正常状态。

5.10 小练习

  1. 把错误颜色改成橙色,观察哪些地方需要一起改。
  2. 增加“今天”按钮,但不要直接修改 DateTextBox,而是修改 SelectedDate。
  3. 给 ValidationMessage 增加一个默认提示“请输入日期”。

6. 自定义控件处理与数字框实例

6.1 本节目标

完成本节后,你应该能理解:

1
2
3
4
5
6
CustomControl 与 UserControl 的区别
DefaultStyleKeyProperty 的作用
为什么默认模板通常放在 Themes/Generic.xaml
PART_ 元素是什么
控件逻辑怎样与模板外观分离
怎样做一个带上下按钮的数字框

6.2 为什么数字框适合 CustomControl

数字框通常需要:

1
2
3
4
5
6
7
一个值
最小值、最大值、步长
文本框输入
上下按钮
键盘上下键
非法输入恢复
未来可能换主题

这些逻辑与视觉结构可以分开:

1
2
3
4
5
NumericBox.cs
管值、范围、键盘、按钮事件

Themes/Generic.xaml
管文本框、按钮、边框和默认颜色

数字框模板

图 12:类文件与默认模板分工明确。

6.3 跟做:创建 NumericBox 类

在 Controls 文件夹新建 NumericBox.cs:

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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace WpfUserControlDemo.Controls;

public sealed class NumericBox : Control
{
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(
nameof(Value),
typeof(int),
typeof(NumericBox),
new FrameworkPropertyMetadata(
0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue));

public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(
nameof(Minimum),
typeof(int),
typeof(NumericBox),
new PropertyMetadata(0));

public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(
nameof(Maximum),
typeof(int),
typeof(NumericBox),
new PropertyMetadata(100));

public static readonly DependencyProperty StepProperty =
DependencyProperty.Register(
nameof(Step),
typeof(int),
typeof(NumericBox),
new PropertyMetadata(1));

private TextBox? _textBox;
private bool _isUpdating;

static NumericBox()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(NumericBox),
new FrameworkPropertyMetadata(typeof(NumericBox)));
}

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

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

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

public int Step
{
get => (int)GetValue(StepProperty);
set => SetValue(StepProperty, value);
}

public event EventHandler<int>? ValueChanged;

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

if (GetTemplateChild("PART_IncreaseButton") is Button increaseButton)
{
increaseButton.Click -= OnIncreaseClick;
increaseButton.Click += OnIncreaseClick;
}

if (GetTemplateChild("PART_DecreaseButton") is Button decreaseButton)
{
decreaseButton.Click -= OnDecreaseClick;
decreaseButton.Click += OnDecreaseClick;
}

if (_textBox is not null)
{
_textBox.KeyDown -= OnTextBoxKeyDown;
_textBox.LostKeyboardFocus -= OnTextBoxLostFocus;
}

_textBox = GetTemplateChild("PART_TextBox") as TextBox;

if (_textBox is not null)
{
_textBox.KeyDown += OnTextBoxKeyDown;
_textBox.LostKeyboardFocus += OnTextBoxLostFocus;
UpdateText();
}
}

public void Increase()
{
Value = ControlRules.ClampValue(
Value + Step,
Minimum,
Maximum);
}

public void Decrease()
{
Value = ControlRules.ClampValue(
Value - Step,
Minimum,
Maximum);
}

private void OnIncreaseClick(
object sender,
RoutedEventArgs e)
{
Increase();
}

private void OnDecreaseClick(
object sender,
RoutedEventArgs e)
{
Decrease();
}

private void OnTextBoxKeyDown(
object sender,
KeyEventArgs e)
{
if (e.Key == Key.Up)
{
Increase();
e.Handled = true;
}
else if (e.Key == Key.Down)
{
Decrease();
e.Handled = true;
}
else if (e.Key == Key.Enter)
{
CommitText();
e.Handled = true;
}
}

private void OnTextBoxLostFocus(
object sender,
KeyboardFocusChangedEventArgs e)
{
CommitText();
}

private void CommitText()
{
if (_textBox is null)
{
return;
}

if (int.TryParse(_textBox.Text, out var value))
{
Value = ControlRules.ClampValue(
value,
Minimum,
Maximum);
}
else
{
UpdateText();
}
}

private void UpdateText()
{
if (_textBox is null || _isUpdating)
{
return;
}

_isUpdating = true;
_textBox.Text = Value.ToString();
_isUpdating = false;
}

private static object CoerceValue(
DependencyObject dependencyObject,
object baseValue)
{
var box = (NumericBox)dependencyObject;
return ControlRules.ClampValue(
(int)baseValue,
box.Minimum,
box.Maximum);
}

private static void OnValueChanged(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs e)
{
var box = (NumericBox)dependencyObject;
box.UpdateText();
box.ValueChanged?.Invoke(box, (int)e.NewValue);
}
}

6.4 跟做:创建默认模板

在项目中新建文件夹:

1
Themes

新建 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
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
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:WpfUserControlDemo.Controls">
<Style TargetType="{x:Type controls:NumericBox}">
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="#C7D4E8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="#172033" />
<Setter Property="MinWidth" Value="148" />
<Setter Property="Height" Value="38" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type controls:NumericBox}">
<Border x:Name="RootBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="34" />
</Grid.ColumnDefinitions>

<TextBox x:Name="PART_TextBox"
Grid.Column="0"
Padding="11,7"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="{TemplateBinding Foreground}"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button x:Name="PART_IncreaseButton"
Grid.Row="0"
Padding="0"
Background="#EAF1FF"
BorderThickness="0"
Foreground="#2456A6"
FontFamily="Segoe MDL2 Assets"
Content="&#xE70E;" />
<Button x:Name="PART_DecreaseButton"
Grid.Row="1"
Padding="0"
Background="#EAF1FF"
BorderThickness="0"
Foreground="#2456A6"
FontFamily="Segoe MDL2 Assets"
Content="&#xE70D;" />
</Grid>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocusWithin"
Value="True">
<Setter TargetName="RootBorder"
Property="BorderBrush"
Value="#2456A6" />
</Trigger>
<Trigger Property="IsEnabled"
Value="False">
<Setter TargetName="RootBorder"
Property="Opacity"
Value="0.55" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

6.5 跟做:注册主题资源

新建 Themes\Themes.cs:

1
2
3
4
5
6
7
8
9
10
11
using System.Windows;

[assembly: ThemeInfo(
ResourceDictionaryLocation.None,
ResourceDictionaryLocation.SourceAssembly)]

namespace WpfUserControlDemo.Themes;

public static class Themes
{
}

ThemeInfo 告诉 WPF:默认主题资源在当前程序集中。没有这段注册时,NumericBox 可能能创建,但找不到 Generic.xaml 里的默认样式,结果只显示成一个空控件。

6.6 跟做:在页面使用 NumericBox

1
2
3
4
5
6
<controls:NumericBox x:Name="QuantityBox"
Width="230"
Value="{Binding Quantity, Mode=TwoWay}"
Minimum="0"
Maximum="99"
Step="1" />

ViewModel 中保留:

1
2
3
4
5
6
7
private int _quantity = 12;

public int Quantity
{
get => _quantity;
set => SetProperty(ref _quantity, value);
}

增加按钮:

1
2
3
4
<Button Content="设为 25"
Click="OnNumericSetClick" />
<Button Content="设为 -10"
Click="OnNumericInvalidClick" />

代码后置:

1
2
3
4
5
6
7
8
9
private void OnNumericSetClick(object sender, RoutedEventArgs e)
{
QuantityBox.Value = 25;
}

private void OnNumericInvalidClick(object sender, RoutedEventArgs e)
{
QuantityBox.Value = -10;
}

运行后:

1
2
3
4
5
点击“设为 25”:显示 25
点击“设为 -10”:显示 0
按上箭头:值增加 Step
按下箭头:值减少 Step
输入非数字并失去焦点:恢复当前值

6.7 运行截图

数字框实例

图 13:NumericBox 使用默认模板,按钮和输入框都来自 Generic.xaml。

6.8 为什么要有 PART_ 名称

模板里的元素名称不是随意起的:

1
2
3
<TextBox x:Name="PART_TextBox" />
<Button x:Name="PART_IncreaseButton" />
<Button x:Name="PART_DecreaseButton" />

控件代码通过:

1
GetTemplateChild("PART_TextBox")

找到它们。以后换模板时,只要保留这三个名称,控件逻辑仍然能工作。

可以把它理解成控件和模板之间的约定:

1
2
控件:我需要一个文本框和两个按钮
模板:我按约定提供它们

6.9 跟做检查点

  • 页面上的数字框不是普通 TextBox,而是能显示上下按钮的 NumericBox。
  • 输入 -10 后值为 0。
  • 输入 120 后值为 99。
  • 按上、下键能改变值。
  • 输入非数字并失去焦点后,文本恢复。
  • 修改 Generic.xaml 的颜色,页面重新运行后外观变化。

6.10 常见错误

错误一:没有覆盖 DefaultStyleKeyProperty

控件会使用基类 Control 的默认样式,而不是你写的 NumericBox 样式。

错误二:Generic.xaml 没有放在 Themes 文件夹

WPF 默认主题查找约定通常要求文件名和位置符合 Themes/Generic.xaml。

错误三:模板里改了 PART_ 名称

控件代码会找不到元素。要么保持名称不变,要么同时修改代码。

错误四:在模板事件里直接写 Click 方法

ResourceDictionary 没有代码后置类,不能在 Generic.xaml 中直接绑定 Click="OnIncreaseClick"。正确做法是通过 GetTemplateChild 找到按钮,再在控件类中订阅事件。

错误五:把范围修正写成弹窗

强制回调适合自动修正。弹窗会打断输入,且可能在绑定初始化阶段触发。

6.11 小练习

  1. 给 NumericBox 增加 SmallChange 和 LargeChange,支持 PageUp、PageDown。
  2. 把 Step 改成 5,观察上下按钮和键盘变化。
  3. 修改 Generic.xaml 的按钮颜色,再新增第二个 NumericBox,确认两个实例共享样式但值互不影响。
  4. 给数字框增加 ValueChanged 日志,记录旧值和新值。需要把事件参数从 int 改成包含旧值、新值的对象。

完整项目结构

随附示例的完整结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
WpfUserControlDemo
├─ App.xaml
├─ App.xaml.cs
├─ ControlRules.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ SelfTestRunner.cs
├─ UiProbe.cs
├─ Controls
│ ├─ DateTimePicker.xaml
│ ├─ DateTimePicker.xaml.cs
│ └─ NumericBox.cs
├─ Models
│ └─ AppOptions.cs
├─ Themes
│ ├─ Generic.xaml
│ └─ Themes.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

自动测试

构建:

1
2
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfUserControlDemo"
dotnet build "WpfUserControlDemo.csproj" --configuration Debug --nologo

运行内置自测:

1
2
3
4
$exe = (Resolve-Path ".\bin\Debug\net9.0-windows\WpfUserControlDemo.exe").Path
$p = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait
"ExitCode=$($p.ExitCode)"
Get-Content ".\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8

本次验证结果:

1
2
生成成功:0 个警告,0 个错误
自测:11/11 通过

自测覆盖:

1
2
3
4
5
6
7
8
9
10
11
日期下界强制
日期上界强制
空日期保持为空
数字下界强制
数字上界强制
日期文本格式化
DateTimePicker 已实例化
NumericBox 模板已应用
NumericBox 值强制
NumericBox 上界强制
SelectedDate 双向同步

常见错误速查

现象 先检查什么 常见原因
自定义控件找不到类型 xmlns:controls 命名空间或程序集名写错
NumericBox 只显示空白 Themes/Generic.xaml、ThemeInfo 默认模板没有注册
日期文本不更新 SelectedDate 回调 没有调用 UpdateTextFromValue
绑定后 ViewModel 不更新 Mode、INotifyPropertyChanged 单向绑定或没有通知
日历弹层不出现 IsOpen 绑定 IsDropDownOpen 没有改变
日期范围无效 CoerceValueCallback 没有读取 Minimum、Maximum
数字框越界不修正 CoerceValue 直接写字段或绕过了依赖属性
文本框输入非法值后消失 CommitText 没有恢复原值的分支
按钮点击没有反应 OnApplyTemplate 没有找到 PART_ 按钮

综合练习

练习一:日期范围与提示

把日期选择器的范围改为“今天到一年后”,并增加:

1
2
3
范围外输入时显示提示
点击“今天”按钮自动选中今天
清空后保留水印

要求仍然只通过 SelectedDate 修改业务值。

练习二:三个数字框联动

创建三个 NumericBox:

1
2
3
最小值:0
最大值:100
步长:1

让它们的值同步,并观察:

1
2
3
哪个控件先触发 ValueChanged
强制回调是否会把值修正两次
模板中的 PART_TextBox 是否都独立

练习三:换一套模板

复制 Generic.xaml,把数字框按钮改成橙色,边框改成圆角更大的样式。运行后确认:

1
2
3
控件逻辑没有改
外观发生变化
两个实例都使用新模板

练习四:把日期控件放进列表

创建一个 ItemsControl,每一项都有一个日期选择器,并绑定到集合中的不同对象。思考:

1
2
3
为什么不能把所有项都绑定到同一个 SelectedDate?
DataContext 在子项中变成了什么?
控件内部 ElementName 绑定是否仍然有效?

结语

这篇笔记的重点不是背下所有代码,而是建立三个习惯:

1
2
3
先拆布局,再写逻辑
先统一数据入口,再接界面
先让控件能工作,再考虑换模板和换皮肤

当你能独立完成下面三件事,就说明这一篇真正掌握了:

  1. 不看示例,写出一个包含文本框、按钮和日历弹层的 UserControl。
  2. 让 SelectedDate 同时支持控件输入、ViewModel 赋值和范围修正。
  3. 写出一个使用 Generic.xaml 默认模板的 NumericBox,并解释 PART_TextBox 的作用。