016.5-小项目-仿时钟

auther: abinng date: 2026-05-18 09:07 createDate:2026-05-18 09:06

复习路线

这篇笔记是一个综合实战——用 Qt 的绘图系统和动画框架,从零实现一个可交互的拟物化时钟控件。

涉及的知识点:

1
2
3
4
5
QPainter 坐标系变换 → 绘制 60 个刻度 + 三根针(时/分/秒)
→ Q_PROPERTY + QPropertyAnimation 实现平滑扫秒
→ QTimer 驱动时间推进
→ 信号/槽连接控件与 LCD 面板
→ 日间/夜间主题切换、手动设置时间

建议先读完 015-Qt 的绘图系统016-小项目-仿汽车仪表盘 之后再来看这篇,它们分别覆盖了基础绘图和 Q_PROPERTY 动画模式。

1. 效果描述

实现一个拟物化时钟控件,具备以下功能:

  • 圆形表盘,60 个刻度(每 5 个加粗),时针/分针/秒针
  • 两种走针模式:平滑扫秒(秒针连续转动)和机械跳动(秒针每秒跳一格)
  • 日间/夜间主题切换
  • 右侧控制面板:数字时间显示(LCD)、走针模式、主题、手动设置时间

2. 核心架构

项目分为两层:

  • ABClockWidget — 时钟控件本体。负责绘制表盘、推进时间、执行动画。是本文核心。
  • ABMainWin — 上层窗口。左右布局,左侧放大钟,右侧放控制面板。

ABMainWin 通过信号槽连接各个按钮到 ABClockWidget 的接口:

1
2
3
4
5
6
7
ABMainWin (窗口层)
├── ABClockWidget (时钟控件) ← setSmoothMode / setDarkMode / setManualTime
├── QLCDNumber ← 接收 timeUpdated 信号更新数字显示
└── 控制面板 (RadioButton/PushButton/QTimeEdit)
- 走针模式 → setSmoothMode(bool)
- 主题切换 → setDarkMode(bool)
- 手动设时 → setManualTime(QTime)

3. 时间推进机制

时钟需要每秒走一格。核心是用 QTimer 每 1000ms 触发一次,在槽函数里推进时间:

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
// 构造中启动定时器
m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout, this, &ABClockWidget::updateTime);
m_timer->start(1000); // 严格每秒一次

void ABClockWidget::updateTime()
{
m_currentTime = m_currentTime.addSecs(1); // 时间推进 1 秒
m_hour = m_currentTime.hour();
m_minute = m_currentTime.minute();
int nextSecond = m_currentTime.second();

if (m_isSmooth) {
// 平滑模式:用动画从当前秒滑到下一秒
m_anim->stop();
qreal startSecond = fmod(m_second, 60.0);
qreal endSecond = nextSecond;
if (endSecond == 0.0) {
endSecond = 60.0; // 解决 59→0 的逆时针回绕问题
}
m_anim->setStartValue(startSecond);
m_anim->setEndValue(endSecond);
m_anim->start();
} else {
// 机械模式:直接跳到目标秒
setSecond(nextSecond);
}

emit timeUpdated(m_currentTime.toString("hh:mm:ss"));
}

这里有一个容易忽略的细节:当秒针从 59 走向 0 时,如果 endSecond = 0,动画会让秒针逆时针倒转一圈回到 0(因为从 59 补间到 0 是数值下降)。解决办法很简单——当目标值为 0 时,把动画终点设为 60.0。绘制时通过 fmod(second, 60.0) 把 60° 映射回 0° 的位置,视觉上就是顺时针平滑跨过 12 点方向。

4. 绘制表盘

绘制在 paintEvent() 中完成。首先把坐标系变换到中心,缩放到 [-100, 100]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ABClockWidget::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, H / 2);
painter.scale(side / 200.0, side / 200.0);

// 日间/夜间配色
QColor bgColor = m_isDark ? QColor(40, 44, 52) : QColor(245, 245, 240);
QColor tickColor = m_isDark ? Qt::white : Qt::black;

// 绘制圆形底板
painter.setPen(QPen(QColor(60, 65, 70), 4));
painter.setBrush(bgColor);
painter.drawEllipse(-98, -98, 196, 196);

4.1 绘制刻度

60 个刻度均匀分布在圆周上,每步旋转 6°。每 5 个刻度加粗:

1
2
3
4
5
6
7
8
9
10
11
painter.setPen(tickColor);
for (int i = 0; i < 60; ++i) {
if (i % 5 == 0) {
painter.setPen(QPen(tickColor, 2));
painter.drawLine(86, 0, 95, 0); // 小时刻度(粗长)
} else {
painter.setPen(QPen(tickColor, 1));
painter.drawLine(91, 0, 95, 0); // 分钟刻度(细短)
}
painter.rotate(6.0);
}

这里利用了 painter.rotate(6.0) 不重置的特性——每次 rotate 是累加在之前的角度上的,60 次刚好回到起点。

4.2 绘制三根针

关键认知:QPainter 的 0° 方向是 3 点钟方向(水平向右)。而时钟的 12 点在正上方(相当于 270°)。所以每根针的最终角度 = 270° + 该针对应的角度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
qreal offset = 270.0;  // 补偿 0° 方向

// 时针:12小时一圈,每小时30°,加上分钟微调
painter.save();
painter.rotate(offset + (m_hour % 12) * 30.0 + m_minute / 2.0);
painter.setPen(QPen(tickColor, 6, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(0, 0, 50, 0);
painter.restore();

// 分针:60分钟一圈,每分钟6°,加上秒的微调
painter.save();
painter.rotate(offset + m_minute * 6.0 + fmod(m_second, 60.0) * 0.1);
painter.setPen(QPen(Qt::gray, 4, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(0, 0, 75, 0);
painter.restore();

// 秒针:60秒一圈,每秒6°
painter.save();
painter.rotate(offset + fmod(m_second, 60.0) * 6.0);
painter.setPen(QPen(QColor(230, 50, 50), 2, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(-15, 0, 85, 0); // 尾巴向后延伸15,针尖向前85
painter.setBrush(QColor(230, 50, 50));
painter.drawEllipse(-4, -4, 8, 8); // 中心红色圆点
painter.restore();

为什么 fmod(m_second, 60.0)?因为平滑扫秒模式下,m_second 在 59→60 过渡时会变成 60.0,用 fmod 取模后映射回 6° 的位置,指针不会跳回 0°。

5. 手动设置时间

用户通过 QTimeEdit 选择一个时间后,点击按钮调用 setManualTime()

1
2
3
4
5
6
7
8
9
10
void ABClockWidget::setManualTime(const QTime &time)
{
m_anim->stop(); // 停止当前动画
m_currentTime = time;
m_hour = time.hour();
m_minute = time.minute();
m_second = time.second();
update();
emit timeUpdated(m_currentTime.toString("hh:mm:ss"));
}

停止动画是为了避免旧动画继续修改 m_second,和新设的值产生冲突。

6. 主题切换

日间/夜间模式只需要切换两个颜色变量,然后 update() 重绘:

1
2
3
4
5
void ABClockWidget::setDarkMode(bool enable)
{
m_isDark = enable;
update();
}

paintEvent() 中根据 m_isDark 选择配色——深色底板 + 白色刻度和指针,或者浅色底板 + 黑色刻度指针。

7. 完整代码

点击展开
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
#ifndef ABCLOCKWIDGET_H
#define ABCLOCKWIDGET_H
#include <QTime>
#include <QTimer>
#include <QWidget>
#include <QPropertyAnimation>

class ABClockWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal second READ getSecond WRITE setSecond)

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

qreal getSecond() const;
void setSecond(qreal second);
void setSmoothMode(bool enable);
void setDarkMode(bool enable);
void setManualTime(const QTime &time);

signals:
void timeUpdated(const QString &timeStr);

protected:
void paintEvent(QPaintEvent *event) override;

private slots:
void updateTime();

private:
qreal m_second;
int m_minute, m_hour;
QTimer *m_timer;
QPropertyAnimation *m_anim;
bool m_isDark, m_isSmooth;
QTime m_currentTime;
};

#endif //ABCLOCKWIDGET_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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include "ABClockWidget.h"
#include <QPainter>
#include <cmath>

ABClockWidget::ABClockWidget(QWidget *parent) : QWidget(parent)
{
m_isDark = false;
m_isSmooth = true;
m_currentTime = QTime::currentTime();

m_hour = m_currentTime.hour();
m_minute = m_currentTime.minute();
m_second = m_currentTime.second();

m_anim = new QPropertyAnimation(this, "second");
m_anim->setDuration(1000);
m_anim->setEasingCurve(QEasingCurve::Linear);

m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout, this, &ABClockWidget::updateTime);
m_timer->start(1000);
}

qreal ABClockWidget::getSecond() const { return m_second; }

void ABClockWidget::setSecond(qreal second)
{
m_second = second;
update();
}

void ABClockWidget::setSmoothMode(bool enable)
{
m_isSmooth = enable;
if (!m_isSmooth) {
m_anim->stop();
setSecond(m_currentTime.second());
}
}

void ABClockWidget::setDarkMode(bool enable)
{
m_isDark = enable;
update();
}

void ABClockWidget::setManualTime(const QTime &time)
{
m_anim->stop();
m_currentTime = time;
m_hour = time.hour();
m_minute = time.minute();
m_second = time.second();
update();
emit timeUpdated(m_currentTime.toString("hh:mm:ss"));
}

void ABClockWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing);

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

QColor bgColor = m_isDark ? QColor(40, 44, 52) : QColor(245, 245, 240);
QColor tickColor = m_isDark ? Qt::white : Qt::black;

painter.setPen(QPen(QColor(60, 65, 70), 4));
painter.setBrush(bgColor);
painter.drawEllipse(-98, -98, 196, 196);

painter.setPen(tickColor);
for (int i = 0; i < 60; ++i) {
if (i % 5 == 0) {
painter.setPen(QPen(tickColor, 2));
painter.drawLine(86, 0, 95, 0);
} else {
painter.setPen(QPen(tickColor, 1));
painter.drawLine(91, 0, 95, 0);
}
painter.rotate(6.0);
}

qreal offset = 270.0;

painter.save();
painter.rotate(offset + (m_hour % 12) * 30.0 + m_minute / 2.0);
painter.setPen(QPen(tickColor, 6, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(0, 0, 50, 0);
painter.restore();

painter.save();
painter.rotate(offset + m_minute * 6.0 + std::fmod(m_second, 60.0) * 0.1);
painter.setPen(QPen(Qt::gray, 4, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(0, 0, 75, 0);
painter.restore();

painter.save();
painter.rotate(offset + std::fmod(m_second, 60.0) * 6.0);
painter.setPen(QPen(QColor(230, 50, 50), 2, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(-15, 0, 85, 0);
painter.setBrush(QColor(230, 50, 50));
painter.drawEllipse(-4, -4, 8, 8);
painter.restore();
}

void ABClockWidget::updateTime()
{
m_currentTime = m_currentTime.addSecs(1);
m_hour = m_currentTime.hour();
m_minute = m_currentTime.minute();
int nextSecond = m_currentTime.second();

if (m_isSmooth) {
m_anim->stop();
qreal startSecond = std::fmod(m_second, 60.0);
qreal endSecond = nextSecond;
if (endSecond == 0.0) {
endSecond = 60.0;
}
m_anim->setStartValue(startSecond);
m_anim->setEndValue(endSecond);
m_anim->start();
} else {
setSecond(nextSecond);
}

emit timeUpdated(m_currentTime.toString("hh:mm:ss"));
}
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
#include "ABMainWin.h"
#include "ABClockWidget.h"

#include <QGroupBox>
#include <QHBoxLayout>
#include <QLCDNumber>
#include <QPushButton>
#include <QRadioButton>
#include <QTimeEdit>
#include <QVBoxLayout>

ABMainWin::ABMainWin(QWidget *parent) : QWidget(parent)
{
auto main_layout = new QHBoxLayout(this);

m_clock = new ABClockWidget(this);
m_clock->setMinimumSize(300, 300);

auto ctl_layout = new QVBoxLayout();

auto group_lcd = new QGroupBox("数字时间");
auto layout_lcd = new QHBoxLayout(group_lcd);
m_lcd = new QLCDNumber(group_lcd);
m_lcd->setDigitCount(8);
m_lcd->setSegmentStyle(QLCDNumber::Flat);
m_lcd->display(QTime::currentTime().toString("hh:mm:ss"));
layout_lcd->addWidget(m_lcd);

auto group_mode = new QGroupBox("走针模式");
auto layout_mode = new QHBoxLayout(group_mode);
auto btn_mech = new QRadioButton("机械跳动");
auto btn_smooth = new QRadioButton("平滑扫秒");
btn_smooth->setChecked(true);
layout_mode->addWidget(btn_mech);
layout_mode->addWidget(btn_smooth);

auto group_theme = new QGroupBox("主题");
auto layout_theme = new QHBoxLayout(group_theme);
auto btn_day = new QPushButton("日间模式");
auto btn_night = new QPushButton("夜间模式");
layout_theme->addWidget(btn_day);
layout_theme->addWidget(btn_night);

auto group_time = new QGroupBox("设置时间");
auto layout_time = new QHBoxLayout(group_time);
auto time_edit = new QTimeEdit(QTime::currentTime());
time_edit->setDisplayFormat("hh:mm:ss");
auto btn_go = new QPushButton("跳转");
layout_time->addWidget(time_edit);
layout_time->addWidget(btn_go);

ctl_layout->addWidget(group_lcd);
ctl_layout->addWidget(group_mode);
ctl_layout->addWidget(group_theme);
ctl_layout->addWidget(group_time);
ctl_layout->addStretch();

main_layout->addWidget(m_clock, 2);
main_layout->addLayout(ctl_layout, 1);

connect(m_clock, &ABClockWidget::timeUpdated, this,
[this](const QString &timeStr) { m_lcd->display(timeStr); });
connect(btn_mech, &QRadioButton::toggled, this,
[this](bool c) { if (c) m_clock->setSmoothMode(false); });
connect(btn_smooth, &QRadioButton::toggled, this,
[this](bool c) { if (c) m_clock->setSmoothMode(true); });
connect(btn_day, &QPushButton::clicked, this, [this]() { m_clock->setDarkMode(false); });
connect(btn_night, &QPushButton::clicked, this, [this]() { m_clock->setDarkMode(true); });
connect(btn_go, &QPushButton::clicked, this, [this, time_edit]() {
m_clock->setManualTime(time_edit->time());
});
}

8. 两个版本对比

项目里其实有两套实现——ClockWidgetABClockWidget(“AB” 意为 “A Better”,即改进版)。它们功能相同,区别在动画的边界处理:

对比点 ClockWidget(初版) ABClockWidget(改进版)
59→0 处理 检测 m_second > 45 && target < 15endSec += 60 直接判断 endSecond == 0.0 则设为 60.0
时针微调 m_minute / 2.0 同上
分针微调 m_second / 10.0(等价于 * 0.1 fmod(m_second, 60.0) * 0.1(对平滑模式更安全)

改进版的逻辑更简洁直白,推荐以改进版为准。