简介:这是一份面向C# WinForm开发者的圆形进度条控件源码资源,适用于需要自定义UI动效的桌面应用开发场景,尤其适合初学者理解GDI+绘图原理与控件封装逻辑。压缩包共20个文件,包含6个核心C#源码文件(含主窗体、设计器及程序入口)、3个配置文件(App.config等)、2个.NET Framework 4.0兼容的控件DLL、2个本地化资源文件(.resx),以及项目解决方案(.sln)、工程配置(.csproj)、调试符号(.pdb)和NuGet包(.nupkg)等完整构建要素,总大小16.81MB。已有728人学习下载,资源结构规范,目录层级清晰,可直接在Visual Studio 2019中加载编译运行,配套博客详细说明了绘制逻辑、属性绑定与主题适配方法,便于开发者快速集成、二次开发或深入研究WinForm自定义控件的设计模式。
1. 项目概述与核心价值
最近在做一个桌面端的小工具,需要展示一些耗时操作的进度,比如文件批量处理或者数据同步。用惯了系统自带的那个长条形的进度条,总觉得有点审美疲劳,而且界面显得不够精致。于是就想,能不能自己动手做一个圆形的进度条?一来可以美化界面,二来也能更直观地展示环形进度。网上找了一圈,发现现成的、能直接拿来改的C# WinForm圆形进度条源码要么太复杂,要么功能不全,要么就是依赖了第三方控件库。对于一个小项目来说,引入一个庞大的库显然不划算。所以,我决定从零开始,基于GDI+自己绘制一个。这个过程中,我不仅实现了一个外观可定制、性能不错的圆形进度条控件,还深入理解了WinForm自定义控件的绘制、属性封装和事件处理机制。如果你也在为WinForm界面美化发愁,或者想学习如何创建一个功能完整的自定义控件,那么这份从思路到源码的完整记录,应该能给你不少直接的参考。
2. 圆形进度条的核心设计思路
2.1 为何选择GDI+自绘而非第三方控件
当我们决定在WinForm中实现一个非标准控件时,通常有几条路:使用现成的第三方控件库(如DevExpress、Telerik)、继承现有控件并重绘、或者完全从Control基类开始自绘。对于圆形进度条这种侧重于特定视觉效果和轻量级的组件,我倾向于选择从Control基类开始,利用GDI+进行自绘。原因有三点:首先是零依赖,编译后就是一个独立的DLL或直接源码集成,部署时没有任何额外负担,特别适合小型工具或对安装包体积敏感的项目。其次是完全可控,从进度条的颜色、宽度、背景、文字样式到动画效果,每一个像素的渲染逻辑都掌握在自己手里,可以根据产品需求进行极致定制,不会受限于第三方控件提供的有限属性。最后是学习价值,这个过程是对WinForm绘图模型、控件生命周期和面向对象封装的一次绝佳实践,理解了原理之后,你能轻松举一反三,做出仪表盘、雷达图等其他自定义图形控件。
2.2 控件功能点与属性规划
在动笔写代码之前,先要在纸上(或脑海里)把控件需要暴露给使用者的功能定义清楚。一个基础的圆形进度条,我认为至少需要以下核心属性:
Value(当前值): 最核心的属性,表示当前的进度值,通常与Minimum和Maximum共同决定绘制角度。Minimum和Maximum(最小值与最大值): 定义进度范围,比如0到100。这使控件不仅能表示百分比,还能表示任意范围的进度(如“已完成35项,共100项”)。ProgressColor(进度弧颜色): 已经完成部分弧段的颜色。BackColor(背景弧颜色): 未完成部分或底层背景弧的颜色。注意,这里最好与控件的BackColor区分开,特指进度条圆环的背景部分。ProgressWidth(进度条宽度): 圆环的粗细。TextDisplay(文本显示模式): 决定圆环中心显示什么文字,比如“百分比”、“数值(如35/100)”、“自定义文本”或者“不显示”。
除了这些,还可以考虑扩展属性,如:
StartAngle: 进度条起始角度(默认从顶部12点钟方向开始)。ProgressCap: 进度条两端的样式(圆形、方形等)。AnimationEnabled: 是否启用值变化时的平滑动画。ShowBackground: 是否显示背景弧。
将这些属性规划好,后续的绘制逻辑和属性封装就有了清晰的蓝图。
2.3 绘制原理:GDI+的Graphics类与关键方法
整个绘制工作的核心是System.Drawing.Graphics类。在控件的OnPaint方法中,我们可以获取到PaintEventArgs参数中的Graphics对象,它就像一块画布和一套画笔。绘制圆形进度条主要用到以下几个方法:
DrawArc: 用于绘制弧线。我们需要用它分别绘制背景弧和前景(进度)弧。关键是计算弧线的外接矩形和起始角、扫描角。DrawEllipse: 如果需要画一个空心的圆环作为边框,可能会用到。但在我们的实现中,用DrawArc绘制不同颜色的弧段来模拟圆环更为常见。DrawString: 用于在圆环中心绘制文本。需要精心计算文本的位置使其居中。FillPie: 如果你想要一个实心的扇形进度条(像饼图),可以用这个方法。但圆形进度条通常指环形的,所以我们主要用DrawArc。
计算角度的公式是核心:扫描角 = (Value - Minimum) / (Maximum - Minimum) * 360。注意,GDI+中角度以度为单位,0度指向X轴正方向(右侧),而通常我们希望从顶部开始,所以起始角可以设置为-90度。
3. 从零构建圆形进度条控件
3.1 创建自定义控件项目与类
首先,在Visual Studio中创建一个新的“Windows 控件库”项目,命名为CircularProgressBarLib。删除默认的UserControl1.cs,添加一个新的类,命名为CircularProgressBar,并让其继承自System.Windows.Forms.Control。
using System; using System.ComponentModel; using System.Drawing; using System.Drawing.Drawing2D; using System.Windows.Forms; namespace CircularProgressBarLib { [DefaultProperty("Value")] [DefaultEvent("ValueChanged")] public partial class CircularProgressBar : Control { public CircularProgressBar() { // 设置默认样式,减少闪烁 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.ResizeRedraw | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); // 初始化默认尺寸 Size = new Size(100, 100); } } }这里我们通过SetStyle设置了控件的样式标志。AllPaintingInWmPaint和UserPaint表示控件将完全由我们自己绘制。ResizeRedraw确保控件大小改变时重绘。OptimizedDoubleBuffer是关键,它启用双缓冲,能有效消除绘制时的闪烁现象,让动画(如果后续添加)更加平滑。
3.2 定义与封装核心属性
接下来,我们为之前规划的核心属性创建对应的私有字段和公共属性。属性设置器(set)在值改变后,必须调用Invalidate()方法来请求重绘控件,这是让界面及时更新的关键。
private int _value = 0; private int _minimum = 0; private int _maximum = 100; private Color _progressColor = Color.DodgerBlue; private Color _backColor = Color.LightGray; private int _progressWidth = 10; private TextDisplayMode _textDisplay = TextDisplayMode.Percentage; // 枚举,定义文本显示模式 public enum TextDisplayMode { None, Percentage, Value, ValueOverMaximum, Custom } [Category("Appearance"), Description("当前进度值")] public int Value { get { return _value; } set { // 确保值在范围内 value = Math.Max(_minimum, Math.Min(_maximum, value)); if (_value != value) { _value = value; OnValueChanged(EventArgs.Empty); Invalidate(); // 值改变,触发重绘 } } } [Category("Appearance"), Description("进度最小值")] public int Minimum { get { return _minimum; } set { if (_minimum != value) { _minimum = value; if (_value < _minimum) _value = _minimum; if (_maximum < _minimum) _maximum = _minimum; Invalidate(); } } } [Category("Appearance"), Description("进度最大值")] public int Maximum { get { return _maximum; } set { if (_maximum != value) { _maximum = value; if (_value > _maximum) _value = _maximum; if (_minimum > _maximum) _minimum = _maximum; Invalidate(); } } } [Category("Appearance"), Description("进度弧的颜色")] public Color ProgressColor { get { return _progressColor; } set { _progressColor = value; Invalidate(); } } // 注意:这里我们用一个新属性表示圆环背景色,避免与Control的BackColor混淆 [Category("Appearance"), Description("圆环背景的颜色")] public Color CircleBackColor { get { return _backColor; } set { _backColor = value; Invalidate(); } } [Category("Appearance"), Description("圆环的宽度(像素)")] public int ProgressWidth { get { return _progressWidth; } set { _progressWidth = Math.Max(1, value); Invalidate(); } // 宽度至少为1 } [Category("Appearance"), Description("中心文本的显示模式")] public TextDisplayMode TextDisplay { get { return _textDisplay; } set { _textDisplay = value; Invalidate(); } } // 自定义文本属性,当TextDisplay为Custom时使用 private string _customText = ""; [Category("Appearance"), Description("自定义显示的文本")] public string CustomText { get { return _customText; } set { _customText = value; Invalidate(); } }注意:属性封装时,一定要考虑数据有效性。比如
Value要在Minimum和Maximum之间,ProgressWidth不能小于1。同时,在Minimum和Maximum的setter中,要处理好它们之间以及它们与Value的依赖关系,确保逻辑一致。
3.3 重写OnPaint方法:绘制圆环与文本
这是控件的灵魂所在。我们需要在OnPaint方法中,根据所有属性的当前状态,计算出绘制所需的所有几何参数,然后调用GDI+方法进行绘制。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // 调用基类方法,虽然基类可能不绘制什么,但这是一个好习惯。 Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 开启抗锯齿,让边缘平滑 // 1. 计算绘制圆环的矩形区域(要考虑到进度条的宽度,即内缩) int penWidth = _progressWidth; Rectangle rect = new Rectangle(penWidth / 2, penWidth / 2, ClientSize.Width - penWidth, ClientSize.Height - penWidth); // 2. 绘制背景圆环 using (Pen backPen = new Pen(_backColor, penWidth)) { // 绘制一个完整的360度弧 g.DrawArc(backPen, rect, 0, 360); } // 3. 绘制进度圆环 if (_maximum > _minimum) // 避免除零 { float sweepAngle = 360f * (_value - _minimum) / (_maximum - _minimum); using (Pen progressPen = new Pen(_progressColor, penWidth)) { progressPen.StartCap = LineCap.Round; // 设置起点为圆头 progressPen.EndCap = LineCap.Round; // 设置终点为圆头,使进度条两端圆润 // 从-90度开始(12点钟方向),绘制计算出的角度 g.DrawArc(progressPen, rect, -90, sweepAngle); } } // 4. 绘制中心文本 if (_textDisplay != TextDisplayMode.None) { string textToDraw = GetTextToDraw(); if (!string.IsNullOrEmpty(textToDraw)) { using (Font font = new Font(this.Font.FontFamily, GetOptimalFontSize(g, rect, textToDraw), FontStyle.Bold)) using (Brush textBrush = new SolidBrush(this.ForeColor)) { SizeF textSize = g.MeasureString(textToDraw, font); PointF textLocation = new PointF( (ClientSize.Width - textSize.Width) / 2, (ClientSize.Height - textSize.Height) / 2 ); g.DrawString(textToDraw, font, textBrush, textLocation); } } } } // 根据TextDisplayMode获取要显示的文本 private string GetTextToDraw() { switch (_textDisplay) { case TextDisplayMode.Percentage: if (_maximum > _minimum) return string.Format("{0}%", (int)(100f * (_value - _minimum) / (_maximum - _minimum))); else return "0%"; case TextDisplayMode.Value: return _value.ToString(); case TextDisplayMode.ValueOverMaximum: return $"{_value} / {_maximum}"; case TextDisplayMode.Custom: return _customText; default: return string.Empty; } } // 一个简单的算法,根据可用区域和文本动态计算合适的字体大小 private float GetOptimalFontSize(Graphics g, Rectangle rect, string text) { float fontSize = 20; // 初始字体大小 SizeF textSize; do { using (Font testFont = new Font(this.Font.FontFamily, fontSize, FontStyle.Bold)) { textSize = g.MeasureString(text, testFont); } if (textSize.Width < rect.Width * 0.8 && textSize.Height < rect.Height * 0.8) { break; // 文本尺寸小于可用区域的80%,认为合适 } fontSize -= 0.5f; } while (fontSize > 6); // 字体最小为6 return fontSize; }实操心得:
using语句在这里至关重要。Pen,Brush,Font等GDI+对象是非托管资源,必须及时释放。用using包裹可以确保在离开作用域时调用Dispose方法,避免内存泄漏。这是WinForm/GDI+编程中一个非常容易踩坑的地方。
3.4 添加ValueChanged事件
为了让控件使用者能在进度值变化时做出响应(比如更新其他UI),我们需要公开一个事件。
// 定义事件 [Category("Action"), Description("当Value属性更改时发生")] public event EventHandler ValueChanged; // 触发事件的方法 protected virtual void OnValueChanged(EventArgs e) { ValueChanged?.Invoke(this, e); }这样,在Value属性的setter中调用OnValueChanged,外部就可以订阅这个事件了。
4. 进阶功能与性能优化
4.1 实现平滑动画效果
一个会“动”的进度条显然更有吸引力。我们可以借助WinForm的Timer组件来实现Value变化时的平滑过渡动画。
首先,在控件类中添加几个私有字段:
private System.Windows.Forms.Timer _animationTimer; private int _animatedValue; private int _animationStep = 1; // 每次动画增加的值 private int _animationInterval = 20; // 计时器间隔(ms),控制动画速度然后,在构造函数中初始化计时器:
public CircularProgressBar() { // ... 之前的初始化代码 ... // 初始化动画计时器 _animationTimer = new System.Windows.Forms.Timer(); _animationTimer.Interval = _animationInterval; _animationTimer.Tick += AnimationTimer_Tick; }修改Value属性的setter,使其触发动画逻辑:
set { value = Math.Max(_minimum, Math.Min(_maximum, value)); if (_value != value) { int targetValue = value; // 如果启用了动画,启动计时器逐步逼近目标值 if (AnimationEnabled && !DesignMode) // DesignMode下不运行动画,避免设计器卡顿 { _animationTimer.Stop(); _animatedValue = _value; _animationStep = (targetValue > _animatedValue) ? Math.Abs(_animationStep) : -Math.Abs(_animationStep); _animationTimer.Start(); } else { _value = value; Invalidate(); } // 注意:这里先不直接设置_value,等动画完成或直接跳转时再设 // 事件触发也需相应调整 } }添加AnimationEnabled属性:
private bool _animationEnabled = true; [Category("Behavior"), Description("是否启用值变化的平滑动画")] public bool AnimationEnabled { get { return _animationEnabled; } set { _animationEnabled = value; } }最后,实现计时器的Tick事件处理函数:
private void AnimationTimer_Tick(object sender, EventArgs e) { // 判断是否到达目标值(这里的目标值存储在局部变量或另一个字段中,需要设计) // 为了简化,我们假设目标值就是最终要设置的_value。需要一个字段_targetValue来存储。 if (_animatedValue == _value) // 这里逻辑需要完善,需要_targetValue { _animationTimer.Stop(); return; } // 逐步改变_animatedValue if (Math.Abs(_value - _animatedValue) <= Math.Abs(_animationStep)) { _animatedValue = _value; _animationTimer.Stop(); } else { _animatedValue += _animationStep; } // 重绘控件,但使用_animatedValue来绘制进度 // 这就需要我们修改OnPaint,使其能根据一个“显示值”而非真正的_value来绘制。 // 我们可以引入一个属性如`DisplayValue`,在动画期间等于_animatedValue,否则等于_value。 Invalidate(); }注意事项:完整的动画实现需要更精细的状态管理(比如记录动画目标值
_targetValue),并修改绘制逻辑,使其根据一个用于显示的DisplayValue(在动画期间是_animatedValue,否则是_value)来绘制。同时,要确保在控件销毁时停止并释放计时器。这是一个典型的“状态驱动UI”的案例。
4.2 设计时支持(工具箱与属性窗口)
为了让控件在Visual Studio的设计界面中更好用,我们需要添加一些设计时特性。
工具箱图标: 在项目中添加一个16x16像素的位图(
.bmp)文件,命名为CircularProgressBar.bmp。在其属性窗口中,将“生成操作”设置为“嵌入的资源”。然后,在控件类上添加ToolboxBitmap特性。[ToolboxBitmap(typeof(CircularProgressBar), "CircularProgressBar.bmp")] public partial class CircularProgressBar : Control这样编译后,控件出现在工具箱时就会显示这个图标。
属性分类与描述: 我们已经在前面的属性定义中使用了
[Category("Appearance")]和[Description("...")]特性。这会使属性在属性窗口中按类别分组,并显示描述文本,极大提升易用性。默认值与重置: 可以为属性添加
DefaultValue特性,这样在属性窗口中右键点击属性选择“重置”时,会恢复到这个默认值。[DefaultValue(0)] public int Value { ... } [DefaultValue(typeof(Color), "DodgerBlue")] public Color ProgressColor { ... }
4.3 性能优化关键点
双缓冲(Double Buffering): 我们在构造函数中通过
SetStyle设置的ControlStyles.OptimizedDoubleBuffer标志已经启用了WinForm内置的双缓冲机制,这是消除绘制闪烁最有效的方法,务必确保设置。减少不必要的绘制: 在
OnPaint中,只绘制需要更新的部分。虽然我们通过Invalidate()重绘整个控件区域很简单,但如果控件很大且更新频繁,可以考虑使用Invalidate(Rectangle)只重绘脏矩形区域。对于圆形进度条这种尺寸通常不大的控件,全区域重绘开销可以接受。资源管理: 重申一遍,所有
IDisposable对象(Pen,Brush,Font,GraphicsPath等)必须及时释放。坚持使用using语句块。计算优化: 像
GetOptimalFontSize这样的函数,如果在每次绘制时都进行循环计算,可能会成为性能瓶颈(尽管对于进度条来说微乎其微)。可以考虑将计算结果缓存起来,只有当文本、字体或控件大小改变时才重新计算。
5. 使用示例与常见问题排查
5.1 在WinForm项目中引用与使用
- 编译上面的控件库项目,生成
CircularProgressBarLib.dll。 - 在你的WinForm应用程序项目中,右键“引用” -> “添加引用” -> “浏览”,找到并添加这个DLL。
- 打开工具箱(通常视图菜单里可以找到),右键工具箱区域,选择“选择项...”。在“.NET Framework组件”选项卡中点击“浏览”,再次找到你的DLL并添加。这时,工具箱里应该会出现
CircularProgressBar控件。 - 将其拖拽到你的窗体上,就像使用Button、TextBox一样。
- 在属性窗口中,你可以调整
Value、ProgressColor、CircleBackColor、ProgressWidth、TextDisplay等属性,即时看到设计效果。 - 在代码中,你可以通过编程控制进度,并订阅
ValueChanged事件。
// 示例:在按钮点击事件中模拟进度 private async void btnStart_Click(object sender, EventArgs e) { circularProgressBar1.Minimum = 0; circularProgressBar1.Maximum = 100; circularProgressBar1.Value = 0; for (int i = 0; i <= 100; i++) { circularProgressBar1.Value = i; // 模拟耗时操作 await Task.Delay(50); } }5.2 常见问题与解决方案速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 控件绘制时严重闪烁 | 未启用双缓冲。 | 确保在控件构造函数中设置了ControlStyles.OptimizedDoubleBuffer标志。 |
| 进度条颜色或文本不更新 | 属性值改变后未调用Invalidate()。 | 检查所有影响绘制的属性(如Value,ProgressColor),在其setter中确保调用了Invalidate()。 |
| 圆环绘制不完整或有锯齿 | 1. 绘制矩形rect计算错误,未考虑笔宽。2. 未开启抗锯齿。 | 1. 确认rect的坐标和大小是ClientSize减去penWidth。2. 在 OnPaint开始时设置g.SmoothingMode = SmoothingMode.AntiAlias。 |
| 文本显示不全或位置不对 | 文本位置计算错误或字体过大。 | 使用MeasureString获取文本尺寸,并以此计算居中位置。使用GetOptimalFontSize或类似逻辑动态调整字体大小。 |
| 设计时无法看到控件,或拖拽后报错 | 控件库未正确编译,或设计时特性(如无参构造函数)有问题。 | 1. 重新编译控件库项目。 2. 确保控件类有公共的无参构造函数。 3. 检查是否引用了正确的、最新版本的DLL。 |
| 动画不流畅或卡顿 | 1.Timer间隔太短,UI线程忙于绘制。2. 在 AnimationTimer_Tick中进行了耗时计算。 | 1. 适当增加_animationInterval(如30-50ms)。2. 确保Tick事件处理函数执行速度很快,复杂的计算应提前或异步进行。 |
| 内存占用持续增长 | GDI+对象(Pen, Brush, Font)未释放。 | 严格使用using语句创建和使用这些对象,确保它们被及时Dispose。 |
5.3 扩展思路与个性化定制
这个基础的圆形进度条已经具备了核心功能,但你可以在此基础上进行无限扩展,使其更贴合你的项目需求:
- 渐变颜色: 修改
ProgressColor属性,使其接受一个ColorBlend或线性渐变画笔(LinearGradientBrush),让进度条的颜色可以是从红到绿的渐变。 - 内圈填充: 在圆环内部再绘制一个实心圆,并根据进度值填充不同比例,实现“水波纹”或“液体填充”效果。
- 多段进度: 支持将圆环分为多个不同颜色的段,用于表示多个任务的完成情况。
- 添加图标或图片: 在圆环中心除了文字,还可以绘制一个图标(如图标字体或小图片)。
- 绑定数据源: 将
Value属性与后台的BindingSource或INotifyPropertyChanged对象绑定,实现MVVM模式下的数据驱动UI更新。
通过这个从零构建圆形进度条的过程,我希望传达的不仅仅是一段可运行的代码,更是一种解决问题的方法:定义需求、设计接口、实现核心、优化体验、处理边界。掌握了自定义控件的这套流程,你在面对任何独特的UI需求时,都能从容地拿起GDI+这支“画笔”,画出属于自己应用的精彩界面。
本文还有配套的精品资源,点击获取