简介:面向 WPF 桌面端开发者与计算机视觉标注工具初学者,这份示例工程围绕 Prism.DryIoc 的 MVVM 架构,演示在画布上动态添加控件,并实现鼠标拖动、缩放与旋转,可用于视频电子围栏、目标框选等区域标注场景。压缩包共 752 个文件、约 1.62MB,以 160 个 C# 源码、4 个 XAML/BAML 界面文件及若干 csproj、json、dll、pdb 等工程与运行依赖为主,另含大量构建缓存和编辑器配置,便于直接还原项目结构。已有 1523 人学习,读者可从中了解 ItemsControl 控件模板、Thumb 拖拽、Adorner 装饰器、CommandParameter 多参数传递以及按 UID 查找子控件等实现思路,适合作为区域标注 Demo 的参考与二次开发起点。
1. 从一块能拖能转的设计画布说起:WPF 动态添加控件难在哪
在 WPF 里做 WPF 动态添加控件的设计器画布,表面看只用 Canvas 摆几个元素、在鼠标事件里改 Left/Top 就够了。真动手时,拖动、缩放、旋转一旦叠在一起,坐标系、变换中心、事件冒泡会同时出现冲突,那种“明明公式没错,元素却越拖越偏”的抓狂感,做过的都懂。这份基于 .NET 6 + Prism + MVVM 的源码,把整套交互完整跑通了:从工具栏点一下,画布上立刻多出一个元素,元素能被选中、按住鼠标拖走、捏角缩放、绕中心旋转,而且所有状态都落在 ViewModel 的属性里,不需要在后台写一堆字段。它适合正在做可视化组态、报表设计器、白板批注这类工具的开发者,尤其是想把交互从“事件处理混乱”中抽出来的团队。
2. 工程骨架拆解:Prism 的 Region、模块与 MVVM 分层怎么落地
先看整体,不然拿到源码会不知道从哪个文件下手。这类项目最怕的就是把拖动逻辑写进 View 的 code-behind,写着写着一个 800 行的.xaml.cs就出来了,改一个缩放行为牵动半个页面。这套结构用附加行为和 Prism 的依赖注入把职责切开,View 只负责“长什么样”,行为只负责“鼠标怎么响应”,模型只负责“状态存什么”。
2.1 目录结构与职责边界
我一般把这类画布项目拆成下面几层,源码基本也是这个骨架:
WpfCanvasDesigner/ ├── App.xaml / App.xaml.cs # PrismApplication 入口,注册类型与 Region ├── Views/ │ ├── MainWindow.xaml # 外壳,两个 Region 占位 │ ├── CanvasView.xaml # 画布,ItemsControl + Canvas │ └── ToolboxView.xaml # 左侧控件库 ├── ViewModels/ │ ├── MainWindowViewModel.cs │ ├── CanvasViewModel.cs # Items 集合、选中项、增删命令 │ └── ToolboxViewModel.cs # 发布“加一个控件”的事件 ├── Models/ │ └── DesignItem.cs # 元素数据镜像:坐标、尺寸、角度、缩放 ├── Behaviors/ │ ├── TransformBehavior.cs # 把模型里的角度/缩放刷到 RenderTransform │ ├── DragBehavior.cs # 拖动 │ ├── ResizeBehavior.cs # 角手柄缩放 │ └── RotateBehavior.cs # 旋转手柄 ├── Events/ │ └── AddItemEvent.cs # Prism EventAggregator 的载荷 └── Services/ └── DesignItemFactory.cs # 按类型名造元素关键分界在Behaviors。拖动这类逻辑必须知道鼠标位置和 Canvas 坐标,天然属于视图层,但它又不能直接改Canvas.Left,否则数据就只存活在可视化树里,保存和还原都会丢。所以行为只做一件事:把鼠标位移换算成坐标,写回DesignItem的属性,绑定链路自动把 UI 刷新。这个“行为写模型、绑定刷 UI”的单向流,是后面所有交互能复用的前提。
Models里存的是纯数据,不引用任何控件类型。这一点在二次开发时价值很大:想换成导出图片、存 JSON、做撤销重做,都只需要围绕这一个对象转。
2.2 启动流程:从 PrismApplication 到 Region 注册
Prism 的入口不是普通的Application,而是PrismApplication。它接管了容器创建、Shell 解析和 Region 管理器初始化,所以App.xaml里的根节点要换成 Prism 提供的类型,App.xaml.cs继承PrismApplication并重写三个方法:
// App.xaml.cs public partial class App : PrismApplication { // 1. 解析主窗口,Prism 会自动把它设为 Application.Current.MainWindow protected override Window CreateShell() => Container.Resolve<MainWindow>(); // 2. 注册服务与视图,容器用 DryIoc protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingleton<IDesignItemFactory, DesignItemFactory>(); containerRegistry.RegisterForNavigation<CanvasView>("CanvasView"); containerRegistry.RegisterForNavigation<ToolboxView>("ToolboxView"); } // 3. Shell 建好之后再把视图注入 Region protected override void OnInitialized() { RegionManager.RegisterViewWithRegion("CanvasRegion", typeof(CanvasView)); RegionManager.RegisterViewWithRegion("ToolboxRegion", typeof(ToolboxView)); base.OnInitialized(); } }CreateShell返回的窗口就是主界面,Prism 负责把它显示出来。RegisterForNavigation让视图可以通过名称被导航或注入,RegisterViewWithRegion则是“打开就塞进去”的静态注入,适合工具箱和画布这种常驻面板。
Shell 里两个 Region 就是普通ContentControl,只多一个附加属性:
<Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="220"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <ContentControl Grid.Column="0" prism:RegionManager.RegionName="ToolboxRegion"/> <ContentControl Grid.Column="1" prism:RegionManager.RegionName="CanvasRegion"/> </Grid>View 和 ViewModel 的对应关系不写代码,靠命名约定:Views.CanvasView自动找到ViewModels.CanvasViewModel。前提是 View 根节点上写了prism:ViewModelLocator.AutoWireViewModel="True",且命名空间符合Xxx.Views/Xxx.ViewModels的规则。这条约定踩过一次坑就记住了:命名空间改了一半,ViewModel 就静默不注入,界面上什么都不报错,只是绑定全是空。
版本上有个现实问题:Prism 9 之后授权方式做过调整,很多团队还停在 8.1.x 配 .NET 6。新项目开工前先确认一下版本策略,比写到一半发现包不能商用要省事得多。
2.3 控件描述模型:一个 DesignItem 要存哪些字段
整个项目的地基就是这个类。它的字段设计决定了后面拖动、缩放、旋转能不能写得干净:
using Prism.Mvvm; namespace WpfCanvasDesigner.Models { // 画布上每个可视元素的数据镜像,UI 只负责渲染它 public class DesignItem : BindableBase { private double _x; public double X { get => _x; set => SetProperty(ref _x, value); } private double _y; public double Y { get => _y; set => SetProperty(ref _y, value); } private double _width = 160; public double Width { get => _width; set => SetProperty(ref _width, value); } private double _height = 90; public double Height { get => _height; set => SetProperty(ref _height, value); } private double _angle; public double Angle { get => _angle; set => SetProperty(ref _angle, value); } // 度,顺时针为正 private double _scaleX = 1; public double ScaleX { get => _scaleX; set => SetProperty(ref _scaleX, value); } private double _scaleY = 1; public double ScaleY { get => _scaleY; set => SetProperty(ref _scaleY, value); } private bool _isSelected; public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected, value); } private int _zIndex = 1; public int ZIndex { get => _zIndex; set => SetProperty(ref _zIndex, value); } public string TypeName { get; set; } = "TextBlock"; // 决定用哪个 DataTemplate public string Text { get; set; } = "新元素"; public string Id { get; set; } = Guid.NewGuid().ToString("N"); } }坐标和尺寸分开存,是因为Canvas.Left/Top管位置、Width/Height管布局尺寸,这两组值永远不受变换影响;角度和缩放单独存,是为了让变换成为“渲染层的附加信息”,随时可以清零而不破坏布局。TypeName不存控件实例只存字符串,模型就能脱离 WPF 类型独立序列化,这也是后面能一键存 JSON 的原因。
SetProperty来自BindableBase,值变了才触发PropertyChanged。别小看这个判断:拖动时鼠标每移动一像素就赋值一次,如果无脑RaisePropertyChanged,绑定层会被无效刷新淹没,几百个元素同屏时直接掉帧。
3. 动态添加控件的完整链路:从工具栏命令到画布上多出一个元素
“动态添加”四个字听着玄乎,拆开就是三步:工具箱发一个信号,画布 ViewModel 往集合里塞一个对象,ItemsControl 收到集合变更通知后生成容器。中间不能有一步是手工new Button()然后Children.Add(),否则数据源和界面就成了两套账。
3.1 用 ItemsControl + Canvas 做动态容器
容器选ItemsControl而不是Canvas直接塞子元素,核心原因是它自带数据驱动:集合加一项,界面自动多一个;集合删一项,容器自动回收。面板模板换成Canvas就能保留绝对定位能力:
<ItemsControl ItemsSource="{Binding Items}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <!-- 背景必须是 Transparent,否则空白区域收不到鼠标事件 --> <Canvas Background="Transparent" ClipToBounds="True"/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding X}"/> <Setter Property="Canvas.Top" Value="{Binding Y}"/> <Setter Property="Panel.ZIndex" Value="{Binding ZIndex}"/> <!-- 三个附加行为挂在容器上,DataContext 就是 DesignItem --> <Setter Property="b:DragBehavior.IsEnabled" Value="True"/> <Setter Property="b:TransformBehavior.Angle" Value="{Binding Angle}"/> <Setter Property="b:TransformBehavior.ScaleX" Value="{Binding ScaleX}"/> <Setter Property="b:TransformBehavior.ScaleY" Value="{Binding ScaleY}"/> </Style> </ItemsControl.ItemContainerStyle> <ItemsControl.ItemTemplate> <DataTemplate> <Grid Width="{Binding Width}" Height="{Binding Height}"> <ContentControl Content="{Binding}" ContentTemplateSelector="{StaticResource ItemTemplateSelector}"/> <!-- 手柄层只在选中时出现 --> <Grid Visibility="{Binding IsSelected, Converter={StaticResource BoolToVis}}"> <Rectangle Stroke="#2F80ED" StrokeThickness="1" Fill="Transparent"/> <Thumb Width="10" Height="10" Margin="-5,-5,0,0" Cursor="SizeNWSE" HorizontalAlignment="Left" VerticalAlignment="Top" b:ResizeBehavior.Corner="TopLeft"/> <Thumb Width="10" Height="10" Margin="0,-5,-5,0" Cursor="SizeNESW" HorizontalAlignment="Right" VerticalAlignment="Top" b:ResizeBehavior.Corner="TopRight"/> <Thumb Width="12" Height="12" Margin="0,-18,0,0" Cursor="Hand" HorizontalAlignment="Center" VerticalAlignment="Top" b:RotateBehavior.IsEnabled="True"/> </Grid> </Grid> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>几个参数必须留意。ItemContainerStyle的TargetType是ContentPresenter,不是DesignItem——写错类型,样式不报错但完全不生效,这是最容易卡住的一步。Canvas.Left绑定默认就是单向的,行为层改了DesignItem.X会推上来,反过来不需要。Panel.ZIndex挂在容器上才管用,挂在模板内部元素上只影响同一容器内的层叠。
手柄用的是Thumb而不是Border + MouseDown。Thumb自带DragDelta事件和鼠标捕获,缩放和旋转都不需要自己算按下/抬起状态,少写一半代码,也顺手绕开了“拖手柄却把整个元素拖走”这个经典冲突。
3.2 命令绑定与 IEventAggregator 解耦工具栏与画布
工具箱和画布是两块互不认识的视图,硬连引用会让它们互相依赖。稳妥做法是用 Prism 的事件聚合器发一条消息,谁关心谁订阅:
// Events/AddItemEvent.cs public class AddItemEvent : PubSubEvent<string> { } // ViewModels/ToolboxViewModel.cs public class ToolboxViewModel { private readonly IEventAggregator _ea; public ToolboxViewModel(IEventAggregator ea) => _ea = ea; private DelegateCommand<string> _pickCommand; // 参数是控件类型名,由 XAML 里的 CommandParameter 传入 public DelegateCommand<string> PickCommand => _pickCommand ??= new DelegateCommand<string>( type => _ea.GetEvent<AddItemEvent>().Publish(type)); }画布这边订阅同一事件,并在收到后完成“造对象 + 摆放 + 选中”三件事:
public class CanvasViewModel : BindableBase { private readonly IDesignItemFactory _factory; public ObservableCollection<DesignItem> Items { get; } = new(); private DesignItem _selectedItem; public DesignItem SelectedItem { get => _selectedItem; set { if (SetProperty(ref _selectedItem, value)) RefreshSelection(); } } public DelegateCommand<string> AddItemCommand { get; } public DelegateCommand DeleteSelectedCommand { get; } public CanvasViewModel(IEventAggregator ea, IDesignItemFactory factory) { _factory = factory; AddItemCommand = new DelegateCommand<string>(AddItem); DeleteSelectedCommand = new DelegateCommand(DeleteSelected, () => SelectedItem != null) .ObservesProperty(() => SelectedItem); // 选中项为空时命令自动禁用 ea.GetEvent<AddItemEvent>().Subscribe(AddItem); } private void AddItem(string typeName) { var item = _factory.Create(typeName); // 按已有数量错位摆放,避免新元素完全重叠在一起 item.X = 40 + Items.Count % 6 * 24; item.Y = 40 + Items.Count % 6 * 24; item.ZIndex = Items.Count + 1; Items.Add(item); SelectedItem = item; } private void RefreshSelection() { foreach (var it in Items) it.IsSelected = ReferenceEquals(it, SelectedItem); } }ObservesProperty是 Prism 里很实用的一招:删除命令的可用性跟着选中项走,不需要手动RaiseCanExecuteChanged。AddItem里的错位摆放看着像凑数,实际上没有它,连点五次同一个按钮,屏幕上只看得见一个元素,使用者会以为功能坏了。
3.3 选中态与 DataTemplateSelector:一个模型渲染不同控件
TypeName决定渲染成什么,靠的是DataTemplateSelector:
public class ItemTemplateSelector : DataTemplateSelector { public DataTemplate TextTemplate { get; set; } public DataTemplate ButtonTemplate { get; set; } public DataTemplate ImageTemplate { get; set; } public override DataTemplate SelectTemplate(object item, DependencyObject container) => item is DesignItem d ? d.TypeName switch { "Button" => ButtonTemplate, "Image" => ImageTemplate, _ => TextTemplate } : base.SelectTemplate(item, container); }三个DataTemplate在资源字典里定义,各自把Text之类的字段绑上去。选中态不靠样式触发器,而是直接绑IsSelected控制手柄层显隐——因为手柄层是模板的一部分,用DataTrigger也能做,但多写十几行样式,不如一个布尔绑定直观。
这里有个隐形约束:RefreshSelection里遍历整个集合同步IsSelected,元素上千时是 O(n) 级别的属性写入。真到那个量级,得改成只处理“上一个选中 + 当前选中”两个对象,别每次都全量刷。
4. 拖动、缩放、旋转的实现:RenderTransform 组合与手柄命中
交互层的核心就一句话:位置靠Canvas.Left/Top,形变靠RenderTransform,两者互不干扰。搞混这一条,后面全是坑。
4.1 为什么选 RenderTransform 而不是 LayoutTransform
WPF 提供两套变换,差别不在效果,在发生时机:
| 对比项 | LayoutTransform | RenderTransform |
|---|---|---|
| 生效阶段 | 布局测量之前 | 布局完成之后 |
| 是否影响父容器排布 | 会,旋转后占位跟着变 | 不会,占位保持原尺寸 |
| 变换中心 | 由布局决定 | 由RenderTransformOrigin决定 |
| 适合场景 | 静态排版里的倾斜标题 | 画布上的拖动/缩放/旋转元素 |
画布上必须用RenderTransform。用LayoutTransform旋转一个 30 度的元素,它在 Canvas 里的占位框会跟着膨胀,相邻元素的位置全被带着走,坐标算到崩溃。RenderTransform只在渲染层做矩阵乘法,Canvas.Left/Top的语义完全不变。
组合顺序也有讲究。我一般先缩放再旋转:ScaleTransform在前,RotateTransform在后,缩放沿着元素自身的轴进行,视觉上更符合“先定大小、再定朝向”的直觉。顺序反了,非等比缩放会出现斜切效果,调起来非常麻烦。
4.2 TransformBehavior:把模型数据刷成变换矩阵
RenderTransform是依赖属性,但TransformGroup里的子变换不好直接绑。常见的做法有两种:写值转换器返回新的TransformGroup,或者用附加行为统一维护。前者每次刷新都新建对象,元素一多 GC 压力明显;后者只改子变换的数值,我一般选这个:
public static class TransformBehavior { public static readonly DependencyProperty AngleProperty = DependencyProperty.RegisterAttached("Angle", typeof(double), typeof(TransformBehavior), new PropertyMetadata(0d, OnTransformChanged)); public static readonly DependencyProperty ScaleXProperty = DependencyProperty.RegisterAttached("ScaleX", typeof(double), typeof(TransformBehavior), new PropertyMetadata(1d, OnTransformChanged)); public static readonly DependencyProperty ScaleYProperty = DependencyProperty.RegisterAttached("ScaleY", typeof(double), typeof(TransformBehavior), new PropertyMetadata(1d, OnTransformChanged)); private static void OnTransformChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not FrameworkElement element) return; if (element.RenderTransform is not TransformGroup group || group.IsFrozen) { group = new TransformGroup(); group.Children.Add(new ScaleTransform(1, 1)); // 索引 0:缩放 group.Children.Add(new RotateTransform(0)); // 索引 1:旋转 element.RenderTransform = group; // 关键一行:变换中心钉在元素中心,缩放和旋转都不会跑偏 element.RenderTransformOrigin = new Point(0.5, 0.5); } ((ScaleTransform)group.Children[0]).ScaleX = GetScaleX(element); ((ScaleTransform)group.Children[0]).ScaleY = GetScaleY(element); ((RotateTransform)group.Children[1]).Angle = GetAngle(element); } public static void SetAngle(DependencyObject o, double v) => o.SetValue(AngleProperty, v); public static double GetAngle(DependencyObject o) => (double)o.GetValue(AngleProperty); // ScaleX / ScaleY 的 Get/Set 同理,省略 }RenderTransformOrigin设成(0.5, 0.5)是整个交互体验的分水岭。不设它,默认原点是左上角,缩放时元素会朝右下角“长”,旋转时像被钉住左上角甩起来,用户第一反应就是“这控件坏了”。设好之后,缩放是围绕中心放大,旋转是原地打转,符合直觉。
顺带一提IsFrozen判断。TransformGroup继承自Freezable,一旦被冻结就不能再改子项,赋值会抛异常。行为里加这个判断,是因为有些样式或模板会把变换冻结以提升性能,不加会偶发崩溃,属于特别难查的那类问题。
4.3 拖动:鼠标捕获与 Canvas 坐标系换算
拖动的全部难点在坐标系上。用e.GetPosition(this)拿相对元素自身的坐标,元素一旦被旋转或缩放,这个坐标系就是歪的,位移方向会跟着元素转,表现出来就是“斜着拖”。正确做法是全程只用画布坐标系:
public class DragBehavior : Behavior<FrameworkElement> { private Canvas _canvas; private DesignItem _item; private Point _origin; // 按下时鼠标在 Canvas 坐标系里的点 private double _startX, _startY; private bool _dragging; protected override void OnAttached() { AssociatedObject.MouseLeftButtonDown += OnMouseDown; AssociatedObject.MouseMove += OnMouseMove; AssociatedObject.MouseLeftButtonUp += OnMouseUp; } private void OnMouseDown(object sender, MouseButtonEventArgs e) { // 手柄自己处理事件,这里放行,避免“点手柄却拖走整个元素” if (e.OriginalSource is Thumb) return; _item = AssociatedObject.DataContext as DesignItem; _canvas = FindParentCanvas(AssociatedObject); if (_item == null || _canvas == null) return; _origin = e.GetPosition(_canvas); // 基准点只取一次 _startX = _item.X; _startY = _item.Y; _dragging = true; AssociatedObject.CaptureMouse(); // 鼠标移出元素范围也不丢事件 e.Handled = true; } private void OnMouseMove(object sender, MouseEventArgs e) { if (!_dragging) return; var current = e.GetPosition(_canvas); var dx = current.X - _origin.X; var dy = current.Y - _origin.Y; // 画布整体被缩放过时,屏幕位移要换算回画布坐标 var zoom = GetCanvasZoom(_canvas); _item.X = Snap(_startX + dx / zoom, 8); _item.Y = Snap(_startY + dy / zoom, 8); } private void OnMouseUp(object sender, MouseButtonEventArgs e) { if (!_dragging) return; _dragging = false; AssociatedObject.ReleaseMouseCapture(); e.Handled = true; } // 网格吸附,grid 为 0 表示不吸附 private static double Snap(double value, double grid) => grid <= 0 ? value : Math.Round(value / grid) * grid; private static Canvas FindParentCanvas(DependencyObject node) { while (node != null && node is not Canvas) node = VisualTreeHelper.GetParent(node); return node as Canvas; } }几个参数值得说清。_origin只在按下时取一次,之后每次移动都用“当前点减起点”算增量,而不是累加每次的delta——累加会把每次的舍入误差攒起来,拖十分钟后元素能偏出去几十像素。CaptureMouse保证鼠标快速甩出元素边界时事件不断,ReleaseMouseCapture必须成对调用,漏了会导致整个界面后续都收不到点击。Snap的第二个参数是网格大小,想要自由拖动就传 0,想要整格对齐就传 8 或 10。
dx / zoom这一步容易被忽略。画布如果外面套了一层ScaleTransform做整体缩放,鼠标位移是屏幕像素,而Canvas.Left是画布逻辑像素,两者差一个倍数。常见做法是给Canvas挂一个附加属性记录当前缩放比,拖动时读出来除一下。
4.4 缩放与旋转:角手柄的等比算法与绕中心旋转
缩放用Thumb.DragDelta,算法是“鼠标到中心的距离比”,比逐像素改宽高稳定得多——改宽高会触发重新布局,元素内容一重就卡:
public class ResizeBehavior : Behavior<Thumb> { private Canvas _canvas; private DesignItem _item; private Point _center; private double _startDistance, _startScale; protected override void OnAttached() { AssociatedObject.DragStarted += OnDragStarted; AssociatedObject.DragDelta += OnDragDelta; } private void OnDragStarted(object sender, DragStartedEventArgs e) { _item = AssociatedObject.DataContext as DesignItem; _canvas = FindParentCanvas(AssociatedObject); if (_item == null || _canvas == null) return; // 缩放中心 = 元素未经变换时的几何中心 _center = new Point(_item.X + _item.Width / 2, _item.Y + _item.Height / 2); _startDistance = (Mouse.GetPosition(_canvas) - _center).Length; _startScale = _item.ScaleX; } private void OnDragDelta(object sender, DragDeltaEventArgs e) { if (_item == null) return; var current = Mouse.GetPosition(_canvas); var ratio = (current - _center).Length / Math.Max(_startDistance, 1); // 等比缩放,下限 0.2,防止缩成一条线后再也点不到 var next = Math.Max(0.2, _startScale * ratio); _item.ScaleX = Math.Round(next, 3); _item.ScaleY = _item.ScaleX; } }Math.Max(_startDistance, 1)是防除零。按下时鼠标刚好压在中心点,距离为 0,不拦一下直接除出Infinity,元素瞬间消失。下限 0.2 是血泪经验:允许缩到 0 之后,元素视觉上没了但还在集合里,选不中也删不掉,只能清空重来。
旋转用Atan2算角度差,注意手柄默认在元素正上方,所以要补 90 度:
public class RotateBehavior : Behavior<Thumb> { private Canvas _canvas; private DesignItem _item; private Point _center; protected override void OnAttached() { AssociatedObject.DragStarted += OnDragStarted; AssociatedObject.DragDelta += OnDragDelta; } private void OnDragStarted(object sender, DragStartedEventArgs e) { _item = AssociatedObject.DataContext as DesignItem; _canvas = FindParentCanvas(AssociatedObject); if (_item == null) return; _center = new Point(_item.X + _item.Width / 2, _item.Y + _item.Height / 2); } private void OnDragDelta(object sender, DragDeltaEventArgs e) { if (_item == null) return; var p = Mouse.GetPosition(_canvas); var rad = Math.Atan2(p.Y - _center.Y, p.X - _center.X); var angle = rad * 180 / Math.PI + 90; // 手柄朝上,补 90 度 // 按住 Shift 以 15 度为步长吸附,做对齐时非常顺手 if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) angle = Math.Round(angle / 15) * 15; _item.Angle = (angle % 360 + 360) % 360; // 归一到 0~360 } }(angle % 360 + 360) % 360这个写法是为了处理负角度。C# 的取模对负数返回负值,不归一的话属性面板上会出现-270这种数字,看着就不像正经设计器。
5. 排查清单:拖动偏移、变换中心错乱与手柄失灵的五种典型现象
这套东西跑起来之后,真正花时间的不是写功能,是修那些“看起来能用但手感不对”的问题。下面五条是我在这个项目里踩得最实的。
5.1 现象:元素越拖越偏,松手后还会弹一下
原因:用元素自身作坐标参考系,或者把每次MouseMove的delta累加进坐标。前者在元素旋转后方向全错,后者把舍入误差一点点攒起来。
解决:基准点只取一次,位移用“当前点减基准点”的绝对值算。同时确认e.GetPosition()的入参是Canvas而不是AssociatedObject。改完这两个,偏移问题基本绝迹。
5.2 现象:缩放时元素朝右下角生长,旋转像被甩起来
原因:RenderTransformOrigin没设,默认原点是左上角(0,0)。父级Grid又有HorizontalAlignment="Left",两个因素叠在一起,视觉中心彻底跑偏。
解决:显式设置element.RenderTransformOrigin = new Point(0.5, 0.5)。如果元素内容本身有Margin或Padding,还要确认Width/Height是内容区尺寸而不是含边距的尺寸,否则中心点会偏几个像素,放大后很明显。
5.3 现象:拖右下角手柄,整个元素跟着鼠标平移
原因:Thumb的DragDelta事件冒泡到了外层容器的MouseMove,拖动逻辑被触发;DragBehavior里又没拦OriginalSource。
解决:两处都要处理。Thumb内部把e.Handled置位,DragBehavior的OnMouseDown里加if (e.OriginalSource is Thumb) return;。只做一处经常因为模板内部结构不同而漏掉。
5.4 现象:集合里加了元素,界面上不出现
原因:Items声明成了List<DesignItem>或IEnumerable,没有变更通知;或者ItemContainerStyle的TargetType写成了DesignItem,样式静默失效,Canvas.Left全变 0,十几个元素全堆在左上角看起来像只有一个。
解决:集合固定用ObservableCollection<T>,容器样式TargetType必须是ContentPresenter。排查时打开“实时可视化树”看一眼容器类型,比盲猜快得多。
5.5 现象:元素过百之后拖动明显卡顿
原因:每个元素模板里都渲染了四个角手柄加一个旋转手柄,即使不可见也在可视化树里;ItemsControl的Canvas面板不做虚拟化,全部容器常驻。
解决:手柄层只在选中时创建,可以用ContentControl+DataTemplate延迟加载;或者把Visibility换成Collapsed让布局跳过。真要上千元素,就得自己写一个只在视口
本文还有配套的精品资源,点击获取