Пользовательский интерфейс в Qt

Анимации переходов состояний через QStyle и QSS одновременно

Введение

QSS поддерживает базовые CSS-подобные transition-свойства для плавного изменения некоторых параметров при смене состояния виджета, но поддержка анимаций в чистом QSS ограничена. В статье разберём, как комбинировать QSS-анимации с программными анимациями через QPropertyAnimation для создания более выразительных переходов между визуальными состояниями виджетов.

Концепция

QSS поддерживает свойство transition для плавного изменения некоторых свойств (в первую очередь — цветов фона/границы) при смене псевдосостояния, но эта поддержка значительно более ограничена, чем в полноценном CSS — не все свойства поддерживают плавный переход, и сложные, нелинейные анимации недостижимы только через QSS. Для более выразительных переходов (изменение размера, позиции, прозрачности с кастомной кривой анимации) используется QPropertyAnimation, работающий поверх обычных Qt-свойств виджета, что комбинируется с базовым QSS-стилем для статичного оформления.

Пример кода: Анимации переходов состояний

app.setStyleSheet(R"(
    QPushButton {
        background-color: #2563eb;
        border-radius: 6px;
        color: white;
        padding: 10px 20px;
    }

    /* Базовая поддержка transition в QSS — плавное изменение цвета фона при hover */
    QPushButton:hover {
        background-color: #1d4ed8;
    }
)");
// animatedtogglebutton.h — программная анимация позиции "ползунка" переключателя
// через QPropertyAnimation, скомбинированная с QSS для статичного оформления
#pragma once
#include <QWidget>
#include <QPropertyAnimation>
#include <QPainter>
#include <QMouseEvent>

class AnimatedToggleSwitch : public QWidget
{
    Q_OBJECT
    Q_PROPERTY(qreal handlePosition READ handlePosition WRITE setHandlePosition)

public:
    explicit AnimatedToggleSwitch(QWidget *parent = nullptr) : QWidget(parent)
    {
        setFixedSize(50, 26);
        m_animation = new QPropertyAnimation(this, "handlePosition", this);
        m_animation->setDuration(200);
        m_animation->setEasingCurve(QEasingCurve::OutCubic); // нелинейная кривая —
                                                              // недостижимо средствами одного QSS
    }

    qreal handlePosition() const { return m_handlePosition; }
    void setHandlePosition(qreal position)
    {
        m_handlePosition = position;
        update(); // запрос перерисовки при каждом шаге анимации
    }

    bool isChecked() const { return m_checked; }

protected:
    void mousePressEvent(QMouseEvent *event) override
    {
        Q_UNUSED(event);
        m_checked = !m_checked;

        m_animation->stop();
        m_animation->setStartValue(m_handlePosition);
        m_animation->setEndValue(m_checked ? 1.0 : 0.0);
        m_animation->start();

        emit toggled(m_checked);
    }

    void paintEvent(QPaintEvent *event) override
    {
        Q_UNUSED(event);
        QPainter painter(this);
        painter.setRenderHint(QPainter::Antialiasing);

        // Фон переключателя плавно меняет цвет в зависимости от текущей позиции ползунка
        const QColor backgroundColor = lerpColor(QColor("#d1d5db"), QColor("#2563eb"), m_handlePosition);
        painter.setPen(Qt::NoPen);
        painter.setBrush(backgroundColor);
        painter.drawRoundedRect(rect(), height() / 2.0, height() / 2.0);

        // Позиция ползунка вычисляется из анимируемого свойства handlePosition (0.0–1.0)
        const double handleX = 3 + m_handlePosition * (width() - height());
        painter.setBrush(Qt::white);
        painter.drawEllipse(QRectF(handleX, 3, height() - 6, height() - 6));
    }

signals:
    void toggled(bool checked);

private:
    static QColor lerpColor(const QColor &from, const QColor &to, qreal t)
    {
        return QColor::fromRgb(
            from.red() + (to.red() - from.red()) * t,
            from.green() + (to.green() - from.green()) * t,
            from.blue() + (to.blue() - from.blue()) * t);
    }

    qreal m_handlePosition = 0.0;
    bool m_checked = false;
    QPropertyAnimation *m_animation;
};

Пояснения к коду

Q_PROPERTY(qreal handlePosition ...) объявляет анимируемое Qt-свойство — именно через эту систему свойств QPropertyAnimation способен плавно изменять значение от начального до конечного, вызывая setHandlePosition() на каждом шаге анимации (определяемом частотой обновления и заданной длительностью). setEasingCurve(QEasingCurve::OutCubic) задаёт нелинейную, «пружинящую» кривую изменения значения во времени — переключатель быстро начинает движение и плавно замедляется к концу, что выглядит заметно более естественно, чем линейное движение, и недостижимо средствами одного только базового QSS transition.

paintEvent() использует текущее значение m_handlePosition (изменяемое анимацией) для вычисления как цвета фона (через линейную интерполяцию lerpColor), так и позиции самого ползунка — поскольку оба визуальных аспекта зависят от одного и того же анимируемого значения, они естественным образом синхронизированы между собой без необходимости в отдельной координации.

Подводные камни

  • QSS transition поддерживается ограниченным набором свойств и стилей, и попытка положиться только на него для сложных, многоступенчатых анимаций обычно заканчивается разочарованием в возможностях — для чего-то более выразительного, чем простое изменение цвета при hover, практически всегда нужен QPropertyAnimation или ручная отрисовка через paintEvent(), как в примере с переключателем.
  • Конфликт между QSS-стилизацией виджета и кастомной отрисовкой через paintEvent() — если виджет одновременно имеет применённый QSS (например, заданный фон через background-color) и переопределённый paintEvent(), который сам рисует фон, может произойти неожиданное визуальное наложение или, наоборот, один из способов окажется проигнорирован — для виджетов с полностью кастомной отрисовкой (как AnimatedToggleSwitch) обычно не применяют QSS к самому этому виджету вовсе, оставляя всю стилизацию полностью внутри paintEvent().
  • Забытый вызов update() после изменения анимируемого свойства — без этого вызова Qt не узнает, что виджет нуждается в перерисовке, и анимация, формально продолжающая изменять значение свойства, не будет видна на экране; в примере setHandlePosition() явно вызывает update() именно по этой причине, и пропуск этого шага — частая причина «анимации, которая просто не анимируется визуально».
  • Слишком частые вызовы update() во время анимации могут создавать заметную нагрузку на CPU/GPU, особенно при множестве одновременно анимируемых виджетов на экране — для embedded-устройств со слабым железом (основной цикл статей, статья 242) стоит ограничивать количество и сложность одновременных программных анимаций, выбирая, действительно ли конкретный визуальный эффект оправдывает связанные с ним накладные расходы на рендеринг.