做流程设计器这类功能,最容易让人头大的往往不是业务逻辑,而是“画布交互”这一层。尤其是流程节点连线,听起来不就是画条线嘛,真做起来会发现涉及命中检测、状态切换、曲线绘制、坐标换算一堆破事。这篇文章想把我在C# + WinForms里实现节点连线的完整思路、核心代码和踩坑记录一次性讲清楚,给正准备做类似功能的开发者一个可以直接“抄作业”的底子。
这个功能本身是很多内部工具、审批流设计器、甚至简单拓扑图编辑器的最小核心。适合 WinForms 项目里需要自绘画布、节点拖拽、锚点连线的场景。如果你对 WPF 不熟、又不想为一个小功能引入重型第三方库,那用 GDI+ 手绘是一个极其务实的路线。接下来从方案选型开始讲,倒不是想论证自绘有多高级,而是想说明:数据模型怎么分、绘制怎么组织、交互状态怎么管,这几件事想清楚,功能就是水到渠成的事。
1. 整体设计思路与方案选型
1.1 为什么选 GDI+ 自绘而不是现成控件
做流程图类功能,第一反应往往是去找现成的流程控件。真正调研过的人应该清楚,成熟的第三方控件普遍自带工具栏、缩放、自动布局,功能很全,但你要为这些功能付费,而且遇到定制需求时,改它的内部行为比从头写还难受。比如说,很多控件对节点样式、连线上文字标签、锚点位置这些扩展都不够开放,改起来要顺着它的接口走,绕来绕去。
WinForms 自带的 ListView、TreeView 这类控件只能解决列表和树形的展示,撑不起自由画布。剩下最主流的路子就是 UserControl 上用 GDI+ 手绘。GDI+ 做节点绘制、贝塞尔曲线、命中测试都够用,而且只依赖 System.Drawing,不用引额外的包。性能上,只要节点量控制在几百个以内、合理做局部刷新,完全不会卡。这个量级覆盖了绝大多数企业内部的流程编辑场景。
我选择自绘还有一个非常实际的原因:调试方便。绘制、命中、数据序列化都是我自己的代码,出问题单步跟进去就能定位。不像第三方控件,出个诡异问题,你根本不知道它内部哪一步出了问题。
1.2 先拆数据模型,再谈绘制
很多人上来就写 Paint 事件,把节点和连线画在代码里,结果越写越乱。核心问题在于把“数据”和“视图”混在一起了。流程节点连线的本质是:我有一批节点,每个节点上有若干锚点,锚点之间建立了连线关系。至于节点画成方框还是圆角矩形、连线是直线还是曲线,这些都是视图层的渲染方式。
所以我设计了三个最基础的模型类:
- FlowNode:流程节点。记录矩形区域、标题、输入锚点列表、输出锚点列表。
- Connector:锚点。挂在节点上,类型分输入/输出,偏移量是相对节点左上角的。
- FlowConnection:连线。记录 Source 和 Target 两个锚点的引用。
这三个类组成了整个画布的数据层。绘制时只需要遍历这三个集合,把节点画出来、把锚点画出来、把连线路径画出来。交互时则是修改这些模型的属性,然后触发重绘。
这样做的收益很大。保存文件时,序列化这三个集合就行;加载时反序列化后重建对象;后面如果要加撤销重做,拦截修改操作就能做成历史记录。数据、渲染、交互三者解耦,这个思路是整套实现能保持清晰的关键。
1.3 交互本质上是一个状态机
节点连线这个功能看着简单,但鼠标操作有很多种可能:按下时可能点在节点上、点在锚点上、点在连线上、点在空白处;拖动过程中可能拖的是节点,也可能在拉一条新连线;松开时还要判断是否落在合法的目标锚点上。
如果不做状态管理,在 OnMouseMove 里用大量 if 嵌套判断当前是什么操作,很快就会失控。我一开始偷懒这么写过,后来加功能时痛苦得不行。后来老老实实梳理了一遍状态:
| 状态 | 触发条件 | 主要行为 |
|---|---|---|
| 空闲 | 初始状态 | 悬停检测、光标切换 |
| 拖拽节点 | 按下时命中节点 | 移动节点 Rect,更新锚点位置 |
| 拉线中 | 按下时命中输出锚点 | 跟随鼠标画预览线,检测合法目标锚点 |
| 鼠标操作中 | 按压后未松开的任何状态 | 防止误触,统一处理 |
这套状态机不复杂,但它把鼠标事件里的逻辑从一团乱麻变成清晰的几个分支,后面加缩放、加多选、加右键菜单都只是在这套机制上做扩展,不会把原有逻辑改崩。我认为这是流程交互里性价比最高的一笔投资。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构与绘制原理
2.1 节点类与锚点的建模细节
先看节点的定义。这里我刻意把 Rect 定义为相对画布的坐标,而不是控件上的屏幕坐标。这样后续加滚动、缩放,只需要统一做一套坐标变换,不需要改模型层。
csharp复制public class FlowNode
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "节点";
public RectangleF Rect { get; set; } = new RectangleF(0, 0, 140, 48);
public List<Connector> Inputs { get; } = new List<Connector>();
public List<Connector> Outputs { get; } = new List<Connector>();
public bool Selected { get; set; }
public void InitConnectors()
{
Inputs.Clear();
Outputs.Clear();
// 输入锚点在左边缘垂直居中,输出锚点在右边缘垂直居中
Inputs.Add(new Connector(this, ConnectorType.Input, new PointF(0, Rect.Height / 2)));
Outputs.Add(new Connector(this, ConnectorType.Output, new PointF(Rect.Width, Rect.Height / 2)));
}
public bool HitTest(PointF p) => Rect.Contains(p);
}
InitConnectors 里的 Offset 是相对节点的偏移,不是绝对坐标。这么设计的原因很直接:节点拖走之后,锚点要跟着节点走。如果存的是绝对坐标,每次拖拽还要同步给每个锚点算一遍新坐标,麻烦而且容易漏。
锚点类长这样:
csharp复制public enum ConnectorType { Input, Output }
public class Connector
{
public Guid Id { get; set; } = Guid.NewGuid();
public FlowNode Owner { get; }
public ConnectorType Type { get; }
public PointF Offset { get; set; }
public const float HitRadius = 8f;
public Connector(FlowNode owner, ConnectorType type, PointF offset)
{
Owner = owner;
Type = type;
Offset = offset;
}
public PointF Position => new PointF(Owner.Rect.X + Offset.X, Owner.Rect.Y + Offset.Y);
public bool CanConnectTo(Connector other)
=> Type == ConnectorType.Output
&& other.Type == ConnectorType.Input
&& !ReferenceEquals(Owner, other.Owner);
public bool HitTest(PointF p)
=> Distance(p, Position) <= HitRadius;
}
CanConnectTo 非常重要,它把连线规则收敛到一个模型方法里。默认规则只允许输出锚点连到输入锚点,且不能连回同一个节点。以后要限制“同一类型不能互连”“某些节点禁止作为目标”之类的业务规则,直接在这里扩展就行,不用去改鼠标事件代码。
2.2 连线的数据模型与路径生成
连线保存的是 Source 和 Target 两个锚点的引用。这里有一个重要原则:绝不保存连线的坐标快照。因为节点拖动后锚点位置会变,如果连线保存的是画线瞬间的坐标,那节点移动后连线就悬空了。
csharp复制public class FlowConnection
{
public Guid Id { get; set; } = Guid.NewGuid();
public Connector Source { get; }
public Connector Target { get; }
public FlowConnection(Connector source, Connector target)
{
Source = source;
Target = target;
}
public GraphicsPath BuildPath()
{
PointF start = Source.Position;
PointF end = Target.Position;
// 控制点偏移量取固定最小值和起点终点水平距离的一半
float dx = Math.Max(40f, Math.Abs(end.X - start.X) * 0.5f);
var path = new GraphicsPath();
path.AddBezier(start,
new PointF(start.X + dx, start.Y),
new PointF(end.X - dx, end.Y),
end);
return path;
}
public bool HitTest(PointF p, float tolerance)
{
using (var path = BuildPath())
using (var pen = new Pen(Color.Black, tolerance))
{
return path.IsOutlineVisible(p, pen);
}
}
}
这里选用三次贝塞尔曲线,而不是简单画一条直线。节点连线的常见视觉要求是不能穿过节点本体太多,两个相邻节点的连线要有一点点圆弧过渡感。经典的思路是:起点的控制点沿着水平方向往右拉,终点的控制点沿着水平方向往左拉。这样曲线先水平出起点,再平滑拐弯,最后水平进入终点,视觉上正好贴合左右两边的锚点。
控制点偏移量 dx 我给了个下限 40。为什么?因为两个节点靠得很近时,如果 dx 太小,曲线会变得很“硬”,甚至在起终点之间挤压出奇怪的形状。给个下限保证无论节点间距多小,连线都有基本弧度和可读性。当距离变大时,取水平距离的一半,曲线会随距离自然拉长,视觉节奏比较一致。
2.3 绘制管线与画布渲染
绘制全部集中在自定义 UserControl 的 OnPaint 里。整个绘制顺序是:背景网格、连线、预览线、节点、锚点。为什么锚点放最后?因为锚点需要浮在节点上方,鼠标靠近时有高亮反馈,放在最后画才不会被节点覆盖。
csharp复制protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
DrawGrid(g);
foreach (var conn in _connections)
{
using (var path = conn.BuildPath())
using (var pen = new Pen(Color.Gray, 2f))
{
g.DrawPath(pen, path);
}
}
// 拉线过程中的预览虚线
if (_isConnecting && _pendingSource != null)
{
using (var pen = new Pen(Color.DodgerBlue, 2f)
{
DashStyle = DashStyle.Dash
})
{
g.DrawLine(pen, _pendingSource.Position, _mousePosition);
}
}
foreach (var node in _nodes)
{
DrawNode(g, node);
}
}
这里有几个细节值得注意。第一,SmoothingMode.AntiAlias 必须开,否则曲线锯齿感很强,做出来像老式画图工具。第二,GDI+ 的 Pen、Brush 这类对象建议用 using 包住,及时释放非托管资源。第三,预览线用虚线,和真实连线在视觉上区分开,这是交互设计中一个很小的点,但体验上很重要。
节点的绘制我会用 FillRectangle 填充背景,DrawRectangle 画边框,选中时边框换成高亮色;标题文本用 TextRenderer.DrawText 绘制,它的文本渲染效果比 Graphics.DrawString 在 WinForms 下更清晰。
csharp复制private void DrawNode(Graphics g, FlowNode node)
{
using (var fill = new SolidBrush(node.Selected ? Color.LightSteelBlue : Color.White))
using (var border = new Pen(node.Selected ? Color.DodgerBlue : Color.DarkGray, 2f))
{
g.FillRectangle(fill, node.Rect);
g.DrawRectangle(border, Rectangle.Round(node.Rect));
TextRenderer.DrawText(g, node.Title, Font,
Rectangle.Round(node.Rect), Color.Black,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
foreach (var input in node.Inputs)
DrawConnector(g, input, input == _hoverConnector ? Color.DodgerBlue : Color.Green);
foreach (var output in node.Outputs)
DrawConnector(g, output, output == _hoverConnector ? Color.DodgerBlue : Color.Orange);
}
锚点画成直径 10 的实心圆,外面再画一圈白色描边。白色描边是为了让锚点和节点边框重叠时依然清晰可见。悬停时锚点变蓝,给用户一个“这里可以点”的暗示。五颜六色的锚点会让画面看起来更花,但实际用下来,输入输出用两种颜色区分,工作效率提升很明显,用户几乎不用看标签。
2.4 网格背景的绘制
网格背景主要起定位作用,让用户拖拽时对节点位置有空间感知。简单画垂直和水平线即可,格子大小 20 像素比较舒服。如果想让视觉更柔和,可以画点阵,但性能会差一点。线网格在几百节点场景下完全够用。
csharp复制private void DrawGrid(Graphics g)
{
const int cell = 20;
using (var pen = new Pen(Color.LightGray, 1f))
{
for (int x = 0; x < Width; x += cell)
g.DrawLine(pen, x, 0, x, Height);
for (int y = 0; y < Height; y += cell)
g.DrawLine(pen, 0, y, Width, y);
}
}
这部分代码虽然简单,但如果节点量大、控件频繁刷新,网格绘制会成为性能瓶颈。后面我会讲怎么优化局部刷新,避免每次重绘都全量画一遍网格。
3. 核心交互流程与关键实现
3.1 命中检测的优先级
鼠标事件里所有逻辑开始之前,先要回答一个问题:当前鼠标位置命中的是什么?命中检测的顺序非常关键,顺序错了,功能就错乱了。
我采用的顺序是:锚点优先于节点,节点优先于连线,连线优先于空白。
为什么锚点优先?因为锚点绘制在节点上方,而且面积小,用户想拖节点时手指可能正好落在锚点上,如果节点优先响应,那这个锚点就永远点不到。连线最弱,因为它是一条细线,用户一般是点空白区域想取消选中,如果线刚好穿过那片区域,响应它不算误操作,但还应该允许后续继续操作空白区域。
csharp复制private FlowNode HitNode(PointF p)
{
for (int i = _nodes.Count - 1; i >= 0; i--)
if (_nodes[i].HitTest(p)) return _nodes[i];
return null;
}
private Connector HitConnector(PointF p, Func<Connector, bool> filter = null)
{
foreach (var node in _nodes)
{
foreach (var input in node.Inputs)
if (input.HitTest(p) && (filter == null || filter(input)))
return input;
foreach (var output in node.Outputs)
if (output.HitTest(p) && (filter == null || filter(output)))
return output;
}
return null;
}
private FlowConnection HitConnection(PointF p)
{
foreach (var conn in _connections)
if (conn.HitTest(p, 6f)) return conn;
return null;
}
遍历顺序我特意用了倒序。_nodes 列表里后面的节点视觉上本来就画在上面,交互时也应该优先响应。这是很自然的“从后往前”顺序,和绘制时“从前往后”正好相反。
3.2 拖拽节点与连线跟随
按下鼠标时,先做锚点判断,再做节点判断。如果命中节点,开始进入“拖拽节点”状态,同时记录鼠标按下位置和节点左上角之间的偏移量。
csharp复制protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
var p = e.Location;
var conn = HitConnector(p);
if (conn != null && conn.Type == ConnectorType.Output)
{
_pendingSource = conn;
_isConnecting = true;
_mousePosition = p;
Invalidate();
return;
}
var node = HitNode(p);
if (node != null)
{
SelectNode(node);
_draggingNode = node;
_dragOffset = new PointF(p.X - node.Rect.X, p.Y - node.Rect.Y);
Cursor = Cursors.SizeAll;
Invalidate();
return;
}
var line = HitConnection(p);
if (line != null)
{
// 选中连线,可在这扩展删除等操作
return;
}
ClearSelection();
Invalidate();
}
拖拽偏移量这个细节很容易被忽略。如果按下鼠标时直接记 p,而不是记差值,那么拖动时节点会“跳一下”,跳到鼠标位置,而不是保持按住的位置关系。必须记录按下瞬间鼠标点相对节点左上角的偏移,移动时才不会产生跳跃。
拖动过程中只需要修改节点 Rect 的位置,锚点 Position 是实时从节点 Rect 计算出来的,连线路径也是实时计算的。所以移动节点时,我什么都不用额外更新,只需要 Invalidate 触发重绘即可。
csharp复制protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
var p = e.Location;
_mousePosition = p;
if (_isConnecting)
{
_hoverConnector = HitConnector(p, c => c.Type == ConnectorType.Input);
Invalidate();
return;
}
if (_draggingNode != null)
{
_draggingNode.Rect = new RectangleF(
p.X - _dragOffset.X,
p.Y - _dragOffset.Y,
_draggingNode.Rect.Width,
_draggingNode.Rect.Height);
Invalidate();
return;
}
var hover = HitConnector(p);
if (!Equals(hover, _hoverConnector))
{
_hoverConnector = hover;
Cursor = hover != null ? Cursors.Cross : Cursors.Default;
Invalidate();
}
}
3.3 拉线连线的完整状态流转
拉线是整套功能里最有交互感的部分。按下输出锚点时进入拉线状态,此时鼠标移动会实时绘制一条从源锚点到鼠标位置的虚线,同时实时检测鼠标是否落在某个合法的输入锚点上,如果落在了合法锚点上就把锚点高亮,给用户“可以松手”的反馈。
鼠标弹起时做最终判断:
csharp复制protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
if (_isConnecting)
{
var target = HitConnector(e.Location, c => c.Type == ConnectorType.Input);
if (target != null && _pendingSource.CanConnectTo(target))
{
_connections.Add(new FlowConnection(_pendingSource, target));
}
_pendingSource = null;
_isConnecting = false;
_hoverConnector = null;
Invalidate();
return;
}
_draggingNode = null;
Cursor = Cursors.Default;
}
注意这里松开鼠标时,我用的是 HitConnector 并且过滤条件只让输入锚点进来。也就是说,把线拉到节点本体上松手是无效的,必须准确落在输入锚点上。会不会太严格?实践下来不会。锚点绘制成 10 像素的圆,命中半径有 8 像素,加上白色描边,视觉上完全够大。而且这么做规避了一个很常见的 bug:连线连到节点上之后,节点一移动,线端落在节点区域内却没有锚点,画出来的线看起来是“半悬空”的。
3.4 画布坐标体系与序列化保存
上面讲的都是基于控件客户区坐标,也就是屏幕坐标。一旦引入滚动条或缩放,事情就不一样了。我的做法是模型里永远存“画布坐标”,鼠标事件拿到的坐标先反变换成画布坐标,再去做命中检测和位置修改,绘制时再把画布坐标正变换成屏幕坐标。
最朴素的版本没有滚动和缩放,所以坐标可以默认相等。但如果一开始就让模型层存画布坐标、事件层做一层转换,后续扩展滚动缩放时改动极小。这是一个典型的“提前一点设计,省掉后续大改”的例子。
序列化保存是一个很容易被轻视的环节。你不能直接把 FlowConnection 对象序列化,因为它包含了对 Connector 对象的引用,而 Connector 又挂在 FlowNode 上,直接序列化会变成一大坨重复对象图。更常见的做法是保存节点信息和连线两端的节点Id与锚点索引。
csharp复制public class FlowDocument
{
public List<NodeData> Nodes { get; set; }
public List<ConnectionData> Connections { get; set; }
}
public class NodeData
{
public Guid Id { get; set; }
public string Title { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Width { get; set; }
public float Height { get; set; }
}
public class ConnectionData
{
public Guid SourceNodeId { get; set; }
public int SourceOutputIndex { get; set; }
public Guid TargetNodeId { get; set; }
public int TargetInputIndex { get; set; }
}
加载时先重建所有节点,再遍历连线数据,通过 Id 找到源节点和目标节点,再通过锚点索引取出对应的 Connector,重新创建 FlowConnection。这个方案既避免把整个对象图序列化进去,又保证了节点移动后连线依然正确,因为连线只保存节点Id和锚点位置信息,不保存任何坐标。
关于 JSON 序列化,如果项目里已经引了第三方 JSON 库,直接用它就行。如果不想引包,System.Text.Json 在 .NET Core 3.0+ / .NET 5+ 里也可以用。WinForms 项目现在很多都跑在 .NET 6/8 上,直接用内置的就可以,不需要再折腾其他依赖。
4. 常见问题与排查技巧实录
4.1 画面闪烁问题与双缓冲
做自绘画布遇到的第一个坑十有八九是闪烁。拖动节点时整个画布闪得厉害,特别是 GDI+ 在默认情况下每次 OnPaint 都是先清空背景再重绘,人眼很容易看到中间态的空白。
解决办法其实很简单:在 UserControl 的构造函数里设置 DoubleBuffered = true;。这个属性是 Control 自带的,通过双缓冲把绘制先画到内存画布,再一次性提交到屏幕,闪烁问题基本消失。
csharp复制public FlowDesigner()
{
DoubleBuffered = true;
BackColor = Color.White;
}
如果节点数量特别大、刷新频率特别高,双缓冲也可能力不从心,这时候就要考虑局部刷新。不要每次 MouseMove 都整个 Invalidate,而是只 Invalidate 鼠标所在的更新区域。具体思路是:保存上一次的节点 Rect 和新的 Rect,取两个区域的并集作为刷新区域。GDI+ 的刷新机制会自动裁剪绘制区域,网格也只需要画新露出来的部分,性能会有明显提升。
4.2 锚点太小难点中的问题
画锚点的时候如果画的是 6 像素的小方块,视觉看着还行,实际点的时候你就知道有多难受了。鼠标稍微偏一点就点不中,用户会烦躁。所以锚点绘制尺寸和命中半径是两码事,绘制可以画得小巧精致,但命中半径一定要给足。
csharp复制public const float HitRadius = 8f;
绘制我用的是直径 10 的圆,命中半径 8,等于给了用户一个比视觉大 60% 的点击区域。这就类似手机 App 里的按钮,肉眼可见的部分可能就 40 像素,但实际点击热区会放大到 48 像素左右。把命中区域和绘制尺寸拆开,交互舒适度立竿见影。
顺带提一句,锚点最好画成圆形而不是方形。原因不仅仅是圆形好看,还因为计算点到圆心的距离做命中检测非常直观且稳定,如果画成方形,命中检测就变成矩形包含判断,斜着点边缘时手感会很怪。
4.3 连线命中测试不准的问题
流程连线是贝塞尔曲线,命中测试如果直接用鼠标点和曲线起终点做直线距离判断,几乎完全不可用。因为线是弯的,鼠标点在线中间但离起终点都很远,直线距离判断会失效。
我用的方案是 GraphicsPath.IsOutlineVisible,配合一个 6 像素粗的 Pen。这个 API 会判断一个点是否落在路径的“轮廓”范围内,Pen 的粗细相当于命中容差。实测下来手感不错,连线的宽度看起来只有 2 像素,但给它 6 像素的命中容差,点起来就舒服很多。
csharp复制public bool HitTest(PointF p, float tolerance)
{
using (var path = BuildPath())
using (var pen = new Pen(Color.Black, tolerance))
{
return path.IsOutlineVisible(p, pen);
}
}
有个性能细节:每次命中检测都重新 BuildPath,在线条数量多时会有开销。实测 200 条连线的画布上单次命中检测耗时大约零点几毫秒,基本无感。如果连线条数上千,可以考虑在节点移动完成后再重建路径并缓存。我的建议是保持简单,先跑起来再说,真遇到卡顿再优化。
4.4 连线的方向问题
最初我实现连线预览时,只是简单地从源锚点画了一条直线到鼠标位置。一个疏忽是,从右边的输出锚点拉线时,线要从右向左延伸,如果直接从锚点画直线,视觉上会穿过节点框体部分,看起来非常脏。
后来我把预览线也改成了一条带方向反馈的浅弧线:同样用贝塞尔曲线,控制点从源锚点向外偏移,鼠标在源锚点左侧时控制点向左偏移,鼠标在右侧时向右偏移。判断方向最简单的方式是比较鼠标 X 坐标和源锚点 X 坐标,鼠标在左边就往左拉,在右边就往右拉。这样拉出来的预览线永远先顺着锚点的朝向走一段,不会横穿节点。
这一点在真实连线上也是一样的:从右侧输出锚点连到左侧输入锚点,起点控制点向右偏移,终点控制点向左偏移,曲线自然形成一条从右向左的平滑通道,不穿节点。
4.5 关于加宽白边的经验
锚点外圈我画了一圈白色描边,这个细节一开始也没在意。后来把锚点画在节点边缘时才发现,锚点的颜色和节点边框颜色靠得很近,眼睛基本分辨不出来锚点的边界。加上一圈白边之后,锚点从视觉上“跳”出来,节点边缘再复杂也能看清锚点的位置。这个技巧在很多自绘图形界面里通用:相邻两个颜色相近的区域,用细的白色描边做分隔,清晰度立刻提升。
如果节点的底色不是白色,白色描边可能会突兀。更好的做法是取一个比锚点本身颜色亮或暗的颜色做描边。不过在我常用的浅色画布主题下,白边就是最省事的方案。
4.6 添加测试节点的注意事项
开发过程中我习惯在加载后自动添加几个节点,方便立刻测试拖拽和连线逻辑。有一个注意点:添加节点后必须手动调用一次 InitConnectors(),否则节点的输入输出锚点列表是空的,后面所有交互逻辑都会失灵。
csharp复制public void AddNode(float x, float y, string title)
{
var node = new FlowNode { Title = title };
node.Rect = new RectangleF(x, y, node.Rect.Width, node.Rect.Height);
node.InitConnectors();
_nodes.Add(node);
Invalidate();
}
这个初始化动作容易写在构造函数里,然后在 Rect 改变之后忘记重新调用。如果每个节点创建时 Rect 是默认值,且后续不改变尺寸,那构造时初始化没问题。但一旦你按业务需求修改节点宽高,锚点的 Offset 还停留在旧位置,节点看起来就会很怪。建议把 InitConnectors 封装成可重复调用的方法,并在 Rect 变化后主动调用,养成这个习惯能省不少调试时间。
4.7 坐标转换与滚动的扩展预留
项目做到中期,大概率会有人提出加滚动条或缩放工具。代码里目前所有交互都基于控件的客户区坐标,和画布坐标是 1:1 的关系。我预留了一个简单的转换概念:把鼠标事件的位置先换算成画布坐标,画布坐标是模型层使用的坐标系。
加入滚动条后,换算公式大体是:
csharp复制PointF ToCanvas(PointF screenPoint)
{
float canvasX = screenPoint.X + _scrollOffset.X;
float canvasY = screenPoint.Y + _scrollOffset.Y;
return new PointF(canvasX, canvasY);
}
缩放的话,在式子后面再除以缩放因子即可。这个思路是通用的。实际上大部分绘制代码只要把 Graphics 对象先 TranslateTransform 和 ScaleTransform,绘制代码甚至不用改多少,因为 GDI+ 的变换矩阵会帮你处理屏幕坐标到画布坐标的映射。但交互层一定要反过来做一次手工换算,因为鼠标事件返回的坐标是不经过 Graphics 变换的。
这一块是我踩过最深的一个坑:绘制做了缩放,命中检测没做,结果节点画在屏幕上 A 位置,鼠标点到 B 位置才能选到它。坐标换算的代码要写一份,绘制和交互共用同一个转换函数,才能避免这种错位。
写在最后的一点私货
如果你要在我这套代码的基础上扩展,下一步最值得做的不是一个炫酷的动画,而是给连线增加“删除”能力。选中一条连线,按 Delete 键删除,这是流程编辑器的基本能力。实现起来不复杂,在控件的 KeyDown 事件里维护一个 _selectedConnection,删除后 Invalidate 就行。
另外,节点样式现在比较朴素,如果想做得更讲究,可以试试圆角矩形。GDI+ 画圆角矩形没有内置 API,需要手动构造 GraphicsPath 加几条弧线和直线。这个工作量不大,但对整个画布的观感提升非常明显。
如果让我重新写一遍,我会一开始就把状态机定义得更完整,把所有交互分支先列出来再动手写事件代码。第一次写这个功能时我边写边试,代码里堆了不少分支,后面加功能时不得不重构。状态机文档化的价值,在功能简单时体会不到,等操作复杂起来就明白了。希望这篇东西能帮你少走点弯路,一次把流程节点连线这块地基打稳。
