Qt 自绘电池进度条:从 paintEvent 到可复用仪表盘控件
简介这份资源面向Qt界面编程初学者与需要自定义控件的开发者围绕paintEvent事件处理函数实现一个可显示电量、区分状态并带过渡动画的电池进度条控件。包内共8个文件以3个cpp源文件与2个h头文件为核心配合1个pro工程文件、1个ui界面文件和1个user配置压缩包约7KB结构精简便于直接编译运行与二次修改。内容涵盖QPainter绘制电池外壳与电量区域、按电量比例切换正常色与警示色、借助QPropertyAnimation驱动电量变化并触发重绘以及绘制逻辑与动画逻辑分离、缓存计算结果等性能优化思路。已有501人学习适合想掌握Qt二维绘图、状态反馈与属性动画的读者可作为课程设计或界面组件练习的参考实例。1. 电池进度条这件小事藏着 Qt 自绘控件的全部基本功很多人第一次在 Qt 里做电池电量显示第一反应是找个 QProgressBar 改改样式表或者干脆贴一张 PNG 让 UI 切图。结果真到项目里就翻车了进度条要圆角、要渐变、要显示充电闪电图标、要在低电量时变红闪烁样式表写了几百行还是对不齐切图方案遇到不同 DPI 屏幕直接糊成一片。这时候绕不开的就是paintEvent自绘。这个项目干的事情很纯粹用 Qt 的QWidget加paintEvent从零画出一个电池进度条控件。它适合两类人——一类是刚学 Qt 想搞懂自绘机制的新手另一类是手里有实际仪表盘、电量面板、状态指示器需求需要一套可复用绘制模板的开发者。整个控件的核心就三件事画电池外壳、按百分比填充电量、根据状态切换颜色和图标。听起来简单但圆角矩形、抗锯齿、坐标计算、重绘触发这几块每一块都有坑。下面我按拆项目的顺序把它从结构到代码再到改造讲透。2. 控件结构拆解一个 QWidget 子类怎么撑起整个电池2.1 类设计与成员变量规划这个控件的本质是一个继承自QWidget的自定义类通常命名为BatteryWidget或BatteryProgressBar。它不依赖任何第三方库只用 QtGui 和 QtWidgets 模块。核心成员变量一般这么设计class BatteryWidget : public QWidget { Q_OBJECT public: explicit BatteryWidget(QWidget *parent nullptr); void setValue(int value); // 设置电量 0-100 void setCharging(bool charging); // 设置充电状态 int value() const { return m_value; } protected: void paintEvent(QPaintEvent *event) override; QSize sizeHint() const override; // 给布局一个推荐尺寸 private: int m_value 60; // 当前电量百分比 bool m_charging false; // 是否充电中 QColor m_lowColor QColor(0xE7, 0x4C, 0x3C); // 低电量红 QColor m_normalColor QColor(0x2E, 0xCC, 0x71); // 正常绿 QColor m_chargeColor QColor(0x34, 0x98, 0xDB); // 充电蓝 };这里有几个设计决策值得说。第一m_value用int而不是float因为电量百分比本身就是整数语义用浮点反而引入精度比较的麻烦。第二颜色不写死在paintEvent里而是提成成员变量这样后续换主题、做暗色模式只需要改这几个值不用动绘制逻辑。第三sizeHint()一定要重写否则控件放进布局里会被压成 0 尺寸或者撑得莫名其妙这是新手最常踩的坑之一。setValue里必须做范围钳制和重绘触发常见写法是void BatteryWidget::setValue(int value) { value qBound(0, value, 100); // 钳制到 0-100 if (value m_value) return; // 值没变不重绘省性能 m_value value; update(); // 异步请求重绘不要直接调 paintEvent }注意这里用的是update()而不是repaint()。update()会把重绘请求合并到事件循环里多次调用只触发一次paintEventrepaint()是同步立即重绘频繁调用会卡界面。这个区别在电量这种高频更新的场景里很关键。2.2 目录结构与工程组织一个能跑起来的最小工程目录结构通常是这样BatteryDemo/ ├── BatteryDemo.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── mainwindow.h / .cpp # 主窗口放控件和滑块 ├── batterywidget.h / .cpp # 电池控件本体 └── resources/ └── bolt.png # 充电闪电图标可选.pro文件里只需要基础的模块声明QT core gui widgets TARGET BatteryDemo TEMPLATE app SOURCES main.cpp mainwindow.cpp batterywidget.cpp HEADERS mainwindow.h batterywidget.h如果你用 CMake等价写法是find_package(Qt6 COMPONENTS Widgets REQUIRED)然后target_link_libraries(BatteryDemo PRIVATE Qt6::Widgets)。两种构建方式都行qmake 对新手更友好CMake 更适合往大项目里集成。把控件单独拆成一对.h/.cpp是为了让它能被复制到别的工程里直接用这也是自绘控件该有的组织方式——高内聚零外部依赖。2.3 绘制区域的坐标计算paintEvent里第一件事不是画是算坐标。控件给到的rect()是外框但电池有外壳边框、有内部填充区、有顶部正极凸起这些都要在有限空间里排布。常见做法是留出边距把可用区域缩进void BatteryWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 开抗锯齿 // 整体留 4px 边距顶部再留出正极凸起的高度 const int margin 4; const int capW 8; // 正极凸起宽度 const int capH 6; // 正极凸起高度 QRect bodyRect rect().adjusted(margin, margin capH, -margin, -margin); // bodyRect 就是电池主体外壳的矩形 }adjusted的四个参数分别是左、上、右、下的内缩量。顶部多缩capH是为了给正极凸起腾位置。这里用int而不是qreal是因为控件尺寸本身就是整数像素用浮点反而要处理半像素对齐问题。抗锯齿一定要开否则圆角会有明显锯齿这是自绘控件观感的第一道门槛。3. 核心绘制链路从外壳到电量填充的完整实现3.1 画电池外壳与正极凸起外壳是两段式主体圆角矩形加顶部一个小凸起。绘制顺序很重要先画凸起再画主体或者反过来都行但填充色和边框色要分开设置// 1. 画正极凸起一个小圆角矩形 QRect capRect(bodyRect.right() - bodyRect.width() / 2 - capW / 2, bodyRect.top() - capH, capW, capH); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0x95, 0xA5, 0xA6)); painter.drawRoundedRect(capRect, 2, 2); // 2. 画电池主体外壳 painter.setPen(QPen(QColor(0x7F, 0x8C, 0x8D), 2)); // 2px 边框 painter.setBrush(QColor(0xEC, 0xF0, 0xF1)); // 浅灰底 painter.drawRoundedRect(bodyRect, 6, 6); // 6px 圆角drawRoundedRect的后两个参数是圆角半径的 x 和 y 方向值传相同值就是正圆角。正极凸起的位置计算是bodyRect水平居中宽度capW贴在主体顶部上方。这里有个细节凸起如果画在bodyRect.top()之上而bodyRect顶部已经缩进了capH那凸起正好落在控件顶部边距里不会超出控件范围。坐标算错的话凸起会被裁掉一半这是很典型的翻车点。3.2 按百分比填充电量区域电量填充是核心。思路是先算出内部可用区域外壳再内缩一圈边框宽度然后按百分比取宽度// 3. 计算内部填充区外壳内缩 2px 边框 2px 间隙 QRect innerRect bodyRect.adjusted(4, 4, -4, -4); int fillW innerRect.width() * m_value / 100; QRect fillRect(innerRect.left(), innerRect.top(), fillW, innerRect.height()); // 4. 根据状态选颜色 QColor fillColor m_charging ? m_chargeColor : (m_value 20 ? m_lowColor : m_normalColor); // 5. 用裁剪保证填充不超出圆角 painter.save(); QPainterPath clipPath; clipPath.addRoundedRect(innerRect, 4, 4); painter.setClipPath(clipPath); painter.setPen(Qt::NoPen); painter.setBrush(fillColor); painter.drawRect(fillRect); painter.restore();这段代码有几个关键点。第一fillW用整数乘法再除避免浮点误差导致最后一像素抖动。第二颜色选择用三元表达式链逻辑清晰充电优先显示蓝色否则低电量红色正常绿色。第三也是最容易被忽略的——裁剪。如果直接drawRect填充当电量接近 100% 时填充矩形的直角会盖住外壳的圆角看起来像方角溢出来。用QPainterPath加setClipPath把填充限制在圆角区域内才能保证圆角始终干净。save()和restore()成对出现避免裁剪状态污染后续绘制。3.3 充电图标与文字叠加充电状态下通常要在电池中间叠一个闪电图标或者直接画一个多边形闪电。用图片的话// 6. 充电时叠加闪电图标 if (m_charging) { QPixmap bolt(:/resources/bolt.png); if (!bolt.isNull()) { QRect iconRect(0, 0, 16, 16); iconRect.moveCenter(bodyRect.center()); painter.drawPixmap(iconRect, bolt); } } // 7. 叠加百分比文字 painter.setPen(QColor(0x2C, 0x3E, 0x50)); QFont font painter.font(); font.setPixelSize(qMax(10, bodyRect.height() / 3)); painter.setFont(font); painter.drawText(bodyRect, Qt::AlignCenter, QString::number(m_value) %);图标用moveCenter定位到电池中心比手动算坐标可靠。文字字号用bodyRect.height() / 3动态计算这样控件放大缩小时文字跟着变不会出现控件很大字很小的情况。qMax(10, ...)是兜底防止控件被压得太小时字号变成 0 或负数。文字颜色选深灰而不是纯黑在浅色底上更柔和这是 UI 细节。3.4 重绘触发与状态联动控件本身不会自己更新得靠外部驱动。典型的主窗口里放一个滑块和两个按钮// mainwindow.cpp 片段 connect(ui-slider, QSlider::valueChanged, ui-battery, BatteryWidget::setValue); connect(ui-chargeBtn, QPushButton::toggled, ui-battery, BatteryWidget::setCharging);setCharging和setValue一样内部判断值是否变化变了就update()。这样拖动滑块电池实时变化点充电按钮颜色切蓝并显示闪电。整个链路是外部改状态 → setter 触发 update() → 事件循环合并请求 → paintEvent 重绘。理解这条链路才能明白为什么不能直接在 setter 里调paintEvent——那样会绕过 Qt 的绘制调度轻则闪烁重则崩溃。4. 避坑与排查自绘控件最容易翻车的五个地方4.1 控件不显示或尺寸为 0现象把BatteryWidget放进布局运行后一片空白或者只看到一条线。原因没有重写sizeHint()布局不知道该给多大空间默认按 0 处理。解决实现sizeHint()返回一个合理尺寸比如return QSize(120, 50);同时设置setMinimumSize(60, 24)兜底。如果还是不对检查是否忘了在.pro里加widgets模块。4.2 圆角处填充溢出现象电量接近满格时填充色的直角盖住了外壳圆角看起来像方块。原因drawRect画的是直角矩形没有跟随外壳圆角裁剪。解决用QPainterPath::addRoundedRect建立裁剪路径setClipPath后再填充绘制完restore()。这是自绘圆角控件的高频坑凡是圆角容器里填内容都要考虑裁剪。4.3 高频更新导致界面卡顿现象电量数据每秒来好几次界面拖动变卡。原因每次 setValue 都触发重绘且可能用了repaint()同步重绘。解决setter 里先判断值是否真的变了没变直接 return用update()而非repaint()如果数据源频率极高可以在外部做节流比如 100ms 合并一次再调 setValue。4.4 高 DPI 屏幕下模糊或错位现象在缩放 150% 的屏幕上边框变粗、文字发虚。原因坐标用了硬编码像素没考虑设备像素比。解决开启QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)Qt5或直接用 Qt6 默认行为绘制时尽量用相对尺寸基于rect()计算而不是写死数字图标用 SVG 或提供多倍图。4.5 颜色状态切换不生效现象点了充电按钮颜色没变蓝。原因setCharging里忘了调update()或者判断条件写反了。解决检查 setter 是否在状态变化后触发重绘在paintEvent里打印m_charging确认值传进来了确认颜色选择的三元表达式优先级正确必要时加括号。5. 二次开发改造把电池控件变成通用仪表盘组件这个控件的价值不在于电池本身而在于它是一套自绘模板。我一般会从三个方向改造它。第一个方向是参数化外观。把圆角半径、边框宽度、颜色、是否显示文字都提成Q_PROPERTY这样就能在 Qt Designer 里直接调也能配合 QSS 做主题切换。比如加一个setBorderRadius(int r)内部存成员变量paintEvent里用m_radius替代写死的 6。改造后同一个类能画出圆角电池、方角电池、胶囊电池三种风格。第二个方向是换绘制内容。把「按百分比填充」抽象成一个虚函数drawContent(QPainter, const QRect)电池只是其中一种实现。继承出CircularGauge画圆形仪表盘继承出SegmentBar画分段进度条共用外壳、裁剪、文字叠加这些逻辑。这是从「一个控件」到「一套控件库」的关键一步。第三个方向是加动画。电量跳变时直接刷新很生硬可以用QPropertyAnimation对m_value做插值让填充宽度平滑过渡。做法是把m_value改成qreal并加Q_PROPERTY(qreal value READ value WRITE setValue)然后动画驱动这个属性。注意动画期间setValue会被高频调用前面说的「值没变不重绘」判断要保留否则性能会掉。改造方向关键改动适用场景参数化外观提 Q_PROPERTY 成员变量多主题、Designer 可视化抽象绘制内容抽虚函数 drawContent控件库、多形态仪表加动画qreal 属性 QPropertyAnimation平滑过渡、状态切换验证改造是否成功我习惯写一个最小 demo一个窗口里并排放三个不同配置的控件拖一个滑块同时驱动它们看是否各自表现正确、互不干扰。如果某个控件重绘影响了另一个多半是save/restore没配对或者裁剪路径泄漏了。血泪经验是自绘控件里所有painter的状态改动画笔、画刷、裁剪、变换都要么在绘制结束前还原要么用save/restore包起来。我早期写的时候图省事一个setClipPath忘了 restore结果后面画的文字全被裁没了排查了半天才定位到。从那以后我每次写paintEvent都强制自己先save()再画最后restore()这个习惯帮我省了太多后悔药。希望帮到你。本文还有配套的精品资源点击获取