WPF Adorner
大约 11 分钟约 3196 字
WPF Adorner
简介
WPF Adorner(装饰器)是绑定在 UIElement 上的自定义渲染层,用于在不修改原始控件的前提下叠加视觉效果。常见用途包括拖拽手柄、焦点框、水印、验证错误提示和缩放控制点。在工业上位机中,Adorner 常用于设计器界面中的控件选中框、管道编辑器的连接点、仪表盘的刻度标注等场景。
Adorner 在 WPF 可视树中的位置
AdornerLayer(装饰器图层)
└── Adorner(装饰器,如水印、验证提示)
└── AdornedElement(被装饰的元素,如 TextBox)AdornerLayer 是一个特殊的渲染层,它位于被装饰元素与其父元素之间。当元素的位置或大小发生变化时,Adorner 会自动跟随更新。
Adorner 的核心特点
┌─────────────────────────────────────┐
│ AdornerLayer │
│ ┌───────────────────────────────┐ │
│ │ Adorner │ │
│ │ (水印、验证提示、拖拽手柄) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ AdornedElement │ │
│ │ (原始控件:TextBox 等) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘特点
实现
水印装饰器
基础水印实现
// ========== 水印装饰器 ==========
/// <summary>
/// 文本水印装饰器 — 在 TextBox 为空时显示提示文字
/// </summary>
public class WatermarkAdorner : Adorner
{
private readonly string _text;
private readonly Brush _brush;
private readonly double _fontSize;
public WatermarkAdorner(
UIElement adornedElement,
string text,
Brush? brush = null,
double fontSize = 14)
: base(adornedElement)
{
_text = text;
_brush = brush ?? new SolidColorBrush(Color.FromArgb(100, 128, 128, 128));
_fontSize = fontSize;
IsHitTestVisible = false; // 不拦截鼠标事件,让输入事件穿透到被装饰元素
}
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
// 只有在被装饰元素没有文本时才显示水印
if (AdornedElement is TextBox textBox && !string.IsNullOrEmpty(textBox.Text))
return;
var formattedText = new FormattedText(
_text,
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Microsoft YaHei"),
_fontSize,
_brush,
VisualTreeHelper.GetDpi(this).PixelsPerDip);
// 水印定位:左侧垂直居中
double x = 6;
double y = (AdornedElement.RenderSize.Height - formattedText.Height) / 2;
drawingContext.DrawText(formattedText, new Point(x, y));
}
}附加属性方式使用
// ========== 水印附加属性 ==========
/// <summary>
/// 水印附加属性 — 通过 XAML 设置水印文本
/// </summary>
public static class WatermarkHelper
{
public static readonly DependencyProperty WatermarkTextProperty =
DependencyProperty.RegisterAttached(
"WatermarkText", typeof(string), typeof(WatermarkHelper),
new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender,
OnWatermarkTextChanged));
public static string GetWatermarkText(DependencyObject obj)
=> (string)obj.GetValue(WatermarkTextProperty);
public static void SetWatermarkText(DependencyObject obj, string value)
=> obj.SetValue(WatermarkTextProperty, value);
private static void OnWatermarkTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TextBox textBox) return;
// 移除旧的事件处理器
textBox.Loaded -= TextBox_Loaded;
textBox.TextChanged -= TextBox_TextChanged;
if (!string.IsNullOrEmpty((string)e.NewValue))
{
textBox.Loaded += TextBox_Loaded;
textBox.TextChanged += TextBox_TextChanged;
}
}
private static void TextBox_Loaded(object sender, RoutedEventArgs e)
{
if (sender is TextBox textBox)
UpdateWatermark(textBox);
}
private static void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox)
UpdateWatermark(textBox);
}
private static void UpdateWatermark(TextBox textBox)
{
var layer = AdornerLayer.GetAdornerLayer(textBox);
if (layer == null) return;
// 移除已有的水印装饰器
var adorners = layer.GetAdorners(textBox);
if (adorners != null)
{
foreach (var adorner in adorners.OfType<WatermarkAdorner>().ToList())
layer.Remove(adorner);
}
// 文本为空时添加水印
if (string.IsNullOrEmpty(textBox.Text))
{
var watermarkText = GetWatermarkText(textBox);
if (!string.IsNullOrEmpty(watermarkText))
layer.Add(new WatermarkAdorner(textBox, watermarkText));
}
}
}
// XAML 使用
/*
<TextBox Text="{Binding Username}"
helper:WatermarkHelper.WatermarkText="请输入用户名"/>
*/拖拽缩放手柄
// ========== 拖拽缩放装饰器 ==========
/// <summary>
/// 缩放装饰器 — 在元素四周显示 8 个拖拽手柄
/// 适用于设计器、编辑器场景
/// </summary>
public class ResizeAdorner : Adorner
{
private readonly Thumb[] _thumbs = new Thumb[8]; // 8个方向的手柄
private readonly VisualCollection _visuals;
private readonly double _handleSize = 8;
// 手柄方向
private enum HandlePosition { TopLeft, TopCenter, TopRight, RightCenter, BottomRight, BottomCenter, BottomLeft, LeftCenter }
public ResizeAdorner(UIElement adornedElement) : base(adornedElement)
{
_visuals = new VisualCollection(this);
// 创建手柄样式
var thumbStyle = new Style(typeof(Thumb));
thumbStyle.Setters.Add(new Setter(Thumb.WidthProperty, _handleSize));
thumbStyle.Setters.Add(new Setter(Thumb.HeightProperty, _handleSize));
thumbStyle.Setters.Add(new Setter(Thumb.BackgroundProperty, Brushes.White));
thumbStyle.Setters.Add(new Setter(Thumb.BorderBrushProperty, Brushes.DodgerBlue));
thumbStyle.Setters.Add(new Setter(Thumb.BorderThicknessProperty, new Thickness(1)));
thumbStyle.Setters.Add(new Setter(Thumb.CursorProperty, Cursors.SizeAll));
thumbStyle.Setters.Add(new Setter(Thumb.TemplateProperty,
(DataTemplate)Application.Current.FindResource("ResizeHandleTemplate")));
// 创建 8 个手柄
var cursors = new[]
{
Cursors.SizeNWSE, Cursors.SizeNS, Cursors.SizeNESW, Cursors.SizeWE,
Cursors.SizeNWSE, Cursors.SizeNS, Cursors.SizeNESW, Cursors.SizeWE
};
for (int i = 0; i < 8; i++)
{
_thumbs[i] = new Thumb
{
Style = thumbStyle,
Cursor = cursors[i],
Tag = (HandlePosition)i
};
_thumbs[i].DragDelta += OnThumbDragDelta;
_visuals.Add(_thumbs[i]);
}
}
protected override Size ArrangeOverride(Size finalSize)
{
double w = AdornedElement.RenderSize.Width;
double h = AdornedElement.RenderSize.Height;
double s = _handleSize / 2;
// 安排 8 个手柄的位置(四角 + 四边中点)
_thumbs[(int)HandlePosition.TopLeft].Arrange(new Rect(-s, -s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.TopCenter].Arrange(new Rect(w / 2 - s, -s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.TopRight].Arrange(new Rect(w - s, -s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.RightCenter].Arrange(new Rect(w - s, h / 2 - s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.BottomRight].Arrange(new Rect(w - s, h - s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.BottomCenter].Arrange(new Rect(w / 2 - s, h - s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.BottomLeft].Arrange(new Rect(-s, h - s, _handleSize, _handleSize));
_thumbs[(int)HandlePosition.LeftCenter].Arrange(new Rect(-s, h / 2 - s, _handleSize, _handleSize));
return finalSize;
}
private void OnThumbDragDelta(object sender, DragDeltaEventArgs e)
{
if (AdornedElement is not FrameworkElement fe) return;
var position = (HandlePosition)((Thumb)sender).Tag;
double minWidth = 20;
double minHeight = 20;
switch (position)
{
case HandlePosition.BottomRight:
fe.Width = Math.Max(minWidth, fe.Width + e.HorizontalChange);
fe.Height = Math.Max(minHeight, fe.Height + e.VerticalChange);
break;
case HandlePosition.BottomLeft:
fe.Width = Math.Max(minWidth, fe.Width - e.HorizontalChange);
fe.Height = Math.Max(minHeight, fe.Height + e.VerticalChange);
break;
case HandlePosition.TopRight:
fe.Width = Math.Max(minWidth, fe.Width + e.HorizontalChange);
fe.Height = Math.Max(minHeight, fe.Height - e.VerticalChange);
break;
case HandlePosition.TopLeft:
fe.Width = Math.Max(minWidth, fe.Width - e.HorizontalChange);
fe.Height = Math.Max(minHeight, fe.Height - e.VerticalChange);
break;
case HandlePosition.TopCenter:
case HandlePosition.BottomCenter:
fe.Height = Math.Max(minHeight,
position == HandlePosition.BottomCenter
? fe.Height + e.VerticalChange
: fe.Height - e.VerticalChange);
break;
case HandlePosition.LeftCenter:
case HandlePosition.RightCenter:
fe.Width = Math.Max(minWidth,
position == HandlePosition.RightCenter
? fe.Width + e.HorizontalChange
: fe.Width - e.HorizontalChange);
break;
}
}
protected override Visual GetVisualChild(int index) => _visuals[index];
protected override int VisualChildrenCount => _visuals.Count;
}选中框装饰器
// ========== 选中框装饰器 — 设计器中的元素选中效果 ==========
/// <summary>
/// 选中框装饰器 — 显示虚线边框和调整手柄
/// </summary>
public class SelectionAdorner : Adorner
{
private readonly Pen _selectionPen;
private readonly Brush _selectionBrush;
public bool ShowHandles { get; set; } = true;
public double Padding { get; set; } = 4;
public SelectionAdorner(UIElement adornedElement)
: base(adornedElement)
{
_selectionPen = new Pen(Brushes.DodgerBlue, 1.5)
{
DashStyle = new DashStyle(new double[] { 4, 2 }, 0)
};
_selectionBrush = new SolidColorBrush(Color.FromArgb(20, 0, 120, 215));
IsHitTestVisible = false;
}
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
var size = AdornedElement.RenderSize;
var p = Padding;
// 半透明背景
dc.DrawRectangle(_selectionBrush, null, new Rect(-p, -p, size.Width + 2 * p, size.Height + 2 * p));
// 虚线选中框
dc.DrawRectangle(null, _selectionPen, new Rect(-p, -p, size.Width + 2 * p, size.Height + 2 * p));
// 角落小方块(选中指示)
if (ShowHandles)
{
double handleSize = 6;
var handleBrush = Brushes.DodgerBlue;
var positions = new[]
{
new Point(-p - handleSize / 2, -p - handleSize / 2),
new Point(size.Width + p - handleSize / 2, -p - handleSize / 2),
new Point(-p - handleSize / 2, size.Height + p - handleSize / 2),
new Point(size.Width + p - handleSize / 2, size.Height + p - handleSize / 2)
};
foreach (var pos in positions)
{
dc.DrawRectangle(handleBrush, null,
new Rect(pos.X, pos.Y, handleSize, handleSize));
}
}
}
}验证错误提示装饰器
// ========== 验证错误装饰器 ==========
/// <summary>
/// 验证错误提示装饰器 — 在控件下方显示红色错误消息
/// </summary>
public class ValidationErrorAdorner : Adorner
{
private readonly string _errorMessage;
private readonly Pen _borderPen;
private readonly Brush _background;
private readonly Brush _textBrush;
private readonly double _arrowSize = 6;
public ValidationErrorAdorner(UIElement adornedElement, string errorMessage)
: base(adornedElement)
{
_errorMessage = errorMessage;
_borderPen = new Pen(Brushes.Red, 1.5) { DashStyle = DashStyles.Dash };
_background = new SolidColorBrush(Color.FromArgb(240, 255, 245, 245));
_textBrush = new SolidColorBrush(Colors.Red);
IsHitTestVisible = false;
}
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
var size = AdornedElement.RenderSize;
// 红色虚线边框
dc.DrawRectangle(null, _borderPen, new Rect(0, 0, size.Width, size.Height));
// 错误提示文字
var text = new FormattedText(
_errorMessage,
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Microsoft YaHei"),
12,
_textBrush,
VisualTreeHelper.GetDpi(this).PixelsPerDip);
double tipX = 2;
double tipY = size.Height + 4;
double tipWidth = text.Width + 12;
double tipHeight = text.Height + 8;
// 气泡背景
dc.DrawRectangle(_background, new Pen(Brushes.Red, 0.5),
new Rect(tipX, tipY, tipWidth, tipHeight));
// 小三角箭头
var arrowGeometry = new StreamGeometry();
using (var ctx = arrowGeometry.Open())
{
ctx.BeginFigure(new Point(tipX + 8, tipY), true);
ctx.LineTo(new Point(tipX + 14, tipY - _arrowSize));
ctx.LineTo(new Point(tipX + 20, tipY), true);
}
dc.DrawGeometry(_background, new Pen(Brushes.Red, 0.5), arrowGeometry);
// 错误文字
dc.DrawText(text, new Point(tipX + 6, tipY + 4));
}
}
// ========== 验证装饰器附加属性 ==========
public static class ValidationAdornerHelper
{
public static readonly DependencyProperty HasErrorProperty =
DependencyProperty.RegisterAttached(
"HasError", typeof(bool), typeof(ValidationAdornerHelper),
new FrameworkPropertyMetadata(false, OnHasErrorChanged));
public static readonly DependencyProperty ErrorMessageProperty =
DependencyProperty.RegisterAttached(
"ErrorMessage", typeof(string), typeof(ValidationAdornerHelper),
new FrameworkPropertyMetadata(""));
public static bool GetHasError(DependencyObject obj)
=> (bool)obj.GetValue(HasErrorProperty);
public static void SetHasError(DependencyObject obj, bool value)
=> obj.SetValue(HasErrorProperty, value);
public static string GetErrorMessage(DependencyObject obj)
=> (string)obj.GetValue(ErrorMessageProperty);
public static void SetErrorMessage(DependencyObject obj, string value)
=> obj.SetValue(ErrorMessageProperty, value);
private static void OnHasErrorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement element) return;
element.Loaded -= OnElementLoaded;
if ((bool)e.NewValue)
element.Loaded += OnElementLoaded;
}
private static void OnElementLoaded(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement element) return;
var layer = AdornerLayer.GetAdornerLayer(element);
if (layer == null) return;
// 移除旧装饰器
var adorners = layer.GetAdorners(element);
if (adorners != null)
{
foreach (var adorner in adorners.OfType<ValidationErrorAdorner>().ToList())
layer.Remove(adorner);
}
if (GetHasError(element))
{
var message = GetErrorMessage(element);
layer.Add(new ValidationErrorAdorner(element, message));
}
}
}装饰器管理器
// ========== 装饰器生命周期管理 ==========
/// <summary>
/// 装饰器管理器 — 统一管理装饰器的添加和移除
/// 避免装饰器泄漏和残留
/// </summary>
public static class AdornerManager
{
private static readonly ConditionalWeakTable<UIElement, List<Adorner>> _trackedAdorners = new();
/// <summary>
/// 添加装饰器(自动跟踪生命周期)
/// </summary>
public static T Add<T>(UIElement adornedElement, T adorner) where T : Adorner
{
var layer = AdornerLayer.GetAdornerLayer(adornedElement);
if (layer == null) return adorner;
layer.Add(adorner);
// 跟踪装饰器
_trackedAdorners.GetOrCreateValue(adornedElement).Add(adorner);
return adorner;
}
/// <summary>
/// 移除元素上的所有装饰器
/// </summary>
public static void RemoveAll(UIElement adornedElement)
{
var layer = AdornerLayer.GetAdornerLayer(adornedElement);
if (layer == null) return;
if (_trackedAdorners.TryGetValue(adornedElement, out var adorners))
{
foreach (var adorner in adorners)
layer.Remove(adorner);
adorners.Clear();
}
}
/// <summary>
/// 移除特定类型的装饰器
/// </summary>
public static void Remove<T>(UIElement adornedElement) where T : Adorner
{
var layer = AdornerLayer.GetAdornerLayer(adornedElement);
if (layer == null) return;
var adorners = layer.GetAdorners(adornedElement);
if (adorners != null)
{
foreach (var adorner in adorners.OfType<T>().ToList())
layer.Remove(adorner);
}
if (_trackedAdorners.TryGetValue(adornedElement, out var tracked))
{
tracked.RemoveAll(a => a is T);
}
}
}设计器场景中的装饰器
// ========== 设计器元素装饰器组合 ==========
/// <summary>
/// 设计器元素管理器 — 统一管理选中、拖拽和缩放装饰器
/// </summary>
public class DesignerAdornerManager
{
private readonly Canvas _designCanvas;
private UIElement? _selectedElement;
private ResizeAdorner? _resizeAdorner;
private SelectionAdorner? _selectionAdorner;
public DesignerAdornerManager(Canvas designCanvas)
{
_designCanvas = designCanvas;
_designCanvas.MouseDown += OnCanvasMouseDown;
}
public void SelectElement(UIElement element)
{
// 取消之前的选中
DeselectElement();
_selectedElement = element;
// 添加选中框
var layer = AdornerLayer.GetAdornerLayer(element);
if (layer == null) return;
_selectionAdorner = new SelectionAdorner(element);
_resizeAdorner = new ResizeAdorner(element);
layer.Add(_selectionAdorner);
layer.Add(_resizeAdorner);
}
public void DeselectElement()
{
if (_selectedElement == null) return;
var layer = AdornerLayer.GetAdornerLayer(_selectedElement);
if (layer != null)
{
if (_selectionAdorner != null) layer.Remove(_selectionAdorner);
if (_resizeAdorner != null) layer.Remove(_resizeAdorner);
}
_selectionAdorner = null;
_resizeAdorner = null;
_selectedElement = null;
}
private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e)
{
var hitTest = _designCanvas.InputHitTest(e.GetPosition(_designCanvas));
if (hitTest == _designCanvas)
{
DeselectElement();
}
}
}优点
缺点
总结
Adorner 通过 AdornerLayer 在目标元素上方叠加自定义视觉效果,适合水印、拖拽手柄、验证提示和选中框等场景。使用时注意在合适的生命周期(Loaded 事件)添加和移除装饰器,避免内存泄漏。推荐通过附加属性或 AdornerManager 统一管理装饰器生命周期。
关键知识点
- AdornerLayer 是一个独立的渲染层,位于被装饰元素与其父元素之间。
- Adorner 基类继承自 FrameworkElement,支持 OnRender 和子元素。
- IsHitTestVisible 控制装饰器是否拦截输入事件。
- 装饰器的坐标系统相对于被装饰元素的左上角。
- 必须在元素 Loaded 之后才能获取 AdornerLayer。
- VisualCollection 允许装饰器包含交互式子元素。
项目落地视角
- 封装装饰器的添加和移除逻辑为扩展方法,统一管理生命周期。
- 为设计器或编辑器场景提供统一的选中/拖拽/缩放装饰器基类。
- 注意控件卸载时清理装饰器,防止内存泄漏。
- 在 Loaded 事件中初始化装饰器,确保 AdornerLayer 已就绪。
常见误区
- 在构造函数中获取 AdornerLayer,此时控件可能尚未加载。
- 忘记在不需要时移除装饰器,导致视觉残留。
- 装饰器中做了太多业务逻辑,应该只负责视觉呈现。
- 频繁 Add/Remove 装饰器导致性能问题。
- 装饰器的 OnRender 未调用 InvalidateVisual 导致不刷新。
进阶路线
- 研究 WPF 设计器(如 Blend)的装饰器实现。
- 学习 IOverlayService 模式统一管理装饰器。
- 尝试结合 Adorner 和 InkCanvas 实现批注功能。
- 研究 Canvas 编辑器中多选、对齐、分布装饰器的实现。
适用场景
- 输入框水印提示。
- 设计器中元素的选中框、拖拽手柄和缩放控制点。
- 表单验证错误的红色边框和错误提示气泡。
- 图形编辑器中的连接点和锚点标注。
落地建议
- 为常用装饰器场景封装为 Behavior 或附加属性。
- 使用统一的装饰器管理器跟踪和清理所有装饰器。
- 在单元测试中验证装饰器的添加和移除逻辑。
- 为装饰器编写 XML 文档注释和使用示例。
排错清单
- 检查 AdornerLayer.GetAdornerLayer 是否返回 null(控件可能未加载)。
- 确认装饰器在正确的线程(UI 线程)添加。
- 检查装饰器的 OnRender 是否被调用(可能需要 InvalidateVisual)。
- 检查 IsHitTestVisible 设置是否正确拦截或放行输入。
- 检查元素 Unloaded 时是否清理了装饰器。
复盘问题
- 如果把 Adorner 装饰器放进你的当前项目,最先要验证的视觉效果是什么?
- Adorner 在控件模板重建后是否仍然正确工作?你会用什么测试去确认?
- 相比修改 ControlTemplate 或使用 Popup,采用 Adorner 最大的收益和代价分别是什么?
