
简介本资源是一个基于 .NET 6 的 WPF 实战项目面向中高级 C# 开发者与桌面应用学习者聚焦于动态区域标注场景——为视频目标检测算法如电子围栏、框选区域提供可交互的 UI 支持。项目采用 Prism.DryIoc 框架实现规范 MVVM 架构核心功能涵盖控件的动态添加、鼠标拖动Thumb、自由缩放与旋转Adorner 装饰器并深入运用 ItemsControl 模板化渲染、CommandParameter 多参数传递及 UID 子控件查找等进阶技巧。压缩包共 752 个文件以 160 个 C# 业务逻辑文件、134 个 editorconfig 配置、119 个 BuildWithSkipAnalyzers 构建标记文件为主辅以 XAML 界面定义、BAML 编译资源及少量 DLL/EXE 可执行依赖整体仅 1.62MB轻量且结构清晰。目前已有 1523 人学习下载读者可直接复用完整标注控件体系、理解 WPF 视觉树操作与 MVVM 命令协同机制并获得适配 .NET 6 的工程化项目组织范例。1. WPF Prism MVVM 动态控件鼠标拖动缩放旋转不是“玄学”而是可复现的坐标系工程你有没有试过在 WPF 里动态添加一个Rectangle或Image然后想让它能被鼠标拖拽、双指缩放或 Ctrl滚轮、按住 Shift鼠标右键旋转——结果一上手就卡在坐标转换黑匣子不是事件不触发就是缩放中心偏移、旋转后拖拽错位、多控件叠加时 ZIndex 错乱……这不是你代码写得差而是 WPF 的渲染坐标系、视觉树生命周期、Prism 的 Region 管理、以及 MVVM 下命令绑定与状态同步这四层“结界”没被同时捅穿。这个资源包就是某开发者在模拟项目X中反复推倒重写 7 轮后沉淀下来的完整实现基于 .NET 6 Prism 8.1 CommunityToolkit.Mvvm所有交互逻辑完全剥离到 ViewModel 层View 层仅保留 XAML 声明和行为绑定支持 Canvas 容器内任意数量控件的独立操作且每个控件的状态位置、宽高、角度、ZIndex全部可序列化、可撤销、可响应式更新。适合正在做工业组态界面、流程图编辑器、UI 原型沙盒或教育类交互课件的 .NET 中高级开发者——别再用RenderTransform硬怼了这次我们从坐标变换原理出发把拖动、缩放、旋转拆成三套正交的数学模型来落地。2. 核心机制拆解为什么必须用 CompositeTransform 而非单独 Rotate/ScaleWPF 的RenderTransform是视觉层变换它不改变元素的ActualWidth/Height和Canvas.Left/Top但会直接影响鼠标事件的 HitTest 坐标。如果只用RotateTransform旋转后元素的包围盒仍是原始矩形导致后续拖拽计算出的Canvas.Left/Top偏移量严重失真。而CompositeTransform将 Translate、Scale、Rotate 统一在一个矩阵下运算保证所有变换叠加后的最终坐标可逆推。更重要的是Prism MVVM 要求状态可预测、可测试因此我们必须把变换参数抽象为 ViewModel 中的Point Position、double ScaleX/ScaleY、double Rotation三个独立属性并通过ICommand触发变更而非在 Code-Behind 里直接操作RenderTransform。2.1 坐标系对齐Canvas 坐标 vs RenderTransform 坐标 vs 鼠标坐标Canvas 容器使用绝对坐标左上角为原点而RenderTransform默认以元素左上角为变换中心。但用户直觉是“拖动控件中心”、“缩放以鼠标位置为中心”、“旋转以控件中心为轴”。因此必须做三次坐标映射拖动起点校准鼠标按下时记录Mouse.GetPosition(canvas)与element.TranslateTransform.X/Y的差值作为拖动偏移基准缩放中心动态计算CtrlMouseWheel时将Mouse.GetPosition(canvas)转换为相对于控件自身坐标的点再反算该点在缩放后应保持的 Canvas 坐标旋转锚点统一强制所有变换以RenderTransformOrigin0.5,0.5即控件中心为基准避免RotateTransform.CenterX/CenterY与Canvas.Left/Top冲突。提示不要在 XAML 中直接写Rectangle RenderTransform.../。必须用Binding绑定到 ViewModel 的Transform属性并在属性变更时触发INotifyPropertyChanged否则 Prism 的EventToCommandBehavior无法监听到状态变化。2.2 ViewModel 层状态建模每个控件一个独立的 TransformStatePrism 的RegionManager支持动态注入控件但若所有控件共用同一组ScaleX/Rotation属性就会相互污染。正确做法是为每个动态添加的控件创建独立的DesignItemViewModel实例其核心属性如下public partial class DesignItemViewModel : ObservableObject { [ObservableProperty] private Point _position; // 对应 Canvas.Left/Top单位像素 [ObservableProperty] private double _scaleX 1.0; [ObservableProperty] private double _scaleY 1.0; [ObservableProperty] private double _rotation 0.0; // 单位度非弧度 [ObservableProperty] private int _zIndex 0; // 所有变换最终合成的 TransformGroup供 View 层 Binding [ObservableProperty] private TransformGroup _transform; partial void OnPositionChanged(Point value) { UpdateTransform(); } partial void OnScaleXChanged(double value) UpdateTransform(); partial void OnScaleYChanged(double value) UpdateTransform(); partial void OnRotationChanged(double value) UpdateTransform(); private void UpdateTransform() { var group new TransformGroup(); group.Children.Add(new TranslateTransform(Position.X, Position.Y)); group.Children.Add(new ScaleTransform(ScaleX, ScaleY, 0.5, 0.5)); // 以中心缩放 group.Children.Add(new RotateTransform(Rotation, 0.5, 0.5)); // 以中心旋转 Transform group; } }这段代码的关键在于ScaleTransform和RotateTransform的第三、四参数centerX,centerY必须设为0.5, 0.5表示相对于元素自身尺寸的归一化坐标而非 Canvas 像素坐标。这是避免旋转后拖拽错位的第一道防线。UpdateTransform()在每次属性变更时重建整个TransformGroup确保三者叠加顺序严格为「先平移、再缩放、最后旋转」——因为矩阵乘法不可交换顺序错了坐标就全乱。2.3 View 层绑定用 Interaction.Behaviors 实现无侵入式事件转发WPF 原生事件如MouseMove无法直接绑定到 ViewModel 命令硬写 Code-Behind 会破坏 MVVM。Prism 提供Microsoft.Xaml.Behaviors旧版System.Windows.Interactivity已弃用配合EventTrigger可将 UI 事件转为ICommand调用Rectangle Width120 Height80 FillLightBlue Canvas.Left{Binding Position.X} Canvas.Top{Binding Position.Y} Canvas.ZIndex{Binding ZIndex} RenderTransform{Binding Transform} i:Interaction.Triggers !-- 鼠标按下开始拖动 -- i:EventTrigger EventNameMouseDown prism:InvokeCommandAction Command{Binding StartDragCommand} PassEventArgsToCommandTrue/ /i:EventTrigger !-- 鼠标移动持续更新位置 -- i:EventTrigger EventNameMouseMove prism:InvokeCommandAction Command{Binding DragCommand} PassEventArgsToCommandTrue/ /i:EventTrigger !-- 鼠标释放结束拖动 -- i:EventTrigger EventNameMouseUp prism:InvokeCommandAction Command{Binding EndDragCommand}/ /i:EventTrigger !-- 滚轮缩放 -- i:EventTrigger EventNamePreviewMouseWheel prism:InvokeCommandAction Command{Binding ZoomCommand} PassEventArgsToCommandTrue/ /i:EventTrigger /i:Interaction.Triggers /Rectangle注意PassEventArgsToCommandTrue是关键它让MouseEventArgs或MouseWheelEventArgs传入 ViewModel从而获取e.GetPosition(canvas)。而PreviewMouseWheel事件必须用Preview前缀否则会被父容器如 Canvas先吞掉子控件收不到。3. 拖动实现从按下到释放的四阶段状态机拖动看似简单实则包含「捕获 → 计算 → 应用 → 释放」四个不可跳过的阶段。任何阶段缺失都会导致“拖不动”“拖飞了”“松手后回弹”等翻车现象。本方案采用CaptureMouse()ReleaseMouseCapture()显式管理鼠标捕获避免因快速移出 Canvas 导致拖动中断。3.1 StartDragCommand捕获鼠标并记录初始偏移[RelayCommand] private void StartDrag(MouseButtonEventArgs e) { if (e.ChangedButton ! MouseButton.Left) return; // 强制捕获鼠标防止移出 Canvas 后丢失事件 _canvas?.CaptureMouse(); // 获取鼠标在 Canvas 中的绝对坐标 var mousePos e.GetPosition(_canvas); // 计算鼠标相对于控件左上角的偏移注意此时控件可能已缩放/旋转 // 所以不能直接用 mousePos - Position而要用逆变换 var inverse Transform.Value.Inverse; var localMousePos inverse.Transform(mousePos); // 存储拖动基准鼠标在控件本地坐标系中的位置 _dragOffset localMousePos; IsDragging true; }这里_dragOffset是核心变量它表示“鼠标按下时在控件自身坐标系下的 (X,Y) 值”。后续每次MouseMove都用当前鼠标位置减去这个偏移得到控件新的Position。之所以要inverse.Transform(mousePos)是因为mousePos是 Canvas 坐标而我们需要它在未变换前的原始控件坐标系下的值——这正是Transform.Value.Inverse的作用。3.2 DragCommand实时更新 Position 并防抖限速[RelayCommand] private void Drag(MouseEventArgs e) { if (!IsDragging || _canvas null) return; var mousePos e.GetPosition(_canvas); // 将鼠标 Canvas 坐标通过逆变换转回控件本地坐标 var inverse Transform.Value.Inverse; var localMousePos inverse.Transform(mousePos); // 新 Position 鼠标本地坐标 - 拖动偏移 Position new Point( localMousePos.X - _dragOffset.X, localMousePos.Y - _dragOffset.Y ); // 【关键防抖】限制最小更新间隔避免高频触发导致 UI 卡顿 if (_lastDragUpdateTime default || DateTime.Now.Subtract(_lastDragUpdateTime).TotalMilliseconds 16) // ~60fps { OnPropertyChanged(nameof(Position)); _lastDragUpdateTime DateTime.Now; } }OnPropertyChanged(nameof(Position))触发OnPositionChanged回调进而调用UpdateTransform()重建TransformGroup。但高频MouseMove每秒数百次会导致无谓重绘所以加了 16ms 限速——这是 WPF 渲染帧率的理论上限既保证流畅又避免性能雪崩。3.3 EndDragCommand释放捕获并清理状态[RelayCommand] private void EndDrag() { if (_canvas?.IsMouseCaptured true) { _canvas.ReleaseMouseCapture(); } IsDragging false; _dragOffset default; }必须显式ReleaseMouseCapture()否则下次点击其他控件时鼠标事件仍会被本控件劫持。IsDragging是 UI 状态标志用于控制光标样式如Cursor Cursors.SizeAll和禁用其他操作。4. 缩放与旋转以鼠标位置为动态中心的数学推导缩放和旋转的难点不在实现而在“中心点”的动态计算。用户希望双击放大时放大的是鼠标指针下的内容Ctrl滚轮时滚轮位置保持不动旋转时控件绕自己中心转而非 Canvas 原点。这要求我们对每次操作都重新计算变换中心并反向修正Position。4.1 ZoomCommand双线性缩放 中心锚定修正[RelayCommand] private void Zoom(MouseWheelEventArgs e) { if (_canvas null) return; var mousePosInCanvas e.GetPosition(_canvas); // 当前控件在 Canvas 中的包围盒考虑缩放/旋转后的实际像素区域 var bounds GetBoundsInCanvas(); // 此方法见后文 // 判断鼠标是否在控件范围内避免误触发 if (!bounds.Contains(mousePosInCanvas)) return; // 缩放因子向上滚动为放大向下为缩小 double scaleFactor e.Delta 0 ? 1.1 : 0.9; // 【核心】计算缩放中心鼠标在控件本地坐标系中的点 var inverse Transform.Value.Inverse; var centerInLocal inverse.Transform(mousePosInCanvas); // 更新 ScaleX/ScaleY ScaleX * scaleFactor; ScaleY * scaleFactor; // 【关键修正】缩放后让 centerInLocal 这个点在 Canvas 中的坐标不变 // 即新 Position 旧 Position (centerInLocal - 控件中心) * (1 - scaleFactor) var centerX bounds.Width / 2; var centerY bounds.Height / 2; var offset new Vector(centerInLocal.X - centerX, centerInLocal.Y - centerY); Position new Vector(offset.X * (1 - scaleFactor), offset.Y * (1 - scaleFactor)); }GetBoundsInCanvas()方法需返回控件经Transform后在 Canvas 中的实际矩形Rect。由于RenderTransform不改变ActualWidth/Height必须手动用Transform.Value.TransformBounds(new Rect(0,0,Width,Height))计算private Rect GetBoundsInCanvas() { var rect new Rect(0, 0, Width, Height); return Transform.Value.TransformBounds(rect); }这段缩放逻辑的数学本质是保持某个点鼠标位置在世界坐标系Canvas中不动仅改变物体自身的尺度。offset是鼠标相对于控件中心的向量(1 - scaleFactor)是缩放引起的位移补偿系数——这是所有专业图形软件如 Figma、Sketch的通用算法。4.2 RotateCommandShift右键旋转 角度归一化[RelayCommand] private void Rotate(MouseButtonEventArgs e) { if (e.ChangedButton ! MouseButton.Right || !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return; // 右键拖拽旋转计算鼠标移动的弧度 var currentPos e.GetPosition(_canvas); var angleDelta CalculateAngleDelta(currentPos); // 此方法见下文 Rotation angleDelta; // 【强制归一化】避免 Rotation 累积过大如转 10 圈后变成 3600° Rotation ((Rotation % 360) 360) % 360; } private double CalculateAngleDelta(Point currentMousePos) { // 记录上一次鼠标位置计算两点与控件中心构成的夹角变化 var center new Point(Position.X Width / 2, Position.Y Height / 2); var prevVector _prevMousePos default ? new Vector(1, 0) : new Vector(_prevMousePos.X - center.X, _prevMousePos.Y - center.Y); var currVector new Vector(currentMousePos.X - center.X, currentMousePos.Y - center.Y); var anglePrev Math.Atan2(prevVector.Y, prevVector.X); var angleCurr Math.Atan2(currVector.Y, currVector.X); var delta (angleCurr - anglePrev) * 180 / Math.PI; // 弧度转角度 _prevMousePos currentMousePos; return delta; }CalculateAngleDelta使用向量叉积原理计算旋转角度增量比单纯Math.Atan2(dy,dx)更稳定。Rotation归一化用((x % 360) 360) % 360是经典写法确保结果恒在[0,360)区间避免浮点误差累积导致Rotation值爆炸如3600.0000001影响后续动画或序列化。5. 避坑指南那些让你调试到凌晨三点的坐标陷阱这些坑不是文档里写的而是某开发者在模拟项目X中用血泪经验标记出来的。每一条都对应一个真实翻车现场且解决方案已在资源包中验证。5.1 现象拖动时控件“瞬移”或“抖动”尤其在高 DPI 屏幕上原因WPF 默认启用UseLayoutRoundingTrue它会将Canvas.Left/Top四舍五入到物理像素导致小数位坐标被截断。当Position是100.3, 200.7时实际渲染为100,201下一次MouseMove计算又基于100.3,200.7产生 0.3px 的跳跃。解决在根Canvas上显式关闭Canvas UseLayoutRoundingFalse ...。若需抗锯齿改用RenderOptions.SetBitmapScalingMode(this, BitmapScalingMode.HighQuality);。5.2 现象缩放后控件的ActualWidth/ActualHeight不变但RenderSize变了导致Width/Height绑定失效原因ActualWidth/Height是布局系统计算的原始尺寸RenderSize是渲染后的真实像素尺寸。MVVM 中若用Width{Binding ElementNamerect, PathActualWidth}会拿到错误值。解决彻底放弃ActualWidth/Height所有尺寸逻辑统一用Width/Height属性ViewModel 中定义Transform计算。GetBoundsInCanvas()返回的Rect才是真实尺寸。5.3 现象多个控件叠加时ZIndex 不生效后添加的控件总在最上层原因Canvas.ZIndex依赖Panel.ZIndex附加属性但若控件是通过ItemsControlDataTemplate动态生成且DataTemplate中未显式设置Panel.ZIndex{Binding ZIndex}则默认为 0。解决在DataTemplate中强制绑定Rectangle ... Panel.ZIndex{Binding ZIndex} /并在 ViewModel 中确保ZIndex是int类型非string或object否则绑定失败后取默认值 0。5.4 现象旋转后MouseDown事件在控件“空缺处”也能触发HitTest 失效原因RenderTransform不影响HitTest的几何判定WPF 仍用原始矩形判断鼠标是否在内。旋转 45° 后原始矩形远大于实际可见区域。解决重写HitTestCore或更简单——给控件加IsHitTestVisibleFalse改用Canvas的PreviewMouseDown事件做全局 HitTestprivate void Canvas_PreviewMouseDown(object sender, MouseButtonEventArgs e) { var pos e.GetPosition(_canvas); var hitItem Items.FirstOrDefault(x x.GetBoundsInCanvas().Contains(pos)); hitItem?.StartDrag(e); }5.5 现象.NET 6 下CompositeTransform编译报错“找不到类型”原因.NET 6默认不引用WindowsBase中的CompositeTransform它被移到Microsoft.Toolkit.WinUI.UI.ControlsUWP 兼容包但 WPF 项目不能直接用。解决不用CompositeTransform改用TransformGroup如 2.2 节所示它是 WPF 原生支持的且功能完全等价。资源包中所有Transform属性均声明为TransformGroup类型。6. 进阶技巧状态持久化、撤销重做与跨控件协同做到拖动、缩放、旋转只是基础真正体现工程价值的是状态可管理、可追溯、可协作。本节给出三个生产环境必备技巧全部基于 Prism 的IRegionManager和IEventAggregator实现无需第三方库。6.1 序列化所有控件状态JSON 自定义 ConverterDesignItemViewModel的Position、Rotation等属性可直接JsonSerializer.Serialize()但TransformGroup是不可序列化的引用类型。正确做法是只序列化原始参数加载时重建public class DesignItemState { public Point Position { get; set; } public double ScaleX { get; set; } public double ScaleY { get; set; } public double Rotation { get; set; } public int ZIndex { get; set; } public string Type { get; set; } // Rectangle, Image, etc. } // 保存 var states Items.Select(x new DesignItemState { Position x.Position, ScaleX x.ScaleX, ScaleY x.ScaleY, Rotation x.Rotation, ZIndex x.ZIndex, Type x.GetType().Name }).ToList(); File.WriteAllText(scene.json, JsonSerializer.Serialize(states)); // 加载 var loadedStates JsonSerializer.DeserializeListDesignItemState(json); foreach (var state in loadedStates) { var item CreateItemFromType(state.Type); // 工厂方法 item.Position state.Position; item.ScaleX state.ScaleX; item.ScaleY state.ScaleY; item.Rotation state.Rotation; item.ZIndex state.ZIndex; Items.Add(item); }注意CreateItemFromType()必须根据Type字符串反射创建 ViewModel 实例并注入所需依赖如_canvas。Prism 的ServiceLocator可胜任此任务。6.2 撤销/重做栈用 Stack 管理操作原子性每个拖动、缩放、旋转操作都应封装为一个ICommand其Execute()中记录操作前状态Undo()中恢复public record MoveCommand(DesignItemViewModel Item, Point OldPosition, Point NewPosition) : IUndoableCommand { public void Execute() Item.Position NewPosition; public void Undo() Item.Position OldPosition; } // 全局撤销管理器 public class UndoManager { private readonly StackIUndoableCommand _undoStack new(); private readonly StackIUndoableCommand _redoStack new(); public void Execute(IUndoableCommand cmd) { cmd.Execute(); _undoStack.Push(cmd); _redoStack.Clear(); // 重做栈清空 } public void Undo() _undoStack.TryPop(out var cmd) (cmd.Undo(), _redoStack.Push(cmd)); public void Redo() _redoStack.TryPop(out var cmd) (cmd.Execute(), _undoStack.Push(cmd)); }在StartDragCommand中记录初始Position在EndDragCommand中提交MoveCommand即可实现精准撤销。同理可扩展ScaleCommand、RotateCommand。6.3 跨控件协同选中多个控件并统一缩放/旋转Prism 的IEventAggregator可发布“选中变更”事件让所有控件 ViewModel 监听// 发布选中事件 _eventAggregator.GetEventSelectionChangedEvent().Publish(new SelectionChangedEventArgs { SelectedItems new ListDesignItemViewModel { this }, IsMultiSelect Keyboard.Modifiers.HasFlag(ModifierKeys.Control) }); // 在 ViewModel 中订阅 _eventAggregator.GetEventSelectionChangedEvent() .Subscribe(OnSelectionChanged); private void OnSelectionChanged(SelectionChangedEventArgs args) { IsSelected args.SelectedItems.Contains(this); if (IsSelected args.IsMultiSelect) { // 将自己加入共享的 MultiSelectManager _multiSelectManager.AddItem(this); } }MultiSelectManager维护一个ListDesignItemViewModel当用户对任一选中控件执行缩放时遍历列表对每个控件应用相同的scaleFactor但各自计算自己的缩放中心即各自的GetBoundsInCanvas().Center从而实现“整体缩放局部锚定”。从那以后我每次做 WPF 交互开发都强制走一遍坐标系对齐检查先确认RenderTransformOrigin再验证Inverse.Transform()是否能正确映射鼠标坐标最后用GetBoundsInCanvas()打印实际包围盒——这三步做完90% 的拖动错位、缩放偏移、旋转失焦问题就消失了。希望帮到你。本文还有配套的精品资源点击获取