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

Поддержка screen reader для кастомных виджетов

Введение

Стандартные виджеты Qt автоматически интегрированы с системой доступности, но кастомные виджеты, нарисованные через переопределённый paintEvent() (как разбиралось в основном цикле статей про Qt Widgets), не предоставляют никакой информации screen reader без явной реализации. В статье построим полноценную поддержку доступности для кастомного виджета — кругового индикатора прогресса из предыдущего цикла статей про UI — через QAccessibleInterface.

Концепция

Для кастомного виджета, у которого нет встроенной поддержки доступности, нужно реализовать собственный класс, наследующий QAccessibleInterface (или, что чаще удобнее, QAccessibleWidget — облегчённую базовую реализацию для виджетов), переопределяющий методы, сообщающие screen reader роль элемента (role()), его текущее состояние (state()) и текстовое представление (text()). Эта реализация затем регистрируется через QAccessible::installFactory() — фабричную функцию, которую Qt вызывает каждый раз, когда подсистема доступности запрашивает интерфейс доступности для конкретного объекта.

Пример кода: Поддержка screen reader

// circularprogressbar.h — кастомный виджет из основного цикла, дополненный для доступности
#pragma once
#include <QWidget>
#include <QAccessibleWidget>

class CircularProgressBar : public QWidget
{
    Q_OBJECT
    Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)

public:
    explicit CircularProgressBar(QWidget *parent = nullptr) : QWidget(parent)
    {
        setMinimumSize(100, 100);
        setFocusPolicy(Qt::TabFocus); // делаем виджет доступным для клавиатурной навигации
    }

    int value() const { return m_value; }

    void setValue(int value)
    {
        value = qBound(0, value, 100);
        if (m_value == value) return;
        m_value = value;
        emit valueChanged(value);
        update();

        // Уведомляем подсистему доступности об изменении значения
        QAccessibleValueChangeEvent event(this, value);
        QAccessible::updateAccessibility(&event);
    }

signals:
    void valueChanged(int value);

protected:
    void paintEvent(QPaintEvent *event) override; // реализация из основного цикла

private:
    int m_value = 0;
};
// circularprogressbaraccessible.h — интерфейс доступности для кастомного виджета
#pragma once
#include <QAccessibleWidget>
#include "circularprogressbar.h"

class CircularProgressBarAccessible : public QAccessibleWidget
{
public:
    explicit CircularProgressBarAccessible(QWidget *widget)
        : QAccessibleWidget(widget, QAccessible::ProgressBar) {}

    QString text(QAccessible::Text textType) const override
    {
        auto *progressBar = static_cast<CircularProgressBar *>(widget());
        if (textType == QAccessible::Name) {
            return "Индикатор прогресса загрузки";
        }
        if (textType == QAccessible::Value) {
            return QString::number(progressBar->value()) + "%";
        }
        return QAccessibleWidget::text(textType);
    }

    QAccessible::State state() const override
    {
        QAccessible::State state = QAccessibleWidget::state();
        // Дополнительных специфичных флагов состояния для этого виджета не требуется,
        // базовая реализация уже сообщает focusable/visible корректно
        return state;
    }
};

// Фабричная функция, регистрируемая в подсистеме доступности
QAccessibleInterface *circularProgressBarAccessibleFactory(const QString &classname, QObject *object)
{
    if (classname == QLatin1String("CircularProgressBar") && object && object->isWidgetType()) {
        return new CircularProgressBarAccessible(static_cast<QWidget *>(object));
    }
    return nullptr;
}
// main.cpp — регистрация фабрики при старте приложения
#include <QApplication>
#include <QAccessible>
#include "circularprogressbaraccessible.h"

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QAccessible::installFactory(circularProgressBarAccessibleFactory);

    CircularProgressBar progressBar;
    progressBar.setValue(45);
    progressBar.show();

    return app.exec();
}

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

CircularProgressBarAccessible наследует QAccessibleWidget, который уже предоставляет разумную базовую реализацию большинства методов интерфейса доступности (геометрия, видимость, фокусируемость), требуя переопределения только специфичных для конкретного виджета аспектов — text() возвращает осмысленное имя («Индикатор прогресса загрузки») и текущее значение в процентах, что screen reader озвучит при попадании фокуса на этот элемент. Указание роли QAccessible::ProgressBar в конструкторе сообщает вспомогательным технологиям, что это семантически именно индикатор прогресса, а не произвольный графический элемент — это влияет на то, как именно screen reader представит элемент пользователю (например, объявит «индикатор прогресса, 45 процентов» вместо невнятного «неизвестный элемент интерфейса»).

circularProgressBarAccessibleFactory() — фабричная функция, которую Qt вызывает для каждого объекта, для которого запрашивается интерфейс доступности; проверка classname == "CircularProgressBar" гарантирует, что собственная реализация подставляется именно для экземпляров этого конкретного класса, не затрагивая доступность остальных виджетов приложения. Регистрация через QAccessible::installFactory() должна произойти до создания и показа виджетов, для которых эта фабрика должна сработать.

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

  • Забытая регистрация фабрики (installFactory) до создания виджета. Если фабрика регистрируется после того, как экземпляр виджета уже создан и (что важнее) подсистема доступности уже успела один раз запросить и закэшировать для него интерфейс по умолчанию, кастомная реализация может не подключиться к уже существующему экземпляру — порядок инициализации важен, и регистрация должна происходить как можно раньше в main().
  • setFocusPolicy(Qt::TabFocus) забыт для кастомного виджета, который по умолчанию (как большинство простых QWidget-наследников без явной настройки) не участвует в обходе фокуса по Tab — даже при полностью корректной реализации интерфейса доступности, если пользователь, навигирующий только с клавиатуры (включая пользователей screen reader, которые почти никогда не используют мышь), физически не может перевести фокус на этот элемент через Tab, вся проделанная работа по доступности оказывается недостижимой для него.
  • Сравнение строкового имени класса (classname == "CircularProgressBar") без учёта пространств имён или возможного переименования класса при рефакторинге. Если класс позже перемещается в namespace или переименовывается, а строка в фабричной функции не обновляется синхронно, фабрика тихо перестаёт срабатывать для этого класса без какой-либо явной ошибки компиляции — несоответствие обнаружится только при ручном тестировании с screen reader.
  • Отсутствие уведомления о значении через QAccessibleValueChangeEvent при изменении прогресса — без этого вызова (как показано в setValue()) screen reader, уже сфокусированный на этом элементе ранее, не узнает об изменении значения автоматически и продолжит сообщать устаревший процент при повторных запросах пользователя, аналогично проблеме NameChanged, разобранной в предыдущей статье.