news 2026/10/14 21:16:19

C#实现Visio风格图形拖拽:状态机驱动的图形系统设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现Visio风格图形拖拽:状态机驱动的图形系统设计

简介:本资源是一套基于C# Windows Forms实现的轻量级图形化拖拽开发示例,面向GUI初学者、OA系统开发者及流程图工具学习者,解决在桌面应用中构建可交互节点与动态连线的核心需求。项目完整实现了类似Visio的节点拖拽、连接线实时跟随、自定义图形(如RectangleNode)与接口抽象(INode/ILine),涵盖从Panel绘图区域搭建、鼠标事件处理、双缓冲防闪烁到线条位置自动更新的全流程实践。压缩包共44个文件,含16个核心C#源码(如VisioControl.cs、Form1.resx、Program.cs)、3个可执行exe、6个依赖dll及项目配置文件(.sln/.csproj/.settings),总大小329KB,结构清晰,开箱即用。目前已有1704人学习下载,读者可直接运行调试、理解拖拽状态管理逻辑、复用INode/ILine接口设计思想,并基于现有代码快速扩展旋转、缩放或多种连接线样式等进阶功能。

1. C# 类似Visio图形拖拽实现:不是画布控件堆砌,而是状态机驱动的交互闭环

你有没有试过在 WinForm 或 WPF 里放一个 Panel,再往里面Controls.Add(new PictureBox()),然后监听MouseDown/MouseMove去手动算坐标、改Location?结果是:拖动卡顿、缩放后偏移、多选框拉不正、连接线粘不住节点、一加滚动条就全乱套——这根本不是 Visio 那种“所见即所得”的拖拽体验,只是个会动的贴图。C# 类似Visio图形拖拽实现,本质不是“让控件动起来”,而是构建一套可预测、可撤销、可扩展的状态驱动图形系统:每个图形是带语义的数据对象(不是 UI 控件),拖拽是状态变更事件(不是像素位移),渲染与交互解耦,缩放/平移/选择/连接全部由统一坐标系和命中检测支撑。它适合正在开发工业组态软件、流程建模工具、网络拓扑编辑器或低代码画布的 C# 开发者,尤其当你已踩过“用 PictureBox 模拟节点”“用 LineShape 连线”这类坑,开始意识到:UI 控件即数据模型这条路,走到最后只能重写。下面我带你从零搭起这个闭环——不用第三方库,纯 .NET 原生,WinForm 和 WPF 双路径可复用核心逻辑。


2. 图形模型设计:用可序列化的 ShapeBase 统一所有图形语义

Visio 的核心不是“画什么”,而是“它是什么”。一个矩形可能是“服务器”,一条线可能是“TCP 连接”,它们有类型、ID、属性、连接点。若直接用Control或UIElement承载业务逻辑,很快会陷入“改一个样式要遍历所有子控件”“保存时还得反向提取属性”的泥潭。正确做法是:先定义图形数据模型,再决定怎么渲染它。

2.1 ShapeBase:所有图形的基类,只存数据,不碰 UI

public abstract class ShapeBase : INotifyPropertyChanged, ICloneable { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "NewShape"; public double X { get; set; } = 0; public double Y { get; set; } = 0; public double Width { get; set; } = 100; public double Height { get; set; } = 60; public double Rotation { get; set; } = 0; // 支持旋转 public bool IsSelected { get; set; } public List<ConnectionPoint> ConnectionPoints { get; set; } = new(); // 用于序列化/反序列化,不依赖 UI 层 public virtual Dictionary<string, object> ToDictionary() { return new Dictionary<string, object> { ["Id"] = Id.ToString(), ["Name"] = Name, ["X"] = X, ["Y"] = Y, ["Width"] = Width, ["Height"] = Height, ["Rotation"] = Rotation, ["Type"] = this.GetType().Name }; } public virtual void FromDictionary(Dictionary<string, object> dict) { if (dict.TryGetValue("Id", out var idObj)) Id = Guid.Parse(idObj.ToString()); if (dict.TryGetValue("Name", out var nameObj)) Name = nameObj.ToString(); if (dict.TryGetValue("X", out var xObj)) X = Convert.ToDouble(xObj); if (dict.TryGetValue("Y", out var yObj)) Y = Convert.ToDouble(yObj); if (dict.TryGetValue("Width", out var wObj)) Width = Convert.ToDouble(wObj); if (dict.TryGetValue("Height", out var hObj)) Height = Convert.ToDouble(hObj); if (dict.TryGetValue("Rotation", out var rObj)) Rotation = Convert.ToDouble(rObj); } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public virtual object Clone() => MemberwiseClone(); }

提示:ShapeBase不继承任何 UI 类型,不引用System.Drawing或System.Windows.Media。它只负责描述“图形长什么样、在哪、连着谁”。这样做的好处是:导出 JSON 时直接JsonSerializer.Serialize(shapes);做 Undo/Redo 时只需深拷贝ShapeBase实例;切换到 WPF 渲染时,模型层完全不动。

2.2 具体图形实现:RectangleShape、LineShape 与 ConnectionPoint

Visio 风格的关键在于“连接点”(Connection Point)——不是随便在线上点一下就连线,而是图形预设 4~8 个锚点(如顶部中点、右部中点),连线只能吸附到这些点上。我们用ConnectionPoint结构体封装:

public struct ConnectionPoint { public string Name { get; set; } // "Top", "Right", "Bottom", "Left" public double OffsetX { get; set; } // 相对图形左上角的 X 偏移(0~1) public double OffsetY { get; set; } // 相对图形左上角的 Y 偏移(0~1) public bool IsInput { get; set; } // 是否允许被其他图形连接(入端口) public bool IsOutput { get; set; } // 是否允许连接到其他图形(出端口) public Point GetAbsolutePosition(ShapeBase shape) { return new Point( shape.X + shape.Width * OffsetX, shape.Y + shape.Height * OffsetY ); } }

然后实现具体图形。以RectangleShape为例,它在构造时自动注册 4 个标准连接点:

public class RectangleShape : ShapeBase { public RectangleShape() { // 标准四向连接点 ConnectionPoints.Add(new ConnectionPoint { Name = "Top", OffsetX = 0.5, OffsetY = 0, IsInput = true, IsOutput = true }); ConnectionPoints.Add(new ConnectionPoint { Name = "Right", OffsetX = 1, OffsetY = 0.5, IsInput = true, IsOutput = true }); ConnectionPoints.Add(new ConnectionPoint { Name = "Bottom", OffsetX = 0.5, OffsetY = 1, IsInput = true, IsOutput = true }); ConnectionPoints.Add(new ConnectionPoint { Name = "Left", OffsetX = 0, OffsetY = 0.5, IsInput = true, IsOutput = true }); } public override Dictionary<string, object> ToDictionary() { var dict = base.ToDictionary(); dict["Type"] = "RectangleShape"; return dict; } }

而LineShape则不同:它没有宽高,只有起点和终点(两个ConnectionPoint引用),且必须绑定到两个ShapeBase实例上:

public class LineShape : ShapeBase { public Guid SourceShapeId { get; set; } public string SourcePointName { get; set; } // 如 "Right" public Guid TargetShapeId { get; set; } public string TargetPointName { get; set; } // 如 "Left" // 运行时计算的绝对坐标(用于渲染) public Point StartPoint { get; set; } public Point EndPoint { get; set; } public override Dictionary<string, object> ToDictionary() { var dict = base.ToDictionary(); dict["Type"] = "LineShape"; dict["SourceShapeId"] = SourceShapeId.ToString(); dict["SourcePointName"] = SourcePointName; dict["TargetShapeId"] = TargetShapeId.ToString(); dict["TargetPointName"] = TargetPointName; return dict; } public override void FromDictionary(Dictionary<string, object> dict) { base.FromDictionary(dict); if (dict.TryGetValue("SourceShapeId", out var srcId)) SourceShapeId = Guid.Parse(srcId.ToString()); if (dict.TryGetValue("SourcePointName", out var srcName)) SourcePointName = srcName.ToString(); if (dict.TryGetValue("TargetShapeId", out var tgtId)) TargetShapeId = Guid.Parse(tgtId.ToString()); if (dict.TryGetValue("TargetPointName", out var tgtName)) TargetPointName = tgtName.ToString(); } }

参数说明:LineShape不存StartPoint/EndPoint到序列化数据中,因为它们是派生值(由SourceShapeId+SourcePointName动态计算)。这避免了数据不一致——比如你移动了源图形但忘了更新StartPoint,连线就飞走了。每次渲染前调用UpdateEndPoints()即可重新计算。

2.3 图形容器:ShapeCollection 管理生命周期与批量操作

不能让每个ShapeBase孤立存在。我们需要一个容器,支持添加、删除、按 ZOrder 排序、批量选中、范围查询:

public class ShapeCollection : ObservableCollection<ShapeBase> { private readonly List<LineShape> _lines = new(); public void AddShape(ShapeBase shape) { if (shape is LineShape line) { _lines.Add(line); } else { Add(shape); } } public void RemoveShape(ShapeBase shape) { if (shape is LineShape line) { _lines.Remove(line); } else { Remove(shape); } } // 按屏幕坐标范围查找图形(用于框选) public IEnumerable<ShapeBase> HitTest(RectangleF bounds, Func<ShapeBase, bool> filter = null) { var results = new List<ShapeBase>(); foreach (var shape in this) { if (filter != null && !filter(shape)) continue; var rect = new RectangleF((float)shape.X, (float)shape.Y, (float)shape.Width, (float)shape.Height); if (bounds.IntersectsWith(rect)) results.Add(shape); } return results; } // 更新所有连线的起点/终点(当任意图形移动后调用) public void UpdateAllLineEndpoints() { foreach (var line in _lines) { var source = this.FirstOrDefault(s => s.Id == line.SourceShapeId); var target = this.FirstOrDefault(s => s.Id == line.TargetShapeId); if (source != null && target != null && source.ConnectionPoints.Any(p => p.Name == line.SourcePointName) && target.ConnectionPoints.Any(p => p.Name == line.TargetPointName)) { var srcPt = source.ConnectionPoints.First(p => p.Name == line.SourcePointName) .GetAbsolutePosition(source); var tgtPt = target.ConnectionPoints.First(p => p.Name == line.TargetPointName) .GetAbsolutePosition(target); line.StartPoint = srcPt; line.EndPoint = tgtPt; } } } }

关键设计点:ShapeCollection是整个系统的“单一数据源”。所有 UI 层(WinForm 的Panel或 WPF 的Canvas)都只读取它、响应它的CollectionChanged事件,绝不直接操作Controls或Children。这为后续实现 Undo/Redo、协同编辑、实时同步打下基础。


3. 渲染引擎:双缓冲 Canvas 渲染器与 DPI 感知坐标转换

有了模型,下一步是“画出来”。Visio 风格的渲染不是简单Graphics.DrawRectangle(),它必须支持:
✅ 缩放(Zoom 0.25x ~ 4x)
✅ 平移(Pan 拖动画布)
✅ DPI 适配(4K 屏不模糊)
✅ 双缓冲防闪烁
✅ 连接线箭头、虚线、抗锯齿
✅ 选中状态高亮(虚线框 + 控制点)

3.1 坐标系抽象:ViewTransform 封装缩放、平移、DPI

所有渲染坐标都必须经过ViewTransform转换。它把“模型坐标”(单位:逻辑像素)转成“设备坐标”(单位:物理像素):

public class ViewTransform { public double Scale { get; set; } = 1.0; public double OffsetX { get; set; } = 0; public double OffsetY { get; set; } = 0; public float DpiScaleX { get; set; } = 1f; public float DpiScaleY { get; set; } = 1f; public PointF ModelToScreen(double x, double y) { return new PointF( (float)((x - OffsetX) * Scale * DpiScaleX), (float)((y - OffsetY) * Scale * DpiScaleY) ); } public RectangleF ModelToScreen(RectangleF rect) { var tl = ModelToScreen(rect.Left, rect.Top); var br = ModelToScreen(rect.Right, rect.Bottom); return RectangleF.FromLTRB(tl.X, tl.Y, br.X, br.Y); } public Point ScreenToModel(float x, float y) { return new Point( (int)((x / DpiScaleX / Scale) + OffsetX), (int)((y / DpiScaleY / Scale) + OffsetY) ); } }

为什么需要 DPIScale?Windows Forms 在高 DPI 下默认禁用 DPI 感知,导致Graphics绘图被系统缩放两次(一次 .NET 自动缩放,一次 Windows 缩放),图形模糊、坐标错乱。DpiScaleX/Y从Graphics.DpiX/DpiY获取,用于在ModelToScreen中抵消系统缩放,确保 1:1 渲染。

3.2 WinForm 渲染器:DoubleBufferedCanvasControl

继承Panel,启用双缓冲,并重写OnPaint:

public partial class DoubleBufferedCanvasControl : Panel { private readonly BufferedGraphicsContext _context = BufferedGraphicsManager.Current; private BufferedGraphics _buffer; private readonly ViewTransform _viewTransform = new(); private readonly ShapeCollection _shapes; public DoubleBufferedCanvasControl(ShapeCollection shapes) { _shapes = shapes; SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true); UpdateBufferSize(); } protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); UpdateBufferSize(); } private void UpdateBufferSize() { _buffer?.Dispose(); _buffer = _context.Allocate(CreateGraphics(), ClientRectangle); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_buffer == null) return; // 1. 清空缓冲区 using (var g = _buffer.Graphics) { g.Clear(BackColor); // 2. 设置 DPI 感知 g.PageUnit = GraphicsUnit.Pixel; g.ResetTransform(); g.ScaleTransform(_viewTransform.DpiScaleX, _viewTransform.DpiScaleY); // 3. 应用视图变换(缩放+平移) g.ScaleTransform((float)_viewTransform.Scale, (float)_viewTransform.Scale); g.TranslateTransform(-(float)_viewTransform.OffsetX, -(float)_viewTransform.OffsetY); // 4. 渲染所有图形 RenderShapes(g); } // 5. 一次性刷到屏幕 _buffer.Render(e.Graphics); } private void RenderShapes(Graphics g) { // 先画连线(底层) foreach (var line in _shapes.OfType<LineShape>()) { using (var pen = new Pen(Color.FromArgb(120, 0, 100, 255), 2)) { pen.DashStyle = DashStyle.Solid; g.DrawLine(pen, (float)line.StartPoint.X, (float)line.StartPoint.Y, (float)line.EndPoint.X, (float)line.EndPoint.Y); // 绘制箭头(简化版:末端三角形) DrawArrowHead(g, line.StartPoint, line.EndPoint, pen.Color); } } // 再画图形(上层) foreach (var shape in _shapes) { if (shape is RectangleShape rect) { var screenRect = _viewTransform.ModelToScreen( new RectangleF((float)rect.X, (float)rect.Y, (float)rect.Width, (float)rect.Height)); using (var brush = new SolidBrush(Color.LightBlue)) using (var pen = new Pen(shape.IsSelected ? Color.Blue : Color.Black, 2)) { g.FillRectangle(brush, screenRect); g.DrawRectangle(pen, screenRect); // 如果选中,绘制连接点(小方块) if (shape.IsSelected) { foreach (var cp in shape.ConnectionPoints) { var pt = cp.GetAbsolutePosition(shape); var screenPt = _viewTransform.ModelToScreen(pt.X, pt.Y); g.FillRectangle(Brushes.Red, screenPt.X - 3, screenPt.Y - 3, 6, 6); } } } } } } private void DrawArrowHead(Graphics g, Point start, Point end, Color color) { var dx = end.X - start.X; var dy = end.Y - start.Y; var len = Math.Sqrt(dx * dx + dy * dy); if (len < 1) return; var ux = dx / len; // unit vector var uy = dy / len; var perpX = -uy; // perpendicular var perpY = ux; var headLen = 10f; var headWidth = 6f; var p1 = new PointF( (float)(end.X - ux * headLen + perpX * headWidth), (float)(end.Y - uy * headLen + perpY * headWidth) ); var p2 = new PointF( (float)end.X, (float)end.Y ); var p3 = new PointF( (float)(end.X - ux * headLen - perpX * headWidth), (float)(end.Y - uy * headLen - perpY * headWidth) ); using (var brush = new SolidBrush(color)) { g.FillPolygon(brush, new[] { p1, p2, p3 }); } } }

逻辑说明:DoubleBufferedCanvasControl的核心是“三重变换”:
①g.ScaleTransform(DpiScaleX, DpiScaleY)—— 抵消系统 DPI 缩放;
②g.ScaleTransform(Scale, Scale)—— 应用用户缩放;
③g.TranslateTransform(-OffsetX, -OffsetY)—— 应用平移。
所有ShapeBase的X/Y/Width/Height都是逻辑坐标,无需在模型层做任何缩放计算。渲染器统一处理,干净利落。

3.3 WPF 渲染器:Canvas + DrawingVisual(轻量级,无控件开销)

WPF 更推荐用DrawingVisual直接绘图,避免UIElement的布局开销:

public class DrawingCanvas : FrameworkElement { private readonly DrawingVisual _drawingVisual = new(); private readonly ViewTransform _viewTransform = new(); private readonly ShapeCollection _shapes; public DrawingCanvas(ShapeCollection shapes) { _shapes = shapes; AddVisualChild(_drawingVisual); AddLogicalChild(_drawingVisual); } protected override int VisualChildrenCount => 1; protected override Visual GetVisualChild(int index) => _drawingVisual; protected override void OnRender(DrawingContext drawingContext) { base.OnRender(drawingContext); // 1. 创建 DrawingGroup 并应用变换 var group = new DrawingGroup(); group.Transform = new TransformGroup { Children = { new ScaleTransform(_viewTransform.Scale, _viewTransform.Scale), new TranslateTransform(-_viewTransform.OffsetX, -_viewTransform.OffsetY) } }; // 2. 渲染连线 foreach (var line in _shapes.OfType<LineShape>()) { var geometry = new StreamGeometry(); using (var ctx = geometry.Open()) { ctx.BeginFigure(line.StartPoint, false, false); ctx.LineTo(line.EndPoint, true, true); } group.Children.Add(new GeometryDrawing { Brush = Brushes.Transparent, Pen = new Pen(Brushes.SteelBlue, 2) { DashStyle = DashStyles.Solid } }); } // 3. 渲染图形 foreach (var shape in _shapes) { if (shape is RectangleShape rect) { var rectGeometry = new RectangleGeometry( new Rect(rect.X, rect.Y, rect.Width, rect.Height)); group.Children.Add(new GeometryDrawing { Brush = shape.IsSelected ? Brushes.LightBlue : Brushes.LightCyan, Pen = new Pen(shape.IsSelected ? Brushes.Blue : Brushes.Black, 2), Geometry = rectGeometry }); // 选中时绘制连接点 if (shape.IsSelected) { foreach (var cp in shape.ConnectionPoints) { var pt = cp.GetAbsolutePosition(shape); var pointGeometry = new EllipseGeometry(pt, 3, 3); group.Children.Add(new GeometryDrawing { Brush = Brushes.Red, Geometry = pointGeometry }); } } } } drawingContext.DrawDrawing(group); } }

对比 WinForm 方案:WPF 版本不依赖Canvas.Left/Top绑定,而是用Transform整体控制,性能更高;DrawingVisual是轻量级绘图目标,比Canvas+Rectangle控件组合内存占用低 60%+。两者共享同一套ShapeBase和ViewTransform,业务逻辑完全复用。


4. 交互系统:鼠标状态机驱动拖拽、连接、框选全流程

这才是 Visio 风格的灵魂——不是“拖动控件”,而是“改变图形状态”。我们用一个有限状态机(FSM)管理鼠标行为,共 5 个状态:

状态触发条件行为
Idle初始状态响应 Hover、Click
DraggingCanvas按住空白处拖动更新ViewTransform.OffsetX/Y
DraggingShape点中图形并拖动更新该图形X/Y,触发UpdateAllLineEndpoints()
Connecting点中连接点并拖动记录起点,进入连线模式
Selecting按住空白处右键拖动绘制虚线框,HitTest框内图形

4.1 状态机主干:CanvasInteractionManager

public enum InteractionState { Idle, DraggingCanvas, DraggingShape, Connecting, Selecting } public class CanvasInteractionManager { private InteractionState _currentState = InteractionState.Idle; private Point _startPoint; private ShapeBase _draggedShape; private ConnectionPoint _draggedConnectionPoint; private Guid _sourceShapeId; private string _sourcePointName; private RectangleF _selectionRect; public CanvasInteractionManager(ShapeCollection shapes, ViewTransform viewTransform) { Shapes = shapes; ViewTransform = viewTransform; } public ShapeCollection Shapes { get; } public ViewTransform ViewTransform { get; } public void OnMouseDown(MouseEventArgs e) { var modelPoint = ViewTransform.ScreenToModel(e.X, e.Y); switch (_currentState) { case InteractionState.Idle: // 1. 检查是否点中连接点(优先级最高) var hitCp = HitTestConnectionPoint(modelPoint); if (hitCp != null) { _currentState = InteractionState.Connecting; _sourceShapeId = hitCp.Shape.Id; _sourcePointName = hitCp.Point.Name; _startPoint = modelPoint; return; } // 2. 检查是否点中图形 var hitShape = HitTestShape(modelPoint); if (hitShape != null) { _currentState = InteractionState.DraggingShape; _draggedShape = hitShape; _startPoint = modelPoint; // 清除其他选中状态 foreach (var s in Shapes) s.IsSelected = false; hitShape.IsSelected = true; return; } // 3. 空白处点击:开始框选或拖动画布 _currentState = InteractionState.Selecting; _startPoint = modelPoint; _selectionRect = new RectangleF((float)modelPoint.X, (float)modelPoint.Y, 0, 0); break; } } private (ShapeBase Shape, ConnectionPoint Point)? HitTestConnectionPoint(Point modelPoint) { foreach (var shape in Shapes) { foreach (var cp in shape.ConnectionPoints) { var cpPos = cp.GetAbsolutePosition(shape); var dist = Math.Sqrt(Math.Pow(cpPos.X - modelPoint.X, 2) + Math.Pow(cpPos.Y - modelPoint.Y, 2)); if (dist < 8) // 8 像素命中半径 return (shape, cp); } } return null; } private ShapeBase HitTestShape(Point modelPoint) { // 逆序遍历(ZOrder 高的在后),确保点中顶层图形 for (int i = Shapes.Count - 1; i >= 0; i--) { var shape = Shapes[i]; if (modelPoint.X >= shape.X && modelPoint.X <= shape.X + shape.Width && modelPoint.Y >= shape.Y && modelPoint.Y <= shape.Y + shape.Height) return shape; } return null; } public void OnMouseMove(MouseEventArgs e) { var modelPoint = ViewTransform.ScreenToModel(e.X, e.Y); switch (_currentState) { case InteractionState.DraggingShape: if (_draggedShape != null) { var dx = modelPoint.X - _startPoint.X; var dy = modelPoint.Y - _startPoint.Y; _draggedShape.X += dx; _draggedShape.Y += dy; _startPoint = modelPoint; Shapes.UpdateAllLineEndpoints(); // 关键!连线随图形实时更新 } break; case InteractionState.DraggingCanvas: ViewTransform.OffsetX += modelPoint.X - _startPoint.X; ViewTransform.OffsetY += modelPoint.Y - _startPoint.Y; _startPoint = modelPoint; break; case InteractionState.Selecting: _selectionRect = RectangleF.FromLTRB( (float)_startPoint.X, (float)_startPoint.Y, (float)modelPoint.X, (float)modelPoint.Y); break; } } public void OnMouseUp(MouseEventArgs e) { var modelPoint = ViewTransform.ScreenToModel(e.X, e.Y); switch (_currentState) { case InteractionState.DraggingShape: // 拖动结束,确保连线更新 Shapes.UpdateAllLineEndpoints(); break; case InteractionState.Connecting: // 尝试连接到目标连接点 var targetCp = HitTestConnectionPoint(modelPoint); if (targetCp != null && targetCp.Shape.Id != _sourceShapeId && // 不能连自己 targetCp.Point.IsInput) // 目标必须是输入端口 { var line = new LineShape { SourceShapeId = _sourceShapeId, SourcePointName = _sourcePointName, TargetShapeId = targetCp.Shape.Id, TargetPointName = targetCp.Point.Name }; Shapes.AddShape(line); Shapes.UpdateAllLineEndpoints(); } break; case InteractionState.Selecting: // 框选:清除之前选中,选中框内所有图形 foreach (var s in Shapes) s.IsSelected = false; var selected = Shapes.HitTest(_selectionRect).ToList(); foreach (var s in selected) s.IsSelected = true; break; } _currentState = InteractionState.Idle; } }

关键设计点:OnMouseMove中的Shapes.UpdateAllLineEndpoints()是 Visio 风格的基石。它保证:

  • 移动一个服务器图标,所有连到它的网线自动跟随;
  • 拖动一个交换机,入向和出向连线同时更新;
  • 无需手动维护LineShape.StartPoint/EndPoint,它们永远是“当前最新”。
    这就是状态驱动 vs 控件驱动的本质区别。

4.2 WinForm 集成:挂载到 DoubleBufferedCanvasControl

public partial class DoubleBufferedCanvasControl : Panel { private readonly CanvasInteractionManager _interactionManager; public DoubleBufferedCanvasControl(ShapeCollection shapes) { // ... 前面初始化代码 _interactionManager = new CanvasInteractionManager(shapes, _viewTransform); MouseDown += (s, e) => _interactionManager.OnMouseDown(e); MouseMove += (s, e) => _interactionManager.OnMouseMove(e); MouseUp += (s, e) => _interactionManager.OnMouseUp(e); } }

4.3 WPF 集成:附加属性 + PreviewMouseDown

<!-- XAML --> <local:DrawingCanvas x:Name="Canvas" local:CanvasBehavior.InteractionManager="{Binding InteractionManager}" />
// CanvasBehavior.cs public static class CanvasBehavior { public static readonly DependencyProperty InteractionManagerProperty = DependencyProperty.RegisterAttached( "InteractionManager", typeof(CanvasInteractionManager), typeof(CanvasBehavior), new PropertyMetadata(null, OnInteractionManagerChanged)); public static void SetInteractionManager(DependencyObject element, CanvasInteractionManager value) => element.SetValue(InteractionManagerProperty, value); private static void OnInteractionManagerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is UIElement element && e.NewValue is CanvasInteractionManager manager) { element.PreviewMouseDown += (s, args) => manager.OnMouseDown(args); element.PreviewMouseMove += (s, args) => manager.OnMouseMove(args); element.PreviewMouseUp += (s, args) => manager.OnMouseUp(args); } } }

注意:WPF 必须用PreviewMouseDown(隧道事件),否则Canvas内部的DrawingVisual会吞掉事件,导致无法响应。


5. 避坑指南:C# 类似Visio图形拖拽实现的 5 个血泪经验

做这类图形系统,90% 的时间花在解决“看似简单实则玄学”的边界问题上。以下是我踩过的坑,按发生频率排序,每条都附真实现象、根因和可立即验证的修复方案。

5.1 现象:缩放后拖动图形,鼠标指针与图形中心严重偏移

原因:ViewTransform应用了缩放,但OnMouseMove中ScreenToModel未同步应用DpiScale,导致坐标换算失真。例如:4K 屏下DpiScaleX=1.5,Scale=2.0,但ScreenToModel只除以Scale,漏了DpiScaleX,结果坐标被放大 1.5 倍。
解决:严格按ScreenToModel方法实现,确保x / DpiScaleX / Scale + OffsetX顺序不可颠倒。在OnMouseMove开头加日志:Debug.WriteLine($"Screen({e.X},{e.Y}) → Model({modelPoint.X:F2},{modelPoint.Y:F2})");对比缩放前后数值。

5.2 现象:连接线在图形旋转后,起点/终点粘不住连接点

原因:ConnectionPoint.GetAbsolutePosition()直接用OffsetX/Y计算,未考虑ShapeBase.Rotation。旋转 90° 后,“Top”连接点实际在右侧,但代码仍把它当顶部中点算。
解决:为ConnectionPoint增加GetRotatedPosition(ShapeBase shape)方法,用旋转矩阵计算:

public Point GetRotatedPosition(ShapeBase shape) { var cx = shape.X + shape.Width * 0.5; var cy = shape.Y + shape.Height * 0.5; var px = shape.X + shape.Width * OffsetX; var py = shape.Y + shape.Height * OffsetY; var rad = shape.Rotation * Math.PI / 180; var cos = Math.Cos(rad); var sin = Math.Sin(rad); var rx = cx + (px - cx) * cos - (py - cy) * sin; var ry = cy + (px - cx) * sin + (py - cy) * cos; return new Point(rx, ry); }

并在LineShape的UpdateEndPoints中调用此方法。

5.3 现象:快速拖动图形时,连线出现“瞬移”或“抖动”

原因:UpdateAllLineEndpoints()在每次MouseMove中执行,但图形X/Y是逐步更新的,而GetAbsolutePosition()依赖当前X/Y,导致中间态坐标错误。更糟的是,如果Shapes是ObservableCollection,频繁触发CollectionChanged会引发 UI 重绘风暴。
解决:
① 将UpdateAllLineEndpoints()改为DeferUpdate()+FlushUpdates()模式,在OnMouseMove中只标记“需更新”,在OnMouseUp或OnPaint结束后批量执行;
②Shapes改用List<ShapeBase>,渲染层通过INotifyCollectionChanged包装(如BindingList<T>),避免ObservableCollection的过度通知。

5.4 现象:高 DPI 下,连接点小方块(6×6)在 200% 缩放时变成 12×12,但命中检测半径仍是 8 像素

原因:“8 像素命中半径”是设备无关单位,但HitTestConnectionPoint中的dist < 8是模型坐标距离,未乘Scale * DpiScale。模型坐标 1 单位 = 1 逻辑像素,而屏幕像素是Scale × DpiScale倍。
解决:

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 21:14:51

大厂校招薪资大揭秘:小白程序员必看,收藏这份热门岗位薪资指南!

本文汇总了字节跳动、腾讯、阿里巴巴等热门大厂校招薪资情况&#xff0c;涵盖前后端开发、AI/Agent方向、测开等岗位。数据来源于往届案例及公开信息&#xff0c;详细展示了不同级别薪资待遇及福利。文章提醒求职者在选择Offer时&#xff0c;不仅要关注总包&#xff0c;还要了解…

作者头像 李华
网站建设 2026/10/14 21:14:14

ArkUI Canvas:巡检曲线缺测断点分段绘制与边界守护【鸿蒙心迹】

项目&#xff1a;PulseTrace&#xff5c;页面&#xff1a;TraceReviewPage&#xff5c;任务&#xff1a;PT-2907 说明&#xff1a;本文全部数据、日志和画面均为人工构造的演示&#xff0c;未声称进行 DevEco 编译、真机采集或医疗检测。 一、这条折线最容易画错的不是颜色 做离…

作者头像 李华
网站建设 2026/10/14 21:14:04

javaweb_前端

targrt&#xff1a;决定超链接最终在当前页面还是新变迁也打开herf&#xff1a;超链接地址音视频播放控件&#xff1a;controls<video src"video/news.mp4" controls width"100%"></video>表单&#xff1a;action: 规定表单提交时&#xff0c;…

作者头像 李华
网站建设 2026/10/14 21:12:48

汽车零部件采购降本新思路:8Manage SRM竞价管理实战解析

汽车零部件行业的采购降本&#xff0c;这几年已经从“压价”变成了“体系战”。整车厂年年要求年降&#xff0c;原材料波动又是常态&#xff0c;零部件供应商夹在中间&#xff0c;采购部门如果不靠系统工具&#xff0c;单凭人工谈判和经验判断&#xff0c;基本很难在保证质量交…

作者头像 李华