Qt 实现一个 火箭 圆形进度控件
flyfish
文末有源码下载地址
关键词:Qt 6 · QWidget 自绘 · QPainter · 圆形进度环 · QTimer 动画 · 配色主题
一、先看效果
这是一个小 demo,但它把 Qt 自定义控件最常见的几个知识点都串起来了:
圆形进度环:一圈描边弧线随「扫描进度」从 0° 长到 360°;
滑块联动:拖动 QSlider 实时改变扫描角度,控件同步重绘;
扫描动画:点「开始扫描」后,QTimer 每隔 30ms 推进 12°,弧线循环扫动(约 3 秒一圈);
四套配色:一键切换,整个火箭的颜色跟着变。
火箭的脸、眼睛、翅膀、触角全是用 QPainter 的几何图元一笔笔画出来的,没有用任何图片素材。

二、项目结构
rocket_widget/
├── rocket_widget.pro # qmake 工程文件
├── main.cpp # 组装 UI:滑块 / 按钮 / 定时器
├── circleprogress.h # CircleProgress 控件声明
└── circleprogress.cpp # CircleProgress 的绘制逻辑
rocket_widget.pro 只有三行关键配置:
QT += widgets
TARGET = rocket_widget
TEMPLATE = app
三、原理
1. 自定义控件:继承 QWidget,重写 paintEvent
Qt 里所有"长得和自带控件不一样"的东西,本质都是子类化 QWidget + 重写 paintEvent。
class CircleProgress : public QWidget {
Q_OBJECT
public:
explicit CircleProgress(QWidget *parent = nullptr);
void setAngle(int a); // 0..360 扫描进度
int angle() const { return m_angle; }
void setColors(const QColor &main, const QColor &dark, const QColor &light);
protected:
void paintEvent(QPaintEvent *event) override;
private:
int m_angle;
QColor m_main, m_dark, m_light;
};
设计:
状态与绘制分离:m_angle 和三个颜色是"数据",paintEvent 只负责"把数据画出来"。外部永远通过 setAngle() / setColors() 改数据,而不是直接碰画笔。
改完就 update():两个 setter 结尾都调用了 update(),这是 Qt 约定的"请求重绘"入口,它会异步触发一次 paintEvent,而不是手动去调绘制函数。
2. Qt 的角度单位是 1/16 度
这是写圆形进度最容易被绊倒的地方。QPainter::drawArc 的角度参数不是普通度数,而是度的 1/16:
int startAngle = 90 * 16; // 90° × 16 = 1440,起点在 12 点钟方向
int spanAngle = -m_angle * 16; // 负号表示逆时针扫动
p.drawArc(cx - r + 6, cy - r + 6, (r - 6) * 2, (r - 6) * 2, startAngle, spanAngle);
还要记住 Qt 的两条约定:
- 角度 0° 在 3 点钟方向(不是 12 点);
- 正值 顺时针,负值逆时针。
所以 90 * 16 把弧的起点从 3 点挪到了 12 点;spanAngle 取负,弧就从顶部开始往逆时针方向"长"出来。当 m_angle = 360 时正好画满一整圈。
3. 进度弧本身怎么画
p.setPen(QPen(m_main, 10, Qt::SolidLine, Qt::RoundCap)); // 主色、线宽10、圆头端点
p.drawArc(cx - r + 6, cy - r + 6, (r - 6) * 2, (r - 6) * 2, startAngle, spanAngle);
弧画在半径 r-6 = 58 的圆上,比底圆(r=64)小一圈,给描边留出余量,避免被控件边缘裁切;
Qt::RoundCap 让弧的两端是圆头,视觉更精致;
画笔 setPen 决定"线"的颜色和粗细,画填充图形前记得切回 setPen(Qt::NoPen)。
4. 用几何图元拼出一张火箭脸
整个火箭没有任何贴图,全靠 drawEllipse / drawRoundedRect / drawPolygon 拼出来(坐标以圆心 (70,70) 为基准):
// 脸(主色大椭圆)
p.setBrush(m_main);
p.drawEllipse(cx - 32, cy - 28, 64, 56);
// 眼白
p.setBrush(Qt::white);
p.drawEllipse(cx - 12, cy - 14, 24, 36);
// 鼻子/嘴(圆角矩形)
p.drawRoundedRect(cx - 8, cy - 22, 16, 14, 7, 7);
// 左右翅膀(三角形多边形)
QPolygonF leftWing;
leftWing << QPointF(cx - 12, cy + 8) << QPointF(cx - 24, cy + 20) << QPointF(cx - 12, cy + 18);
p.drawPolygon(leftWing);
// 右翅膀对称……
// 触角上的小圆点
p.drawEllipse(cx - 6, cy + 20, 4, 8);
p.drawEllipse(cx + 2, cy + 20, 4, 8);
// 眼珠(暗色)
p.setBrush(m_dark);
p.drawEllipse(cx - 5, cy - 10, 10, 10);
一个小经验:先 setBrush 后 draw。Qt 的填充色是由当前画笔/画刷状态决定的,画下一个不同颜色的图形前,务必重新 setBrush,否则会沿用上一个颜色。
5. 三色主题:一个接口换一身皮肤
控件把颜色拆成三个语义角色,比"只给一个主色"灵活得多:
| 角色 | 用途 |
|---|---|
m_main | 进度弧 + 脸 + 眼白之外的主体 |
m_dark | 描边感、眼珠等"重色"细节 |
m_light | 底盘背景的浅色 |
切换主题就是调一次 setColors:
cp->setColors(QColor(0x22,0xCB,0x64), QColor(0x1B,0xA3,0x4D), QColor(0xE8,0xF5,0xE9)); // 常见绿
cp->setColors(QColor(0x20,0x9E,0xFF), QColor(0x16,0x7A,0xD9), QColor(0xE3,0xF2,0xFD)); // 科技蓝
cp->setColors(QColor(0xFF,0x4D,0x4F), QColor(0xD9,0x33,0x33), QColor(0xFD,0xEC,0xEC)); // 警示红
「随机色」还顺手演示了 QColor 的明度派生:
QColor main(rnd(), rnd(), rnd());
cp->setColors(main, main.darker(120), main.lighter(185)); // 主色自动推出暗色/浅色
darker() / lighter() 接收一个百分比因子,是做"同色系深浅"最省事的办法。
6. 扫描动画:QTimer + 滑块联动
动画没用任何高级框架,就是 QTimer 定时改角度:
QTimer timer;
QObject::connect(&timer, &QTimer::timeout, [&]() {
static int ang = 0;
ang = (ang + 12) % 360; // 每次前进 12°
cp->setAngle(ang); // 改数据 → update()
slider->setValue(ang); // 顺手同步滑块,UI 不"各画各的"
});
// 点击按钮在 开始/停止 间切换
QObject::connect(scan, &QPushButton::clicked, [&]() {
if (timer.isActive()) { timer.stop(); scan->setText("开始扫描"); }
else { timer.start(30); scan->setText("停止扫描"); }
});
30ms 一次、每次 +12°,数学上 360/12×30ms = 900ms?不对,应该是 360/12 = 30 步 × 30ms = 900ms 一圈。无论快慢,关键是:定时器只改"数据",绘制永远交给 paintEvent,职责清晰。
滑块和动画共用同一个 setAngle,所以手动拖和自动扫不会互相打架——这是"单一数据源"带来的好处。
四、完整代码
circleprogress.h
#ifndef CIRCLEPROGRESS_H
#define CIRCLEPROGRESS_H
#include <QWidget>
#include <QColor>
class CircleProgress : public QWidget {
Q_OBJECT
public:
explicit CircleProgress(QWidget *parent = nullptr);
void setAngle(int a);
int angle() const { return m_angle; }
void setColors(const QColor &main, const QColor &dark, const QColor &light);
protected:
void paintEvent(QPaintEvent *event) override;
private:
int m_angle;
QColor m_main, m_dark, m_light;
};
#endif // CIRCLEPROGRESS_H
circleprogress.cpp
#include "circleprogress.h"
#include <QPainter>
#include <QPolygonF>
CircleProgress::CircleProgress(QWidget *parent) : QWidget(parent), m_angle(0) {
setFixedSize(140, 140);
m_main = QColor(0x22, 0xCB, 0x64);
m_dark = QColor(0x1B, 0xA3, 0x4D);
m_light = QColor(0xE8, 0xF5, 0xE9);
}
void CircleProgress::setAngle(int a) {
m_angle = qBound(0, a, 360);
update();
}
void CircleProgress::setColors(const QColor &main, const QColor &dark, const QColor &light) {
m_main = main; m_dark = dark; m_light = light;
update();
}
void CircleProgress::paintEvent(QPaintEvent *) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
int cx = 70, cy = 70, r = 64;
p.setPen(Qt::NoPen);
p.setBrush(m_light);
p.drawEllipse(cx - r, cy - r, r * 2, r * 2);
int startAngle = 90 * 16;
int spanAngle = -m_angle * 16;
p.setPen(QPen(m_main, 10, Qt::SolidLine, Qt::RoundCap));
p.drawArc(cx - r + 6, cy - r + 6, (r - 6) * 2, (r - 6) * 2, startAngle, spanAngle);
p.setPen(Qt::NoPen);
p.setBrush(m_main);
p.drawEllipse(cx - 32, cy - 28, 64, 56);
p.setBrush(Qt::white);
p.drawEllipse(cx - 12, cy - 14, 24, 36);
p.drawRoundedRect(cx - 8, cy - 22, 16, 14, 7, 7);
QPolygonF leftWing;
leftWing << QPointF(cx - 12, cy + 8) << QPointF(cx - 24, cy + 20) << QPointF(cx - 12, cy + 18);
p.drawPolygon(leftWing);
QPolygonF rightWing;
rightWing << QPointF(cx + 12, cy + 8) << QPointF(cx + 24, cy + 20) << QPointF(cx + 12, cy + 18);
p.drawPolygon(rightWing);
p.drawEllipse(cx - 6, cy + 20, 4, 8);
p.drawEllipse(cx + 2, cy + 20, 4, 8);
p.setBrush(m_dark);
p.drawEllipse(cx - 5, cy - 10, 10, 10);
}
main.cpp
#include <QApplication>
#include <QPushButton>
#include <QSlider>
#include <QVBoxLayout>
#include <QLabel>
#include <QTimer>
#include <QColor>
#include <QRandomGenerator>
#include "circleprogress.h"
int main(int argc, char *argv[]) {
QApplication a(argc, argv);
QWidget w;
w.setWindowTitle(QString::fromUtf8("画火箭 (配色可变)"));
CircleProgress *cp = new CircleProgress;
QSlider *slider = new QSlider(Qt::Horizontal);
slider->setRange(0, 360);
slider->setValue(0);
QObject::connect(slider, &QSlider::valueChanged, cp, &CircleProgress::setAngle);
QPushButton *scan = new QPushButton(QString::fromUtf8("开始扫描"));
QLabel *tip = new QLabel(QString::fromUtf8("拖动滑块调扫描进度,或点「开始扫描」播放循环动画;下方按钮切换配色主题"));
tip->setWordWrap(true);
QTimer timer;
QObject::connect(&timer, &QTimer::timeout, [&]() {
static int ang = 0;
ang = (ang + 12) % 360;
cp->setAngle(ang);
slider->setValue(ang);
});
QObject::connect(scan, &QPushButton::clicked, [&]() {
if (timer.isActive()) { timer.stop(); scan->setText(QString::fromUtf8("开始扫描")); }
else { timer.start(30); scan->setText(QString::fromUtf8("停止扫描")); }
});
QPushButton *green = new QPushButton(QString::fromUtf8("常见绿"));
QPushButton *blue = new QPushButton(QString::fromUtf8("科技蓝"));
QPushButton *red = new QPushButton(QString::fromUtf8("警示红"));
QPushButton *rand = new QPushButton(QString::fromUtf8("随机色"));
QObject::connect(green, &QPushButton::clicked, [&]() {
cp->setColors(QColor(0x22,0xCB,0x64), QColor(0x1B,0xA3,0x4D), QColor(0xE8,0xF5,0xE9));
});
QObject::connect(blue, &QPushButton::clicked, [&]() {
cp->setColors(QColor(0x20,0x9E,0xFF), QColor(0x16,0x7A,0xD9), QColor(0xE3,0xF2,0xFD));
});
QObject::connect(red, &QPushButton::clicked, [&]() {
cp->setColors(QColor(0xFF,0x4D,0x4F), QColor(0xD9,0x33,0x33), QColor(0xFD,0xEC,0xEC));
});
QObject::connect(rand, &QPushButton::clicked, [&]() {
auto rnd = []() { return QRandomGenerator::global()->bounded(256); };
QColor main(rnd(), rnd(), rnd());
cp->setColors(main, main.darker(120), main.lighter(185));
});
QVBoxLayout *lay = new QVBoxLayout(&w);
lay->addWidget(cp, 0, Qt::AlignHCenter);
lay->addWidget(new QLabel(QString::fromUtf8("扫描进度:")));
lay->addWidget(slider);
lay->addWidget(scan);
lay->addWidget(tip);
lay->addWidget(green);
lay->addWidget(blue);
lay->addWidget(red);
lay->addWidget(rand);
w.resize(300, 480);
w.show();
return a.exec();
}
五、清单
- 角度要乘 16:
drawArc/drawPie的参数是 1/16 度,忘乘会得到几乎看不见的弧。 update()不是repaint():前者合并多次请求、在下个事件循环重绘,性能更好;强行同步重绘请用repaint()(一般没必要)。qBound兜底:setAngle用qBound(0, a, 360)把输入夹在合法区间,外部传脏数据也不会画出怪弧。setFixedSize与绘制坐标耦合:控件固定 140×140,绘制时圆心写死 (70,70)。若想支持自适应大小,应改用width()/2、height()/2动态计算,而不是硬编码。- 抗锯齿别忘:
p.setRenderHint(QPainter::Antialiasing)让弧和椭圆边缘平滑,否则在高分屏上会有明显锯齿。 - 画刷状态会"粘":连续画多个不同颜色的图元,记得每次
setBrush,否则颜色会串。
六、可以怎么扩展
绑定真实进度:现在 angle 是演示值,把它接到文件拷贝 / 网络请求的 bytesReceived / bytesTotal 上就是现成的进度环。
渐变描边:把 QPen 的颜色换成 QLinearGradient,进度弧立刻有质感。
呼吸光晕:在 paintEvent 里再叠一层带透明度的外圆,透明度用 QTimer 驱动,做"扫描中"的脉冲效果。
换成 SVG / 图标:若火箭要更精致,可用 QSvgRenderer 加载矢量图,再叠加手绘进度弧,比纯几何图元省事。
支持高 DPI 自适应:用 devicePixelRatio() 配合 QPainter 的变换,避免 4K 屏上控件发虚。
子类化 QWidget → 用成员变量存状态 → setter 里 update() → paintEvent 里用 QPainter 画图 → 用信号槽 / 定时器驱动状态变化。理解了这条主线,写仪表盘、波形图、加载动画都不在话下。

396

被折叠的 条评论
为什么被折叠?



