WPF 依赖属性进阶学习笔记
面向已经写过一两个依赖属性,准备继续学习元数据、继承和附加属性的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。依赖属性机制在 .NET 9 和 .NET 10 中没有变化。
最后更新:2026-10-19
目录
- 依赖属性的元数据选项
- 依赖属性的继承
- 依赖附加属性的定义
- 依赖附加属性的应用
写在前面
这份笔记和上一篇的关系
上一篇《WPF 依赖属性学习笔记》解决的是“怎样把一个普通属性改造成依赖属性”:
1
| 声明 -> 注册 -> 变化回调 -> 验证回调 -> 强制回调
|
这一篇继续向前一步,解决的是“依赖属性怎样和 WPF 的其他机制配合”:
| 本篇主题 |
回答的问题 |
| 元数据选项 |
属性变化后,WPF 应该自动做什么 |
| 属性值继承 |
值怎样从祖先元素流向子孙元素 |
| 附加属性定义 |
怎样给“别人的元素”增加一个属性 |
| 附加属性应用 |
附加属性怎样在 XAML、C# 和容器布局中工作 |
如果上一篇还没有读过,建议先完成其中的 InfoCard 示例。本篇会直接使用依赖属性、验证回调、强制回调和布局元数据,不再重复每一个基础步骤。
运行本篇示例
示例项目位置:
1
| C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAdvancedDependencyPropertyDemo
|
在 PowerShell 中执行:
1 2 3
| cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAdvancedDependencyPropertyDemo" dotnet build dotnet run
|
如果使用 Visual Studio:
- 打开项目。
- 将
WpfAdvancedDependencyPropertyDemo 设为启动项目。
- 按
F5 以调试方式启动。
- 打开“输出”窗口,把显示来源切换为“调试”。
示例文件分工
| 文件 |
作用 |
ThemeSettings.cs |
定义可继承的附加属性 AccentBrush 和普通附加属性 LabelText |
LayoutHints.cs |
定义附加属性 Row 和 ColumnSpan |
HintGrid.cs |
自定义面板,读取子元素上的 LayoutHints 并排列 |
MainWindow.xaml |
在 XAML 中设置继承属性和附加属性 |
MainWindow.xaml.cs |
在 C# 中读取、修改附加属性 |
2026 年的目标框架
新项目如果已经安装 .NET 10 SDK,推荐:
1
| <TargetFramework>net10.0-windows</TargetFramework>
|
如果电脑上只有 .NET 9 SDK,就使用:
1
| <TargetFramework>net9.0-windows</TargetFramework>
|
本篇示例已经验证可以在 .NET 9 上编译和运行。把目标框架改成 .NET 10 后,本篇的依赖属性代码不需要修改。
1. 依赖属性的元数据选项
本节目标
学完本节,你应该能够回答:
FrameworkPropertyMetadataOptions 是做什么的。
AffectsMeasure、AffectsArrange、AffectsRender 分别影响什么。
Inherits 为什么会让属性值沿着元素树传播。
BindsTwoWayByDefault 什么时候有用。
- 多个元数据选项怎样用按位或组合。
- 为什么不能把所有选项都加上。
1.1 元数据不只是“默认值”
前面注册依赖属性时,最常见的写法是:
1
| new FrameworkPropertyMetadata("未命名卡片")
|
这行代码至少完成了一件事:指定默认值。但 FrameworkPropertyMetadata 还能携带更多信息,例如:
1 2 3 4 5
| new FrameworkPropertyMetadata( 默认值, FrameworkPropertyMetadataOptions.AffectsRender, 值变化回调, 强制回调)
|
可以把元数据理解为一张“属性行为登记表”:
| 登记内容 |
回答的问题 |
| 默认值 |
没有本地值时显示什么 |
| 元数据选项 |
属性变化后要通知谁 |
| 变化回调 |
值变化后执行什么代码 |
| 强制回调 |
有效值是否需要调整 |
本节重点看第二项:元数据选项。
1.2 先建立一个最小实验
新建一个 WPF 项目,或者直接打开随附示例。先看 HintGrid.cs 中的 Gap 属性:
1 2 3 4 5 6 7 8 9 10 11 12
| public static readonly DependencyProperty GapProperty = DependencyProperty.Register( nameof(Gap), typeof(double), typeof(HintGrid), new FrameworkPropertyMetadata( 10d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double gap && double.IsFinite(gap) && gap >= 0);
|
这段代码表达了三层含义:
Gap 是 double 依赖属性。
- 默认间距是 10。
Gap 改变后,父容器需要重新测量和排列。
Gap 本身是普通依赖属性,不是附加属性。它放在这里,是因为它的元数据选项最能直接观察到布局变化。
1.3 AffectsMeasure:影响测量
AffectsMeasure 的含义是:
这个属性变化后,测量结果可能不再有效,需要重新测量。
在 WPF 布局中,测量阶段回答的问题是:
如果 Gap 变大,子元素之间的总宽度或总高度可能变化,所以需要重新执行 MeasureOverride。
1
| FrameworkPropertyMetadataOptions.AffectsMeasure
|
常见适合使用 AffectsMeasure 的属性:
| 属性例子 |
为什么影响测量 |
| 子元素间距 |
改变总尺寸 |
| 列数 |
改变每个子元素的宽度 |
| 字体大小 |
改变文本所需空间 |
| 内边距 |
改变内容所需空间 |
1.4 AffectsArrange:影响排列
AffectsArrange 的含义是:
这个属性变化后,元素的最终位置或尺寸可能变化,需要重新排列。
排列阶段回答的问题是:
有些属性不改变“希望尺寸”,但会改变“摆放位置”。例如:
| 属性例子 |
为什么影响排列 |
| 子元素顺序 |
位置变化 |
| 水平对齐方式 |
位置变化 |
| 行号 |
位置变化 |
| 列跨度 |
宽度和位置变化 |
AffectsArrange 通常和 AffectsMeasure 一起使用:
1 2
| FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange
|
原因很简单:布局参数改变后,尺寸和位置往往都需要重新计算。
1.5 AffectsRender:影响绘制
AffectsRender 的含义是:
这个属性变化后,元素需要重新绘制。
它适合只影响外观、不影响布局尺寸的属性,例如:
| 属性例子 |
为什么只影响绘制 |
| 前景色 |
不改变尺寸和位置 |
| 背景色 |
不改变尺寸和位置 |
| 边框颜色 |
不改变布局 |
| 透明度 |
不改变布局 |
示例中的 AccentBrush 就使用了:
1
| FrameworkPropertyMetadataOptions.Inherits
|
它是否还需要 AffectsRender?可以加,但不是必须的,因为示例同时使用了数据绑定,绑定更新会驱动相关元素刷新。理解元数据选项时,不要把“必须加”与“可以加”混为一谈。
1.6 三个选项的区别
用一张表对比:
| 选项 |
影响的阶段 |
典型问题 |
AffectsMeasure |
测量 |
需要多大空间 |
AffectsArrange |
排列 |
放在哪里 |
AffectsRender |
绘制 |
长什么样 |
可以这样记:
1 2 3
| Measure -> 尺寸 Arrange -> 位置 Render -> 外观
|
布局属性通常同时需要前两个,颜色属性通常只需要第三个。
1.7 Inherits:允许值向下继承
Inherits 是一个很特殊的选项:
1
| FrameworkPropertyMetadataOptions.Inherits
|
它表示这个依赖属性的值可以沿着元素树向下传递。典型例子包括:
FontSize
Foreground
DataContext
- 自定义主题色
示例中的 AccentBrush 就是这样定义的:
1 2 3 4 5 6 7 8 9
| public static readonly DependencyProperty AccentBrushProperty = DependencyProperty.RegisterAttached( "AccentBrush", typeof(Brush), typeof(ThemeSettings), new FrameworkPropertyMetadata( Brushes.DodgerBlue, FrameworkPropertyMetadataOptions.Inherits, OnAccentBrushChanged));
|

图 1:附加属性由 ThemeSettings * 定义,却可以设置到窗口、面板和其他普通元素上。*
Inherits 不是“所有依赖属性默认都会继承”。只有显式加上这个选项,并且属性值能沿当前元素树传播时,才会出现继承效果。
1.8 继承不是简单的对象引用
假设窗口上设置了:
1
| local:ThemeSettings.AccentBrush="#2F6FED"
|
子元素没有设置自己的 AccentBrush,读取时会得到窗口上的值。这看起来像“共享同一个值”,但实际过程更接近:
1 2 3 4 5 6 7 8 9 10
| 窗口设置 AccentBrush | v 子元素没有本地值 | v 沿元素树向上查找可继承值 | v 子元素读取到窗口的值
|
一旦子元素设置了自己的本地值,本地值优先:
1
| <TextBlock Foreground="Red" />
|
清除本地值后,继承值又会重新生效。
1.9 继承实验
在窗口中设置:
1 2
| <Window ... local:ThemeSettings.AccentBrush="#2F6FED">
|
然后在窗口内部放两个没有单独设置颜色的 TextBlock:
1 2 3
| <TextBlock Text="继承者 A" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/>
|
运行后,两个文本都会显示蓝色。
点击“切换主题色”按钮:
1 2 3 4 5 6 7 8 9 10 11 12
| private void ToggleAccent_Click(object sender, RoutedEventArgs e) { var currentBrush = ThemeSettings.GetAccentBrush(this); var isBlue = currentBrush is SolidColorBrush brush && brush.Color == Color.FromRgb(47, 111, 237);
ThemeSettings.SetAccentBrush(this, isBlue ? new SolidColorBrush(Color.FromRgb(16, 139, 104)) : new SolidColorBrush(Color.FromRgb(47, 111, 237)));
UpdateStatus("已切换继承属性 AccentBrush。"); }
|
窗口上的继承值改变后,所有读取该值的子元素都会更新。
1.10 本地值优先于继承值
如果某个子元素不想使用继承值,可以设置自己的本地值:
1 2
| <TextBlock Text="本地覆盖" Foreground="OrangeRed" />
|
优先级可以简化为:
真实 WPF 属性系统还有样式、模板、动画等来源,但本节先掌握这个最小模型。
1.11 BindsTwoWayByDefault
BindsTwoWayByDefault 让数据绑定在默认情况下使用双向模式:
1
| FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
|
它适合“用户编辑后需要把值写回数据源”的属性,例如:
不是所有属性都需要它。Title、Background 这种通常由代码或样式设置的属性,就不一定需要默认双向绑定。
1.12 多个选项怎样组合
元数据选项是标志枚举,可以用按位或组合:
1 2 3
| FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange | FrameworkPropertyMetadataOptions.Inherits
|
读法:
1 2 3 4
| 这个属性变化后: 1. 需要重新测量 2. 需要重新排列 3. 值可以向下继承
|
不要为了“保险”把所有选项都加上。选项越多,WPF 需要做的工作越多,行为也越难预测。
1.13 元数据选项不是业务规则
这是初学者最容易踩的坑之一:
1 2 3
| AffectsMeasure:WPF 自动重新测量 AffectsArrange:WPF 自动重新排列 AffectsRender:WPF 自动重新绘制
|
它们不是:
1 2 3
| 自动验证输入 自动调整值 自动更新内部 TextBlock
|
验证、强制和界面同步仍然要写回调。元数据选项只负责告诉 WPF“哪一类工作失效了”。
1.14 本节小结
| 问题 |
答案 |
AffectsMeasure 做什么 |
让测量失效 |
AffectsArrange 做什么 |
让排列失效 |
AffectsRender 做什么 |
让绘制失效 |
Inherits 做什么 |
允许属性值沿元素树向下传播 |
| 多个选项能组合吗 |
可以,用按位或 |
| 选项能代替回调吗 |
不能,职责不同 |
| 所有选项都加上好吗 |
不好,会增加不必要的工作 |
小练习
- 给
HintGrid.Gap 只保留 AffectsMeasure,观察修改间距后界面是否仍然正确。
- 把
AccentBrush 的 Inherits 去掉,观察继承实验是否还成立。
- 给一个只影响颜色的属性加
AffectsMeasure,观察是否产生了不必要的布局工作。
2. 依赖属性的继承
本节目标
理解属性值继承的工作方式,并能够:
- 声明一个支持继承的依赖属性或附加属性。
- 在祖先元素上设置一次值,让多个子孙元素共享。
- 解释本地值为什么能覆盖继承值。
- 区分“属性值继承”和“C# 类继承”。
- 知道继承适合什么、不适合什么。
2.1 先区分两个“继承”
WPF 里的“继承”至少有两个完全不同的意思:
| 名称 |
含义 |
例子 |
| C# 类继承 |
子类获得父类的成员 |
InfoCard : Border |
| 属性值继承 |
子元素读取祖先元素的属性值 |
FontSize 从窗口传到文本 |
本篇讨论的是第二种。
InfoCard 继承 Border,这是类继承。
TextBlock 没有继承 Window 类,却可以读取窗口上的 FontSize,这是属性值继承。
2.2 属性值继承需要什么条件
一个依赖属性要参与属性值继承,通常需要:
- 使用依赖属性或附加属性。
- 元数据中设置
Inherits。
- 元素处于同一棵逻辑树或视觉树中。
- 子元素没有更高优先级的本地值。
示例中的 AccentBrush 同时满足前两项,并在窗口中设置了一次值,因此整棵窗口内容树都可以读取。
2.3 先观察默认继承
WPF 自带很多可继承属性。最典型的是字体属性:
1 2 3 4 5 6
| <Window FontSize="20"> <StackPanel> <TextBlock Text="我没有写 FontSize" /> <TextBlock Text="我也没有写 FontSize" /> </StackPanel> </Window>
|
两个 TextBlock 都会显示 20 号字体。
这不是因为 TextBlock 从 Window 类继承了 FontSize,而是因为 FontSize 是依赖属性,并且元数据允许继承。
2.4 实现一个可继承的主题色
在 ThemeSettings.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
| public static class ThemeSettings { public static readonly DependencyProperty AccentBrushProperty = DependencyProperty.RegisterAttached( "AccentBrush", typeof(Brush), typeof(ThemeSettings), new FrameworkPropertyMetadata( Brushes.DodgerBlue, FrameworkPropertyMetadataOptions.Inherits, OnAccentBrushChanged));
public static void SetAccentBrush(DependencyObject element, Brush value) { element.SetValue(AccentBrushProperty, value); }
public static Brush GetAccentBrush(DependencyObject element) { return (Brush)element.GetValue(AccentBrushProperty); }
private static void OnAccentBrushChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var element = d; Debug.WriteLine( $"[Inherited AccentBrush] {e.OldValue} -> {e.NewValue} on {element.GetType().Name}"); } }
|
这里用了附加属性,下一章会详细讲定义。现在先关注元数据中的 Inherits。
2.5 在 XAML 中设置继承值
最直观的用法是设置在窗口上:
1 2
| <Window ... local:ThemeSettings.AccentBrush="#2F6FED">
|
然后在任意子孙元素中读取:
1 2 3
| <TextBlock Text="继承主题色" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}" />
|
为什么绑定路径要加括号?
1
| Path=(local:ThemeSettings.AccentBrush)
|
因为 AccentBrush 是附加属性。XAML 绑定附加属性时,需要用“定义类型.属性名”的形式,并通常加括号。
2.6 继承值能不能自动成为 Foreground
不能直接“自动变成”。
继承的是 ThemeSettings.AccentBrush,不是 TextBlock.Foreground。要让文本颜色跟着主题色变化,还需要绑定:
1 2
| Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"
|
继承负责“值能传下来”,绑定负责“把这个值接到另一个属性上”。
2.7 用 C# 读取继承值
假设窗口已经设置了主题色:
1
| var brush = ThemeSettings.GetAccentBrush(this);
|
如果当前窗口没有本地值,读取会继续向上查找可继承值;如果整棵树都没有,则返回默认值。
在代码中也可以主动设置:
1
| ThemeSettings.SetAccentBrush(this, Brushes.OrangeRed);
|
这会改变整棵树的继承来源。
2.8 本地值覆盖继承值
在 XAML 中给某个子元素设置自己的值:
1 2
| <TextBlock Text="我只想用红色" Foreground="OrangeRed" />
|
即使窗口上有蓝色主题,这个 TextBlock 仍然显示橙红色。
这就是本地值优先。
用代码验证:
1 2 3 4 5 6 7 8
| ThemeSettings.SetAccentBrush(root, Brushes.OrangeRed); Console.WriteLine(ThemeSettings.GetAccentBrush(leaf));
leaf.SetValue(ThemeSettings.AccentBrushProperty, Brushes.SeaGreen); Console.WriteLine(ThemeSettings.GetAccentBrush(leaf));
leaf.ClearValue(ThemeSettings.AccentBrushProperty); Console.WriteLine(ThemeSettings.GetAccentBrush(leaf));
|
运行结果:
1 2 3
| Leaf inherited: #FFFF4500 Leaf local: #FF2E8B57 Leaf after clear: #FFFF4500
|
这说明:
2.9 继承会向下传播,不会向上传播
假设:
1 2 3 4 5
| <Window local:ThemeSettings.AccentBrush="Blue"> <StackPanel> <TextBlock /> </StackPanel> </Window>
|
TextBlock 可以读取窗口上的值。
如果只在 TextBlock 上设置值:
1
| <TextBlock local:ThemeSettings.AccentBrush="Red" />
|
窗口和 StackPanel 不会因此变成红色。继承的方向是向下,不是向上。
2.10 继承值会沿哪些树传播
WPF 中常见的树有两种:
| 树 |
说明 |
| 逻辑树 |
由 XAML 内容关系组成的树 |
| 视觉树 |
由模板展开后的视觉元素组成的树 |
属性值继承主要依赖逻辑树。对于普通 XAML 布局,你可以先把它理解为“父子元素关系”。
自定义控件模板中的某些元素,可能需要额外注意逻辑树关系。初学阶段先掌握普通布局中的继承即可。
2.11 继承和绑定的区别
两者都能让子元素获得值,但机制不同:
| 机制 |
工作方式 |
适合场景 |
| 属性值继承 |
WPF 自动沿树查找 |
主题、字体、通用默认值 |
| 数据绑定 |
显式指定来源和路径 |
业务数据、动态计算值 |
继承通常用于“整棵树共享的上下文值”,绑定更适合“某个具体数据源”。
示例中两者配合使用:
1 2 3 4
| ThemeSettings.AccentBrush 继承下来 | v Foreground 绑定到继承值
|
2.12 继承不适合什么
属性值继承很好用,但不应该滥用:
| 不适合的场景 |
原因 |
| 每个元素都要不同的业务数据 |
继承值会让所有子元素得到同一个来源 |
| 需要明确的数据流 |
隐式继承会增加调试难度 |
| 大型复杂控件树中的频繁变化 |
可能导致较多元素重新计算 |
| 替代 ViewModel |
继承是界面属性机制,不是业务数据容器 |
可以把继承用于“主题、字体、方向、全局视觉设置”等横切关注点。具体业务数据仍然建议通过绑定和 ViewModel 传递。
2.13 继承值变化时会发生什么
当祖先元素上的继承值变化时:
- 继承来源更新。
- 没有本地值的子元素读取到新值。
- 绑定到该值的属性收到更新。
- 如果元数据还包含布局选项,布局可能重新执行。
示例中切换主题色后,多个文本和边框会同时变色,就是这条链路的结果。
2.14 本节小结
| 问题 |
答案 |
| 属性值继承和类继承一样吗 |
不一样 |
| 哪些属性能继承 |
元数据设置了 Inherits 的属性 |
| 继承方向是什么 |
从祖先到子孙 |
| 本地值会被覆盖吗 |
本地值优先 |
| 清除本地值后会怎样 |
恢复继承值 |
| 继承能自动更新所有外观吗 |
不能,通常还要绑定或回调 |
| 继承适合业务数据吗 |
通常不适合 |
小练习
- 把
AccentBrush 的默认值改成绿色,观察没有设置主题色时的效果。
- 在某个子元素上设置本地
AccentBrush,观察它是否覆盖祖先值。
- 清除该本地值,观察继承值是否恢复。
- 给
ThemeSettings 增加一个可继承的 CornerRadius 属性,并用绑定把它接到边框上。
3. 依赖附加属性的定义
本节目标
理解附加属性的定位,并能够:
- 用
RegisterAttached 注册附加属性。
- 写标准的
SetXxx / GetXxx 静态方法。
- 在 XAML 中使用附加属性。
- 给附加属性添加验证和强制回调。
- 知道附加属性和普通依赖属性的区别。
3.1 为什么需要附加属性
假设我们有一个自定义面板 HintGrid,它希望每个子元素告诉它:
最直接的想法是给 Border、TextBlock 等每个控件都加一个 Row 属性。但这是不现实的:
- 不能修改 WPF 自带的控件。
- 每个控件都加属性会污染类型。
- 布局信息属于容器规则,不属于子元素本身。
附加属性解决的正是这个问题:
1 2 3
| 属性定义在 LayoutHints 中 值却设置在任意 DependencyObject 实例上 容器再读取这些值进行布局
|
3.2 附加属性和普通依赖属性的区别
| 对比项 |
普通依赖属性 |
附加属性 |
| 注册方法 |
Register |
RegisterAttached |
| 通常定义在 |
控件或数据类中 |
工具类或容器类中 |
| 谁使用 |
定义类型的实例 |
任意 DependencyObject |
| 访问方式 |
实例属性 |
静态 SetXxx / GetXxx |
| XAML 写法 |
Title="..." |
local:LayoutHints.Row="0" |
普通依赖属性回答“这个对象自己的属性是什么”。
附加属性回答“某个外部规则要给这个对象附加什么信息”。
3.3 附加属性的注册
先看一个最小版本:
1 2 3 4 5 6 7 8 9 10 11
| public static class LayoutHints { public static readonly DependencyProperty RowProperty = DependencyProperty.RegisterAttached( "Row", typeof(int), typeof(LayoutHints), new FrameworkPropertyMetadata( 0, FrameworkPropertyMetadataOptions.AffectsParentArrange)); }
|
四个关键参数:
| 参数 |
示例 |
作用 |
| 属性名 |
"Row" |
XAML 和属性系统识别名称 |
| 属性类型 |
typeof(int) |
值类型 |
| 所有者类型 |
typeof(LayoutHints) |
定义者 |
| 元数据 |
FrameworkPropertyMetadata |
默认值和行为 |
注意 RowProperty 是静态字段,但它的值可以分别存在于不同元素上:
1 2
| LayoutHints.SetRow(elementA, 0); LayoutHints.SetRow(elementB, 1);
|
RowProperty 只回答“这是 Row 属性”,元素 A 和元素 B 各自保存自己的值。
3.4 标准访问方法
附加属性通常提供一对静态方法:
1 2 3 4 5 6 7 8 9
| public static void SetRow(DependencyObject element, int value) { element.SetValue(RowProperty, value); }
public static int GetRow(DependencyObject element) { return (int)element.GetValue(RowProperty); }
|
命名约定:
方法参数通常是 DependencyObject element,因为附加属性可以挂在任何依赖对象上。
不要写成实例方法:
1 2
| public void SetRow(int value)
|
3.5 XAML 中怎样使用附加属性
需要先在根元素声明命名空间:
1
| xmlns:local="clr-namespace:WpfAdvancedDependencyPropertyDemo"
|
然后这样写:
1 2
| <Border local:LayoutHints.Row="1" local:LayoutHints.ColumnSpan="2" />
|
读法:
1 2 3
| 把 LayoutHints 中定义的 Row 属性 附加到当前 Border 上 值为 1
|
它看起来像 Border.Row,但实际上 Border 类型中没有这个属性。这个值由 LayoutHints 管理。
3.6 完整定义:Row 和 ColumnSpan
下面是示例中的完整 LayoutHints.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
| using System; using System.Diagnostics; using System.Windows;
namespace WpfAdvancedDependencyPropertyDemo;
public static class LayoutHints { public static readonly DependencyProperty RowProperty = DependencyProperty.RegisterAttached( "Row", typeof(int), typeof(LayoutHints), new FrameworkPropertyMetadata( 0, FrameworkPropertyMetadataOptions.AffectsParentArrange, null, CoerceRow), ValidateRow);
public static readonly DependencyProperty ColumnSpanProperty = DependencyProperty.RegisterAttached( "ColumnSpan", typeof(int), typeof(LayoutHints), new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.AffectsParentArrange, null, CoerceColumnSpan), ValidateColumnSpan);
public static void SetRow(DependencyObject element, int value) { element.SetValue(RowProperty, value); }
public static int GetRow(DependencyObject element) { return (int)element.GetValue(RowProperty); }
public static void SetColumnSpan(DependencyObject element, int value) { element.SetValue(ColumnSpanProperty, value); }
public static int GetColumnSpan(DependencyObject element) { return (int)element.GetValue(ColumnSpanProperty); }
private static bool ValidateRow(object value) { return value is int row && row >= 0; }
private static object CoerceRow(DependencyObject d, object baseValue) { var row = (int)baseValue; var coerced = Math.Clamp(row, 0, 2); Debug.WriteLine($"[LayoutHints.Row] base={row}, result={coerced}"); return coerced; }
private static bool ValidateColumnSpan(object value) { return value is int span && span >= 1; }
private static object CoerceColumnSpan(DependencyObject d, object baseValue) { var span = (int)baseValue; var coerced = Math.Clamp(span, 1, 2); Debug.WriteLine($"[LayoutHints.ColumnSpan] base={span}, result={coerced}"); return coerced; } }
|
3.7 为什么用 AffectsParentArrange
Row 和 ColumnSpan 不改变子元素自身需要多大,而是改变父容器怎样安排它。
所以示例使用了:
1
| FrameworkPropertyMetadataOptions.AffectsParentArrange
|
它的含义是:
这个附加属性变化后,让父元素的排列失效。
对比:
| 选项 |
影响谁 |
AffectsArrange |
当前元素 |
AffectsParentArrange |
父元素 |
AffectsMeasure |
当前元素的测量 |
AffectsParentMeasure |
父元素的测量 |
布局信息通常由父容器读取,所以 AffectsParentArrange 很常见。
3.8 验证和强制怎样用于附加属性
附加属性同样支持验证和强制:
1 2 3 4 5 6
| new FrameworkPropertyMetadata( 0, FrameworkPropertyMetadataOptions.AffectsParentArrange, null, CoerceRow), ValidateRow
|
规则设计:
| 值 |
验证 |
强制 |
有效值 |
0 |
通过 |
0 |
0 |
2 |
通过 |
2 |
2 |
9 |
通过 |
9 -> 2 |
2 |
-1 |
拒绝 |
不执行 |
抛异常 |
实际运行结果:
1 2 3 4
| Row 2: 2 Row 9 coerced: 2 Row -1 rejected: ArgumentException Span 5 coerced: 2
|
这说明附加属性的回调机制和普通依赖属性一致。
3.9 为什么附加属性常定义在静态类中
附加属性通常只需要提供:
1 2 3
| 注册字段 静态 Get / Set 方法 可选的静态回调
|
它不需要每个定义者实例保存状态,因此常放在静态类中。
但要注意:
- 附加属性字段本身是静态的。
- 属性值仍然是按目标元素保存的。
- 回调可以访问目标元素
d。
3.10 附加属性可以挂在哪些对象上
访问方法通常接收 DependencyObject:
1
| public static int GetRow(DependencyObject element)
|
这意味着理论上可以挂到任何 DependencyObject 上。但实际使用时应根据业务含义限制:
1
| LayoutHints.Row 只对 HintGrid 的子元素有意义
|
附加属性不会自动知道“谁应该读取它”。读取逻辑仍然由容器或业务代码决定。
3.11 附加属性的常见错误
| 错误 |
结果 |
修正 |
用 Register 注册 |
XAML 附加属性语法可能不工作 |
改用 RegisterAttached |
| 属性名和字段名不一致 |
XAML 找不到属性 |
保持 "Row" 与 RowProperty 对应 |
| 忘记 Get / Set 方法 |
C# 读写不方便 |
补上标准访问方法 |
| 所有元素共用同一个值 |
误把值放进静态字段 |
值通过 SetValue 存在元素上 |
| 布局属性没有失效选项 |
修改后界面不刷新 |
使用 AffectsParentArrange 等 |
| 默认值不合法 |
初始状态就违反验证 |
确保默认值满足规则 |
3.12 本节小结
| 问题 |
答案 |
| 附加属性用什么注册 |
DependencyProperty.RegisterAttached |
| 访问方法是什么 |
静态 SetXxx / GetXxx |
| XAML 怎么写 |
local:Type.Property="value" |
| 值存在哪里 |
存在目标元素上 |
| 支持验证和强制吗 |
支持 |
| 布局变化怎么通知 |
使用 AffectsParentArrange 等选项 |
| 定义在静态类中可以吗 |
可以,而且很常见 |
小练习
- 给
LayoutHints 增加 Column 附加属性,要求非负,并强制限制在 0 到 1。
- 给
Column 增加 AffectsParentArrange。
- 在 XAML 中给两个元素分别设置不同的
Column。
- 在 C# 中读取它们,确认值互不影响。
4. 依赖附加属性的应用
本节目标
把附加属性真正用起来,做到:
- 在 XAML 中设置附加属性。
- 在 C# 中读取和修改附加属性。
- 让自定义容器读取附加属性并完成布局。
- 理解附加属性与绑定、样式、动画的配合。
- 能判断什么时候该用附加属性。
4.1 先看最终效果
示例窗口分为三个区域:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 操作区 | +-- 切换主题色 +-- 重设面板间距 +-- Row 设为 2 +-- Row 设为 9 +-- 列跨度设为 2 +-- 尝试非法 Row -1
属性值继承区 | +-- 多个元素共享 AccentBrush
附加属性应用区 | +-- HintGrid 读取子元素上的 Row 和 ColumnSpan
|
运行后,你可以看到:
- 点击“切换主题色”,多个元素同时变色。
- 点击“Row 设为 2”,第一个元素移动到第三行。
- 点击“Row 设为 9”,有效值被强制为 2。
- 点击“列跨度设为 2”,第一个元素横跨两列。
- 点击“尝试非法 Row -1”,验证失败并显示提示。
4.2 在 XAML 中设置附加属性
完整写法:
1 2 3 4 5 6 7 8 9 10
| <local:HintGrid x:Name="HintGrid" Gap="12" Background="#F7F9FD" local:ThemeSettings.LabelText="HintGrid 的子元素会继承这段附加文本"> <Border local:LayoutHints.Row="0" local:LayoutHints.ColumnSpan="1" Background="#EAF1FF"> <TextBlock Text="元素 A" /> </Border> </local:HintGrid>
|
这里有三个附加属性:
| 附加属性 |
目标元素 |
谁读取 |
ThemeSettings.LabelText |
HintGrid |
子元素或代码 |
LayoutHints.Row |
Border |
HintGrid |
LayoutHints.ColumnSpan |
Border |
HintGrid |
注意:附加属性写在目标元素上,但定义类型不是目标元素。
4.3 在 C# 中读取附加属性
读取第一个子元素:
1 2 3 4
| var firstChild = HintGrid.Children[0];
var row = LayoutHints.GetRow(firstChild); var span = LayoutHints.GetColumnSpan(firstChild);
|
设置新值:
1 2
| LayoutHints.SetRow(firstChild, 2); LayoutHints.SetColumnSpan(firstChild, 2);
|
也可以用更底层的写法:
1 2
| firstChild.SetValue(LayoutHints.RowProperty, 2); var row = (int)firstChild.GetValue(LayoutHints.RowProperty);
|
推荐优先使用 SetRow / GetRow,可读性更好,也方便以后修改内部实现。
4.4 让容器读取附加属性
附加属性本身不会自动改变布局。容器必须主动读取它。

图 2:定义者只提供读写 API,真正解释附加值的仍然是读取它的容器。
在 HintGrid.MeasureOverride 中:
1 2 3 4 5 6 7 8 9 10
| foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2);
var childWidth = span == 1 ? cellWidth : width; child.Measure(new Size(childWidth, availableSize.Height));
rowHeights[row] = Math.Max(rowHeights[row], child.DesiredSize.Height); }
|
在 ArrangeOverride 中:
1 2 3 4 5 6 7 8 9
| var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2);
var column = nextColumn[row]; var x = column == 0 ? 0 : cellWidth + Gap; var childWidth = span == 1 ? cellWidth : finalSize.Width;
child.Arrange(new Rect(x, rowTops[row], childWidth, rowHeights[row])); nextColumn[row] += span;
|
这段代码体现了附加属性的真正价值:
1 2
| LayoutHints 只负责保存“行、列跨度” HintGrid 负责解释这些值并完成布局
|
4.5 完整容器代码
下面是示例中的 HintGrid.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
| using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls;
namespace WpfAdvancedDependencyPropertyDemo;
public class HintGrid : Panel { public double Gap { get => (double)GetValue(GapProperty); set => SetValue(GapProperty, value); }
public static readonly DependencyProperty GapProperty = DependencyProperty.Register( nameof(Gap), typeof(double), typeof(HintGrid), new FrameworkPropertyMetadata( 10d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double gap && double.IsFinite(gap) && gap >= 0);
protected override Size MeasureOverride(Size availableSize) { var count = InternalChildren.Count; if (count == 0) { return new Size(0, 0); }
var width = double.IsInfinity(availableSize.Width) ? 720 : availableSize.Width; var cellWidth = Math.Max(0, (width - Gap) / 2); var rowHeights = new double[3];
foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var childWidth = span == 1 ? cellWidth : width;
child.Measure(new Size(childWidth, availableSize.Height)); rowHeights[row] = Math.Max(rowHeights[row], child.DesiredSize.Height); }
var totalHeight = rowHeights[0] + rowHeights[1] + rowHeights[2] + Gap * 2; Debug.WriteLine( $"[HintGrid.Measure] width={width}, cellWidth={cellWidth}, totalHeight={totalHeight}");
return new Size(width, totalHeight); }
protected override Size ArrangeOverride(Size finalSize) { var count = InternalChildren.Count; if (count == 0) { return finalSize; }
var cellWidth = Math.Max(0, (finalSize.Width - Gap) / 2); var rowHeights = new double[3];
foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var childWidth = span == 1 ? cellWidth : finalSize.Width; rowHeights[row] = Math.Max(rowHeights[row], child.DesiredSize.Height); }
var rowTops = new double[3]; rowTops[0] = 0; rowTops[1] = rowHeights[0] + Gap; rowTops[2] = rowTops[1] + rowHeights[1] + Gap;
var nextColumn = new int[3]; foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var column = nextColumn[row]; var x = column == 0 ? 0 : cellWidth + Gap; var childWidth = span == 1 ? cellWidth : finalSize.Width;
child.Arrange(new Rect(x, rowTops[row], childWidth, rowHeights[row])); nextColumn[row] += span; }
Debug.WriteLine( $"[HintGrid.Arrange] finalWidth={finalSize.Width}, finalHeight={finalSize.Height}");
return finalSize; } }
|
4.6 附加属性和布局失效怎样配合
当代码执行:
1
| LayoutHints.SetRow(firstChild, 2);
|
如果 RowProperty 没有设置 AffectsParentArrange,父容器可能不会重新排列。
加上:
1
| FrameworkPropertyMetadataOptions.AffectsParentArrange
|
后,变化会通知父容器:
1 2 3 4 5 6 7 8 9 10
| Row 改变 | v 父容器排列失效 | v ArrangeOverride 重新执行 | v HintGrid 读取新的 Row
|
这和普通布局依赖属性的思路完全一致,只是这次通知的是父元素。
4.7 附加属性和数据绑定
附加属性也可以参与绑定。例如:
1
| <Border local:LayoutHints.Row="{Binding CurrentRow}" />
|
当 CurrentRow 变化时,附加属性会更新,进而触发父容器重新排列。
这适合“布局规则来自 ViewModel”的场景。不过,布局细节通常更适合放在 XAML 或视图层。是否绑定到 ViewModel,要看项目架构。
4.8 附加属性和样式
附加属性可以写在样式中:
1 2 3
| <Style TargetType="Border"> <Setter Property="local:LayoutHints.Row" Value="1" /> </Style>
|
这适合统一布局规则。要注意:
- 样式值不是本地值。
- 显式设置的本地值通常优先于样式。
- 具体优先级仍要结合实际属性系统来源理解。
4.9 附加属性和动画
依赖属性可以参与动画,附加属性也不例外。只要属性类型支持动画,就可以在 Storyboard 中操作。
不过,布局附加属性通常不需要动画。更常见的动画目标是透明度、位置变换、颜色等。
4.10 附加属性能做验证吗
能。示例中的 Row 使用了验证:
1 2 3 4
| private static bool ValidateRow(object value) { return value is int row && row >= 0; }
|
设置:
1
| LayoutHints.SetRow(element, -1);
|
会抛出 ArgumentException,旧值保持不变。
这适合拦截明显错误的输入,例如负数行号、零列跨度。
4.11 附加属性能做强置吗
能。示例中的 Row 还会把过大的行号限制到 2:
1 2 3 4 5 6
| private static object CoerceRow(DependencyObject d, object baseValue) { var row = (int)baseValue; var coerced = Math.Clamp(row, 0, 2); return coerced; }
|
设置 Row = 9 后:
读取时得到 2。这样容器就不需要处理超出范围的行号。
4.12 附加属性适合哪些场景
| 场景 |
示例 |
| 容器给子元素附加布局信息 |
Grid.Row、LayoutHints.Row |
| 给元素附加行为配置 |
ToolTipService.ToolTip |
| 给元素附加视觉提示 |
主题、角标、辅助标签 |
| 服务类功能挂到任意元素 |
拖拽、快捷键、验证提示 |
判断标准:
如果属于外部规则,并且需要挂在多个不同类型的元素上,附加属性往往合适。
4.13 什么时候不要用附加属性
| 不适合的场景 |
更合适的方式 |
| 每个元素都有独立业务数据 |
绑定 ViewModel |
| 只属于某个控件的外观 |
普通依赖属性 |
| 需要复杂生命周期管理 |
附加行为或服务类 |
| 只是给一个类增加成员 |
普通属性或继承 |
| 全局共享状态 |
单例服务、资源或依赖注入 |
附加属性不是“万能扩展点”。它最适合表达“某个外部机制附加到元素上的信息”。
4.14 应用完整流程
以 LayoutHints.Row 为例:
1 2 3 4 5 6 7 8
| 1. LayoutHints 注册附加属性 RowProperty 2. XAML 在 Border 上写 local:LayoutHints.Row="1" 3. WPF 把值保存到该 Border 的属性系统中 4. HintGrid 在 MeasureOverride 中读取 Row 5. HintGrid 根据 Row 计算测量尺寸 6. HintGrid 在 ArrangeOverride 中读取 Row 7. HintGrid 把 Border 放到对应行的位置 8. Row 变化时 AffectsParentArrange 让父容器重新排列
|
这条链路把“定义、存储、读取、布局”四件事串起来了。
4.15 本节小结
| 问题 |
答案 |
| 附加属性写在谁身上 |
目标元素 |
| 定义在哪里 |
工具类或容器类 |
| 谁负责读取 |
容器或业务代码 |
| 容器怎样自动刷新 |
使用 AffectsParentArrange 等选项 |
| 支持绑定吗 |
支持 |
| 支持样式吗 |
支持 |
| 支持验证和强制吗 |
支持 |
| 适合业务数据吗 |
通常不适合,优先用绑定 |
小练习
- 给
LayoutHints 增加 Column,并让 HintGrid 支持每行两个固定列。
- 让
Row 支持 0 到 3 四行,并修改验证和强制范围。
- 给
ColumnSpan 增加一个“最大不能超过 2”的验证。
- 尝试在样式里设置
LayoutHints.Row,观察是否生效。
- 把
Row 绑定到一个 ViewModel 属性,观察布局是否随数据变化。
元数据选项速查
| 选项 |
影响 |
常见用途 |
AffectsMeasure |
当前元素测量失效 |
尺寸相关属性 |
AffectsArrange |
当前元素排列失效 |
位置相关属性 |
AffectsRender |
当前元素绘制失效 |
颜色、画笔、外观 |
AffectsParentMeasure |
父元素测量失效 |
子元素影响父尺寸 |
AffectsParentArrange |
父元素排列失效 |
附加布局信息 |
Inherits |
值可沿树向下继承 |
主题、字体、通用设置 |
BindsTwoWayByDefault |
绑定默认双向 |
可编辑控件值 |
NotDataBindable |
禁止数据绑定 |
特殊属性 |
Journal |
参与导航日志 |
页面状态 |
组合示例:
1 2
| FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange
|
1
| FrameworkPropertyMetadataOptions.Inherits
|
1
| FrameworkPropertyMetadataOptions.AffectsParentArrange
|
普通依赖属性与附加属性对照
| 对比项 |
普通依赖属性 |
附加属性 |
| 注册方法 |
Register |
RegisterAttached |
| 定义位置 |
控件或数据类 |
工具类、服务类、容器类 |
| 值存放 |
定义类型的实例上 |
任意目标 DependencyObject 上 |
| C# 访问 |
实例属性 |
静态 Get / Set |
| XAML 访问 |
Title="..." |
local:Type.Title="..." |
| 典型例子 |
Width、Text |
Grid.Row、Canvas.Left |
| 适合表达 |
对象自身属性 |
外部规则附加信息 |
完整示例代码
文件结构
1 2 3 4 5 6 7 8 9 10
| WpfAdvancedDependencyPropertyDemo/ ├─ App.xaml ├─ App.xaml.cs ├─ AssemblyInfo.cs ├─ HintGrid.cs ├─ LayoutHints.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ ThemeSettings.cs └─ WpfAdvancedDependencyPropertyDemo.csproj
|
WpfAdvancedDependencyPropertyDemo.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。
ThemeSettings.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
| using System.Diagnostics; using System.Windows; using System.Windows.Media;
namespace WpfAdvancedDependencyPropertyDemo;
public static class ThemeSettings { public static readonly DependencyProperty AccentBrushProperty = DependencyProperty.RegisterAttached( "AccentBrush", typeof(Brush), typeof(ThemeSettings), new FrameworkPropertyMetadata( Brushes.DodgerBlue, FrameworkPropertyMetadataOptions.Inherits, OnAccentBrushChanged));
public static void SetAccentBrush(DependencyObject element, Brush value) { element.SetValue(AccentBrushProperty, value); }
public static Brush GetAccentBrush(DependencyObject element) { return (Brush)element.GetValue(AccentBrushProperty); }
public static readonly DependencyProperty LabelTextProperty = DependencyProperty.RegisterAttached( "LabelText", typeof(string), typeof(ThemeSettings), new FrameworkPropertyMetadata( string.Empty, FrameworkPropertyMetadataOptions.Inherits));
public static void SetLabelText(DependencyObject element, string value) { element.SetValue(LabelTextProperty, value); }
public static string GetLabelText(DependencyObject element) { return (string)element.GetValue(LabelTextProperty); }
private static void OnAccentBrushChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var element = d; Debug.WriteLine( $"[Inherited AccentBrush] {e.OldValue} -> {e.NewValue} on {element.GetType().Name}"); } }
|
LayoutHints.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
| using System; using System.Diagnostics; using System.Windows;
namespace WpfAdvancedDependencyPropertyDemo;
public static class LayoutHints { public static readonly DependencyProperty RowProperty = DependencyProperty.RegisterAttached( "Row", typeof(int), typeof(LayoutHints), new FrameworkPropertyMetadata( 0, FrameworkPropertyMetadataOptions.AffectsParentArrange, null, CoerceRow), ValidateRow);
public static readonly DependencyProperty ColumnSpanProperty = DependencyProperty.RegisterAttached( "ColumnSpan", typeof(int), typeof(LayoutHints), new FrameworkPropertyMetadata( 1, FrameworkPropertyMetadataOptions.AffectsParentArrange, null, CoerceColumnSpan), ValidateColumnSpan);
public static void SetRow(DependencyObject element, int value) { element.SetValue(RowProperty, value); }
public static int GetRow(DependencyObject element) { return (int)element.GetValue(RowProperty); }
public static void SetColumnSpan(DependencyObject element, int value) { element.SetValue(ColumnSpanProperty, value); }
public static int GetColumnSpan(DependencyObject element) { return (int)element.GetValue(ColumnSpanProperty); }
private static bool ValidateRow(object value) { return value is int row && row >= 0; }
private static object CoerceRow(DependencyObject d, object baseValue) { var row = (int)baseValue; var coerced = Math.Clamp(row, 0, 2); Debug.WriteLine($"[LayoutHints.Row] base={row}, result={coerced}"); return coerced; }
private static bool ValidateColumnSpan(object value) { return value is int span && span >= 1; }
private static object CoerceColumnSpan(DependencyObject d, object baseValue) { var span = (int)baseValue; var coerced = Math.Clamp(span, 1, 2); Debug.WriteLine($"[LayoutHints.ColumnSpan] base={span}, result={coerced}"); return coerced; } }
|
HintGrid.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
| using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls;
namespace WpfAdvancedDependencyPropertyDemo;
public class HintGrid : Panel { public double Gap { get => (double)GetValue(GapProperty); set => SetValue(GapProperty, value); }
public static readonly DependencyProperty GapProperty = DependencyProperty.Register( nameof(Gap), typeof(double), typeof(HintGrid), new FrameworkPropertyMetadata( 10d, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), value => value is double gap && double.IsFinite(gap) && gap >= 0);
protected override Size MeasureOverride(Size availableSize) { var count = InternalChildren.Count; if (count == 0) { return new Size(0, 0); }
var width = double.IsInfinity(availableSize.Width) ? 720 : availableSize.Width; var cellWidth = Math.Max(0, (width - Gap) / 2); var rowHeights = new double[3];
foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var childWidth = span == 1 ? cellWidth : width;
child.Measure(new Size(childWidth, availableSize.Height)); rowHeights[row] = Math.Max(rowHeights[row], child.DesiredSize.Height); }
var totalHeight = rowHeights[0] + rowHeights[1] + rowHeights[2] + Gap * 2; Debug.WriteLine( $"[HintGrid.Measure] width={width}, cellWidth={cellWidth}, totalHeight={totalHeight}");
return new Size(width, totalHeight); }
protected override Size ArrangeOverride(Size finalSize) { var count = InternalChildren.Count; if (count == 0) { return finalSize; }
var cellWidth = Math.Max(0, (finalSize.Width - Gap) / 2); var rowHeights = new double[3];
foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var childWidth = span == 1 ? cellWidth : finalSize.Width; rowHeights[row] = Math.Max(rowHeights[row], child.DesiredSize.Height); }
var rowTops = new double[3]; rowTops[0] = 0; rowTops[1] = rowHeights[0] + Gap; rowTops[2] = rowTops[1] + rowHeights[1] + Gap;
var nextColumn = new int[3]; foreach (UIElement child in InternalChildren) { var row = Math.Clamp(LayoutHints.GetRow(child), 0, 2); var span = Math.Clamp(LayoutHints.GetColumnSpan(child), 1, 2); var column = nextColumn[row]; var x = column == 0 ? 0 : cellWidth + Gap; var childWidth = span == 1 ? cellWidth : finalSize.Width;
child.Arrange(new Rect(x, rowTops[row], childWidth, rowHeights[row])); nextColumn[row] += span; }
Debug.WriteLine( $"[HintGrid.Arrange] finalWidth={finalSize.Width}, finalHeight={finalSize.Height}");
return finalSize; } }
|
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 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
| <Window x:Class="WpfAdvancedDependencyPropertyDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:WpfAdvancedDependencyPropertyDemo" Title="WPF 进阶依赖属性实验台" Height="900" Width="1080" WindowStartupLocation="CenterScreen" Background="#F4F7FC" local:ThemeSettings.AccentBrush="#2F6FED"> <Window.Resources> <Style TargetType="Button"> <Setter Property="Padding" Value="14,8"/> <Setter Property="Margin" Value="0,0,10,10"/> <Setter Property="MinWidth" Value="128"/> </Style>
<Style x:Key="PanelBorderStyle" TargetType="Border"> <Setter Property="Background" Value="White"/> <Setter Property="BorderBrush" Value="#DCE5F2"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="CornerRadius" Value="12"/> <Setter Property="Padding" Value="18"/> </Style> </Window.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto"> <Grid Margin="32"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions>
<StackPanel> <TextBlock Text="WPF 依赖属性进阶实验台" FontSize="30" FontWeight="SemiBold" Foreground="#172033"/> <TextBlock Text="依次观察元数据、继承、附加属性定义和应用。" Margin="0,8,0,22" FontSize="15" Foreground="#5D6B82"/> </StackPanel>
<Border Grid.Row="1" Style="{StaticResource PanelBorderStyle}" Margin="0,0,0,18"> <StackPanel> <TextBlock Text="操作区" FontSize="17" FontWeight="SemiBold"/> <WrapPanel Margin="0,14,0,0"> <Button Content="切换主题色" Click="ToggleAccent_Click"/> <Button Content="重设面板间距" Click="ResetGap_Click"/> <Button Content="Row 设为 2" Click="SetRowTwo_Click"/> <Button Content="Row 设为 9" Click="SetRowNine_Click"/> <Button Content="列跨度设为 2" Click="SetColumnSpanTwo_Click"/> <Button Content="尝试非法 Row -1" Click="SetInvalidRow_Click"/> </WrapPanel> </StackPanel> </Border>
<Grid Grid.Row="2"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions>
<Border Style="{StaticResource PanelBorderStyle}" Margin="0,0,0,18"> <StackPanel> <TextBlock Text="1 属性值继承" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="下面四个元素没有分别绑定颜色,它们通过 Inherits 元数据选项继承 AccentBrush。" Margin="0,8,0,16" Foreground="#5D6B82" TextWrapping="Wrap"/>
<Border BorderBrush="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}" BorderThickness="2" CornerRadius="8" Padding="16"> <StackPanel> <TextBlock Text="继承者 A" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="我在 Window 的视觉子树中。" Margin="0,4,0,0"/> </StackPanel> </Border>
<Border BorderBrush="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}" BorderThickness="2" CornerRadius="8" Padding="16" Margin="0,12,0,0"> <StackPanel> <TextBlock Text="继承者 B" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="我没有写自己的 AccentBrush 本地值。" Margin="0,4,0,0"/> </StackPanel> </Border> </StackPanel> </Border>
<Border Grid.Row="1" Style="{StaticResource PanelBorderStyle}"> <StackPanel> <TextBlock Text="2 附加属性应用" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="Row 和 ColumnSpan 定义在 LayoutHints 中,却设置在下面这些普通元素上,由 HintGrid 读取并排列。" Margin="0,8,0,16" Foreground="#5D6B82" TextWrapping="Wrap"/>
<local:HintGrid x:Name="HintGrid" Gap="12" Background="#F7F9FD" local:ThemeSettings.LabelText="HintGrid 的子元素会继承这段附加文本"> <Border local:LayoutHints.Row="0" local:LayoutHints.ColumnSpan="1" Background="#EAF1FF" CornerRadius="10" Padding="18"> <StackPanel> <TextBlock Text="元素 A" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="Row = 0,ColumnSpan = 1" Margin="0,6,0,0"/> </StackPanel> </Border>
<Border local:LayoutHints.Row="0" local:LayoutHints.ColumnSpan="1" Background="#EAF7EF" CornerRadius="10" Padding="18"> <StackPanel> <TextBlock Text="元素 B" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="Row = 0,ColumnSpan = 1" Margin="0,6,0,0"/> </StackPanel> </Border>
<Border local:LayoutHints.Row="1" local:LayoutHints.ColumnSpan="2" Background="#FFF3DF" CornerRadius="10" Padding="18"> <StackPanel> <TextBlock Text="元素 C" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="Row = 1,ColumnSpan = 2" Margin="0,6,0,0"/> </StackPanel> </Border>
<Border local:LayoutHints.Row="2" local:LayoutHints.ColumnSpan="1" Background="#FDE8EF" CornerRadius="10" Padding="18"> <StackPanel> <TextBlock Text="元素 D" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="Row = 2,ColumnSpan = 1" Margin="0,6,0,0"/> </StackPanel> </Border> </local:HintGrid> </StackPanel> </Border> </Grid>
<Border Grid.Row="3" Style="{StaticResource PanelBorderStyle}" Margin="0,18,0,0"> <StackPanel> <TextBlock Text="3 附加属性读取" FontSize="18" FontWeight="SemiBold" Foreground="{Binding Path=(local:ThemeSettings.AccentBrush), RelativeSource={RelativeSource AncestorType=Window}}"/> <TextBlock Text="C# 和 XAML 都通过同一个附加属性标识符读写元素上的值。" Margin="0,8,0,14" Foreground="#5D6B82"/> <TextBlock x:Name="ReadoutText" TextWrapping="Wrap" FontFamily="Consolas" FontSize="15" Foreground="#34425C"/> </StackPanel> </Border>
<TextBlock Grid.Row="4" x:Name="StatusText" Margin="0,16,0,0" TextWrapping="Wrap" Foreground="#5D6B82"/> </Grid> </ScrollViewer> </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
| using System; using System.Windows; using System.Windows.Media;
namespace WpfAdvancedDependencyPropertyDemo;
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); UpdateReadout(); }
private void ToggleAccent_Click(object sender, RoutedEventArgs e) { var currentBrush = ThemeSettings.GetAccentBrush(this); var isBlue = currentBrush is SolidColorBrush brush && brush.Color == Color.FromRgb(47, 111, 237);
ThemeSettings.SetAccentBrush(this, isBlue ? new SolidColorBrush(Color.FromRgb(16, 139, 104)) : new SolidColorBrush(Color.FromRgb(47, 111, 237)));
UpdateStatus("已切换继承属性 AccentBrush。"); }
private void ResetGap_Click(object sender, RoutedEventArgs e) { HintGrid.Gap = 12; UpdateStatus("HintGrid.Gap 已恢复为 12。"); }
private void SetRowTwo_Click(object sender, RoutedEventArgs e) { var firstChild = HintGrid.Children[0]; LayoutHints.SetRow(firstChild, 2); UpdateStatus("第一个元素的基础 Row 为 2。"); }
private void SetRowNine_Click(object sender, RoutedEventArgs e) { var firstChild = HintGrid.Children[0]; LayoutHints.SetRow(firstChild, 9); UpdateStatus("Row 被强制限制到 2,基础值仍保留 9。"); }
private void SetColumnSpanTwo_Click(object sender, RoutedEventArgs e) { var firstChild = HintGrid.Children[0]; LayoutHints.SetColumnSpan(firstChild, 2); UpdateStatus("第一个元素的 ColumnSpan 已设为 2。"); }
private void SetInvalidRow_Click(object sender, RoutedEventArgs e) { try { LayoutHints.SetRow(HintGrid.Children[0], -1); } catch (ArgumentException ex) { UpdateStatus($"验证回调拒绝了 Row = -1:{ex.Message}"); } }
private void UpdateReadout() { var firstChild = HintGrid.Children[0]; ReadoutText.Text = $"ThemeSettings.GetAccentBrush(this) = {ThemeSettings.GetAccentBrush(this)}\n" + $"LayoutHints.GetRow(Element A) = {LayoutHints.GetRow(firstChild)}\n" + $"LayoutHints.GetColumnSpan(Element A) = {LayoutHints.GetColumnSpan(firstChild)}\n" + $"ThemeSettings.GetLabelText(Element A) = \"{ThemeSettings.GetLabelText(firstChild)}\""; }
private void UpdateStatus(string message) { UpdateReadout(); StatusText.Text = message; } }
|
App.xaml.cs
1 2 3 4 5 6 7
| using System.Windows;
namespace WpfAdvancedDependencyPropertyDemo;
public partial class App : Application { }
|
运行检查表
| 检查项 |
预期结果 |
执行 dotnet build |
显示 0 个错误 |
| 启动程序 |
出现“WPF 依赖属性进阶实验台” |
| 点击“切换主题色” |
多个标题和边框同时变色 |
| 点击“Row 设为 2” |
元素 A 移动到第三行 |
| 点击“Row 设为 9” |
读取到的有效行号仍为 2 |
| 点击“列跨度设为 2” |
元素 A 横跨两列 |
| 点击“尝试非法 Row -1” |
显示验证失败提示 |
| 查看读取区 |
能看到 Row、ColumnSpan、LabelText |
| 查看调试输出 |
能看到继承和强制日志 |
快速验证继承
如果需要再次验证继承,可以在 MainWindow 的构造函数中临时加入下面的代码,运行后查看输出窗口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| var root = new Border(); var middle = new StackPanel(); var leaf = new TextBlock(); root.Child = middle; middle.Children.Add(leaf);
ThemeSettings.SetAccentBrush(root, Brushes.OrangeRed); Debug.WriteLine($"[Inheritance] leaf brush = {ThemeSettings.GetAccentBrush(leaf)}");
leaf.SetValue(ThemeSettings.AccentBrushProperty, Brushes.SeaGreen); Debug.WriteLine( $"[Inheritance] local override = {ThemeSettings.GetAccentBrush(leaf)}");
leaf.ClearValue(ThemeSettings.AccentBrushProperty); Debug.WriteLine( $"[Inheritance] after clear = {ThemeSettings.GetAccentBrush(leaf)}");
|
输出:
1 2 3
| [Inheritance] leaf brush = #FFFF4500 [Inheritance] local override = #FF2E8B57 [Inheritance] after clear = #FFFF4500
|
这说明:
- 子元素继承了根元素的
OrangeRed。
- 子元素设置本地
SeaGreen 后覆盖继承值。
- 清除本地值后,继承值恢复。
快速验证附加属性
也可以在窗口中临时加入下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| var probe = new Border();
LayoutHints.SetRow(probe, 2); Debug.WriteLine($"[Attached] row 2 = {LayoutHints.GetRow(probe)}");
LayoutHints.SetRow(probe, 9); Debug.WriteLine($"[Attached] row 9 coerced = {LayoutHints.GetRow(probe)}");
try { LayoutHints.SetRow(probe, -1); } catch (ArgumentException ex) { Debug.WriteLine($"[Attached] row -1 rejected: {ex.GetType().Name}"); }
LayoutHints.SetColumnSpan(probe, 5); Debug.WriteLine( $"[Attached] column span 5 coerced = {LayoutHints.GetColumnSpan(probe)}");
|
输出:
1 2 3 4
| [Attached] row 2 = 2 [Attached] row 9 coerced = 2 [Attached] row -1 rejected: ArgumentException [Attached] column span 5 coerced = 2
|
这说明附加属性的验证、强制和普通依赖属性一致。
常见问题
为什么设置了附加属性,界面没有变化
按顺序检查:
- 是否使用了
RegisterAttached。
- 是否设置了
AffectsParentArrange 或相关布局选项。
- 容器是否真的读取了这个附加属性。
- XAML 是否写成了
local:Type.Property。
- 是否被验证回调拒绝,或者被强制回调调整。
附加属性本身只保存值。没有读取者,它不会自动产生效果。
为什么继承属性没有生效
检查:
- 元数据是否设置了
Inherits。
- 祖先元素是否真的设置了值。
- 子元素是否被更高优先级的值覆盖。
- 元素是否在同一棵树中。
- 绑定路径是否正确使用了括号。
为什么本地值没有覆盖继承值
本地值通常优先于继承值。如果看起来没有覆盖,先检查:
- 是否设置到了错误的元素。
- 是否在清除本地值。
- 是否绑定或样式又写回了旧值。
- 是否读取的是另一个属性。
附加属性能当 ViewModel 用吗
不建议。附加属性适合界面层的外部规则和附加信息。业务数据、状态和命令仍然应该放在 ViewModel 或服务中。
继承和 DataContext 是一回事吗
不是。DataContext 是一个可继承的依赖属性,但属性值继承是更通用的机制。继承解决“值怎样沿树传递”,DataContext 解决“绑定的默认数据上下文是什么”。
为什么附加属性定义成静态类
因为附加属性通常只提供注册、访问方法和静态回调,不需要定义者实例保存状态。值保存在目标元素上,而不是定义者实例上。
附加属性可以设置默认值吗
可以,默认值放在 FrameworkPropertyMetadata 中。和普通依赖属性一样,默认值应该满足验证规则。
附加属性可以被样式设置吗
可以。样式 Setter 可以设置附加属性。注意本地值、样式值和继承值的优先级关系。
附加属性可以做动画吗
可以,但要看属性类型是否支持动画。布局附加属性通常不需要动画。
最后三个综合练习
练习一:增加 Column 附加属性
要求:
- 定义
LayoutHints.Column。
- 验证列号非负。
- 强制限制在 0 到 1。
- 设置
AffectsParentArrange。
- 让
HintGrid 使用列号排列。
完成后,你会把第 3 章和第 4 章串起来。
练习二:做一个可继承的圆角设置
要求:
- 定义
ThemeSettings.CornerRadius 附加属性。
- 设置
Inherits。
- 在窗口上设置一次值。
- 多个
Border 通过绑定读取它。
这个练习可以帮助你理解“继承 + 绑定”的组合。
练习三:给附加属性加只读版本
要求:
- 定义一个只读附加属性。
- 用
RegisterAttachedReadOnly 注册。
- 对外只提供 Get 方法。
- 在控件内部用
SetValue 更新。
提示:只读附加属性和只读依赖属性的思路一致,只是注册方法换成 RegisterAttachedReadOnly。
结语
这一篇把依赖属性从“单个属性”扩展到了 WPF 的整体协作机制:
1 2 3 4 5 6 7 8 9 10 11
| 元数据选项 决定属性变化后通知哪一层
属性值继承 决定值怎样沿元素树向下流动
附加属性 决定怎样把外部规则挂到任意元素上
容器读取附加属性 决定这些信息最终怎样变成布局
|
学习时最重要的不是背 API,而是分清职责:
| 机制 |
负责什么 |
| 依赖属性 |
值由属性系统管理 |
| 元数据 |
默认值、继承和失效通知 |
| 验证 |
拒绝非法值 |
| 强制 |
调整有效值 |
| 变化回调 |
值变化后的同步工作 |
| 附加属性 |
把外部信息附加到元素 |
| 容器 |
解释附加信息并执行布局 |
把这几个角色分开,复杂控件和自定义布局就不再像一团黑盒。