016-小项目-仿汽车仪表盘

auther: abinng date: 2026-05-10 18:21 createDate:2026-05-10 18:21

复习路线

这篇笔记是一个综合实战——用 Qt 的绘图系统和动画框架,从零实现一个仿汽车仪表盘的自定义控件。

涉及的知识点:

1
2
3
4
QPainter 坐标系变换(translate/rotate/scale)
→ 绘制环形刻度盘 + 刻度文字
→ QPainterPath 布尔运算(subtract 镂空指针)
→ Q_PROPERTY + QPropertyAnimation(平滑动画)

建议先读完 015-Qt 的绘图系统,掌握 QPaintertranslate/rotate/scaledrawArc 之后再来看这篇。

1. 效果描述

实现一个汽车速度仪表盘控件,具备以下功能:

  • 速度范围 0 ~ 240 km/h
  • 环形刻度盘,短刻度和长刻度交替,长刻度旁显示速度值
  • 指针采用中间镂空样式
  • 支持设置目标速度,指针以动画平滑滑到对应位置

2. 核心架构

整个项目分为两层:

  • SpeedWidget — 自定义绘图控件,负责仪表盘的绘制和动画。是本文的核心。
  • MainWin — 上层窗口,放一个 SpeedWidget + 输入框 + 按钮,负责接收用户输入的速度值。

SpeedWidget 的关键设计是用 Qt 的属性动画系统驱动重绘:

1
2
3
4
用户设置速度 → animateToSpeed(target)
→ QPropertyAnimation 逐帧修改 speed 属性
→ set_speed() 被调用 → update() 触发 paintEvent()
→ paintEvent() 根据当前 speed 值重绘指针位置

3. 动画机制:Q_PROPERTY + QPropertyAnimation

这是本篇最核心的技巧。我们希望指针不是瞬间跳到目标位置,而是平滑滑动过去。Qt 提供了 QPropertyAnimation,它可以对某个对象的属性值做补间动画。

关键代码在头文件中:

1
2
3
4
5
6
7
8
9
10
11
class SpeedWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal speed READ get_speed WRITE set_speed); // 向动画系统注册属性
public:
qreal get_speed() const; // 读取当前速度
void set_speed(qreal speed); // 写入速度 → 内部调用 update() 触发重绘
void animateToSpeed(qreal target); // 启动动画
private:
QPropertyAnimation *m_anim;
qreal m_speed;
};

Q_PROPERTY 宏向 Qt 元对象系统注册了一个名为 speed 的属性。QPropertyAnimation 通过这个名字来读写属性值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void SpeedWidget::animateToSpeed(qreal target)
{
if (target < 0) target = 0;
else if (target > 240) target = 240; // 限幅

if (m_anim == nullptr) {
m_anim = new QPropertyAnimation(this, "speed", this);
m_anim->setEasingCurve(QEasingCurve::OutQuad); // 缓出曲线
}
m_anim->stop();
m_anim->setStartValue(m_speed); // 从当前速度开始
m_anim->setEndValue(target); // 到目标速度
m_anim->setDuration(1500); // 持续 1.5 秒
m_anim->start();
}

void SpeedWidget::set_speed(qreal speed)
{
m_speed = speed;
update(); // 每次属性值改变都触发重绘
}

动画期间,Qt 以高帧率不断调用 set_speed() 写入中间值,每次写入都 update() 一次,指针就看起来在平滑滑动了。QEasingCurve::OutQuad 让动画有减速收尾的效果。

4. 绘制刻度盘

绘制在 paintEvent() 中完成,整体流程是:坐标系变换 → 画外壳圆 → 画刻度线 → 画指针 → 画单位。

首先把坐标系变换到画布中心,并缩放到 [-100, 100] 的逻辑坐标范围:

1
2
3
4
5
6
7
8
9
10
void SpeedWidget::paintEvent(QPaintEvent *event)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);

qreal W = width(), H = height();
qreal side = qMin(W, H);
painter.translate(W / 2.0, H / 2.0); // 原点移到中心
painter.scale(side / 210.0, side / 210.0); // 缩放到 [-100,100] 范围内
// ...

为什么要做这个变换?控件大小可能变化,但在逻辑坐标系里绘制的图形会自动等比缩放,省去手动计算像素坐标的麻烦。

刻度盘共 25 条刻度线(0 到 240,每隔 10 km/h 一条),分布在从 150° 到 390°(即 150° + 240°)的弧上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
qreal startAngle = 150;       // 起始角度(7点钟方向)
qreal rangeAngle = 240; // 总跨度
qreal max_speed = 240;

for (int i = 0; i <= 24; ++i) {
painter.save();
qreal currentAngle = startAngle + (i * rangeAngle / 24.0);
painter.rotate(currentAngle); // 旋转坐标系到当前刻度位置

if (i % 2 == 0) {
// 长刻度 + 数字
painter.setPen(QPen(Qt::black, 2));
painter.drawLine(80, 0, 95, 0); // 从半径80画到95

// 画文字——注意要把坐标系转回去,保证文字是水平的
painter.translate(70, 0); // 移动到文字位置
painter.rotate(-currentAngle); // 反向旋转,文字保持水平
QRect textRect(-15, -10, 30, 20);
QFont font = painter.font();
font.setPointSize(6);
painter.setFont(font);
painter.drawText(textRect, Qt::AlignCenter, QString::number(i * 10));
} else {
painter.setPen(QPen(Qt::black, 1));
painter.drawLine(80, 0, 95, 0); // 短刻度
}
painter.restore(); // 恢复到 save() 时的状态
}

这里使用 save()/restore() 是关键——restore() 后坐标系回到画布中心,下一轮循环从干净的起点开始。

5. 绘制镂空指针

指针需要“中间镂空“的效果,用的是 QPainterPath 的布尔运算——从大三角形里减掉一个小三角形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
qreal speedAngle = startAngle + (m_speed / max_speed) * rangeAngle;
painter.rotate(speedAngle); // 旋转到当前速度对应的角度

// 大三角形(外轮廓)
QPainterPath bigNeedle;
bigNeedle.addPolygon(QPolygonF({QPointF(0, 4), QPointF(80, 0), QPointF(0, -4)}));

// 小三角形(镂空部分)
QPainterPath smallNeedle;
smallNeedle.addPolygon(QPolygonF({QPointF(4, 2), QPointF(60, 0), QPointF(4, -2)}));

// 减法运算:大三角 减 小三角 = 镂空效果
QPainterPath finalNeedle = bigNeedle.subtracted(smallNeedle);

painter.setBrush(QColor(255, 60, 60)); // 红色填充
painter.setPen(QPen(Qt::darkRed, 1));
painter.drawPath(finalNeedle);

QPainterPath 的布尔运算和 015-绘图系统中讲过的 intersected/subtracted/united 完全一样,这里只是应用到了指针绘制上。

6. 完整代码

点击展开
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#ifndef SPEEDWIDGET_H
#define SPEEDWIDGET_H
#include <QWidget>
#include <QPropertyAnimation>

class SpeedWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal speed READ get_speed WRITE set_speed);

public:
explicit SpeedWidget(QWidget *parent = nullptr);
~SpeedWidget() override = default;

qreal get_speed() const;
void set_speed(qreal speed);
void animateToSpeed(qreal target);

protected:
void paintEvent(QPaintEvent *event) override;

private:
QPropertyAnimation *m_anim;
qreal m_speed;
};

#endif //SPEEDWIDGET_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include "SpeedWidget.h"
#include <QPainter>
#include <QPainterPath>

SpeedWidget::SpeedWidget(QWidget *parent) : QWidget(parent)
{
m_speed = 0.0;
m_anim = nullptr;
}

qreal SpeedWidget::get_speed() const { return m_speed; }

void SpeedWidget::animateToSpeed(qreal target)
{
constexpr qreal max_speed = 240.0;
if (target < 0) target = 0;
else if (target > max_speed) target = max_speed;

if (m_anim == nullptr) {
m_anim = new QPropertyAnimation(this, "speed", this);
m_anim->setEasingCurve(QEasingCurve::OutQuad);
}
m_anim->stop();
m_anim->setStartValue(m_speed);
m_anim->setEndValue(target);
m_anim->setDuration(1500);
m_anim->start();
}

void SpeedWidget::set_speed(qreal speed)
{
m_speed = speed;
update();
}

void SpeedWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);

qreal W = width(), H = height();
qreal side = qMin(W, H);
painter.translate(W / 2.0, H / 2.0);
painter.scale(side / 210.0, side / 210.0);

// 外壳圆
painter.save();
painter.setPen(QPen(Qt::blue, 2));
painter.drawEllipse(-100, -100, 200, 200);
painter.restore();

// 刻度盘
qreal startAngle = 150, rangeAngle = 240, max_speed = 240;
for (int i = 0; i <= 24; ++i) {
painter.save();
qreal currentAngle = startAngle + (i * rangeAngle / 24.0);
painter.rotate(currentAngle);

if (i % 2 == 0) {
painter.setPen(QPen(Qt::black, 2));
painter.drawLine(80, 0, 95, 0);

painter.translate(70, 0);
painter.rotate(-currentAngle);
QRect textRect(-15, -10, 30, 20);
QFont font = painter.font();
font.setPointSize(6);
painter.setFont(font);
painter.drawText(textRect, Qt::AlignCenter, QString::number(i * 10));
} else {
painter.setPen(QPen(Qt::black, 1));
painter.drawLine(80, 0, 95, 0);
}
painter.restore();
}

// 镂空指针
painter.save();
qreal speedAngle = startAngle + (m_speed / max_speed) * rangeAngle;
painter.rotate(speedAngle);

QPainterPath bigNeedle;
bigNeedle.addPolygon(QPolygonF({QPointF(0, 4), QPointF(80, 0), QPointF(0, -4)}));
QPainterPath smallNeedle;
smallNeedle.addPolygon(QPolygonF({QPointF(4, 2), QPointF(60, 0), QPointF(4, -2)}));
QPainterPath finalNeedle = bigNeedle.subtracted(smallNeedle);

painter.setBrush(QColor(255, 60, 60));
painter.setPen(QPen(Qt::darkRed, 1));
painter.drawPath(finalNeedle);
painter.restore();

// 单位
painter.save();
QRect textRect(-20, 60, 40, 20);
QFont font = painter.font();
font.setPointSize(12);
painter.setFont(font);
painter.drawText(textRect, Qt::AlignCenter, "Km/h");
painter.restore();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <QHBoxLayout>
#include <QPushButton>
#include <QLineEdit>
#include "MainWin.h"
#include "SpeedWidget.h"

MainWin::MainWin(QWidget *parent) : QWidget(parent)
{
auto main_layout = new QVBoxLayout(this);

auto ctl_widget = new QWidget(this);
auto ctl_layout = new QHBoxLayout(ctl_widget);
auto btn_start = new QPushButton("速度设置");
auto edt_value = new QLineEdit;
ctl_layout->addWidget(edt_value);
ctl_layout->addWidget(btn_start);

auto m_speed_meter = new SpeedWidget();
main_layout->addWidget(m_speed_meter);
main_layout->addWidget(ctl_widget);
main_layout->setStretch(0, 9);
main_layout->setStretch(1, 1);
resize(400, 400);

connect(btn_start, &QPushButton::clicked, this, [m_speed_meter, edt_value]() {
bool ok = false;
double v = edt_value->text().toDouble(&ok);
if (ok) {
m_speed_meter->animateToSpeed(v);
}
});
}