从零手搓一个 Qt 自定义控件:会渐变、会呼吸的 StatusLed 状态灯
相关仓库仍然已经开源,正在积极火热的建设之中,欢迎各位大佬提Issue和PR!
链接地址:https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt
静态网站一键直达:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeQt/
StatusLED 是个状态指示灯------服务器面板上那种绿、黄、红的小圆点。听起来简单到好像不值得单做一个控件,QLabel 贴个色块不就行了?但我们偏要把它当成「手搓自定义控件」的第一件成品认真搓一遍,因为它体量小,却能把一个正经自绘控件该有的东西占全了:Q_PROPERTY 全套、动画驱动绘制、尺寸契约,还有实打实的踩坑。
这一篇我们就从空白的 QWidget 开始,一行行把一个会颜色平滑过渡 、会正弦呼吸的状态灯搓出来。先把目标说清楚,再动手。

它要做成什么样
一个 AwesomeQt::StatusLED 控件,得做到这几件事:
- 四种状态 :
NORMAL(绿)/WARNING(琥珀)/ERROR(红)/OFFLINE(灰) - 状态切换颜色平滑过渡 :不突变,300ms
OutCubic缓动,由QPropertyAnimation驱动一个QColor属性 - 三种闪烁模式 :
None(不闪)/OnOff(生硬明灭)/Breathing(正弦呼吸) - 完整 Q_PROPERTY :
status/color/blinkMode/ledSize四个属性都能被动画、Qt Designer、状态机驱动
听起来不少,但拆开看,核心其实就是「自绘一个圆 + 用动画驱动它的颜色」。我们一步步来。
整体架构:三条互不干涉的动画通道
动手前先建立大局观,这是整份代码最关键的设计。
一个 StatusLED 同时「拥有」三个动画/定时器对象,各写各的成员变量,谁也不碰谁:
cpp
QPropertyAnimation* color_anim_{nullptr}; // 颜色过渡 → 写 current_color_
QVariantAnimation* breathing_anim_{nullptr}; // 呼吸 → 写 breathing_factor_
QTimer* onoff_timer_{nullptr}; // 明灭 → 写 onoff_visible_
color_anim_ 负责状态切换时的颜色过渡,每帧把插值色写进 current_color_;breathing_anim_ 负责呼吸,把一个 0...1 的亮度因子写进 breathing_factor_;onoff_timer_ 负责生硬明灭,翻转一个 onoff_visible_ 布尔。它们写的变量不一样,所以「边过渡颜色、边呼吸」天然并行,不用为「过渡中要不要暂停呼吸」去设计什么状态机。
真正的合成发生在 paintEvent 入口------一个叫 applyDisplayTransform 的函数把过渡色当 base,再叠上呼吸亮度,算出最终绘制色。记住这个分工,后面每一步都是在填这三条通道。
第一步:画一个带高光的圆盘
先把静态的圆画出来。继承 QWidget,重写 paintEvent,用一个 QRadialGradient 做径向渐变:中心亮、边缘暗,这样圆点才有立体感,不是一坨纯色。
cpp
void StatusLED::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const QColor c = applyDisplayTransform(current_color_); // 先占位:这步可暂时当 statusColor(status_)
// 兜底:w/h 极小时半径可能为负/0,clamp 到 1(见后面踩坑⑥)
const int r = std::max(1, std::min(width(), height()) / 2 - 1);
QRadialGradient g(rect().center(), r);
g.setColorAt(0.0, c.lighter(160)); // 中心高光
g.setColorAt(0.6, c); // 中段原色
g.setColorAt(1.0, c.darker(150)); // 边缘暗
p.setPen(Qt::NoPen);
p.setBrush(g);
p.drawEllipse(rect().center(), r, r);
}
这里有个容易忽略的小坑:半径 std::min(width(), height()) / 2 - 1,当窗口被缩到极小时它会变成负数或 0,drawEllipse 行为未定义,灯会消失。所以外面包一层 std::max(1, ...)------这是后面踩坑表里的第 6 条。
这一步里的
applyDisplayTransform先别管,暂时当成直接用当前状态色就行;等第四步加上呼吸,它才真正发挥作用。
第二步:Status 枚举 + 切色
画圆的色从哪来?定义一个状态枚举,再给每个状态一个代表色。枚举放在类里,紧跟一个 Q_ENUM------这一行很关键,它让 moc 认得这个枚举,后面 Q_PROPERTY 才能用上它。
cpp
enum class Status { NORMAL, WARNING, ERROR, OFFLINE };
Q_ENUM(Status)
代表色用一个私有函数返回,别用裸数组下标,跟枚举解耦:
cpp
QColor StatusLED::statusColor(Status s) const {
switch (s) {
case Status::NORMAL: return QColor(0, 200, 0); // 绿
case Status::WARNING: return QColor(255, 180, 0); // 琥珀
case Status::ERROR: return QColor(255, 40, 40); // 红
case Status::OFFLINE: return QColor(160, 160, 160); // 灰
}
return QColor(160, 160, 160);
}
再加一个 setStatus(Status),里面改成员、调 update() 请求重绘,paintEvent 用 statusColor(status_) 取色画。到这一步,状态切换是「突变」的------直接换色,没有过渡。别急,过渡是下一步的重头戏。
第三步:把颜色暴露成 Q_PROPERTY,让动画驱动它(核心)
这是整个控件最精妙的一步。思路是:别手写 RGB 插值 ,Qt 内置了 QColor 的插值器,只要把「当前显示色」用一个 Q_PROPERTY 暴露出去,QPropertyAnimation 就能按名字驱动它,每帧写一个插值色进来。
先在头里挂上属性:
cpp
Q_PROPERTY(Status status READ status WRITE setStatus NOTIFY statusChanged)
Q_PROPERTY(QColor color READ color WRITE setAnimatedColor NOTIFY colorChanged)
Q_PROPERTY(BlinkMode blinkMode READ blinkMode WRITE setBlinkMode NOTIFY blinkModeChanged)
Q_PROPERTY(int ledSize READ ledSize WRITE setLedSize NOTIFY ledSizeChanged)
注意 color 这条:它的 WRITE 指向的是 setAnimatedColor,不是 setStatus。这是刻意设计的,也是最容易踩的坑------
setStatus是业务入口,它会启动动画;setAnimatedColor是动画每帧的回调 ,它只做赋值 +emit+update(),绝不启动动画。
如果 color 的 WRITE 错指向 setStatus,就会变成:动画每帧驱动 setStatus → setStatus 又启动动画 → 再驱动 setStatus......无限递归,直接栈溢出。所以 WRITE 必须指向那个「纯赋值」的回调:
cpp
void StatusLED::setAnimatedColor(const QColor& color) {
if (current_color_ == color) return;
current_color_ = color;
emit colorChanged(color);
update(); // 异步请求重绘,不立即触发 paintEvent
}
动画对象本身用持久成员指针 ,构造时建好,反复 stop()/重配/start() 复用,不要用 DeleteWhenStopped------否则频繁切换时对象被 delete,下一次持指针调用就悬空崩溃:
cpp
void StatusLED::initColorAnimation() {
color_anim_ = new QPropertyAnimation(this, "color", this); // parent=this,对象树托管释放
color_anim_->setDuration(300);
color_anim_->setEasingCurve(QEasingCurve::OutCubic);
}
最后是 setStatus 的接力逻辑,这是过渡丝滑的关键:切换时从当前显示色(可能是上次过渡还没跑完的中间值)接到新目标色,而不是从上一次的目标色重启。这样快速连切时颜色连续过渡,不跳变:
cpp
void StatusLED::setStatus(Status new_status) {
if (status_ == new_status) return;
status_ = new_status;
emit statusChanged(new_status);
color_anim_->stop();
color_anim_->setStartValue(current_color_); // 从当前显示色接力
color_anim_->setEndValue(statusColor(new_status));
color_anim_->start();
}
到这里,状态切换已经是 300ms 的平滑过渡,而不是突变了。
第四步:呼吸和明灭------再开两条独立通道
闪烁我收进一个枚举:enum class BlinkMode { None, OnOff, Breathing }。OnOff 是老式生硬明灭,用一个 QTimer 每 500ms 翻转布尔;Breathing 是正弦呼吸,用一个 QVariantAnimation 无限循环驱动一个 0...1 的亮度因子:
cpp
void StatusLED::initBreathingAnimation() {
breathing_anim_ = new QVariantAnimation(this);
breathing_anim_->setDuration(1400);
breathing_anim_->setStartValue(0.0);
breathing_anim_->setEndValue(0.0);
breathing_anim_->setKeyValueAt(0.5, 1.0); // 0 → 1 → 0
breathing_anim_->setEasingCurve(QEasingCurve::InOutSine);
breathing_anim_->setLoopCount(-1); // 无限循环
connect(breathing_anim_, &QVariantAnimation::valueChanged, this,
[this](const QVariant& value) {
breathing_factor_ = value.toDouble();
update();
});
}
注意呼吸动画写的是 breathing_factor_,和颜色过渡写的 current_color_ 是两个完全独立的变量 ------这就是开头说的「三条互不干涉的通道」。它们在 paintEvent 入口的 applyDisplayTransform 里才合成:先用过渡色做 base,再按呼吸因子在暗↔亮之间线性插值。

cpp
QColor StatusLED::applyDisplayTransform(const QColor& base) const {
if (blink_mode_ == BlinkMode::OnOff && !onoff_visible_) {
return base.darker(400); // 熄灭
}
if (blink_mode_ == BlinkMode::Breathing) {
const QColor dim = base.darker(280);
const QColor bright = base.lighter(140);
const double t = breathing_factor_; // 0..1
auto lerp = [](int a, int b, double f) {
return static_cast<int>(a + (b - a) * f);
};
return QColor(lerp(dim.red(), bright.red(), t),
lerp(dim.green(), bright.green(), t),
lerp(dim.blue(), bright.blue(), t));
}
return base;
}
因为过渡和呼吸写在不同变量上,它们正交,所以「边过渡颜色边呼吸」根本不需要额外协调------这就是解耦带来的好处。
第五步:给布局系统一个尺寸契约
最后补两个尺寸函数,让布局系统知道这个控件有多大、最小能缩到多小。缩窗时 LED 不会被裁切到看不见:
cpp
QSize StatusLED::sizeHint() const {
return QSize(led_size_, led_size_);
}
QSize StatusLED::minimumSizeHint() const {
const int min_side = std::max(8, led_size_ / 2);
return QSize(min_side, min_side);
}
到这里,一个会渐变、会呼吸的 StatusLed 就搓完了。
几个关键设计决策,为什么这么写
复盘一下,这套实现里有几个值得记住的取舍:
- 颜色过渡走
Q_PROPERTY(QColor color),不手写 RGB 插值。 Qt 内置QColor插值器(RGB 线性),QPropertyAnimation(this, "color")直接能用,省掉手写 lerp。代价是绿→红中间一帧偏暗褐,但 300ms + OutCubic 快速逼近终点,肉眼可接受。 color_anim_用持久成员指针,不用DeleteWhenStopped。 「每次 new、停了自动 delete」的写法在频繁切换时反复 new/delete,且别处持指针会悬空。持久指针 +stop()/setStartValue(current_color_)/start()从当前显示色接力,连切不跳变、不崩。- 过渡色与呼吸因子解耦,
applyDisplayTransform合成。 两个独立变量,paintEvent 入口才合成,天然正交、可并行,省掉「过渡中要不要暂停呼吸」的状态机。 - BlinkMode 枚举收编三种闪烁。
None/OnOff/Breathing一个枚举讲清;旧的setBlinking(bool)留着映射到OnOff/None,老 demo 不用改。 - 诚实承认 RGB 插值中间色问题,把 HSV 留给进阶。 不装没问题。要鲜艳过渡,
qRegisterAnimationInterpolator注册 HSV 插值器是正路。
踩坑实录
搓的过程中,下面这些坑都是真踩过的,按「现象 → 原因 → 解法」列出来:
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| ① | 频繁切换状态偶发崩溃 | color_anim_ 用 DeleteWhenStopped,stop 后被 delete、指针悬空 |
持久成员指针 + stop()/重配/start() |
| ② | 绿→红过渡中间一帧偏暗褐 | Qt 对 QColor 默认 RGB 线性插值,绿红中间是橄榄色 |
OutCubic + 300ms 掩盖;要鲜艳就注册 HSV 插值器 |
| ③ | 以为过渡时呼吸会让颜色乱跳 | 误解:过渡和呼吸写在不同变量,正交 | 认清 current_color_ 与 breathing_factor_ 解耦,合成即可并行 |
| ④ | 以为每帧 new QRadialGradient 有性能问题 |
误判 | 有意不缓存:LED 像素量极小,60fps 可忽略 |
| ⑤ | 动画回调里用了 repaint() |
repaint() 同步立即重绘,不等事件循环 |
一律用 update()(异步合并) |
| ⑥ | 窗口缩到极小时 LED 消失 | 半径 min(w,h)/2-1 在 w/h 极小时为负/0 |
std::max(1, ...) 兜底 |
| ⑦ | color 的 WRITE 错指向 setStatus |
动画驱动 setStatus → 又启动画 → 无限递归 |
WRITE 指 setAnimatedColor(纯赋值 + emit + update) |
完整源码 & 延伸阅读
这篇只贴了关键片段,完整实现(头文件、cpp、demo)都在仓库里:widget/status-led/,拉下来 cmake -B build && cmake --build build 就能跑 demo 对照。
想再往深挖,下面是几个关键的官方文档:
- QPropertyAnimation------属性动画,颜色过渡用的就是它
- QVariantAnimation------值动画,呼吸用的就是它
- Qt 属性系统(Q_PROPERTY)------为什么
color能被动画按名字驱动 - QRadialGradient------径向渐变高光
- QColor::darker / lighter------亮度调制(呼吸 / 熄灭)
这套「Q_PROPERTY + 动画驱动 + 解耦合成」的骨架不是 StatusLED 专属的------它就是「一个能被动画驱动的自绘控件」的标准范式。toggle-switch、circle-progress、speed-meter 后面都会换皮复用同一套。想自己搓一遍?完整代码就在仓库里,照着这篇一步步填,跑起来对照 demo,你就拥有了自己的 StatusLed。