简介:面向C#初学者的Windows Forms图形绘制示例工程GraphExample2,以完整可运行的代码演示了在PictureBox上绘制矩形、圆形、菱形等基本图形,并实现鼠标拖拽移动、缩放与旋转等交互操作,适合正在学习GDI+绘图、控件事件编程,或需要快速搭建简易画图工具的开发者参考。压缩包共50个文件,包含21个C#源文件、项目与配置文件、编译生成的exe程序、调试符号及资源文件等,整包仅100KB,轻量便于阅读与调试。目前已有4757人学习下载。通过研读源码,可掌握Shape基类与派生类的多态设计、Paint事件中的绘图流程、鼠标拖拽逻辑、缩放与旋转的坐标变换,以及基于序列化的图形保存与加载方法;项目还包含标注、数据管理等辅助模块,目录结构清晰,适合作为课程设计或绘图工具开发的起点。
1. 用 C# 画图:从能画矩形到能拖能缩,这套 WinForms 方案差在哪
手头正好在做一个轻量级画板工具,需求听起来很简单:用 C# 画图,能画出矩形、圆形、菱形这些基本图形,画完之后还得能随意拖动、放大缩小。实际写下来才发现,最麻烦的从来不是画一条线,而是“画完还能改”这件事——命中测试、坐标换算、锚点缩放、重绘性能,每一步都有不少细节。这份资源解决的就是这个完整链路:从绘制内核到鼠标交互,再到拖动缩放和避坑,属于拿来能改、改完能跑的实战代码。适合刚学完 C# 基础想做完整项目的同学,也适合需要在 WinForms 里快速实现图形编辑功能的从业者。我拆完这套代码之后,最大的感受是:它的数据结构设计比绘制代码本身更值得读。
2. 绘制内核:GDI+ 图层模型与图形对象的三种实现选型
2.1 Graphics 对象到底是个什么黑匣子
WinForms 里所有的绘制最终都要落到 System.Drawing.Graphics 上。很多新手第一次写画图程序,会在 Paint 事件里e.Graphics.DrawRectangle(...),然后发现窗口一刷新图形就没了。这不是代码写错了,而是对 Graphics 的生命周期理解不到位。
Graphics 是一个“一次性”的绘制上下文,它封装了当前设备(屏幕、位图、打印机)的绘制状态。每次 Paint 事件触发时,系统都会创建一个新的 Graphics 实例,绘制完之后这个实例就失效了。所以你必须在 Paint 事件里完成所有绘制,或者更准确地说,把你的图形数据保存下来,在 Paint 里重新绘制。
这套资源的做法是定义一个图形对象模型,把每个图形的形状、位置、大小、样式全部存下来,Paint 事件只做一件事:遍历图形列表,逐个调用 Draw 方法。
public abstract class ShapeBase { public RectangleF Bounds { get; set; } public Color StrokeColor { get; set; } = Color.Black; public float StrokeWidth { get; set; } = 2f; public bool IsSelected { get; set; } public abstract void Draw(Graphics g); }这段代码定义了一个抽象基类,Bounds 是图形的外接矩形,所有继承类都通过它来定位和计算命中区域。StrokeColor 和 StrokeWidth 控制边框样式,IsSelected 标记选中状态。这里的核心思想是:绘制参数和绘制行为分离,数据负责描述“有什么”,Draw 方法负责描述“怎么画”。
2.2 三种实现选型:switch、继承多态、数据类加渲染器
我在拆这套资源之前,自己也写过一版“switch 大法”,在一个方法里判断图形类型然后分别绘制。跑通小 demo 没问题,但一旦加图形类型、加拖动、加缩放,那个方法会膨胀到没法维护。
这套资源采用的是继承多态方案,常见做法是定义抽象基类,每种图形一个派生类,各自实现 Draw 和 HitTest。我后来自己重构时也是这么做的,对比下来,三种选型各有适用场景:
- switch 分支:图形类型固定不超过五种、不需要扩展时最直接,缺点是每加一种类型就要改 switch 结构。
- 继承多态:图形类型会持续增加、每个图形有不同绘制逻辑时最合适,代价是类数量多一些。
- 数据类加渲染器:图形类型很多但绘制逻辑相近时,可以用一个数据结构存类型枚举加参数列表,再用一个渲染器统一绘制。这种方式最灵活,但命中测试和缩放逻辑会稍微复杂。
这三种方案在资源的代码里有对照实现,我个人建议直接采用继承多态。原因很简单:拖动和缩放需要每个图形自己判断“点是否在我身上”、自己计算锚点位置,把这些逻辑放进各自的类里,比放在外部统一判断要清晰得多。
2.3 从 ShapeBase 派生矩形与菱形:绘制坐标必须统一走 Bounds
矩形和菱形的绘制逻辑看似简单,但有一个关键点:所有几何计算都要以 Bounds 为基准,而不是直接存四个顶点。
public class RectangleShape : ShapeBase { public override void Draw(Graphics g) { using (var pen = new Pen(StrokeColor, StrokeWidth)) { g.DrawRectangle(pen, Rectangle.Round(Bounds)); } } } public class DiamondShape : ShapeBase { public override void Draw(Graphics g) { var cx = Bounds.Left + Bounds.Width / 2f; var cy = Bounds.Top + Bounds.Height / 2f; PointF[] points = { new PointF(cx, Bounds.Top), new PointF(Bounds.Right, cy), new PointF(cx, Bounds.Bottom), new PointF(Bounds.Left, cy) }; using (var pen = new Pen(StrokeColor, StrokeWidth)) { g.DrawPolygon(pen, points); } } }菱形的四个顶点是从 Bounds 四条边的中点算出来的,所以不管用户是正着拖还是斜着拖,菱形都能正确跟随鼠标。注意代码里用了 using 包裹 Pen,这是为了确保 GDI+ 对象被及时释放。
这里有两个容易踩的坑。第一个坑是:Pen 和 Brush 属于 GDI+ 非托管资源,不释放会慢慢涨 GDI 句柄,程序跑几个小时之后就画不出东西了。第二个坑是:绘制时不要直接修改 Bounds 的值,而是先计算出新 Bounds 再赋值,这样重绘时不会出现图形抖动的现象。
3. 鼠标交互链路:按下、移动、抬起三段式状态机与坐标换算
3.1 三段式交互状态机:MouseDown、MouseMove、MouseUp 各司其职
画图的交互可以抽象成一个状态机:空闲、正在绘制、正在拖动、正在缩放。这套资源用一组状态标记加鼠标事件来驱动切换,我拆的时候觉得这个设计很值得借鉴——它不是简单地在每个事件里塞逻辑,而是先判断当前处于什么状态,再决定做什么操作。
public enum EditMode { DrawRectangle, DrawDiamond, SelectAndMove } private EditMode _mode = EditMode.SelectAndMove; private ShapeBase _currentShape; private bool _isDrawing;状态枚举行定义了当前要执行的操作。_currentShape 保存正在绘制或拖动的图形对象,_isDrawing 标记是否处于绘制过程中。这种设计的优点是:MouseMove 里不需要写大量 if-else 来判断用户到底想干嘛,只要看状态就能走对应分支。
3.2 按下时判定意图:新建图形还是命中已有图形
MouseDown 是整个交互链路的入口,它的逻辑决定了后面所有事件的走向。按下时先判断当前模式:如果是绘制模式,就创建一个对应类型的图形对象并加入列表;如果是选择模式,就做命中测试,判断是否点中了某个已有图形。
protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (_mode == EditMode.DrawRectangle) { _currentShape = new RectangleShape { Bounds = new RectangleF(e.X, e.Y, 0, 0) }; Shapes.Add(_currentShape); _isDrawing = true; } else if (_mode == EditMode.SelectAndMove) { var hit = HitTest(e.Location); if (hit != null) { _currentShape = hit; _dragStartPoint = e.Location; _originalBounds = hit.Bounds; SetCursor(e.Location); } } }HitTest 方法会遍历图形列表,倒序查找最后绘制在顶层的图形。这里有一个细节:鼠标事件参数 e.Location 是相对于当前控件的客户区坐标,如果在自定义控件里处理,不需要额外转换;但如果控件嵌在带滚动条的容器里,就要加上滚动偏移。
_originalBounds 保存拖动前的原始位置,这样在拖动过程中如果发现误操作,可以直接还原,不需要手动计算偏移量。我一般在实现时还会加一个 Esc 取消机制,按下 Esc 就把_originalBounds 写回,算是给用户一个后悔药。
3.3 移动时动态刷新:用 Invalidate 而不是手动重绘
MouseMove 里做的事情取决于当前状态:绘制中则更新 Bounds 的宽高,拖动中则更新位置,缩放中则更新锚点对应的边。
protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_isDrawing && _currentShape != null) { var bounds = _currentShape.Bounds; _currentShape.Bounds = new RectangleF( Math.Min(bounds.Left, e.X), Math.Min(bounds.Top, e.Y), Math.Abs(e.X - bounds.Left), Math.Abs(e.Y - bounds.Top) ); Invalidate(); } else if (_currentShape != null && _currentShape.IsSelected && _dragStartPoint.HasValue) { var dx = e.X - _dragStartPoint.Value.X; var dy = e.Y - _dragStartPoint.Value.Y; _currentShape.Bounds = new RectangleF( _originalBounds.X + dx, _originalBounds.Y + dy, _originalBounds.Width, _originalBounds.Height ); Invalidate(); } }这段代码的核心是 Invalidate()。很多刚接触 GDI+ 的开发者会尝试在 MouseMove 里直接调用 Refresh 或者手动调用 Draw,这会让界面闪烁到没法看。Invalidate 只标记区域需要重绘,系统会在合适的时机触发 Paint 事件,由 Paint 事件统一完成绘制。
这里的坐标换算是重点:拖动时用 _dragStartPoint 记录按下时的鼠标位置,用 _originalBounds 记录按下时的图形位置,每次移动时计算差值并施加到原始位置。这里对拖动后图形的显示效果有直接影响——如果用当前位置减去当前值,会导致图形越拖越飘;只有始终参考原始值,拖动才能保持稳定。
3.4 抬起时收尾:清理临时标记并触发最终重绘
MouseUp 时要做的事情不多,但有一件事必须做:把 _isDrawing 置回 false,把 _currentShape 置空,然后触发最后一次 Invalidate。如果不做这次 Invalidate,图形可能会残留最后一段拖动痕迹。
protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); if (_isDrawing) { _isDrawing = false; _currentShape = null; Invalidate(); } else if (_currentShape != null) { _dragStartPoint = null; _currentShape = null; Invalidate(); } }这里还有一个交互习惯:绘制完成之后自动切换到选择模式。很多画图软件都是这么做的,用户画完一个矩形,马上就能拖动它,不需要手动切换工具。实现方式是在 MouseUp 里把 _mode 改回 SelectAndMove。
4. 拖动与缩放:HitTest 命中判定、锚点算法与光标反馈
4.1 HitTest 命中测试:矩形判定加容差,菱形用路径判定
拖动一个图形的第一个前提是“能选中它”。命中测试的精度直接决定了交互手感。矩形和圆形可以用 Bounds 直接判断,菱形就必须用路径包含了。
public abstract class ShapeBase { public abstract bool HitTest(PointF point); protected virtual bool HitTestBounds(PointF point, float tolerance = 5f) { var inflated = RectangleF.Inflate(Bounds, tolerance, tolerance); return inflated.Contains(point); } }RectangleF.Inflate 把 Bounds 向外扩一圈,这个 tolerance 参数解决了“图形太细不容易点中”的问题。比如一个宽度只有 2 像素的矩形边框,鼠标稍微偏一点就点不中,加了容差之后体验会好很多。
菱形需要重写 HitTest 方法,用 GraphicsPath 来判断点是否在闭合路径内。
public class DiamondShape : ShapeBase { public override bool HitTest(PointF point) { using (var path = new GraphicsPath()) { var cx = Bounds.Left + Bounds.Width / 2f; var cy = Bounds.Top + Bounds.Height / 2f; path.AddPolygon(new[] { new PointF(cx, Bounds.Top), new PointF(Bounds.Right, cy), new PointF(cx, Bounds.Bottom), new PointF(Bounds.Left, cy) }); return path.IsVisible(point); } } }GraphicsPath 在这种场景下的优势是:不需要自己去判断点在三角形还是四边形里,直接用 GetBounds、IsVisible 这些现成方法就能完成几何计算。需要注意的是路径包含的是边框还是填充区域——IsVisible 对闭合路径默认是“包含内部”,如果你只想让用户点边框才能选中,就得改成判断点到路径的距离。
4.2 缩放的锚点算法:八个方向映射到 Bounds 的四条边
拖动解决的是“图形在哪”的问题,缩放解决的是“图形多大”的问题。实现缩放要先把 Bounds 的八个方位映射到唯一的枚举值,MouseMove 里根据锚点类型更新对应的边。
public enum ResizeAnchor { None, TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left } private ResizeAnchor GetAnchor(PointF point) { var tol = 8f; if (Math.Abs(point.X - Bounds.Left) <= tol && Math.Abs(point.Y - Bounds.Top) <= tol) return ResizeAnchor.TopLeft; if (Math.Abs(point.X - Bounds.Right) <= tol && Math.Abs(point.Y - Bounds.Top) <= tol) return ResizeAnchor.TopRight; // 其他方向类似 return ResizeAnchor.None; }锚点判断的容差 tol 就是之前说的“命中容差”,太小了手指粗的用户点不到,太大了容易误触相邻锚点。我一般习惯用 8 像素左右,和鼠标指针的命中区域匹配。
缩放的核心逻辑是保持对边不动。比如拖动右下角时,左上角的坐标保持不变,右下角的坐标跟随鼠标移动。
private RectangleF ResizeByAnchor(ResizeAnchor anchor, PointF mousePos) { var newBounds = _originalBounds; switch (anchor) { case ResizeAnchor.BottomRight: newBounds.Width = Math.Max(10, mousePos.X - newBounds.Left); newBounds.Height = Math.Max(10, mousePos.Y - newBounds.Top); break; case ResizeAnchor.TopLeft: newBounds.X = mousePos.X; newBounds.Y = mousePos.Y; newBounds.Width = _originalBounds.Right - mousePos.X; newBounds.Height = _originalBounds.Bottom - mousePos.Y; break; // 其余方向类似 } return newBounds; }Math.Max(10, ...) 是防止用户把图形缩到负尺寸。如果不加这个保护,鼠标拖到对边之外会让图形变成负数宽度,直接画出奇怪的形状。这段代码同样基于 _originalBounds 计算,不是基于当前 Bounds,这样每次缩放移动都从原始状态出发,不容易累积误差。
4.3 光标反馈:根据锚点切换十字、双向箭头和四项箭头
光标反馈是最容易被忽略但体验提升最明显的功能。用户看到光标变成双向箭头,才知道自己可以缩放;看到四项箭头,才知道自己可以拖动。
private void SetCursor(PointF point) { if (_currentShape == null) { Cursor = Cursors.Default; return; } var anchor = GetAnchor(point); if (anchor != ResizeAnchor.None) { Cursor = anchor switch { ResizeAnchor.TopLeft or ResizeAnchor.BottomRight => Cursors.SizeNWSE, ResizeAnchor.TopRight or ResizeAnchor.BottomLeft => Cursors.SizeNESW, ResizeAnchor.Top or ResizeAnchor.Bottom => Cursors.SizeNS, ResizeAnchor.Left or ResizeAnchor.Right => Cursors.SizeWE, _ => Cursors.Default }; } else if (_currentShape.HitTest(point)) { Cursor = Cursors.SizeAll; } else { Cursor = Cursors.Default; } }光标切换的逻辑要在 MouseMove 里持续调用,并且在 MouseLeave 时重置回 Default。这段代码里的 switch 表达式是 C# 8.0 的新语法,如果你用的编译器版本比较旧,改成普通 switch 语句或 if-else 都行。
光标映射关系需要注意:我是把光标方向与锚点对角线方向对应才能保持用户直觉——左上角到右下角的缩放显示 NWSE,右上角到左下角显示 NESW。左、右、上、下四个边用单向箭头。
5. 避坑清单:五个让人抓狂的画图细节与排查记录
5.1 图形一拖就闪屏,拖得越快闪得越厉害
现象:按住矩形拖动时整个控件疯狂闪烁,鼠标移动越快,画面撕裂感越明显。
原因:默认背景色每次重绘都会先清空画布,而拖动过程中 Invalidate 触发的重绘频率远高于普通静态画面。没有开启双缓冲时,GDI+ 先擦除原区域再绘制新区域,中间会产生空白帧。
解决:为自定义控件开启双缓冲。在构造函数里设置DoubleBuffered = true,或者在自定义控件的构造函数里调用SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)。我用的是前者,代码更直接,效果也一样。
public CanvasControl() { InitializeComponent(); DoubleBuffered = true; SetStyle(ControlStyles.ResizeRedraw, true); }ResizeRedraw 的作用是让控件在尺寸变化时自动重绘,不设置它的话,窗口拉大后新露出的区域可能不显示图形。
5.2 点击菱形边框没反应,但点内部却选中了
现象:菱形只有点中心附近才能选中,点击边缘线条反而没反应。
原因:菱形重写的 HitTest 使用了 GraphicsPath.IsVisible,这个方法对闭合路径的判定是“包含内部”,不包含边框线。边框只有几个像素宽,鼠标点在外围离边框有一点距离就判定不中。
解决:用边界矩形加容差做第一层判定,再用路径判定做第二层。或者更简单,用稍大一点的矩形做命中区域。我一般是把 HitTest 改成先判断点是否接近路径边界,用一个小的容差范围判断。
public override bool HitTest(PointF point) { using (var path = new GraphicsPath()) { // 构建菱形路径(代码同前) if (path.IsVisible(point)) return true; using (var pen = new Pen(Color.Black, 8f)) { // 用粗画笔测试点是否在线条附近 return path.IsOutlineVisible(point, pen); } } }IsOutlineVisible 搭配一个粗 Pen 来判断点是否靠近线条,这里的粗 Pen 并不实际绘制,只用来扩展检测范围。这个方法会稍微多消耗一点性能,但对交互手感提升明显。
5.3 拖动图形后位置漂移,越拖越偏
现象:用鼠标把矩形从左上角拖到右下角,松手后发现图形停在了某个意想不到的位置。
原因:MouseMove 里用当前位置更新了 Bounds,然后又用新的 Bounds 计算偏移量,导致每次移动都叠加了上一次的位移。
解决:使用 _dragStartPoint 和 _originalBounds 的差值方案,就是第 3 章里那段代码的做法。拖动时始终从按下时记录的原始位置出发,而不是从当前值出发。
// 错误写法:使用当前 Bounds 计算偏移 _currentShape.Bounds.X += dx; _currentShape.Bounds.Y += dy; // 正确写法:从原始位置计算绝对位置 _currentShape.Bounds = new RectangleF( _originalBounds.X + dx, _originalBounds.Y + dy, _originalBounds.Width, _originalBounds.Height );两种写法在鼠标匀速移动时结果差别不大,但一旦鼠标抖动或加速,第一种写法就会累积误差。第二种写法天然具备误差抵消能力。
5.4 按下 Esc 后图形无法还原,撤销功能实现失败
现象:拖动到一半按下 Esc,图形没有回到拖动前的位置。
原因:撤销逻辑只记录了 _originalBounds,但 MouseDown 里没有在创建 _currentShape 时同步记录,或者 Esc 处理函数里没有调用 Invalidate。
解决:在 MouseDown 创建 _currentShape 时立即记录 _originalBounds。Esc 处理函数里把 _originalBounds 写回后强制 Invalidate。
protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData == Keys.Escape && _currentShape != null) { _currentShape.Bounds = _originalBounds; _currentShape = null; _dragStartPoint = null; Invalidate(); return true; } return base.ProcessCmdKey(ref msg, keyData); }ProcessCmdKey 是 WinForms 里比较早的键盘事件入口,它能在控件获得焦点时先于 KeyDown 触发,对全局快捷键一类场景的处理比 KeyDown 靠谱。关键是 Esc 处理完一定要 return true,否则事件会继续冒泡到上层控件。
5.5 圆形绘制时 Bounds 宽高为负,画出来的形状反向
现象:从右下角往左上角拖圆形,拖出来的椭圆方向不对,或者干脆画不出来。
原因:MouseDown 时的 Bounds 记录的是按下点,MouseMove 更新时直接取鼠标坐标和按下点坐标的差值,没有考虑鼠标可以移动到按下点的左上方。
解决:在更新 Bounds 时做归一化处理,用 Math.Min 和 Math.Max 保证宽高永远为正。
var left = Math.Min(startX, currentX); var top = Math.Min(startY, currentY); var width = Math.Abs(currentX - startX); var height = Math.Abs(currentY - startY); _currentShape.Bounds = new RectangleF(left, top, width, height);这段代码在画矩形时同样适用。我之前有一版画矩形时直接用了按下坐标作为左上角,结果拖反方向直接翻车,改成归一化之后就稳了。
6. 进阶:图形对象序列化与画布持久化的实战取舍
画图程序做到能画、能拖、能缩放,只能算完成了一半,另一半是把画布内容保存下来。这套资源里图形对象已经抽象成了 ShapeBase 派生类,天然适合序列化。
我最初尝试用 BinaryFormatter,代码写着很简单——一个序列化方法加一个反序列化方法就能搞定。但有两点促使我放弃:一是 BinaryFormatter 在较新的 .NET 版本里被标记为不安全,二是二进制格式的可读性太差,不便于调试。
后来改成了 JSON 方案,用 System.Text.Json 做序列化。关键在于多态处理:图形列表里同时有 RectangleShape 和 DiamondShape,直接序列化 List<ShapeBase> 会丢失类型信息。解决方案是用 JsonSerializerOptions 配置多态转换,或者给每个图形加一个 TypeName 字段,反序列化时按字段值创建对应类型。
public class ShapeData { public string Type { get; set; } public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public string StrokeColor { get; set; } public float StrokeWidth { get; set; } } public static List<ShapeBase> LoadShapes(List<ShapeData> dataList) { return dataList.Select(data => data.Type switch { "Rectangle" => new RectangleShape { Bounds = new RectangleF(data.X, data.Y, data.Width, data.Height), StrokeColor = ColorTranslator.FromHtml(data.StrokeColor), StrokeWidth = data.StrokeWidth }, "Diamond" => new DiamondShape { Bounds = new RectangleF(data.X, data.Y, data.Width, data.Height), StrokeColor = ColorTranslator.FromHtml(data.StrokeColor), StrokeWidth = data.StrokeWidth }, _ => null }).Where(s => s != null).ToList(); }这种方式比直接用 JsonSerializer 序列化 ShapeBase 派生类更可控,至少版本迭代时字段变了不会直接反序列化失败。颜色字段存成 HTML 字符串,比如 "#FF0000",比存整数更容易排查问题。加载代码里 Where 是为了过滤掉未知类型,防御以后加了新图形但旧文件里没有的情况。
画布性能优化方面,一个值得做的优化是局部失效:在 MouseMove 里,只 Invalidate 当前图形的旧位置和新位置合并的矩形区域,而不是整个画布。
var oldBounds = _currentShape.Bounds; // 计算新位置后 var invalidRegion = RectangleF.Union(oldBounds, _currentShape.Bounds); invalidRegion.Inflate(20, 20); Invalidate(Rectangle.Round(invalidRegion));这个优化对图元数量多时效果明显,几十个图形时感知不强,但到了几百个图形,整个画布重绘会开始卡顿。局部失效每次只重绘变化区域,性能提升是本质性的。
从那以后我每次做画布类控件都会强制走一遍流程:先确定数据模型,再实现 Paint,最后再加交互。顺序反了就会出现“画得出来但改不动”的窘境,回头再重构数据结构,成本比一开始就设计好多太多了。希望帮到你。
本文还有配套的精品资源,点击获取