资讯动态

Qt环形进度条自定义控件:绘制、动画与高DPI避坑

发布时间:2026/10/26 4:31:10 来源:尧图企业网站定制
简介一份基于 Qt 的自定义环形进度条示例工程面向需要在桌面或嵌入式界面中实现圆形进度展示的开发者尤其适合文件下载、任务加载、数据占比等场景。Qt 原生仅提供水平与垂直进度条该示例通过自绘控件补齐环形进度显示能力可作为界面开发中可直接参考的轻量实现。压缩包共 7 个文件包含 3 个 cpp 源文件、2 个头文件以及界面文件和工程配置文件整体约 8KB规模小巧但覆盖了控件实现、测试调用与界面搭建的基本结构便于快速阅读和移植。已有 493 人学习说明其在 Qt 自定义控件学习中具有一定参考价值。读者可从中了解环形进度条的绘制思路、进度值更新与界面集成方式并据此改造成符合项目风格的下载进度、加载等待或仪表盘式展示组件。1. 为什么现成控件不够用一个环形进度条要接住的四类需求Qt 环形进度条很多人习惯叫圆形进度条是自定义控件里最容易被低估的一个看着只是把一条直线弯成圆真接进项目才发现要同时处理线宽内缩、端帽溢出、数值动画、主题切换和高分屏发虚。用 QProgressBar 加样式表能改颜色和高度但改不出圆环也改不出中心文字和不确定态旋转。这套方案要解决的就是把它做成一个能直接实例化、能被设计改参数、能被测试验收的独立 QWidget外部只暴露 value、ringWidth、颜色和模式几个属性内部把绘制、动画、状态管理分层嵌进任何面板、仪表盘或弹窗都不用再改业务代码。适合正在做课设/毕设需要一个能拿得出手的自定义控件、又不想被“样式表玄学”拖住的人。2. 控件总体设计一个 QWidget 拆成四个可独立替换的绘制层2.1 类结构与成员职责环形进度条最容易写崩的地方是把所有东西塞进一个 paintEvent轨道、圆弧、渐变、文字、旋转全挤在一起改一个颜色要翻二十行。我一般按“数据 → 几何 → 绘制 → 动画”四层切数据层只放 value/range/颜色/模式几何层负责把控件矩形换算成正方形绘制区绘制层按四遍画法依次落笔动画层只改数据、不碰画笔。这样后期换配色、换端帽样式、加副标题都只动一层。先看头文件的结构重点是属性声明的位置// ringprogressbar.h class RingProgressBar : public QWidget { Q_OBJECT // 注意 WRITE 不是 setValue而是 applyValue // 原因QPropertyAnimation 每帧都会调 WRITE如果 WRITE 里再新建动画就会无限递归 Q_PROPERTY(qreal value READ value WRITE applyValue NOTIFY valueChanged) public: enum Mode { Determinate, Indeterminate }; // 确定态 / 不确定态 Q_ENUM(Mode) explicit RingProgressBar(QWidget *parent nullptr); qreal value() const { return m_value; } QSize sizeHint() const override; public slots: void setValue(qreal v); // 业务侧调用带缓动 void applyValue(qreal v); // 动画专用只写值 update void setValueImmediate(qreal v); // 初始化、恢复现场跳过动画 void setMode(Mode m); void setRingWidth(qreal w); void setRingColor(const QColor from, const QColor to); signals: void valueChanged(qreal v); void finished(); // 到上限发一次业务接这个信号切页面 protected: void paintEvent(QPaintEvent *) override; void showEvent(QShowEvent *) override; void hideEvent(QHideEvent *) override; private: qreal m_value 0.0, m_min 0.0, m_max 100.0; qreal m_ringWidth 12.0; qreal m_startAngle 90.0; // 12 点钟起画 qreal m_spinAngle 0.0; // 不确定态的旋转角 QColor m_colorFrom, m_colorTo, m_trackColor, m_textColor; bool m_showText true; Mode m_mode Determinate; QPropertyAnimation *m_anim nullptr; QTimer *m_spinTimer nullptr; };这里最值钱的是那两个入口setValue给业务用内部会起动画applyValue给动画用只做赋值和重绘。很多教程把两者合成一个结果是第一次跑动画就栈溢出或者动画帧率越高越卡因为每一帧都新建了一个 QPropertyAnimation。2.2 角度、半径、线宽的坐标系约定在动手写绘制之前先把三个单位定死不然后面全是返工角度用度进 drawArc 前乘 16半径用短边算不用宽高分别算线宽只在半径上扣一次不要在绘制和布局里各扣一次。约定成表就是参数含义初始值改动影响m_startAngle起始角度90 表示 12 点钟90改 0 变成从 3 点钟起画m_ringWidth圆环线宽像素12直接影响内缩量和最小尺寸inset绘制区向内收缩量ringWidth/2 2端帽是否被裁边的唯一开关span进度弧跨角1/16 度-ratio×360×16负值才顺时针正负写反会从反方向长出来写反符号是最常见的第一次翻车进度从 12 点钟逆时针往左长看着像退格。9 点钟、6 点钟方向同理只要记住 Qt 的 drawArc 以 3 点钟为 0 度、逆时针为正把 90 换算成 startAngle、把进度换算成负的 span方向就不会错。2.3 对外接口、属性与信号约定控件交付给别人用接口比实现重要。我给这套环形进度条定的对外契约只有五条setValue 接受任意实数内部按 range 截断setValueImmediate 用于从配置文件或数据库恢复现场避免打开界面就播一遍动画setMode 切不确定态时会自动停掉 value 动画两者不并存finished 只在达到上限的瞬间发一次不重复发所有 setter 都做幂等判断同值不触发重绘。这五条写进头文件注释里接手的人不用问就知道边界在哪。还有一个容易被忽略的不要在控件内部直接改 QPalette。有些人为了做深浅主题直接palette().setColor(...)结果兄弟控件的文字颜色全被带跑。颜色只走自己的成员变量主题切换由外部显式调 setRingColor责任清楚。3. 绘制层实现paintEvent 里的四遍画法与抗锯齿参数3.1 把绘制区校正成正方形并留出端帽余量控件放在布局里宽高几乎不可能相等。直接拿 rect() 画弧宽 300 高 100 的时候就是一个压扁的椭圆评审一眼就能看出来。正确做法是取短边做正方形再把它在控件里居中。第二步更容易漏QPen 是以路径为中心向两侧各扩 ringWidth/2 的圆头端帽还会再往外鼓半个线宽所以绘制区必须向内收缩否则上下左右各被裁掉一条。// ① 取短边做正方形居中inset 保证端帽不被裁 const qreal side qMin(width(), height()); const qreal inset m_ringWidth / 2.0 2.0; // 2 是给抗锯齿边缘留的余量 QRectF box((width() - side) / 2.0 inset, (height() - side) / 2.0 inset, side - inset * 2.0, side - inset * 2.0);这段是整个绘制的地基。参数怎么调ringWidth 越大inset 越大控件的最小可用尺寸也跟着涨所以 setRingWidth 里要同步setMinimumSize(QSize(qRound(m_ringWidth * 4), qRound(m_ringWidth * 4)))不然缩到 40×40 时圆环会自己套在一起。那个 2 不是玄学是抗锯齿采样会向外溢出一个像素左右的半透明边不留就会被父控件的裁剪矩形切平。3.2 轨道圆环与进度圆弧的角度换算轨道和进度弧分成两遍画不要想着用一次 drawArc 搞定。轨道用 FlatCap 画整圆进度弧用 RoundCap 画部分弧两个笔分开建。轨道如果用 RoundCap0 度和 360 度那个接缝处会鼓出一个小疙瘩放大四倍非常明显。// ② 轨道整圆FlatCap 避免首尾接缝处鼓包 QPen track(m_trackColor, m_ringWidth, Qt::SolidLine, Qt::FlatCap); p.setPen(track); p.drawArc(box, 0, 360 * 16); // ③ 进度弧先归一化再换算成 1/16 度 const qreal ratio qBound(0.0, (m_value - m_min) / (m_max - m_min), 1.0); if (ratio 0.0) { // 0% 时干脆不画见第 5 章 const int startAngle qRound(m_startAngle * 16); const int spanAngle -qRound(ratio * 360 * 16); // 负值 顺时针 p.drawArc(box, startAngle, spanAngle); }(m_value - m_min) / (m_max - m_min)这一步别省。有人的进度条接的是 01 的小数有人接 0100还有人接文件字节数如果在绘制里写死除以 100换个数据源就全乱。归一化放在绘制入口做一次后面所有计算都基于 01。3.3 渐变色的重映射与圆头端帽渐变是这套控件里最容易做出“高级感”也最容易做砸的地方。QConicalGradient 默认铺满整个 360 度如果只按 0 和 1 两个 stop 设色30% 的时候弧段只取到色轮的前三成看着偏冷90% 的时候又几乎是纯尾色跟设计稿对不上。办法是把 stop 跟着当前 ratio 一起重映射让起止色永远落在弧的两端。QConicalGradient grad(box.center(), m_startAngle); grad.setColorAt(0.0, m_colorFrom); grad.setColorAt(qMax(ratio, 0.001), m_colorTo); // 防止 ratio0 时 stop 顺序非法 grad.setColorAt(1.0, m_colorTo); QPen arcPen(QBrush(grad), m_ringWidth, Qt::SolidLine, Qt::RoundCap); p.setPen(arcPen);qMax(ratio, 0.001)是个保护QConicalGradient 的 stop 位置必须单调不减ratio 恰好为 0 时三个 stop 全是 0Qt 会给出未定义结果运气不好就是整圈变黑。另外注意 QConicalGradient 的旋转方向跟 drawArc 相反如果发现渐变头和尾对调了把起始角改成m_startAngle 360 * ratio再试一次别去改颜色。3.4 中心文字的排版与字体自适应中心文字不是简单 drawText 就完事。控件从 200×200 缩到 60×60固定 24px 的字号会直接糊出圆环放大到 400×400 又显得小得可怜。按短边比例算字号最省事同时留出安全边距保证数字不会顶到内圈。// ④ 中心文字字号跟短边走最大不超过控件的 22% if (m_showText) { QFont f font(); f.setPixelSize(qMax(8, qRound(side * 0.16))); f.setBold(true); p.setFont(f); p.setPen(m_textColor); // 用 fontMetrics 判断是否会溢出溢出了就退化成整数百分比 QString txt QString::number(qRound(ratio * 100)) %; if (QFontMetricsF(f).horizontalAdvance(txt) box.width() * 0.7) txt QString::number(qRound(ratio * 100)); p.drawText(box, Qt::AlignCenter, txt); }0.16 这个系数是我试出来的太小看不清超过 0.2 三位的“100%”就会贴到内圈。如果要做“百分比 副标题”的双行排版别用两次 AlignCenter会重叠正确做法是把 box 拆成上下两个 QRectF主文字占 60%副标题占 30%中间留 10%。高 DPI 这一块QPainter 直接画在 QWidget 上时 Qt 会自己处理缩放真正出问题的是缓存。为了省 CPU 把整圈画进 QPixmap 再贴图必须pixmap.setDevicePixelRatio(devicePixelRatioF())并按size() * devicePixelRatioF()建图否则 150% 缩放下边缘一圈毛刺测试提 bug 时很难解释。4. 数值动画与状态切换让跳变看起来像“跑”上去的4.1 用 Q_PROPERTY QPropertyAnimation 驱动内部值进度从 20% 跳到 80%中间没有任何过渡人眼会觉得控件卡了一下。加缓动只需要一个 QPropertyAnimation但有两个坑必须提前处理一是连续 setValue 时旧动画要停二是起始值必须取“当前实际值”而不是上一次的目标值否则快速连发会看到进度往回缩。RingProgressBar::RingProgressBar(QWidget *parent) : QWidget(parent) { m_anim new QPropertyAnimation(this, value, this); m_anim-setDuration(400); m_anim-setEasingCurve(QEasingCurve::OutCubic); // 先快后慢收尾不突兀 } void RingProgressBar::setValue(qreal v) { v qBound(m_min, v, m_max); if (qFuzzyCompare(v, m_value)) return; m_anim-stop(); // 关键一同一属性永远只跑一个动画 m_anim-setStartValue(m_value); // 关键二从当前实际值起步 m_anim-setEndValue(v); m_anim-start(); if (v m_max) emit finished(); } void RingProgressBar::applyValue(qreal v) // 动画每帧回调只写值 { if (qFuzzyCompare(v, m_value)) return; m_value v; update(); // 只重绘不触发动画不会递归 emit valueChanged(m_value); }duration 400ms 是折中低于 200ms 看不出缓动高于 600ms 会让人觉得程序慢。如果这个进度条接的是真实耗时任务比如安装、导出、上传动画时长应该跟着预估总时长走任务本身只有 1 秒就别做 600ms 的过渡。4.2 不确定模式一段固定弧长的匀速旋转不知道总进度的时候连接中、加载中、等待响应旋转弧比来回滚动的横条更耐看。实现上不是让整个圆环转而是画一段固定 90 度的弧绕中心匀速跑。// 不确定态固定 90 度弧长整体绕中心旋转 if (m_mode Indeterminate) { QConicalGradient grad(box.center(), m_startAngle); grad.setColorAt(0.0, m_colorFrom); grad.setColorAt(1.0, m_colorTo); QPen spinPen(QBrush(grad), m_ringWidth, Qt::SolidLine, Qt::RoundCap); p.setPen(spinPen); p.drawArc(box, qRound((m_startAngle - m_spinAngle) * 16), -90 * 16); }旋转角 m_spinAngle 由定时器推进间隔 16ms 对应大约 60fps每帧转 3 度一圈 2 秒跟主流加载动画的节奏差不多。这里要提醒一句定时器不要new QTimer之后一直 start进不确定态再 start、退出再 stop隐藏时也要停否则面板一多后台十几个不可见的控件一起烧 CPU笔记本风扇会替用户投诉。4.3 主题色与状态色切换的收敛点配色不要散落在各处。我一般定一个内部状态枚举正常/警告/异常/完成setState 时统一改 colorFrom、colorTo、trackColor、textColor 四个变量然后一次 update。业务侧只需要bar-setState(RingProgressBar::Warning)不用关心具体色值。深色主题下轨道色要压低到背景亮度之上大约 10%否则整圈糊成一块浅色主题下轨道色不能太浅不然 0% 的时候看起来像控件没画出来。这两条在验收时要拿取色器量一下肉眼在两种显示器上判断很容易偏。4.4 尺寸策略与布局嵌入放进 QVBoxLayout 的控件默认会被纵向拉满圆环控件被拉成 600 高、200 宽结果还是正圆但上下一大片空白看着别扭。我的做法是 sizeHint 返回 (160, 160)sizePolicy 设 Preferred/Preferred同时在父容器需要撑满的场合给一个 QSizePolicy::Expanding 的外层容器包一层让圆环居中。这样两种布局都不会出问题。如果确实要让它随窗口缩放记得 setMinimumSize不然拖到 30×30 时 inset 已经把绘制区吃成负数drawArc 会静默什么都不画表现为“控件消失了”排查起来很折磨人。5. 避坑与常见问题排查五个让控件“看着对、用着错”的点5.1 圆环被裁边或压成椭圆现象控件上下边缘有一条被切平的直线或者明显是椭圆。原因有两个一是绘制区直接用了 rect() 没有取短边二是没有按线宽向内收缩QPen 以路径为中心向两侧扩展超出部分被父级裁剪。解决办法是 3.1 那段取 qMin 做正方形inset 取 ringWidth/2 2居中摆放。改完用 100×300、300×100、40×40 三档尺寸各截一张图对比一次就能确认。5.2 连续 setValue 时进度倒退、闪跳现象100ms 内连发 0.5、0.9、0.3 三个值控件先往前冲再往回缩或者干脆停在中间不动。原因是每个 setValue 都新建了一个 QPropertyAnimation几个动画同时改同一个属性谁最后跑完谁说了算或者 startValue 取的是上一次的目标值而不是当前值。解决就是复用单个动画实例、先 stop、再 setStartValue(m_value)并且把 Q_PROPERTY 的 WRITE 指向 applyValue 而不是 setValue避免动画回调又触发动画。5.3 0% 位置残留一个小圆点现象数值为 0 时12 点钟方向仍然有一个圆点像没有归零。原因是 drawArc 在 span 为 0 时配合 RoundCap 依然会绘制一个端帽圆点。解决办法是在绘制进度弧之前加if (ratio 0.0)判断值为 0 直接跳过顺带把 100% 的接缝也检查一遍首尾两个圆头会叠在一起视觉上比线宽略粗如果设计不接受就在 ratio 接近 1 时改成 FlatCap。5.4 隐藏后 CPU 仍被定时器占着现象切到别的页面控件已经看不见任务管理器里进程还占着几个百分点。原因是不确定态的 QTimer 一直在跑每 16ms 触发一次 update()而 QWidget 隐藏后 paintEvent 不执行定时器却照跳。解决办法是在 showEvent 里按当前模式启动定时器、在 hideEvent 里停掉并且在 setMode 切回确定态时也停一次。这条在有多块面板的桌面端项目里尤其明显。5.5 高分屏发虚、缩小时文字溢出现象150% 缩放下圆环边缘一圈灰边中心数字糊控件缩到 60×60 时文字顶出内圈。原因分别是缓存 QPixmap 没设 devicePixelRatio以及字号写死。解决办法是按size() * devicePixelRatioF()建缓存并设置 DPR字号改成按短边比例算再用 QFontMetricsF 测一次宽度超了就退化成不带百分号的整数。这两条都属于“自己机器上看不出来换台笔记本就露馅”的类型验收清单里必须留一行。6. 验收与答辩用一张清单证明这个控件能进项目控件类的东西功能跑通只是及格线。评审真正看的是你有没有把边界想清楚所以我一般会先写一张验收表再回头补实现。验收项判定标准测试方法几何正确性任意宽高比下都是正圆端帽完整不被裁100×300、300×100、40×40 三档截图比对数值边界0% 不画弧100% 首尾闭合无明显叠痕放大 400% 观察 12 点钟接缝动画连续性快速连发不出现回跳最终停在最后一个值100ms 内连发 5 个不同值空闲开销静止时 CPU 接近 0隐藏后仍接近 0挂 10 分钟看进程占用曲线高 DPI150%/200% 缩放边缘锐利、文字不出圈切换系统缩放后重启进程主题适配深浅两套配色下轨道与文字对比度足够两套主题各截图取色器量亮度差重复创建反复 show/hide 500 次内存不持续上涨循环脚本 内存曲线答辩演示三分钟够用顺序别乱先讲为什么不用 QProgressBar一句话样式表做不出圆环和不确定态再放一张类结构图讲四层怎么分然后现场改一次 value 让大家看动画和 finished 信号最后主动抛一个坑——比如 0% 残留圆点那次——说你当时怎么定位、怎么改的。主动讲坑比讲功能更能证明这东西是你写的如果全程只演示“能转”评委一句“那 QProgressBar 加个 GIF 行不行”就能把人问住。还有个小技巧把 setValue 的调用链打在日志里setValue - stop - start - applyValue × N - finished答辩现场有人质疑帧率或者动画重入直接把日志翻出来比解释十分钟管用。这套做法我从一个设备面板项目里带出来之后就一直没丢。从那以后我每次交付自定义控件都强制自己先写验收表再写代码哪怕只写三行也比事后被测试追着问“这个在 200% 缩放下什么样”要从容得多。上面这套类结构和 paintEvent 抄过去改两个颜色就能跑缺的只是把边界一条条对一遍——希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑