首页 / 资讯中心 / 文章详情

C# WinForms画板实战:从绘制矩形到拖动缩放的完整实现

C# WinForms画板实战:从绘制矩形到拖动缩放的完整实现 ★ FEATURED ARTICLE
简介面向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直接序列化 ListShapeBase 会丢失类型信息。解决方案是用 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 ListShapeBase LoadShapes(ListShapeData 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最后再加交互。顺序反了就会出现“画得出来但改不动”的窘境回头再重构数据结构成本比一开始就设计好多太多了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站