简介:一份面向Qt初中级开发者的自定义控件实战资源,重点演示在paintEvent中重绘电池进度条,贯穿QPainter基础绘图、QPropertyAnimation属性动画、电量阈值判断与状态颜色切换,适合学习界面绘制或需要为桌面应用补充动态进度指示的读者。压缩包共8个文件,包含3个cpp与2个h源码、1个pro工程文件、1个ui界面文件和1个user配置,从控件初始化、电量矩形填充到动画触发均有可运行代码,可直接导入Qt Creator查看效果。示例中电量低于20%时显示警示色,高于80%使用正常色,其余区间为过渡色,动画触发后每次电量变化都会自动重绘,直观还原动态反馈过程。已有498人浏览学习,压缩包虽仅7KB,但代码结构清晰,将绘制逻辑、动画逻辑和颜色判断适当拆分,方便初学者对照理解自定义QWidget的常用写法,并能在此基础上扩展为音量条、信号强度指示等类似图形组件。
1. 用 paintEvent 自绘电池进度条:为什么不用现成控件
接手过一个模拟项目X的仪表盘界面,第一版偷懒用QLabel加QProgressBar拼电池图标,结果拉伸窗口时三个控件各跑各的,间距错位,圆角对不上,深色背景下还有白边。后来改成在QWidget里重写paintEvent,一个类搞定所有绘制,坐标、圆角、颜色全部统一管理,才彻底摆脱这种“凑控件”的尴尬。
paintEvent的思路是:整块电池外观、电量填充、百分比文字都在一次绘图事件里完成。它不只适合电池,信号强度、柱状图、自定义仪表盘这类需要动态反馈的组件都能复用这套写法。适合正在用Qt做桌面客户端、嵌入式HMI界面,或者想搞懂QPainter坐标与绘图机制的人。这篇文章会把绘制顺序、比例换算、状态配色、抗锯齿、自适应缩放这些关键点拆开讲,最后附上踩过的坑和参数调整心得,照着可以直接落地到自己的项目里。
2. 理解 paintEvent 与 QPainter:先搞清楚绘图顺序和坐标体系
2.1 绘制顺序决定了外观层级
QWidget的所有绘制都发生在paintEvent里,你需要在这个函数里按从下到上的顺序画出背景、外框、内芯和文字。电池的层级关系是:最底层是半透明外框或外壳底色,然后是内部电量的填充矩形,最上面是百分比文字。这个顺序反了,填充色就会盖住文字,或者外框把内容切掉一块。
void BatteryWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿 painter.setRenderHint(QPainter::TextAntialiasing, true); // 文字抗锯齿 // 1. 绘制电池外壳(圆角矩形描边) drawBatteryShell(painter); // 2. 绘制电量填充(以正极到负极的坐标方向) drawBatteryCore(painter); // 3. 绘制百分比文本 drawBatteryText(painter); }逻辑说明:paintEvent是QWidget的受保护虚函数,窗口需要重绘时系统会自动调用。在这个函数里创建QPainter对象,并设置两个渲染提示——Antialiasing是让电池圆角边缘不再有锯齿,TextAntialiasing是让百分比文字更平滑。然后按三个步骤依次绘制,每个步骤用独立函数封装,便于单独调试和修改。event参数是绘制事件的描述对象,在自定义绘制时一般用不到,但函数签名必须保留。
参数说明:setRenderHint的枚举值可以组合,比如同时开启两个抗锯齿就写两次调用,这是最常见的写法。如果界面缩放比例很大且性能紧张,可以关掉Antialiasing,圆角边缘会出现肉眼可见的像素锯齿,但绘制速度会提升,嵌入式设备上经常需要这么取舍。
2.2 坐标系与矩形边距计算
电池绘图最容易被绕进去的是坐标系。QPainter的(0,0)点在左上角,X轴向右延伸,Y轴向下延伸,和数学里的坐标不一样。绘制时我习惯先设定一个电池内容区contentRect,在构造函数或者resizeEvent里计算好,后面所有子组件都基于这个矩形做相对偏移。
void BatteryWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); int w = width(); int h = height(); // 电池头(正极凸起部分)占整个宽度的 8% m_headWidth = qRound(w * 0.08); // 外壳圆角半径取高度的 20%,高度太小时做下限保护 m_cornerRadius = qMax(2, qRound(h * 0.2)); // 绘制内容区:右侧减去电池头宽度 m_contentRect = QRect(m_headWidth, 0, w - m_headWidth, h); // 内芯填充区:比外壳小 4px,留出外壳描边空间 int margin = 4; m_coreRect = m_contentRect.adjusted(margin, margin, -margin, -margin); }逻辑说明:resizeEvent在窗口尺寸变化时触发,这里把所有比例相关的几何参数都集中计算,避免在paintEvent里做除法运算。width()和height()返回当前控件像素尺寸。电池头宽度按整宽8%估算,视觉上比较接近真实电池比例。外壳和内芯之间留4像素边距,这样外框描边不会和内芯填充重叠,缩小到80px高度时这个边距依然清晰可见。
参数说明:cornerRadius取高度的20%是为了让圆角随控件大小等比缩放,但用qMax包了一层下限保护,避免高度太小算出0导致直角。margin值4px是按60px高度选的,如果你把控件拉伸到200px高度,视觉上边距可以适当加大到8px,更协调——这个值可以做成可配置属性,不用硬编码。
2.3 内芯矩形按电量比例动态变化
电量填充不是画满整个内芯区域,而是按百分比线性截取。这里有两个主流做法:一种是从左往右填充矩形,另一种是从正极方向往负极方向收缩。多数手机电池图标是正极在右侧,电量耗尽时内芯从右侧往左侧缩短,所以填充矩形的宽度应该是总宽乘以百分比。
void BatteryWidget::drawBatteryCore(QPainter &painter) { if (m_percent <= 0) { return; } double ratio = m_percent / 100.0; int fillWidth = qRound(m_coreRect.width() * ratio); QRect fillRect = QRect(m_coreRect.left(), m_coreRect.top(), fillWidth, m_coreRect.height()); QColor fillColor = getLevelColor(m_percent); painter.setPen(Qt::NoPen); painter.setBrush(fillColor); painter.drawRoundedRect(fillRect, m_cornerRadius - 2, m_cornerRadius - 2); }逻辑说明:percent是类成员变量,调用方通过setPercent接口更新。ratio把百分比转成0到1之间的小数,乘以内芯矩形宽度得到实际填充像素宽度。fillRect从内芯矩形左边开始,宽度按比例截取。setPen(Qt::NoPen)很重要——如果不设NoPen,画笔会用默认颜色给这个圆角矩形描边,看起来像填充区域被套了个框。填充颜色getLevelColor按电量区间返回不同颜色,后面配表细说。
参数说明:圆角半径在这里减去2,因为内芯矩形比外壳小,如果直接用外壳圆角半径,视觉上内部圆角会显得比外壳更大,减掉2正好和内缩边距匹配。这个数值可以根据margin微调,我一般是让内芯圆角半径等于cornerRadius减margin的一半。
3. 电池外观细节与状态配色:圆角、描边、百分比文本一次成型
3.1 外壳描边和电池头绘制
电池需要画出金属外壳轮廓和正极凸起。外壳用圆角矩形描边,电池头用一个普通矩形加可选的小圆角。两个部分可以用同一种颜色绘制,但描边和填充要区分开。
void BatteryWidget::drawBatteryShell(QPainter &painter) { // 外壳:只描边不填充,模拟金属边框 QColor borderColor(180, 180, 180, 255); painter.setPen(QPen(borderColor, 3)); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(m_contentRect, m_cornerRadius, m_cornerRadius); // 电池头:与外壳同色填充,连接处不需要圆角 QRect headRect = QRect(0, m_contentRect.height() / 4, m_headWidth - 2, m_contentRect.height() / 2); painter.setPen(QPen(borderColor, 2)); painter.setBrush(borderColor); painter.drawRect(headRect); }逻辑说明:外壳画笔宽度设为3像素,这样在小尺寸下描边依然明显。电池头高度取内容区高度的四分之一到四分之三之间,也就是中间的二分之一长度,左右位置从0开始占满整个headWidth。连接处不画圆角是因为真实电池正极和外壳是焊接关系,直角过渡反而更自然。
参数说明:画笔宽度3是按60px高度设计的。如果控件放到150px高度,3像素会显得太细,看起来像细线框;建议把画笔宽度做成和cornerRadius相关的变量,比如qMax(2, h/20)。headRect的left从0开始,宽度给headWidth减去2像素,是为了和外壳之间留1像素视觉间隙,避免两个图形块完全贴死显得闷。
3.2 电量区间配色:低电量红色、正常绿色、充电黄色
电池的电量状态直接反映在填充色上,这是UI反馈最直观的一环。我习惯用一个独立的取色函数,通过switch或if分支控制区间色值。
QColor BatteryWidget::getLevelColor(int percent) { if (percent <= 20) { return QColor(230, 66, 66); // 危险:红色 } else if (percent <= 50) { return QColor(240, 180, 60); // 警告:琥珀色 } else { return QColor(38, 166, 91); // 正常:绿色 } }逻辑说明:20%、50%是两个阈值,低于20%用高饱和红色引起注意,20%到50%之间用琥珀色表示电量偏低但还能撑一会,50%以上用绿色传达安全状态。这三个颜色都是在RGB空间直接定义的具体值,没有用系统调色板,保证深浅色背景下都稳定可识别。
参数说明:阈值和颜色不是写死的,我在实际项目里会做成可配置的属性或从QSS读取。有时客户要求低电量用橙色而不是红色,或者把警告阈值调整到30%,这时候如果颜色写死在函数里,就得翻代码改——不划算。建议在类里定义三个QColor成员变量,构造函数给默认值,外部可以通过setter覆盖。
3.3 百分比文字的绘制与居中算法
百分比文本画在内芯中间偏右的位置,字体大小应该随控件高度变化,不然拉伸窗口后文字会和填充矩形重叠或者溢出。文字居中要用QFontMetrics的boundingRect计算实际像素包围盒,再偏移到目标位置。
void BatteryWidget::drawBatteryText(QPainter &painter) { if (m_showText == false) { return; } QString text = QString::number(m_percent) + QString("%"); QFont font = painter.font(); font.setPixelSize(qMax(10, m_coreRect.height() / 2)); font.setBold(true); painter.setFont(font); QFontMetrics metrics(font); QRect textRect = metrics.boundingRect(text); int x = m_coreRect.center().x() - textRect.width() / 2; int y = m_coreRect.center().y() + textRect.height() / 2; painter.setPen(Qt::white); painter.drawText(x, y, text); }逻辑说明:字体高度取内芯矩形高度的一半,下限10像素防止控件太小时文字不可读。boundingRect返回不带任何缩放的文字包围矩形,x坐标用中心点减去文字宽一半实现水平居中,y坐标用中心点加文字高一半实现垂直居中——注意drawText的y参数是文字基线而不是顶部,所以是加而不是减。画笔设为白色,因为填充区在低电量时是深红色,白字在任何背景上对比度都足够。
参数说明:m_showText是控制是否显示文字的总开关,有些场景电池图标很小不需要文字,只留填充色块。font.setPixelSize用的是像素而不是point,因为paintEvent是在像素坐标系中工作,point在缩放因子下可能出现模糊。textRect的height()在不同字体下可能包含行距,如果追求像素级垂直居中,可以用ascent加descent替代。
3.4 动态刷新:setPercent 接口与 update 触发重绘
外部修改电量之后必须触发重绘,否则界面不会变化。setPercent里做范围限定,然后调用update()。
void BatteryWidget::setPercent(int percent) { m_percent = qBound(0, percent, 100); // 电量从 <=20 跨越到 >20 时,即使数字没变也可能有视觉歧义 if (m_percent != m_lastPercent) { m_lastPercent = m_percent; update(); } }逻辑说明:qBound把传入的百分数限定在0到100之间——有些业务逻辑可能传入负数(比如估算误差),不处理的话ratio会变成负值,QRect构造会得到反向矩形,绘制结果不可预期。这里做了一个简单的变化检测,只有百分比真的改变时才触发update,避免同一数值反复重绘消耗CPU。update()是异步的,它会把控件标记为“脏区域”,等待事件循环进入下一次绘制时统一处理,不会立即调用paintEvent——所以连续多次setPercent也只会触发一次或少量重绘,这是Qt的合并机制,性能上是安全的。
参数说明:m_lastPercent是新增的成员变量,在构造函数里初始化为-1,这样第一次setPercent即使传0也会触发重绘。如果你需要强制同步刷新(比如动画场景),可以调用repaint(),但会阻塞当前线程直到绘制完成,不建议在频繁调用的地方使用。
4. 避坑指南:paintEvent 绘制的五个常见问题与排查方法
4.1 电池头分叉变成一块黑色方块
现象:绘制出来的电池正极不是凸起的小矩形,而是一个填满左上角的大黑块。
原因:drawBatteryShell里设置画笔和画刷的代码顺序有坑——有时画笔设置了颜色,下一行setBrush又随手设置成黑色,或者画笔用了默认颜色叠加在填充外框上。另一个常见原因是headRect的高度或宽度计算使用了负值,比如contentRect.height()在窗口未完成布局时返回0,除4得到0,而drawRect对零高度矩形会直接绘制一条线而非填充。
解决:每一步设置画笔、画刷前先明确要用哪个对象。我一般是先setPen再setBrush,两者都要显式指定,不依赖上一次的状态。坐标计算统一用resizeEvent里缓存的成员变量,不要在paintEvent里现场计算依赖布局的值——布局完成前width()可能尚未更新。调试时可以在paintEvent里临时加qDebug输出矩形坐标,确认是不是负数。
4.2 设置样式表后外壳圆角内部出现白色背景
现象:在外部给BatteryWidget设置了QSS,比如background-color: transparent,绘制电池时圆角矩形内部有一块白色或灰色底,怎么也消不掉。
原因:QSS的background-color作用于QWidget的paintEvent绘制之前,相当于在自绘内容下面画了一层背景。当QSS背景色不是透明时,就会盖住你绘制的外壳填充色。
解决:给这个自绘控件设置一个空样式或者属性选择器排除样式影响。常见做法是在构造函数里setAttribute(Qt::WA_StyledBackground, false)或者直接setStyleSheet("background: transparent;")。更干净的方式是让BatteryWidget继承QWidget时重写paintEvent,同时把setAttribute(Qt::WA_OpaquePaintEvent, true)设置上,告诉Qt这个控件完全由自己绘制,不需要清理背景。
4.3 窗口缩放后内芯矩形底部露出一个小缺口
现象:窗口高度拉大后,填充矩形的底部或右侧出现未绘制区域,像电池被削了一角。
原因:paintEvent里直接使用了width()和height()计算坐标,而窗口缩放过程中这两个值在resizeEvent和paintEvent之间存在不一致——resizeEvent已经更新了成员变量,但paintEvent可能在同一个事件循环里先执行了。另一个原因是用m_coreRect.adjusted加边距时,边距值大于矩形自身尺寸,导致QRect变成负宽度,绘制结果不可预测。
解决:强制在paintEvent里重新取一次实时尺寸,或者用一个标志位让resizeEvent和paintEvent之间保证同步。我一般是在resizeEvent里重新计算所有矩形,同时调用update(),这样paintEvent拿到的必然是最新几何。调整边距时加一个最小尺寸保护,比如margin值不超过coreRect短边的四分之一。如果问题只出现在极限缩放,也可以把QRect运算换成QRectF,浮点运算在边界处容错性更好。
4.4 电量从 100% 变成 99% 时内芯右边出现一条缝隙
现象:电量100%时内芯填充矩形与外壳描边之间贴合紧密,但显示99%时右侧出现约1像素的缝隙,肉眼仔细看能看到背景色。
原因:fillWidth是用double类型乘以矩形宽度后qRound取整。100%时fillWidth等于m_coreRect.width(),而99%时乘出的小数被四舍五入到整数,可能比完整宽度少1像素,这1像素宽度就是缝隙产生的根源。
解决:在绘制填充矩形时,如果百分比大于等于99,直接把fillRect的宽度强制设成m_coreRect.width(),或者用qCeil代替qRound保证接近满电时不会留缝。更稳妥的做法是把fillRect的right值用qMin限幅到m_coreRect.right(),这样即使计算误差超了也不会越界。低电量同理,小于1%时直接返回不绘制,而不是画一个1像素宽度的细条。
4.5 文字在部分显示屏上发虚或带锯齿
现象:同一段代码在不同分辨率或缩放比例下,百分比文字边缘模糊,和小字号的系统字体对比明显。
原因:QFontMetrics计算出的boundingRect和实际绘制时字体渲染存在偏差,尤其是字体启用了subpixel antialiasing时会更明显。另一个因素是drawText的坐标计算使用了整数,字体在非整数倍缩放时可能落在像素边界上。
解决:改成QRectF浮点坐标绘制,或者用QPainterPath包裹文字路径。我在项目里一般把drawText的x和y全部转成double,绘制前统一加0.5做像素偏移校准。如果文字模糊持续存在,最直接的方案是换字体,等宽字体在小字号下通常比黑体更锐利。还要确认控件的devicePixelRatio设置正确,Qt高DPI模式下忽略了devicePixelRatio会导致整个绘制内容的模糊——包括外壳圆角。
// 高DPI适配 void BatteryWidget::setDeviceScale(qreal scale) { m_scale = scale; m_contentRect = QRectF(m_headWidth * scale, 0, (w - m_headWidth) * scale, h * scale); update(); }逻辑说明:这段展示了高DPI下用缩放因子统一调整坐标的做法。devicePixelRatio在高分屏下通常为2或更高,所有基于像素的几何计算都要乘以scale,否则绘制结果只有屏幕的四分之一大小。m_contentRect从QRect改成QRectF后,浮点坐标即使在非整数倍缩放下也不会产生累积误差。如果不需要支持高DPI,这段可以整个忽略,但移动端和Retina屏幕的项目建议提前写好。
参数说明:scale值由外部传入,不应该在控件内部硬编码获取,因为有些场景需要手动覆盖系统默认值。比如远程桌面或虚拟机里,系统DPI报告和实际显示不匹配,手动设置scale能矫正绘制效果。
5. 进阶技巧:从电池进度条到通用自绘控件
5.1 把几何计算抽成独立方法,方便其他项目复用
BatteryWidget的核心逻辑其实只有三件事:计算内容矩形、计算填充矩形、计算文字居中位置。这三件事可以抽成一个独立的布局计算函数,不依赖任何Qt控件,只做纯数学运算,然后任何自绘控件都能调用同样的方法。
struct BatteryLayout { QRectF shellRect; QRectF coreRect; QRectF textRect; qreal headWidth; }; BatteryLayout calculateBatteryLayout(qreal w, qreal h, qreal percent, qreal shellMargin, qreal cornerRatio) { BatteryLayout layout; layout.headWidth = w * 0.08; qreal radius = qMax(2.0, h * cornerRatio); layout.shellRect = QRectF(layout.headWidth, 0, w - layout.headWidth, h); layout.coreRect = layout.shellRect.adjusted(shellMargin, shellMargin, -shellMargin, -shellMargin); layout.coreRect.setWidth(layout.coreRect.width() * percent / 100.0); return layout; }逻辑说明:这个纯函数不依赖QWidget的成员状态,任何地方传入宽高和百分比都能得到完整布局结果。这样做的好处是可以在单元测试里直接验证不同输入下矩形是否正确,不需要实例化控件跑事件循环。结构体BatteryLayout统一封装所有绘制所需的几何参数,绘制函数只需要从这个结构体里取数值,职责拆分清楚。
参数说明:cornerRatio控制圆角高度比例,不同风格可以传0.15到0.25的不同值。shellMargin默认4像素,但如果高度小于30像素,传入的margin会导致计算出的coreRect高度为负——调用方需要在外层做最小尺寸限定,或者在函数里再加一层qMax保护。
5.2 用单例缓存字体和颜色,避免paintEvent里反复创建
paintEvent每帧都会执行,如果每次绘制都new一个QFont或者QColor,在60帧刷新率下会累积大量内存分配和释放。更麻烦的是QFontMetrics每次构造都要做字体度量计算,相当耗时。
void BatteryWidget::drawBatteryText(QPainter &painter) { static QFont s_cacheFont; static QColor s_cacheColor; if (m_fontDirty) { s_cacheFont = painter.font(); s_cacheFont.setPixelSize(qMax(10, m_coreRect.height() / 2)); s_cacheFont.setBold(true); } painter.setFont(s_cacheFont); if (m_colorDirty) { s_cacheColor = getLevelColor(m_percent); } painter.setPen(s_cacheColor); }逻辑说明:静态局部变量在首次调用时初始化,后续调用直接复用,不会再创建对象。m_fontDirty和m_colorDirty是用成员标志位控制的失效标记——字体相关参数变了才重建字体,电量颜色变了才重新计算颜色。这样paintEvent体积变得很小,绘制路径里没有昂贵的构造和析构操作。
参数说明:静态局部变量的生命周期是整个程序,如果在多个BatteryWidget实例之间共享状态,需要考虑颜色是否会串。这里cacheColor按percent计算,不同实例如果百分比不同会互相覆盖。我的做法是把这些对象提升为成员变量,标志位操作换成成员变量版本,性能和静态变量差不多,但多实例间互不干扰。
5.3 给电池增加充电动画和边框发光效果
电量进度条最常用的扩展效果有两个:充电时的呼吸高亮和低电量时的边框红色闪烁。呼吸高亮的本质是让填充色在基准色和亮色之间周期性插值,可以用QTimer配合一个透明度变量实现。边框发光则用QPainter的绘制路径阴影或者多层描边叠加完成,不需要额外引入QGraphicsDropShadowEffect——那个在paintEvent里不起作用。
void BatteryWidget::startChargingAnimation() { m_charging = true; m_glowOpacity = 0.0; m_animTimer.start(50); // 50ms 刷新一次 }逻辑说明:QTimer每50毫秒触发一次,在槽函数里让m_glowOpacity在0到1之间来回摆动。绘制时用当前充电状态计算颜色,把fillColor的lightness调亮glowOpacity对应的梯度值。这样不需要开线程,所有更新都通过update()汇入主线程绘制流程,和paintEvent天然兼容。定时器在窗口隐藏时记得停止,省电也避免无意义的重绘。
参数说明:50毫秒对应20FPS的动画刷新率——视觉上已经平滑,但CPU占用比30FPS低不少。如果追求更细腻的呼吸效果可以改成30毫秒,但要注意低端嵌入式平台上高频率定时器可能导致主线程卡顿。边框红色闪烁用同样的定时器机制,在电量低于20%时切换描边颜色,比单色常亮更醒目。
5.4 用 Q_PROPERTY 暴露可配置属性,让 QSS / QML 可以直接控制
把电量百分比、边框颜色、警示阈值注册成Qt属性,外部用setProperty就能动态修改,这比手写一堆setter更灵活。Q_PROPERTY注册后,QML和QSS也能通过属性名直接操作,不用改C++代码就能换皮肤。
Q_PROPERTY(int percent READ percent WRITE setPercent NOTIFY percentChanged) Q_PROPERTY(QColor fillColor READ fillColor WRITE setFillColor NOTIFY colorChanged)逻辑说明:Q_PROPERTY把C++成员变量暴露给Qt元对象系统。percent属性有读函数、写函数和信号,外部赋值后自动触发通知信号,任何绑定了这个属性的UI组件都会同步更新。我在实际项目里靠这个机制做了主题切换——把填充主色、描边色、低电量阈值全部注册成属性,运行时通过配置文件直接改外观,不需要重新编译。
参数说明:NOTIFY信号必须存在才能支持MVVM双向绑定,如果只是单向显示,信号可选。setPercent里如果值没变化,记得不要发信号,否则会引发无意义的级联刷新。还有一点容易被忽略——用Q_PROPERTY注册的属性建议使用getter和setter而非直接暴露public成员,这样可以在setter里做范围校验、触发update,保证进入绘制流程的数据一定是合法的。
从那以后我每次做自绘控件都强制走一遍这个流程:先理清绘制层级,再把几何计算从paintEvent里抽出来,最后才考虑配色和动画。抽布局计算这个习惯帮我省掉了大量调坐标的时间,现在给同事演示或者改需求,都只需要动一个纯函数就能看到效果,不用在绘图事件里反复试错。希望这篇笔记能帮你在电池进度条或类似的自绘组件上少踩几个坑,快速做出能直接落地的界面。
本文还有配套的精品资源,点击获取