☰
Qt自绘电池进度条:paintEvent实现圆角渐变与充电动画
2026/10/21 12:08:19 网站建设 项目流程

简介:本资源是一份面向Qt初学者与界面开发者的自定义控件实战示例,围绕paintEvent绘制电池进度条展开,帮助读者理解QPainter绘图、电量状态反馈与动画更新的完整实现思路。包内共8个文件,以3个cpp源文件与2个h头文件为核心,配合1个pro工程文件、1个ui界面文件和1个user配置,压缩包约7KB,结构精简,便于直接编译运行与二次修改。项目通过继承QWidget并重写paintEvent,使用fillRect、drawRect等方法绘制电池外壳与电量区域,并依据电量比例切换正常色、过渡色与警示色;同时借助QPropertyAnimation驱动电量值变化,每次更新触发重绘,形成平滑动画效果。代码将绘制逻辑、动画逻辑与颜色判断分离封装,兼顾可读性与性能,适合作为Qt图形界面编程的入门练习与课程设计参考。目前已有501人学习下载。

1. 用 paintEvent 手搓电池进度条:为什么我不推荐 QProgressBar

去年帮一个做嵌入式上位机的朋友改界面,他那个电池电量显示用的是 QProgressBar 套了个样式表,结果在 4K 屏上圆角糊成一团,低电量红色阈值还得靠定时器硬刷 palette。我接手后直接把整个控件删了,用 QWidget 加 paintEvent 重写,代码不到 120 行,圆角、渐变、电量分段配色、充电动画全齐了。Qt 里画电池进度条这件事,QProgressBar 能应付 80% 的常规场景,但一旦涉及异形边框、多段阈值配色、动态充电指示,还是得回到 paintEvent 里自己拿 QPainter 一笔一笔画。这篇笔记就拆一个我实际在用的电池进度条控件,从坐标计算到抗锯齿参数,把每个坑都摆出来。适合已经会写 Qt Widgets、但一碰到自绘就发怵的开发者,也适合想把手头进度条从“能用”拉到“能看”的熟手。

2. 拆解 paintEvent 绘制流程:从 QWidget 到电池轮廓

2.1 为什么选 QWidget 自绘而不是 QProgressBar

QProgressBar 的本质是一个带样式的矩形填充条,它的绘制逻辑被 Qt 样式系统锁死了。你可以在 QSS 里写border-radius、background-color,但一旦电池需要“正极凸起”这种非矩形轮廓,QSS 就无能为力了。更麻烦的是,QProgressBar 的 chunk 宽度是按百分比算的,低电量时你想让填充部分变成红色、并且带一个闪烁的充电图标,就得子类化 QProgressBar 再重写 paintEvent,绕了一圈还是回到自绘。

直接继承 QWidget 的好处是:整个绘制区域完全由你控制,paintEvent里拿到的 QPainter 没有任何预设样式干扰。我一般会在构造函数里做三件事:设置setMinimumSize防止控件被压扁、开启setAttribute(Qt::WA_OpaquePaintEvent)减少重绘闪烁、把电量值用Q_PROPERTY暴露出去方便 QSS 或动画系统调用。

class BatteryBar : public QWidget { Q_OBJECT Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) public: explicit BatteryBar(QWidget *parent = nullptr) : QWidget(parent) { setMinimumSize(120, 48); setAttribute(Qt::WA_OpaquePaintEvent); } int value() const { return m_value; } void setValue(int v) { v = qBound(0, v, 100); if (v == m_value) return; m_value = v; update(); // 只触发重绘,不重建控件 emit valueChanged(v); } signals: void valueChanged(int); private: int m_value = 0; };

这段代码里qBound把电量钳在 0 到 100,避免外部传入异常值导致绘制越界。update()是异步的,Qt 会合并多次调用,所以即使你每秒刷 60 次也不会把事件循环压垮。WA_OpaquePaintEvent告诉 Qt 这个控件不透明,省掉背景擦除那一步,在嵌入式设备上能明显降低 CPU 占用。

2.2 电池轮廓的坐标计算:留白、圆角与正极凸起

电池轮廓不是简单一个圆角矩形。标准画法是:主体占控件宽度的 85%,右侧留出 15% 给正极凸起。主体圆角半径取高度的 20%,正极凸起高度取主体的 40%,垂直居中。这些比例不是拍脑袋定的,是我在 1080P 和 4K 屏上反复调出来的——圆角太小显得廉价,太大在低分辨率下会糊。

void BatteryBar::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); const int w = width(), h = height(); const int bodyW = w * 0.85; const int capW = w - bodyW; const int capH = h * 0.4; const int radius = h * 0.2; // 电池主体路径 QPainterPath body; body.addRoundedRect(QRectF(0, 0, bodyW, h), radius, radius); // 正极凸起路径 QPainterPath cap; cap.addRoundedRect(QRectF(bodyW, (h - capH) / 2.0, capW, capH), capW * 0.3, capW * 0.3); // 先画外框描边 p.setPen(QPen(QColor(80, 80, 80), 2)); p.setBrush(Qt::NoBrush); p.drawPath(body); p.drawPath(cap); }

QPainterPath的好处是能把多个形状合并成一个逻辑整体,后面做裁剪填充时直接p.setClipPath(body)就行,不用担心填充溢出到正极外面。addRoundedRect的半径参数如果传负数或超过半宽,Qt 会自动钳制,但为了可预期,我习惯手动算好再传。正极凸起的圆角用capW * 0.3是为了让它看起来像个金属帽,而不是一个方块。

2.3 电量填充与阈值配色:三色分段的实现

电量填充我分三档:0% 到 20% 红色,20% 到 50% 橙色,50% 以上绿色。这个阈值不是固定的,实际项目里我会把它做成QVector<QPair<int, QColor>>成员,方便从配置文件读。填充区域的高度要比主体略小,留出 2px 内边距,否则填充色会盖住描边,视觉上像溢出来。

// 填充区域:主体内缩 2px QRectF fillRect = QRectF(2, 2, (bodyW - 4) * m_value / 100.0, h - 4); QPainterPath fillPath; fillPath.addRoundedRect(fillRect, radius - 2, radius - 2); QColor fillColor; if (m_value <= 20) fillColor = QColor(220, 60, 60); else if (m_value <= 50) fillColor = QColor(230, 150, 40); else fillColor = QColor(60, 180, 90); p.setClipPath(body); // 裁剪到主体内 p.fillPath(fillPath, fillColor); p.setClipping(false);

这里有个细节:fillRect的宽度是按m_value / 100.0算的,但圆角半径不能直接用radius,否则电量很低时圆角会把填充区域“吃掉”,看起来像没电。我一般把填充圆角设为radius - 2,并且当m_value小于 5 时直接不画填充,只留一个空框,避免出现一个奇怪的小圆点。

3. 把控件接进真实项目:信号、动画与高 DPI 适配

3.1 用 Q_PROPERTY 驱动平滑动画

直接setValue会让电量条跳变,体验很硬。我通常在外面套一个QPropertyAnimation,把value属性作为动画目标,持续时间 300ms,缓动曲线用QEasingCurve::OutCubic。这样电量从 80 掉到 15 的时候,填充条是滑下去的,不是闪一下。

QPropertyAnimation *anim = new QPropertyAnimation(battery, "value"); anim->setDuration(300); anim->setEasingCurve(QEasingCurve::OutCubic); anim->setStartValue(battery->value()); anim->setEndValue(newValue); anim->start(QAbstractAnimation::DeleteWhenStopped);

Q_PROPERTY里必须带NOTIFY信号,否则动画系统无法感知属性变化。DeleteWhenStopped让动画对象在结束后自动释放,避免每次更新都 new 一个导致内存缓慢增长。如果电量变化频繁(比如每秒一次),我会把 duration 降到 150ms,否则动画会排队,看起来反而卡顿。

3.2 高 DPI 下的坐标缩放与 devicePixelRatio

在 4K 屏上,Qt 默认会做逻辑像素到物理像素的映射,width()返回的是逻辑宽度。如果你直接用width()算坐标,在devicePixelRatio为 2 的屏幕上,绘制结果会被放大两倍,圆角看起来就糊了。解决办法是在paintEvent里用QPainter的device()拿实际缩放比,或者更简单:所有坐标都用qreal算,最后交给 QPainter 去处理。

qreal dpr = devicePixelRatioF(); p.scale(1.0 / dpr, 1.0 / dpr); // 仅在手动管理像素时使用

不过大多数情况下不需要手动 scale,Qt 的QPainter已经帮你做了。真正要注意的是setMinimumSize传的是逻辑像素,在 4K 屏上控件实际会占更多物理像素,所以圆角半径要用qreal而不是int,否则h * 0.2在 h 为奇数时会截断,导致左右圆角不对称。

3.3 充电状态的动态指示:画一个闪电符号

充电时我一般在电池主体中间画一个闪电符号,用QPainterPath三个点连成折线,填充黄色。闪电的坐标要随控件大小缩放,不能写死。

if (m_charging) { QPainterPath bolt; qreal cx = bodyW / 2.0, cy = h / 2.0; qreal s = h * 0.3; bolt.moveTo(cx + s * 0.2, cy - s); bolt.lineTo(cx - s * 0.3, cy + s * 0.1); bolt.lineTo(cx + s * 0.1, cy + s * 0.1); bolt.lineTo(cx - s * 0.2, cy + s); bolt.lineTo(cx + s * 0.3, cy - s * 0.1); bolt.lineTo(cx - s * 0.1, cy - s * 0.1); bolt.closeSubpath(); p.fillPath(bolt, QColor(255, 210, 60)); }

闪电的六个点是我用坐标纸画出来的,比例s = h * 0.3保证在不同尺寸下都不会超出电池主体。closeSubpath让路径闭合,填充时不会出现缺口。如果充电状态需要闪烁,我会在外面挂一个QTimer,每 500ms 切换一次m_charging并update(),而不是在paintEvent里做定时逻辑——绘制函数只负责画,不负责状态调度。

4. 避坑排查:自绘电池条最容易翻车的五个地方

4.1 现象:圆角边缘有锯齿,开了抗锯齿也没用

原因:QPainter::Antialiasing只对路径描边和填充生效,但如果你的paintEvent里先画了一个不透明的背景矩形,再画圆角,背景矩形的直角边缘会露出来。解决:要么不画背景,要么把背景也做成圆角路径,并且确保WA_OpaquePaintEvent没有和样式表冲突。我一般直接在paintEvent开头p.fillRect(rect(), Qt::transparent)清一次,但前提是控件本身不设背景色。

4.2 现象:电量 0% 时填充区域还残留一条细线

原因:fillRect宽度算出来是 0,但QPainterPath::addRoundedRect在宽度为 0 时仍会画出一条线。解决:在填充前加判断if (m_value > 0),或者把填充宽度钳到最小 1px 再配合裁剪。我习惯直接跳过 0% 的填充绘制,只画外框。

4.3 现象:在嵌入式 ARM 板上跑,界面刷新明显卡顿

原因:每帧都在paintEvent里重新构建QPainterPath,路径三角化开销在低端 CPU 上很可观。解决:把电池主体和正极的路径缓存为成员变量,只在resizeEvent里重建,paintEvent直接复用。填充路径因为随电量变化,没法缓存,但它的形状简单,开销可以接受。

4.4 现象:QSS 设置的border和自绘描边打架

原因:QWidget 子类默认会走样式系统,如果你在 QSS 里写了BatteryBar { border: 1px solid red; },Qt 会在paintEvent之前先画一遍样式边框,和你的描边重叠。解决:在构造函数里setStyleSheet("background: transparent;")或者重写paintEvent时不要调用QWidget::paintEvent。我一般直接不写 QSS,所有视觉都在paintEvent里控制。

4.5 现象:动画过程中电量值跳变,填充条闪烁

原因:QPropertyAnimation每帧都会调setValue,而setValue里调了update(),如果动画帧率和屏幕刷新率不同步,就会出现撕裂。解决:把setValue里的update()改成update(rect())限定重绘区域,或者用QTimer手动插值,把动画步进和paintEvent解耦。我通常用前者,简单且有效。

5. 进阶技巧:用 QPainter 的合成模式做发光效果

电池低电量时,我想让红色填充带一点“呼吸”发光,但又不想引入 QGraphicsEffect——那东西在嵌入式上性能很差。后来发现直接用QPainter::CompositionMode_Plus叠一层半透明红色就能模拟。

if (m_value <= 20) { p.setCompositionMode(QPainter::CompositionMode_Plus); QColor glow(120, 0, 0, 80); p.fillPath(fillPath, glow); p.setCompositionMode(QPainter::CompositionMode_SourceOver); }

CompositionMode_Plus会把源颜色和目标颜色相加,红色叠加后亮度提升,看起来像在发光。alpha 值我设 80,太高会过曝,太低没效果。这个技巧在深色背景上尤其明显,浅色背景上反而会发灰,所以我会根据背景色动态调整 alpha。

另一个我常用的验证方法是:把paintEvent里的绘制结果渲染到一个QImage上,然后保存成 PNG,用脚本对比不同电量下的像素颜色。这样能在不启动完整界面的情况下回归测试配色阈值。

QImage img(size(), QImage::Format_ARGB32); img.fill(Qt::transparent); QPainter p(&img); // ... 同样的绘制逻辑 ... p.end(); img.save("battery_15.png");

从那以后我每次改电池控件的绘制代码,都强制走一遍这个离屏渲染流程,把 0%、20%、50%、100% 四个关键帧存下来肉眼比对。血泪经验是:在 4K 屏上看着完美的圆角,到了 720P 的嵌入式屏上可能糊成一片,只有离屏渲染才能提前发现。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询