简介:基于C# Windows Forms的图形绘制与交互示例项目,面向C#初学者及图形编程学习者,演示了如何在窗体中创建矩形、圆形、菱形等基本图形,并实现拖动、缩放与旋转等交互操作。包里共50个文件,以21个.cs源码文件为核心,涵盖Shape基类与各图形派生类、图形集合管理及序列化存储逻辑;另有.sln工程文件、空白配置、可执行exe与调试符号等,压缩包仅100KB,结构精简便于剖析。目前已有4757人学习下载。通过研读该项目,读者可掌握Windows Forms中PictureBox绘图表面的初始化、Graphic对象绘制、鼠标事件驱动的图形拖拽,以及RotateTransform实现旋转的核心思路,同时学习使用XmlSerializer保存图形数据,对后续自研简易图形编辑器或CAD类课设具参考价值。
1. C# 画图功能拆解:从零把矩形、圆形、菱形串成一套可拖动体系
很多刚开始接触 C# 的人,都会想做一个迷你画图程序:按住左键拖一下,屏幕上多一个矩形;换个工具再拖一下,又变成圆形或菱形。画的时候很爽,真正麻烦的是画完之后还能随意拖动、放大缩小。这个项目的核心就是用 C# 自绘控件把“画、拖、缩”这三件事串起来。它没有用任何第三方图形库,而是把图形抽象成一个 ShapeBase 基类,由矩形、椭圆、菱形三个子类分别实现绘制和点选逻辑,最后通过一个画布控件统一接收鼠标事件。适合想理解 GDI+ 自绘和鼠标交互状态机的人,也适合把这份代码当基础框架,去扩展成流程图编辑器或者几何练习工具。
2. 技术选型与工程结构:为什么看似简单的画板需要拆成五个类
2.1 从目录结构看项目分层
拿到手的时候,可以先看整个解决方案的文件分布。我见过的这类画图示例,大部分都把全部逻辑塞在 Form1.cs 里,几千行堆在一起,改动一个颜色选择器都要上下翻半天。而这个项目的结构明显不一样,核心文件这样划分:
| 文件 | 职责 | 关键点 |
|---|---|---|
| Program.cs | 程序入口 | 只负责启动主窗体 |
| MainForm.cs | 主窗体 | 管工具切换、颜色选择、清空画布 |
| CanvasControl.cs | 自绘画布 | 重写 OnPaint,接收鼠标事件 |
| ShapeBase.cs | 图形基类 | 定义位置、尺寸、绘制接口 |
| RectangleShape.cs | 矩形图形 | FillRectangle + DrawRectangle |
| EllipseShape.cs | 椭圆/圆形 | FillEllipse + DrawEllipse |
| DiamondShape.cs | 菱形图形 | 计算四个顶点并画多边形 |
| ShapeFactory.cs | 图形工厂 | 通过枚举创建对应的 ShapeBase 实例 |
| ShapeType.cs | 图形类型枚举 | Rectangle / Ellipse / Diamond |
这个分层的核心思路是把“画布”和“图形”彻底分开。MainForm 更像一个遥控器,它只负责记录当前选中了哪个工具、当前填充色和边框色是什么,然后把这些参数交给 CanvasControl。CanvasControl 自己维护一个 List ,在 OnPaint 里遍历列表逐张绘制,在鼠标事件里修改列表元素的属性,最后由 Invalidate 触发重绘。
这种结构的最大好处是,添加新图形时不一定要改动画布控件。你只需要新增一个继承 ShapeBase 的类,在工厂里加一个 case,再在枚举里加一个值。画布的遍历、命中、拖动逻辑完全不变。换句话说,核心扩展点是 ShapeBase 和 ShapeFactory,而不是一堆 switch 分支堆在鼠标事件里。
2.2 为什么选择 WinForms + GDI+,而不是 WPF 或 Direct2D
直接给结论:对这种小工具,WinForms 加 GDI+ 的学习成本和写代码速度都比 WPF 更合适。Graphics 对象上开箱即用的 FillRectangle、FillEllipse、FillPolygon 正好覆盖矩形、圆形、菱形三种图形的填充和描边。如果换成 WPF,你需要为每个图形构造 Geometry 对象,还要考虑 DrawingVisual 的缓存,代码量至少多一倍。Direct2D 性能确实好,但它的资源生命周期和绘制链路对新手不友好,写一个简单的形状编辑器属于大炮打蚊子。
另一个原因是鼠标事件模型。WinForms 控件的 OnMouseDown、OnMouseMove、OnMouseUp 是天然的交互状态机入口,拖动一个图形只需要在这三个事件里维护一个“当前抓住谁”的字段。WPF 虽然也有 MouseDown/MouseMove/MouseUp,但它的事件路由机制会经过隧道和冒泡两层,初学者很容易在某个子元素上丢失消息。用 WinForms 的 Control 作为画布,事件边界清晰,调试时定位问题也快。
当然,GDI+ 的痛点也很明显:它默认不开启双缓冲,如果直接在 OnPaint 里绘制大量图形,窗口刷新时会出现明显闪烁。这一点不是选型错了,而是实现细节上要主动处理,后面避坑章节会讲到常用方案。
2.3 运行环境与依赖清单:拿到源码之后怎么跑起来
这个项目不依赖任何第三方 NuGet 包,运行条件非常低。只要本机安装了 .NET Framework 4.6.1 以上版本,或者安装了 .NET 6 SDK,直接用 Visual Studio 打开解决方案按 F5 就能跑。如果命令行环境更顺手,也可以这样启动:
dotnet run前提是项目文件已经是 SDK 风格。旧式 .csproj 想迁移也很简单,我一般会改成下面这样的配置:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>disable</Nullable> </PropertyGroup> </Project>这里最关键的是 TargetFramework 用了 net6.0-windows,以及 UseWindowsForms 设置为 true。前者告诉编译系统这是 Windows 专用项目,后者把 WinForms 运行时引用进来。如果在 SDK 项目里漏掉 UseWindowsForms,编译时会直接报错说找不到 System.Windows.Forms 命名空间。迁移过程中代码文件本身基本不用改,MainForm 的构造函数里继续调用 InitializeComponent 就行。
另一点值得养成习惯:拿到源码后先跑通旧版本,再动手改。命令行编译生成的 exe 在 bin 目录下,双击能运行,说明环境没问题;如果连一次干净的编译都过不去,后面所有改动都容易混进环境问题。
3. 核心模块拆解:形状基类、工厂与三种图形的完整绘制链路
3.1 ShapeBase:把所有图形的公共行为抽象到基类
先看最核心的 ShapeBase。这个类定义了图形的位置、尺寸、填充色、边框色和选中状态,并预留了 Draw、ContainsPoint、Resize 三个关键方法。
public abstract class ShapeBase { // 图形外接矩形的左上角锚点 public PointF Location { get; set; } // 图形外接矩形的宽高,所有图形都通过这个矩形参与布局 public SizeF Size { get; set; } public Color FillColor { get; set; } = Color.Transparent; public Color BorderColor { get; set; } = Color.Black; public bool IsSelected { get; set; } // 子类实现具体的绘制逻辑 protected abstract void DrawCore(Graphics g); public void Draw(Graphics g) { DrawCore(g); if (IsSelected) { DrawSelectionBorder(g); } } // 默认用外接矩形做命中测试,子类可覆盖 public virtual bool ContainsPoint(PointF point) { return new RectangleF(Location, Size).Contains(point); } // 限定最小尺寸,防止图形被缩放到看不见 public virtual void Resize(float newWidth, float newHeight) { Size = new SizeF( Math.Max(5, newWidth), Math.Max(5, newHeight)); } private void DrawSelectionBorder(Graphics g) { using (Pen pen = new Pen(Color.DodgerBlue, 1f)) { pen.DashStyle = System.Drawing.Drawing2D.DashStyle.Dash; g.DrawRectangle( pen, Location.X - 3, Location.Y - 3, Size.Width + 6, Size.Height + 6); } } }这里的 Location 和 Size 并不是图形的绝对形状坐标,而是外接矩形的参数。为什么要用外接矩形?原因很实际:矩形、椭圆、菱形在 Math 层面的几何表达差别很大,但在一张画布上,它们都需要占据一块矩形区域来响应鼠标点击、参与排版和缩放。Draw 方法做了两件事,先调用子类的 DrawCore 画实际内容,再根据 IsSelected 画一圈虚线选中框,选中框向外扩 3 像素,可以避免虚线边框和图形边缘完全重叠。
3.2 ShapeFactory:把创建图形的逻辑从鼠标事件中剥出去
如果一个画布控件的 OnMouseDown 里直接 new RectangleShape,那只要新增图形类型,就得改鼠标事件。工厂的作用是把这段创建逻辑单独收口。
public static class ShapeFactory { public static ShapeBase Create(ShapeType type, PointF location, SizeF size) { switch (type) { case ShapeType.Rectangle: return new RectangleShape { Location = location, Size = size }; case ShapeType.Ellipse: return new EllipseShape { Location = location, Size = size }; case ShapeType.Diamond: return new DiamondShape { Location = location, Size = size }; default: throw new ArgumentOutOfRangeException(nameof(type)); } } }这段代码的逻辑很简单:参数 type 决定创建哪个子类实例,location 决定初始锚点,size 决定外接矩形尺寸。三个子类共享同一个基类型,所以画布控件后续处理它们不需要感知具体类型。新增一种图形时,只需要在 ShapeType 枚举里加一个值,再在这个工厂里补一个 case 即可。注意工厂方法返回的是 ShapeBase,而不是具体的 RectangleShape,这给上层代码留出了多态操作空间。
3.3 三种图形的绘制实现与坐标计算差异
矩形和椭圆可以直接用 GDI+ 提供的方法绘制。菱形则要手动算四个顶点,再交给 DrawPolygon。我见过不少写法是把菱形的顶点坐标写死在常量里,那样的图形一拖动就错位。正确做法是每次绘制时根据 Location 和 Size 临时计算顶点。
public class RectangleShape : ShapeBase { protected override void DrawCore(Graphics g) { using (SolidBrush brush = new SolidBrush(FillColor)) using (Pen pen = new Pen(BorderColor, 2f)) { g.FillRectangle( brush, Location.X, Location.Y, Size.Width, Size.Height); g.DrawRectangle( pen, Location.X, Location.Y, Size.Width, Size.Height); } } } public class EllipseShape : ShapeBase { protected override void DrawCore(Graphics g) { using (SolidBrush brush = new SolidBrush(FillColor)) using (Pen pen = new Pen(BorderColor, 2f)) { g.FillEllipse( brush, Location.X, Location.Y, Size.Width, Size.Height); g.DrawEllipse( pen, Location.X, Location.Y, Size.Width, Size.Height); } } } public class DiamondShape : ShapeBase { protected override void DrawCore(Graphics g) { float cx = Location.X + Size.Width / 2f; float cy = Location.Y + Size.Height / 2f; PointF[] points = { new PointF(cx, Location.Y), new PointF(Location.X + Size.Width, cy), new PointF(cx, Location.Y + Size.Height), new PointF(Location.X, cy) }; using (SolidBrush brush = new SolidBrush(FillColor)) using (Pen pen = new Pen(BorderColor, 2f)) { g.FillPolygon(brush, points); g.DrawPolygon(pen, points); } } }菱形的四个顶点分别是外接矩形的上边中点、右边中点、下边中点、左边中点。这里 cx 和 cy 是根据当前位置实时算出来的,所以图形被拖动到任意位置都不会画歪。Pen 的宽度参数可以放在基类里统一管理,我这里直接用 2f,方便调试时观察边框。FillColor 如果设置为 Color.Transparent,SolidBrush 填充时看起来就是透明的,内部不会覆盖背景色。
4. 拖动与缩放:鼠标事件链路的完整状态机实现
4.1 命中测试:如果点击落在图形上
拖动的前提是能判断鼠标按到哪个图形。矩形直接判断点是否落在外接矩形里,圆形用椭圆方程来判断,菱形则用中心点到目标点的归一化距离判断。三个子类分别重写 ContainsPoint。
public class RectangleShape : ShapeBase { public override bool ContainsPoint(PointF point) { return new RectangleF(Location, Size).Contains(point); } } public class EllipseShape : ShapeBase { public override bool ContainsPoint(PointF point) { float rx = Size.Width / 2f; float ry = Size.Height / 2f; float cx = Location.X + rx; float cy = Location.Y + ry; float dx = point.X - cx; float dy = point.Y - cy; return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1f; } } public class DiamondShape : ShapeBase { public override bool ContainsPoint(PointF point) { float cx = Location.X + Size.Width / 2f; float cy = Location.Y + Size.Height / 2f; float dx = Math.Abs(point.X - cx); float dy = Math.Abs(point.Y - cy); float rx = Size.Width / 2f; float ry = Size.Height / 2f; return (dx / rx) + (dy / ry) <= 1f; } }椭圆命中测试用的是标准椭圆方程,把当前点变换到圆心坐标系,再算归一化距离。菱形命中测试用的是菱形两条对角线构成的斜坐标系,dx/rx + dy/ry 小于等于 1 说明点在菱形内部。注意这里没有用 PointInPolygon 的通用算法,因为菱形顶点已知,直接用数学表达式判断更快,也不容易出错。
4.2 拖动状态机:按下选中、移动更新、弹起释放
画布控件里维护两个关键字段,一个是 grabbedShape,记录当前被按住的图形;一个是 grabOffset,记录鼠标点相对于图形锚点的偏移量。
protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); for (int i = shapes.Count - 1; i >= 0; i--) { if (shapes[i].ContainsPoint(e.Location)) { grabbedShape = shapes[i]; grabOffset = new PointF( e.X - grabbedShape.Location.X, e.Y - grabbedShape.Location.Y); shapes.RemoveAt(i); shapes.Add(grabbedShape); SelectShape(grabbedShape); break; } } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (grabbedShape != null) { grabbedShape.Location = new PointF( e.X - grabOffset.X, e.Y - grabOffset.Y); Invalidate(); } else { Cursor = HitTest(e.Location) ? Cursors.SizeAll : Cursors.Default; } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); grabbedShape = null; Cursor = Cursors.Default; }OnMouseDown 里从后往前遍历列表,是为了让视觉上最上层的图形优先获得命中事件。命中的图形会被临时移出列表再添加到末尾,这样画的时候后画的图形永远盖住前面的图形。grabOffset 记录的是按下瞬间,鼠标位置与图形左上角的差值。OnMouseMove 里更新 Location 时减去这个差值,图形就不会出现“鼠标拖到哪儿,图形左上角就跳到鼠标位置”的突兀移动。
HitTest 方法遍历所有图形并返回是否有任意图形命中,这里主要是为了切换光标。不做这个处理的话,鼠标悬浮在图形上方时还是默认箭头,交互反馈会差很多。实际项目中我还会加一个 hover 效果,给鼠标下面的图形做轻微高亮,思路就是同一套 HitTest。
4.3 缩放:选中后拖动右下角手柄并限制最小尺寸
缩放比拖动更考验边界处理。常见做法是让被选中的图形绘制一个右下角缩放手柄,鼠标在手柄区域内按下时进入缩放模式。
private const int ResizeHandleSize = 6; private ResizeMode resizeMode = ResizeMode.None; private PointF resizeStart; private ResizeMode GetResizeMode(PointF p) { if (selectedShape == null) return ResizeMode.None; RectangleF handle = GetResizeHandleRect(selectedShape); if (handle.Contains(p)) return ResizeMode.Resize; return ResizeMode.None; } private RectangleF GetResizeHandleRect(ShapeBase shape) { return new RectangleF( shape.Location.X + shape.Size.Width - ResizeHandleSize / 2f, shape.Location.Y + shape.Size.Height - ResizeHandleSize / 2f, ResizeHandleSize, ResizeHandleSize); }在 OnMouseMove 的缩放分支里,根据鼠标移动的增量更新 Size:
if (resizeMode == ResizeMode.Resize && selectedShape != null) { float dx = e.X - resizeStart.X; float dy = e.Y - resizeStart.Y; float newWidth = selectedShape.Size.Width + dx; float newHeight = selectedShape.Size.Height + dy; // 按住 Shift 时锁定等比缩放 if (ModifierKeys.HasFlag(Keys.Shift)) { float ratio = Math.Max( newWidth / selectedShape.Size.Width, newHeight / selectedShape.Size.Height); newWidth = selectedShape.Size.Width * ratio; newHeight = selectedShape.Size.Height * ratio; } selectedShape.Resize(newWidth, newHeight); Invalidate(); return; }缩放比例的计算方法是取宽高变化量中较大的一个作为基准值,乘以原始宽高得到新尺寸。这样做的好处是,Shift 缩放时图形不会因为宽高变化幅度不同而变形。Resize 里有 Math.Max(5, newWidth) 的保护,避免鼠标把图形缩到宽度为 0 甚至负数,图形直接消失或引发绘制异常。在真实的拖动场景里,快速移动鼠标会产生很多中间事件,这些事件都依次更新 Size,视觉上不会有断续感。
5. 避坑指南:画布闪烁、图形跑飞、坐标错位这些坑的排查记录
中间加了几个交互模块之后,很多问题才会浮现出来。下面这 5 条是我在实际调试时踩过的比较典型的坑,每一条都按现象、原因、解决的顺序整理,方便你照着排查。
5.1 画面疯狂闪烁,重绘区域像心电图
现象:从画一个图形开始就会闪烁,鼠标拖动的过程中画面像心电图。实际上不只是拖动,窗口尺寸变化时也一样。
原因:Control 的默认绘制过程是先用背景色擦除客户区,再触发 Paint 事件绘制内容。每次 Invalidate 都走一遍“擦除-重绘”,如果图形数量多或者 OnPaint 里做了大量创建和销毁,闪烁就很明显。很多人以为把图形画到 Form 上就不会闪,其实 WinForms 自绘控件不开启双缓冲就是会闪,这是机制决定的。
解决:在 CanvasControl 构造函数里设置控件样式:
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);第一行防止系统先擦除背景,第二行让控件自己负责绘制,第三行启用内置双缓冲。设置之后所有绘制先进入内存画布,再一次性提交到屏幕。如果你用的是 .NET Framework 4.6 以上,也可以直接把控件的 DoubleBuffered 属性设为 true,两种方式效果一样。
5.2 快速拖动时图形瞬间“跑飞”到画布外
现象:快速拖动图形时,图形会突然跳到鼠标所在位置的某个偏移处,甚至飞出去找不到。慢速拖动正常,快速就出问题。
原因:错误地使用了“每帧把图形中心改成鼠标坐标”的方法,而没有记录按下时的偏移量。准确原因通常是 MouseMove 的 e.Location 是控件坐标,但鼠标一旦移动到画布边缘外,WinForms 不会继续派发 MouseMove 事件,最后一个事件里图形就停在那里,等鼠标再回到画布时又突然跳动。更深一层是抓取偏移量没有用按下时的坐标减去图形锚点,导致每次事件都累加错误增量。
解决:按下时计算 grabOffset = e.Location - grabbedShape.Location,移动时设置 grabbedShape.Location = e.Location - grabOffset。同时拖动过程中改用 Control.MousePosition 和 PointToClient 来获取坐标,这样鼠标离开画布边界也不怕。标准写法是:
protected override void OnMouseMove(MouseEventArgs e) { Point client = PointToClient(MousePosition); if (grabbedShape != null) { grabbedShape.Location = new PointF( client.X - grabOffset.X, client.Y - grabOffset.Y); Invalidate(); } }PointToClient 把屏幕坐标转换成客户区坐标,和 e.Location 保持一致,可以避免快速拖出边界后的跳变。
5.3 绘制结果整体偏移几个像素
现象:画出的图形整体偏移 2 到 3 个像素,图形越大越明显。尤其在切换窗体 DPI 或把画布放在 Panel 里时,偏移量会变成一个动态数据。
原因:绘制用的是画布控件的客户区坐标,但某些代码把 MousePosition 转成了屏幕坐标,或者在 MainForm 的 Paint 事件里绘制 shapes,而鼠标事件却在 CanvasControl 里,两边坐标基准不一致。另一个常见原因是 DPI 缩放,WinForms 在高 DPI 屏幕上如果没有设置自动缩放,坐标会被放大,图形就会落在偏下的位置。
解决:全部绘制和命中测试统一以画布控件为坐标基准。鼠标按下时如果拿到的是屏幕坐标,先用 PointToClient 转换再存储。高 DPI 下不要手动乘以缩放比例,而是让 WinForms 自动缩放。所有图形的 Location 只接受客户区坐标,禁止把 Cursor.Position 直接赋值给图形。排查时在鼠标事件和 OnPaint 里各打印一组坐标,偏移量是不是常量一眼就能看出来。
5.4 圆形只有边缘能被选中,空心部分点不中
现象:画好的圆形只能从边框附近选中,大部分内部区域点上去没有反应;矩形和菱形反而正常。
原因:EllipseShape 的 ContainsPoint 如果用的是外接矩形判断,会把矩形四角也包含进来,这导致鼠标点了图形外面的角上也能选中,看起来“选得过多”;反过来,如果用边框像素判断,又“选得过少”。最常见的翻车是把椭圆命中写成了判断 |dx| < rx && |dy| < ry,那只是一个矩形盒子,点在圆边缘内部的角落区域时会被漏掉。
解决:使用椭圆方程归一化判断:
float dx = point.X - cx; float dy = point.Y - cy; return dx * dx / (rx * rx) + dy * dy / (ry * ry) <= 1f;这个公式把椭圆内域完整覆盖,四个角不会被误判,边缘区域也能正常选中。菱形同理,不使用矩形判断,用对角线公式。写完多画几个大圆,在点内部和外部角落分别测试,命中结果需要符合直觉。
5.5 新增三角形工具后图形点不中、缩放时灵时不灵
现象:新增一个三角形工具后,画出来的图形可以显示,但是几乎点不中,拖动缩放更是时灵时不灵。
原因:TriangleShape 的 ContainsPoint 没有重写,用的还是基类外接矩形判断。三角形的边界形状和外接矩形差距很大,点击矩形右下角的空白区域时也被判断为命中,反过来点三角形中心偏外的位置却可能判定为未命中。
解决:为每个新形状重写 ContainsPoint,用点与多边形的包含关系判断。最简单的方式是构建 GraphicsPath,调用 IsVisible:
using (GraphicsPath path = GetPath()) { return path.IsVisible(point); }多边形和任意曲线形状都建议用 GraphicsPath,它内部采用几何填充规则,比自己写射线法更可靠。这个教训说明,给形状类新增子类不能只处理绘制,命中测试永远要一起实现。
以上 5 条如果按照出现频率排序,双缓冲和坐标偏移几乎每个自绘控件项目都会遇到。排查时不要急着怀疑框架,先在关键位置把坐标值和状态值打印出来,问题通常藏在事件时序里。
6. 二次开发:把形状引擎扩出可插拔的新图形插槽
6.1 新增一个三角形或者六边形
顺着当前代码的扩展点,增加新图形只需要三步。首先在 ShapeType 枚举里加一个 Triangle。然后在 Shapes 目录下新建 TriangleShape 类,继承 ShapeBase,重写 DrawCore 和 ContainsPoint。最后在 ShapeFactory 的 switch 里补一个 case。整个过程不需要改动画布控件的任何鼠标逻辑,因为所有调用都发生在多态层面。
6.2 给图形增加序列化
如果想把画布内容存下来,最简单的方式是给每个图形加 SaveToXml 和 LoadFromXml 方法,遍历 shapes 列表,保存类型名、Location、Size、颜色四个字段。加载时根据类型名反查 ShapeType,再调用 ShapeFactory.Create 重新生成图形。重点是把保存逻辑放在 ShapeBase 子类内部,别把序列化代码堆在 MainForm 里。
6.3 验证方法:用手工用例和简单断言把交互链路盯住
改动之前最好准备一组固定数据:在固定位置画一个 100x50 的矩形、一个半径 40 的圆、一个 80x80 的菱形。验证拖动后 Location 与鼠标偏移一致,验证缩放到最小尺寸后不会小于 5 像素。也可以用单元测试对 ContainsPoint 做断言,比如圆心内部返回 true,菱形顶点向内偏移一个像素返回 true。这些用例很小,但对拦截回归非常有效。
做完这几处扩展,你会发现自己再面对类似白板、脑图编辑器需求时,不需要从零开始搭框架,只需要把这个画布控件当作一个小小的形状引擎。当初我把这套结构改成“圆形+矩形+箭头连线”的简单流程图工具,只用了大概一晚上时间。从那以后我每次给画布控件加新功能,都强制把“命中测试-光标状态-按下落点-移动更新-弹起释放”这条链路完整走一遍,哪怕只是加一个小手柄,也不跳过鼠标事件边界检查。这个习惯帮我堵住了不少缩放跑飞的意外。希望帮到你。
本文还有配套的精品资源,点击获取