Создаём сложные интерфейсы и спецэффекты на базе Qt. Часть II
Привет, Хабр! Меня зовут Михаил Полукаров, я занимаюсь разработкой desktop-версии в команде VK Teams. В первой части я рассказывал о том, как использовать маски и создавать сложные многослойные окна. Под катом этой статьи мы продолжим исследовать возможности Qt Framework, рассмотрим полупрозрачность и управление буксировкой окна, а также реализуем интересный спецэффект. В конце статьи рассмотрим, как можно применить на практике весь, изложенный в этом цикле статей, материал для создания современного демонстрационного приложения.
Примечание 1. Всё, что приводится в статье, может работать, а может и не работать в зависимости от сочетания программного-аппаратного обеспечения, версии Qt Framework, версии и типа операционной системы и прочих факторов. Весь код, приведённый в статье, проверялся для Qt Framework версии 5.14.1 в ОС Windows 10, Ubuntu 20.1 LTS Linux и macOS Big Sur. Для упрощения понимания весь код реализации методов написан в классе, а в production-коде настоятельно рекомендую такого избегать. Полный код примеров вы сможете найти на странице проекта в Github. Для сборки примеров потребуются установленный Qt Framework и компилятор С++17.
Примечание 2. Всё, что описывается в статье, вероятно, можно реализовать с использованием Qt QML или других языков программирования и фреймворков. Особо отмечу, что цель статьи — показать, как использовать Qt Framework исключительно с применением классического QWidget-based подхода.
Frameless окна и полупрозрачность
Полупрозрачные окна всегда выглядят эффектно и привлекательно, и на первый взгляд кажется, что с ними не должно возникнуть трудностей. На самом деле сложности здесь могут поджидать в самых неожиданных местах. Необходимо помнить, что, согласно документации Qt, в некоторых ОС нельзя создать полупрозрачное окно без удаления его заголовка. Удалить системный заголовок окна просто — достаточно установить флаги окна следующим образом:
setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
Здесь важно обратить внимание на то, что используется побитовая операция OR между уже установленными флагами и флагом Qt::FramelessWindowHint
. Это необходимо, чтобы избежать непредвиденного поведения и стиля отображения окна. Если по каким-то причинам получить текущие флаги не представляется возможным, то необходимо явно указать стиль окна (Qt::Window
, Qt::Tool
и т.д.), подробное описание всех флагов можно найти в документации Qt.
Таким образом, получается, что если мы хотим иметь полупрозрачное окно и единый код на всех основных платформах, то необходимо убрать системный заголовок. Но тут возникает следующая загвоздка: без системного заголовка невозможно изменить размеры или буксировать окно мышью, нельзя свернуть, развернуть, закрыть окно — нет системных кнопок. Дело в том, что системный заголовок помимо предоставления иконки, текста и кнопок отвечает ещё и за изменение размеров и перемещение окна. Значит, если нужна такого рода функциональность, нам потребуется реализовать её вручную:
constexpr QPoint kInvalidPoint(-1, -1);
class ToolWindow : public QWidget
{
Q_OBJECT
public:
explicit ToolWindow(QWidget* _parent)
: QWidget(_parent)
, pos_(kInvalidPoint)
{
setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setMouseTracking(true);
setCursor(Qt::OpenHandCursor);
resize(256, 256);
}
protected:
void mousePressEvent(QMouseEvent* _event)
{
if (_event->button() == Qt::LeftButton &&
_event->modifiers() == Qt::NoModifier)
{
pos_ = _event->globalPos();
setCursor(Qt::CloseHandCursor);
return;
}
QWidget::mousePressEvent(_event);
}
void mouseMoveEvent(QMouseEvent* _event)
{
if (pos_ == kInvalidPoint)
return QWidget::mouseMoveEvent(_event);
const QPoint delta = _event->globalPos() - pos_;
move(pos() + delta);
pos_ = _event->globalPos();
}
void mouseReleaseEvent(QMouseEvent* _event)
{
pos_ = kInvalidPoint;
setCursor(Qt::OpenHandCursor);
QWidget::mouseReleaseEvent(_event);
}
private:
QPoint pos_;
};
Код, приведённый выше, позволяет сделать окно, которое можно перетаскивать мышью, нажимая в любом месте окна. Следует обратить особое внимание на использование глобальных координат курсора. Это важно, поскольку в противном случае окно при буксировке будет дёргаться и вести себя неадекватно.
Добавляем полупрозрачность
Этого эффекта можно добиться, как минимум, двумя способами:
Переопределением метода
QWidget::paintEvent()
.Использованием метода
QWidget::setWindowOpacity()
.
Разница между этими подходами в визуальном результате.
При использовании setWindowOpacity()
полупрозрачным становится всё окно, вместе со всеми компонентами внутри. Такой подход удобен, когда необходимо сделать анимацию появления или исчезновения всего окна.
При переопределении paintEvent()
мы можем выбирать, какие компоненты будут полупрозрачными, а какие нет. Этот вариант подходит для постоянного отображения окна.
Проиллюстрирую эти возможности, немного поменяв уже написанный выше класс:
Код
class ToolWindow : public QWidget
{
Q_OBJECT
public:
explicit ToolWindow(QWidget* _parent)
: QWidget(_parent)
, pos_(kInvalidPoint)
{
setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setMouseTracking(true);
setCursor(Qt::OpenHandCursor);
resize(256, 256);
button_ = new QPushButton(tr(“Close”), this);
connect(button_, &QPushButton::clicked, this, &ToolWindow::close);
QVBoxLayout* layout = new QVBoxLayout(this);
layout->addWidget(button_, 0, Qt::AlignCenter);
// анимация появления/исчезания
animation_ = new QPropertyAnimation(this);
animation_->setTargetObject(this);
animation_->setPropertyName(“windowOpacity”);
animation_->setStartValue(0.0);
animation_->setEndValue(1.0);
animation_->setDuration(500);
connect(animation_, &QPropertyAnimation::finished, this, &ToolWindow::onAnimationFinished);
setWindowOpacity(0.0);
}
private Q_SLOTS:
void onAnimationFinished()
{
// проверим что завершилась анимация исчезновения
if (animation_->direction() == QPropertyAnimation::Backward &&
animation_->state() == QPropertyAnimation::Stopped)
close(); // вызов close() приведет к вызову closeEvent()
}
void showEvent(QShowEvent* _event) Q_DECL_OVERRIDE
{
QWidget::showEvent(_event);
// Запускаем анимацию появления
animation_->setDirection(QPropertyAnimation::Forward);
animation_->start();
}
void closeEvent(QCloseEvent* _event) Q_DECL_OVERRIDE
{
// проверим что анимация исчезновения завершилась
if (animation_->direction() == QPropertyAnimation::Backward &&
animation_->state() == QPropertyAnimation::Stopped)
return QWidget::closeEvent(_event); // обрабатываем closeEvent как обычно
// Запускаем анимацию исчезновения
animation_->setDirection(QPropertyAnimation::Backward);
animation_->start();
_event->ignore();
}
void paintEvent(QPaintEvent* _event) Q_DECL_OVERRIDE
{
QPainter painter(this);
painter.setOpacity(0.6);
painter.fillRect(_event->rect(), Qt::black);
}
};
Для реализации анимации появления или исчезновения используется следующий трюк. Для анимации появления в showEvent()
мы запускаем её в прямом направлении от 0 до 1. А при исчезновении запускаем анимацию в обратном направлении. Для того, чтобы понять, в какой момент необходимо запустить эту анимацию, мы используем closeEvent()
. Дело здесь в том, что closeEvent()
, в отличие от hideEvent()
, может быть отменён. Таким образом мы откладываем вызов closeEvent()
до тех пор, пока не закончится анимация исчезновения, используя механизмы Qt для игнорирования событий. Когда же анимация исчезновения закончена, мы вызываем метод QWidget::close()
, который приводит к повторному вызову closeEvent()
, и на этот раз мы закрываем окно уже обычным способом.
Blur Behind
Blur behind (или back drop) — это спецэффект, заключающийся в размытии фона под окном или его элементом. На картинке ниже показано, как выглядит этот эффект в Windows.
Чтобы реализовать такой спецэффект для окна, нам в любом случае потребуется писать платформенно-зависимый код. Более того, этот эффект невозможно реализовать на некоторых платформах. Дело в том, что для такого размытия требуется специальная композиция (регион под приложением, регион в самом окне, размытие и окончательная сборка этих частей) которая реализуется в самой ОС. В Linux, например, за это отвечает Composition Manager, в Windows — Desktop Window Manager (DWM). Для macOS такой эффект можно сделать штатным API, для Windows начиная с Windows 8 — только используя недокументированные возможности, для Linux — только для рабочего стола KDE, для остальных (Xfce, Gnome и проч.) такое не поддерживается вовсе. Более подробное описание Blur Behind с использованием нативного API на разных платформах можно найти по ссылке.
VK Teams используют системообразующие компании и поэтому использование недокументированных функций ОС невозможно, не говоря уже о неполной поддержке на всех платформах. Но тогда, может быть, можно как-то эмулировать этот эффект, если ОС не поддерживает его напрямую? И тут опять ответ, скорее, отрицательный: всё, что я ни пробовал, либо не работает как нужно, либо приводит к артефактам отрисовки, либо поедает слишком много ресурсов процессора и памяти.
Однако, в какой-то момент я задал себе вопрос: хорошо, мы не можем сделать blur behind для всего окна, но может быть получится реализовать этот эффект для отдельных компонентов внутри окна? Для того, чтобы ответить на этот вопрос нет иного пути, кроме как попробовать реализовать этот эффект самому.
На рисунках выше показан конечный результат, но прежде чем к нему прийти пришлось немало попотеть.
Итак, сперва нам нужна какая-то стратегия реализации: попробуем пойти самым простым путём — сделаем виджет на всё окно с blur behind, а потом попробуем масштабировать решение на другие варианты. Кроме того, необходимо продумать алгоритм композиции и реализовать само размытие. Сразу оговорюсь, что вариантов алгоритма размытия много и обсуждение их реализации выходит далеко за рамки этой статьи. В итоге я взял несколько готовых реализаций:
Ещё один важный момент, связанный с размытием изображения. Для экономии ресурсов процессора мы можем использовать следующий трюк: нет необходимости размывать именно оригинальное изображение, можно размыть его уменьшенную копию (такой подход называется downsampling), а затем полученное изображение масштабировать обратно. Теперь мы размываем меньшее по размерам изображение, а значит и тратим меньше ресурсов процессора и памяти. Поскольку само размытие является низкочастотным фильтром, то такой трюк визуально не влияет на конечный результат. Нам же важен именно визуальный эффект, а не точность самого алгоритма.
Важно также обратить внимание на наличие некоторых опций масштабирования изображений, доступных в Qt — Qt::SmoothPixmapTransform
и Qt::FastPixmapTransform
. Разница между этими опциями в том, что Qt::SmoothPixmapTransfrom
использует билинейную интерполяцию (или какую-то из её разновидностей) — это не самый дешёвый в вычислительном плане алгоритм. В таких условиях казалось бы логичным сэкономить, используя быстрое и грубое масштабирование Qt::FastPixmapTransform
, но визуальный эффект от этого станет хуже. Дело в том, что при Qt::FastPixmapTransform
происходит грубая интерполяция цветов. Мы рискуем получить на выходе артефакты в виде разноцветных пикселов по краям с резким переходом цветов (в фотографии такой эффект называется хроматической аберрацией), которые будут затем масштабированы ещё раз и конечный результат окажется уже совершенно неудовлетворительным. Можно было бы использовать грубую интерполяцию при масштабировании только в большую сторону, но в таком случае мы получим не то, что ожидали — эффект пикселизации вместо размытия. Визуально это выглядит неплохо, но не является нашей целью.
Забегая немного вперёд, скажу, что можно также использовать масштабированную отрисовку части виджета. Для этого достаточно установить масштабирование в QPainter
, который будет передан в метод QWidget::render()
(речь о нём пойдёт ниже). Такой способ позволяет избежать хроматических аберраций, но приводит к неприятному «подёргиванию» размытого изображения при перерисовке. Алгоритм не успевает полностью размыть изображение между двумя тактами перерисовки и мы вынуждены отрисовывать старое изображение либо на неверной позиции, либо растянув или сжав его по ширине или высоте. Такие визуальные артефакты я счёл неприемлемыми, и в итоге решил остановиться на downsampling в сочетании с Qt::SmoothPixmapTransform
.
Итак, начнем с самой композиции. Будем считать, что мы уже получили необходимое изображение под нашим виджетом. Дальнейший алгоритм выглядит вполне очевидным:
размытие изображения;
отрисовка размытого изображения в переопределённом
paintEvent()
.
В коде это будет выглядеть примерно так:
void BlurBehindWidget::paintEvent(QPaintEvent* _event)
{
QPainter painter(this);
QImage image = blurImage(image);
painter.drawImage(_event.rect(), image, _event.rect());
}
Здесь мы размываем изображение при каждом paintEvent()
. Такое решение будет занимать очень много ресурсов процессора, поскольку мы выполняем тяжелый в вычислительном плане алгоритм размытия при каждой перерисовке, даже если в исходном изображении ничего не поменялось. Чтобы улучшить решение, необходимо перенести размытие в код получения размываемого изображения. Теперь сосредоточимся на том, как именно его получить. Тут существует как минимум три варианта:
Прямая отрисовка через
QWidget::render(…)
.Получение изображения виджета через
QWidget::grabPixmap()
.Получение widget baсking store.
Теперь необходимо сказать пару слов о каждом методе.
Прямая отрисовка — наиболее сложный подход, однако и наиболее контролируемый. В метод QWidget::render()
передаётся устройство, в которое будет производится отрисовка, в виде указателя на QPaintDevice
, регион для отрисовки, смещение по x и y, а также флаги отрисовки дочерних виджетов. При использовании этого метода необходимо быть аккуратным и учитывать device pixel ratio — параметр, отвечающий за разрешение (в точках на дюйм) устройства отрисовки. Это важно в первую очередь для поддержки Retina-дисплеев с высоким разрешением.
Получение изображения через QWidget::grabPixmap()
, как ни странно, практически то же самое, что и QWidget::render()
, его упрощённый вариант. Изучив исходный код этой функции в самом Qt легко увидеть, что в её реализации вызывается QWidget::render()
. Так что разница между двумя подходами в том, что во втором случае о тонкостях Retina-дисплеев, геометрии на плоскости и прочем за нас уже позаботились разработчики Qt.
Ну и, наконец, последний вариант — по сути хак. Дело в том, что если внимательно изучить документацию Qt, то выясняется, что при отрисовке многих виджетов (есть, правда, исключения — о них речь пойдёт ниже) используется внутренний буфер (так называемая двойная буферизация). Исследовав исходный код Qt я обнаружил, что в простых случаях в качестве такого буфера используется QImage
, более того, этот буфер можно получить следующим кодом:
QImage* image = dynamic_cast<QImage*>(widget->backingStore()->paintDevice());
Такой способ таит в себе множество опасностей — например, мы не можем быть уверены в том, что в качестве backing store в следующем релизе Qt для нужного виджета будет использоваться именно QImage
, более того, для некоторых виджетов это не так уже сейчас.
Получается, что нам остаётся только grabPixmap()
и render()
как легальные и относительно безопасные способы захвата виджета в изображение. Если мы попробуем использовать grabPixmap()
, то желаемого эффекта не получим. Дело в том, что grabPixmap()
отрисовывает весь виджет вместе с дочерними, то есть и вместе с нашим тоже. Получается, надо при отрисовке каким-то образом исключить из дерева владения наш виджет. Добиться такого можно только с помощью render()
. Нам будет необходимо пройтись по всем дочерним виджетам и вручную их отрисовать на нужных позициях, попутно исключив себя из этого списка, не забыв про device pixel ratio и про простейшую геометрию на плоскости:
QPixmap grabBuffer(QWidget* target, const QRect& r, QWidget* that)
{
if (target == Q_NULLPTR)
return QPixmap{};
if (!target->isAncestorOf(that)) // that должен быть частью дерева target
return QPixmap{};
const qreal dpr = target->devicePixelRatioF();
QPixmap pixmapBuffer((QSizeF(r.size() * dpr).toSize());
pixmapBuffer.setDevicePixelRatio(dpr);
pixmapBuffer.fill(Qt::transparent);
QPainter painter(&pixmapBuffer);
const QPoint p = that->mapTo(target, r.topLeft());
const QRect viewportRect(p, r.size());
const QWidgetList children = target->findChildren<QWidget*>(QString{}, Qt::FindDirectChildrenOnly);
for (auto child : children)
{
if (child == that || !child->isVisible())
continue; // исключаем себя и невидимые виджеты
const QRect geom = child->geometry();
if (!viewportRect.intersects(geom))
continue; // если виджет не пересекается с нашей геометрией не отрисовываем его
const QPoint d = geom.topLeft() - p;
child->render(&painter, d, child->rect(), QWidget::DrawChildren|QWidget::IgnoreMask);
}
}
Код не кажется простым и изящным, но пока остановимся на таком варианте.
Пара слов про OpenGL и нестандартные контексты устройств вывода графики
Как упоминалось выше для описываемого подхода есть исключения: это нестандартные контексты, такие как OpenGL, DirectX и т.п. В документации Qt указано, что метод QWidget::render()
не будет работать для, например, QOpenGLWidget
и его наследников. Для таких классов предлагается использовать grabFrameBuffer()
. Однако этот метод также будет работать далеко не всегда. Поэтому все описываемые далее подходы применимы только для стандартных виджетов.
Момент захвата изображения
Теперь остаётся ещё один важный шаг: определиться с тем в какой именно момент мы будем захватывать изображение нижележащего виджета. Логичным, на первый взгляд, будет перехват QPaintEvent
и захват изображения нижележащих виджетов там. Однако это приведёт к рекурсивной перерисовке и, как результат, к переполнению стека. Значит, требуется какое-то более изощрённое решение.
Если посмотреть список всех событий, предоставляемых Qt, то можно найти упоминание о QEvent::RepaintRequest
и QEvent::UpdateRequest
, но и эти события нам не подойдут. Можно попробовать перехватывать вообще все события, которые так или иначе могут приводить к перерисовке (например, изменение размеров или положения мыши, нажатия на клавиатуру и проч.), но и в таком случае мы либо пропустим какие-то варианты, либо будем перерасходовать ресурсы в пустую, поскольку не знаем логики перерисовки виджетов, находящихся под нашим. Кроме того, необходимо помнить о том, что некоторые виджеты могут иметь динамическое содержимое (анимации, стикеры, видео и т.п.).
В итоге мы попадаем в ловушку. Вроде как мы уже продумали и частично реализовали подход, но использовать его не получается. Если поразмышлять дальше, то мы придём к следующему наблюдению: попытки отлова событий (как перерисовки, так и других) — тупиковый вариант, поскольку эти события попадают в фильтр до того, как они попадут в целевой объект. А нам нужно их обработать после того, как целевой объект их получит. Другими словами, нам необходимо каким-то образом вклиниться в процесс перерисовки. Нужен способ подменить часть или всю реализацию метода QWidget::render()
(такой способ называется code injection).
Но каким же образом можно реализовать это?
Давайте попробуем мыслить немного шире. В Qt уже есть нечто, что реализует подобный эффект. Действительно, мы можем использовать QGraphicsBlurEffect
для создания эффекта размытия, но это немного не то, что нужно нам — ведь этот эффект размывает не то, что находится под виджетом, а сам виджет. Чтобы понять, как можно использовать этот эффект, давайте для начала разберёмся, какая именно «магия» заставляет его работать.
Под капотом QGraphicsEffect
Заглянем для начала в QGraphicsBlurEffect::draw()
. Кажется, там должно происходить что-то интересное:
void QGraphicsBlurEffect::draw(QPainter *painter)
{
Q_D(QGraphicsBlurEffect);
if (d->filter->radius() < 1) {
drawSource(painter);
return;
}
PixmapPadMode mode = PadToEffectiveBoundingRect;
QPoint offset;
QPixmap pixmap = sourcePixmap(Qt::LogicalCoordinates, &offset, mode);
if (pixmap.isNull())
return;
d->filter->draw(painter, offset, pixmap);
}
Здесь интересны вызовы drawSource(painter)
и sourcePixmap(Qt::LogicalCoordinates, &offset, mode)
. Первый только отрисовывает сам виджет, если радиус размытия меньше единицы (то есть размывать ничего не надо), а вот второй получает исходное изображение виджета, которое далее передается в d->filter->draw(painter, offset, pixmap)
для размытия. Вот это интересно: мы же тоже пытались захватить изображение виджета. Может, магия где-то в реализации QGraphicsEffect::sourcePixmap()
? Заглянем в код и этого метода:
QPixmap QGraphicsEffect::sourcePixmap(Qt::CoordinateSystem system,
QPoint *offset,
QGraphicsEffect::PixmapPadMode mode) const
{
Q_D(const QGraphicsEffect);
if (d->source)
return d->source->pixmap(system, offset, mode);
return QPixmap();
}
Здесь явно используется некий source. Немного поискав, можно найти абстрактный класс QGraphicsEffectSourcePrivate
; очевидно, что интересующая нас реализация в наследниках (некоторые части кода опущены, чтобы упростить понимание):
QPixmap QWidgetEffectSourcePrivate::pixmap(Qt::CoordinateSystem system,
QPoint *offset,
QGraphicsEffect::PixmapPadMode mode) const
{
// ...
// ...
qreal dpr(1.0);
if (const auto *paintDevice = context->painter->device())
dpr = paintDevice->devicePixelRatioF();
else
qWarning("QWidgetEffectSourcePrivate::pixmap: Painter not active");
QPixmap pixmap(effectRect.size() * dpr);
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
m_widget->render(&pixmap, pixmapOffset, QRegion(), QWidget::DrawChildren);
return pixmap;
}
Получается, что этот код делает ровно то же самое, что написали и мы ранее — захватывает изображения виджета с использованием QWidget::render()
! Но всё же пока ещё непонятно, за счёт чего именно работает QGraphicsBlurEffect
. Давайте пока подытожим то, что мы уже знаем:
QGraphicsBlurEffect
используетQWidget::render()
для захвата изображения для его дальнейшего размытия.Захват изображения делегируется наследникам
QGraphicsEffectSourcePrivate
, в частности, для виджетов используется классQWidgetEffectSourcePrivate
.QGraphicsBlurEffect
реализует виртуальный методdraw()
, в котором может использовать либо прямую отрисовку виджета, либо захваченное изображение для дальнейшей его обработки и отрисовки.
Теперь появляется ещё один интересный вопрос: а где же вызывается сам метод QGraphicsEffect::draw()
?
Очевидно, он должен быть где-то внутри отрисовки виджета. Давайте взглянем на код QWidget::render()
(опустим часть, не относящуюся к делу):
void QWidget::render(QPainter *painter, const QPoint &targetOffset,
const QRegion &sourceRegion, RenderFlags renderFlags)
{
// ...
d->render(target, targetOffset, toBePainted, renderFlags);
// ...
}
Внутри этого метода довольно много кода, но, по сути, помимо геометрических и других вычислений сама отрисовка производится во внутреннем методе QWidgetPrivate::render()
:
void QWidgetPrivate::render(QPaintDevice *target, const QPoint &targetOffset,
const QRegion &sourceRegion, QWidget::RenderFlags renderFlags)
{
// ...
// ...
// Set backingstore flags.
int flags = DrawPaintOnScreen | DrawInvisible;
if (renderFlags & QWidget::DrawWindowBackground)
flags |= DrawAsRoot;
if (renderFlags & QWidget::DrawChildren)
flags |= DrawRecursive;
else
flags |= DontSubtractOpaqueChildren;
flags |= DontSetCompositionMode;
// Render via backingstore.
drawWidget(target, paintRegion, offset, flags, sharedPainter());
// Restore shared painter.
if (oldSharedPainter)
setSharedPainter(oldSharedPainter);
}
Тут тоже довольно много кода, относящегося к геометрии на плоскости, и некоторых проверок, но важно, что и здесь для отрисовки нас перенаправляют в вызов ещё одного внутреннего метода QWidgetPrivate::drawWidget()
. Не буду приводить здесь полный код (он весьма объёмный), покажу лишь самую интересную для решаемой задачи часть:
void QWidgetPrivate::drawWidget(QPaintDevice *pdev,
const QRegion &rgn,
const QPoint &offset, int flags,
QPainter *sharedPainter,
QWidgetBackingStore *backingStore)
{
if (rgn.isEmpty())
return;
const bool asRoot = flags & DrawAsRoot;
bool onScreen = paintOnScreen();
Q_Q(QWidget);
#if QT_CONFIG(graphicseffect)
if (graphicsEffect && graphicsEffect->isEnabled()) {
QGraphicsEffectSource *source = graphicsEffect->d_func()->source;
QWidgetEffectSourcePrivate *sourced = static_cast<QWidgetEffectSourcePrivate *>
(source->d_func());
if (!sourced->context) {
QWidgetPaintContext context(pdev, rgn, offset, flags, sharedPainter, backingStore);
sourced->context = &context;
if (!sharedPainter) {
setSystemClip(pdev->paintEngine(), pdev->devicePixelRatioF(), rgn.translated(offset));
QPainter p(pdev);
p.translate(offset);
context.painter = &p;
graphicsEffect->draw(&p); // <-- вызов отрисовки через QGraphicsEffect
setSystemClip(pdev->paintEngine(), 1, QRegion());
} else {
context.painter = sharedPainter;
if (sharedPainter->worldTransform() != sourced->lastEffectTransform) {
sourced->invalidateCache();
sourced->lastEffectTransform = sharedPainter->worldTransform();
}
sharedPainter->save();
sharedPainter->translate(offset);
setSystemClip(sharedPainter->paintEngine(), sharedPainter->device()->devicePixelRatioF(), rgn.translated(offset));
graphicsEffect->draw(sharedPainter); // <-- вызов отрисовки через QGraphicsEffect
setSystemClip(sharedPainter->paintEngine(), 1, QRegion());
sharedPainter->restore();
}
sourced->context = 0;
// Native widgets need to be marked dirty on screen so painting will be done in correct context
// Same check as in the no effects case below.
if (backingStore && !onScreen && !asRoot && (q->internalWinId() || !q->nativeParentWidget()->isWindow()))
backingStore->markDirtyOnScreen(rgn, q, offset);
return; // <-- возврат из отрисовки
}
}
#endif // QT_CONFIG(graphicseffect)
// even more code ...
// ...
// ...
}
Если мы проанализируем этот код, то станет очевидным, что QGraphicsEffect
берёт на себя всю отрисовку виджета — это и есть code injection! Ура, теперь у нас есть абсолютно легальный способ подменить реализацию отрисовки виджета! Осталось придумать, как это использовать.
Делегируй это!
Теперь мы понимаем, что используя QGraphicsEffect
можем полностью подменить отрисовку виджета, но как это может помочь реализовать эффект Blur Behind?
Идея заключается в том, чтобы захватить изображение внутри QGrаphicsEffect
, но делегировать отрисовку размытого изображения другому виджету! Схематично это можно изобразить так:
Мы захватываем изображение виджета, вызывая для него QWidget::render()
, но в качестве устройства вывода используем QPixmap
. Далее полученное изображение мы отрисовываем на контексте целевого виджета, а размытую копию отрисовываем на другом виджете, поверх целевого. Более того, теперь мы можем управлять практически всеми параметрами отрисовки. Например, можем сделать эффект soft focus (он изображён на картинке), когда основное изображение полупрозрачное и как бы «просвечивает» сквозь размытый фон.
Далее мы можем развивать идею дальше: нам не всегда необходимо размывать весь виджет, зачастую достаточно некоторой области. Значит, мы можем использовать только необходимый регион для размытия, попутно сэкономив на памяти и вычислениях, поскольку нам теперь необходима лишь некоторая часть исходного изображения.
Давайте теперь сосредоточимся на реализации всего описанного выше.
Собираем всë вместе
Поскольку реализация довольно объёмная, приведу здесь только самые интересные моменты, а полный код можно найти на странице проекта в Github. Отмечу, что реализация не идеальна, но демонстрирует основные идеи. Начнём с определения класса:
class BlurBehindEffect : pubic QGraphicsEffect
{
Q_OBJECT
public:
BlurBehindEffect(QWidget* parent = Q_NULLPTR);
// Назначение региона для размытия
void setRegion(const QRegion& region);
// Прочие методы управления свойствами
// ...
// Метод позволяющий делегировать отрисовку размытого
// изображения целевого виджета в другой виджет
void render(QPainter* painter);
protected:
// переопределение виртуального метода QGraphicsEffect
void draw(QPainter* painter) Q_DECL_OVERRIDE;
private:
// Будем использовать Pimpl идиому чтобы скрыть детали реализации
std::unique_ptr<class BlurBehindEffectPrivate> d;
};
Пара слов о внутреннем классе и его определении:
class BlurBehindEffectPrivate
{
QImage sourceImage_; // изображение исходной части виджета
QImage blurredImage_; // размытое изображение
QRegion region_; // размываемый регион
double downsamplingFactor_; // см. далее
int blurRadius_; // радиус размытия
bool sourceUpdated_; // флаг изменения изображения целевого виджета
// ... прочие переменные
// размытие изображения
QImage blurImage(const QImage& _input);
// захват изображения
QPixmap grabPixmap(QWidget* _widget);
// отрисовка размытого изображения
void renderImage(QPainter* _painter, const QImage& _image);
};
Способ делегирования отрисовки, применяемый в данном случае, прост. Наш класс предоставляет публичный метод void BlurBehindEffect::render(QPainter*)
. Это немного нарушает инкапсуляцию, более корректный вариант просто займёт ещё больше кода, но суть решения от этого не поменяется. Виджет, который будет отвечать за делегированную отрисовку, сможет, обращаясь по указателю на наш класс, вызывать этот метод и передавать экземпляр QPainter
с собственным контекстом. Например, так:
class OverlayBlurWidget : public QWidget
{
Q_OBJECT
public:
OverlayBlurWidget(QtBlurBehindEffect* effect, QWidget* parent = Q_NULLPTR);
void paintEvent(QPaintEvent*) Q_DECL_OVERRIDE
{
QPainter painter(this);
if (m_effect)
m_effect->render(&painter);
}
void resizeEvent(QResizeEvent* _event)
{
const QRect r = rect();
if (m_effect)
m_effect->setRegion(r);
QWidget::resizeEvent(_event);
}
private:
QPointer<BlurBehindEffect> m_effect;
};
Теперь вернёмся к реализации самого эффекта, в частности, к методам отрисовки (код немного упрощён):
void BlurBehindEffect::draw(QPainter* painter)
{
QWidget* w = qobject_cast<QWidget*>(parent());
if (!w)
return;
const QRect bounds = d->region_.boundingRect();
// «захват» изображения целевого виджета (см. далее)
QPixmap pixmap = d->grabSource();
// отрисуем немодифицированное изображение, иначе его не будет видно вовсе
painter->drawPixmap(0, 0, pixmap);
// если регион не пуст и радиус размытия больше 1
if (!d->region_.isEmpty() && d->blurRadius_ > 1)
{
// расчет размера области копирования c учетом downsampling
const double dpr = pixmap.devicePixelRatioF();
const QSize s = (QSizeF(bounds.size()) * (dpr / d->dowmsamplingFactor_)).toSize();
// копирование масштабированной области
QImage pixmapPart = std::move(pixmap.copy(bounds).scaled(s, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).toImage();
if (d->sourcePixmap_ != pixmapPart)
{
// если скопированная область отличается от хранимой,
// присвоим новую и выставим флаг обновления
d->sourceImage_ = pixmapPart;
d->sourceUpdated_ = true;
}
}
}
Показанный выше метод переопределяет виртуальный метод QGraphicsEffect::draw()
. Как мы видели ранее, именно он используется в отрисовке, поэтому важно после захвата изображения целевого виджета отрисовать это изображение, иначе та часть виджета, которая не подвергалась размытию, так и не будет отрисована никогда.
Для захвата изображения можно использовать уже готовый метод QGraphicsEffect::sourcePixmap()
, но для него невозможно указать регион, который мы хотим захватить. Следовательно, мы можем либо написать захват изображения сами (аналогично тому, как это было показано раньше) с учётом только нужного участка виджета, либо использовать метод sourcePixmap()
и затем вырезать из всего изображения нужную часть. Второй вариант мне показался слишком расточительным по ресурсам. Также, чтобы впустую не тратить ресурс процессора, проверим, есть ли смысл размывать изображение. Ведь если оно совпадает с ранее полученным, то нет смысла выполнять «тяжёлый» алгоритм размытия, можно сразу вернуть уже размытое изображение.
Как ранее упоминалось, для размытия могут использоваться разные алгоритмы: ещё одной из возможных оптимизаций нагрузки на процессор является перенос алгоритма размытия на видеокарту. Мы можем использовать для этого функции OpenGL. В таком случае все основные расчёты будут производиться на графической карте, а на процессор ляжет лишь копирование памяти из или в видеокарту. Как ни странно, для моих тестов это не дало особого положительного эффекта.
Теперь перейдём к методу BlurBehindEffect::render()
— именно он будет вызывать вышележащий виджет для получения размытой копии целевого виджета:
void BlurBehindEffect::render(QPainter* painter)
{
if (d->blurRadius_ <= 1 || d->sourceImage_.isNull())
return; // нет исходного изображения или радиус размытия меньше 1
// ничего не поменялось — отрисовываем уже размытое изображение
if (!d->sourceUpdated_)
return d->renderImage(painter, d->blurredImage_);
// размываем изображение
d->blurredImage_ = std::move(d->blurImage(d->sourceImage_));
// отрисовываем изображение
d->renderImage(painter, d->blurredImage_);
// сбрасываем флаг обновления
d->sourceUpdated_ = false;
}
Внутри этого метода вызываются внутренние методы в соответствии с нашей изначальной стратегией: размытие изображения (при необходимости) и отрисовка на целевом контексте. В нашем случае перенос размытия в метод draw()
не даст особого эффекта, потому что в конечном итоге интервал между вызовами draw()
и render()
будет достаточно мал. Я также пробовал переносить размытие изображения в отдельный поток, но и это не дало положительного эффекта, больше времени было потрачено на копирование изображения из и в поток.
В итоге мы получили подход, позволяющий получать эффект Blur Behind для элементов внутри окна. Его можно модифицировать для получения других спецэффектов. Например, чтобы сделать эффект увеличительного стекла, или же, наоборот, уменьшенное изображение виджета для предпросмотра большого количества информации в нём. Можно пойти и другим путём и помимо прочего использовать анимацию параметров размытия - простор для фантазии практически неограничен!
Для проверки всех изложенных идей было написано тестовое приложение, скриншоты которого показаны в начале этой части. Оно позволяет менять параметры эффекта и визуально отследить их влияние на отрисовку и производительность решения. Для эмуляции динамического контента был использован пример из документации Qt. Полный код тестового примера можно найти в репозитарии.
Собираем всë вместе: демонстрационное приложение
Теперь, вооружившись всеми знаниями и приемами из всех частей этой статьи, рассмотрим реализацию демонстрационного приложения.
На рисунке показан скриншот приложения сразу после загрузки тестового изображения.
По своему функционалу - это простенький графический редактор, наподобие небезызвестного Paint. В качестве основного виджета рисования был использован слегка модифицированный пример из документации Qt. За основу главного окна был взят код из примера многослойных окон из предыдущей части статьи.
Основные модификации коснулись панелей управления и уведомлений. Чтобы было удобнее использовать эффект и повысить повторное использование весь код связанный с blur behind эффектом был вынесен в отдельный базовый класс OverlayPanel
. Сами панели (классы ControlPanel
и PopupPanel
) теперь наследуются от этого базового класса. Вот немного упрощенный исходный код класса OverlayPanel
c дополнительными комментариями:
class OverlayPanel : public QWidget
{
Q_OBJECT
public:
OverlayPanel(BlurBehindEffect* _effect, QWidget* _parent = nullptr)
: QWidget(_parent)
, effect_(_effect)
{
if (effect_)
{
// соединим сигнал о необходимости перерисовки с функцией repaint()
connect(effect_, &BlurBehindEffect::repaintRequired, this, qOverload<>(&OverlayPanel::repaint));
// если эффект отключен, прячем весь виджет
connect(effect_, &QGraphicsEffect::enabledChanged, this, &OverlayPanel::setVisible);
}
setAttribute(Qt::WA_TranslucentBackground, true);
}
void resizeEvent(QResizeEvent* _event) Q_DECL_OVERRIDE
{
QWidget::resizeEvent(_event);
// поскольку вычисления QPainterPath довольно затратные,
// закешируем здесь форму панели
cachedPath_ = clipPath();
}
void paintEvent(QPaintEvent* _event) Q_DECL_OVERRIDE
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing|QPainter::SmoothPixmapTransform);
// установим cachedPath_ для придания панели необходимой формы
painter.setClipPath(cachedPath_);
QPainterPath path = cachedPath_;
// переместим форму панели относительно родительского виджета
path.translate(geometry().topLeft());
if (effect_) // отрисуем размытое изображение, если эффект не nullptr
effect_->render(&painter, path);
else
QWidget::paintEvent(_event);
}
// виртуальный метод позволяющий наследникам переопределять форму панели
virtual QPainterPath clipPath() const
{
QPainterPath path;
path.addRect(rect().adjusted(0, 0, -1, -1));
return path;
}
protected:
QPointer<BlurBehindEffect> effect_;
QPainterPath cachedPath_;
};
Стоит отметить несколько важных моментов.
При реализации этого демонстрационного приложения я столкнулся со следующей особенностью. Реализация эффекта размытия описанная ранее отлично работает, если эффект используется только в одном виджете, но если виджетов несколько (как на скриншоте), то некоторые из них могут перестать работать так как задумывалось. Связано это с тем что при изменении размеров мы размываем только часть виджета. Этот регион размытия, очевидно, будет разным для разных панелей, но поскольку панели не пересекаются по геометрии, то правильно отрисовываться будет только одна из них. Ситуация ухудшается тем, что некоторые панели могут быть невидимыми, и в итоге эффект не будет видим вовсе. Чтобы преодолеть эти сложности необходимо задать регион на весь размываемый виджет, а в класс BlurBehindEffect
дописать функцию, которая будет вырезать необходимую часть из размытого изображения и отрисовывать только её. Поскольку теперь система координат у нас отсчитывается от верхнего левого угла размываемого виджета (а не панели), то необходимо переместить QPainterPath
в правильную локацию. При отрисовке панели мы теперь будем передавать помимо экземпляра QPainter
еще и регион самой панели. Вот код дополнительного метода render()
в классе BlurBehindEffect
с поясняющими комментариями:
void BlurBehindEffect::render(QPainter* _painter, const QPainterPath& _clipPath)
{
if (blurRadius() <= 1 || d->sourceImage_.isNull())
return;
if (_clipPath.isEmpty())
return render(_painter);
const QRect regionRect = d->region_.boundingRect(); // прямоугольник размываемого региона
const QRectF targetBounds = _clipPath.boundingRect(); // прямоугольник панели
if (!regionRect.contains(targetBounds.toRect()))
{
qWarning() << "target path is outside of source region";
return;
}
if (d->sourceUpdated_) // необходимо размыть изображение, поскольку оно поменялось
{
d->blurredImage_ = std::move(d->blurImage(d->sourceImage_));
d->sourceUpdated_ = false;
}
// промасштабируем размытое изображение в масштаб 1:1
QImage image = d->blurredImage_.scaled(regionRect.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// выставим прозрачность
_painter->setOpacity(d->blurOpacity_);
// отрисовываем часть размытого изображения в координатах панели (от верхнего левого угла)
_painter->drawImage(QPointF{}, image, targetBounds);
}
Заключение
Хочется отметить, что зачастую современный дизайн интерфейса — вызов, без которого эта статья вряд ли была бы настолько ценной. Также хотелось бы поделиться с читателями своими, немного философскими, наблюдениями. Часто программирование, особенно объектно-ориентированное (ООП), сравнивают с созданием кирпичиков, конструктором, а сам процесс программирования — со строительством. В целом, такое сравнение вполне верное, особенно учитывая, что в обоих случаях решается конкретная инженерная задача. Но, как показывает практика и эта статья, в отличие от обычного Lego библиотека Qt (да и любая хорошо спроектированная ООП-библиотека) позволяет создавать такие решения и использовать предоставляемые объекты в таких сочетаниях, которые даже сами разработчики не закладывали и не могли предвидеть! Этот удивительный факт лишний раз доказывает, насколько гибким может быть ООП при правильном использовании. Этот скромный цикл из двух статей проливает свет лишь на малую часть возможностей библиотеки Qt. Если я навёл и подтолкнул читателей к изучению и более широкому использованию Qt, то можно считать, что цель достигнута.