简介面向WPF/.NET 6开发者的MVVM区域标注Demo解决视频场景中为后台目标检测算法绘制电子围栏与框选标注区域的需求核心演示了动态添加控件并利用鼠标拖动、缩放、旋转交互。资源基于Prism.DryIoc搭建涵盖ItemsControl控件模板、Thumb可拖动控件、Adorner装饰器、CommandParameter多参数传递及GetChildObjectByUid按UID查找子控件等实用技术。压缩包共752个文件以C#源码160个cs、XAML/BAML界面文件、DLL依赖库为主另有editorconfig、JSON等配置及编译缓存文件总大小仅1.62MB结构清晰便于定位核心逻辑。已有1520人浏览学习适合需要为视频标注工具增加交互编辑能力或学习WPF MVVM高级控件用法的开发者。通过该Demo可快速掌握拖拽、缩放、旋转与目标框选标注在Prism框架下的实现思路为二次开发提供可直接参考的代码基础。 做组态工具、大屏可视化配置器或者类 Visio 的编辑器迟早会遇到一个需求往画布上动态添加控件还要支持鼠标拖动、缩放、旋转。这个需求听起来单纯但一旦叠加了 WPF、Prism、MVVM 三件套很多人就卡住了——交互事件到底写在哪ViewModel 怎么保存位置和角度代码写多了会不会又变成“半 MVVM”先说结论这类需求最合适的方式是“数据驱动画布”。把所有控件的位置、大小、角度抽象成 ViewModel 属性界面只用 ItemsControl 渲染鼠标操作通过附加行为封装业务逻辑仍然留在 ViewModel。标题里的三个操作——拖动、缩放、旋转——本质是修改数据模型中的 X、Y、Width、Height、Angle 这五个值。搞明白这个代码就不难写了。这篇文章适合谁WPF 进阶、正在做组态类工具或上层应用配置界面的开发者。我会把整体架构、动态添加控件的做法、拖动缩放旋转的封装方式还有我在实际项目里踩过的坑全部写清楚照着复现即可。1. 整体设计数据驱动画布交互独立成行为1.1 把画布上的控件抽象成 ViewModel先回到本质。你看画布上的一个按钮、一个仪表盘、一张图片对程序来说它到底是什么传统写法里它是一个控件实例你要 new 一个 Button把它 add 到 Canvas 的 Children 里然后通过Canvas.SetLeft控制位置。这种思路在 Demo 里很爽项目一复杂就崩撤销重做、保存布局、权限控制、联动参数全部要跟控件实例打交道。MVVM 的做法反过来把每个画布元素抽象成一个 ViewModel 对象字段就是模型该有的字段——X、Y、Width、Height、Angle、Content、Type。界面上的控件只是这个模型的“投影”。底层的 ItemsControl 根据集合渲染出对应的 UI用户用鼠标改位置最终改的也是这些属性。这样做的好处是显而易见的保存布局变成序列化一个集合撤销变成回滚属性动态添加就是往集合里 Add 一个对象。我做过一个组态配置器导出画面文件时直接把ObservableCollectionCanvasItemViewModel序列化成 XML界面重开时反序列化填回集合整套流程非常顺畅完全不需要碰 UI 控件。1.2 Prism 在这里解决哪两个问题标题里带了 Prism很多人会把注意力放在 Prism 的语法上其实 Prism 在这个场景里帮的主要是两件事依赖注入和命令绑定。我要向画布添加不同类型的控件添加逻辑应该放在画布 ViewModel 里但“从哪添加”——比如工具栏按钮、右键菜单、外部拖入——最好用命令的方式解耦。Prism 的DelegateCommand可以直接绑定到按钮上点击就触发 ViewModel 里的 Add 方法完全不管按钮是安排在工具栏还是右键菜单。另一个容易忽略的点是跨模块通信。做个编辑器通常不只有一张画布还有工具栏、属性面板、图层列表。属性面板要显示当前选中项的参数工具栏点删除要通知画布删除选中项。这种跨 ViewModel 的消息传递Prism 的 EventAggregator 是现成方案。我在项目里选中画布元素时发布一个ItemSelectedEvent属性面板订阅后刷新数据删除时发布DeleteRequestedEvent画布 ViewModel 处理删除。写起来不复杂但结构非常清晰。1.3 为什么交互要封装成附加行为拖动、缩放、旋转这种交互说到底属于 View 层的行为不是业务逻辑。但你也不能把几十行鼠标事件全堆在 code-behind 里尤其是当多个 UserControl 都可能复用这套逻辑时复制粘贴就是灾难的开始。我的选择是把事件处理封装成附加行为Attached Behavior比如CanvasItemDragBehavior、CanvasItemResizeBehavior和CanvasItemRotateBehavior。附加行为的好处有两个其一通过依赖属性开关控制是否启用在 XAML 里一行就能挂到任意控件上其二行为内部通过element.DataContext as CanvasItemViewModel拿数据上下文直接修改 ViewModel 属性不破坏 MVVM 边界。这套方案的架构分层很清晰数据在 ViewModel渲染在 DataTemplate交互在附加行为跨模块通信在 EventAggregator。后面所有代码都围绕这个分层展开。2. 动态添加控件从 ViewModel 到界面渲染2.1 数据模型与集合定义先看数据模型这里用 Prism 的BindableBase做基类SetProperty会自动触发属性通知省去手写INotifyPropertyChanged的样板代码。public class CanvasItemViewModel : BindableBase { private double _x 100; private double _y 100; private double _width 160; private double _height 80; private double _angle; private string _content 控件; private string _type TextBox; public double X { get _x; set SetProperty(ref _x, value); } public double Y { get _y; set SetProperty(ref _y, value); } public double Width { get _width; set SetProperty(ref _width, value); } public double Height { get _height; set SetProperty(ref _height, value); } public double Angle { get _angle; set SetProperty(ref _angle, value); } public string Content { get _content; set SetProperty(ref _content, value); } public string Type { get _type; set SetProperty(ref _type, value); } }画布容器的 ViewModel 里维护一个集合并提供添加命令public class DesignerCanvasViewModel : BindableBase { public ObservableCollectionCanvasItemViewModel Items { get; } new(); private CanvasItemViewModel _selectedItem; public CanvasItemViewModel SelectedItem { get _selectedItem; set SetProperty(ref _selectedItem, value); } public DelegateCommand AddTextCommand { get; } public DelegateCommand AddButtonCommand { get; } public DesignerCanvasViewModel() { AddTextCommand new DelegateCommand(() AddItem(TextBox, 文本内容)); AddButtonCommand new DelegateCommand(() AddItem(Button, 按钮)); } private void AddItem(string type, string content) { Items.Add(new CanvasItemViewModel { X 120 Items.Count * 20, Y 80 Items.Count * 20, Content content, Type type }); } }SelectedItem不是必须的但推荐加上后面做属性面板联动、删除选中项都会用到。注意X/Y的偏移每次添加错开一点否则多个控件叠在同一个位置新手很难察觉到底加没加上。2.2 界面侧用 ListBox 做画布容器界面这块有个容易纠结的地方用 ItemsControl 还是 ListBoxItemsControl 更轻量但选中态要自己管理。ListBox 自带SelectedItem双向绑定把它的默认模板重写成一个透明容器视觉上就完全是自定义的了选中逻辑却是现成的。ListBox ItemsSource{Binding Items} SelectedItem{Binding SelectedItem} BackgroundTransparent BorderThickness0 ListBox.ItemsPanel ItemsPanelTemplate Canvas IsItemsHostTrue / /ItemsPanelTemplate /ListBox.ItemsPanel ListBox.ItemContainerStyle Style TargetTypeListBoxItem Setter PropertyCanvas.Left Value{Binding X} / Setter PropertyCanvas.Top Value{Binding Y} / Setter PropertyFocusable ValueFalse / Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeListBoxItem ContentPresenter / /ControlTemplate /Setter.Value /Setter /Style /ListBox.ItemContainerStyle /ListBox这里两个细节容易被忽略。第一IsItemsHostTrue必须写在 Canvas 上告诉 ListBox 用这个 Canvas 来摆放 Item这样才能用 Canvas.Left/Top 控制位置。第二ListBoxItem 的默认模板有选中高亮和焦点框不重写模板的话每次选中都会出现一个难看的蓝条。每个项的具体外观通过DataTemplate定义核心套路是外层 Grid 绑定宽高和旋转角度DataTemplate DataType{x:Type vm:CanvasItemViewModel} Grid Width{Binding Width} Height{Binding Height} RenderTransformOrigin0.5,0.5 Grid.RenderTransform RotateTransform Angle{Binding Angle} / /Grid.RenderTransform Border BorderBrushGray BorderThickness1 BackgroundWhite ContentControl Content{Binding} / /Border /Grid /DataTemplateRenderTransformOrigin0.5,0.5表示旋转中心在控件正中央这是旋转看起来“正常”的关键忘了这行控件会绕着左上角转圈。2.3 添加不同类型控件的扩展方式标题里的“动态添加控件”不只是添加同一个模板而是可能添加文本框、按钮、图表、仪表盘等不同控件。做法一般有两种。第一种ViewModel 加一个Type字符串属性在 DataTemplate 内部用ContentControl配合多个DataTrigger切换内容模板。简单直接但模板多了之后 XAML 会变得很长。第二种用DataTemplateSelector根据数据的Type返回对应的 DataTemplate。这个更优雅适合控件种类多、每个模板比较复杂的情况。我在项目里就是用 DataTemplateSelector把文本框模板、图片模板、仪表盘模板分别写到独立 ResourceDictionary 里画布只关心“渲染当前选中类型对应的模板”具体长什么样完全由资源字典决定。实际项目里还会遇到一种情况把图形列表拖到画布上生成控件。这个可以先在工具栏侧处理 DragDrop 的DataObject把类型信息放在里面画布侧订阅Drop事件后调用 ViewModel 的添加方法。逻辑上仍然是“解析数据 → Add 到集合 → 界面自动渲染”跟按钮点击触发命令没有本质区别。3. 拖动、缩放、旋转的具体实现3.1 拖动行为里更新 X/Y拖动是最基础的思路很直接鼠标按下时记录起点移动时计算偏移量更新 ViewModel 的 X 和 Y。我把逻辑封装成附加行为挂载到画布项的外层 Grid 上。public static class CanvasItemDragBehavior { public static readonly DependencyProperty IsEnabledProperty DependencyProperty.RegisterAttached( IsEnabled, typeof(bool), typeof(CanvasItemDragBehavior), new PropertyMetadata(false, OnIsEnabledChanged)); public static void SetIsEnabled(DependencyObject obj, bool value) obj.SetValue(IsEnabledProperty, value); public static bool GetIsEnabled(DependencyObject obj) (bool)obj.GetValue(IsEnabledProperty); private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is FrameworkElement element (bool)e.NewValue) { element.MouseLeftButtonDown OnMouseDown; element.MouseMove OnMouseMove; element.MouseLeftButtonUp OnMouseUp; } } private static void OnMouseDown(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement element e.LeftButton MouseButtonState.Pressed) { element.CaptureMouse(); e.Handled true; _lastPoint e.GetPosition(FindCanvas(element)); } } private static void OnMouseMove(object sender, MouseEventArgs e) { if (e.LeftButton ! MouseButtonState.Pressed) return; if (sender is FrameworkElement element element.DataContext is CanvasItemViewModel vm) { var current e.GetPosition(FindCanvas(element)); vm.X current.X - _lastPoint.X; vm.Y current.Y - _lastPoint.Y; _lastPoint current; } } private static void OnMouseUp(object sender, MouseButtonEventArgs e) { (sender as FrameworkElement)?.ReleaseMouseCapture(); } }这里有两个关键点。第一CaptureMouse()一定要调否则鼠标快速移动超出控件边界后Move 事件就收不到了拖到一半断掉体验极差。第二FindCanvas需要从当前元素沿 VisualTree 向上找到画布容器因为Mouse.GetPosition的参数决定坐标参考系用画布坐标系才能保证vm.X dx的计算是准确的。3.2 缩放手柄方向与尺寸更新的对应关系缩放比拖动复杂一点因为不同方向的手柄修改的属性不一样。我通常在每个选中项的四角和四边中点放 8 个透明小方块作为手柄每个手柄带一个方向标记比如 NW、SE、E、S 等。以右下角SE手柄为例鼠标按下后当前位置减去控件的 X/Y 就是新的宽高vm.Width Math.Max(20, pt.X - vm.X); vm.Height Math.Max(20, pt.Y - vm.Y);但如果是左上角NW手柄直接这么写就反了。正确逻辑是新宽度 原 X 原宽度 - 当前鼠标 X同时 X 也要更新为鼠标的 X因为左上角位置本身在变var newWidth Math.Max(20, vm.X vm.Width - pt.X); var newHeight Math.Max(20, vm.Y vm.Height - pt.Y); vm.X pt.X; vm.Y pt.Y; vm.Width newWidth; vm.Height newHeight;每个方向的手柄都有一套对应的属性更新公式我建议把这 8 种情况写成一个枚举加 switch集中在行为类里管理别散落在事件代码里。Math.Max(20, ...)是防止控件被缩没了最小尺寸按需设置。另外缩放的命中区域不能太大否则会挡住拖拽操作。我的习惯是手柄矩形设为 8x8 像素用透明背景填充鼠标移上去时通过触发器变成明显颜色。这样视觉上只有选中态出现手柄操作区域却足够大。3.3 旋转Atan2 算角度RenderTransform 落地旋转是三个操作里最有趣的核心就一个函数反正切Math.Atan2。旋转手柄通常放在控件上方中央鼠标按下后以控件中心为原点计算鼠标位置与原点之间的夹角这个夹角就是控件的旋转角度。private static void OnRotateMouseMove(object sender, MouseEventArgs e) { if (e.LeftButton ! MouseButtonState.Pressed) return; if (sender is FrameworkElement element element.DataContext is CanvasItemViewModel vm) { var canvas FindCanvas(element); var center new Point(vm.X vm.Width / 2, vm.Y vm.Height / 2); var mousePos e.GetPosition(canvas); double angle Math.Atan2(mousePos.Y - center.Y, mousePos.X - center.X) * 180 / Math.PI; vm.Angle angle; } }这段代码看起来很朴素但能用关键在于前面 XAML 里做了两个铺垫Canvas.Left/Top 控制位置RotateTransform 只做视觉旋转所以控件的逻辑矩形在布局里始终是不旋转的而RenderTransformOrigin0.5,0.5让视觉旋转中心正好落在逻辑矩形的中心vm.X vm.Width / 2算出来的中心点和视觉中心就是同一个点。Atan2返回的角度范围是 -180 到 180 度WPF 的RotateTransform完全支持负角度所以直接绑定就行。如果想存 0 到 360 度加一个if (angle 0) angle 360;即可不影响使用。4. 实际项目里的坑与排查经验4.1 旋转后拖动总是“抓不准”这是我踩过的第一个大坑。控件旋转 90 度后再用附加上面的拖动行为去拖鼠标会明显偏离按住的那个点看起来就像控件在鼠标下面“滑走”了。原因其实很好理解拖动时我们把鼠标在画布中的位移直接加到 X/Y 上但控件是旋转过的鼠标抓住的本地点和控件中心之间的向量也在旋转。修正方法是在拖动的过程中把鼠标按下时记录的本地偏移经过旋转矩阵变换到画布坐标再反推 X/Y。// MouseDown 时记录 var local e.GetPosition(element); var centerLocal new Point(vm.Width / 2, vm.Height / 2); _offset local - centerLocal; // MouseMove 时计算 double radians vm.Angle * Math.PI / 180; var rotatedOffset new Vector( _offset.X * Math.Cos(radians) - _offset.Y * Math.Sin(radians), _offset.X * Math.Sin(radians) _offset.Y * Math.Cos(radians)); var mouseInCanvas e.GetPosition(canvas); vm.X mouseInCanvas.X - vm.Width / 2 - rotatedOffset.X; vm.Y mouseInCanvas.Y - vm.Height / 2 - rotatedOffset.Y;这段代码有很多人问过其实就是一个二维向量旋转公式。加了之后旋转 90 度、180 度、任意角度鼠标都不会再“跑偏”。如果你的项目暂时不需要旋转后拖动很精确可以先不处理但要做成面向用户的编辑器这步绕不开。4.2 ListBox 截获了鼠标事件用 ListBox 作为画布容器很容易遇到一个问题在 ListBoxItem 的子元素上按下鼠标点击事件被 ListBox 的选中逻辑截获结果元素没拖动倒是先被选中了。这时需要在拖动行为的 MouseDown 里设置e.Handled true阻止事件继续冒泡。另一个更隐蔽的问题是 ListBox 默认会响应空格键、方向键、鼠标拖动多选等交互这些在画布场景里基本是副作用。我直接把 ListBoxItem 的Focusable设为 False并按需屏蔽键盘导航。如果你用 ItemsControl 附加行为自己做选中就不存在这个问题但要多写不少选中管理代码。两害相权我选 ListBox。4.3 性能问题别全都甩锅给绑定拖拽缩放的过程中每次 MouseMove 都会更新 ViewModel 属性触发属性通知再驱动 UI 更新。很多人担心这样性能不行其实对于几十个控件的画布WPF 的绑定机制完全扛得住卡顿的真正原因往往出在 DataTemplate 太复杂。我之前在一个模板里放了一个实时刷新曲线的图表控件拖动时就卡成幻灯片。后来把图表控件的显示层和交互层拆开拖动时只显示一个半透明的占位矩形释放鼠标后才真正渲染图表内容瞬间流畅了。如果你的项目里也是动态添加图表、大图、复杂 UserControl建议优先考虑这种“拖动时简化渲染”的方案而不是一上来就怀疑绑定性能有问题。还有一个容易被忽略的点如果CanvasItemViewModel的属性变更在后台线程触发界面不会自动更新必须切回 UI 线程。Prism 的BindableBase不会帮你处理线程切换所以在异步加载布局文件后批量填充集合时记得用Application.Current.Dispatcher.Invoke包裹否则会出现部分控件不显示、位置不更新这种奇怪的间歇性问题。把这个方案跑通之后后续加撤销重做、图层管理、多选、吸附对齐都会顺畅得多。我自己在实际开发中的体会是这类交互组件第一版别追求把所有细节做到完美先把“数据驱动画布”这条主线立住再把拖动、缩放、旋转这些操作做成可复用的附加行为后面的扩展就是往这条主线上挂新功能而已。本文还有配套的精品资源点击获取