Введение
Контекстная справка по F1 (статья 164) хорошо работает, когда пользователь уже знает, что искать — но при первом знакомстве с приложением многие пользователи не нажимают F1 вовсе. Онбординг через coachmarks (всплывающие подсказки, привязанные к конкретным элементам интерфейса, показываемые последовательно при первом запуске) — другой, более проактивный подход к обучению пользователя. В статье построим простую систему coachmarks на Qt Widgets.
Концепция
Coachmark обычно состоит из полупрозрачной overlay-подложки, затемняющей весь интерфейс кроме одного выделенного элемента, и всплывающей подсказки рядом с этим элементом с текстом и кнопкой «Далее». Последовательность coachmarks образует «тур» по интерфейсу — пользователь проходит через несколько шагов, каждый привязанный к своему виджету, прежде чем оверлей исчезает и обычная работа с приложением становится доступна без ограничений. Решение о показе тура обычно сохраняется в QSettings — тур показывается один раз и не повторяется при последующих запусках, если пользователь не запросит его явно.
Пример кода: Онбординг и coachmarks
// coachmarkoverlay.h
#pragma once
#include <QWidget>
#include <QPainter>
#include <QPushButton>
#include <QLabel>
class CoachmarkOverlay : public QWidget
{
Q_OBJECT
public:
explicit CoachmarkOverlay(QWidget *parentWindow) : QWidget(parentWindow)
{
setAttribute(Qt::WA_TransparentForMouseEvents, false);
resize(parentWindow->size());
m_hintLabel = new QLabel(this);
m_hintLabel->setStyleSheet(
"background: white; border-radius: 8px; padding: 12px; font-size: 14px;");
m_hintLabel->setWordWrap(true);
m_hintLabel->setFixedWidth(250);
m_nextButton = new QPushButton("Далее", this);
connect(m_nextButton, &QPushButton::clicked, this, &CoachmarkOverlay::nextStep);
}
void addStep(QWidget *targetWidget, const QString &hintText)
{
m_steps.append({targetWidget, hintText});
}
void start()
{
m_currentStep = -1;
nextStep();
show();
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), QColor(0, 0, 0, 150)); // полупрозрачное затемнение
if (m_currentStep >= 0 && m_currentStep < m_steps.size()) {
const QRect targetRect = m_steps[m_currentStep].widget->geometry();
const QRect globalRect(mapFromGlobal(
m_steps[m_currentStep].widget->mapToGlobal(QPoint(0, 0))), targetRect.size());
// "Прорезаем" затемнение в области целевого виджета
painter.setCompositionMode(QPainter::CompositionMode_Clear);
painter.fillRect(globalRect.adjusted(-4, -4, 4, 4), Qt::transparent);
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
painter.setPen(QPen(QColor(66, 133, 244), 3));
painter.drawRect(globalRect.adjusted(-4, -4, 4, 4));
}
}
private slots:
void nextStep()
{
m_currentStep++;
if (m_currentStep >= m_steps.size()) {
close();
emit finished();
return;
}
const auto &step = m_steps[m_currentStep];
m_hintLabel->setText(step.hintText);
const QPoint globalPos = step.widget->mapToGlobal(QPoint(0, step.widget->height() + 10));
const QPoint localPos = mapFromGlobal(globalPos);
m_hintLabel->move(localPos);
m_hintLabel->adjustSize();
m_nextButton->move(localPos.x(), localPos.y() + m_hintLabel->height() + 5);
update();
}
signals:
void finished();
private:
struct Step { QWidget *widget; QString hintText; };
QList<Step> m_steps;
int m_currentStep = -1;
QLabel *m_hintLabel;
QPushButton *m_nextButton;
};
// Использование при первом запуске приложения
#include "coachmarkoverlay.h"
#include <QSettings>
void MainWindow::maybeShowOnboarding()
{
QSettings settings;
if (settings.value("onboarding/completed", false).toBool()) {
return;
}
auto *overlay = new CoachmarkOverlay(this);
overlay->addStep(m_newDocumentButton, "Нажмите здесь, чтобы создать новый документ.");
overlay->addStep(m_searchField, "Здесь можно искать по всем открытым документам.");
overlay->addStep(m_helpButton, "А здесь — справка, если что-то непонятно.");
connect(overlay, &CoachmarkOverlay::finished, [overlay]() {
QSettings().setValue("onboarding/completed", true);
overlay->deleteLater();
});
overlay->start();
}
Пояснения к коду
CoachmarkOverlay — полупрозрачный виджет, растянутый на весь родительское окно, который в paintEvent() заливает весь экран затемнением и «прорезает» в нём прозрачную область точно по геометрии текущего целевого виджета через QPainter::CompositionMode_Clear — этот режим композиции позволяет буквально удалить уже нарисованные пиксели в указанной области, создавая визуальный эффект «окна» в затемнении, через которое виден подсвечиваемый элемент интерфейса без затемнения.
addStep() накапливает последовательность шагов тура, каждый привязанный к конкретному виджету и тексту подсказки; nextStep() пересчитывает позицию подсказки и кнопки относительно текущего целевого виджета через mapToGlobal()/mapFromGlobal() (необходимое преобразование координат, поскольку оверлей и целевой виджет могут быть в разных системах координат вложенных layout’ов) и перерисовывает оверлей. Проверка settings.value("onboarding/completed", false) в maybeShowOnboarding() гарантирует, что тур показывается только один раз за всё время использования приложения, если пользователь явно не запросит его повторно.
Подводные камни
- Координаты целевого виджета могут измениться между шагами, если интерфейс динамический (изменение размера окна пользователем во время прохождения тура, или появление/скрытие других элементов) — без переподписки на
resizeEvent()родительского окна и перерасчёта позиций оверлей может «оторваться» от реальной позиции целевого элемента при изменении размеров окна. - Слепое следование туру без возможности его прервать — отсутствие в примере кнопки «Пропустить» может фрустрировать опытных пользователей, уже знакомых с похожими интерфейсами (например, мигрирующих с конкурирующего продукта) — полноценная реализация должна предусматривать явный выход из тура на любом шаге, а не только последовательное прохождение до конца.
- Coachmarks, привязанные к виджетам, которые могут не существовать в момент показа тура (например, элемент, видимый только в определённом режиме приложения, отсутствующем сразу после запуска) — обращение к
nullptrили скрытому виджету вaddStep()без проверки его видимости приведёт либо к краху, либо к показу подсказки в неожиданном месте экрана (нулевые координаты). - Чрезмерно длинный тур, утомляющий пользователя избытком шагов при первом запуске — онбординг эффективен, когда ограничивается тремя-пятью ключевыми элементами, действительно критичными для первого успешного использования, а не пытается «показать всё» сразу; для менее важных функций лучше подходит контекстная справка по требованию (F1), а не принудительный тур.