Qt 实现一个 水波进度球

Qt 实现一个 水波进度球

flyfish

文末有源码下载地址

有一种 软件 里的健康指数球 或者 下载进度球------圆形水球里有一层水波缓缓流动,水位随数值升降,外圈还绕着一段高亮进度弧?本文就是实现它,怎么画圆里的水、怎么让水波动起来、怎么让气泡上浮、怎么管理这些对象的生命周期。

一个好看的水波进度球:椭圆裁剪 + 贝塞尔波浪 + 变量驱动的定时重绘 + 简单的粒子(气泡)系统 。它不依赖任何贴图,纯靠 QPainter 的数学运算,因此能随 progress 实时、平滑地变化。

完整可运行示例见同目录 main.cppwaveball_widget.pro,改动 setProgress / setWaveMode / showRound 即可看到不同效果。

一、整体架构:自绘 QWidget

WaveBallWidget 继承自 QWidget,所有视觉效果都在 paintEvent() 里用 QPainter 现画------没有用任何图片资源,纯数学 + 路径。

它的绘制被拆成一组 分层画 的私有函数,每次重绘按固定顺序叠上去:

复制代码
paintEvent
 ├─ drawFullWaveBall()      // 实心 / 满进度时整球填充
 ├─ drawWaveBallBg()        // 水波「背景层」(更透明)
 ├─ drawWaveBall()          // 水波「前景层」(更不透明)
 ├─ drawRound()             // 外环弧 + 旋转高亮点
 ├─ drawScores()            // 中心百分比 + 「分」字
 ├─ drawBubble()            // 上浮的白色气泡
 └─ drawImage()             // 球心图标

关键点在于:水波不是画一张图,而是每帧都根据当前 offsetprogress 重新计算路径再填充。这就是动画能流畅、且能随进度实时变化的原因。

二、技术原理

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() 用的是同一套 offsetprogress,但波的起始相位相反(一个以 y+waveH 开头,一个以 y-waveH 开头)。背景层用半透明渐变(alpha = 0x7F),前景层用不透明渐变(alpha = 0xFF),叠在一起就得到一条「前浪压后浪」的层次感。

2.4 水位映射:progress → 水面高度

水位由 progress(0~1)决定,核心公式:

复制代码
y = 2 * u * (1 - progress)
  • u = height()/2 是半径;
  • progress = 0y = 2u(水面在底部,球空);
  • progress = 1y = 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() 里:

  1. 每个气泡 point.setY(y - speed) 向上移动;
  2. 当越过水面(波形模式)或球顶(实心模式)就 remove 回收;
  3. 数量不足 5 个就 getRandBubble() 在底部随机生成补充。

drawBubble() 用半透明白色画这些小圆点,就有了「咕噜咕噜往上冒」的效果。

2.7 渐变填充与配色:QLinearGradient + alpha

颜色按进度区间切换(红橙 → 金黄 → 翠绿),用 QLinearGradient 在垂直方向设多个 setColorAt 色标。

亮点在于 isBackColor 参数控制 alpha 通道 :背景层传 0x7F(半透明),前景层传 0xFF(不透明)。同一个渐变函数通过 alpha 复用了配色,又自然区分了前后两层。

2.8 文字与图标的居中绘制

drawScores()QFontMetrics 先量出文字宽高,再反向算出 left/top 把「百分比整数 + 分」整体水平居中、并落在水面上方区域;字体用宋体,大小随控件宽度缩放(width()/3width()/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。

下载地址

相关推荐
无忧.芙桃1 小时前
数据结构之排序算法(中):冒泡排序与快速排序,从相邻交换到工程级优化
c语言·c++·排序算法
Hhy_11071 小时前
《C++深度解构04》类和对象(下)——类型转换、static、友元与编译器优化
c语言·c++·学习·类和对象·visual studio
qq_199886872 小时前
第7板块·第1节:通用算子分类与设计模式
c++·人工智能·gpu算力·cuda
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 205. 同构字符串 | C++ 双向绑定与哈希表映射
c++·算法·leetcode
神仙别闹9 小时前
基于 C++ 实现(控制台)学生成绩管理系统
开发语言·c++
会周易的程序员9 小时前
5 节点边缘冗余方案(上):基于 aiRaft 的物联网高可用控制面设计
c++·分布式·物联网·raft·iot·共识
qq_1998868713 小时前
第6板块·第4节:构建系统与多文件项目
c++·人工智能·gpu算力
汉克老师14 小时前
GESP2026年9月认证C++三级( 第三部分编程题(2、分割字符串))精讲
c++·gesp·小学生·学c++编程
估值探索者14 小时前
【Python量化系统工程化 #08】关了 SSH 就停?systemd 让脚本开机自启 + 异常自动拉起
java·c++·人工智能·分类·数据挖掘