资讯详情

资讯详情

C# WinForm流程图设计器源码解析:拖拽、缩放、撤销与序列化实现

简介这是一份基于 C# WinForm 的完整流程图绘制项目面向需要开发图形化编辑工具的.NET开发者提供了从工具箱创建图元到属性调节的一整套可复用方案。项目内置矩形、菱形、圆、直线、曲线等图元支持六个操纵柄、四个自动吸附连接点、文字编辑、缩放画布、文件打开存储且带操作记录可撤销移动图形时已连接线条会联动属性窗口可调整背景、填充和文字等参数功能覆盖非常全面。资源包共94个文件以53个cs源代码文件为核心辅以配置文件、图片资源和可执行文件等整体仅2.29MB结构紧凑清晰适合直接参考、移植或二次开发。目前已有880人学习下载对希望掌握WinForm交互绘图、图元编辑与撤销机制的开发者来说是一份性价比很高的实战模板。1. C# WinForm 流程图从工具箱拖拽到撤销重做这套源码把设计器该有的都做全了做上位机或者内部工具的时候十有八九会遇到画流程图的需求。用第三方控件贵的要命不说改个样式还要看文档看到吐自己从零写画布缩放、图元拖动、连线、撤销重做每一项都是看不见的坑。这套 C# WinForm 流程图源码属于「能直接跑、能照着改」的那一类工具箱拖拽、文件存储打开、画布放大缩小、图元操作、可撤销、图元属性调节全都齐了。它不是那种只有一个画板画几个方块的 demo而是把设计器该有的交互闭环做完了。适合想在自己的 WinForm 项目里嵌流程图功能、又不想被商业控件绑架的开发者也适合拿来做二次开发的底子。2. 画布与坐标变换缩放平移背后那套矩阵逻辑搞不懂后面全是坑2.1 三层坐标系屏幕、视口与画布流程图设计器最大的错觉就是你以为鼠标点在哪图元就画在哪。实际上鼠标给的是屏幕坐标图元存的是画布坐标中间隔着一个「视口偏移 缩放系数」。这套源码里画布坐标是图元的唯一真实坐标所有图元、连线的位置都按画布坐标存储绘制时统一做一次变换这样才能保证缩放后数据不丢。我一般会维护两个核心字段_offsetX / _offsetY表示视口左上角在屏幕上的位置_scale表示缩放系数。绘制时用TranslateTransform平移、ScaleTransform缩放然后所有图元用画布坐标直接画。这样做的好处是图元代码干净不用每个图元自己关心缩放。代价是 GDI 的变换矩阵会把 Pen 宽度、Font 字号一起缩放这个坑后面专门说。鼠标交互时要做的是屏幕坐标转画布坐标公式很简单// 屏幕坐标 - 画布坐标 PointF ScreenToCanvas(Point screen) { return new PointF( (screen.X - _offsetX) / _scale, (screen.Y - _offsetY) / _scale ); } // 画布坐标 - 屏幕坐标 Point CanvasToScreen(PointF canvas) { return new Point( (int)(canvas.X * _scale _offsetX), (int)(canvas.Y * _scale _offsetY) ); }这两个转换方法是整个交互的地基。拖拽、命中测试、框选、连线所有涉及鼠标的操作都要先过ScreenToCanvas。新手最容易犯的错是拿屏幕坐标直接和图元的画布坐标比较结果一缩放全乱套。记住一条鼠标事件进来第一步永远是转画布坐标转完再谈逻辑。2.2 滚轮缩放围绕鼠标位置公式与实现画布缩放不能以左上角为锚点否则鼠标盯着一个图元放大图元反而跑了。正确做法是缩放前后鼠标所在的画布坐标点保持不动。推导不复杂先算出缩放前鼠标指向的画布点p缩放后让p乘以新缩放系数、加上新偏移等于当前鼠标屏幕坐标反解新偏移。private void Canvas_MouseWheel(object sender, MouseEventArgs e) { float oldScale _scale; float newScale oldScale * (e.Delta 0 ? 1.1f : 0.9f); newScale Math.Clamp(newScale, 0.2f, 5f); // 限制缩放范围防止缩得找不回来 // 缩放前鼠标指向的画布坐标 PointF p new PointF( (e.X - _offsetX) / oldScale, (e.Y - _offsetY) / oldScale ); // 缩放后让 p 仍对准鼠标位置反解 offset _offsetX e.X - p.X * newScale; _offsetY e.Y - p.Y * newScale; _scale newScale; Invalidate(); // 触发重绘 }e.Delta是滚轮增量WinForm 里向上为正每次滚一格一般取 120。1.1f / 0.9f是缩放步进想要手感更细可以改成1.05f。Math.Clamp限制缩放范围非常关键没有它用户能把画布缩到 0.01 倍图元变成像素点连选中都做不到。缩放下限我一般设 0.2上限 5.0这个范围对流程图编辑足够了。2.3 命中测试必须在画布坐标下做图元选中、拖拽、连线端点捕捉全都依赖命中测试。常见做法是每个图元实现一个HitTest(PointF p)方法矩形图元用Bounds.Contains(p)连线用点到线段距离。// 矩形图元命中测试tolerance 是容差像素 public bool HitTest(PointF p, float tolerance) { RectangleF rect Bounds; rect.Inflate(tolerance, tolerance); return rect.Contains(p); } // 线段命中测试点到线段距离小于容差 public bool HitTestLink(PointF p, float tolerance) { float dist DistanceToSegment(p, StartPoint, EndPoint); return dist tolerance; }命中测试的容差必须除以_scale因为容差是屏幕像素概念要转成画布单位再比较。比如屏幕容差 8 像素画布容差就是8f / _scale。缩放 0.5 倍时容差是 16 个画布单位这很合理因为用户缩小时眼睛和鼠标的精度都下降了不加容差根本点不中细线段。3. 工具箱与图元交互拖拽创建、选中移动与连线锚点3.1 图元基类把形状、边界、连接点统一起来这套源码的图元不是一堆散装绘制代码而是有一个清晰的继承结构。基类定义公共接口绘制、命中测试、边界、连接点、属性集合。派生类只管自己的形状长什么样。public abstract class FlowNode { public string Name { get; set; } 图元; public RectangleF Bounds { get; set; } public Color FillColor { get; set; } Color.White; public Color BorderColor { get; set; } Color.Black; // 每个图元自己负责画基类不管形状 public abstract void Draw(Graphics g); // 默认按矩形区域命中菱形或圆形图元可重写 public virtual bool HitTest(PointF p, float tolerance) { RectangleF rect Bounds; rect.Inflate(tolerance, tolerance); return rect.Contains(p); } // 四个连接锚点上下左右 public PointF GetAnchor(AnchorDirection dir) { switch (dir) { case AnchorDirection.Top: return new PointF(Bounds.Left Bounds.Width / 2, Bounds.Top); case AnchorDirection.Bottom: return new PointF(Bounds.Left Bounds.Width / 2, Bounds.Bottom); case AnchorDirection.Left: return new PointF(Bounds.Left, Bounds.Top Bounds.Height / 2); case AnchorDirection.Right: return new PointF(Bounds.Right, Bounds.Top Bounds.Height / 2); } return Bounds.Location; } }派生类只需要画形状。比如决策节点是菱形开始节点是圆角矩形处理节点是普通矩形。连线锚点不要自己算统一走GetAnchor这样连线代码才不会在每种图元里重复一遍。3.2 工具箱拖拽创建从 ListBox 到画布的 DragDrop工具箱在 WinForm 里最省事的实现就是 ListBox每一项绑定一个NodeType枚举。用户按下鼠标左键时发起拖拽画布接收后创建图元。// 工具箱鼠标按下开始拖拽 private void Toolbox_MouseDown(object sender, MouseEventArgs e) { if (toolboxList.SelectedItem null) return; NodeType type (NodeType)toolboxList.SelectedItem; toolboxList.DoDragDrop(new NodeCreateRequest { NodeType type }, DragDropEffects.Copy); } // 画布接收拖拽 private void Canvas_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(typeof(NodeCreateRequest))) e.Effect DragDropEffects.Copy; else e.Effect DragDropEffects.None; } private void Canvas_DragDrop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(typeof(NodeCreateRequest))) return; NodeCreateRequest req (NodeCreateRequest)e.Data.GetData(typeof(NodeCreateRequest)); PointF canvasPt ScreenToCanvas(new Point(e.X, e.Y)); // 注意 e.X/e.Y 是屏幕坐标 FlowNode node NodeFactory.Create(req.NodeType); node.Bounds new RectangleF(canvasPt.X, canvasPt.Y, 120, 60); doc.Nodes.Add(node); Invalidate(); }DragEventArgs里的e.X / e.Y拿到的是屏幕坐标必须经ScreenToCanvas转换这个漏掉就是图元跑偏。还有一个小细节容易被忽视WinForm 的DragDrop事件里e.Data.GetDataPresent判断的是数据类型不是字符串拖拽对象要能被同一个程序集识别跨程序集拖拽则要用序列化后的格式。这套源码里定义了一个NodeCreateRequest小类干净利落。3.3 连线不存坐标存锚点拖动图元不会散架流程图里连线最容易做成「存两个点的坐标」那样拖动图元时线就断了。这套源码的做法是连线只存起点图元和终点图元的引用加锚点方向绘制时实时取GetAnchor拿坐标。public class FlowLink { public FlowNode From { get; set; } public FlowNode To { get; set; } public AnchorDirection FromAnchor { get; set; } public AnchorDirection ToAnchor { get; set; } public void Draw(Graphics g) { PointF p1 From.GetAnchor(FromAnchor); PointF p2 To.GetAnchor(ToAnchor); // 贝塞尔曲线控制点沿锚点方向外延 float offset Math.Max(40, Math.Abs(p2.X - p1.X) / 2); PointF c1 OffsetAnchor(p1, FromAnchor, offset); PointF c2 OffsetAnchor(p2, ToAnchor, offset); using GraphicsPath path new GraphicsPath(); path.AddBezier(p1, c1, c2, p2); g.DrawPath(Pens.Black, path); } }控制点外延距离offset决定连线弧度两个图元水平排列时弧线会比较自然。连线交互的流程是鼠标按下时检测是否点到已有图元然后进入「连线模式」鼠标弹起时检测是否落在目标图元上是则创建FlowLink并加入文档。这个流程里唯一要小心的是不要把连线动作跟拖拽动作混淆一般用鼠标按下时的距离阈值判断移动超过 5 像素算拖拽否则算点击。4. 文件存储与打开序列化方案、版本兼容与反序列化陷阱4.1 为什么选 JSON 而不是二进制流程图文件的存储最常见也最稳妥的方案是 JSON而不是二进制。原因很实际二进制序列化BinaryFormatter在 .NET 里已经被标记过时而且它的数据里带程序集全名一改类名、改名空间、升级版本旧文件全废。XML 能用但太啰嗦手改文件调试不方便。JSON 可读、可 diff、可手写出了问题还能拿出来人工检查。序列化结构大概是这样的{ version: 1, nodes: [ { $type: FlowEditor.StartNode, FlowEditor, name: 开始, x: 120, y: 80, width: 120, height: 60 } ], links: [ { fromId: a1f3c9, fromAnchor: Bottom, toId: b2e8d7, toAnchor: Top } ] }节点和连线分开存。节点用$type标记具体类型连线不直接存对象引用而是存fromId / toId这两个 ID 是节点序列化时生成的Guid。反序列化后要用字典把 ID 映射回节点实例重建引用关系。4.2 Newtonsoft.Json 的 TypeNameHandling 配置多态反序列化是 JSON 方案里最不好处理的点。FlowDocument里的ListFlowNode是基类列表反序列化时如果不加配置JsonConvert根本不知道某个 JSON 对象该反序列化成ProcessNode还是DecisionNode。常见做法是用 Newtonsoft.Json 的TypeNameHandling.All序列化时自动写$type字段JsonSerializerSettings settings new JsonSerializerSettings { TypeNameHandling TypeNameHandling.All, Formatting Formatting.Indented, NullValueHandling NullValueHandling.Ignore }; // 保存 string json JsonConvert.SerializeObject(doc, settings); File.WriteAllText(path, json); // 打开 FlowDocument doc JsonConvert.DeserializeObjectFlowDocument(json, settings);TypeNameHandling.All会递归处理所有类型ListFlowNode里的每个元素都能正确还原成实际类型。但这个配置有一个隐患如果文件被外部篡改$type可以被改成任意类型存在反序列化攻击风险。在自己的本地文件场景下问题不大但如果你要把.flow.json这种文件发给别人我建议改成自定义JsonConverter只允许白名单里的图元类型代价是代码多一点换来的是文件不会被恶意利用。这也是这套源码里值得研究的一个点读它的时候可以留意一下用的是哪种方案。4.3 版本字段与旧文件兼容流程图文件最大的坑是改版。这周加了一个新图元类型上周存的旧文件打开就报错下周把某个属性改名了所有旧文件里的数据全部丢失。这套源码的文档结构里带了version字段打开文件时先读版本号再做字段兜底。// 打开文件后做版本迁移 private void MigrateDocument(FlowDocument doc) { if (doc.Version 2) { foreach (FlowNode node in doc.Nodes) { // 旧版本没有 Width/Height用默认值补齐 if (node.Bounds.Width 0) node.Bounds new RectangleF(node.Bounds.X, node.Bounds.Y, 120, 60); } } doc.Version 2; // 迁移完成后更新版本 }反序列化时还要配MissingMemberHandling.IgnoreJSON 里缺字段不要抛异常让属性保持默认值。我的习惯是每改一次图元结构就升一版version打开文件时判断版本号走对应的迁移逻辑。不加版本号的流程图文件三个月后你打开自己写的程序都会发现旧文件全是坏的更别说发给同事用了。5. 常见问题排查缩放失真、画布闪烁、撤销失效三个翻车点5.1 缩放后连线粗细异常、文字模糊现象画布放大到 2 倍连线变成 2 像素粗缩小到 0.5 倍线几乎看不见图元文字在放大时糊成一片。原因Graphics.ScaleTransform是作用于整个绘图管线的Pen 的宽度、Font 的字号都会被缩放系数影响。这不是 bug是 GDI 的数学行为但流程图场景下几乎没人希望文字和线宽跟着缩放走。解决画图元的形状时可以接受缩放但画文字和连线前要把缩放抵消。文字绘制前恢复字号Font用new Font(font.FontFamily, font.Size / _scale)Pen 宽度同理new Pen(color, penWidth / _scale)。或者更彻底一点绘制时完全不使用ScaleTransform所有坐标手动乘_scale再画Pen 宽度保持恒定。这两种方案我推荐后者因为手动乘坐标虽然代码丑一点但文字、线宽、阴影效果全部可控不会有奇怪的副作用。5.2 画布闪烁严重尤其是缩放和平移时现象拖动画布或者滚动缩放时画布区域疯狂闪烁图元多的时候几乎没法操作。原因WinForm 默认的控件绘制是先擦背景再画前景两帧之间有一个空窗期频繁Invalidate时这个空窗期被无限放大。常见的误用是在OnPaint里直接画然后每次交互都Invalidate没有开双缓冲。解决开双缓冲是标准做法。public CanvasControl() { // 双缓冲三件套缺一个都会闪 SetStyle(ControlStyles.AllPaintingInWmPaint, true); SetStyle(ControlStyles.OptimizedDoubleBuffer, true); SetStyle(ControlStyles.UserPaint, true); UpdateStyles(); // 减少无效重绘区域 this.ResizeRedraw false; } protected override void OnPaintBackground(PaintEventArgs e) { // 背景交给 OnPaint 绘制避免闪白 }OptimizedDoubleBuffer是核心AllPaintingInWmPaint告诉系统别在 WM_PAINT 之前擦背景UserPaint是让控件自己负责绘制。还有一个细节拖动图元时不要每次都Invalidate()整个画布只Invalidate(图元旧位置 ∪ 新位置)的合并矩形图元多的时候性能差好几倍。5.3 撤销之后图元位置不对或者撤销一次跳两步现象连续拖动两个图元点撤销第二个图元回到原位了第一个图元的移动也被撤销了但只显示撤销了一次。原因撤销命令栈里存的是「新建的图元对象」而不是「同一个图元实例的前后值」。常见误用是某个操作内部new了一个节点来做逻辑命令的Undo()作用于这个临时对象画布上新对象完全没受影响或者反过来Undo()里把画布上的对象替换成了命令里的旧对象后续操作全堵在旧对象上。解决命令模式里命令持有的是图元的实例引用Undo和Redo都改这个实例的属性而不是替换引用。public class MoveNodeCommand : ICommand { private FlowNode _node; private RectangleF _oldBounds; private RectangleF _newBounds; public void Execute() _node.Bounds _newBounds; public void Undo() _node.Bounds _oldBounds; }这套源码的撤销重做是操作级而不是像素级的一条命令对应一次完整的图元操作——移动、新建、删除、连线都各自封装成实现ICommand的类入栈时Execute()撤销时Undo()。判断撤销栈有没有问题最快的验证方法是连续移动同一个图元三次再全部撤销图元应该准确回到起点。这在这个源码里是可以直接跑通验证的。5.4 打开文件后连线的 From 是 null现象文件保存时好好的重新打开后连线的起点终点丢失或者连线的From引用指向 null 导致绘制时报NullReferenceException。原因序列化时连线存的是对象引用JSON 序列化后引用变成了嵌套的对象副本反序列化后连线的From指向的节点和文档Nodes列表里的节点根本不是同一个实例。这是所有图元编辑器序列化最容易踩的坑。解决连线只存 ID不存引用。序列化时节点分配Guid Id连线存FromId / ToId反序列化后通过字典重建引用。// 反序列化后重建连线引用 DictionaryGuid, FlowNode nodeMap doc.Nodes.ToDictionary(n n.Id); foreach (FlowLink link in doc.Links) { link.From nodeMap[link.FromId]; link.To nodeMap[link.ToId]; }6. 属性调节进阶PropertyGrid 直接绑定图元属性改动也进撤销栈图元属性调节最自然的做法就是用 WinForm 自带的PropertyGrid控件把选中图元的实例赋给SelectedObject属性和值就自动展示出来了。表面看是一行代码的事但要做得好用有两个进阶点。第一是显示中文属性名和分组。属性加特性标注PropertyGrid才能按中文显示、按分类折叠[Category(外观), DisplayName(填充颜色), Description(图元的背景色)] public Color FillColor { get; set; } [TypeConverter(typeof(ExpandableObjectConverter))] [Category(布局), DisplayName(边界矩形)] public RectangleF Bounds { get; set; }ExpandableObjectConverter可以让RectangleF这类复合类型在属性网格里展开成 X、Y、Width、Height 分别编辑比直接把整个结构体平铺出来体验好得多。第二是属性改动联动撤销。默认情况下PropertyGrid改值只回写图元属性不会进撤销栈用户改了十几个属性后想撤销完全没有后悔药。我的做法是挂PropertyValueChanged事件把旧值和新值封装成命令推入撤销栈private void propGrid_PropertyValueChanged(object sender, PropertyValueChangedEventArgs e) { FlowNode node (FlowNode)propGrid.SelectedObject; object oldValue e.OldValue; string propName e.ChangedItem.PropertyDescriptor.Name; var cmd new ChangePropertyCommand(node, propName, oldValue); _undoStack.Push(cmd); }ChangePropertyCommand的Undo用反射把oldValue写回图元属性Redo写回新值。注意属性变更后要触发画布重绘否则颜色改了画布上没反应。从那以后我每次做流程图编辑器都会把「属性 → 撤销 → 重绘」这三个环节放在一起设计缺一个交互就是断的。这套源码里已经把这套逻辑串起来了真要集成进自己的项目优先看ICommand的实现和PropertyGrid的绑定方式比看绘制代码更有收获。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →