ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C# WinForm自定义圆形进度条控件开发:从GDI+绘图到性能优化

C# WinForm自定义圆形进度条控件开发:从GDI+绘图到性能优化 简介这是一份面向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这支“画笔”画出属于自己应用的精彩界面。本文还有配套的精品资源点击获取
返回列表