首页 / 资讯中心 / 文章详情

C# WinForms实现类Visio拖拽节点与连线编辑器

C# WinForms实现类Visio拖拽节点与连线编辑器 ★ FEATURED ARTICLE
简介面向C#桌面应用开发者与流程设计工具爱好者这份资源以Windows Forms Demo为载体完整演示了类似Visio的图形拖拽交互实现方案。针对OA节点拖拽、自定义图形节点、连接线跟随等常见需求资源通过INode与ILine接口抽象节点和线条行为并给出矩形节点、实线连接、鼠标按下/移动/释放事件处理以及双缓冲重绘等关键代码便于读者快速迁移到流程编辑器或图形化界面项目中。包体共44个文件以16个cs源码文件为核心辅以dll运行库、resx/resources资源文件及exe演示程序总大小仅329KB结构精简、便于对照阅读。工程从界面搭建、接口定义、绘图逻辑到拖拽测试完整覆盖运行exe可直接查看效果阅读源码则能理解节点移动时线条自动更新的实现细节并掌握通过自定义类扩展更多图形类型与连接线样式的方法。目前已有1704人学习浏览适合具备一定C#基础、希望掌握拖拽绘图核心思路的开发者参考实践。1. 为什么用 C# 重写 Visio 式拖拽从 OA 节点编排到图形化编辑器的第一公里某公司的 OA 流程配置一直停在表格时代配置一个审批节点要填上级节点编号加一个条件分支要复制三份配置再手动改指向业务人员改一版流程要和开发对半天 Excel。这套 C# 工程解决的问题就是把「类似 Visio 的拖拽体验」搬进业务流程编辑器节点拖到画布上从一个端口拖出连线到另一个端口保存后生成带坐标和连线关系的流程定义重新打开还能原样还原。适合两类人一类在做 OA 节点拖拽、工作流设计器、拓扑图编辑器想拿一个现成的交互骨架改成自己的业务模型另一类是刚开始接触 GDI 自定义绘图的同学想找一个能把 MouseDown、Paint、命中测试串起来的完整范例。项目不依赖重型第三方图形库核心就是一个自定义控件加三个模型类。2. 整体架构与数据模型拖拽画布背后的三个核心组件看源码包之前先把这套工程的结构理清。它没有把逻辑堆在 Form 里而是拆成了三层模型层只管节点和连线的数据视图层是继承 UserControl 的画布控件控制层负责鼠标状态和命令管理。画布控件同时扮演视图和控制器的角色这对中小型图形编辑器来说是最划算的组合不用引入 MVVM 框架也能保持各模块可替换。2.1 画布、节点与连线GDI 之上的分层设计先看两个最基础的模型类。源码里 DiagramNode 和 DiagramConnection 几乎不掺绘制逻辑做法如下public class DiagramNode { public string Id { get; set; } public string NodeType { get; set; } public RectangleF Bounds { get; set; } public PointF Center new PointF(Bounds.X Bounds.Width / 2f, Bounds.Y Bounds.Height / 2f); public ListDiagramPort Ports { get; set; } new ListDiagramPort(); } public class DiagramConnection { public string Id { get; set; } public string StartNodeId { get; set; } public string EndNodeId { get; set; } public int StartPortIndex { get; set; } public int EndPortIndex { get; set; } public string Label { get; set; } }注意一个设计点DiagramConnection 本身不保存连线的起点和终点坐标只保存两个节点 Id 和端口索引。这样节点移动时连线会自动跟随因为绘制时每次从对应端口重新计算端点。很多类 Visio 拖拽实现翻车就是在这里把连接线坐标存在 Connection 对象里节点一拖线就断在半空。画布控件的骨架也很简单构造函数里做两件关键的事public sealed class DiagramCanvas : UserControl { private readonly ListDiagramNode _nodes new ListDiagramNode(); private readonly ListDiagramConnection _connections new ListDiagramConnection(); private readonly CommandManager _commandManager new CommandManager(); public DiagramCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); BackColor Color.White; } }三层关系是这样Canvas 持有节点和连线集合负责把集合渲染到屏幕节点和连线对外暴露数据CommandManager 记录每次用户操作。Canvas 的 OnPaint 只做一件事就是遍历集合绘制不要在 Paint 里执行业务判断否则后面加撤销和复制会很痛苦。2.2 数据模型与序列化让拖拽结果能存能读OA 节点拖拽做完之后要保存流程所以序列化是刚需。源码里用的 Newtonsoft.Json 把整个画布内容转成 JSON 文档public string Serialize() { return JsonConvert.SerializeObject(new { nodes _nodes, connections _connections }); } public void LoadFromJson(string json) { var data JsonConvert.DeserializeObjectDiagramDocument(json); _nodes.Clear(); _nodes.AddRange(data.Nodes); _connections.Clear(); _connections.AddRange(data.Connections); _nodes.ForEach(n n.PropertyChanged OnNodeChanged); Invalidate(); }DiagramDocument 是一个轻量容器只有 Nodes 和 Connections 两个属性。保存时可以再在外面包一层流程 Id、版本号方便日后做流程版本管理。加载后有一个步骤不能漏重新订阅节点的 PropertyChanged 事件否则之后拖拽节点改了 Bounds画布不会收到通知界面不会刷新。实际项目里我一般会把序列化单独放一个 DiagramSerializer 类Canvas 只暴露 Nodes 和 Connections 的只读列表。这样换数据库、换成二进制存储都不用动画布代码。保存文件格式先用 JSON 起步等需要保存节点颜色、折叠状态时再往里加字段这个结构扩展起来很顺手。2.3 事件分发与命中测试从鼠标坐标到业务对象的映射画布上的每一个鼠标动作第一步都是坐标换算和命中测试。鼠标点击到某个像素点程序要先回答三个问题点到了端口吗点到了节点吗还是点在空白区域。源码里对应两个方法private PointF ToCanvas(Point e) { return new PointF(e.X - AutoScrollPosition.X, e.Y - AutoScrollPosition.Y); } public DiagramNode HitTestNode(PointF pt) { for (int i _nodes.Count - 1; i 0; i--) { if (_nodes[i].Bounds.Contains(pt)) return _nodes[i]; } return null; } public DiagramPort HitTestPort(PointF pt) { foreach (var node in _nodes) foreach (var port in node.Ports) if (port.HitBounds.Contains(pt)) return port; return null; }ToCanvas 是这套工程里最容易忽略的方法。用户滚动画布以后鼠标事件的 X、Y 是相对客户区的而节点的 Bounds 是相对整个逻辑画布的二者相差一个滚动偏移。如果不减 AutoScrollPosition拖拽一个节点时节点会和鼠标相差一截看起来就像“拖丢了”。命中顺序也有讲究先查端口再查节点而且节点从后往前遍历。因为后画的节点在上层倒序遍历能保证最上面的节点被命中符合直觉。端口热区我习惯做成半径 8 到 10 像素的圆HitBounds 就用这个圆的外接矩形太小的话用户很难点中特别是 OA 节点密集排布的时候。3. 核心模块拆解一个拖拽动作从 MouseDown 到 Paint 的完整链路这一章把交互拆成状态机来看。鼠标按下、移动、抬起每一步都对应一个状态变化。读源码时如果直接扎进 OnMouseMove很容易被一堆 if 绕晕先看状态字段和 MouseDown 的分支剩下的都顺了。3.1 自定义控件的选择UserControl 还是 Panel很多 Demo 直接继承 Panel因为 Panel 也是控件能承载绘制。但源码里选的是 UserControl区别主要在扩展性上。Panel 的 DoubleBuffered 属性在部分 .NET 版本里是 protected 的要写子类才能打开UserControl 默认就是用来做复合控件的改起来顺手。构造函数里这样配置public DiagramCanvas() { DoubleBuffered true; ResizeRedraw true; SetStyle(ControlStyles.Selectable, true); TabStop true; }DoubleBuffered 解决闪烁ResizeRedraw 保证窗体尺寸变化时立即重绘。SetStyle(ControlStyles.Selectable, true) 和 TabStop 是给键盘操作准备的。后面做 Delete 删除节点、CtrlZ 撤销时画布必须能获得焦点否则键盘事件会被其他控件吃掉。为什么不用 WPF 或者第三方图形库这套代码的目标场景是内嵌到老牌 OA 系统里WinForms 和 GDI 是很多企业桌面的底线依赖。如果业务方环境允许你也可以把同样的状态机搬到 WPF 的 Canvas 上但事件模型和渲染层的替换成本不会低建议先跑通当前这套再谈迁移。3.2 拖拽与缩放Anchor 点的命中与光标切换源码里的交互状态用一个枚举管理避免在事件代码里堆布尔变量private enum DragMode { None, MoveNode, DrawConnection, RubberBand } private DragMode _mode DragMode.None; private DiagramNode _draggedNode; private SizeF _dragOffset; private DiagramPort _startPort; private PointF _downPoint; private DiagramConnection _tempConnection;MouseDown 的核心逻辑是三分支优先判断端口再判断节点最后才是空白区域框选。端口命中时进入 DrawConnection 状态节点命中时进入 MoveNode 状态。看代码protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); Focus(); PointF pt ToCanvas(e.Location); _downPoint pt; DiagramPort port HitTestPort(pt); if (port ! null) { _mode DragMode.DrawConnection; _startPort port; _tempConnection new DiagramConnection { Id Guid.NewGuid().ToString(N), StartNodeId port.OwnerId, StartPortIndex port.Index }; return; } DiagramNode node HitTestNode(pt); if (node ! null) { _mode DragMode.MoveNode; _draggedNode node; _dragOffset new SizeF(pt.X - node.Bounds.X, pt.Y - node.Bounds.Y); Cursor Cursors.SizeAll; } }_dragOffset 保存的是鼠标点击位置相对节点左上角的偏移。如果按下时直接把节点左上角设成鼠标坐标节点会“跳”到鼠标底下用户会明显感到拖拽手感是坏的。有了偏移移动时用鼠标坐标减去偏移节点始终和点击位置保持固定相对距离。MouseMove 里只处理当前状态protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); PointF pt ToCanvas(e.Location); if (_mode DragMode.MoveNode _draggedNode ! null) { _draggedNode.Bounds new RectangleF( pt.X - _dragOffset.Width, pt.Y - _dragOffset.Height, _draggedNode.Bounds.Width, _draggedNode.Bounds.Height); Invalidate(); } else if (_mode DragMode.DrawConnection) { _tempConnection.EndNodeId string.Empty; Invalidate(); } }这里 Invalidate 不是每次移动只画一个像素吗GDI 的 Invalidate 只是把画布标记为需要重绘真正的绘制在 UI 线程空闲时批量执行。默认的 UserControl 没有先擦除再重画的剧烈闪烁因为构造函数里已经开了双缓冲。3.3 连线的绘制与吸附贝塞尔曲线与最近端口算法连线不是直线而是从起点端口延伸到终点端口的贝塞尔曲线。源码里的绘制代码集中在 OnPaintprotected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; foreach (var conn in _connections) { PointF start GetConnectionPoint(conn, true); PointF end GetConnectionPoint(conn, false); DrawBezierLine(g, start, end); } foreach (var node in _nodes) DrawNode(g, node); }DrawBezierLine 里控制点的偏移方向取决于节点之间的左右关系。起点控制点向右拉终点控制点向左拉这样曲线看起来像从节点侧面自然延伸出去。常见错误是控制点偏移量固定为正值导致从右往左画线时曲线绕一个大圈。真正的亮点在 MouseUp 时的端口吸附。用户拖到端口附近但没精确瞄准时程序把临时连线的终点吸到最近端口上private const float SnapDistance 12f; private DiagramPort SnapToPort(PointF current, out PointF snappedPoint) { DiagramPort best null; var minDist SnapDistance * SnapDistance; snappedPoint current; foreach (var node in _nodes) { foreach (var port in node.Ports) { PointF center GetPortCenter(port); float dx center.X - current.X; float dy center.Y - current.Y; float dist dx * dx dy * dy; if (dist minDist) { minDist dist; best port; snappedPoint center; } } } return best; }MouseUp 时如果捕捉到了目标端口就把 _tempConnection 补上 EndNodeId 和 EndPortIndex然后加入 _connections 集合如果没捕捉到丢弃临时连线。这个阈值控制很讲究12 像素吸附半径够大用户不需要瞄点但也不要超过 20 像素否则旁边有几个节点时连线会吸到错误的端口反而让用户发火。3.4 撤销/重做与复制粘贴命令模式落地源码没有用一堆全局变量保存“上一步”而是实现了命令模式。每次用户操作在 MouseUp 时创建一个命令对象Do 立即执行Undo 负责还原。命令接口长这样public interface ICommand { void Do(); void Undo(); }以移动节点为例MouseUp 时把 MouseDown 时的坐标和 MouseUp 时的坐标包进一个命令public sealed class MoveNodeCommand : ICommand { private readonly DiagramNode _node; private readonly RectangleF _oldBounds; private readonly RectangleF _newBounds; public MoveNodeCommand(DiagramNode node, RectangleF oldBounds, RectangleF newBounds) { _node node; _oldBounds oldBounds; _newBounds newBounds; } public void Do() Apply(_newBounds); public void Undo() Apply(_oldBounds); private void Apply(RectangleF bounds) { _node.Bounds bounds; } }注意 MouseMove 过程中每帧都在改 Bounds但不要每帧都压命令栈否则按一次 CtrlZ 只能回退一像素撤销等于失效。正确做法是在 MouseUp 时只压一条命令记录这次拖拽的起点和终点。源码在鼠标抬起时调用 _commandManager.Execute(new MoveNodeCommand(...))CommandManager 内部维护 UndoStack 和 RedoStack执行新命令时清空 RedoStack。复制粘贴是另一块很实用的功能。CtrlC 时把选中的节点深拷贝到剪贴板同时记录端口和连线关系CtrlV 时把每个节点 Id 重新生成连线 Id 也重新生成再把整个群组向右下偏移 15 像素。如果不重新生成 Id粘贴出来的连线和原图连线共用节点引用删除其中一个另一个节点上的线也会消失这就是典型的“看起来保存正常操作起来全乱”的隐患。4. 跑通工程从解决方案结构到 F5 运行源码包拿到手先别急着双击 .sln按下面顺序走一遍能省掉不少环境问题。这套工程的启动方式不需要特殊配置有 .NET Framework 4.7.2 或 .NET 6 以上的 WinForms 环境就能跑。4.1 解决方案结构与关键类说明工程里核心文件不多我按职责整理了一张表文件职责关键依赖DiagramCanvas.cs画布控件负责鼠标事件、绘制、命中测试UserControlDiagramNode.cs节点模型包含节点类型、位置、端口无DiagramPort.cs端口模型记录端口在节点内的相对位置DiagramNodeDiagramConnection.cs连线模型引用节点 Id 与端口索引无DiagramDocument.cs序列化容器保存/加载 JSONNewtonsoft.JsonCommandManager.cs命令管理器管理撤销/重做栈ICommandMainForm.cs入口窗口放置工具栏和画布DiagramCanvas建议先把 DiagramNode、DiagramPort、DiagramConnection 这三个类读完再进 DiagramCanvas。因为 Canvas 里所有逻辑都在操作这三个对象先理解数据模型再看事件就不会满眼都是坐标计算。4.2 初始化画布与节点数据一个最小可运行示例启动入口在 MainForm。它做的事情很直接创建一个 DiagramCanvas 填满窗口然后添加两个节点和一条连线public sealed class MainForm : Form { private readonly DiagramCanvas _canvas; public MainForm() { Text 类 Visio 图形拖拽示例; WindowState FormWindowState.Maximized; _canvas new DiagramCanvas { Dock DockStyle.Fill }; Controls.Add(_canvas); BuildSampleData(); } private void BuildSampleData() { var start new DiagramNode { Id Guid.NewGuid().ToString(N), NodeType Start, Bounds new RectangleF(30, 30, 120, 50) }; var approve new DiagramNode { Id Guid.NewGuid().ToString(N), NodeType Approve, Bounds new RectangleF(220, 120, 140, 70) }; _canvas.AddNode(start); _canvas.AddNode(approve); _canvas.AddConnection(start.Id, approve.Id, 0, 0); } }运行后你会看到两个矩形节点中间有一条曲线。按住节点拖动连线会跟着动从一个节点的端口拖向另一个节点的端口松手后生成新连线。这个最小示例验证了拖拽、绘制、连通三条主链路。如果运行后界面空白优先检查是不是没有调用 BuildSampleData或者 OnPaint 里的 _nodes 集合为空。4.3 依赖项与运行时注意.NET 版本、DPI 与双缓冲源码的序列化部分引用了 Newtonsoft.JsonNuGet 还原通常在打开解决方案时自动完成。如果你的环境无法联网还原可以直接把 Serialize/LoadFromJson 里的 JsonConvert 换成 DataContractJsonSerializer只保留模型层依赖就能降到零。运行时最需要注意的是高 DPI 显示。在 125% 或 150% 缩放的屏幕上WinForms 默认会把鼠标坐标按系统缩放后再传给控件而 Graphics 绘制默认又用逻辑像素两套单位一旦错开坐标就漂移。解决方法是给工程配置 app.manifest 并声明 PerMonitorV2 感知ApplicationManifestapp.manifest/ApplicationManifest声明之后窗体和画布按实际物理像素工作鼠标坐标和绘制坐标保持一致。如果项目是从旧机器迁移过来的还要检查主窗体的 AutoScaleMode 是否设置成了 Dpi否则控件尺寸会被二次缩放。双缓冲方面构造函数已经通过 SetStyle 打开 OptimizedDoubleBuffer这里不需要额外写 BufferedGraphics。5. 常见问题与排查拖拽抖动、卡顿和坐标漂移的排查手册这套代码跑起来之后最常见的坑基本集中在坐标换算和重绘策略上。我把从实际调试中遇到的几个高频问题按「现象 → 原因 → 解决」列在下面照着排查能省不少时间。5.1 四个高频问题现象、原因与解决问题一拖拽时节点和鼠标错位节点跳到你点击位置的右下角。现象鼠标按下后节点没有出现在原位而是整体向左上或右下偏移一截拖拽过程中偏移量保持不变。原因MouseDown 没用 ToCanvas直接用了 e.Location而节点 Bounds 是相对逻辑画布的坐标。画布没有滚动时两者一致一旦用户滚动画布AutoScrollPosition 不再为零差值就暴露了。解决所有鼠标事件先通过 ToCanvas 换算再交给命中测试。这个函数已经在画布类里实现统一入口后这类问题不会再出。问题二拖拽时画布闪烁像老电视在换台。现象移动节点时背景色闪黑条或者节点边缘出现残影。原因构造函数里的双缓冲没生效或者某个子控件把 ControlStyles.AllPaintingInWmPaint 关了。另一个常见原因是 OnPaint 里每次重绘都调用 base.OnPaint 后又直接清空背景导致缓冲被强行擦除。解决确认 SetStyle 包含 OptimizedDoubleBuffer并且不要在 OnPaint 里调用 Graphics.Clear。如果还有少量闪烁把 BackColor 设置成固定值不要使用透明背景WinForms 的透明区域重绘代价很高。问题三连线拖到端口旁边却吸附不上。现象临时连线的终点已经接近端口圆点鼠标一松连线消失。原因吸附阈值和端口热区不匹配。如果 HitTestPort 的热区半径是 6 像素SnapDistance 也是 6 像素用户稍微偏一点点就失败。解决端口热区半径放大到 10 到 12 像素吸附判断用平方距离和半径平方比较MouseUp 时再调用一次 SnapToPort 作为强制吸附。注意阈值不要太大否则相邻端口会互相抢线。问题四保存后重新打开连线端点偏移节点和端口位置对不上。现象在 150% 缩放的开发机上编辑正常放到 100% 缩放的机器上打开后节点位置漂移连线指向旧坐标。原因序列化时把屏幕像素坐标当成逻辑坐标存了下来DPI 变化后绘制坐标系变了坐标也就漂了。解决启用 PerMonitorV2 DPI 感知保存前把节点 Bounds 统一换算到逻辑像素。更稳妥的做法是保存时用相对坐标节点存相对画布左上角的逻辑坐标端口存相对节点左上角的坐标这样连绘制的缩放都解耦了。问题五删除节点后之前连接它的连线没有消失。现象删掉一个节点画布上还留着几条一端悬空的线。原因删除操作只从 _nodes 移除节点没有同步删除 _connections 里引用了这个节点的所有连线也没有把相关命令从撤销栈里清掉。解决在删除入口先收集 StartNodeId 或 EndNodeId 等于该节点 Id 的连线全部移除后再删节点。撤销栈里如果有旧的连线命令要单独处理否则 Undo 时会触发空引用。5.2 定位问题把模型层和绘图层分开验证很多拖拽问题看似玄学最后都能归结到两个层面要么模型坐标错了要么绘制坐标错了。定位的方法很简单在状态变化时 Dump 一份模型数据private void DebugDumpState() { foreach (var node in _nodes) Debug.WriteLine($Node {node.Id}: {node.Bounds}); foreach (var conn in _connections) Debug.WriteLine($Conn {conn.StartNodeId}-{conn.EndNodeId}); }拖拽过程中如果 Model 里的 Bounds 正确、屏幕上显示的节点位置不对问题在渲染层重点查 Paint 的坐标转换如果 Model 里的坐标本身就不对问题在 MouseDown 或 ToCanvas。不要在 OnPaint 里断点拖拽一次会触发几十次重绘断点会直接把 IDE 卡死。用一个布尔开关只输出状态变化比盯屏幕更高效。这个思路可以一直保留到二次开发阶段每次新增交互都跑一遍 Dump能避免把旧问题带进新功能。6. 二次开发把固定画布改造成可配置的 OA 节点编排引擎基础画布能跑通之后下一步就是把它从“图形 Demo”变成“业务引擎”。OA 节点拖拽的核心不是画图而是让不同类型的节点带上不同的属性和校验规则。改造思路是把写死在代码里的节点类型抽出来做成注册表。6.1 节点类型注册表与属性面板联动常见做法是定义一个节点工厂由工厂根据 NodeType 创建指定尺寸和默认颜色的节点public sealed class DefaultNodeFactory { public DiagramNode CreateNode(string nodeType, PointF location) { var bounds nodeType switch { Start new RectangleF(location.X, location.Y, 120, 40), Approve new RectangleF(location.X, location.Y, 140, 70), Condition new RectangleF(location.X, location.Y, 160, 60), _ new RectangleF(location.X, location.Y, 120, 50) }; return new DiagramNode { Id Guid.NewGuid().ToString(N), NodeType nodeType, Bounds bounds }; } }工具栏上维护一个 NodeType 清单点击按钮时调用工厂创建节点再交给画布。这样 Canvas 不需要认识每个业务类型只负责渲染和拖拽。属性面板联动也顺理成章在画布上暴露 SelectionChanged 事件属性控件拿到当前选中的 DiagramNode根据 NodeType 动态显示字段比如审批节点显示审批人列表条件节点显示流转条件表达式。序列化时把这些业务字段放进一个 Dictionarystring, string 附在节点上加载时再原样填回属性面板。6.2 验证方法自动化模拟拖拽与回归测试改完交互后手工点按只能覆盖最简单的路径。我给画布加了一个 internal 方法直接调用拖拽状态机不经过真实鼠标_canvas.AddNode(startNode); var before _canvas.Nodes.Count; _canvas.SimulateDrag(startNode.Id, new PointF(50, 50), new PointF(200, 200)); var after _canvas.Nodes.Count; Debug.Assert(before after); Debug.Assert(startNode.Bounds.X 200 startNode.Bounds.Y 200);这里断言的是拖拽不会新增或删除节点移动后节点位置符合预期。再叠加撤销断言调用 Undo 后节点 Bounds 回到原始值。从此每改一次状态机先把这套回放跑一遍再上手工测试。以前我在画布上拖动调试改一处漏一处回放脚本把这类回归问题挡在了发布之前。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站