简介:这是一份面向WPF开发者与界面设计学习者的流程图绘制实战资源,聚焦如何在.NET框架下用WPF实现类似Visio的图形编辑功能。内容围绕图形元素、可缩放绘图板、带箭头的连接线、拖放交互、数据模型绑定、自动布局算法、撤销重做、样式模板以及序列化等核心模块展开,适合具备一定C#与XAML基础、希望深入图形化编辑器开发的中高级开发者参考。资源包共142个文件,以cs源码、xaml与baml界面文件为主,辅以dll、pdb、exe等编译产物及少量图片、配置与文档文件,压缩包约4.68MB,结构完整便于直接运行与二次开发。目前已有3114人学习下载。通过研读源码,读者可掌握流程图编辑器的整体架构、交互事件处理与数据持久化思路,并借鉴布局与命令模式等实现方式,快速搭建可扩展的图形编辑工具。
1. 用 WPF 画流程图:从拖拽节点到连线吸附的完整实现
很多做桌面端工控、医疗或内部管理系统的开发者都遇到过这个需求:客户想要一个能自己拖拽节点、连线、保存布局的流程图编辑器,类似 Visio 那种体验,但又不希望引入庞大的第三方控件库。WPF 实现流程图绘制这个方向,恰好卡在“功能要够用”和“技术栈要可控”之间。它适合已经熟悉 C# 和 XAML、想自己掌控渲染与交互逻辑的开发者,也适合需要把流程图能力嵌入现有 WPF 业务系统的团队。整个项目围绕画布、节点、连线、交互四块展开,核心难点不在画,而在拖拽时的坐标换算、连线端点的吸附判定,以及撤销重做这类编辑器的“后悔药”机制。下面按能跑起来、能看懂、能改动的顺序拆一遍。
2. 项目结构与技术选型:为什么用 Canvas 而不是 Grid
2.1 整体分层与目录组织
这个项目没有走 MVVM 重型框架的路子,而是用轻量级的分层方式,把渲染、交互、数据模型拆开。常见做法是分成四个目录:Models 放节点和连线的数据定义,Views 放主窗口和画布容器,Controls 放自定义的节点控件和连线控件,Services 放命中测试、序列化、撤销栈这些逻辑。这样拆的好处是,后面要把流程图导出成图片或者 JSON 时,不用去动界面代码。
主窗口的 XAML 里,核心是一个Canvas作为绘图表面,外面套ScrollViewer支持大画布滚动,再外层用Grid做工具栏和状态栏的布局。节点不是直接画在 Canvas 上的矩形,而是自定义的UserControl,里面包含边框、标题文本和四个方向的连接点。连线用Path加BezierSegment或者Polyline实现,具体选哪种取决于要不要曲线效果。
<!-- MainWindow.xaml 核心结构 --> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <!-- 工具栏:添加节点、删除、保存、加载 --> <ToolBar Grid.Row="0"> <Button Content="添加节点" Click="AddNode_Click"/> <Button Content="删除选中" Click="DeleteSelected_Click"/> <Button Content="保存布局" Click="SaveLayout_Click"/> </ToolBar> <!-- 画布区域,支持滚动 --> <ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <Canvas x:Name="FlowCanvas" Width="3000" Height="2000" Background="White" MouseMove="FlowCanvas_MouseMove" MouseLeftButtonUp="FlowCanvas_MouseLeftButtonUp"/> </ScrollViewer> </Grid>这里Canvas的Width和Height给了一个较大的固定值,是为了让ScrollViewer有滚动空间。如果想让画布无限扩展,可以在节点拖到边缘时动态增大这两个值,但那样会引入额外的坐标重算,新手阶段先固定尺寸更稳。
2.2 节点数据模型与依赖属性
节点模型用普通的INotifyPropertyChanged类就够了,不需要上ObservableCollection的复杂嵌套。每个节点记录唯一 Id、显示文本、X/Y 坐标、宽高,以及一个连接点集合。连接点本身也是一个简单对象,记录方向(上、下、左、右)和相对于节点左上角的偏移量。
public class FlowNode : INotifyPropertyChanged { public string Id { get; set; } = Guid.NewGuid().ToString(); public string Text { get; set; } = "新节点"; private double _x; public double X { get => _x; set { _x = value; OnPropertyChanged(); } } private double _y; public double Y { get => _y; set { _y = value; OnPropertyChanged(); } } public double Width { get; set; } = 120; public double Height { get; set; } = 60; // 四个连接点,按方向索引 public List<Connector> Connectors { get; set; } = new(); public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }X和Y做成依赖属性或者带通知的普通属性,是为了在拖拽时界面能自动刷新。如果只是普通字段,拖完松手界面才更新,体验会很差。连接点的偏移量一般取节点宽高的一半,比如上连接点是(Width/2, 0),右连接点是(Width, Height/2),这样连线端点能准确落在边框中点。
2.3 连线控件的绘制方式
连线用Path实现,数据是PathGeometry,里面放一段BezierSegment让线条有自然的弧度。起点和终点分别绑定到两个连接点的绝对坐标。绝对坐标的计算方式是:节点在 Canvas 上的Canvas.Left加上连接点相对偏移。这里容易翻车的地方是,如果直接绑定节点属性,拖拽过程中连线不会实时跟随,因为Canvas.Left是附加属性,绑定路径要写对。
// 根据两个连接点生成贝塞尔曲线路径 private PathGeometry BuildConnectionGeometry(Point start, Point end) { var geometry = new PathGeometry(); var figure = new PathFigure { StartPoint = start }; // 控制点偏移量,让曲线有弧度而不是直线 double offset = Math.Abs(end.X - start.X) * 0.5; var segment = new BezierSegment { Point1 = new Point(start.X + offset, start.Y), Point2 = new Point(end.X - offset, end.Y), Point3 = end }; figure.Segments.Add(segment); geometry.Figures.Add(figure); return geometry; }offset取水平距离的一半,曲线会向中间鼓起。如果两个节点垂直距离很大,可以把offset改成基于Math.Max的动态值,避免曲线过于扁平。连线控件本身还要处理选中高亮和删除,通常给Path加一个透明的粗描边作为命中区域,不然细线很难点中。
3. 拖拽与连线交互:坐标换算和吸附判定怎么做
3.1 节点拖拽的完整事件链
拖拽看起来简单,实际涉及三个事件:MouseLeftButtonDown记录起始点,MouseMove计算偏移并更新位置,MouseLeftButtonUp结束拖拽。关键点是坐标要基于Canvas而不是窗口,否则滚动条一动,节点位置就飘了。用e.GetPosition(FlowCanvas)拿到的是相对于画布的坐标,这个值可以直接赋给Canvas.SetLeft和Canvas.SetTop。
private Point _dragStart; private FlowNode? _draggingNode; private void Node_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var element = sender as FrameworkElement; _draggingNode = element?.DataContext as FlowNode; _dragStart = e.GetPosition(FlowCanvas); element?.CaptureMouse(); // 捕获鼠标,防止快速拖动时丢失事件 e.Handled = true; } private void FlowCanvas_MouseMove(object sender, MouseEventArgs e) { if (_draggingNode == null || e.LeftButton != MouseButtonState.Pressed) return; var current = e.GetPosition(FlowCanvas); double dx = current.X - _dragStart.X; double dy = current.Y - _dragStart.Y; _draggingNode.X += dx; _draggingNode.Y += dy; _dragStart = current; // 更新起始点,实现增量移动 UpdateConnectionsFor(_draggingNode); // 同步刷新相关连线 }CaptureMouse这行不能省,否则鼠标移出节点范围后MouseMove就不再触发,节点会卡在半路。增量移动的写法比记录绝对偏移更稳,因为每次只加差值,不会因为浮点累积误差导致节点跳变。UpdateConnectionsFor负责找到所有跟这个节点相连的连线,重新计算端点坐标。
3.2 连线端点的吸附判定
吸附是流程图编辑器的体验分水岭。做法是:当鼠标从某个连接点按下并拖动时,进入“连线模式”,画一条临时的虚线跟随鼠标。鼠标松开时,遍历画布上所有节点的连接点,计算鼠标位置与每个连接点的距离,如果小于阈值(常见取 15 到 20 像素),就吸附到那个连接点并创建正式连线。
private Connector? FindNearestConnector(Point mousePos, double threshold = 18) { Connector? nearest = null; double minDist = threshold; foreach (var node in _nodes) { foreach (var conn in node.Connectors) { // 连接点绝对坐标 = 节点坐标 + 相对偏移 var abs = new Point(node.X + conn.OffsetX, node.Y + conn.OffsetY); double dist = (abs - mousePos).Length; if (dist < minDist) { minDist = dist; nearest = conn; } } } return nearest; }阈值 18 是血泪经验值,太小了用户要对得很准,太大了容易吸到隔壁节点。如果画布缩放比例不是 1,阈值还要除以缩放系数,否则放大后吸附范围会显得特别小。找到最近连接点后,还要做一次方向校验,比如起点是右侧连接点,终点最好也是左侧连接点,这样连线不会绕一圈。
3.3 临时连线的绘制与取消
连线模式下,鼠标移动时要实时更新一条临时Path的终点。这条临时线通常用虚线样式,颜色浅一点,跟正式连线区分开。如果用户中途按了 Esc 或者右键,要能取消这次连线,把临时线从 Canvas 上移除。
private void StartConnection(Connector source, Point startPos) { _tempPath = new Path { Stroke = Brushes.Gray, StrokeThickness = 2, StrokeDashArray = new DoubleCollection { 4, 2 }, Data = new PathGeometry() }; FlowCanvas.Children.Add(_tempPath); _connectionSource = source; _tempStart = startPos; } private void UpdateTempConnection(Point current) { if (_tempPath == null) return; _tempPath.Data = BuildConnectionGeometry(_tempStart, current); } private void CancelConnection() { if (_tempPath != null) { FlowCanvas.Children.Remove(_tempPath); _tempPath = null; } _connectionSource = null; }StrokeDashArray里的{4, 2}表示画 4 个像素空 2 个像素,视觉上就是虚线。临时线不要加命中测试,否则会挡住下面节点的鼠标事件,设置IsHitTestVisible = false即可。取消逻辑要同时清理临时线和状态变量,不然下次连线会带着旧状态。
4. 避坑与常见问题:拖拽、连线、序列化里的翻车点
4.1 节点拖拽时连线不跟随
现象是拖动节点,节点动了但连线还停在原地,松手后才跳过去。原因通常是连线端点绑定的是节点属性的单向绑定,而Canvas.Left变化没有触发连线重算。解决方式是在MouseMove里主动调用连线刷新,或者把连线端点做成依赖属性,监听节点坐标变化。我一般选主动刷新,逻辑更直白,性能也可控。
4.2 连线端点吸附到错误的连接点
现象是明明想连右侧,结果吸到了下方。原因是遍历连接点时没有做方向优先级排序,距离最近的不一定是方向最合理的。解决方式是在FindNearestConnector里加一个方向权重,比如起点是右侧,那么左侧连接点的距离乘以 1.5 的惩罚系数,让同向连接点优先被选中。
4.3 保存后重新加载,连线全部错位
现象是 JSON 里存了节点坐标和连线关系,加载后连线端点对不上。原因多半是保存时存的是相对坐标,加载时没有还原节点宽高,导致连接点偏移量算错。解决方式是序列化时把节点宽高一起存,加载时先创建节点再根据宽高重建连接点,最后才恢复连线。
4.4 大量节点时拖拽卡顿
现象是节点超过一百个后,拖动一个节点整个界面发涩。原因是每次MouseMove都遍历所有连线做刷新。解决方式是给每个节点维护一个“关联连线列表”,拖拽时只刷新这个列表里的连线,而不是全量遍历。另外可以把MouseMove里的刷新用Dispatcher.BeginInvoke延迟到渲染帧,避免过于频繁。
4.5 删除节点后连线残留
现象是删了节点,连到它的线还在画布上。原因是删除逻辑只移除了节点控件,没有清理关联连线。解决方式是在删除节点前,先遍历连线集合,把所有起点或终点指向该节点的连线一并删除,并且从数据模型里移除,保证界面和数据一致。
5. 二次开发改造:从能跑到好用,序列化与撤销重做怎么加
5.1 用 JSON 做布局序列化
要让流程图能保存和加载,最直接的方式是把节点和连线序列化成 JSON。节点存 Id、文本、坐标、宽高;连线存起点节点 Id、起点连接点方向、终点节点 Id、终点连接点方向。加载时先重建所有节点,再根据 Id 找到对应连接点重建连线。这样存出来的文件小,也方便版本管理。
public class FlowDocument { public List<NodeDto> Nodes { get; set; } = new(); public List<ConnectionDto> Connections { get; set; } = new(); } public class NodeDto { public string Id { get; set; } = ""; public string Text { get; set; } = ""; public double X { get; set; } public double Y { get; set; } public double Width { get; set; } public double Height { get; set; } } public class ConnectionDto { public string FromNodeId { get; set; } = ""; public string FromDirection { get; set; } = "Right"; public string ToNodeId { get; set; } = ""; public string ToDirection { get; set; } = "Left"; }序列化用System.Text.Json就够了,不需要引入第三方库。保存时把FlowDocument转成字符串写入文件,加载时反序列化后按顺序重建。注意加载前要清空当前画布,否则会叠加。
5.2 撤销重做的命令栈设计
撤销重做是编辑器类项目的标配,但也是最容易写乱的部分。常见做法是定义一个ICommand接口,每个可撤销操作实现Execute和Undo。用一个Stack<ICommand>存已执行命令,另一个存已撤销命令。新操作执行时清空重做栈,撤销时从执行栈弹出并调用Undo,再压入重做栈。
public interface IUndoableCommand { void Execute(); void Undo(); } public class MoveNodeCommand : IUndoableCommand { private readonly FlowNode _node; private readonly Point _oldPos; private readonly Point _newPos; public MoveNodeCommand(FlowNode node, Point oldPos, Point newPos) { _node = node; _oldPos = oldPos; _newPos = newPos; } public void Execute() { _node.X = _newPos.X; _node.Y = _newPos.Y; } public void Undo() { _node.X = _oldPos.X; _node.Y = _oldPos.Y; } }拖拽结束时才把移动命令压栈,不要在MouseMove里压,否则一次拖拽会产生几百条命令。删除节点、添加连线、修改文本都各自实现一个命令类,统一走命令栈管理。
5.3 改造时的验证习惯
加完序列化和撤销重做后,我一般会强制走一遍固定流程:添加三个节点、连两条线、拖动其中一个、删除一条线、撤销三次、重做两次、保存、关闭、重新打开加载。这套流程能覆盖坐标、连线、命令栈、序列化四个模块的联动问题。从那以后我每次改完交互逻辑,都强制走一遍这个流程,比单元测试还管用。希望帮到你。
本文还有配套的精品资源,点击获取