简介:这份面向WPF桌面开发者的Prism MVVM实战示例,聚焦视频目标检测场景中的区域标注需求,演示如何动态添加可交互控件,并实现鼠标拖动、缩放与旋转操作,可用于电子围栏绘制和框选区域等交互功能的学习与二次开发,适合具备一定WPF基础的中级开发者参考。压缩包共752个文件,约1.62MB,包含160个C#源码文件、若干XAML与BAML界面资源、17个DLL依赖,以及cache缓存、editorconfig和构建配置等工程辅助文件,能够较完整地还原项目结构与依赖关系。示例采用Prism.DryIoc组织MVVM,涉及ItemsControl控件模板、Thumb可拖动控件、Adorner装饰器、CommandParameter多参数传递和通过UID查找子控件等实现要点,便于理解动态控件生成、交互标注与界面组织的结合方式。目前已有1523人学习下载,可对照源码梳理拖动、缩放、旋转及区域标注逻辑,并参考其工程组织进行二次开发。
1. 动态控件画布:为什么大多数 WPF 拖拽 Demo 一放大就翻车
很多人第一次在 WPF 里做“可拖动、可缩放、可旋转”的控件,都会先搜到一堆 Canvas + MouseMove 的示例,粘进来跑一遍发现能用,然后接真实需求——控件要能动态新增、要能选中、要能多选一起旋转——立刻就翻车了:点击一个控件,另一个跟着动;把控件旋转 45 度后,拖动方向开始“玄学”,鼠标往右拖,控件往左上跑。
这个项目解决的就是这一层问题。它基于 WPF + Prism + MVVM,实现的是一个可视化画布:运行时动态添加控件,每个控件可以被鼠标拖动、四角缩放、绕中心旋转,并且交互逻辑不塞进 code-behind。适合两类人:一类是要在桌面端做简易流程编排、图元标注、仪表盘布局编辑器的开发者;另一类是刚学 MVVM 但写不出“不写事件处理器的拖动”的人。下面按“它怎么组织 → 怎么跑起来 → 核心交互怎么实现 → 怎么改”拆。
2. 工程骨架:Prism 模块划分与动态控件的数据模型
2.1 为什么这一层必须用 MVVM 落数据
WPF 里拖动控件的直觉写法是给控件挂三个鼠标事件,在Canvas.SetLeft里改坐标。这种写法在“静态几个控件”时没问题,但一旦控件是运行时动态生成的,每个控件都需要独立的坐标、尺寸、角度状态,把状态存在控件对象本身就会导致——业务流程拿不到数据、序列化保存不了布局、撤销重做无处下手。
这个项目选择用 Prism 的模块化 + MVVM 的双向绑定来承载。核心数据模型是“一个可变换图元”,它的状态全部是属性,控件只负责渲染:
// 可变换图元的抽象基类,所有画布元素都从这里派生 public abstract class CanvasItemBase : BindableBase { // 画布坐标(未变换前的左上角位置) private double _left; public double Left { get => _left; set => SetProperty(ref _left, value); } private double _top; public double Top { get => _top; set => SetProperty(ref _top, value); } // 缩放比例与旋转角度,组合后构成 RenderTransform private double _scale = 1.0; public double Scale { get => _scale; set => SetProperty(ref _scale, value); } private double _angle; public double Angle { get => _angle; set => SetProperty(ref _angle, value); } // 选中态:决定是否显示操作手柄(缩放点、旋转柄) private bool _isSelected; public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected, value); } }BindableBase来自 Prism,省掉自己写INotifyPropertyChanged的样板代码。这里有个容易忽略的点:Left/Top和Scale/Angle是两组语义不同的属性——前者是布局坐标,由Canvas.SetLeft/SetTop消费;后者是渲染变换,走RenderTransform。混为一谈是后面拖动“方向偏了”的根源,这一点在第 4 章讲交互时会展开。
2.2 目录结构与模块职责
Prism 项目常见的组织方式是按模块拆分,主程序只负责 Shell 和区域注册,业务视图和视图模型各自独立成项目,编译期互不引用,运行时通过模块目录加载:
Solution ├─ App.Shell # 主程序,只放 Shell 窗口与区域声明 │ ├─ Views/ShellWindow.xaml # 包含 ContentControl 区域 │ └─ App.xaml.cs # PrismApplication,注册模块目录 ├─ Modules.Canvas # 画布模块:视图、ViewModel、图元模型 │ ├─ Models/CanvasItemBase.cs # 前面那段基类 │ ├─ Models/RectItem.cs # 具体图元(矩形、文本、图片等) │ ├─ ViewModels/CanvasViewModel.cs │ └─ Views/CanvasView.xaml # ItemsControl + Canvas 模板 ├─ Modules.Toolbox # 工具栏模块:添加控件的按钮区 └─ Infrastructure # 公共接口、事件、常量这种拆法的收益在扩展阶段才显现:新增一种图元只动Modules.Canvas,加一个工具栏分区只动Modules.Toolbox,Shell 不需要重编。代价是初学者上手时找不到“入口在哪”——启动逻辑在 Shell,界面在模块,导航和区域注入在 Prism 容器里,中间隔着一步。
2.3 Shell 与区域注册的关键几行
主程序继承PrismApplication,模块目录可以用配置文件扫描,也可以手写ConfigureModuleCatalog显式注册:
public partial class App : PrismApplication { protected override Window CreateShell() => Container.Resolve<ShellWindow>(); protected override void RegisterTypes(IContainerRegistry containerRegistry) { // 画布服务单例:全局只有一份当前画布状态 containerRegistry.RegisterSingleton<ICanvasService, CanvasService>(); } protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { // 显式注册模块,避免目录扫描在发布后路径变化导致加载失败 moduleCatalog.AddModule<CanvasModule>(); moduleCatalog.AddModule<ToolboxModule>(); } }RegisterSingleton把画布状态提升为全局单例,是为了让工具栏模块“添加控件”和画布模块“渲染控件”操作同一个集合。如果这里注册成Register(瞬态),工具栏添加完的控件和画布绑定的集合就是两个实例,表现是点按钮没反应——这种问题排查起来很浪费时间,因为编译、运行都不报错,就是“点了没用”。
提示:模块加载失败时优先看输出窗口的 Prism 日志,常见原因是模块项目未被主程序引用导致 DLL 没进输出目录。
3. 跑起来:从环境准备到第一次成功拖动
3.1 环境与依赖确认
项目面向 .NET 6 的 WPF,Windows 桌面环境。动手前确认 SDK 版本和 Prism 包是否齐:
# 确认 SDK:必须是 6.0 或以上,且安装了 Windows Desktop 运行时 dotnet --list-sdks # 还原依赖,重点看 Prism.DryIoc 和 Prism.Wpf 是否拉到 dotnet restore # 构建整个解决方案 dotnet build -c Debug依赖核心是三个包:Prism.DryIoc(容器 + 启动引导)、Prism.Wpf(区域、导航、BindableBase)、Microsoft.Xaml.Behaviors.Wpf(把鼠标事件转成命令,MVVM 下替代事件处理器的关键)。最后一个最容易被漏装,表现是 XAML 里写i:Interaction.Triggers时设计器一片红。
3.2 画布视图:ItemsControl 套 Canvas 的模板写法
WPF 没有现成的“数据驱动画布”,标准做法是ItemsControl的ItemsPanel换成Canvas,再在ItemContainerStyle里把数据模型的Left/Top绑到容器的附加属性上。这是整个项目最值得抄的一段 XAML:
<ItemsControl ItemsSource="{Binding Items}"> <!-- 关键一:面板换成 Canvas,容器才有 Left/Top 附加属性 --> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas ClipToBounds="True" Background="Transparent"/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <!-- 关键二:把数据坐标映射到容器附加属性 --> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding Left}"/> <Setter Property="Canvas.Top" Value="{Binding Top}"/> </Style> </ItemsControl.ItemContainerStyle> <!-- 关键三:渲染变换绑定到图元内容上,而不是容器上 --> <ItemsControl.ItemTemplate> <DataTemplate> <Grid Width="{Binding Width}" Height="{Binding Height}" RenderTransformOrigin="0.5,0.5"> <Grid.RenderTransform> <TransformGroup> <ScaleTransform ScaleX="{Binding Scale}" ScaleY="{Binding Scale}"/> <RotateTransform Angle="{Binding Angle}"/> </TransformGroup> </Grid.RenderTransform> <!-- 具体图元内容与操作手柄在这里 --> </Grid> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>三个“关键”注释对应三个必踩的坑:ItemsPanel不换Canvas,Left/Top绑了也无效,容器按默认StackPanel排;ItemContainerStyle里绑的是ContentPresenter的附加属性,不是数据模板内部元素;RenderTransformOrigin必须设0.5,0.5,否则旋转和缩放的中心跑到左上角,视觉上控件“飞出去”。画布Background一定要给个透明色,否则空白区域的鼠标事件收不到,点空白取消选中就失效。
3.3 启动后应该看到什么
F5 起来,Shell 窗口左侧是工具栏按钮,右侧是灰底画布。点“添加矩形”,画布中央出现一个带八个小方块的矩形;鼠标移到矩形上拖动,矩形跟着走;拖四角的小方块,矩形等比缩放;拖顶部的圆形手柄,绕中心旋转。选中状态下按 Delete 能删,点空白处取消选中。
如果第一次运行出现“点按钮什么也没发生”,按顺序排查:模块是否被主程序引用(project reference)、ItemsSource绑定的集合是否为ObservableCollection、工具栏的按钮Command是否绑到了被注入的同一个 ViewModel 实例。第三条最常见——DataContext在模块内被 new 了一次,容器注入的是另一个实例。
注意:以管理员身份运行和非管理员运行时,高 DPI 缩放的鼠标坐标换算结果不同,调试时尽量固定一个 DPI 环境,避免坐标问题被环境差异掩盖。
4. 核心交互拆解:拖动、缩放、旋转在 MVVM 下怎么落
4.1 拖动:命中测试与坐标换算
MVVM 下不能写MouseMove事件处理器的原因是事件处理器必须挂在 code-behind 上,视图和逻辑耦合。项目的做法是用Microsoft.Xaml.Behaviors的Interaction.Triggers把鼠标事件转成命令,参数通过EventArgsParameter传过去:
<Grid Width="{Binding Width}" Height="{Binding Height}" RenderTransformOrigin="0.5,0.5"> <i:Interaction.Triggers> <i:EventTrigger EventName="MouseLeftButtonDown"> <!-- 把鼠标位置和当前元素一起传给 ViewModel --> <i:InvokeCommandAction Command="{Binding DataContext.BeginDragCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}" PassEventArgsToCommand="True"/> </i:EventTrigger> <i:EventTrigger EventName="MouseMove"> <i:InvokeCommandAction Command="{Binding DataContext.DragMoveCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}" PassEventArgsToCommand="True"/> </i:EventTrigger> <i:EventTrigger EventName="MouseLeftButtonUp"> <i:InvokeCommandAction Command="{Binding DataContext.EndDragCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"/> </i:EventTrigger> </i:Interaction.Triggers> </Grid>注意命令的绑定路径用了RelativeSource上溯到ItemsControl——因为每一项的DataContext是图元模型,而拖动逻辑在画布 ViewModel 上,不上溯就绑不到。这一步是新手最容易卡住的地方。命令参数里e.GetPosition(canvas)拿到的是相对于画布的坐标,这比用e.GetPosition(item)稳定得多,后者会受控件自身变换影响。
ViewModel 侧记录拖动起点,用差值累计位移:
// 开始拖动:记录鼠标按下时的画布坐标与图元原始坐标 private void OnBeginDrag(MouseButtonEventArgs e) { var pos = e.GetPosition(_canvas); _dragStartPoint = pos; _dragging = _activeItem; _dragOriginLeft = _activeItem.Left; _dragOriginTop = _activeItem.Top; } // 拖动中:差值加到原始坐标上,避免累计误差 private void OnDragMove(MouseEventArgs e) { if (_dragging is null || e.LeftButton != MouseButtonState.Pressed) return; var pos = e.GetPosition(_canvas); _dragging.Left = _dragOriginLeft + (pos.X - _dragStartPoint.X); _dragging.Top = _dragOriginTop + (pos.Y - _dragStartPoint.Y); }关键在第 4 行和最后两行:必须记录拖动起点,用“当前 - 起点”的差值加回原始值,而不是每帧Left += delta。后者在鼠标事件密集时会出现抖动累积,控件慢慢“漂”走,这是血泪经验。另外e.LeftButton != Pressed的判断不能省,快速甩动时鼠标移出控件范围,事件仍然会进来,不判断就会变成“鼠标松开后控件还在追着跑”。
4.2 缩放:以中心为基准的四角拖动
缩放的直觉实现是记录手柄拖动距离直接改Width/Height,但项目里图元有旋转角度,一旦转了 45 度,拖右下手柄时用户期望的是沿控件本地坐标轴放大,而屏幕坐标是斜的。正确做法的思路是:拿到鼠标在该控件本地坐标系里的位置,换算成缩放增量,再以中心为基准同时调整宽高和位置。
// Scale 为渲染缩放倍数,CenterX/CenterY 为图元中心(画布坐标) private void ApplyScaleDelta(Point mouseOnCanvas, double startScale) { // 1. 先把鼠标点逆旋转回未旋转坐标系,消掉角度影响 var center = new Point(_activeItem.Left + _activeItem.Width / 2, _activeItem.Top + _activeItem.Height / 2); var rad = -_activeItem.Angle * Math.PI / 180.0; var dx = mouseOnCanvas.X - center.X; var dy = mouseOnCanvas.Y - center.Y; var localX = dx * Math.Cos(rad) - dy * Math.Sin(rad); // 2. 用本地横向距离与初始半径的比值作为缩放比例 var halfWidth = _activeItem.Width / 2; var ratio = Math.Max(0.1, Math.Abs(localX) / halfWidth); _activeItem.Scale = startScale * ratio; }rad取负角度做逆变换,是把屏幕坐标“还原”到控件自己的坐标轴方向;Math.Max(0.1, ...)是给缩放设下限,否则用户能把控件拖到 0 尺寸,之后再也点不中它,只能删掉重建。startScale缓存按下瞬间的缩放值,避免连续相乘导致指数级放大,这和拖动里“记录起点”是同一个思路。
如果项目里改用ScaleTransform的ScaleX/ScaleY而非统一的Scale,四角拖动就能做非等比缩放;但要注意改Scale不影响ActualWidth,命中测试和布局用的仍是原始尺寸,这点在第 5 章避坑里再说。
4.3 旋转:手柄位置与角度解算
旋转手柄通常固定在图元正上方,拖动时把鼠标相对图元中心的极角作为新的Angle:
private void OnRotate(MouseEventArgs e) { if (_rotateTarget is null) return; var pos = e.GetPosition(_canvas); var center = new Point(_rotateTarget.Left + _rotateTarget.Width / 2, _rotateTarget.Top + _rotateTarget.Height / 2); // Atan2 得到 -PI ~ PI 的弧度,转成角度后 +90 是因为手柄默认在正上方 var angle = Math.Atan2(pos.Y - center.Y, pos.X - center.X) * 180.0 / Math.PI + 90; // 归一到 0~360,便于界面上直接显示 _rotateTarget.Angle = (angle % 360 + 360) % 360; }三个参数要讲清:Atan2(dy, dx)的参数顺序不能反,反了角度会镜像;+90是因为RotateTransform的 0 度对应水平向右,而手柄在正上方,不补 90 度会出现“鼠标在正上方,控件却是横的”;最后的取模是让角度落在 0~360,否则连续旋转几圈后界面上出现-720之类的数值,用户看了一脸懵。
旋转和缩放同时存在时还有个细节:TransformGroup里Scale一定写在Rotate前面。顺序反了,缩放会作用在旋转后的坐标系上,非等比缩放的控件会变形。这个顺序在 XAML 里看起来只是两行的先后,改起来要命。
5. 避坑排查:动态画布上最容易翻车的五个地方
5.1 现象:点击任意控件,所有控件一起动
原因:命令绑在了图元模型自身({Binding BeginDragCommand}),但模型类没有这个命令属性,绑定时静默失败后又沿DataContext继承链找到了父级 ViewModel 的同名命令,导致所有项共享一个“当前目标”,而这个目标没在命令参数里区分。另一种可能是Interaction.Triggers放在了ItemsControl外层,事件源是整块画布。
解决:命令统一绑到画布 ViewModel(用RelativeSource AncestorType=ItemsControl),并把被操作项本身作为命令参数传进去;在 ViewModel 里用_activeItem显式记录当前操作对象,操作结束置空。调试时先确认鼠标按下时_activeItem是否指向正确的对象,这一步能排掉八成“连锁移动”问题。
5.2 现象:旋转后再拖动,方向偏了
原因:缩放改的是RenderTransform,但拖动改的是Canvas.Left/Top,两者不在一个坐标系。控件被旋转 45 度后,Canvas布局坐标方向不变,用户视觉上往右拖,控件却沿自身旋转后的轴向移动,看起来像“偏了一个角度”。
解决:拖动位移也要做逆旋转:把画布坐标系的位移向量用-Angle逆旋转到控件本地坐标系,再叠加到Left/Top(如果Left/Top绑定的容器不参与旋转)。反过来说,如果旋转是加在容器而非内容上,就直接改Left/Top不需换算。先确认RenderTransform挂在哪一层,再决定是否换算,别盲改。
5.3 现象:控件缩放到很小后,点不中了
原因:ScaleTransform只影响渲染,不影响ActualWidth/ActualHeight,控件的命中测试区域却是按布局尺寸算的。放大的控件周围有一圈“看得见点不着”的空白,缩小的控件反而处处能点中。这是 WPFRenderTransform的固有行为,不是 bug。
解决:给缩放手柄设最小Scale(如 0.1),并在交互上把“选中”这件事放到外层容器上,用IsHitTestVisible控制内部内容。更彻底的方案是改Width/Height而非ScaleTransform,代价是文本和图片会被拉伸变形,需要按需权衡。
5.4 现象:点空白取消选中失效,或者点一下背景所有控件闪一下
原因:Canvas的Background为null(透明不等于空),空白区域不参与命中测试,鼠标事件穿透到父级;如果父级也没背景,事件直接丢了。
解决:画布Canvas显式设Background="Transparent",并在画布层面绑MouseLeftButtonDown命令执行“清空IsSelected”。注意这个命令要判断e.OriginalSource是不是画布本身,否则点到图元上也会触发取消选中,出现“点哪个都选不中”的奇怪现象。
5.5 现象:多显示器或高 DPI 下,拖动位置和鼠标对不上
原因:GetPosition返回的是 DIP(设备无关像素),而某些自定义换算里混用了屏幕物理像素;另外 WPF 应用默认未声明 DPI 感知,系统缩放 125% 时窗口被系统位图拉伸,坐标整体偏移。
解决:全程只用GetPosition(相对元素)拿坐标,不碰屏幕坐标;在应用清单里声明 PerMonitorV2 的 DPI 感知。改完如果还有偏移,检查是否有父级LayoutTransform参与了缩放——LayoutTransform会影响布局尺寸,和RenderTransform的换算规则不一样,两者混用是坐标全乱的典型来源。
6. 二次开发:把图元抽象换成你的业务对象
6.1 扩一种新图元的完整改动面
新增图元(比如一个“状态标签”)在这个架构里是纯加法,不需要动任何已有交互逻辑,改动面只有三处:
| 改动位置 | 改动内容 | 是否必须 |
|---|---|---|
Models/ | 新增LabelItem : CanvasItemBase,补自身业务属性 | 必须 |
Views/或资源字典 | 新增对应DataTemplate,按类型选择模板 | 必须 |
| 工具栏 ViewModel | 加一条“添加标签”命令,往集合里塞新实例 | 按需 |
| 序列化层 | 如已实现布局保存,补类型标识的读写 | 按需 |
模板按类型匹配,用ItemsControl.ItemTemplateSelector或者DataTemplate DataType隐式选择都行,后者更省事:只要资源字典里声明<DataTemplate DataType="{x:Type m:LabelItem}">,WPF 就会自动套用,不需要写选择器代码。
6.2 布局持久化:把变换状态存成可回放的 JSON
动态画布做完交互,下一步几乎必然是“保存布局、下次打开还原”。因为所有状态都在模型属性上,序列化几乎零成本:
// 保存:只存数据,不存控件 var dto = Items.Select(i => new ItemDto { Type = i.GetType().Name, // 反序列化时据此还原具体类型 Left = i.Left, Top = i.Top, Width = i.Width, Height = i.Height, Scale = i.Scale, Angle = i.Angle, Payload = i.GetPayload() // 业务自定义字段,各图元自己实现 }).ToList(); File.WriteAllText(path, JsonSerializer.Serialize(dto));这里的关键是Type字段。反序列化时按名字建实例需要一份类型映射表,直接Activator.CreateInstance(Type.GetType(name))在模块化项目里经常拿不到类型(程序集未加载),稳妥做法是维护一个Dictionary<string, Func<CanvasItemBase>>注册表,每种图元在模块初始化时注册进去。我一般会顺手给对方一个约定:只要新增图元就必须在注册表里加一行,否则保存的布局读不回来——这种“约定强于文档”的做法在小团队里比写一堆注释管用。
6.3 一个值得先做的改造:撤销重做
拖动、缩放、旋转每次结束都会改一批属性,做撤销重做最省事的方式不是记录每一步操作,而是在操作结束时存一份快照。全量快照在几十个图元的量级下完全够用,内存和性能都不是问题,别一上来就设计命令模式的ICommand反向操作,那个复杂度会拖垮整个交互层。
验证方式也很直接:连续拖动同一个控件十次,撤销十次后应该回到初始位置,且Left/Top/Scale/Angle全等——只要有一个属性不恢复,就说明快照漏存了字段。我吃过这个亏,快照里漏了Angle,撤销后控件位置对了但角度没回去,视觉上像是“撤销到一半卡住”,查了半天才发现是序列化字段没对齐。
从那以后我每次给这类画布加变换属性,都强制走一遍“改属性 → 快照 → 撤销 → 逐字段比对”的流程,宁可多花十分钟,也不要在用户面前出现撤销一半的诡异状态。希望这套拆解能帮到你,把动态画布的交互稳稳落地。
本文还有配套的精品资源,点击获取