简介:这是一份面向Qt初学者与界面开发者的自定义控件实践资源,围绕paintEvent绘制电池进度条展开,帮助读者理解QPainter绘图、电量比例填充、颜色状态判断与QPropertyAnimation动画更新等核心机制,适合作为图形界面编程的入门练手项目。压缩包共8个文件,约7KB,以3个cpp与2个h源文件为主体,配合1个pro工程文件、1个ui界面文件及1个user配置,结构精简,便于直接编译运行与二次修改。已有501人学习下载,说明其作为小型示例具有一定参考价值。资源将绘制逻辑与动画逻辑分离,把颜色判断封装为独立方法,读者可从中掌握低电量警示色、正常色与过渡色的切换思路,并了解避免在paintEvent中重复计算、缓存结果以优化性能的做法,为后续开发更复杂的自绘控件打下基础。
1. 电池进度条不是画个矩形:从 paintEvent 说起
很多同学接到「Qt 绘制电池进度条」这个题目,第一反应是拖一个 QProgressBar 改改样式表就交差。真到答辩现场,导师一句「电量低于 20% 怎么变色」「充电时那个闪电怎么闪」「圆角怎么保证不糊」就能把人问住。这套方案要解决的就是这类问题:不依赖任何第三方控件,纯靠 QWidget 加 paintEvent 手绘一个可复用、可配置、能应对答辩追问的电池电量组件。适合正在做 Qt 课程设计、毕设界面模块,或者需要在嵌入式 HMI 上做轻量自绘控件的同学。核心关键词就一个:qt 自绘。下面从需求、架构、实现到验收,把这块电池完整拆开。
2. 需求拆解与技术选型:为什么不用 QProgressBar
2.1 先把需求钉死,别急着写 paintEvent
自绘控件最容易翻车的地方不是画不出来,而是画之前没想清楚要画成什么样。我一般会先列一张需求清单,把「必须做」和「可以不做」分开,避免写到一半发现架构撑不住。
| 需求项 | 具体要求 | 优先级 |
|---|---|---|
| 电量显示 | 0~100 整数,支持小数电量(如 87.5%) | 必须 |
| 颜色分级 | 正常绿、低电黄、危险红三档可配阈值 | 必须 |
| 充电状态 | 充电时显示闪电图标并做呼吸动画 | 必须 |
| 圆角与边框 | 电池外壳圆角、内部填充圆角,缩放不糊 | 必须 |
| 尺寸自适应 | 任意宽高比下比例协调,不拉伸变形 | 必须 |
| 主题切换 | 深色/浅色两套配色 | 可选 |
| 数值动画 | 电量变化时平滑过渡而非跳变 | 可选 |
这张表看着朴素,但它决定了后面类的接口设计。比如「颜色分级阈值可配」意味着构造函数或 setter 里要暴露 lowThreshold、criticalThreshold 两个参数;「充电呼吸动画」意味着类里得有一个 QTimer 和一个 phase 变量,paintEvent 里根据 phase 算透明度。需求没钉死,接口就会反复改,这是血泪经验。
2.2 为什么放弃 QProgressBar 和 QStyle
QProgressBar 加 QSS 确实能做出「像电池」的效果,但它的本质是一条进度槽,圆角、内边距、充电图标这些都得靠 border-image 拼图,一旦控件尺寸变化,九宫格拉伸就容易糊。更关键的是,QSS 对「电池头部那个小凸起」这种非矩形轮廓几乎无解,只能贴图。
QStyle 自绘是另一条路,重写 drawControl 能接管绘制,但 QStyle 的定位是「给所有同类控件提供统一外观」,你为了一个电池去动全局样式,耦合太重,答辩时被问「你这个改动影响其他控件吗」就不好答。
所以选型结论很明确:继承 QWidget,重写 paintEvent,用 QPainter 的路径绘制(QPainterPath)画外壳和填充,用 QPropertyAnimation 或 QTimer 驱动动画。这样做的好处是绘制逻辑完全可控、无外部资源依赖、任意尺寸矢量清晰,缺点是所有细节都得自己算——但这恰恰是答辩时能讲出东西的地方。
2.3 类结构设计:一个控件类加一个配置结构
我一般把电池控件拆成两部分:BatteryConfig 结构体存所有可配参数,BatteryWidget 负责绘制和状态管理。这样配置和逻辑分离,换主题、调阈值不用动绘制代码。
// batteryconfig.h struct BatteryConfig { int lowThreshold = 30; // 低于此值显示黄色 int criticalThreshold = 15; // 低于此值显示红色 QColor normalColor = QColor(76, 175, 80); // 正常绿 QColor lowColor = QColor(255, 193, 7); // 低电黄 QColor criticalColor = QColor(244, 67, 54); // 危险红 QColor shellColor = QColor(120, 120, 120); // 外壳描边 QColor emptyColor = QColor(230, 230, 230); // 空槽底色 int shellRadius = 6; // 外壳圆角半径 int fillMargin = 3; // 填充与外壳的内边距 bool showPercent = true; // 是否在内部显示百分比文字 };这个结构体的设计要点在于:所有颜色和阈值都有默认值,调用方可以只改关心的字段。shellRadius 和 fillMargin 分开,是因为外壳圆角和内部填充圆角需要联动计算——填充圆角通常等于外壳圆角减去内边距,否则内填充会顶到外壳圆角外面,视觉上「漏出来」。这个细节不写代码时想不到,画出来一眼就丑。
3. 绘制核心实现:paintEvent 里的几何计算与分层
3.1 坐标系与几何分解:把电池拆成四个矩形
paintEvent 里第一件事不是 setPen,而是算几何。电池的视觉结构可以拆成:外壳矩形、电池头部凸起、内部空槽、电量填充。四个区域的坐标都基于控件当前 rect 推导,这样任意尺寸都能自适应。
void BatteryWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿,圆角不糊的关键 const QRectF full = rect(); const qreal headW = full.width() * 0.06; // 头部凸起宽度占 6% const qreal headH = full.height() * 0.36; // 头部高度占 36% const qreal gap = 2.0; // 外壳与头部之间的间隙 // 外壳区域:右侧留出头部宽度和间隙 QRectF shell(full.left(), full.top(), full.width() - headW - gap, full.height()); // 头部凸起:垂直居中 QRectF head(shell.right() + gap, full.top() + (full.height() - headH) / 2.0, headW, headH); drawShell(painter, shell, head); drawFill(painter, shell); if (m_charging) drawBolt(painter, shell); if (m_config.showPercent) drawPercentText(painter, shell); }这段代码的关键决策有三个。第一,头部宽度和高度用百分比而不是固定像素,保证控件放大缩小时比例不变;第二,外壳矩形主动减去头部宽度和间隙,避免头部和外壳重叠;第三,绘制顺序是外壳→填充→闪电→文字,后画的盖住先画的,文字永远在最上层。参数 headW 取 6% 是试出来的经验值,太小像天线,太大像尾巴,5%~8% 之间视觉最舒服。
3.2 外壳与填充:QPainterPath 画圆角,裁剪保证不溢出
外壳用 QPainterPath 分别对外壳矩形和头部矩形加圆角,然后合并成一个路径描边。填充部分则用「裁剪 + 矩形」的方式,保证电量条永远不超出外壳内壁。
void BatteryWidget::drawShell(QPainter &p, const QRectF &shell, const QRectF &head) { QPainterPath path; path.addRoundedRect(shell, m_config.shellRadius, m_config.shellRadius); path.addRoundedRect(head, 2, 2); // 头部小圆角 p.setPen(QPen(m_config.shellColor, 2)); // 2px 描边 p.setBrush(Qt::NoBrush); p.drawPath(path); } void BatteryWidget::drawFill(QPainter &p, const QRectF &shell) { const qreal m = m_config.fillMargin; QRectF inner = shell.adjusted(m, m, -m, -m); // 内缩,留出内边距 if (inner.width() <= 0 || inner.height() <= 0) return; // 按电量比例计算填充宽度 qreal ratio = qBound(0.0, m_value / 100.0, 1.0); QRectF fillRect(inner.left(), inner.top(), inner.width() * ratio, inner.height()); QPainterPath clip; qreal r = qMax(0.0, m_config.shellRadius - m); // 内圆角 = 外圆角 - 内边距 clip.addRoundedRect(inner, r, r); p.save(); p.setClipPath(clip); // 裁剪,填充绝不溢出内壁 p.setPen(Qt::NoPen); p.setBrush(currentColor()); // 根据电量返回绿/黄/红 p.drawRect(fillRect); p.restore(); }这里有两个容易踩的点。其一,内圆角半径必须用 shellRadius 减 fillMargin,如果直接沿用外壳半径,填充的圆角会比外壳大,视觉上「鼓出来」;其二,setClipPath 之后一定要 restore,否则后续绘制闪电和文字都会被裁掉。currentColor() 是个小函数,按 value 与两个阈值比较返回对应颜色,逻辑简单但别写反比较方向——我见过把value < critical写成critical < value的,结果满电显示红色,答辩现场很尴尬。
3.3 充电闪电与呼吸动画:QTimer 驱动 phase,别用 QPropertyAnimation 硬套
充电图标用 QPainterPath 画一个多边形闪电,呼吸效果靠一个 0~1 循环的 phase 变量控制透明度。这里我选 QTimer 而不是 QPropertyAnimation,原因是呼吸动画是无限循环且只改一个浮点值,QTimer 每 40ms 触发一次 update() 更直接,QPropertyAnimation 反而要额外定义一个属性、写 getter/setter,绕。
// 构造函数里 m_animTimer = new QTimer(this); m_animTimer->setInterval(40); // 约 25fps,够用且省 CPU connect(m_animTimer, &QTimer::timeout, this, [this]() { m_phase += 0.05; if (m_phase > 1.0) m_phase = 0.0; update(); // 只重绘,不重算布局 }); void BatteryWidget::setCharging(bool on) { m_charging = on; if (on) m_animTimer->start(); else { m_animTimer->stop(); m_phase = 0.0; } update(); } void BatteryWidget::drawBolt(QPainter &p, const QRectF &shell) { // 闪电多边形,坐标相对外壳中心归一化后缩放 QPolygonF bolt; bolt << QPointF(0.55, 0.15) << QPointF(0.35, 0.52) << QPointF(0.48, 0.52) << QPointF(0.42, 0.85) << QPointF(0.65, 0.45) << QPointF(0.52, 0.45); // 映射到外壳区域 QTransform t; t.translate(shell.center().x(), shell.center().y()); t.scale(shell.width(), shell.height()); t.translate(-0.5, -0.5); QPolygonF mapped = t.map(bolt); QColor c = Qt::white; c.setAlphaF(0.55 + 0.45 * qAbs(qSin(m_phase * M_PI))); // 呼吸透明度 p.setPen(Qt::NoPen); p.setBrush(c); p.drawPolygon(mapped); }闪电坐标用 0~1 归一化再映射,好处是换任何尺寸的电池都不用改坐标。呼吸透明度用qAbs(qSin(phase * PI))而不是线性增减,是因为正弦曲线两端变化慢、中间快,视觉上更像「呼吸」而不是「闪烁」。定时器间隔 40ms 是权衡:20ms 更顺滑但 CPU 占用翻倍,60ms 开始有顿挫感,40ms 在嵌入式板子上也扛得住。
3.4 百分比文字与高 DPI:字体度量与 devicePixelRatio
文字绘制看似简单,但高 DPI 屏上容易糊或者偏。正确做法是用 QFontMetricsF 算文字包围盒,居中绘制,并且依赖 Qt 的自动高 DPI 缩放(Qt5.6 之后默认开启),不要手动乘 devicePixelRatio,否则会二次放大。
void BatteryWidget::drawPercentText(QPainter &p, const QRectF &shell) { QFont f = font(); f.setPixelSize(qMax(8, int(shell.height() * 0.42))); // 字号随高度缩放 f.setBold(true); p.setFont(f); QString txt = QString::number(qRound(m_value)) + "%"; QFontMetricsF fm(f); QRectF tb = fm.boundingRect(txt); tb.moveCenter(shell.center()); // 文字颜色根据填充是否覆盖文字位置决定,保证对比度 p.setPen(isTextOnFill(tb, shell) ? Qt::white : Qt::black); p.drawText(tb, Qt::AlignCenter, txt); }字号取外壳高度的 42% 是试出来的,太小看不清,太大顶到边框。isTextOnFill 判断文字中心是否落在填充区域内,落在里面用白字,落在空槽用黑字,避免「绿底黑字看不清」这种低级问题。高 DPI 这块,只要在 main 里设置QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling)(Qt5)或直接用 Qt6 默认行为,paintEvent 里按逻辑坐标画就行,别自己乘缩放因子。
4. 状态管理与对外接口:让控件能被别人用起来
4.1 属性、信号与槽:value 变化要发信号
一个自绘控件如果只能自己玩,价值就有限。对外至少暴露 setValue、value、setCharging、setConfig 四个接口,value 变化时发 valueChanged 信号,方便外部联动(比如低电时弹提示)。
class BatteryWidget : public QWidget { Q_OBJECT public: explicit BatteryWidget(QWidget *parent = nullptr); void setValue(double v); // 0~100,内部 clamp double value() const { return m_value; } void setCharging(bool on); void setConfig(const BatteryConfig &cfg); QSize sizeHint() const override; // 给布局一个合理默认尺寸 signals: void valueChanged(double v); void lowPowerTriggered(); // 进入低电阈值时发一次 private: double m_value = 0.0; bool m_charging = false; double m_phase = 0.0; BatteryConfig m_config; QTimer *m_animTimer = nullptr; bool m_lowFired = false; // 防止低电信号重复发 };setValue 里要做三件事:clamp 到 0~100、判断是否跨过低电阈值决定发不发 lowPowerTriggered、调用 update()。m_lowFired 这个标志位是防抖用的——电量在阈值附近抖动时,不加标志会连续发信号,外部弹窗能弹一串。sizeHint 返回比如 QSize(120, 50),让布局系统有个参考,不然控件可能被压成一条线。
4.2 主题切换与配置热更新
setConfig 里改完参数必须调用 update(),否则界面不刷新。如果配置里颜色变了但没触发重绘,用户会以为「设置没生效」,这是自绘控件最常见的「假 bug」。另外建议在 setConfig 里做一次参数合法性校验,比如 criticalThreshold 必须小于 lowThreshold,否则颜色分级逻辑会错乱。
void BatteryWidget::setConfig(const BatteryConfig &cfg) { m_config = cfg; if (m_config.criticalThreshold > m_config.lowThreshold) { std::swap(m_config.criticalThreshold, m_config.lowThreshold); // 兜底纠正 } update(); // 必须,否则配置不生效 }深色主题就是把 shellColor、emptyColor 换成深灰系,normalColor 稍微提亮,其余逻辑不变。因为颜色全在配置结构里,切主题只是换一个 BatteryConfig 实例,绘制代码一行不用动——这就是前面把配置抽出来的回报。
5. 避坑与排查:自绘控件最容易翻车的五个点
5.1 圆角发虚、边缘有锯齿
现象:圆角处能看到毛边,放大后尤其明显。原因:没开抗锯齿,或者开了抗锯齿但画笔宽度是奇数且坐标落在半像素上。解决:paintEvent 开头setRenderHint(QPainter::Antialiasing, true),描边宽度用偶数(2px),矩形坐标尽量对齐到 0.5 的整数倍,避免半像素模糊。
5.2 控件缩放后比例失调
现象:拉宽控件,电池头部变得又扁又长。原因:头部宽高用了固定像素,或者只按宽度算没按高度约束。解决:所有几何量都用相对比例(百分比)推导,并在 resizeEvent 里不做额外计算,全部放到 paintEvent 里按当前 rect 现算,保证每次重绘都基于最新尺寸。
5.3 动画卡顿或 CPU 飙高
现象:充电呼吸动画一顿一顿,或者任务管理器里 CPU 占用明显。原因:定时器间隔太短(如 10ms)且每帧都触发完整重绘,或者忘了在停止充电时 stop 定时器。解决:间隔取 40~50ms,动画只改 phase 变量后 update(),不重算布局;setCharging(false) 时务必 stop,否则后台一直空转。
5.4 填充溢出外壳或文字被裁
现象:电量 100% 时填充条顶出圆角,或者百分比文字显示不全。原因:填充没做 clipPath 裁剪,或者文字包围盒没留内边距。解决:填充前 setClipPath 内缩后的圆角路径,绘制后 restore;文字用 QFontMetricsF 算实际包围盒再居中,别用固定矩形硬套。
5.5 高 DPI 屏上整体偏小或模糊
现象:在 4K 屏或缩放 150% 的环境下,控件看起来比设计小一圈,或者边缘糊。原因:手动乘了 devicePixelRatio 导致二次缩放,或者没启用高 DPI 支持。解决:Qt5 在 main 里设 AA_EnableHighDpiScaling,Qt6 默认即可;paintEvent 里一律用逻辑坐标,不手动乘缩放因子;图片资源才需要考虑 DPR,纯矢量绘制不用。
6. 验收与答辩:怎么证明这块电池是「对的」
验收自绘控件,光看截图不够,得有可复现的检查项。我一般准备一张验收表,逐项过一遍,答辩时直接拿这张表讲,比空口说「我实现了」有说服力。
| 验收项 | 操作方法 | 合格标准 |
|---|---|---|
| 电量边界 | setValue(-5)、setValue(105) | 分别显示 0% 和 100%,不崩溃 |
| 颜色分级 | 依次设 50、25、10 | 绿、黄、红三色正确切换 |
| 充电动画 | setCharging(true) 观察 10 秒 | 闪电呼吸平滑,无闪烁跳变 |
| 尺寸自适应 | 拖拽窗口从 80x30 到 400x150 | 比例协调,圆角不糊,文字不溢出 |
| 高 DPI | 系统缩放调至 150% 重启 | 清晰无模糊,尺寸符合预期 |
| 主题切换 | 切换深色配置 | 颜色即时生效,无需重启 |
答辩时最容易被追问的三个问题,提前准备好答案:一是「为什么不用 QProgressBar」,答自绘可控性、无贴图依赖、任意尺寸矢量清晰;二是「动画为什么用 QTimer 不用 QPropertyAnimation」,答单值循环动画用定时器更直接,省去属性定义;三是「高 DPI 怎么处理的」,答依赖框架自动缩放加逻辑坐标绘制。把这三个答顺,基本就稳了。
还有一个实操技巧:答辩演示前,把电量从 100 拖到 0 走一遍完整过程,让评委看到颜色过渡和低电信号触发,比静态截图有说服力得多。演示环境的分辨率和缩放比例提前调成和开发机一致,避免现场「怎么和你说得不一样」的翻车。
从那以后我每次做自绘控件,都强制先写需求表和验收表再动手,画完对着表逐项打勾,不靠「看着差不多」过关。希望帮到你。
本文还有配套的精品资源,点击获取