资讯详情

C# WinForms绘图白板:GDI+图形绘制、鼠标拖拽与缩放实战解析

📅 2026/10/21 6:12:21 | 华诺云谱 👁 阅读
C# WinForms绘图白板:GDI+图形绘制、鼠标拖拽与缩放实战解析
简介这是一份C#图形绘制与编辑器示例项目GraphExample2面向需要学习Windows Forms或WPF中自定义绘图与图形交互的开发者也适合作为桌面应用绘图模块的起步参考。项目集中演示了如何绘制矩形、圆形、菱形等基础图形并实现图形的自由拖动、缩放与旋转是理解GDI绘图模型和鼠标事件处理的实用范例。资源压缩包共50个文件整体约100KB以cs源码文件为主同时包含exe可执行程序、config配置、resx资源文件以及sln解决方案等既可运行观察效果也能直接阅读源码学习关键实现。项目覆盖了初始化绘图表面、定义Shape基类与派生图形类、在Paint事件中绘制、处理MouseDown/MouseMove/MouseUp实现拖拽以及通过坐标变换完成缩放旋转。更提供了图形保存与加载、Region或Path优化重绘等进阶思路读者可借此掌握C#中图形对象封装、序列化存储与交互状态管理的常用做法从而迁移到实际业务。该资源已有4757人学习下载。1. 一个能拖动、能缩放的绘图白板C# 画图项目到底做了什么第一次把用 C# 写的画图程序跑起来时我发现真正让项目“像个画图软件”的难点不在画矩形和圆形——那几个 API 五分钟就能学会——而在于一个被反复提到却很少有人讲透的问题怎么让画出来的图形可以选中、可以拖着跑、可以在缩放时不变形。这个项目解决的正是这件事。它用 Windows Forms 搭了一个绘图白板内置矩形、圆形、菱形三种基础图形支持鼠标绘制、任意拖动、框选缩放同时留出了很干净的面向对象接口。无论你是学生想做图形编辑器课程设计还是刚转 C# 的开发者想理解 GDI 图形绘制和鼠标交互的完整套路这份源码都能当做一个能直接跑、能拆着改的样板间。2. 技术选型与架构Form、GDI 与图形对象模型2.1 为什么选 Windows Forms 而不是 WPF看到 C# 画图大多数人的第一反应可能是 WPF毕竟现代一点。但这份代码选的是 Windows Forms原因很实际绘制逻辑直白。WinForms 里的System.Drawing命名空间也就是 GDI提供了一整套画布、画笔、刷子、坐标换算的底层能力Paint事件模型把“窗口需要重绘”这件事暴露得非常清晰——你想画什么就往Graphics对象上画系统告诉你什么时候该画。相比之下 WPF 的DrawingContext和矢量模型虽然也强大但对于一个教学级的绘图项目来说中间多了很多需要解释的概念层不利于读者把注意力放在图形对象的设计上。另外 WinForms 对鼠标消息的处理很直接MouseDown、MouseMove、MouseUp三个事件的参数里直接给出光标坐标不需要像 WPF 那样处理路由事件和坐标转换。这使得“按下—拖动—松开”这个交互模型容易追踪和理解。这个项目的核心价值不是某个炫酷的界面渲染而是让读者看清一个图形编辑器的最简骨架把图形的数据和行为封装成一个类把鼠标动作翻译成对图形对象的操作再把操作的结果画回画布。2.2 目录结构与核心类型项目结构不算大但分层很清爽。整个工程围绕三个角色展开图形对象Shape 体系、画布控件绘图区、交互逻辑鼠标事件处理。CSharpDrawing.sln └─ CSharpDrawing ├─ Shapes/ │ ├─ Shape.cs // 抽象基类坐标、颜色、轮廓与命中测试 │ ├─ RectangleShape.cs // 矩形 │ ├─ EllipseShape.cs // 圆形实际是椭圆 │ └─ DiamondShape.cs // 菱形绘制菱形时用的多边形方式 ├─ DrawingCanvas.cs // 自定义控件画布、选中态、缩放逻辑 ├─ MainForm.cs // 主窗口工具栏、图形类型切换、状态提示 └─ Program.cs // 入口Shape.cs负责所有图形的公共数据起始点、终点、边框颜色、填充颜色、是否被选中。RectangleShape、EllipseShape、DiamondShape都继承它各自实现“把图形画到画布上”和“判断点到图形的距离”这两个核心方法。DrawingCanvas是项目的重头戏维持一个ListShape作为画布上的图形列表同时负责在鼠标事件里判断该新建图形、移动图形还是缩放图形。MainForm则负责向画布转发用户的选择当前画矩形、圆形还是菱形以及用什么颜色画。这个设计最大的好处是“加一种新图形”的流程被固化成了三个步骤继承Shape、写Draw方法、写HitTest方法然后在主窗体加一个按钮。后面我们拆核心代码时会看到这种接口的粒度到底落在哪里。3. 核心模块拆解从鼠标按下到图形重绘的完整链路3.1 图形基类的设计与参数说明任何绘图程序的第一步都是定义“图形”这个概念。这个项目里Shape抽象类把矩形的起点终点、边框色、填充色、选中状态作为公共字段然后要求所有子类实现两个方法。先看基类的核心代码public abstract class Shape { public Point Start { get; set; } // 图形外接矩形的左上角 public Point End { get; set; } // 图形外接矩形的右下角 public Color BorderColor { get; set; } // 边框色默认黑色 public Color FillColor { get; set; } // 填充色默认透明 public bool IsSelected { get; set; } // 当前是否被选中 // 由子类实现把图形画到画布上 public abstract void Draw(Graphics g); // 由子类实现判断某一点是否落在图形内用于选中 public abstract bool Contains(Point p); // 供所有子类使用的公共方法获取外接矩形的宽高 public int Width Math.Abs(End.X - Start.X); public int Height Math.Abs(End.Y - Start.Y); }这里的核心思想是把图形统一成“由起点和终点确定的矩形区域”。矩形就是字面意义的起点终点圆形是“画在这个矩形内切椭圆”菱形是“依次连接矩形四个边的中点”。这样每个图形都能用同样的两个点来描述拖动和缩放就只需要改这两个点的坐标值Draw方法里根据新坐标重新计算形状即可。Width和Height用了表达式体属性专门给缩放和绘制定位用。注意这里用Math.Abs做了一次绝对值运算——如果用户从右往左拖拽画矩形End.X小于Start.X也是合法的绘制代码内部只关心区域而不是方向。3.2 三种图形的绘制实现矩形最简单直接调用Graphics的DrawRectangle方法得到一个由起点和终点框住的范围。椭圆更巧妙因为DrawEllipse接受的是矩形参数圆形实现代码只有几行public class EllipseShape : Shape { public override void Draw(Graphics g) { using (var brush new SolidBrush(FillColor)) using (var pen new Pen(BorderColor, 2f)) { var rect GetNormalizedRect(); g.FillEllipse(brush, rect); g.DrawEllipse(pen, rect); } } public override bool Contains(Point p) { var rect GetNormalizedRect(); // 将图形区域归一化到原点判断点到椭圆中心的距离 double rx rect.Width / 2.0; double ry rect.Height / 2.0; double dx (p.X - rect.X - rx) / rx; double dy (p.Y - rect.Y - ry) / ry; return dx * dx dy * dy 1.0; } private Rectangle GetNormalizedRect() { int x Math.Min(Start.X, End.X); int y Math.Min(Start.Y, End.Y); int w Math.Abs(End.X - Start.X); int h Math.Abs(End.Y - Start.Y); return new Rectangle(x, y, w, h); } }这段代码有两个细节值得展开。第一个是using声明Brush和Pen都是 GDI 对象不释放会占系统资源用using包住是血泪经验后面避坑章节还会专门说。第二个是命中测试Contains把点和椭圆中心做一个归一化计算判断是否落在标准单位圆内。这个算法比逐个像素扫描快得多也是后面“选项选中图形”的判断基础。菱形实现为多边形public class DiamondShape : Shape { public override void Draw(Graphics g) { var rect GetNormalizedRect(); Point[] points { new Point(rect.X rect.Width / 2, rect.Y), // 上顶点 new Point(rect.X rect.Width, rect.Y rect.Height / 2), // 右顶点 new Point(rect.X rect.Width / 2, rect.Y rect.Height), // 下顶点 new Point(rect.X, rect.Y rect.Height / 2) // 左顶点 }; using (var brush new SolidBrush(FillColor)) using (var pen new Pen(BorderColor, 2f)) { g.FillPolygon(brush, points); g.DrawPolygon(pen, points); } } public override bool Contains(Point p) { var rect GetNormalizedRect(); // 取归一化后的坐标判断点是否在四条线段构成的多边形内 return IsPointInPolygon(p, GetVertices(rect)); } }菱形实际上是矩形区域的四个中点连线所以它的宽高和外接矩形完全一致拖动和缩放的逻辑跟其它图形天然兼容。IsPointInPolygon是经典的射线法判断从点向任意方向发射一条射线统计与多边形边的交点数奇数则在内部——这是相当成熟的做法不需要读者自己发明轮子。3.3 鼠标三阶段按下、拖动、松开画布控件DrawingCanvas里维护了当前正在操作的图形引用_currentShape、操作模式_mode绘制、移动、缩放以及鼠标按下的起始点_startPoint。鼠标事件按三个阶段处理protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _startPoint e.Location; // 先判断是否点中了某个图形的缩放手柄 var hitShape HitTest(e.Location); if (hitShape ! null _mode EditMode.Select) { if (IsNearResizeHandle(hitShape, e.Location)) { _mode EditMode.Resize; _currentShape hitShape; } else { _mode EditMode.Move; _currentShape hitShape; } } else if (_mode EditMode.Draw) { // 创建新图形实例要求子类提供创建方法 _currentShape ShapeFactory.CreateShape(_shapeType); _currentShape.Start e.Location; _currentShape.End e.Location; _shapes.Add(_currentShape); } Invalidate(); }处理顺序有讲究先检查是否点到已有图形的缩放手柄再决定是移动还是新建。因为图形是后画的盖先画的HitTest方法遍历_shapes列表时要倒序查找保证最上层的图形优先被选中。这个细节在图形重叠时非常关键。拖动阶段的核心代码在OnMouseMove里protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_currentShape null) return; if (_mode EditMode.Move) { int dx e.X - _startPoint.X; int dy e.Y - _startPoint.Y; _currentShape.Start new Point(_currentShape.Start.X dx, _currentShape.Start.Y dy); _currentShape.End new Point(_currentShape.End.X dx, _currentShape.End.Y dy); _startPoint e.Location; // 更新起点避免位移累积 Invalidate(); // 请求重绘 } else if (_mode EditMode.Resize) { // 缩放的锚点是对角点保持锚点位置不动 var anchor GetAnchorPoint(_currentShape, _resizeHandle); _currentShape.Start anchor; _currentShape.End e.Location; Invalidate(); } }移动的位移计算用了一个小技巧每次移动都更新_startPoint为当前鼠标位置。如果只在MouseDown时记录一次起点鼠标移动的快慢会导致拖拽过程中图形跟手速度不一致因为积累的是从按下到当前的相对距离每次都重置基准点,图形就时刻处于“从上一个位置位移这么多”的状态跟手性最好。缩放逻辑里GetAnchorPoint用来固定对角点用户拖右下角手柄时左上角作为锚点不动只改动End拖左上手柄时右下角作为锚点不动。这符合大多数人缩放图形时的直觉——你拽哪一个角对面的角就是基准。3.4 重绘与坐标折算整个程序没有到处调用绘图代码而是统一依赖Paint事件。只要坐标或状态变了调用Invalidate()让窗体标记为“需要重绘”系统就会异步触发OnPaintprotected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; foreach (var shape in _shapes) { shape.Draw(g); if (shape.IsSelected) { DrawResizeHandles(g, shape); // 绘制四个角的缩放手柄 } } }SmoothingMode.AntiAlias必须在这里设置这是图形边缘不出现锯齿的关键。缩放手柄画在图形外接矩形的四角表现为小方框。IsSelected是选中状态的“存储”而DrawResizeHandles是这种状态的“表现”——数据和展示彻底分离后续如果想加选中高亮、虚线边框只需要改这一处。4. 常见问题与避坑拖不动、闪烁、图形变形的排查记录4.1 图形拖动时画面狂闪现象鼠标拖拽图形时画面明显闪烁图形边缘甚至能看到残影。原因OnMouseMove里每次移动都调用Invalidate()系统立刻重绘整个画布。默认情况下窗体的背景色会先被擦掉再重画形成“先白后黑”的视觉闪烁。GDI 在未开启双缓冲时对频繁重绘非常吃力。解决在DrawingCanvas的构造函数里开启双缓冲public DrawingCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); }第二个ControlStyles.UserPaint是可选的但是加上以后会强制让控件自己完成绘制而跳过系统对背景的清除配合双缓冲效果更干净。从那以后我每次写 GDI 绘图控件都会先检查这三行有没有写。4.2 图形拖不动点一次没反应现象点击图形没有任何反应要点击两次甚至三次才能选中。原因Contains方法只在图形内部做命中判断而矩形的命中测试只对点是否落在四条边附近的“窄带”内做了判断。如果用户点到的是图形内部的填充区比如中间位置程序认为没选中。另外如果没有倒序遍历_shapes后画的图形会挡住先画的图形。解决先把命中测试改成“填充区域也算命中”public override bool Contains(Point p) { var rect GetNormalizedRect(); // 命中条件放宽落在矩形区域内部或边缘都算选中 return rect.Contains(p); }再把遍历顺序改成倒序优先命中视觉上在顶层的图形。其实很多编辑器的做法是把“选中”和“点中内部”区分成两个概念选中要求点在边框附近而内部点击可能只是取消选择或框选。如果以后要做框选歪打正着这个基类接口仍然适用。4.3 缩放后图形变形或跑到别处去现象拖动右下角手柄放大图形图形不仅变大了整个还移了位置。原因缩放只改动了End坐标但Start也被某些代码路径修改了。还有一种情况如果鼠标拖动的方向是从右下往左上End跑到Start的左上代码里GetNormalizedRect做了坐标归一化但Start和End本身存的还是非归一化的值下次缩放时锚点选错了。解决缩放时要固定锚点坐标只修改对边private Rectangle ResizeByHandle(Rectangle original, Point newPoint, ResizeHandle handle) { int left original.Left; int top original.Top; int right original.Right; int bottom original.Bottom; switch (handle) { case ResizeHandle.BottomRight: right newPoint.X; bottom newPoint.Y; break; case ResizeHandle.TopLeft: left newPoint.X; top newPoint.Y; break; // 其它手柄略 } int width Math.Max(10, right - left); // 最小宽度限制防止图形反扣 int height Math.Max(10, bottom - top); return new Rectangle(left, top, width, height); }Math.Max(10, ...)这个下限很关键——没有它把图形缩到负数宽度时DrawRectangle会直接画错甚至抛异常。另外还要在OnMouseMove的 resize 分支里用ResizeByHandle计算新矩形后再回写Start、End而不是直接让End e.Location。这样一个图形就始终不会出现“宽度为负”的状态。4.4 GDI 对象泄漏导致程序越来越卡现象程序运行一段时间后拖动图形越来越卡最后画面静止不动。原因Draw方法里每次都用new SolidBrush和new Pen如果忘了Dispose系统 GDI 对象句柄会被耗尽。表现是内存占用不高但界面僵死。解决所有临时创建的画笔、刷子一律用using包裹或者作为控件字段只创建一次private Pen _borderPen new Pen(Color.Black, 2f); private SolidBrush _fillBrush new SolidBrush(Color.Transparent); protected override void Dispose(bool disposing) { if (disposing) { _borderPen.Dispose(); _fillBrush.Dispose(); } base.Dispose(disposing); }把高频创建的 GDI 对象提升为字段这样绘图路径中没有反复创建、释放的损耗。绘制一张复杂图形涉及几百次绘制这种损耗会被放大。从任务管理器里能看到句柄数不再持续上涨这个问题也算是治本了。5. 二次开发把项目改造成你自己的图形编辑器5.1 加一种新图形以画三角形为例让现有的菱形换成三角形不需要改画布代码。新建TriangleShape类继承Shape实现Draw和Containspublic class TriangleShape : Shape { public override void Draw(Graphics g) { var rect GetNormalizedRect(); Point[] points { new Point(rect.X rect.Width / 2, rect.Y), new Point(rect.X, rect.Y rect.Height), new Point(rect.X rect.Width, rect.Y rect.Height) }; using (var brush new SolidBrush(FillColor)) using (var pen new Pen(BorderColor, 2f)) { g.FillPolygon(brush, points); g.DrawPolygon(pen, points); } } public override bool Contains(Point p) { // 复用菱形的射线法需要把顶点计算提取为辅助方法 ... } }然后在主窗体的工具栏加一个“三角形”按钮ShapeFactory.CreateShape里增加一个分支。整个过程控制在半小时以内核心逻辑完全复用几乎不需要改动画布。我一般还会顺手把每个新图形注册到工程里一个静态字典用字符串做键这样以后做持久化保存直接存图形类型名就能反序列化。5.2 保存与加载把画布变成文件项目目前没有持久化但是加起来的成本很低把Shape的公共属性类型名、坐标、边框色、填充色用 JSON 序列化到文件。public void SaveCanvas(string filePath) { var data _shapes.Select(s new ShapeData { ShapeType s.GetType().Name, Start s.Start, End s.End, BorderColor s.BorderColor, FillColor s.FillColor }).ToList(); string json JsonSerializer.Serialize(data); File.WriteAllText(filePath, json); }加载反过来根据ShapeType反射创建实例并恢复属性。加上这个能力后画图程序就从“课堂演示”变成了“能存储用户作品”的完整小工具。如果再给图形对象加一个ZIndex字段还能实现图层的上下移动这些扩展点都是因为基类的属性设计够收敛才变得容易。5.3 给图形加文字组合比多态更合适很多人给图形加文字时会考虑在Shape里增加Text字段并让Draw方法画上去。我一般不建议这么做因为字体、对齐、换行是另一种维度的逻辑塞进图形类会让职责失衡。更合理的做法是做一个LabelShape组合了文字和图形的装饰者持有InnerShape引用在Draw时先画内部图形再画文字。这样矩形、圆形、菱形都可以无侵入地支持文字标签而不需要破坏原有的继承结构。从实现上讲LabelShape依然继承Shape内部包一个具体的Shape实例重写Contains把判断转发给实例。这种设计模式的好处是后续加的每个新图形都自动支持了“文字标注”能力。这段代码是这个项目最值得抽出来理解的部分之一——它在告诉你图形编辑器里的“图形”不是一个僵硬的类层次而是一个可以组合的树状结构。到现在我养成了一个习惯拿到任何绘图或编辑器类项目先看图形类型怎么组织、命中测试怎么实现、拖拽时坐标基准怎么处理这三个点决定了一个程序是“能画”还是“好用”。这个 C# 项目用来理解这三个点绰绰有余希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑