面向刚开始学习 WPF 的读者。
推荐环境:.NET 10 LTS + WPF。示例项目为了能在当前只安装了 .NET 9 SDK 的机器上直接编译,目标框架写成了 net9.0-windows。依赖属性的写法在 .NET 9 和 .NET 10 中没有变化,把目标框架改一行即可。
最后更新:2026-10-19
目录
- 依赖属性的声明
- 依赖属性的注册
- 依赖属性的值变化回调
- 依赖属性的值验证回调
- 依赖属性的值强置回调
- 自定义容器的依赖属性改造
写在前面
这份笔记怎么用
这份笔记有两种用法,可以任选一种:
| 路线 |
做法 |
适合情况 |
| 跟做路线 |
新建一个 WPF 项目,从第 1 章开始逐步加入代码 |
想亲手敲一遍 |
| 对照路线 |
直接打开随附示例,运行后对照章节阅读 |
想先看到效果 |
无论选择哪条路线,都建议按照下面的顺序操作:
- 先运行程序,观察界面变化。
- 再看调试输出,确认回调确实被调用了。
- 最后回到代码,理解每个参数和回调的职责。
示例项目位置:
1
| C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfDependencyPropertyDemo
|
示例项目的运行方法
在 PowerShell 中执行:
1 2 3
| cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfDependencyPropertyDemo" dotnet build dotnet run
|
如果使用 Visual Studio:
- 打开项目或解决方案。
- 确认启动项目是
WpfDependencyPropertyDemo。
- 按
F5 以调试方式启动。
- 打开“输出”窗口,把显示来源切换为“调试”。
Debug.WriteLine 的输出主要用于调试。只使用 dotnet run 时,WPF 窗口仍然能正常显示,但不一定能在终端看到这些调试文本。
2026 年的目标框架怎么选
如果是新项目,并且已经安装了 .NET 10 SDK,推荐使用:
1
| <TargetFramework>net10.0-windows</TargetFramework>
|
如果电脑上只有 .NET 9 SDK,就使用:
1
| <TargetFramework>net9.0-windows</TargetFramework>
|
本笔记讨论的是 WPF 依赖属性机制,不是某个版本才有的新语法。目标框架改变后,下面的依赖属性代码不需要跟着改。
先建立直觉:依赖属性到底是什么
普通 C# 属性通常把值放在一个私有字段里:
1 2 3 4 5 6 7
| private string _title = "未命名卡片";
public string Title { get => _title; set => _title = value; }
|
这个属性很好用,但它只认识 C# 代码。WPF 还希望属性能够参与下面这些事情:
- XAML 赋值
- 数据绑定
- 样式和模板
- 动画
- 属性值继承
- 默认值管理
- 属性值变化通知
- 布局失效
依赖属性就是 WPF 属性系统认识的一种属性。它不是把值简单地放在普通字段里,而是把值交给 WPF 的属性系统管理。
| 对比项 |
普通 CLR 属性 |
依赖属性 |
| 值的存放 |
通常放在私有字段 |
由 WPF 属性系统管理 |
| XAML 支持 |
可以读写,但功能有限 |
原生支持 |
| 数据绑定 |
需要自己实现通知 |
可以直接参与绑定 |
| 样式和模板 |
支持有限 |
原生支持 |
| 动画 |
需要额外处理 |
可以直接动画 |
| 默认值 |
在字段或构造函数中设置 |
在元数据中注册 |
| 变化通知 |
通常依赖 INotifyPropertyChanged |
可以使用属性变化回调 |
一句话理解:依赖属性是“注册给 WPF 属性系统管理的属性”。你声明一个静态的“属性身份证”,WPF 再按照这个身份证为每个实例保存和计算值。
依赖属性里的“依赖”不是“依赖注入”的意思。它更接近“这个属性的值可能依赖多个来源”,例如本地值、样式、模板、动画和默认值。
依赖属性的四个角色
学习依赖属性时,先把下面四个角色分开:
| 角色 |
典型代码 |
作用 |
| 依赖属性标识符 |
TitleProperty |
告诉 WPF 这是哪一个属性 |
| CLR 包装器 |
public string Title |
让 C# 代码可以像普通属性一样读写 |
| 元数据 |
FrameworkPropertyMetadata |
规定默认值和布局行为 |
| 回调 |
Validate、Coerce、PropertyChanged |
在特定时机执行规则 |
很多初学者会把它们混在一起,导致代码越写越乱。后面每章只聚焦一个角色。

图 1:依赖属性的四个角色,以及“静态标识符共享、实例值分开保存”的关系。
1. 依赖属性的声明
本节目标
能够写出依赖属性的基本结构,并理解下面几个问题:
- 为什么依赖属性字段通常是
public static readonly。
- 为什么还要写一个普通的 CLR 属性。
GetValue 和 SetValue 在做什么。
- 为什么不应该在属性的
set 里面写业务逻辑。
1.1 先看一个普通属性的局限
假设我们想做一个卡片控件,卡片有一个标题:
1 2 3 4
| public class InfoCard : Border { public string Title { get; set; } = "未命名卡片"; }
|
这段代码可以编译,也可以在 C# 中赋值:
1 2
| var card = new InfoCard(); card.Title = "第一张卡片";
|
但是,如果希望下面这些事情自动工作,普通属性就不够了:
1
| <local:InfoCard Title="来自 XAML 的标题" />
|
1
| <local:InfoCard Title="{Binding CurrentTitle}" />
|
1 2 3
| <Style TargetType="local:InfoCard"> <Setter Property="Title" Value="来自样式的标题" /> </Style>
|
依赖属性把这些能力接入 WPF 的属性系统。
1.2 依赖属性字段的命名约定
依赖属性通常这样声明:
1
| public static readonly DependencyProperty TitleProperty;
|
这行代码有几个约定:
| 部分 |
含义 |
public |
通常允许外部代码和 XAML 访问 |
static |
每个类型只有一个属性标识符,不是每个对象一个 |
readonly |
注册后不再重新赋值 |
DependencyProperty |
字段类型必须是依赖属性类型 |
TitleProperty |
名称通常是 CLR 属性名加 Property |
这里最容易误解的是 static。
TitleProperty 是“属性身份证”,所有 InfoCard 实例共用同一张身份证。真正的值不是存放在这个静态字段里,而是由 WPF 按照每个实例分别保存。
可以把它想成:
1 2 3
| TitleProperty -> 这是哪个属性 card1.GetValue(...) -> card1 的这个属性值是多少 card2.GetValue(...) -> card2 的这个属性值是多少
|
1.3 再写一个 CLR 包装器
只有静态字段还不够,通常还要写一个普通的 CLR 属性作为“外壳”:
1 2 3 4 5
| public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
这个属性叫 CLR 包装器。它的作用是让 C# 代码可以这样写:
而不是每次都写:
1
| card.SetValue(InfoCard.TitleProperty, "第一张卡片");
|
包装器内部只是转交给 WPF:
| 包装器代码 |
实际发生的事情 |
GetValue(TitleProperty) |
从 WPF 属性系统读取当前有效值 |
SetValue(TitleProperty, value) |
把值提交给 WPF 属性系统 |
(string) |
把 object 转回属性的声明类型 |
重要规则:包装器的 get 和 set 应该保持简单。验证、强制和变化后的界面更新,应放在依赖属性回调中,而不是写在包装器里。
为什么不能把逻辑写在包装器里?因为 XAML 解析、数据绑定和样式系统有时会直接调用属性系统,不一定会经过你的 CLR 包装器。如果把关键逻辑藏在 set 里,某些赋值路径就可能绕过它。
1.4 声明阶段的完整结构
把字段和包装器放在一起,结构如下:
1 2 3 4 5 6 7 8 9 10
| public class InfoCard : Border { public static readonly DependencyProperty TitleProperty;
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); } }
|
不过,这段代码还不能真正运行,因为 TitleProperty 还没有注册。readonly 字段必须在声明时或构造函数中赋值,下一章会补上注册代码。
1.5 为什么字段名要加 Property
这是 WPF 和 XAML 生态中的通用约定:
1 2 3
| public static readonly DependencyProperty TitleProperty;
public string Title { get; set; }
|
这样写有三个好处:
- 一眼能看出
TitleProperty 是依赖属性标识符。
- 不会和普通的
Title 属性重名。
- 其他开发者、设计器工具和代码分析器都容易识别。
注册时也建议使用 nameof(Title),不要手写字符串:
这样以后重命名属性时,编译器会帮助你发现问题。
1.6 属性类型必须一致
如果注册时声明的是 string,包装器也应该是 string:
1 2 3 4 5
| public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
如果把注册类型写成 int,却用 (string) 去转换,运行时就会出问题。初学者遇到“值类型转换失败”时,先检查这两个地方是否一致。
1.7 本节小结
| 问题 |
答案 |
| 依赖属性字段为什么是静态的 |
它是属性标识符,所有实例共享一个标识符 |
| 为什么还需要 CLR 包装器 |
方便 C# 代码像普通属性一样读写 |
| 值存在哪里 |
由 WPF 属性系统按实例管理 |
| 包装器里能写验证吗 |
不建议,应该使用依赖属性回调 |
| 声明完就能用吗 |
还不能,必须完成注册 |
小练习
- 把示例中的
Title 改名为 Header,同时修改字段名、包装器和 nameof。
- 思考:如果两个
InfoCard 实例的标题不同,TitleProperty 这个静态字段会不会保存两个不同的字符串?
第二题的答案是:不会。静态字段只保存“属性标识符”,两个实例的值由 WPF 属性系统分别保存。
2. 依赖属性的注册
本节目标
完成第一个真正可运行的依赖属性,并理解:
DependencyProperty.Register 的四个关键参数。
PropertyMetadata 和 FrameworkPropertyMetadata 的区别。
- 默认值放在哪里。
- 只读依赖属性的基本写法。
2.1 注册方法的四个关键参数
最常用的注册形式如下:
1 2 3 4 5 6
| public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( nameof(Title), typeof(string), typeof(InfoCard), new FrameworkPropertyMetadata("未命名卡片"));
|
四个参数分别表示:
| 参数 |
示例 |
作用 |
| 属性名 |
nameof(Title) |
WPF 内部识别这个属性 |
| 属性类型 |
typeof(string) |
值必须是什么类型 |
| 所有者类型 |
typeof(InfoCard) |
哪个类拥有这个依赖属性 |
| 元数据 |
new FrameworkPropertyMetadata(...) |
默认值和属性行为 |
可以把注册理解为给 WPF 提交一份登记表:
1 2 3 4
| 名字:Title 类型:string 主人:InfoCard 默认值:未命名卡片
|
注册只应该执行一次,所以通常直接写在静态字段初始化中。
2.2 最小可运行的 InfoCard
先创建一个只包含标题的版本。这个版本可以帮助你确认注册、XAML 和包装器都能工作。
在项目中新建 InfoCard.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
| using System.Windows; using System.Windows.Controls; using System.Windows.Media;
namespace WpfDependencyPropertyDemo;
public class InfoCard : Border { private readonly TextBlock _titleText;
public InfoCard() { _titleText = new TextBlock { FontSize = 20, FontWeight = FontWeights.SemiBold };
Background = Brushes.White; BorderBrush = Brushes.LightGray; BorderThickness = new Thickness(1); CornerRadius = new CornerRadius(12); Padding = new Thickness(22); Child = _titleText;
UpdateTitle(); }
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( nameof(Title), typeof(string), typeof(InfoCard), new FrameworkPropertyMetadata( "未命名卡片", FrameworkPropertyMetadataOptions.AffectsRender));
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
private void UpdateTitle() { _titleText.Text = Title; } }
|
这里的 InfoCard 继承自 Border。Border 是 WPF 自带的控件,适合做卡片外框。Child 是 Border 用来放内部内容的属性。
把 MainWindow.xaml 暂时改成:
1 2 3 4 5 6 7 8 9 10 11 12
| <Window x:Class="WpfDependencyPropertyDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:WpfDependencyPropertyDemo" Title="依赖属性示例" Height="420" Width="720" WindowStartupLocation="CenterScreen"> <Grid Margin="30"> <local:InfoCard /> </Grid> </Window>
|
运行后应该看到一个白色边框卡片,标题是“未命名卡片”。
现在把 XAML 改成:
1
| <local:InfoCard Title="来自 XAML 的标题" />
|
如果你还没有添加第 3 章的值变化回调,界面上的文字可能仍然是“未命名卡片”。这不是注册失败,而是因为 XAML 设置属性后,控件没有收到“更新文字”的通知。下一章会补上这个缺口。
FrameworkPropertyMetadata 是 WPF 中常用的元数据类。它可以保存默认值,也可以告诉 WPF:这个属性变化后应该影响什么。
常用选项如下:
| 选项 |
作用 |
AffectsMeasure |
属性变化后重新测量布局 |
AffectsArrange |
属性变化后重新排列布局 |
AffectsRender |
属性变化后重新绘制 |
BindsTwoWayByDefault |
绑定默认使用双向模式 |
Inherits |
允许属性值沿元素树向下继承 |
SubPropertiesDoNotAffectRender |
子属性变化不触发重新渲染 |
不是每个属性都需要这些选项。比如 Title 主要影响内部文本,变化回调里已经主动更新了 TextBlock,所以 AffectsRender 不是必须的;这里保留它只是为了展示元数据选项的写法。
布局属性更常用 AffectsMeasure 和 AffectsArrange,第 6 章会用到。
2.4 默认值应该放在哪里
依赖属性的默认值放在元数据中:
1
| new FrameworkPropertyMetadata("未命名卡片")
|
不要在包装器的 get 里写:
1
| get => _title ?? "未命名卡片";
|
也不要在构造函数里用普通字段代替默认值。默认值是属性系统的一部分,应该由注册信息统一管理。
对于字符串、数字、枚举等不可变值,默认值通常很安全。对于引用类型,要特别注意“多个实例共享同一个默认对象”的问题。
错误示例:
1
| new FrameworkPropertyMetadata(new List<string>())
|
如果多个控件都拿到同一个列表默认值,就可能互相影响。更稳妥的做法是:
- 使用
null 作为默认值,在需要时创建实例。
- 使用不可变的默认对象。
- 使用
Freezable 并在创建后调用 Freeze()。
示例中的默认画刷就是这样处理的:
1 2 3 4 5 6
| private static Brush CreateFrozenBrush(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; }
|
冻结后的画刷不能再修改,因此可以安全地作为共享默认值。
两者都能保存默认值和回调,但 FrameworkPropertyMetadata 多了 WPF 框架相关的能力。
| 类型 |
适合场景 |
PropertyMetadata |
只关心默认值和基础回调 |
FrameworkPropertyMetadata |
WPF 控件、布局、样式、绑定等场景 |
编写 WPF 自定义控件时,通常优先使用 FrameworkPropertyMetadata。
2.6 只读依赖属性
有些属性只希望控件内部修改,外部只能读取。示例中的 ClickCount 就是这种属性。
注册只读依赖属性需要两部分:
1 2 3 4 5 6 7 8 9 10 11
| private static readonly DependencyPropertyKey ClickCountKey = DependencyProperty.RegisterReadOnly( nameof(ClickCount), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata(0));
public static readonly DependencyProperty ClickCountProperty = ClickCountKey.DependencyProperty;
public int ClickCount => (int)GetValue(ClickCountProperty);
|
几个关键点:
DependencyPropertyKey 是写入权限。
DependencyProperty 是公开的读取标识。
- 外部代码只能通过
ClickCountProperty 读取。
- 控件内部通过
SetValue(ClickCountKey, ...) 写入。
示例中的点击计数方法:
1 2 3 4
| public void IncreaseClickCount() { SetValue(ClickCountKey, ClickCount + 1); }
|
不要把 ClickCountKey 公开出去,否则“只读”就失去意义了。
2.7 注册时常见的错误
| 错误 |
结果 |
修正 |
字段不是 static |
每个实例都有自己的标识符,破坏属性系统约定 |
改为 static readonly |
字段不是 readonly |
可能被重新赋值 |
改为 readonly |
| 所有者类型写错 |
XAML 或绑定找不到属性 |
使用 typeof(InfoCard) |
| 注册类型和包装器类型不一致 |
运行时类型转换异常 |
两处保持一致 |
| 同一类型重复注册同名属性 |
注册异常 |
一个属性只注册一次 |
| 把默认值写在包装器里 |
默认值管理混乱 |
放进元数据 |
2.8 本节小结
现在你已经有了一个真正的依赖属性:
1 2 3 4
| TitleProperty -> 注册信息 Title -> CLR 包装器 GetValue -> 读取 SetValue -> 写入
|
下一章解决一个实际问题:XAML 或代码修改 Title 后,界面如何自动更新。
小练习
- 把默认标题改成“新的卡片”,运行确认没有设置 XAML 时显示新默认值。
- 在 XAML 中给
InfoCard 设置 Title,观察界面是否立即变化。
- 思考:为什么第 2 步可能没有变化?
第三题的答案是:注册只负责让属性存在,不会自动帮你更新内部 TextBlock。需要变化回调或绑定来完成界面同步。
3. 依赖属性的值变化回调
本节目标
理解并实现 PropertyChangedCallback,做到:
- XAML 设置
Title 后,卡片标题自动更新。
- 用
Debug.WriteLine 观察旧值和新值。
- 知道回调什么时候会触发,什么时候不会触发。
3.1 为什么需要变化回调
InfoCard 的内部结构不是直接暴露给外部的。外部只认识 Title 依赖属性,内部真正显示文字的是一个 TextBlock。
所以需要一个桥梁:
1 2 3 4 5 6 7 8 9 10
| 外部设置 Title | v WPF 属性系统 | v PropertyChangedCallback | v 更新内部 TextBlock
|
如果没有这座桥,Title 的值可能已经变了,但界面仍然显示旧文字。

图 2:一次赋值经过验证、强制和变化回调的基本顺序。
3.2 把回调加入元数据
把 TitleProperty 的元数据改成带回调的版本:
1 2 3 4 5 6 7 8 9
| public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( nameof(Title), typeof(string), typeof(InfoCard), new FrameworkPropertyMetadata( "未命名卡片", FrameworkPropertyMetadataOptions.AffectsRender, OnTitleChanged));
|
新增 OnTitleChanged:
1 2 3 4 5 6 7 8 9
| private static void OnTitleChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d;
Debug.WriteLine($"[Title] {e.OldValue} -> {e.NewValue}"); card.UpdateTitle(); }
|
别忘了在文件顶部加入:
1
| using System.Diagnostics;
|
现在把 XAML 改成:
1
| <local:InfoCard Title="来自 XAML 的标题" />
|
以调试方式运行,应该能在输出窗口看到类似内容:
1
| [Title] 未命名卡片 -> 来自 XAML 的标题
|
界面上的标题也会更新。
3.3 回调为什么是静态方法
PropertyChangedCallback 的签名是:
1 2 3
| private static void OnTitleChanged( DependencyObject d, DependencyPropertyChangedEventArgs e)
|
它是静态方法,因为依赖属性的注册信息属于类型,不属于某一个实例。WPF 调用回调时,会把发生变化的实例作为 d 参数传进来。
因此,回调里通常要先把 d 转成具体类型:
然后再访问实例成员:
如果直接写 UpdateTitle(),编译器会报错,因为静态方法不能直接调用实例方法。
3.4 事件参数里有什么
DependencyPropertyChangedEventArgs 提供三个常用信息:
| 成员 |
含义 |
Property |
哪个依赖属性发生了变化 |
OldValue |
变化前的有效值 |
NewValue |
变化后的有效值 |
示例:
1 2 3
| Debug.WriteLine($"属性:{e.Property.Name}"); Debug.WriteLine($"旧值:{e.OldValue}"); Debug.WriteLine($"新值:{e.NewValue}");
|
OldValue 和 NewValue 都是 object,需要时再转换成具体类型。
3.5 回调什么时候不会触发
最重要的一条是:
值变化回调只在“有效值”真正发生变化时触发。
如果设置的值和当前值相同,通常不会触发变化回调:
1
| card.Title = card.Title;
|
如果属性被强制回调限制,设置的值虽然改变了“基础值”,但有效值没有变化,变化回调也可能不触发。第 5 章会看到这种情况。
另外,属性注册时的默认值不会自动帮你执行一次界面初始化。因此示例在构造函数里主动调用了:
这一步负责把默认值显示到内部 TextBlock 上。
3.6 回调里不要做什么
变化回调是一个“属性已经变化”的通知点,不是一个适合做所有事情的地方。
| 不推荐的做法 |
原因 |
| 在回调里再次无条件设置同一个属性 |
可能造成递归调用 |
| 在回调里执行很慢的 IO |
可能阻塞界面线程 |
| 在回调里假设某个字段一定已经初始化 |
XAML 解析顺序可能让回调早于你的预期 |
| 在回调里直接抛出普通业务异常 |
可能让 XAML 加载失败 |
| 在回调里依赖固定的调用次数 |
属性系统可能因为绑定、样式或强制而多次调用 |
如果需要做复杂业务,可以先把结果记录下来,再通过事件或命令交给上层处理。
3.7 AffectsRender 和变化回调不是一回事
这两个概念经常被混淆:
| 机制 |
作用 |
AffectsRender |
告诉 WPF:这个属性变化后,控件需要重新绘制 |
PropertyChangedCallback |
让你自己执行代码,例如更新内部子元素 |
AffectsRender 不会自动把 Title 写进 TextBlock。它只是让 WPF 知道需要重新绘制。真正更新文本的逻辑仍然在回调里。
同理:
| 元数据选项 |
主要影响 |
AffectsMeasure |
测量阶段 |
AffectsArrange |
排列阶段 |
AffectsRender |
绘制阶段 |
选择哪个选项,要看属性改变后需要重新做哪一步。
3.8 给其他属性也加回调
完整示例中,除了 Title,还有几个属性使用了同样的模式:
| 属性 |
变化后做什么 |
Title |
更新标题文字 |
AccentBrush |
更新标题颜色和边框颜色 |
Message |
更新说明文字 |
Level |
更新状态文字 |
MaxLevel |
重新强制 Level,再更新状态文字 |
ClickCount |
更新状态文字 |
以 AccentBrush 为例:
1 2 3 4 5 6 7 8 9
| public static readonly DependencyProperty AccentBrushProperty = DependencyProperty.Register( nameof(AccentBrush), typeof(Brush), typeof(InfoCard), new FrameworkPropertyMetadata( DefaultAccentBrush, FrameworkPropertyMetadataOptions.AffectsRender, OnAccentBrushChanged));
|
回调:
1 2 3 4 5 6 7 8
| private static void OnAccentBrushChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[AccentBrush] {e.OldValue} -> {e.NewValue}"); card.UpdateTitle(); }
|
这里 UpdateTitle 不只更新文字,还更新颜色:
1 2 3 4 5 6
| private void UpdateTitle() { _titleText.Text = Title; BorderBrush = AccentBrush; _titleText.Foreground = AccentBrush; }
|
注意:BorderBrush 和 _titleText.Foreground 本身也是依赖属性,赋值后 WPF 会处理它们的显示更新。
3.9 本节小结
| 问题 |
答案 |
| 变化回调的签名是什么 |
static void Callback(DependencyObject, DependencyPropertyChangedEventArgs) |
| 为什么是静态方法 |
属性注册属于类型,实例通过 d 传入 |
| 旧值和新值在哪里 |
e.OldValue 和 e.NewValue |
| 设置相同值会触发吗 |
通常不会 |
| 默认值会自动触发吗 |
不要依赖它,构造函数里主动初始化 |
AffectsRender 能替代回调吗 |
不能,它只负责失效和重绘 |
小练习
- 给
InfoCard 增加一个 Subtitle 依赖属性,并在回调中更新一个新增的 TextBlock。
- 连续两次设置相同的标题,观察输出窗口是否出现两次日志。
- 把
OnTitleChanged 中的 card.UpdateTitle() 暂时注释掉,运行后观察界面和属性值是否不一致。
第 3 题能帮助你直观看到“属性值变了”和“界面更新了”是两件事。
4. 依赖属性的值验证回调
本节目标
理解 ValidateValueCallback,做到:
- 拒绝不合法的属性值。
- 知道验证回调为什么不能访问实例。
- 知道验证和强制分别解决什么问题。
- 能捕获并解释
ArgumentException。
4.1 先区分验证和强制
这两个概念很容易混淆:
| 机制 |
典型问题 |
处理方式 |
| 验证 |
这个值允许存在吗 |
返回 true 或 false |
| 强制 |
这个值需要调整到允许范围吗 |
返回调整后的值 |
举例:
1 2
| Level = -1 验证:不允许负数,直接拒绝
|
1 2 3
| Level = 99,MaxLevel = 10 验证:99 本身是合法整数 强制:把有效值限制为 10
|
验证负责“拒绝非法输入”,强制负责“把值约束到规则范围内”。两者经常一起使用。
4.2 给 Level 添加验证
在 InfoCard 中加入 Level 和 MaxLevel。先只添加验证,强制回调暂时不写。
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
| public static readonly DependencyProperty LevelProperty = DependencyProperty.Register( nameof(Level), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.None, OnLevelChanged), ValidateLevel);
public int Level { get => (int)GetValue(LevelProperty); set => SetValue(LevelProperty, value); }
public static readonly DependencyProperty MaxLevelProperty = DependencyProperty.Register( nameof(MaxLevel), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata( 10, FrameworkPropertyMetadataOptions.None, OnMaxLevelChanged), ValidateMaxLevel);
public int MaxLevel { get => (int)GetValue(MaxLevelProperty); set => SetValue(MaxLevelProperty, value); }
|
验证方法:
1 2 3 4 5 6 7 8 9 10 11
| private static bool ValidateLevel(object value) { Debug.WriteLine($"[ValidateLevel] value={value}"); return value is int level && level >= 0; }
private static bool ValidateMaxLevel(object value) { Debug.WriteLine($"[ValidateMaxLevel] value={value}"); return value is int maxLevel && maxLevel >= 1; }
|
变化回调:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| private static void OnLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[Level] {e.OldValue} -> {e.NewValue}"); card.UpdateMessage(); }
private static void OnMaxLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[MaxLevel] {e.OldValue} -> {e.NewValue}"); card.UpdateMessage(); }
|
状态文字:
1 2 3 4 5
| private void UpdateMessage() { _messageText.Text = $"{Message} Level = {Level} / {MaxLevel} ClickCount = {ClickCount}"; }
|
为了让这个版本能显示 Message,还需要 Message 依赖属性。完整示例中已经包含它;如果你在跟做,可以先暂时把状态文字改成只显示等级:
1 2 3 4
| private void UpdateMessage() { _messageText.Text = $"Level = {Level} / {MaxLevel}"; }
|
4.3 验证回调为什么不能访问实例
验证回调的签名是:
1
| private static bool ValidateLevel(object value)
|
它只有 value,没有 DependencyObject 参数。这意味着验证回调不能直接读取 this,也不能读取某个实例的 MaxLevel。
因此下面这种写法是做不到的:
1 2
| return level >= 0 && level <= MaxLevel;
|
这不是设计缺陷,而是验证的定位:
- 验证回调负责检查“这个值本身是否合法”。
- 跨属性的关系,通常交给强制回调、业务层校验或命令层校验。
例如,Level >= 0 是属性自身的规则,可以放在验证里。
Level <= MaxLevel 依赖另一个实例属性,更适合放在强制回调里,或者在实际提交数据时检查。
4.4 设置非法值时会发生什么
假设当前 Level 是 3,执行:
流程大致是:
1 2 3 4 5 6 7 8 9 10
| SetValue(LevelProperty, -1) | v ValidateLevel(-1) 返回 false | v 抛出 ArgumentException | v 原来的 Level 仍然是 3
|
示例中的按钮处理:
1 2 3 4 5 6 7 8 9 10 11
| private void SetInvalidLevel_Click(object sender, RoutedEventArgs e) { try { Card.Level = -1; } catch (ArgumentException ex) { StatusText.Text = $"验证回调拒绝了 -1:{ex.Message}"; } }
|
这里只捕获 ArgumentException,因为我们要演示的是依赖属性验证失败,不建议把所有异常都吞掉。
4.5 XAML 里的非法值也会被拦截
验证不是只对 C# 代码生效。下面这种 XAML 也会触发验证:
1
| <local:InfoCard Level="-1" />
|
如果验证返回 false,XAML 加载会失败。排查时先检查验证回调的规则,再检查 XAML 中的值。
4.6 默认值也必须是合法的
注册时的默认值应该满足验证规则:
1 2 3 4
| new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.None, OnLevelChanged)
|
如果默认值是 -1,而验证只允许非负数,那么控件从一开始就处于不合法的状态。不要把“默认值检查”留给验证回调来兜底,注册时就应该保证默认值合法。
4.7 验证回调的返回值规则
验证回调必须返回 bool:
| 返回值 |
结果 |
true |
允许这个值继续进入属性系统 |
false |
拒绝这个值,SetValue 抛出异常 |
不要在验证回调里尝试“修正”值:
1 2 3 4 5
| if (level < 0) { level = 0; }
|
验证回调的职责是判断,不是转换。转换和约束应该交给强制回调。
4.8 验证与强制的职责对照
| 场景 |
应该用什么 |
结果 |
| 不允许负数 |
验证 |
负数被拒绝,抛出异常 |
| 超过上限时自动截断 |
强制 |
返回上限值,不抛异常 |
| 必须是某个枚举值 |
验证 |
非法枚举值被拒绝 |
| 根据窗口宽度调整实际尺寸 |
强制或布局逻辑 |
有效值被调整 |
| 字符串不能为空白 |
验证 |
空白字符串被拒绝 |
| 超出范围时取最近边界 |
强制 |
自动变为边界值 |
4.9 本节小结
| 问题 |
答案 |
| 验证回调的签名是什么 |
static bool Callback(object value) |
| 验证回调能访问实例吗 |
不能,它没有实例参数 |
| 验证失败会怎样 |
SetValue 抛出 ArgumentException |
| 验证失败会改变旧值吗 |
通常不会,旧值保持不变 |
| 验证能自动截断值吗 |
不能,截断应该用强制回调 |
| XAML 赋值会验证吗 |
会 |
小练习
- 给
Level 增加“必须小于等于 100”的验证规则,观察设置 101 时的异常。
- 给
MaxLevel 增加“必须小于等于 1000”的规则。
- 思考:为什么“
Level 必须小于等于 MaxLevel”不适合单独写在验证回调里?
第三题的答案是:验证回调没有实例参数,无法读取当前实例的 MaxLevel。这类跨属性约束更适合放在强制回调或业务校验中。
5. 依赖属性的值强置回调
本节目标
理解 CoerceValueCallback,做到:
- 把
Level 自动限制在 0 到 MaxLevel 之间。
- 知道“基础值”和“有效值”的区别。
- 知道修改相关属性后,为什么要手动调用
CoerceValue。
- 能解释为什么恢复上限后,原来的值还能回来。
5.1 强置回调解决什么问题
假设规则是:
1
| Level 必须在 0 到 MaxLevel 之间
|
当用户设置 Level = 99,而 MaxLevel = 10 时,有两种处理方式:
| 方式 |
结果 |
| 验证拒绝 |
直接报错,用户必须重新输入 |
| 强制限制 |
接受设置,但有效值变成 10 |
在很多界面场景中,强制限制更友好。例如滑块、进度、分页和等级控件,通常希望值自动落在边界内,而不是直接抛异常。
5.2 把 Coerce 回调加入 Level 的注册
FrameworkPropertyMetadata 的构造函数可以接收四个常用参数:
1 2 3 4 5
| new FrameworkPropertyMetadata( 默认值, 元数据选项, 值变化回调, 强制回调)
|
把 LevelProperty 改成:
1 2 3 4 5 6 7 8 9 10 11
| public static readonly DependencyProperty LevelProperty = DependencyProperty.Register( nameof(Level), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.None, OnLevelChanged, CoerceLevel), ValidateLevel);
|
新增强制回调:
1 2 3 4 5 6 7 8 9 10 11 12 13
| private static object CoerceLevel( DependencyObject d, object baseValue) { var card = (InfoCard)d; var value = (int)baseValue; var coercedValue = Math.Clamp(value, 0, card.MaxLevel);
Debug.WriteLine( $"[CoerceLevel] base={value}, max={card.MaxLevel}, result={coercedValue}");
return coercedValue; }
|
baseValue 是属性系统准备设置的值,回调返回的值才是最终有效值。
5.3 让 MaxLevel 变化时重新强制 Level
只注册 CoerceLevel 还不够。MaxLevel 改变后,WPF 不会自动知道 Level 需要重新计算,除非你显式告诉它:
1 2 3 4 5 6 7 8 9 10
| private static void OnMaxLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[MaxLevel] {e.OldValue} -> {e.NewValue}");
card.CoerceValue(LevelProperty); card.UpdateMessage(); }
|
CoerceValue 会重新执行 CoerceLevel,并根据新的 MaxLevel 计算有效值。
关键点:AffectsMeasure、AffectsArrange 这类元数据选项不会自动处理“两个属性之间的业务关系”。MaxLevel 和 Level 的关系需要你自己在回调里调用 CoerceValue。
5.4 基础值和有效值
这是理解强置回调的核心。
假设:
1 2
| MaxLevel = 10 用户设置 Level = 99
|
属性系统内部可以简单理解为:
1 2 3
| 基础值:99 强制回调:99 -> 10 有效值:10
|
代码读取时:
拿到的是有效值 10。
但基础值并没有被永久改成 10。它仍然保留着 99。后来如果把 MaxLevel 改成 100,再次强制:
1 2 3
| 基础值:99 强制回调:99 -> 99 有效值:99
|
这就是为什么恢复上限后,原来的值可以重新出现。
5.5 按按钮走一遍完整流程
示例界面的操作顺序建议如下:
- 启动程序,初始状态是
Level = 3、MaxLevel = 10。
- 点击“等级设为 99”。
- 观察卡片状态和输出窗口。
- 点击“上限设为 5”。
- 点击“上限恢复 100”。
- 再点击“等级 +1”或“点击计数”,观察不同属性的行为。
对应的结果:
| 操作 |
验证 |
强制 |
变化回调 |
界面结果 |
设置 Level = 99 |
ValidateLevel(99) 通过 |
99 -> 10 |
OnLevelChanged(3 -> 10) |
显示 10 / 10 |
设置 MaxLevel = 5 |
ValidateMaxLevel(5) 通过 |
CoerceValue(Level) 把 99 变成 5 |
OnLevelChanged(10 -> 5) |
显示 5 / 5 |
设置 MaxLevel = 100 |
ValidateMaxLevel(100) 通过 |
基础值 99 重新变成有效值 99 |
OnLevelChanged(5 -> 99) |
显示 99 / 100 |
设置 Level = -1 |
ValidateLevel(-1) 失败 |
不执行 |
不执行 |
抛出异常,旧值不变 |
5.6 为什么验证回调会被调用两次
在 .NET 9 WPF 中实测,设置一个会被强制的值时,可能看到这样的顺序:
1 2 3 4
| ValidateLevel(99) CoerceLevel(99) -> 10 ValidateLevel(10) OnLevelChanged(3 -> 10)
|
可以这样理解:
- 先验证用户提交的基础值。
- 强制回调把基础值调整为有效值。
- 再验证强制后的结果。
- 有效值确实变化后,调用变化回调。
不同版本的内部实现细节可能略有差异,但“先验证、再强制、最后通知变化”这个理解方向是稳定的。写业务代码时,不要把日志顺序当成必须依赖的接口契约。
5.7 有效值没变时,变化回调不会触发
假设当前有效值是 5,基础值也是 5。执行:
如果 MaxLevel 是 5,强制结果是 5:
有效值从 5 到 5,没有变化,所以变化回调通常不会触发。但基础值已经变成 6,之后把上限提高到 10,再执行 CoerceValue,有效值可能变成 6。
这说明:
- 变化回调观察的是有效值。
- 基础值可能已经改变。
- 强制值不是永久覆盖基础值。
5.8 Coerce 回调的写法规则
强制回调应该尽量保持“纯函数”风格:
| 推荐做法 |
原因 |
根据 baseValue 和实例状态计算返回值 |
逻辑清晰,可重复调用 |
| 不修改外部状态 |
强制可能被多次调用 |
不调用 SetValue 设置同一个属性 |
容易递归 |
| 不在里面做耗时操作 |
强制可能频繁发生 |
| 返回的值要满足验证规则 |
否则可能再次验证失败 |
不推荐的写法:
1 2 3 4 5 6 7 8 9 10 11
| private static object CoerceLevel( DependencyObject d, object baseValue) { var card = (InfoCard)d;
card.Level = 0;
return baseValue; }
|
这种写法可能造成递归调用,调试起来非常困难。
5.9 强置和验证的配合
一个常见的组合是:
1 2
| 验证:只接受非负整数 强制:把值限制在 0 到 MaxLevel
|
这样:
-1 会在验证阶段被拒绝。
99 会通过验证,然后被强制到 MaxLevel。
- 强制后的结果仍然会再次验证,确保结果本身合法。
这也是示例中 Level 同时使用 ValidateLevel 和 CoerceLevel 的原因。
5.10 本节小结
| 问题 |
答案 |
| 强置回调的签名是什么 |
static object Callback(DependencyObject, object baseValue) |
| 返回什么 |
调整后的有效值 |
| 会改变基础值吗 |
不会永久改变基础值 |
| 什么时候执行 |
设置值、调用 CoerceValue 等时机 |
| 相关属性变化会自动重算吗 |
不会自动推断,需要显式调用 CoerceValue |
| 有效值没变会触发变化回调吗 |
通常不会 |
小练习
- 增加一个
MinLevel 依赖属性,默认值为 0,并在 CoerceLevel 中同时使用最小值和最大值。
- 当
MinLevel 或 MaxLevel 改变时,调用 CoerceValue(LevelProperty)。
- 尝试设置
Level = 200,观察验证、强制和变化回调的顺序。
- 思考:如果把
CoerceLevel 改成直接 return 0,会发生什么?
第 4 题可以帮助你理解:强制回调可以改变有效值,但错误地使用它会让属性失去真实含义。
6. 自定义容器的依赖属性改造
本节目标
把一个普通的自定义面板改造成能够参与 WPF 属性系统和布局系统的容器:
- 给面板增加
Spacing 依赖属性。
- 给面板增加
ItemWidth 依赖属性。
- 使用
AffectsMeasure 和 AffectsArrange 让布局自动刷新。
- 理解
MeasureOverride 和 ArrangeOverride 的职责。
- 用验证回调保护布局参数。
6.1 为什么普通 CLR 属性不够
假设自定义面板写成这样:
1 2 3 4
| public class UniformSpacingPanel : Panel { public double Spacing { get; set; } = 8; }
|
当 Spacing 改变时,WPF 并不知道布局需要重新计算。界面可能仍然保持旧间距,直到其他原因触发布局更新。
改成依赖属性,并在元数据中声明布局影响:
1 2 3 4
| new FrameworkPropertyMetadata( 8d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange)
|
这样,Spacing 改变后,WPF 会重新测量和排列子元素。
6.2 自定义面板的布局流程
WPF 布局大致分为两个阶段:
| 阶段 |
方法 |
你要回答的问题 |
| 测量 |
MeasureOverride |
你需要多大空间 |
| 排列 |
ArrangeOverride |
每个子元素放在哪里 |
一个简单的自定义面板通常要实现这两个方法:
1 2 3 4 5 6 7 8 9
| protected override Size MeasureOverride(Size availableSize) { }
protected override Size ArrangeOverride(Size finalSize) { }
|
MeasureOverride 的返回值是“希望尺寸”,父容器不一定会给这么多。ArrangeOverride 拿到的是最终可用尺寸,应该根据它来放置子元素。

图 3:自定义面板的测量与排列流程,以及布局依赖属性如何触发失效。
在项目中新建 UniformSpacingPanel.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
| using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls;
namespace WpfDependencyPropertyDemo;
public class UniformSpacingPanel : Panel { private static readonly DependencyPropertyKey ChildCountKey = DependencyProperty.RegisterReadOnly( nameof(ChildCount), typeof(int), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata(0));
public static readonly DependencyProperty ChildCountProperty = ChildCountKey.DependencyProperty;
public int ChildCount => (int)GetValue(ChildCountProperty);
protected override void OnVisualChildrenChanged( DependencyObject visualAdded, DependencyObject visualRemoved) { base.OnVisualChildrenChanged(visualAdded, visualRemoved); SetValue(ChildCountKey, InternalChildren.Count); }
public static readonly DependencyProperty SpacingProperty = DependencyProperty.Register( nameof(Spacing), typeof(double), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata( 8d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double spacing && double.IsFinite(spacing) && spacing >= 0);
public double Spacing { get => (double)GetValue(SpacingProperty); set => SetValue(SpacingProperty, value); }
public static readonly DependencyProperty ItemWidthProperty = DependencyProperty.Register( nameof(ItemWidth), typeof(double), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata( double.NaN, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double itemWidth && (double.IsNaN(itemWidth) || (double.IsFinite(itemWidth) && itemWidth > 0)));
public double ItemWidth { get => (double)GetValue(ItemWidthProperty); set => SetValue(ItemWidthProperty, value); }
protected override Size MeasureOverride(Size availableSize) { var count = InternalChildren.Count; if (count == 0) { return new Size(0, 0); }
var itemWidth = GetItemWidth(availableSize.Width, count); var totalHeight = 0d;
foreach (UIElement child in InternalChildren) { child.Measure(new Size(itemWidth, availableSize.Height)); totalHeight = Math.Max(totalHeight, child.DesiredSize.Height); }
var totalWidth = itemWidth * count + Spacing * (count - 1); Debug.WriteLine( $"[Measure] count={count}, spacing={Spacing}, itemWidth={itemWidth}, totalWidth={totalWidth}");
return new Size(totalWidth, totalHeight); }
protected override Size ArrangeOverride(Size finalSize) { var count = InternalChildren.Count; if (count == 0) { return finalSize; }
var itemWidth = GetItemWidth(finalSize.Width, count); var x = 0d;
foreach (UIElement child in InternalChildren) { child.Arrange(new Rect(x, 0, itemWidth, finalSize.Height)); x += itemWidth + Spacing; }
Debug.WriteLine( $"[Arrange] count={count}, spacing={Spacing}, itemWidth={itemWidth}, finalWidth={finalSize.Width}");
return finalSize; }
private double GetItemWidth(double availableWidth, int count) { if (!double.IsNaN(ItemWidth)) { return ItemWidth; }
if (double.IsInfinity(availableWidth)) { return 120; }
return Math.Max(0, (availableWidth - Spacing * (count - 1)) / count); } }
|
6.4 逐段理解代码
Spacing 依赖属性
1 2 3 4 5 6 7 8 9 10 11 12
| public static readonly DependencyProperty SpacingProperty = DependencyProperty.Register( nameof(Spacing), typeof(double), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata( 8d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double spacing && double.IsFinite(spacing) && spacing >= 0);
|
它做了三件事:
- 注册一个
double 类型的依赖属性。
- 默认间距是 8。
- 间距变化后,让测量和排列失效。
验证规则:
| 输入 |
结果 |
原因 |
0 |
允许 |
最小间距可以是 0 |
12.5 |
允许 |
非负有限值 |
-1 |
拒绝 |
间距不能为负 |
double.NaN |
拒绝 |
间距必须是确定数值 |
double.PositiveInfinity |
拒绝 |
无穷大不能作为布局间距 |
double.IsFinite 可以同时排除 NaN 和正负无穷大。
ItemWidth 依赖属性
1 2 3 4 5
| public double ItemWidth { get => (double)GetValue(ItemWidthProperty); set => SetValue(ItemWidthProperty, value); }
|
ItemWidth 的默认值是 double.NaN,表示“自动宽度”。
验证规则:
| 输入 |
结果 |
原因 |
double.NaN |
允许 |
表示自动计算宽度 |
120 |
允许 |
固定宽度 |
0 |
拒绝 |
子元素宽度不能为 0 |
-20 |
拒绝 |
宽度不能为负 |
double.PositiveInfinity |
拒绝 |
无穷大不能作为布局宽度 |
这里使用 NaN 表示自动宽度,是 WPF 布局中常见的做法。Width、Height 等属性也使用 NaN 表示自动尺寸。
ChildCount 只读属性
1 2 3 4 5 6 7 8 9 10 11
| private static readonly DependencyPropertyKey ChildCountKey = DependencyProperty.RegisterReadOnly( nameof(ChildCount), typeof(int), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata(0));
public static readonly DependencyProperty ChildCountProperty = ChildCountKey.DependencyProperty;
public int ChildCount => (int)GetValue(ChildCountProperty);
|
这是第 2 章只读依赖属性知识点的应用。外部可以读取子元素数量,但不能直接修改。
更新时机在 OnVisualChildrenChanged:
1 2 3 4 5 6 7
| protected override void OnVisualChildrenChanged( DependencyObject visualAdded, DependencyObject visualRemoved) { base.OnVisualChildrenChanged(visualAdded, visualRemoved); SetValue(ChildCountKey, InternalChildren.Count); }
|
InternalChildren 是面板内部使用的子元素集合。自定义面板在测量和排列时通常使用它。
MeasureOverride
测量阶段先确定每个子元素能拿到多宽:
1
| var itemWidth = GetItemWidth(availableSize.Width, count);
|
然后逐个测量:
1
| child.Measure(new Size(itemWidth, availableSize.Height));
|
最后返回面板希望的尺寸:
1
| return new Size(totalWidth, totalHeight);
|
其中总宽度是:
1
| 子元素宽度 * 数量 + 间距 * (数量 - 1)
|
ArrangeOverride
排列阶段拿到最终尺寸,再给每个子元素分配矩形区域:
1 2
| child.Arrange(new Rect(x, 0, itemWidth, finalSize.Height)); x += itemWidth + Spacing;
|
x 每次增加“子元素宽度 + 间距”,所以子元素会依次向右排列。
6.5 在 XAML 中使用自定义面板
在 MainWindow.xaml 中加入命名空间:
1
| xmlns:local="clr-namespace:WpfDependencyPropertyDemo"
|
然后加入面板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <local:UniformSpacingPanel x:Name="SpacingPanel" Spacing="12" Height="54"> <Border Background="#EAF1FF" CornerRadius="8"> <TextBlock Text="子元素 A" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <Border Background="#EAF7EF" CornerRadius="8"> <TextBlock Text="子元素 B" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <Border Background="#FDE8EF" CornerRadius="8"> <TextBlock Text="子元素 C" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> </local:UniformSpacingPanel>
|
示例中还提供了三个按钮:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| private void SetSpacing_Click(object sender, RoutedEventArgs e) { SpacingPanel.Spacing = 24; UpdateStatus(); }
private void SetItemWidth_Click(object sender, RoutedEventArgs e) { SpacingPanel.ItemWidth = 160; UpdateStatus(); }
private void ResetItemWidth_Click(object sender, RoutedEventArgs e) { SpacingPanel.ItemWidth = double.NaN; UpdateStatus(); }
|
运行后可以观察:
- 点击“Spacing = 24”,三个子元素之间的间距变大。
- 点击“ItemWidth = 160”,每个子元素使用固定宽度。
- 点击“恢复自动宽度”,子元素重新均分可用宽度。
6.6 为什么布局属性需要 AffectsMeasure 和 AffectsArrange
如果没有这两个选项:
1
| new FrameworkPropertyMetadata(8d)
|
那么修改 Spacing 后,WPF 可能不会重新测量和排列。界面看起来就像“属性改了但没生效”。
加入:
1 2
| FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange
|
后,属性变化会通知布局系统:
1 2 3 4 5 6 7
| Spacing 改变 | v 测量失效 -> MeasureOverride 重新执行 | v 排列失效 -> ArrangeOverride 重新执行
|
对于只影响绘制的属性,使用 AffectsRender。对于影响尺寸或位置的属性,使用布局相关的选项。
6.7 自定义容器改造清单
把一个普通面板改造成依赖属性容器,可以按照下面的清单检查:
| 检查项 |
问题 |
| 依赖属性 |
布局参数是否注册为 DependencyProperty |
| 默认值 |
默认值是否适合初始布局 |
| 验证 |
是否拒绝负数、无穷大等非法值 |
| 布局失效 |
是否设置了 AffectsMeasure 和 AffectsArrange |
| 测量 |
MeasureOverride 是否测量了所有子元素 |
| 排列 |
ArrangeOverride 是否给所有子元素分配了区域 |
| 空集合 |
子元素数量为 0 时是否安全返回 |
| 自动尺寸 |
NaN 和无限可用空间是否处理 |
| 只读属性 |
内部统计属性是否使用只读依赖属性 |
| 性能 |
测量和排列中是否避免了不必要的分配 |
6.8 这个面板的边界
UniformSpacingPanel 是一个教学示例,适合演示依赖属性和布局的基本配合。它还有这些限制:
- 只支持单行横向排列。
- 不自动换行。
- 没有实现完整的对齐、边距和换行策略。
- 固定
ItemWidth 时可能超出容器宽度。
- 没有处理从右到左的布局方向。
真实项目如果需要复杂布局,可以优先使用 WPF 自带的 UniformGrid、WrapPanel、Grid,或者在这些控件基础上组合。自己写面板时,要额外考虑边距、对齐、方向和性能。
6.9 本节小结
| 问题 |
答案 |
| 自定义面板的布局参数为什么要用依赖属性 |
才能参与 XAML、绑定、样式和布局失效 |
| 修改布局属性后如何刷新 |
设置 AffectsMeasure 和 AffectsArrange |
| 测量阶段做什么 |
计算希望尺寸 |
| 排列阶段做什么 |
分配最终位置和尺寸 |
NaN 表示什么 |
自动尺寸 |
| 为什么要处理空集合 |
避免除以零或访问不存在的子元素 |
小练习
- 给
Spacing 增加一个上限验证,例如最大值为 100。
- 给面板增加
MinItemWidth 依赖属性,并在自动宽度计算中保证不低于最小值。
- 修改
GetItemWidth,让固定宽度也不能超过可用宽度。
- 尝试把
Spacing 改成普通 CLR 属性,观察布局是否还会自动刷新。
第 4 题是理解“依赖属性为什么要配合布局元数据”的最好实验。
回调顺序总表
下面的顺序来自示例的调试输出。不同 WPF 版本的内部细节可能略有差别,但可以用来建立整体模型。
假设初始状态:
设置 Level = 99
1 2 3 4
| ValidateLevel(99) CoerceLevel(99) -> 10 ValidateLevel(10) OnLevelChanged(3 -> 10)
|
结果:有效值是 10,基础值保留 99。
设置 Level = -1
1 2
| ValidateLevel(-1) -> false SetValue 抛出 ArgumentException
|
结果:没有进入强制阶段,也没有变化回调,旧值保持不变。
设置 MaxLevel = 5
1 2 3 4 5
| ValidateMaxLevel(5) OnMaxLevelChanged(10 -> 5) CoerceLevel(99) -> 5 ValidateLevel(5) OnLevelChanged(10 -> 5)
|
结果:有效值变成 5,基础值仍然是 99。
恢复 MaxLevel = 100
1 2 3 4 5
| ValidateMaxLevel(100) OnMaxLevelChanged(5 -> 100) CoerceLevel(99) -> 99 ValidateLevel(99) OnLevelChanged(5 -> 99)
|
结果:基础值 99 重新变成有效值 99。
设置相同的值
结果:通常不会触发 OnTitleChanged。不要依赖“每次赋值都触发回调”的假设。
强制后有效值没有变化
1 2 3 4
| 当前有效值:5 设置 Level = 6 MaxLevel = 5 强制结果:5
|
结果:基础值可能变成 6,但有效值仍然是 5,所以变化回调通常不会触发。
常见错误与排查
| 现象 |
常见原因 |
排查方法 |
| XAML 提示找不到属性 |
依赖属性没有注册,或所有者类型写错 |
检查 Register 的第三个参数 |
| XAML 能赋值但界面不更新 |
没有变化回调,或回调没有更新内部元素 |
在回调中打断点或写调试输出 |
| 绑定不工作 |
使用了普通 CLR 属性 |
改成依赖属性 |
| 样式 Setter 不生效 |
属性不是依赖属性 |
检查字段和注册 |
| 验证没有执行 |
验证回调放错参数位置 |
确认它是 Register 的第五个参数 |
| 强制没有执行 |
元数据里没有传入强制回调 |
检查 FrameworkPropertyMetadata 的第四个参数 |
| 修改 MaxLevel 后 Level 没变 |
没有调用 CoerceValue |
在相关属性回调中显式调用 |
| 布局属性改了但界面没刷新 |
没有设置布局失效选项 |
加 AffectsMeasure 和 AffectsArrange |
| 多个控件共享了默认集合 |
默认值是可变引用类型 |
使用 null、不可变对象或冻结对象 |
| 回调里出现空引用 |
回调触发时字段还没初始化 |
确保内部字段先创建,或在回调中做保护 |
SetValue 抛出类型异常 |
注册类型和传入值类型不一致 |
检查 typeof(...) 和实际值 |
| 只读属性无法从外部赋值 |
只读依赖属性只暴露了读取标识 |
使用 DependencyPropertyKey 在内部写入 |
| 调试输出看不到 |
没有以调试方式运行 |
使用 Visual Studio 输出窗口,或添加日志 |
包装器里写业务逻辑的隐患
下面这种写法看起来方便,但不推荐:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public string Title { get => (string)GetValue(TitleProperty); set { if (string.IsNullOrWhiteSpace(value)) { return; }
SetValue(TitleProperty, value); UpdateTitle(); } }
|
问题在于:
- XAML 解析可能不经过这个
set。
- 绑定更新可能不经过这个
set。
- 样式系统可能直接操作属性系统。
- 变化逻辑会分散在多个地方。
更清晰的做法是:
1 2 3 4 5
| public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
把规则放进验证、强制和变化回调。
回调里再次设置同一个属性
下面这种代码很容易造成递归:
1 2 3 4 5 6 7
| private static void OnLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; card.Level = 0; }
|
如果每次变化都把值设回 0,就可能反复触发回调。需要限制范围时,优先使用强制回调。
验证和强制的职责不要混
错误思路:
1 2 3 4 5
| private static bool ValidateLevel(object value) { return true; }
|
正确分工:
1 2 3
| 验证:这个值允许吗 强制:这个值应该调整成多少 变化回调:有效值变了以后要更新什么
|
术语速查
| 术语 |
含义 |
| 依赖属性 |
由 WPF 属性系统管理的属性 |
| 属性标识符 |
例如 TitleProperty,表示“是哪个属性” |
| CLR 包装器 |
例如 public string Title,方便 C# 读写 |
| 元数据 |
描述默认值和属性行为的信息 |
| 基础值 |
属性系统准备设置的值 |
| 有效值 |
经过强制回调后,代码实际读取到的值 |
| 验证回调 |
判断值是否允许 |
| 强制回调 |
调整有效值 |
| 变化回调 |
有效值变化后的通知 |
| 测量 |
计算控件希望占多大空间 |
| 排列 |
给子元素分配最终位置和尺寸 |
完整示例代码
下面代码与随附示例项目对应。可以直接对照,也可以复制到自己的项目中。
文件结构
1 2 3 4 5 6 7 8 9
| WpfDependencyPropertyDemo/ ├─ App.xaml ├─ App.xaml.cs ├─ AssemblyInfo.cs ├─ InfoCard.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ UniformSpacingPanel.cs └─ WpfDependencyPropertyDemo.csproj
|
WpfDependencyPropertyDemo.csproj
1 2 3 4 5 6 7 8 9 10 11
| <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net9.0-windows</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <UseWPF>true</UseWPF> </PropertyGroup>
</Project>
|
如果使用 .NET 10 SDK,把 net9.0-windows 改成 net10.0-windows 即可。
InfoCard.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
| using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls; using System.Windows.Media;
namespace WpfDependencyPropertyDemo;
public class InfoCard : Border { private static readonly Brush DefaultAccentBrush = CreateFrozenBrush( Color.FromRgb(47, 111, 237));
private readonly TextBlock _titleText; private readonly TextBlock _messageText;
public InfoCard() { _titleText = new TextBlock { FontSize = 20, FontWeight = FontWeights.SemiBold };
_messageText = new TextBlock { Margin = new Thickness(0, 8, 0, 0), Foreground = new SolidColorBrush(Color.FromRgb(93, 107, 130)) };
var stack = new StackPanel(); stack.Children.Add(_titleText); stack.Children.Add(_messageText);
Background = Brushes.White; BorderBrush = new SolidColorBrush(Color.FromRgb(221, 229, 240)); BorderThickness = new Thickness(1); CornerRadius = new CornerRadius(14); Padding = new Thickness(22); Child = stack;
UpdateTitle(); UpdateMessage(); }
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( nameof(Title), typeof(string), typeof(InfoCard), new FrameworkPropertyMetadata( "未命名卡片", FrameworkPropertyMetadataOptions.AffectsRender, OnTitleChanged));
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public static readonly DependencyProperty AccentBrushProperty = DependencyProperty.Register( nameof(AccentBrush), typeof(Brush), typeof(InfoCard), new FrameworkPropertyMetadata( DefaultAccentBrush, FrameworkPropertyMetadataOptions.AffectsRender, OnAccentBrushChanged));
public Brush AccentBrush { get => (Brush)GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); }
public static readonly DependencyProperty MessageProperty = DependencyProperty.Register( nameof(Message), typeof(string), typeof(InfoCard), new FrameworkPropertyMetadata( string.Empty, FrameworkPropertyMetadataOptions.None, OnMessageChanged));
public string Message { get => (string)GetValue(MessageProperty); set => SetValue(MessageProperty, value); }
public static readonly DependencyProperty LevelProperty = DependencyProperty.Register( nameof(Level), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.None, OnLevelChanged, CoerceLevel), ValidateLevel);
public int Level { get => (int)GetValue(LevelProperty); set => SetValue(LevelProperty, value); }
public static readonly DependencyProperty MaxLevelProperty = DependencyProperty.Register( nameof(MaxLevel), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata( 10, FrameworkPropertyMetadataOptions.None, OnMaxLevelChanged, null), ValidateMaxLevel);
public int MaxLevel { get => (int)GetValue(MaxLevelProperty); set => SetValue(MaxLevelProperty, value); }
private static readonly DependencyPropertyKey ClickCountKey = DependencyProperty.RegisterReadOnly( nameof(ClickCount), typeof(int), typeof(InfoCard), new FrameworkPropertyMetadata(0, OnClickCountChanged));
public static readonly DependencyProperty ClickCountProperty = ClickCountKey.DependencyProperty;
public int ClickCount => (int)GetValue(ClickCountProperty);
public void IncreaseClickCount() { SetValue(ClickCountKey, ClickCount + 1); }
private static void OnTitleChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[Title] {e.OldValue} -> {e.NewValue}"); card.UpdateTitle(); }
private static void OnAccentBrushChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[AccentBrush] {e.OldValue} -> {e.NewValue}"); card.UpdateTitle(); }
private static void OnMessageChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[Message] {e.OldValue} -> {e.NewValue}"); card.UpdateMessage(); }
private static void OnClickCountChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[ClickCount] {e.OldValue} -> {e.NewValue}"); card.UpdateMessage(); }
private static void OnLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[Level] {e.OldValue} -> {e.NewValue}"); card.UpdateMessage(); }
private static void OnMaxLevelChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var card = (InfoCard)d; Debug.WriteLine($"[MaxLevel] {e.OldValue} -> {e.NewValue}"); card.CoerceValue(LevelProperty); card.UpdateMessage(); }
private static object CoerceLevel( DependencyObject d, object baseValue) { var card = (InfoCard)d; var value = (int)baseValue; var coercedValue = Math.Clamp(value, 0, card.MaxLevel);
Debug.WriteLine( $"[CoerceLevel] base={value}, max={card.MaxLevel}, result={coercedValue}");
return coercedValue; }
private static bool ValidateLevel(object value) { Debug.WriteLine($"[ValidateLevel] value={value}"); return value is int level && level >= 0; }
private static bool ValidateMaxLevel(object value) { Debug.WriteLine($"[ValidateMaxLevel] value={value}"); return value is int maxLevel && maxLevel >= 1; }
private static Brush CreateFrozenBrush(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; }
private void UpdateTitle() { _titleText.Text = Title; BorderBrush = AccentBrush; _titleText.Foreground = AccentBrush; }
private void UpdateMessage() { _messageText.Text = $"{Message} Level = {Level} / {MaxLevel} ClickCount = {ClickCount}"; } }
|
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
| using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls;
namespace WpfDependencyPropertyDemo;
public class UniformSpacingPanel : Panel { private static readonly DependencyPropertyKey ChildCountKey = DependencyProperty.RegisterReadOnly( nameof(ChildCount), typeof(int), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata(0));
public static readonly DependencyProperty ChildCountProperty = ChildCountKey.DependencyProperty;
public int ChildCount => (int)GetValue(ChildCountProperty);
protected override void OnVisualChildrenChanged( DependencyObject visualAdded, DependencyObject visualRemoved) { base.OnVisualChildrenChanged(visualAdded, visualRemoved); SetValue(ChildCountKey, InternalChildren.Count); }
public static readonly DependencyProperty SpacingProperty = DependencyProperty.Register( nameof(Spacing), typeof(double), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata( 8d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double spacing && double.IsFinite(spacing) && spacing >= 0);
public double Spacing { get => (double)GetValue(SpacingProperty); set => SetValue(SpacingProperty, value); }
public static readonly DependencyProperty ItemWidthProperty = DependencyProperty.Register( nameof(ItemWidth), typeof(double), typeof(UniformSpacingPanel), new FrameworkPropertyMetadata( double.NaN, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double itemWidth && (double.IsNaN(itemWidth) || (double.IsFinite(itemWidth) && itemWidth > 0)));
public double ItemWidth { get => (double)GetValue(ItemWidthProperty); set => SetValue(ItemWidthProperty, value); }
protected override Size MeasureOverride(Size availableSize) { var count = InternalChildren.Count; if (count == 0) { return new Size(0, 0); }
var itemWidth = GetItemWidth(availableSize.Width, count); var totalHeight = 0d;
foreach (UIElement child in InternalChildren) { child.Measure(new Size(itemWidth, availableSize.Height)); totalHeight = Math.Max(totalHeight, child.DesiredSize.Height); }
var totalWidth = itemWidth * count + Spacing * (count - 1); Debug.WriteLine( $"[Measure] count={count}, spacing={Spacing}, itemWidth={itemWidth}, totalWidth={totalWidth}");
return new Size(totalWidth, totalHeight); }
protected override Size ArrangeOverride(Size finalSize) { var count = InternalChildren.Count; if (count == 0) { return finalSize; }
var itemWidth = GetItemWidth(finalSize.Width, count); var x = 0d;
foreach (UIElement child in InternalChildren) { child.Arrange(new Rect(x, 0, itemWidth, finalSize.Height)); x += itemWidth + Spacing; }
Debug.WriteLine( $"[Arrange] count={count}, spacing={Spacing}, itemWidth={itemWidth}, finalWidth={finalSize.Width}");
return finalSize; }
private double GetItemWidth(double availableWidth, int count) { if (!double.IsNaN(ItemWidth)) { return ItemWidth; }
if (double.IsInfinity(availableWidth)) { return 120; }
return Math.Max(0, (availableWidth - Spacing * (count - 1)) / count); } }
|
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 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
| <Window x:Class="WpfDependencyPropertyDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:WpfDependencyPropertyDemo" Title="WPF 依赖属性入门示例" Height="840" Width="1000" WindowStartupLocation="CenterScreen" Background="#F3F6FB"> <Window.Resources> <Style TargetType="Button"> <Setter Property="Padding" Value="14,8"/> <Setter Property="Margin" Value="0,0,10,10"/> <Setter Property="MinWidth" Value="120"/> </Style> </Window.Resources>
<Grid Margin="30"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions>
<StackPanel> <TextBlock Text="依赖属性实验台" FontSize="30" FontWeight="SemiBold" Foreground="#172033"/> <TextBlock Text="先点按钮,再看卡片与输出窗口,最后阅读代码。" Margin="0,8,0,22" FontSize="15" Foreground="#5D6B82"/> </StackPanel>
<Border Grid.Row="1" Background="White" BorderBrush="#DDE5F0" BorderThickness="1" CornerRadius="14" Padding="20" Margin="0,0,0,20"> <StackPanel> <TextBlock Text="操作区" FontSize="17" FontWeight="SemiBold"/> <WrapPanel Margin="0,14,0,0"> <Button Content="等级 +1" Click="IncreaseLevel_Click"/> <Button Content="等级设为 99" Click="SetHighLevel_Click"/> <Button Content="上限设为 5" Click="SetMaxFive_Click"/> <Button Content="上限恢复 100" Click="SetMaxHundred_Click"/> <Button Content="点击计数" Click="IncreaseClickCount_Click"/> <Button Content="尝试非法值 -1" Click="SetInvalidLevel_Click"/> </WrapPanel> </StackPanel> </Border>
<local:InfoCard x:Name="Card" Grid.Row="2" Title="依赖属性演示卡" Message="这是一张自定义卡片。" Level="3" MaxLevel="10" AccentBrush="#2F6FED" HorizontalAlignment="Stretch" VerticalAlignment="Top"/>
<Border Grid.Row="3" Background="White" BorderBrush="#DDE5F0" BorderThickness="1" CornerRadius="14" Padding="20" Margin="0,20,0,0"> <StackPanel> <TextBlock Text="UniformSpacingPanel 容器演示" FontSize="17" FontWeight="SemiBold"/> <TextBlock Text="Spacing 和 ItemWidth 都是面板自己的依赖属性。" Margin="0,6,0,14" Foreground="#5D6B82"/> <local:UniformSpacingPanel x:Name="SpacingPanel" Spacing="12" Height="54"> <Border Background="#EAF1FF" CornerRadius="8"> <TextBlock Text="子元素 A" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <Border Background="#EAF7EF" CornerRadius="8"> <TextBlock Text="子元素 B" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <Border Background="#FDE8EF" CornerRadius="8"> <TextBlock Text="子元素 C" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> </local:UniformSpacingPanel> <StackPanel Orientation="Horizontal" Margin="0,14,0,0"> <Button Content="Spacing = 24" Click="SetSpacing_Click"/> <Button Content="ItemWidth = 160" Click="SetItemWidth_Click"/> <Button Content="恢复自动宽度" Click="ResetItemWidth_Click"/> </StackPanel> </StackPanel> </Border>
<TextBlock Grid.Row="4" x:Name="StatusText" Margin="0,18,0,0" TextWrapping="Wrap" Foreground="#5D6B82"/> </Grid> </Window>
|
MainWindow.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
| using System; using System.Windows;
namespace WpfDependencyPropertyDemo;
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); UpdateStatus(); }
private void IncreaseLevel_Click(object sender, RoutedEventArgs e) { Card.Level++; UpdateStatus(); }
private void SetHighLevel_Click(object sender, RoutedEventArgs e) { Card.Level = 99; UpdateStatus(); }
private void SetMaxFive_Click(object sender, RoutedEventArgs e) { Card.MaxLevel = 5; UpdateStatus(); }
private void SetMaxHundred_Click(object sender, RoutedEventArgs e) { Card.MaxLevel = 100; UpdateStatus(); }
private void IncreaseClickCount_Click(object sender, RoutedEventArgs e) { Card.IncreaseClickCount(); UpdateStatus(); }
private void SetInvalidLevel_Click(object sender, RoutedEventArgs e) { try { Card.Level = -1; } catch (ArgumentException ex) { StatusText.Text = $"验证回调拒绝了 -1:{ex.Message}"; } }
private void SetSpacing_Click(object sender, RoutedEventArgs e) { SpacingPanel.Spacing = 24; UpdateStatus(); }
private void SetItemWidth_Click(object sender, RoutedEventArgs e) { SpacingPanel.ItemWidth = 160; UpdateStatus(); }
private void ResetItemWidth_Click(object sender, RoutedEventArgs e) { SpacingPanel.ItemWidth = double.NaN; UpdateStatus(); }
private void UpdateStatus() { StatusText.Text = $"Level = {Card.Level},MaxLevel = {Card.MaxLevel}," + $"ClickCount = {Card.ClickCount}," + $"Spacing = {SpacingPanel.Spacing}," + $"ItemWidth = {SpacingPanel.ItemWidth}," + $"ChildCount = {SpacingPanel.ChildCount}"; } }
|
App.xaml
保持模板默认内容即可:
1 2 3 4 5 6 7 8 9
| <Application x:Class="WpfDependencyPropertyDemo.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:WpfDependencyPropertyDemo" StartupUri="MainWindow.xaml"> <Application.Resources>
</Application.Resources> </Application>
|
运行检查表
按下面顺序检查,可以快速确认示例是否完整:
| 检查项 |
预期结果 |
执行 dotnet build |
显示 0 个错误 |
| 启动程序 |
出现“依赖属性实验台”窗口 |
| 卡片标题 |
显示“依赖属性演示卡” |
| 点击“等级 +1” |
状态中的 Level 增加 |
| 点击“等级设为 99” |
Level 被限制到 MaxLevel |
| 点击“上限设为 5” |
Level 被强制到 5 |
| 点击“上限恢复 100” |
Level 恢复到基础值 99 |
| 点击“尝试非法值 -1” |
状态显示验证失败信息 |
| 点击“Spacing = 24” |
三个子元素间距变大 |
| 点击“ItemWidth = 160” |
子元素使用固定宽度 |
| 点击“恢复自动宽度” |
子元素重新均分宽度 |
| 查看输出窗口 |
能看到 Validate、Coerce、Level 等日志 |
如果“上限恢复 100”后没有恢复到 99,先检查:
OnMaxLevelChanged 是否调用了 card.CoerceValue(LevelProperty)。
LevelProperty 的元数据是否传入了 CoerceLevel。
- 之前是否真的设置过
Level = 99,让基础值变成 99。
最后再做三个小练习
练习一:增加一个只读属性
给 InfoCard 增加一个 DisplayCount 只读依赖属性,每次标题或等级变化时加一。提示:
- 使用
RegisterReadOnly。
- 使用
DependencyPropertyKey 在内部写入。
- 在变化回调中更新计数。
练习二:增加最小值
给 InfoCard 增加 MinLevel,让强制规则变成:
1
| Level 必须在 MinLevel 和 MaxLevel 之间
|
提示:
- 给
MinLevel 添加验证。
- 在
MinLevel 和 MaxLevel 变化时都调用 CoerceValue(LevelProperty)。
- 确保默认值满足规则。
练习三:改造面板
给 UniformSpacingPanel 增加 MinItemWidth 依赖属性,要求:
- 默认值为 0。
- 不允许负数。
- 自动宽度计算时不能低于最小值。
- 修改属性后布局自动刷新。
完成后,你会经历一次完整的“声明、注册、验证、布局失效、使用”的流程。
结语
依赖属性看起来代码不少,但可以把它们拆成固定的几个问题:
1 2 3 4 5 6
| 它是哪个属性? -> 声明和注册 默认值是什么? -> 元数据 非法值怎么处理? -> 验证回调 值超出范围怎么调整? -> 强制回调 值变化后界面怎么更新?-> 变化回调 布局属性怎么刷新? -> AffectsMeasure / AffectsArrange
|
刚开始学习时,不必一次记住所有细节。先让一个最小的 Title 依赖属性跑起来,再依次加入变化回调、验证和强制,最后尝试自定义面板。每加一层,都先运行一次,观察输出和界面,这样比单纯阅读代码更容易建立稳定的理解。