ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WPF Adorner装饰器实战:从选中框拖拽到MVVM校验提示

WPF Adorner装饰器实战:从选中框拖拽到MVVM校验提示 我在做可视化画板的时候遇到过一个很典型的需求选中画布上任意一个元素它周围要出现一圈选中框四个角还要有可以拖拽的手柄用来调整大小。一开始我图省事直接在元素模板里加了一层 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 内部却没有手动包 AdornerDecoratorGetAdornerLayer会返回 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.ErrorTemplateWPF 自带的表单校验错误提示底层就是 Adorner 实现的。你在 TextBox 的 Validation.ErrorTemplate 里写的 ControlTemplate会被放到一个专门为错误提示准备的 AdornerLayer 中。看下面这段自定义错误模板Style TargetTypeTextBox Setter PropertyValidation.ErrorTemplate Setter.Value ControlTemplate DockPanel Border Margin2 Background#FFFFE0E0 BorderBrush#FFFF0000 BorderThickness1 CornerRadius3 AdornedElementPlaceholder / /Border /DockPanel /ControlTemplate /Setter.Value /Setter Style.Triggers Trigger PropertyValidation.HasError ValueTrue Setter PropertyToolTip Value{Binding RelativeSource{RelativeSource Self}, Path(Validation.Errors)[0].ErrorContent} / /Trigger /Style.Triggers /StyleAdornedElementPlaceholder是一个特殊标记它会在装饰器内部留出原控件的位置。也就是说模板里画的边框天然贴合 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 Width100 Height80 FillLightBlue 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 或者带 ClipToBoundstrue 的容器。画布类应用经常要给 AdornerDecorator 设置 ClipToBoundsfalse或者干脆自定义一层不受裁剪的 AdornerLayer 放在最外层。这个坑在画布元素拖着手柄往远角移动时尤其明显。5.3 OnRender 不刷新记得 InvalidateVisual装饰器不是数据绑定控件你改了内部字段后WPF 不知道你换了内容。比如拖拽手柄改变边框颜色直接设置字段是不起作用的必须调用 InvalidateVisual 强制重绘。在上一节的 ResizeAdorner 里我在 OnMouseMove 中每次都调用了一次 InvalidateVisual确保边框紧贴新尺寸。忘了这个调用的话拖拽过程中边框会追不上手柄看起来断断续续。5.4 装饰器挡住了底层元素做精细穿透要用 HitTestCore我在 3.2 里提到过这个问题。实际上 WPF 对 Adorner 的命中测试是按整个布局矩形来判定的不是你画了手柄的地方才可命中。要做到手柄区域可点击空白区域穿透标准做法是重写 HitTestCoreprotected 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 依然需要手动传递。先把这个思路留在脑子里等基础装饰器用熟了自然就知道什么时候该用它了。
返回列表