Различные статьи по Qt и c++

Онбординг и coachmarks: подсветка элементов интерфейса при первом запуске

Введение

Контекстная справка по 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), а не принудительный тур.