Qt 实现一个 水波进度球
flyfish
文末有源码下载地址
有一种 软件 里的健康指数球 或者 下载进度球------圆形水球里有一层水波缓缓流动,水位随数值升降,外圈还绕着一段高亮进度弧?本文就是实现它,怎么画圆里的水、怎么让水波动起来、怎么让气泡上浮、怎么管理这些对象的生命周期。
一个好看的水波进度球:椭圆裁剪 + 贝塞尔波浪 + 变量驱动的定时重绘 + 简单的粒子(气泡)系统 。它不依赖任何贴图,纯靠 QPainter 的数学运算,因此能随 progress 实时、平滑地变化。
完整可运行示例见同目录 main.cpp 与 waveball_widget.pro,改动 setProgress / setWaveMode / showRound 即可看到不同效果。

一、整体架构:自绘 QWidget
WaveBallWidget 继承自 QWidget,所有视觉效果都在 paintEvent() 里用 QPainter 现画------没有用任何图片资源,纯数学 + 路径。
它的绘制被拆成一组 分层画 的私有函数,每次重绘按固定顺序叠上去:
paintEvent
├─ drawFullWaveBall() // 实心 / 满进度时整球填充
├─ drawWaveBallBg() // 水波「背景层」(更透明)
├─ drawWaveBall() // 水波「前景层」(更不透明)
├─ drawRound() // 外环弧 + 旋转高亮点
├─ drawScores() // 中心百分比 + 「分」字
├─ drawBubble() // 上浮的白色气泡
└─ drawImage() // 球心图标
关键点在于:水波不是画一张图,而是每帧都根据当前 offset 和 progress 重新计算路径再填充。这就是动画能流畅、且能随进度实时变化的原因。
二、技术原理
2.1 自定义绘制的基础:paintEvent + QPainter
Qt 里想自己画东西,只要重写 paintEvent 并在里面 new QPainter(this) 即可。控件收到 update() 后会向事件循环投递一个重绘请求,下一帧调用 paintEvent。
cpp
void WaveBallWidget::paintEvent(QPaintEvent *event) {
QWidget::paintEvent(event); // 先让基类画背景
// ......分层绘制
}
抗锯齿通过 painter.setRenderHint(QPainter::Antialiasing, true) 开启,圆形边缘才不会有锯齿。
2.2 水波形状:用二次贝塞尔曲线拼一条正弦波
波浪的本质,是一段上下起伏的曲线。代码里没有用 sin() 逐点采样,而是用 QPainterPath::quadTo()(二次贝塞尔)一段段拼接出近似正弦的形状:
cpp
int u = height() / 2;
int y = 2 * u * (1 - progress); // 水面 y 坐标
int waveH = 10; // 波峰高度
mPath.moveTo(offset + 0, y);
mPath.quadTo(offset + u/2, y - waveH, offset + u, y); // 波谷
mPath.quadTo(offset + u/2+u, y + waveH, offset + u + u, y); // 波峰
// ...... 重复若干段,直到覆盖整个圆
每一段下凸 → 上凸的 quadTo 连起来,就得到一条连续起伏的波。路径故意画得比控件宽(5u),超出圆外也没关系------下一步会被裁剪掉。
2.3 裁剪 + 双层水波:制造水在球里的立体感
怎么让水只出现在圆内、并呈现两层水波前后错开的效果?两个技巧:
(1) 裁剪到圆形。 先构造一个完整的椭圆路径 mPath2,再用波浪路径 mPath 作为裁剪区域:
cpp
QPainterPath mPath2;
mPath2.addEllipse(QRect(6, 6, width() - 12, height() - 12)); // 球身
painter.setClipPath(mPath); // 只画波浪以下的部分
painter.drawPath(mPath2); // 填充 ------ 超出圆形的被裁掉
于是椭圆被「波浪线」拦腰截断,露出的下半部分就成了球内的水。
(2) 前后两层相位相反。 drawWaveBallBg() 与 drawWaveBall() 用的是同一套 offset 和 progress,但波的起始相位相反(一个以 y+waveH 开头,一个以 y-waveH 开头)。背景层用半透明渐变(alpha = 0x7F),前景层用不透明渐变(alpha = 0xFF),叠在一起就得到一条「前浪压后浪」的层次感。
2.4 水位映射:progress → 水面高度
水位由 progress(0~1)决定,核心公式:
y = 2 * u * (1 - progress)
u = height()/2是半径;progress = 0→y = 2u(水面在底部,球空);progress = 1→y = 0(水面在顶部,球满)。
当 progress 足够大(代码中 progress * 0.9 >= 1 或处于 noWaveMode)时,直接调用 drawFullWaveBall() 整球填充,省去画波浪。
2.5 动画驱动:QTimer + offset + update
Qt 自绘动画的标准套路:用 QTimer 周期性改一个变量,再 update() 触发重绘。
cpp
void WaveBallWidget::on_timer_out() {
if (offset > -width()) offset -= 3; // 水波每帧左移 3px
else offset = 0; // 移出一屏后归零,循环
update();
}
timer 间隔 20 ms(约 50 fps)。由于波浪路径比球宽很多,持续左移 offset 会让水面看起来在「朝左流动」,归零瞬间因内容无缝而看不出跳变。
外环旋转与气泡则用另一个 roundTimer(同样 20 ms)驱动:roundAlen -= 5,减到 0 附近时回到 360,形成旋转;drawRound() 用 drawArc(rect, roundAlen*16, 315*16) 画弧------注意 Qt 角度单位是 1/16 度,所以乘 16。
2.6 上浮气泡系统:一个迷你「粒子系统」
气泡被抽象成内部类 Bubble(继承 QObject),只有三个属性:位置 point、半径 width、上浮速度 speed。
cpp
class Bubble : public QObject {
QPoint point; int width; float speed;
};
每帧 on_roundTimer_out() 里:
- 每个气泡
point.setY(y - speed)向上移动; - 当越过水面(波形模式)或球顶(实心模式)就
remove回收; - 数量不足 5 个就
getRandBubble()在底部随机生成补充。
drawBubble() 用半透明白色画这些小圆点,就有了「咕噜咕噜往上冒」的效果。
2.7 渐变填充与配色:QLinearGradient + alpha
颜色按进度区间切换(红橙 → 金黄 → 翠绿),用 QLinearGradient 在垂直方向设多个 setColorAt 色标。
亮点在于 isBackColor 参数控制 alpha 通道 :背景层传 0x7F(半透明),前景层传 0xFF(不透明)。同一个渐变函数通过 alpha 复用了配色,又自然区分了前后两层。
2.8 文字与图标的居中绘制
drawScores() 用 QFontMetrics 先量出文字宽高,再反向算出 left/top 把「百分比整数 + 分」整体水平居中、并落在水面上方区域;字体用宋体,大小随控件宽度缩放(width()/3 与 width()/6)。
drawImage() 则把一张 QImage 按给定 QSize 居中绘制在球心,用于放图标或头像。
2.9 对象生命周期:别让气泡泄漏
Bubble 的构造函数以 WaveBallWidget 自身作为 QObject 父对象:
cpp
explicit Bubble(QWidget *parent = nullptr, ...) : QObject(parent), ... {}
// 创建: new Bubble(this, point, bubbleWidth, speed)
这样 bubbles.clear() 只清空指针容器,真正的对象随父控件销毁而由 Qt 对象树自动回收------无需手动 delete,也不会内存泄漏。
三、可扩展
- 绘制频率 :两个
QTimer都是 20 ms。若多球同屏卡顿,可把间隔提到 33 ms(30 fps)或合并到一个定时器里。 - 局部更新 :目前每次
update()重绘整个控件。若控件很大且静止区域多,可改用update(rect)只更新变化区域。 - 正方形约束 :
u = height()/2假设控件是正方形,集成时务必setFixedSize(w, w),否则圆与水位会错位。 - 高分屏 DPI :自绘坐标基于像素,在
main()里开启QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)以适配 Per-Monitor DPI。