我在做可视化画板的时候,遇到过一个很典型的需求:选中画布上任意一个元素,它周围要出现一圈选中框,四个角还要有可以拖拽的手柄,用来调整大小。一开始我图省事,直接在元素模板里加了一层 Border,结果布局被撑开;又试了 Popup,结果滚动和缩放下坐标对得人头皮发麻。后来才静下心来把 WPF 的装饰器(Adorner)从头到尾啃了一遍,才发现这套机制简直是给这类需求量身定做的。这篇文章就把我从概念到代码再到踩坑的完整过程整理出来,里面有可以直接抄的选中框拖拽 Demo,也有在 MVVM 和自定义校验提示里接装饰器的实践经验,适合刚接触 WPF 但对视觉树已有基本概念的开发者。
WPF 的 Adorner 没有很多教程讲得那么玄乎,它就是一块替某个元素临时"盖在上层"的渲染区域,不改变元素本来的布局和尺寸。但真正用好它需要理解几个隐藏机制,代码写起来也有很多细节容易翻车。下面按我自己的探索顺序,把整个过程拆给各位。
1. 为什么我放弃了模板内覆盖和 Popup,转向 Adorner
先说场景。我要做的不是单控件的花哨效果,而是画布上一堆元素的编辑交互:选中、描边、拖动端点改大小。这种需求在可视化编辑器、报表设计器、流程图工具里特别常见。面对这种需求,正常人第一反应是往被选中元素上叠加一层视觉。
我最早尝试的是直接在控件模板里加覆盖元素。比如给一个 Rectangle 的 Template 里再加一个 Border,通过 Trigger 控制可见性。这种做法在静态效果下没问题,但遇到复杂模板就会很难受:TextBox、ContentControl 这类控件内部有自己的 ControlTemplate,你要改覆盖层就得侵入原模板,把业务样式和框架逻辑搅在一起。而且覆盖层放在元素自身范围内,元素一旦被 Clip 或者本身带 RenderTransform,边角效果都会被卷进去,怎么看都别扭。更麻烦的是,多个元素需要不同覆盖样式时,模板代码会成倍膨胀。
第二次尝试是用 Popup。Popup 确实能不声不响地盖在元素上面,但它本质是一个独立窗口,不参与原视觉树的结构。这意味着它不会跟着父级一起旋转、缩放,ScrollViewer 滚动时位置还得手动同步。我一度在 ScrollChanged 事件里反复计算 Popup 的偏移量,代码越来越脏,拖动几下就会出现明显的闪烁和错位,完全达不到"像元素自己长出来的手柄"那种自然感。
真正把思路拉回正轨的,是发现 WPF 早就内置了一套为这类需求准备的机制:AdornerLayer。它专门解决"既要覆盖显示,又不想动原元素布局"的问题。装饰器会被放在一个名为 AdornerLayer 的透明层里,这个层在视觉树上比被装饰元素高一级,所以画出来的边框和手柄永远在元素上方,又不会把元素挤开。更关键的是,装饰器自身的坐标体系与 AdornedElement 严格对齐,拖拽时不需要人工偏移计算,ScrollViewer 滚动、窗口移动这些场景下它天然跟随。
这里有个容易混淆的认知:很多人以为 Adorner 是必须写一坨继承代码的东西,其实它的价值恰恰在于提供了一种"非侵入式覆盖"的架构。你的原控件不用知道装饰器的存在,装饰器也不需要改控件的模板。什么场景值得用?我的判断标准很简单:如果覆盖物只是临时出现、需要跟随元素、但又不影响布局,那 Adorner 就是正解。比如选中高亮、编辑手柄、校验错误提示、焦点标记、放大镜效果,都属于这类的典型应用。
2. 拆清装饰器体系的三个关键对象:层级、承载与绘制
用 Adorner 之前,必须先把三个对象关系搞清楚,否则写出来的装饰器经常莫名其妙不显示,或者显示位置不对。
第一个是 AdornerLayer。它负责容纳并管理一层专用的装饰区域。WPF 的 Window 根模板里默认放了一个 AdornerDecorator,这个 Decorator 内部就持有 AdornerLayer。你通过AdornerLayer.GetAdornerLayer(element)拿到的是视觉树上距离 element 最近的那个层,而不是全局唯一实例。TextBox 这些控件模板内部也会内嵌一个 AdornerDecorator,所以校验错误模板能正常显示。这里有一个高频坑:如果 element 还没连上视觉树,或者在 Popup 内部却没有手动包 AdornerDecorator,GetAdornerLayer会返回 null,装饰器自然加不上去。这个问题后面专门细讲。
第二个是 Adorner 基类。自定义装饰器必须继承它,构造时传入"被装饰元素" AdornedElement。Adorner 本身是一个 Visual/UIElement,所以可以画内容、接鼠标事件。它的坐标原点在常规情况下就是 AdornedElement 的左上角,不需要自己弹来弹去地定位。重写 OnRender 时直接用 (0,0) 到 RenderSize 之间的矩形画就行了。需要强调的是,Adorner 不是逻辑树里的子节点,它是挂在 AdornerLayer 上的独立元素,所以你在装饰器里访问 DataContext 时拿不到"理所当然"的绑定上下文,这是个典型陷阱。
第三个是 AdornerDecorator。它是对外暴露"是否启用装饰层"的入口容器。如果你把某个 UIElement 放进一个普通 Border,这个 Border 里不会自动生成装饰层。但 Window 模板、部分控件模板默认就有。一般不会手工创建 AdornerDecorator,除非你在做自定义 WindowChrome 或 Popup 内部装饰。另外要注意的是,AdornerLayer 默认会给装饰器套一层裁剪限制,超出 Decorator 可视范围的内容会被裁掉,这也是画布中装饰器超出边界时尾部"凭空消失"的根本原因。
绘制层面,Adorner 跟普通控件一样需要重写 MeasureOverride 和 OnRender。MeasureOverride 决定装饰器的布局尺寸,OnRender 负责画边框、手柄、文字都行。大多数简化版教程只讲 OnRender,不讲 MeasureOverride,导致被装饰元素尺寸变化时装饰器区域不刷新。实际上当元素 Width/Height 改变,装饰器的 MeasureOverride 会被重新调用,如果你直接返回固定的 RenderSize,那尺寸变化后自然能同步。只要记住这点,试写起来会顺很多。
一个元素可以同时挂多个装饰器。比如既要有选中框,又要有错误提示,两个 Adorner 各自独立存在,互不干扰。AdornerLayer.Add 的顺序决定了谁在上层,后 Add 的盖住先 Add 的。这个顺序也常被用来做"选中态上面的额外标记",属于很实用的玩法。
3. 手写一个能拖拽缩放的选中框装饰器(可直接抄的完整代码)
理论说再多,不如一个能跑的 Demo。下面这个例子是我在实际项目里精简出来的:选中一个 FrameworkElement,它会显示蓝色边框和四个角手柄,按住手柄可以拖拽改变元素宽高。代码以"简单可靠"为优先,没有引入额外依赖。
3.1 设计思路与坐标基础
装饰器尺寸始终等于当前元素 RenderSize,这样边框正好贴在元素边缘,手柄固定在四角。拖拽时,鼠标在装饰器坐标系里移动多少像素,元素宽高就增减多少。为什么不用复杂的手柄样式或吸附逻辑?因为要展示核心机制,吸附、最小尺寸、容器边界这类业务逻辑可以等跑通基础版后再一点一点加。
有个前提需要说明:这个 Demo 假定被装饰元素没有 RenderTransform,也没有强制的对齐和 Margin 变化。如果画布本身做了旋转缩放,装饰器不会自动跟随变换,需要额外重写 GetDesiredTransform 或者手动往 DrawingContext 里 Push 矩阵。这属于进阶话题,放到最后一部分展开。
3.2 ResizeAdorner 完整实现
public class ResizeAdorner : Adorner { private enum ResizeDirection { None, LeftTop, RightTop, LeftBottom, RightBottom } private const double HandleSize = 8; private readonly UIElement _adorned; private ResizeDirection _direction = ResizeDirection.None; private Point _startPoint; private double _startWidth; private double _startHeight; private bool _isResizing; private static readonly Brush HandleBrush; private static readonly Pen BorderPen; private static readonly Pen HandlePen; static ResizeAdorner() { var handleBrush = new SolidColorBrush(Color.FromArgb(200, 70, 130, 180)); handleBrush.Freeze(); HandleBrush = handleBrush; var borderPen = new Pen(new SolidColorBrush(Colors.SteelBlue), 1.5); borderPen.Freeze(); BorderPen = borderPen; var handlePen = new Pen(Brushes.White, 1); handlePen.Freeze(); HandlePen = handlePen; } public ResizeAdorner(UIElement adornedElement) : base(adornedElement) { _adorned = adornedElement; IsHitTestVisible = true; } protected override Size MeasureOverride(Size constraint) { return new Size(_adorned.RenderSize.Width, _adorned.RenderSize.Height); } protected override void OnRender(DrawingContext dc) { Rect elementRect = new Rect(0, 0, RenderSize.Width, RenderSize.Height); dc.DrawRectangle(null, BorderPen, elementRect); foreach (ResizeDirection direction in Enum.GetValues(typeof(ResizeDirection))) { if (direction != ResizeDirection.None) { dc.DrawRectangle(HandleBrush, HandlePen, GetHandleRect(direction)); } } } private Rect GetHandleRect(ResizeDirection direction) { double w = RenderSize.Width; double h = RenderSize.Height; switch (direction) { case ResizeDirection.LeftTop: return new Rect(0, 0, HandleSize, HandleSize); case ResizeDirection.RightTop: return new Rect(w - HandleSize, 0, HandleSize, HandleSize); case ResizeDirection.LeftBottom: return new Rect(0, h - HandleSize, HandleSize, HandleSize); case ResizeDirection.RightBottom: return new Rect(w - HandleSize, h - HandleSize, HandleSize, HandleSize); default: return Rect.Empty; } } private ResizeDirection HitTestHandle(Point point) { foreach (ResizeDirection direction in Enum.GetValues(typeof(ResizeDirection))) { if (direction != ResizeDirection.None && GetHandleRect(direction).Contains(point)) { return direction; } } return ResizeDirection.None; } protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { _direction = HitTestHandle(e.GetPosition(this)); if (_direction == ResizeDirection.None) { return; } _isResizing = true; _startPoint = e.GetPosition(this); _startWidth = _adorned.RenderSize.Width; _startHeight = _adorned.RenderSize.Height; Mouse.Capture(this); e.Handled = true; } protected override void OnMouseMove(MouseEventArgs e) { if (_isResizing && e.LeftButton == MouseButtonState.Pressed) { Point current = e.GetPosition(this); double deltaX = current.X - _startPoint.X; double deltaY = current.Y - _startPoint.Y; double newWidth = _startWidth; double newHeight = _startHeight; switch (_direction) { case ResizeDirection.RightTop: newWidth = _startWidth + deltaX; newHeight = _startHeight - deltaY; break; case ResizeDirection.RightBottom: newWidth = _startWidth + deltaX; newHeight = _startHeight + deltaY; break; case ResizeDirection.LeftTop: newWidth = _startWidth - deltaX; newHeight = _startHeight - deltaY; break; case ResizeDirection.LeftBottom: newWidth = _startWidth - deltaX; newHeight = _startHeight + deltaY; break; } if (_adorned is FrameworkElement element) { element.Width = Math.Max(20, newWidth); element.Height = Math.Max(20, newHeight); } InvalidateVisual(); e.Handled = true; return; } Cursor = HitTestHandle(e.GetPosition(this)) switch { ResizeDirection.LeftTop or ResizeDirection.RightBottom => Cursors.SizeNWSE, ResizeDirection.RightTop or ResizeDirection.LeftBottom => Cursors.SizeNESW, _ => Cursors.Arrow }; } protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { if (_isResizing) { _isResizing = false; Mouse.Capture(this, CaptureMode.None); e.Handled = true; } } }这里有几个细节值得解释。
拖拽时用的是e.GetPosition(this),因为装饰器坐标原点等于元素左上角,所以鼠标移动量直接对应元素尺寸变化量,完全不需要换算成屏幕坐标或容器坐标。Mouse.Capture(this)保证鼠标移到装饰器外面时事件依然能送达,否则拖到一半松开、或者拖出矩形范围就断触了。
IsHitTestVisible默认其实是 true,交互型装饰器必须保持 true。但这也带来了一个问题:整个装饰器占用的矩形区域都会拦截鼠标,即使没有画手柄的空白区,也会挡住底下元素的事件。对选中框场景来说,选中状态下挡住底层内容影响不大。如果你希望"手柄能拖,空白区域透明穿透",后面会讲更精细的 HitTestCore 方案。
代码里没有处理"拖左手柄时位置跟随右边界不动"的问题,这是刻意简化的。真实项目中,元素通常是 Canvas 的 child,要保证右手柄不动,就得同时改 Canvas.Left。这类逻辑跟具体容器强相关,放到项目里按需补。
3.3 挂载与使用方式
拿到装饰器之后,挂载代码很简单:
AdornerLayer layer = AdornerLayer.GetAdornerLayer(target); if (layer != null) { layer.Add(new ResizeAdorner(target)); }如果 target 是 XAML 里的一个 Button,在窗口 Loaded 之后再执行这段代码,就能看到按钮四角出现蓝色手柄。要移除装饰器,调用layer.Remove(adorner)即可。layer.GetAdorners(target)可以拿到目标元素上的所有装饰器数组,这个 API 在调试和切换模式时非常有用。
4. 装饰器在数据绑定、校验和 MVVM 场景里的接入姿势
装饰器直接写在代码里是一回事,要跟 MVVM、数据绑定配合则是另一回事。因为 Adorner 不属于逻辑树,它和普通控件"拿到 DataContext 就能 Binding"的体验完全不同,很多初学者在第一步就被卡住了。
4.1 先认识模板里的 Validation.ErrorTemplate
WPF 自带的表单校验错误提示,底层就是 Adorner 实现的。你在 TextBox 的 Validation.ErrorTemplate 里写的 ControlTemplate,会被放到一个专门为错误提示准备的 AdornerLayer 中。看下面这段自定义错误模板:
<Style TargetType="TextBox"> <Setter Property="Validation.ErrorTemplate"> <Setter.Value> <ControlTemplate> <DockPanel> <Border Margin="2" Background="#FFFFE0E0" BorderBrush="#FFFF0000" BorderThickness="1" CornerRadius="3"> <AdornedElementPlaceholder /> </Border> </DockPanel> </ControlTemplate> </Setter.Value> </Setter> <Style.Triggers> <Trigger Property="Validation.HasError" Value="True"> <Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" /> </Trigger> </Style.Triggers> </Style>AdornedElementPlaceholder是一个特殊标记,它会在装饰器内部留出原控件的位置。也就是说,模板里画的边框天然贴合 TextBox 周围,不掉位、不遮挡。想明白这个机制后,你对"装饰器能不能做校验提示"的疑问基本就消失了,因为 WPF 框架自己就是这么用的。
4.2 用附加属性把装饰器和 ViewModel 绑定起来
实际开发中,我更推荐用附加属性来控制装饰器的开关,而不是直接在 View 的代码里到处调用 Add/Remove。好处很明显:可以在 XAML 里用 Binding 把附加属性值绑定到 ViewModel 属性上,选中状态逻辑完全收敛到业务层。
public static class AdornerAttach { public static readonly DependencyProperty ShowResizeAdornerProperty = DependencyProperty.RegisterAttached( "ShowResizeAdorner", typeof(bool), typeof(AdornerAttach), new PropertyMetadata(false, OnShowResizeAdornerChanged)); public static void SetShowResizeAdorner(DependencyObject element, bool value) { element.SetValue(ShowResizeAdornerProperty, value); } public static bool GetShowResizeAdorner(DependencyObject element) { return (bool)element.GetValue(ShowResizeAdornerProperty); } private static void OnShowResizeAdornerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not UIElement uiElement) { return; } if ((bool)e.NewValue) { uiElement.Dispatcher.BeginInvoke(new Action(() => { AdornerLayer layer = AdornerLayer.GetAdornerLayer(uiElement); if (layer != null) { layer.Add(new ResizeAdorner(uiElement)); } }), System.Windows.Threading.DispatcherPriority.Loaded); } else { AdornerLayer layer = AdornerLayer.GetAdornerLayer(uiElement); if (layer != null) { Adorner[] adorners = layer.GetAdorners(uiElement); if (adorners != null) { foreach (Adorner adorner in adorners) { if (adorner is ResizeAdorner) { layer.Remove(adorner); } } } } } } }为什么要用Dispatcher.BeginInvoke包一层?因为附加属性经常在元素还没完全 Loaded 时就被设置,这时候GetAdornerLayer极大概率返回 null。把添加动作延后到 Dispatcher 的 Loaded 优先级,能避开大量"明明设置了却看不见"的诡异现象。
XAML 里的用法长这样:
<Rectangle Width="100" Height="80" Fill="LightBlue" local:AdornerAttach.ShowResizeAdorner="{Binding IsSelected}" />当 ViewModel 的 IsSelected 变为 true,装饰器自动出现;变为 false,自动消失。这套模式也可以推广到高亮标记、水印提示等场景,只要写不同的 Adorner 类,附加属性逻辑完全复用。
4.3 装饰器内部怎么拿命令和数据
有朋友问过:我想在装饰器里放一个删除按钮,点击时触发 ViewModel 的 DeleteCommand,是不是绑不上?
确实绑不上,因为装饰器不在逻辑树里,DataContext 不会自动传递。但办法很多,我推荐两种。
一种是在构造装饰器时直接传入命令对象:
public class ActionAdorner : Adorner { private readonly ICommand _command; public ActionAdorner(UIElement adornedElement, ICommand command) : base(adornedElement) { _command = command; } }构造处由 View 代码或附加属性统一控制,比如new ActionAdorner(uiElement, ((FrameworkElement)uiElement).DataContext as IViewModel). 这种方式依赖外部显式传参,能跑,但耦合会高一些。
另一种更灵活:在装饰器里通过 AdornedElement 找到它的 DataContext,然后从那上面取 Command。算是一个通用的兜底方案:
if (AdornedElement is FrameworkElement element && element.DataContext is IMyViewModel vm) { vm.DeleteCommand.Execute(null); }这个方案不侵入构造函数,装饰器内部就能完成数据桥接,在加上 Prism 或 MVVM Toolkit 时依然有效。要注意的是,拿 DataContext 需要在事件动作触发时再做,不要放在构造函数里,因为那时 DataContext 可能还未初始化。
5. 实战里高频踩中的坑,以及调装饰器时的排查套路
最后这部分是压箱底的经验。装饰器代码量不大,但遇到的怪问题不少,我把踩过的高频坑按症状、原因、解法列出来,各位排查时可以直接对号入座。
5.1 装饰器没显示,先查 GetAdornerLayer 的返回值
最常见的就是拿到了 null。可能的原因有三个:元素还没加载完成,就尝试获取装饰层;元素在 Popup 内部,而 Popup 的视觉树里默认没有 AdornerDecorator;模板最外层没有装饰层。解法也明确:等 Loaded 事件之后再添加,或者把 Popup 内容包进一个 AdornerDecorator 里。用 Snoop 或者 VisualStudio 的实时可视化树,选中目标元素后看属性面板里的 AdornerLayer,能直观确认层是否存在。
5.2 装饰器内容被裁掉,检查 AdornerDecorator 的裁剪范围
默认的 AdornerLayer 会尽量保证装饰器不超出 AdornerDecorator 的可视区域。如果你装饰器里画了一个很大的放大镜或者拖尾效果,边缘会突然被切掉,像是"画到一半消失了"。遇到这种情况,先去确认元素外层有没有 ScrollViewer 或者带 ClipToBounds=true 的容器。画布类应用经常要给 AdornerDecorator 设置 ClipToBounds=false,或者干脆自定义一层不受裁剪的 AdornerLayer 放在最外层。这个坑在画布元素拖着手柄往远角移动时尤其明显。
5.3 OnRender 不刷新,记得 InvalidateVisual
装饰器不是数据绑定控件,你改了内部字段后,WPF 不知道你换了内容。比如拖拽手柄改变边框颜色,直接设置字段是不起作用的,必须调用 InvalidateVisual 强制重绘。在上一节的 ResizeAdorner 里,我在 OnMouseMove 中每次都调用了一次 InvalidateVisual,确保边框紧贴新尺寸。忘了这个调用的话,拖拽过程中边框会"追不上"手柄,看起来断断续续。
5.4 装饰器挡住了底层元素,做精细穿透要用 HitTestCore
我在 3.2 里提到过这个问题。实际上 WPF 对 Adorner 的命中测试是按整个布局矩形来判定的,不是你画了手柄的地方才可命中。要做到"手柄区域可点击,空白区域穿透",标准做法是重写 HitTestCore:
protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) { Point point = hitTestParameters.HitPoint; if (HitTestHandle(point) != ResizeDirection.None) { return new PointHitTestResult(this, hitTestParameters); } return null; }这个重写会让装饰器只有在手柄矩形范围内参与命中测试,手柄之外鼠标事件自然落到底层元素上。代码并不复杂,强烈建议交互型装饰器都加上,不然拖拽完别的元素还得忍受"点不穿"的副作用。
5.5 旋转缩放场景下装饰器不跟手,用 GetDesiredTransform 同步矩阵
前面一直铺垫的进阶问题在这里解决。默认 Adorner 坐标是跟 AdornedElement 对齐,但如果你给元素设置了 RotateTransform 或 ScaleTransform,装饰器不会自动跟着转。需要重写 GetDesiredTransform,把元素的变换矩阵推给装饰器:
public override GeneralTransform GetDesiredTransform(GeneralTransform transform) { if (AdornedElement is FrameworkElement element && element.RenderTransform != null) { return element.RenderTransform; } return base.GetDesiredTransform(transform); }这样装饰器会随元素旋转、缩放。但需要注意,拖拽计算的坐标要反过来做矩阵逆变换,否则鼠标位移和宽高变化方向会对不上。这块逻辑比较复杂,项目里用的时候最好用 Matrix 做一次坐标转换,不要直接加减像素。
5.6 性能观察:装饰器不是无限量的廉价特效
装饰器本质是额外的 Visual,数量多了照样吃渲染性能。我的经验是:同时挂十几二十个问题不大,但上百个元素同时开装饰器,尤其是每个都做动画或高频刷新,UI 线程会明显吃紧。优化手段有三个。第一,把所有用到的 Brush 和 Pen 做成静态字段并 Freeze,不要在 OnRender 里 new 资源。第二,高频绘制内容尽量重绘最小范围,能用 DrawingGroup 缓存的就缓存。第三,装饰器不需要交互时把 IsHitTestVisible 设为 false,减少命中测试成本。做到这三点,常见的标注场景基本不会卡。
说实话,Adorner 是 WPF 里少见的"能一劳永逸解决问题"的机制,但它的学习曲线确实偏陡。我个人的体会是,不要一上来就想做复杂的设计器辅助工具,而是先拿一个简单的选中框跑通全链路:创建装饰器、挂到 AdornerLayer、处理一次拖拽、再手动移除。把这一圈走顺了,再上 MVVM、变换同步、命中穿透这些进阶玩法,会发现之前踩的坑都能对号入座。
最后分享一个小技巧:你可以在装饰器里用 VisualCollection 塞一个真实的 UIElement,比如在选中框上直接放一个 Button 作为删除入口。这种做法的好处是能继续用样式和模板,代码比纯 OnRender 画图好维护,但要注意没有逻辑树的绑定继承,DataContext 依然需要手动传递。先把这个思路留在脑子里,等基础装饰器用熟了,自然就知道什么时候该用它了。