Создаем сложные интерфейсы и спецэффекты на базе Qt. Часть I

npk5h-oqakfibstggen3wudn9gs.jpeg


Привет, Хабр! Меня зовут Михаил Полукаров, я занимаюсь разработкой desktop-версии в команде VK Teams. Каждый день нашим мессенджером пользуются миллионы людей, поэтому мы уделяем особое внимание интерфейсу пользователя. Он должен быть не только функциональным и отзывчивым, но ещё и привлекательным с эстетической точки зрения. Для создания графических интерфейсов мы используем Qt Framework, и уже накопили много опыта в решении нетривиальных задач с помощью этого инструмента. Настолько много, что статью пришлось разделить на две части. 

Под катом мы пройдём путь от создания отдельных компонентов-примеров, экспериментов с маскированием, прозрачностью, размытием и перекрывающимися компонентами до разработки небольшого демонстрационного приложения, объединяющего все полученные ранее решения.
Примечание 1. Всё, что приводится в статье, может работать, а может и не работать в зависимости от сочетания программного-аппаратного обеспечения, версии Qt Framework, версии и типа операционной системы и прочих факторов. Весь код, приведённый в статье, проверялся для Qt Framework версии 5.14.1 в ОС Window 10, Ubuntu Linux 20.1 LTS и MacOS BigSur. Для упрощения понимания весь код реализации методов написан в классе, а в production-коде настоятельно рекомендую такого избегать. Полный код примеров вы сможете найти на странице проекта в Github. Для сборки примеров потребуются установленный Qt Framework и компилятор С++17.

Примечание 2. Всё, что описывается в статье, вероятно, можно реализовать с использованием Qt QML или других языков программирования и фреймворков. Особо отмечу, что цель статьи — показать, как использовать Qt Framework исключительно с применением классического QWidget-based подхода.

Маски


На первый взгляд может показаться, что использование масок для создания окон сложной, непрямоугольной формы — пережиток прошлого и интерфейсов, модных в конце 90-х и начале 2000-х годов, достаточно вспомнить знаменитый Winamp. Однако на самом деле применимость такого подхода гораздо шире.

Представим себе такую задачу: необходимо реализовать рамку для подсветки экрана, который демонстрируется в текущий момент. В VK Teams мы используем подобный элемент при «расшаривании» экрана во время видеозвонков. В реальном приложении рамка выглядит как желтый прямоугольник по периметру экрана с небольшой кнопкой «Остановить показ экрана» в верхней части. Для упрощения кода в примерах ниже мы будем использовать ярко-зеленый цвет рамки.

_ybxcsjwwxvqclyhznjsjc37voc.jpeg


Наивный подход


Попробуем реализовать этот макет «в лоб»:

Код
// Выберем в качестве базового — класс QFrame
class SharingFrame : public QFrame
{
    Q_OBJECT
public:
    explicit SharingFrame(QScreen* _screen)
        : QFrame(Q_NULLPTR)
    {
        // Установим флаги окна — без системного заголовка
        setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt:ToolTip);
        // Атрибут Qt::WA_TranslucentBackground позволит избежать заливки 
// фона по умолчанию и сделать окно прозрачным
        setAttribute(Qt::WA_TranslucentBackground);
        
        closeButton_ = new QPushButton(this);
        // здесь может располагаться код настраивающий отображение кнопки
        // пропустим его, поскольку он неинтересен для данной задачи
        connect(closeButton_, &QPushButton::clicked, this, &SharingFrame::close);
        // Настроим расположение кнопки — по верхнему краю и по центру
        QVBoxLayout* layout = new QVBoxLayout(this);
        layout->addWidget(closeButton_, 0, Qt::AlignTop|Qt::AlignHCenter);
        // Настроим размер рамки — по размеру экрана
        if (!_screen)
            _screen = qApp->primaryScreen();
        if (!_screen)
            hide();
        else
            setGeometry(_screen->geometry());
    }

    // Прочие методы
    // ...

protected:
    void paintEvent(QPaintEvent*) Q_DECL_OVERRIDE
    {
        QPainter painter(this);
        painter.setBrush(Qt::NoBrush);
        painter.setPen(Qt::green, frameWidth());
        painter.drawRect(rect().adjusted(0, 0, -1, -1));
    }
private:
    QPushButton* closeButton_;
};


Если мы реализуем эту рамку таким образом, то лишим пользователя возможности взаимодействовать с другими окнами, находящимися под рамкой. Дело в том, что по умолчанию наше окно будет окном верхнего уровня и станет получать все события мыши и клавиатуры. Получается, нужно придумать способ сделать так, чтобы события мыши как бы «пролетали сквозь» нашу рамку. Одно из возможных решений — выставить специальный атрибут Qt::WA_TransparentForMouseEvents; это решит проблему, но создаст новую: теперь нельзя нажать на кнопку «Остановить показ». Убрать рамку в таком случае пользователь просто не сможет, поскольку события мыши будут отключены и для кнопки тоже. 

Ещё одно из возможных решений — собрать рамку из отдельных элементов: кнопка и все стороны как отдельные небольшие компоненты. Это решит проблему, но мы опять получим сложности: потребуется отслеживать и управлять не одним, а сразу пятью объектами (четыре стороны и кнопка). В результате согласовать их геометрию и поведение будет непросто, к тому же такой код сложно поддерживать и расширять.

Используем маски


Идея заключается в следующем: при наложении маски все области, не затрагиваемые ею (согласно документации Qt), не получают никаких событий, в том числе событий перерисовки и событий от мыши и клавиатуры. Таким образом мы решаем сразу пакет проблем. Теперь можно беспрепятственно пользоваться всеми приложениями вне рамки, кнопка нажимается, нет проблем с поддержкой кода — достаточно всего одного объекта. Остается только реализовать получение маски и наложить ее вызовом QWidget::setMask()

Тут следует обратить внимание на то, что маска должна меняться при изменении размеров самой рамки, в противном случае она не будет соответствовать реальным размерам, поведение и отображение рамки будет нарушено. Кроме того, надо не забыть про кнопку и добавить её в маску, иначе кнопка окажется «отрезана» маской. Исходя из этого нам дополнительно потребуется переопределить метод resizeEvent() и обновить в нём маску.

Теперь достаточно немного поменять уже написанный нами класс SharingFrame:

Код
class SharingFrame : public QFrame
{
    Q_OBJECT
public:
    explicit SharingFrame(QScreen* screen = Q_NULLPTR)
        : QFrame(Q_NULLPTR)
    {
        // Установим флаги окна — без системного заголовка
        setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::ToolTip);
        // Атрибут Qt::WA_TranslucentBackground больше не нужен
        // setAttribute(Qt::WA_TranslucentBackground);
        //
        // далее без изменений

        // Установим кастомный стиль через qss, см. описание ниже
        setStyleSheet("background: rgb(0, 255, 0); border: 4px solid rgb(0, 255, 0)”);
    }

    // Прочие методы
    // ...

protected:
    // Переопределение метода paintEvent() больше не требуется:
    // отрезанные маской части не будут отрисованы в любом случае

    // Переопределим метод resizeEvent() чтобы вовремя обновить маску
    void resizeEvent(QResizeEvent* _event) Q_DECL_OVERRIDE
    {
        // Обновляем маску
        updateMask(_event->size());
        // Вызов метода базового класса для остальной обработки новых размеров
        QFrame::resizeEvent(_event);
    }

private:
    void updateMask(const QSize& size)
    {
        const int w = frameWidth(); // ширина рамки
        const QRect r = rect(); // прямоугольник нашей рамки
        QRegion region(r); // наша маска — изначально размером во всю рамку
        region -= r.adjusted(w, w, -w, -w); // оставляем только края рамки
        region += closeButton_->geometry(); // добавляем кнопку
        setMask(region); // устанавливаем маску
    }
private:
    QPushButton* closeButton_;
};


Стоит отметить ещё несколько интересных моментов. Наследование от QFrame позволяет использовать декларативный язык стилей QSS (Qt Style Sheets). С его помощью можно настраивать способ отрисовки QFrame, совсем не внося изменений в код. В нашем примере мы устанавливаем стиль напрямую через вызов setStyleSheet() в конструкторе, в реальном же коде он, скорее всего, будет загружаться из ресурсов. Поскольку в новой версии кода мы отказались от переопределения метода paintEvent(), то рамка будет отрисована с учётом текущего установленного стиля, а значит можно использовать QSS. Если дизайнерская мысль вдруг изменится, то нам не придется вновь править код, а значит и дополнительно проверять код на возможные ошибки!

Обобщай и развивай!


Давайте немного разовьём нашу идею. Стоит обратить внимание, что в приведённом коде маска для внутренних компонентов формируется вручную. Можем ли мы как-то автоматизировать этот процесс? Велика вероятность, что мы захотим использовать этот подход шире, и тогда мы можем запросто забыть добавить в маску необходимые виджеты. Такого рода автоматизацию несложно реализовать, используя отслеживание событий дочерних виджетов. Мы можем создать более обобщённый вариант класса, который будет заниматься всей необходимой обработкой и вычислением масок, а те виджеты, которые нуждаются в таком поведении, мы сможем просто наследовать от этой реализации. Попробуем реализовать такой класс, назовем его ShapedWidget:

Код
class ShapedWidget : public QFrame
{
    Q_OBJECT
public:

    explicit ShapedWidget(QWidget* _parent = Q_NULLPTR, Qt::WindowFlags _flags = Qt::WindowFlags{})
        : QFrame(_parent, _flags) {}

protected:
    // Сделаем метод обновления маски виртуальным,
    // чтобы наследники могли его переопределить при необходимости
    virtual void updateMask()
    {
        setMask(regionMask());
    }

    void resizeEvent(QResizeEvent* _event) Q_DECL_OVERRIDE
    {
        updateMask();
        QFrame::resizeEvent(_event);
    }

    void childEvent(QChildEvent* _event) Q_DECL_OVERRIDE
    {
        QObject* object = _event->child();
        if (_event->added() && object->isWidgetType() && object->parent() == this)
        {
            // Переустанавливаем фильтр событий
            object->removeEventFilter(this);
            object->installEventFilter(this);
        }
    }
    
    bool eventFilter(QObject* _watched, QEvent* _event) Q_DECL_OVERRIDE
    {
        if (_watched->isWidgetType() && _watched->parent() == this)
        {
            QWidget* widget = static_cast(_watched);
            switch (_event->type())
            {
            case QEvent::ParentChange:
                // Виджет больше не дочерний — убираем фильтр событий
                // и убираем его из маски
                if (!isAncestorOf(widget))
                {
                    widget->removeEventFilter(this);
                    removeRegion(widget, mask());
                }
                break;
            case QEvent::Show:
                // Виджет будет отображен — добавляем его в маску
                setMask(appendRegion(widget, mask()));
                break;
            case QEvent::Hide:
                // Виджет будет спрятан — убираем его из маски
                setMask(removeRegion(widget, mask()));
                break;
            case QEvent::Move:
            case QEvent::Resize:
                // Виджет будет перемещен или изменен в размерах -
                // обновим для него маску
                _watched->event(_event);
                setMask(regionMask());
                return true;
            default:
                break;
            }
        }
        return QWidget::eventFilter(_watched, _event);
    }
    
private:
    // Рассчет маски
    QRegion regionMask() const
    {
        // Также как и раньше
        const int w = frameWidth();
        QRegion region(rect());
        region -= rect().adjusted(w, w, -w, -w);

        // Обойдем все прямые дочерние виджеты
        const QList childrenList = findChildren(QString{}, Qt::FindDirectChildrenOnly);
        for (const auto w : childrenList)
        {
            if (!w->isVisible())
                continue;
    
            region += w->geometry();
        }
        return region;
    }
    
    // Добавление региона в маску
    QRegion appendRegion(QWidget* _widget, const QRegion& _maskRegion) const
    {
        QRegion region = _maskRegion;
        return (region += _widget->geometry());
    }
    
    // Удаление региона из маски
    QRegion removeRegion(QWidget* _widget, const QRegion& _maskRegion) const
    {
        QRegion region = _maskRegion;
        return (region -= _widget->geometry());
    }
};


В приведенном выше коде мы переместили метод updateMask() в секцию protected и сделали виртуальным: теперь при необходимости наследники смогут его переопределять, если вдруг возникнет необходимость сформировать маску как-то по-иному. Для отслеживания событий дочерних виджетов мы используем переопределение виртуального метода childEvent(), в котором при добавлении виджета мы ставим на него фильтр событий. Помните, что дочерний виджет может поменять предка. Чтобы обработать и этот случай, уже в реализации фильтра событий мы отслеживаем событие QEvent::ParentChange и проверяем, что дочерний виджет всё ещё имеет отношение к нашему корневому ShapedWidget. Если это не так, то мы снимаем с дочернего виджета фильтр событий и убираем сам виджет из маски.

Важный момент: если у ShapedWidget не осталось дочерних виджетов, то маска обратится в пустой регион, а вызов setMask() с пустым регионом убирает маску с виджета! Стоит об этом помнить, чтобы избежать непредвиденных проблем.

Внимательный читатель обратит внимание, что в приведенном коде мы не обрабатываем ситуацию, когда дочерние виджеты также имеют маску. Добавление такого поведения тривиально, поэтому оставлю его в качестве упражнения читателю.

При всех преимуществах и у масок есть свои недостатки. Главная проблема — отсутствие сглаживания по краям. Дело в том, что внутри регион маски реализуется как набор непересекающихся прямоугольников, из-за этого сделать сглаживание по краям невозможно в принципе. Однако этот недостаток можно обойти, используя прямоугольные маски в сочетании с прозрачным фоном и рисование сложных частей через QPainter/QPainterPath, либо сделав запас по краям региона маски в 1–2 пикселя.

Опишу ещё один случай использования масок, но на этот раз упор сделаю больше на визуальное представление. Представим, что нам необходимо реализовать кнопку нестандартного вида, например, круглую с вырезанными частями под бейдж и кнопку меню, также круглыми. На картинках показана кнопка в разных состояниях: обычном, с наведённым курсором, нажатая.

0uj9rgb8znjgxqkajzlkioqd25q.jpeg

В VK Teams мы используем такие кастомные кнопки в панели управления видеозвонком.

nxq6nzr5nuynodbiwo3tdj-b9vk.jpeg

Один из возможных способов реализации такой кнопки — использование QPainterPath. Давайте напишем обработчик resizeEvent(), в котором это будет использоваться, и проанализируем, что получается:

void CustomButton::resizeEvent(QResizeEvent* _event)
{
    const QRect buttonRc = rect();
    const QRect badgeRc = badgeRect();
    const QRect menuBtnRc = menuButtonRect();

    QPainterPath buttonPath;
    buttonPath.addEllipse(buttonRc);

    QPainterPath badgePath;
    badgePath.addEllipse(badgeRc);

    QPainterPath menuBtnPath;
    menuBtnPath.addEllipse(menuBtnRc);

    buttonPath -= badgePath;
    buttonPath -= menuBtnPath;

    path = buttonPath;
    QWidget::resizeEvent(_event);
}


Здесь мы создаём три объекта QPainterPath: для бейджа, кнопки меню и результирующий для самой кнопки. Далее мы добавляем эллипсы нужного размера в каждый из этих объектов, и затем для получения окончательного результата вычитаем части бейджа и кнопки меню. Производительность такого решения будет оставлять лучшего. Дело в том, что в этом случае мы имеем дело с вычислительной геометрией на плоскости, причем для окружностей, что затратно по ресурсам процессора. 

Если мы сможем придумать другой способ, без использования QPainterPath, то такое решение заметно выиграет по производительности. И оно есть — достаточно использовать перемешивание цветов. Для QPainter доступны различные варианты CompositionMode, и один из них CompositionMode_Source. Он позволяет создавать в памяти маску в виде изображения, которую впоследствии можно залить любым цветом (доступны и градиентные заливки). Правда в таком случае потребуется дополнительно хранить такую маску, но это кажется разумным компромиссом между вычислительно-сложными расчётами геометрии и расходом памяти. Ещё один немаловажный момент: необходимо учитывать device pixel ratio, параметр, отвечающий за разрешение (в точках на дюйм) устройства отрисовки. Это важно для Retina-дисплеев с высоким разрешением.

Код примера с реализацией кнопки таким способом получился весьма объёмным, поэтому приведу здесь только основные и наиболее интересные его фрагменты. Для начала — код получения и отрисовки маски для такой кнопки:

Код
void CustomButton::drawBackground(QPainter& _painter, const QRect& _iconRect)
{
    const QSize sz = size();

    // Для Retina дисплеев важно принимать во внимание device pixel ratio
    const qreal dpr = devicePixelRatioF();

    // Создадим буфер под нашу маску с учетом device pixel ratio
    QPixmap buttonMask((QSizeF(sz) * dpr).toSize());
    buttonMask.setDevicePixelRatio(dpr);

    // заполняем альфа-маску
    buttonMask.fill(Qt::transparent);

    QPainter maskPainter(&buttonMask);
    maskPainter.setRenderHint(QPainter::Antialiasing);
    
    maskPainter.setPen(Qt::NoPen);
    maskPainter.setBrush(buttonColor());
    maskPainter.drawEllipse(_iconRect);

    // Установим CompositionMode_Source ...
    maskPainter.setCompositionMode(QPainter::CompositionMode_Source);
    // и прозрачную кисть
    maskPainter.setBrush(Qt::transparent);
    
    if (hasBadge(badgeValue_)) // вырезаем из маски место под бейдж
        maskPainter.drawEllipse(badgeRect(_iconRect).marginsAdded(kBorderMargins);
    if (popupMode_ != InstantPopup) // вырезаем из маски место под кнопку меню
        maskPainter.drawEllipse(menuButtonRect(_iconRect).marginsAdded(kBorderMargins);

    // Наша маска готова — можно ее использовать
    _painter.drawPixmap(0, 0, buttonMask);
}


Созданием альфа-масок использование QPainter::CompositionMode не ограничивается. Для той же кнопки может понадобиться «перекрасить» иконку. В VK Teams мы используем формат SVG, это позволяет масштабировать иконки без потери качества, а также перекрашивать их при необходимости. Таким образом, достаточно лишь одного файла иконки для разных состояний интерфейса пользователя. Следующий фрагмент кода позволяет отрисовать чёрно-белую иконку в нужном цвете:

void CustomButton::drawIcon(QPainter& _painter, const QIcon& _icon, const QStyleOption& _option)
{
    const QRect r = option.rect;
    
    QPixmap pixmap(r.size());
    pixmap.fill(Qt::transparent);

    QPainter pixmapPainter(&pixmap);
    icon.paint(&PixmapPainter, pixmap.rect());
    pixmapPainter.setCompositionMode(QPainter::CompositionMode_SourceIn);
    pixmapPainter.fillRect(pixmap.rect(), iconColor(option.state));

    painter.drawPixmap(r, pixmap);
}


Помимо использования масок в реализации такой кнопки возникает ещё одна интересная задача: необходимо придумать способ расчёта геометрии отдельных компонентов (бейджа, кнопки меню, иконки и прочего). Это важно, поскольку реализация не должна полагаться на то, что кнопка всегда будет одного размера, при масштабировании элементы не должны «разъезжаться». 

88xisjyxhwejozleztydbscrnum.png


Поскольку кнопки обычно прямоугольные, рассчитать геометрию не составляет труда, поскольку используются декартовы координаты. Но в данном случае кнопка круглая, и нам выгоднее задать угол, на котором должен располагаться бейдж или кнопка меню. Поясню это на чертеже:

ty-knykuz2bp7_c-d7aebvcq8j4.png


У нас есть прямоугольник иконки с известными сторонами и прямоугольник бейджа также с известными сторонами. Но нам необходимо знать центр прямоугольника бейджа, чтобы нарисовать окружность в нужном месте. Согласно чертежу опустим перпендикуляр из предполагаемого центра: получим прямоугольный треугольник. Теперь перед нами несложная геометрическая задача из школьного курса. Необходимо найти один из катетов прямоугольного треугольника по гипотенузе и одному из углов. Достаточно вспомнить теорему синусов и теорему косинусов, чтобы прийти к такому коду:

constexpr kBadgeAngle = qDegreesToRadians(qreal(42));

QRect CustomButton::badgeRect(const QRect& _iconRect) const
{
    QRect rc{ QPoint{}, kBadgeSize };
    const int r = _iconRect.width() / 2 + kBorderWidth;
    const int x = int(r * (qFastCos(kBadgeAngle)));
    const int y = int(-r * (qFastSin(kBadgeAngle)));
    rc.moveCenter(_iconRect.center() + QPoint{x, y});
    return rc;
}


Важно отметить, что для корректных расчётов угол должен быть переведён в радианную меру, это несложно сделать вызовом qDegreesToRadians().

Для прямоугольника кнопки меню задача решается аналогично. Представленный код говорит сам за себя. Добавлю лишь, что поскольку особой точности не требуется, то используются функции qFastCos() и qFastSin(), которые дают грубую аппроксимацию (за счёт использования табличного задания значений синуса и косинуса), но и работают весьма быстро.

Многослойные окна


В стандартных, классических приложениях обычно все компоненты располагаются без перекрытия, но в современных приложениях всё чаще дизайн использует прозрачность, анимацию и перекрытие элементов.

Представим себе такую задачу: по макету у нас имеется некий основной виджет с каким-то содержимым. Поверх этого виджета располагается полупрозрачная панель управления с кнопками, а также, в зависимости от ситуации, могут появляться всплывающие предупреждения двух видов. В случае каких-то простых уведомлений (вроде перехода в полноэкранный режим, копирования информации в буфер обмена и т.п.) появляется полупрозрачное серое всплывающее сообщение посередине виджета, а в случае предупреждений или критических ошибок — полупрозрачное красное сообщение вверху виджета с кнопкой закрытия. В идеале для появления или исчезания таких сообщений должна быть предусмотрена анимация появления и, возможно, ещё какие-то эффекты, но для простоты изложения мы опустим эти моменты.

Схематично весь макет показан на рисунке.

9fxeq6cmgi1cru6aaw8adsrj5_0.png


Этот макет — упрощённая версия всего окна звонка VK Teams, который видят наши пользователи при каждом аудио- и видеозвонке.

9ku4q0ik_eg0n60l8gleq7s5bqi.png

Первый вариант


Теперь сосредоточимся на том, как можно такой макет реализовать. Первое, что приходит в голову, это сделать отдельные компоненты для панелей предупреждений, сообщений и управления. Следующим шагом обычно идёт применение компоновщика (layout). Однако в нашем случае панели перекрывают основной виджет с содержимым, а большая часть широко используемых классов компоновщиков в Qt (QGridLayout, QBoxLayout и его разновидности) рассчитана как раз на отсутствие перекрытий. В такой ситуации очевидный шаг — ручное управление геометрией таких панелей, например, через фильтры событий.

Однако этот, на первый взгляд, логичный и очевидный подход — неудачный. Дело в том, что такое решение оборачивается большим количеством ошибок и сложностью поддержки кода. Очень легко забыть реализовать в фильтре событий реакцию на изменение параметров геометрии родительского виджета. Поскольку нет единого места, управляющего порядком по глубине, и виджеты слабо осведомлены друг о друге, каждый пытается вырваться вперёд: быть выше остальных по оси Z, от этого логичный порядок следования нарушается и становится вовсе неуправляемым. Весь код испещрён фильтрами событий и теперь нет ясности, что именно и в какой момент происходит. На все эти проблемы могут наложиться сложности отрисовки, анимации и прочего. В итоге получается очень запутанный код с крайне неочевидным поведением.

Используем QStackedLayout


Чтобы избежать подобного рода проблем стоит рассмотреть другой вариант. В библиотеке Qt предусмотрен специальный класс компоновщика QStackedLayout. Как можно догадаться из названия, он позволяет компоновать виджеты один над другим в виде стопки. Однако по умолчанию виден только один виджет — тот, который находится на вершине. Чтобы иметь возможность видеть все виджеты достаточно задать свойству stackingMode значение QStackedLayout::StackAll. Важная особенность QStackedLayout: в общем случае порядок виджетов по оси Z может не соответствовать порядку их добавления, поэтому полагаться, например, на метод currentIndex() мы не можем. Но как тогда QStackedLayout может решить вышеописанные проблемы и как нам управлять порядком по оси Z?

Дело в том, что все виджеты (как наследники QObject) связываются в дерево владения — именно их порядок в дереве определяет уровень по оси Z. Если внимательно изучить документацию на QWidget, то можно обнаружить несколько методов, относящихся к управлению порядком дочерних виджетов в дереве владения:

  • QWidget::lower() перемещает дочерний виджет максимально близко к корню (то есть такой виджет будет отрисовываться одним из первых, а значит — иметь наименьшее значение по Z и будет располагаться под остальными).
  • QWidget::raise() перемещает дочерний виджет максимально далеко от корня (то есть такой виджет будет отрисовываться одним из последних, а значит — иметь наибольшее значение по Z и будет располагаться поверх остальных).
  • QWidget::stackUnder(QWidget* w) перемещает один виджет под другим (то есть один виджет будет поверх другого, при этом важно, что эти виджеты должны быть побратимы — иметь общего прямого предка).


На самом деле внутри реализации QStackedLayout используются именно эти методы. Теперь понятно, почему порядок, в котором мы помещали виджеты в компоновщик, не имеет особого значения: в процессе работы виджеты могут быть перетасованы в дереве владения. Для того, чтобы восстановить порядок по Z, мы можем использовать, например, последовательность вызовов QWidget::stackUnder().

Таким образом, получаем следующее решение: делим все компоненты по оси Z на слои, каждый из которых теперь управляется отдельно и может иметь свой компоновщик. Схематично этот подход показан на рисунке:

mddwiun45lgtp83udnnmb6x7sca.png


Основные преимущества такого решения заключаются в том, что теперь у нас есть единый объект, который управляет порядком перекрывающихся виджетов, нет необходимости следить за геометрией и создавать фильтры событий, код короче и яснее, его проще поддерживать и расширять.

Добавляем маски


При всех преимуществах описанного подхода наше решение ещё не полноценно. Если попытаться просто создать QStackedLayout и разместить в нём наши виджеты панелей, то ожидаемого результата мы, скорее всего, не получим. Проблемы могут возникнуть, например, с событиями мыши и клавиатуры, поскольку в Qt есть важные особенности их передачи от одного виджета другому.

Разберёмся, например, с событиями мыши. В Qt их первым получает виджет, наиболее удалённый от корня в дереве владения. Тут проще всего объяснить на примере. 

kt_ne-txfsbkt03suzac672daie.png


Скажем, у нас имеется виджет, в который вложен другой виджет, и в него вложена кнопка. При нажатии мышью в области кнопки первым получит событие именно кнопка (при отрисовке она получит событие QPaintEvent последней, иначе будет скрыта за другими виджетами, что логично сочетается с описанием выше). Далее, если в обработчике нажатия на кнопку мыши событие не было обработано, то оно передаётся родительскому виджету в соответствии с порядком в дереве владения, в противном случае событие игнорируется и удаляется из очереди событий. Весь процесс повторяется до тех пор, пока событие не будет где-то обработано, либо не достигнет корневого виджета.

В случае со QStackedLayout получается, что события мыши будут попадать сначала в виджет, находящийся на вершине по оси Z, а потом сразу уходить в его родительский виджет. Если виджет с содержимым, находящийся на самом нижнем уровне, должен реагировать на события мыши (а он, скорее всего, должен), то ни одно событие до него просто не дойдёт, все уйдут в корневой виджет!

Тут нам опять пригодятся маски и предыдущая обобщённая реализация. Мы можем отрезать маской те части виджета, которые не должны получать события мыши, то есть в нашем случае все те, которые не относятся к панелям.

Таким образом, решение заключается в следующем: для правильного наложения масок необходимо разместить экземпляры классов панелей в ShapedWidget. Экземпляры ShapedWidget будут выступать слоями, которые далее необходимо расположить в QStackedLayout c настроенным параметром stackingMode, равным QStackedLayout::StackAll. В корневом виджете необходимо предусмотреть метод восстановления правильного порядка по оси Z, используя, например, метод QWidget::stackUnder(). Попробуем это реализовать в коде:

Код
class ContentWidget : public QWidget
{
    // ...
};

class ControlPanel : pubic QWidget
{
    // ...
};

class PopupPanel : public QWidget
{
    Q_OBJECT

public:
    enum class Category
    {
        MessagePanel,
        WarningPanel
    };

    explicit PopupPanel(Category _category, QWidget* _parent = Q_NULLPTR)
        : QWidget(_parent)
        , timer_(Q_NULLPTR)
        , label_(Q_NULLPTR)
        , closeButton_(Q_NULLPTR)
        , category_(_category)
    {
        timer_ = new QTimer(this);
        timer_->setSingleShot(true);
        timer_->setInterval(0);
        connect(timer, &QTimer::timeout, this, &PopupPanel::closeRequested);

        // код создающий текст и кнопку,
        // а также настраивающий компоновщик
        // ..
    }

    void setText(const QString& _text)
    {
        label_->setText(_text);
    }
    void setDuration(std::chrono::milliseconds _duration)
    {
        timer_->setInterval(_duration);
    }

Q_SIGNALS:
    void closeRequested();

protected:
    void showEvent(QShowEvent* _event)
    {
        if (timer->interval() > 0)
            timer->start();
        QWidget::showEvent(_event);
    }

    void paintEvent(QPaintEvent* _event)
    {
        constexpr qreal kPanelRadius = 8;
        constexpr qreal kPanelOpacity = 0.7;
        const QRect r = rect().adjusted(0, 0, -1, -1);
        QPainter painter(this);
        painter.setRenderHint(QPainter::Antialiasing);
        painter.setPen(Qt::NoPen);
        switch(category_)
        {
        case Category::MessagePanel:
            painter.setBrush(Qt::black);
            break;
        case Category::WarningPanel:
            painter.setBrush(Qt::red);
            break;
        }
        painter.setOpacity(kPanelOpacity);
        painter.drawRoundedRect(r, kPanelRadius, kPanelRadius);
    }

private:
    QTimer* timer_;
    QLabel* label_;
    QPushButton* closeButton_;
    Category category_;
};

class MultiLayerWindow : public QWidget
{
    Q_OBJECT
public:
    MultiLayerWindow(QWidget* _parent = Q_NULLPTR)
    {
        contentWidget = new ContentWidget(this);
        controlsPanel = new ControlsPanel(this);
        warningPanel = new PopupPanel(this);
        warningPanel->setDuration(std::chrono::milliseconds(0));
        notificationPanel = new PopupPanel(this);
        notificationPanel->setDuration(std::chrono::milliseconds(2000));

        QVBoxLayout* panelLayout = nullptr;

        warningsLayer = new ShapedWidget(this);
        panelLayout = new QVBoxLayout(warningsLayer);
        panelLayout->addWidget(warningPanel, Qt::AlignTop);
        warningsLayer->setVisible(false);

        notificationsLayer = new ShapedWidget(this);
        panelLayout = new QVBoxLayout(notificationsLayer);
        panelLayout->addWidget(notificationPanel);
        notificationLayer->setVisible(false);
        
        controlsLayer = new ShapedWidget(this);
        panelLayout = new QVBoxLayout(controlsLayer);
        panelLayout->addWidget(controlsPanel, Qt::AlignBottom);

        QStackedLayout* stackLayout = new QStackedLayout(this);
        stackLaуout->addWidget(warningsLayer);
        stackLayout->addWidget(notificationsLayer);
        stackLayout->addWidget(controlsLayer);
        stackLayout->addWidget(contentWidget);
    }

    void restoreZOrder()
    {
        warningsLayer->raise(); // поднимем слой с предупреждениями поверх всех
        // упорядочиваем оставшиеся слои
        notificationLayer->stackUnder(warningsLayer);
        controlsLayer->stackUnder(notificationLayer);
        contentWidget->stackUnder(controlsLayer);

        update();
    }

    void showMessage(const QString& _text)
    {
        notificationPanel->setText(_text);
        notificationLayer->show();
    }

    void showWarning(const QString& _text)
    {
        warningPanel->setText(_text);
        warningsLayer->show();
    }

private:
    ContentWidget* contentWidget;
    ControlPanel* controlPanel;
    PopupPanel* messagePanel;
    PopupPanel* warningPanel;

    ShapedWidget* controlsLayer; // слой для панели управления
    ShapedWidget* messagesLayer; // слой для уведомлений
    ShapedWidget* warningsLayer; // слой для предупреждений/ошибок

    QStackedLayout* stackLayout; // компоновщик
};


Заключение


Подведём небольшие итоги. В этой части мы разработали и реализовали компоненты, которые могут быть использованы для построения современного интерфейса пользователя. В следующей части займёмся реализацией спецэффектов и соберём все компоненты воедино в небольшом демонстрационном приложении 

© Habrahabr.ru