ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Winform圆形进度条GDI+自绘教程:双缓冲与DrawArc实战

Winform圆形进度条GDI+自绘教程:双缓冲与DrawArc实战 简介面向需要在WinForm界面中加入动态圆形进度条的.NET开发者这份C#示例源码基于VS2019与.NET Framework 4.7.2编写控件DLL为4.0版本用于弥补系统默认进度条样式单一、无法直接呈现环形进度的不足常见于加载等待、任务完成度展示等场景。压缩包共20个文件包含6个.cs核心源码、2个DLL含HZH_Controls控件库、1个.exe示例程序以及.sln/.csproj工程文件、.resx资源文件、.config配置文件和NuGet依赖包等其中.cs文件负责实现控件逻辑.resx与.config用于界面资源与运行环境配置整体约16.81MB结构清晰直接打开sln即可运行。目前已有734人学习下载。示例以完整工程形式呈现读者可对照Form1.cs与Designer文件理解自定义控件设计过程也可参考HZH_Controls的集成方式扩展更多圆形仪表、环形图表等效果特别适合打算在正式项目中引入动效控件的开发者。1. Winform 圆形进度条一个自绘控件就是界面美化的第一块敲门砖C# Winform 默认的 ProgressBar 只有条状那一种长相放在上位机或者桌面工具里总有种「开发环境没装皮肤」的生硬感。圆形进度条把进度从「填满一条线」变成「画完一个环」视觉反馈完全不同——这几年拆过的 winform 项目里凡是涉及仪表盘、温度监控、下载进度这类界面最后几乎都绕回自绘这条路。这份源码不是拖进来就能用的成品 DLL而是一套完整的自定义控件例子继承 Control、重写 OnPaint、暴露 Progress 和 RingWidth 这类属性、再把双缓冲做掉。适合刚入门 C# 想搞懂 GDI 自绘的新手也适合被那种 Excel 表格式界面折磨、想给工具加一点视觉反馈的 winform 从业者。看完这篇你可以把它的绘制逻辑直接搬进自己的项目。2. 绘制原理GDI 弧形公式、角度起点和双缓冲缺一不可在动手改源码之前得先搞明白一个问题为什么圆形进度条在 winform 里没有现成控件答案很简单——Framework 自带的 ProgressBar 只实现了条状的绘制逻辑圆环形态不属于微软官方控件集。于是市面上常见两条路装第三方 DLL或者干脆 GDI 手绘。我一般推荐后者原因下面细说。2.1 为什么不直接引第三方控件而是选择 GDI 手绘第三方控件最大的问题是「你不知道它内部画了什么」。我之前在一个 winform 项目里用过开源圆形进度条包功能确实全但想改个渐变颜色就得翻它源码里层层封装的画笔逻辑改完还要重新编译引用程序集。其次是版本依赖有些包锁定 .NET Framework 4.7.x换到 .NET 6 的 winform 工程直接报类型加载失败。自绘的好处是整段绘制逻辑就在你自己项目的几十行代码里出问题断点能打进去。还有一个更实际的理由第三方圆形进度条大多是「控件内的固定样式」想把它做成多环叠加、内圈文字自适应、或者环上带刻度线都得靠继承重写。与其绕一圈继承别人的 OnPaint不如直接从 Control 起步。当然这有个前提——你愿意花半小时看明白 DrawArc 的参数。看完 2.2 你会发现它比想象中简单。2.2 DrawArc 的坐标公式起始角、扫过角和矩形边界DrawArc 是 GDI 画弧线的基础方法签名是Graphics.DrawArc(Pen pen, Rectangle rect, float startAngle, float sweepAngle)。理解它需要记住三点矩形 rect 是弧线所在圆周的外切矩形不是画布矩形startAngle 以 x 轴正方向为 0 度顺时针为正sweepAngle 表示从起始角扫过的角度量。圆形进度条把进度值换算成角度就是360f * progress / 100f。坑就藏在起始角这里。我们习惯把进度起点放在正上方12 点方向但 GDI 的 0 度在正右方3 点方向所以代码里必须写-90f。以下是我在项目里实际用的绘制核心去掉了无关逻辑protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; // 抗锯齿必须开否则弧线边缘全是锯齿 g.SmoothingMode SmoothingMode.AntiAlias; // 圆环所在的矩形留出环宽一半的边距防止弧线被控件边缘裁剪 float half _ringWidth / 2f; RectangleF ringRect new RectangleF( half, half, ClientSize.Width - _ringWidth, ClientSize.Height - _ringWidth); // 背景环整圆 using (Pen backPen new Pen(Color.FromArgb(35, 120, 120, 120), _ringWidth)) { g.DrawArc(backPen, ringRect, 0f, 360f); } // 进度弧从 12 点方向开始顺时针扫过百分比对应的角度 float sweepAngle 360f * _progress / 100f; using (Pen forePen new Pen(_progressColor, _ringWidth)) { forePen.StartCap LineCap.Round; forePen.EndCap LineCap.Round; // 端点圆头视觉上更柔和 g.DrawArc(forePen, ringRect, -90f, sweepAngle); } }参数说明集中在三处。ringRect里减去的_ringWidth是环宽以 10f 为例控件 150×150 时矩形就是 (5, 5, 140, 140)这样环的外边缘正好贴合控件边界不会被裁剪。LineCap.Round会让进度弧的首尾变圆头进度接近满环时衔接更自然如果做工业风格界面想突出刻度感可以去掉这两个赋值。sweepAngle的算法决定了进度和角度的线性关系1% 对应 3.6 度这个换算在后续做动画缓动时也复用。提示StartCap和EndCap只在弧线长度足够时可见进度小于 2% 时圆头可能让弧看起来比实际长一点点这是正常的。这里补一个细节DrawArc的sweepAngle是相对当前起始角的增量不是终点角度。很多人误以为传的是「终点角」导致画出来的弧永远不对。如果你要动态调整起点先算好起始角再把扫过角度加上去。关于环宽参数还有个经验值控件直径 120~200 时环宽 8~12 像素视觉最协调。太细小于 6在低 DPI 下弧线容易被抗锯齿吃掉一半看起来像虚线太粗大于 20则内部文字区域小到塞不下两位数百分比。如果你在 4K 高分屏上开发记得给窗体启用 PerMonitorV2 DPI 感知否则控件会被整体拉伸弧线宽度也跟着不规则缩放。2.3 双缓冲为什么拖一下窗体进度条就闪第一次跑通 OnPaint 之后八成会遇到这个问题窗体拖动或者连续更新进度的时候整个圆环在闪。原因是 winform 默认在每次重绘时先擦除背景再画前景擦和画之间的空档肉眼可见。解决方式不是用计时器「忍一忍」而是开双缓冲——把绘制内容先画到内存位图再一次拷贝到屏幕。我一般直接在构造函数里加这样一段public CircularProgressBar() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true); // 这一行和上面的 SetStyle 等效但会更早初始化内部缓冲 base.DoubleBuffered true; }这里几个样式各有分工OptimizedDoubleBuffer提供双缓冲AllPaintingInWmPaint告诉系统 WM_PAINT 已经负责全部绘制避免先擦背景UserPaint则把绘制控制权完全交给 OnPaint。注意ResizeRedraw也很关键——它保证控件尺寸改变时触发完整重绘否则缩放后的圆环会残留破损边缘。如果你需要更精细的控制比如在某些场景手动管理缓冲可以用BufferedGraphicsContext配合Graphics手动绘制BufferedGraphicsContext context BufferedGraphicsManager.Current; using (BufferedGraphics buffer context.Allocate(e.Graphics, ClientRectangle)) { Graphics g buffer.Graphics; PaintRing(g); // 所有绘制逻辑写进这个方法 buffer.Render(e.Graphics); // 一次性拷贝到位 }这种方式适合要叠加多层绘制内容的场景比如同时画圆形进度条和内部仪表盘刻度。单控件场景用 DoubleBuffered 就够了频繁 Allocate 反而增加开销。3. 源码落地从空窗体到能跑起来的进度条与真实场景接入原理看明白了剩下就是把控件写完整。3.1 控件基架继承 Control、暴露属性、触发生效自定义控件最舒服的地方在于属性可以在设计器里直接调不用每次改代码重编译。所以基架部分重点就是三件事让属性带默认值、在 setter 里调用 Invalidate() 触发重绘、设置合理的默认尺寸。先写控件类public class CircularProgressBar : Control { private float _progress; private float _ringWidth 10f; private Color _foreColor Color.FromArgb(0, 151, 255); private bool _showText true; [DefaultValue(0f)] public float Progress { get _progress; set { _progress Math.Max(0f, Math.Min(100f, value)); Invalidate(); } } [DefaultValue(10f)] public float RingWidth { get _ringWidth; set { _ringWidth Math.Max(2f, value); Invalidate(); } } [DefaultValue(true)] public bool ShowText { get _showText; set { _showText value; Invalidate(); } } /// summary进度颜色默认亮蓝/summary public Color ProgressColor { get _foreColor; set { _foreColor value; Invalidate(); } } public CircularProgressBar() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true); Size new Size(150, 150); MinimumSize new Size(80, 80); } protected override void OnPaint(PaintEventArgs e) { /* 绘制逻辑见 2.2 */ } }逻辑说明Progress的 setter 做了两件事——先把值钳制在 0~100再调Invalidate()通知系统重绘。很多人写自定义控件时忘了 Invalidate结果属性改了屏幕上没反应还以为是控件坏了。RingWidth同理加一个最小值限制避免 0 或负数导致 Pen 构造异常。Size和MinimumSize是给设计器用的让你从工具箱拖出来就能看到一块 150×150 的大致落点MinimumSize 防止用户在设计器里拖成一条细线弧线在窄条矩形里根本画不出圆环。设计器使用还有一个细节只要类名和构造函数没有报错编译一次之后就能从工具箱拖出来但如果是internal类工具箱可能找不到建议声明为public。3.2 进度值从哪里来线程更新与 Invoke 的正确姿势圆形进度条本身不产生进度数据它只是消费进度值的显示器。实际项目里进度数据可能来自后台线程——下载任务、硬件采集、算法计算。线程里直接改Progress属性会触发跨线程访问异常因为 Control 的句柄创建在 UI 线程。常见的做法是用Task.Run跑任务在任务里用Invoke或BeginInvoke把 UI 更新封送回主线程private async void btnStart_Click(object sender, EventArgs e) { btnStart.Enabled false; circularProgressBar1.Progress 0f; await Task.Run(() { for (int i 0; i 100; i) { Thread.Sleep(50); // 模拟耗时工作 // 跨线程安全更新 BeginInvoke(new Action(() { circularProgressBar1.Progress i; lblState.Text $正在处理{i}%; })); } }); btnStart.Enabled true; lblState.Text 完成; }逻辑说明Task.Run把循环放到线程池线程执行Thread.Sleep(50)只是模拟工作者在干活。每次循环后BeginInvoke把更新动作发到 UI 线程消息队列——它和Invoke的区别是Invoke会阻塞后台线程直到 UI 执行完BeginInvoke不阻塞适合高频进度回调。单条进度更新差别不大但如果每秒刷新 20 次以上务必用BeginInvoke否则后台线程会被 UI 拖慢任务总耗时会比预期长。如果你用 .NET 4.5 之后的 async/await 风格也可以把耗时操作放到Task.Delayfor (int i 0; i 100; i) { await Task.Delay(50); // 非阻塞等待 circularProgressBar1.Progress i; // await 捕获了 UI 同步上下文赋值线程安全 }这是因为 await 会捕获 UI 同步上下文循环执行回到 UI 线程后直接赋值是线程安全的。注意两种写法各自适用的场景Task.Run适合干重活CPU 密集、阻塞 IO同时要更新进度Task.Delay适合纯等待型进度比如等待某个服务响应。重活和 UI 更新放一起跑界面还是会卡。注意async void 事件处理器要记得在内部做 try/catch否则异常会直接抛到 UI 线程程序可能直接挂掉。3.3 接入一个真实场景模拟文件上传的环形反馈把控件拖到窗体上只是第一步真正让进度条「转起来」的是业务逻辑。我用一个比较典型的场景演示模拟文件上传时进度值来自一个封装好的FileUploader类。public class UploadProgressEventArgs : EventArgs { public float Percent { get; set; } } public class FileUploader { public event EventHandlerUploadProgressEventArgs ProgressChanged; public async Task UploadAsync(string filePath) { float total new FileInfo(filePath).Length; float uploaded 0f; // 模拟分块上传每次读取 4KB byte[] buffer new byte[4096]; using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) { int read; while ((read await fs.ReadAsync(buffer, 0, buffer.Length)) 0) { uploaded read; float percent uploaded / total * 100f; // 触发事件UI 层订阅 ProgressChanged?.Invoke(this, new UploadProgressEventArgs { Percent percent }); await Task.Delay(20); // 模拟上传延迟 } } } }UI 侧订阅事件FileUploader uploader new FileUploader(); uploader.ProgressChanged OnUploadProgress; private void OnUploadProgress(object sender, UploadProgressEventArgs e) { circularProgressBar1.Progress e.Percent; // 顺便更新状态栏文本这里不写了 }这里把业务逻辑和 UI 解耦控件的Progress属性只接受一个 0~100 的数字至于这个数字怎么产生、从哪来控件不关心。这样设计的好处是上传、下载、安装解压、传感器数据归一化——任何进度场景都能塞进同一个控件不用改控件本身。事件写法比直接在任务里赋值更通用尤其当进度值还会被日志模块、状态栏文本等地方消费时事件可以让多界面同步更新而不用手写多个赋值点。4. 避坑排查闪烁、变形、起始角错位的四个典型翻车自定义控件最容易翻车的地方反而不是绘制算法本身而是那几个看起来「应该没问题」的系统交互点。我把实际踩过的坑按现象整理出来每条都是先描述现象再给原因和解决。4.1 拖动窗体或者连续刷新时进度条明显闪烁现象窗体被拖动、或者进度值每 50ms 更新一次时圆环区域出现明显的残影和闪白尤其弧线和背景交替处最明显。原因绘制没有使用双缓冲系统在 WM_PAINT 时先擦背景再用旧背景色填充然后再画新内容两次渲染之间的间隙被肉眼捕捉到。另一个容易被忽略的原因是控件默认的BackColor是半透明的擦除背景时 GDI 无法正确处理透明也会加重闪烁。解决在构造函数里加SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)并且把BackColor设成和父容器一致的纯色。如果还闪检查是否在自定义属性 setter 里做了太重的计算——每次 Invalidate 都触发 OnPaintOnPaint 里不要做文件 IO、字符串格式化排版等重操作。把格式化结果缓存到字段里只在数值真正变化时更新。4.2 控件在窗体拉伸后圆环变成椭圆现象把圆形进度条拖到窗体右下角调整窗体大小时圆环跟着拉伸明显变成横椭圆或竖椭圆。原因这是最经典的坐标写死问题。OnPaint里如果直接用控件的Width / Height计算矩形中心或者半径而ClientSize.Width和ClientSize.Height比例不等于 1:1画出来的圆环自然会被拉伸。解决绘制时始终基于Math.Min(Width, Height)取直径让圆环所在矩形保持正方形float diameter Math.Min(ClientSize.Width, ClientSize.Height); float half _ringWidth / 2f; RectangleF ringRect new RectangleF( (ClientSize.Width - diameter) / 2f half, (ClientSize.Height - diameter) / 2f half, diameter - _ringWidth, diameter - _ringWidth);这段代码把圆环矩形居中先算出直径再用边长差值的一半作为左上角偏移。这样无论控件是 150×150 还是 200×120圆环始终是一个正圆且贴中心。这个坑在固定尺寸的 demo 里看不出来一旦用户把控件放到 TableLayoutPanel 里让窗体带动缩放就会翻车。4.3 背景变成全黑或者不透明色块现象控件放到窗体上后周围一圈变成黑色或灰色色块完全透明不了。想做出圆环中间镂空、透过父窗体背景的效果做不到。原因UserPaintAllPaintingInWmPaint组合下系统跳过了默认的OnPaintBackgroundBackColor不再自动绘制。而且 GDI 的Control类本身不支持真正的透明背景——winform 控件没有 alpha 混合背景的机制。解决不要试图给控件本身设置透明背景而是把镂空效果画出来。在OnPaintBackground里取父容器的背景色填充整个客户区这样视觉上就是「透出父窗体颜色」protected override void OnPaintBackground(PaintEventArgs e) { using (SolidBrush brush new SolidBrush(Parent.BackColor)) { e.Graphics.FillRectangle(brush, ClientRectangle); } }注意这样只能模拟「纯色背景透明」父窗体如果有背景图或者渐变色镂空区域就会对不上。真要完全镂空透出背景图需要处理Region或者直接用SetStyle(ControlStyles.SupportsTransparentBackColor)配合BackColor Color.Transparent但这两条路在某些 winform 版本里依然有兼容问题。从简化维护的角度我的习惯是让圆形进度条所在的窗体背景保持纯色把镂空问题降级为「用背景色填充」然后再把百分比文字叠上去。4.4 进度弧起点偏了 90 度方向也不对现象画出来的弧线不是从正上方开始而是从右侧开始进度增长时弧线逆时针走看起来「倒着转」。原因GDI 的 0 度定义在正右方三点钟方向并且默认顺时针为正。而用户习惯的进度环起点是正上方、增长方向是顺时针。如果直接写DrawArc(pen, rect, 0f, sweepAngle)实测弧线就从正右方开始扫了。解决起点补-90f偏移让 0 度映射到正上方扫过角度保持正值顺时针方向就对了// 起点从正上方开始 float startAngle -90f; // 顺时针扫过范围 float sweepAngle 360f * _progress / 100f; g.DrawArc(forePen, ringRect, startAngle, sweepAngle);如果你希望进度从正下方开始部分仪表盘风格喜欢这种改成90f即可。这一段「角度基准」每次写自定义圆弧都要重新确认一遍因为 GDI 和数学坐标系里的角度零位、正方向定义不一样我是吃过亏的从那以后只要画弧必先写注释标明零位和方向。5. 进阶手感渐变圆弧、平滑动画和文字居中一次讲清自定义控件做到能转、能显示、不闪只能算「能用」。真正让界面有质感的是三个进阶点颜色渐变、动画过渡、文字居中。5.1 渐变圆弧与平滑动画渐变最常见的做法是让进度弧从亮蓝过渡到青绿用LinearGradientBrush替代纯色 Penusing (LinearGradientBrush brush new LinearGradientBrush( ringRect, Color.FromArgb(0, 151, 255), Color.FromArgb(0, 230, 200), 45f)) { using (Pen pen new Pen(brush, _ringWidth)) { g.DrawArc(pen, ringRect, -90f, sweepAngle); } }注意LinearGradientBrush需要一个矩形确定渐变范围直接用 ringRect 即可角度 45f 让渐变方向从左上到右下比水平渐变多一些立体感。另外别在 OnPaint 里重复 new brush把 brush 缓存为字段属性变化时重新构建。平滑动画用Timer把进度值「缓动」到目标值而不是直接跳变private float _displayProgress 0f; private float _targetProgress 0f; private readonly Timer _animTimer; void StartAnimation(float target) { _targetProgress target; _animTimer.Interval 15; // 约 66fps _animTimer.Start(); } void OnTimerTick(object sender, EventArgs e) { _displayProgress (_targetProgress - _displayProgress) * 0.1f; if (Math.Abs(_targetProgress - _displayProgress) 0.1f) { _displayProgress _targetProgress; _animTimer.Stop(); } Invalidate(); }动画更新进度时Progress属性使用_displayProgress保证每一次重绘都落在可见变化上。5.2 文字绝对居中文字居中绘制我踩过DrawString基线偏移的坑。手动MeasureString计算坐标很容易偏上或偏下换成StringFormat配合ClientRectangle才真正省事using (StringFormat sf new StringFormat() { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }) { g.DrawString(_displayProgress.ToString(0) %, Font, Brushes.Black, ClientRectangle, sf); }Alignment控制水平居中LineAlignment控制垂直居中配合ClientRectangle即可做到「控件内绝对居中」——比手动计算坐标靠谱得多。如果圆环内还要放两行文字比如百分比加状态说明可以把ClientRectangle拆成上下两个矩形分别绘制。这些细节点缀完之后圆形进度条就从「demo 能转」变成了「交付到真实界面不被人嫌弃」。每次写完自定义控件我都强制自己在 OnPaint 开头检查三件事SmoothingMode、线帽圆角、ringRect 是否用了 Min 边长。就这三个占视觉影响最大的参数曾经让我在客户现场对着一个锯齿环和歪斜文字折腾到半夜。从那以后凡是自绘控件先过这三关再谈业务。希望这份源码用起来也少走点弯路。本文还有配套的精品资源点击获取
返回列表