从零手搓一个 Qt 自定义控件:会渐变、会呼吸的 StatusLed 状态灯

从零手搓一个 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() 请求重绘,paintEventstatusColor(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(),绝不启动动画。

如果 colorWRITE 错指向 setStatus,就会变成:动画每帧驱动 setStatussetStatus 又启动动画 → 再驱动 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 就搓完了。

几个关键设计决策,为什么这么写

复盘一下,这套实现里有几个值得记住的取舍:

  1. 颜色过渡走 Q_PROPERTY(QColor color),不手写 RGB 插值。 Qt 内置 QColor 插值器(RGB 线性),QPropertyAnimation(this, "color") 直接能用,省掉手写 lerp。代价是绿→红中间一帧偏暗褐,但 300ms + OutCubic 快速逼近终点,肉眼可接受。
  2. color_anim_ 用持久成员指针,不用 DeleteWhenStopped 「每次 new、停了自动 delete」的写法在频繁切换时反复 new/delete,且别处持指针会悬空。持久指针 + stop()/setStartValue(current_color_)/start() 从当前显示色接力,连切不跳变、不崩。
  3. 过渡色与呼吸因子解耦,applyDisplayTransform 合成。 两个独立变量,paintEvent 入口才合成,天然正交、可并行,省掉「过渡中要不要暂停呼吸」的状态机。
  4. BlinkMode 枚举收编三种闪烁。 None/OnOff/Breathing 一个枚举讲清;旧的 setBlinking(bool) 留着映射到 OnOff/None,老 demo 不用改。
  5. 诚实承认 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, ...) 兜底
colorWRITE 错指向 setStatus 动画驱动 setStatus → 又启动画 → 无限递归 WRITEsetAnimatedColor(纯赋值 + emit + update)

完整源码 & 延伸阅读

这篇只贴了关键片段,完整实现(头文件、cpp、demo)都在仓库里:widget/status-led/,拉下来 cmake -B build && cmake --build build 就能跑 demo 对照。

想再往深挖,下面是几个关键的官方文档:

这套「Q_PROPERTY + 动画驱动 + 解耦合成」的骨架不是 StatusLED 专属的------它就是「一个能被动画驱动的自绘控件」的标准范式。toggle-switch、circle-progress、speed-meter 后面都会换皮复用同一套。想自己搓一遍?完整代码就在仓库里,照着这篇一步步填,跑起来对照 demo,你就拥有了自己的 StatusLed。