Привет, Хабр! Меня зовут Евгений Гудков. Я работаю в компании VK, где мы дружной командой делаем VK Teams — классный (не реклама) корпоративный мессенджер.

Но сегодня не об этом. Сегодня я хочу сыграть с тобой в одну игру…

Представим, что вышла новая популярная игра Scroll Master. Ее суть — проскроллить как можно больше контента за отведенное время. Разработчики сделали игру при помощи Qt и раздали на все основные Desktop-платформы. Правила игры не запрещают использовать тачпады, Magic Mouse и тому подобное. Также правилами не запрещается менять системную скорость прокрутки. Используй все, что можешь, чтобы стать лучшим! 

С ростом популярности игры начали приходить жалобы от пользователей MacOS, которые играли в игру с PC-мышью. Как бы они ни пытались изменить системную скорость скролла, она оставалась такой же. Из-за этого новые рекорды не установить, а разбитые от злости маки и мышки никому не нужны. Поэтому нужно разобраться, почему Qt не реагирует на системные настройки скролла на MacOS, и как-то решить эту проблему. 

Вводные условия


Саму игру-пример вы можете скачать с GitHub. По умолчанию в настройках стоит галочка Game Mode. Как только вы начнете скроллить контент, запустится таймер на пять секунд. Когда время истечет, скролл остановится и вы увидите свой результат. Чтобы начать заново, нажмите кнопку Reset. Галочка «Modify scroll event», включающая учет системной скорости скролла, по умолчанию снята.

Советую сначала изучить поведение скролла в режиме игры. Сравните, сколько строк вы сможете прокрутить за отведенное время, используя девайсы мака и PC-мышь. Поменяйте системную скоростью скролла (помните, все для победы). Затем установите галочку «Modify scroll event» и попробуйте снова. В этот раз вы не будете чувствовать себя обделенным, используя PC-мышь.

Если режим игры вам надоест, просто уберите галочку GameMode. Без нее не будет таймера на пять секунд.

Ниже представлены последние две секунды скролла: без учета системной скорости скролла и с ее учетом.



Почему скорость скролла для PC-мыши не меняется


Убедиться в том, что системная скорость скролла никак не влияет на скорость в вашем приложении, написанном на Qt, достаточно легко. Вы можете сделать это тремя способами:

  1. Если у вас установлен Qt Creator, просто измените системную скорость скролла и убедитесь, что в IDE все по-прежнему.
  2. Откройте свое любимое (или нет) приложение, написанное на Qt, и убедитесь, что скорость скролла не меняется.
  3. Скачайте пример с GitHub и запустите его. Чтобы увидеть, как учитывается системная скорость скролла, поставьте галочку «Modify scroll event».

Почему так происходит? Давайте разбираться. Рассмотрим исходный код QAbstractScrollArea, так как все наши view, как правило, так или иначе унаследованы от него. Обработка события скролла мыши происходит в методе QAbstractScrollArea::wheelEvent(QWheelEvent *e). Далее событие пересылается горизонтальному или вертикальному QScrollBar, который тоже обрабатывает его в методе wheelEvent. Вот тут и начинается самое интересное.

void QScrollBar::wheelEvent(QWheelEvent *event)
{
        event->ignore();
        bool horizontal = qAbs(event->angleDelta().x()) > qAbs(event->angleDelta().y());
        // The vertical wheel can be used to scroll a horizontal scrollbar, but only if
        // there is no simultaneous horizontal wheel movement. This is to avoid chaotic
        // scrolling on touchpads.
        if (!horizontal && event->angleDelta().x() != 0 && orientation() == Qt::Horizontal)
            return;
        // scrollbar is a special case - in vertical mode it reaches minimum
        // value in the upper position, however QSlider's minimum value is on
        // the bottom. So we need to invert the value, but since the scrollbar is
        // inverted by default, we need to invert the delta value only for the
        // horizontal orientation.
        int delta = horizontal ? -event->angleDelta().x() : event->angleDelta().y();
        Q_D(QScrollBar);
        if (d->scrollByDelta(horizontal ? Qt::Horizontal : Qt::Vertical, event->modifiers(), delta))
            event->accept();
        if (event->phase() == Qt::ScrollBegin)
            d->setTransient(false);
        else if (event->phase() == Qt::ScrollEnd)
           d->setTransient(true);
}

Qt обрабатывает только составляющую angleDelta() из полученного QWheelEvent. Что же это за составляющая и что в ней передается для PC-мыши? Вот что говорится в документации:


Returns the relative amount that the wheel was rotated, in eighths of a degree. Most mouse types work in steps of 15 degrees, in which case the delta value is a multiple of 120; i.e., 120 units * 1/8 = 15 degrees.


Так как мы работаем с обычной PC-мышью, значение дельты при повороте колесика мыши кратно 120 и константно. Теперь понятно, почему Qt никак не реагирует на системные настройки скорости скролла в MacOS. А как же тогда Qt реагирует на скорость в Windows или Linux? 

bool QAbstractSliderPrivate::scrollByDelta(Qt::Orientation orientation, Qt::KeyboardModifiers modifiers, int delta)
{
        ...
        // Calculate how many lines to scroll. Depending on what delta is (and
        // offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
        // only scroll whole lines, so we keep the reminder until next event.
        qreal stepsToScrollF =
#if QT_CONFIG(wheelevent)
        QApplication::wheelScrollLines() *
#endif
        offset * effectiveSingleStep();
        ...
}

В Windows или Linux QApplication::wheelScrollLines напрямую связан с системной скоростью скролла, что обеспечивает ускорение в наших приложениях, а в MacOS QApplication::wheelScrollLines константен и по умолчанию равен трем.

Складывается ощущение, что ничего не поделать. Найдем в документации к QWheelEvent метод pixelDelta():


Returns the scrolling distance in pixels on screen. This value is provided on platforms that support high-resolution pixel-based delta values, such as MacOS. The value should be used directly to scroll content on screen.


И вот наше спасение. Этот метод возвращает неконстантные значения для дельты прокрутки, которые зависят от системной скорости скролла. Выглядит словно то, что нам нужно.

Можно убедиться в том, что angleDelta и pixelDelta возвращают разные значения, если вывести отладочную информацию от какого-либо QWheelEvent.

Зная, что Qt работает только с angleDelta(), а pixelDelta() возвращает неконстантные значения, зависящие от системной скорости скролла, можно сформулировать нашу цель.

Чего мы хотим: учитывать системную скорость скролла для PC-мыши в MacOS.

Что нам для этого нужно:

  • определить, что мы работаем с PC-мышью;
  • модифицировать QWheelEvent так, чтобы вместо константного angleDelta передавался pixelDelta, зависящий от системной скорости.

Какие еще есть требования:

  • гибкость в управлении,
  • гибкость при написании нового кода.

Варианты решения задачи


Вариант 1


Базовый класс, унаследованный от QAbstractScrollArea, с переопределенным методом wheelEvent.

#pragma once

#include <QAbstractScrollArea>

class CustomView : public QAbstractScrollArea
{
public:
        explicit CustomView(QWidget* _parent = nullptr);

protected:
        void wheelEvent(QWheelEvent* _event)
        {
            // Handle wheel event
        }
};

Достоинства:

  • Модификация события прокрутки в одном месте.

Недостатки:

  • Дополнительный базовый класс. Учитывая, что в программе могут использоваться кастомные View, унаследованные от чего-либо, это приведет к еще большему разбуханию кода.
  • Маленькая гибкость. При добавлении или изменении чего-либо придется убирать наследование.

Этот вариант подходит, но из-за недостатков выглядит не столь привлекательным — хочется  большей гибкости в управлении и меньшего разбухания кода. Модификация скролл-ивента выглядит как свойство объекта, которое хотелось бы выставлять или убирать без особых трудностей.

Вариант 2


Переопределение метода QApplication::notify и использование динамических свойств объектов.

Достоинства:

  • Модификация скролла в одном месте.
  • Нет лишних наследований у различных View.
  • Гибкость в управлении свойствами объектов.

Рассмотрим этот вариант подробнее. Основная его идея в следующем: 

  1. Наследование от QApplication и переопределение метода notify.
  2. Модификация QWheelEvent для QScrollBar, у которого задано динамическое свойство, разрешающее модификацию QWheelEvent.

Интерфейс класса будет выглядеть следующим образом:

class MyApp : public QApplication
{
public:
       MyApp(int& argc, char** argv, int = ApplicationFlags);
       static void setRequireCustomWheelEvent(QScrollBar* _slider, bool _on = true);

public:
        bool notify(QObject* _obj, QEvent* _event);
};

setRequireCustomWheelEvent(QScrollBar* _slider, bool _on = true) используется для того, чтобы выставить динамическое свойство объекту для определения необходимости модифицировать QwheelEvent.

void MyApp::setRequireCustomWheelEvent(QScrollBar* _slider, bool _on)
{
        _slider->setProperty(Utils::Scroll::wheelEventModificationProperty.data(), _on);
}

Рассмотрим реализацию метода MyApp::notify(QObject* _obj, QEvent* _event):

bool MyApp::notify(QObject* _obj, QEvent* _event)
{
#ifdef __APPLE__
        if (_event->type() != QEvent::Wheel)
            return QApplication::notify(_obj, _event);

        if (auto scrollbar = qobject_cast<QScrollBar*>(_obj))
        {
            const QVariant value = _obj->property(Utils::Scroll::wheelEventModificationProperty.data());

            if (value.isValid() && value.toBool())
            {
                QWheelEvent wheelEvent = Utils::Scroll::modify(static_cast<QWheelEvent*>(_event));
                scrollbar->event(&wheelEvent);
                return true;
            }
        }
#endif

        return QApplication::notify(_obj, _event);
}

Рассмотрим реализацию метода Utils::Scroll::modify(static_cast<QWheelEvent*>(_event)), который создает новый QWheelEvent, если мы используем PC-мышь в MacOS. Метод mofidy(QWheelEvent* _event) делает следующее:

  • определяет, что мы работаем с PC-мышью;
  • рассчитывает новое значение дельты прокрутки;
  • создает и возвращает новый QWheelEvent.

QWheelEvent modify(QWheelEvent* _event)
{
#ifdef __APPLE__
        if (isPcMouse(_event)) 
        {
            const QPoint numPixels = _event->pixelDelta();
            const QPoint numDegrees = _event->angleDelta();

            QPoint delta {};
        
            if (!numPixels.isNull())
            {
                delta = numPixels;
                delta *= qApp->wheelScrollLines();
            }
            else if (!numDegrees.isNull())
                delta = numDegrees;

            return { _event->position(), _event->globalPosition(), {}, delta, _event->buttons(), _event->modifiers(), _event->phase(), _event->inverted(), _event->source() };
        }
#endif

       return { _event->position(), _event->globalPosition(), _event->pixelDelta(), _event->angleDelta(), _event->buttons(), _event->modifiers(), _event->phase(), _event->inverted(), _event->source() };
}

Определение использования PC-мыши:

bool isPcMouse(QWheelEvent* _e)
{
        if (_e->source() == Qt::MouseEventSynthesizedBySystem)
            return false;

        return getPcMouseWheelDelta(_e) % QWheelEvent::DefaultDeltasPerStep == 0;
}

В этом методе мы проверяем два условия:

  1. Источник события не синтезирован системой: не Touchpad, Magic Mouse или другие девайсы Apple.
  2. Значение дельты от поворота колесика мышки кратно 120.

Так как метод delta() QWheelEvent объявлен как deprecated, добавим вспомогательный метод int getPcMouseWheelDelta(QWheelEvent* _e) для определения дельты:

int getPcMouseWheelDelta(QWheelEvent* _e)
{
        int delta { 0 };

        const QPoint scrollDelta = _e->angleDelta();

        if (scrollDelta.x() != 0 && scrollDelta.y() != 0)
            delta = std::max(qAbs(scrollDelta.x()), qAbs(scrollDelta.y()));
        else
            delta = scrollDelta.x() != 0 ? scrollDelta.x() : scrollDelta.y();

        return delta;
}

Определение нового значения дельты:

if (!numPixels.isNull())
{
    delta = numPixels;
    delta *= qApp->wheelScrollLines();
}
else if (!numDegrees.isNull())
    delta = numDegrees;

Как говорилось выше, pixelDelta зависит от системной скорости прокрутки. Но при использовании сырых данных pixelDelta скорость очень низкая. Поэтому домножим pixelDelta на qApp->wheelScrollLines(), который по умолчанию равен трем. Да, это легкий визуальный подгон. Вы можете использовать любую другую константу, которая вам покажется более подходящей.

Теперь создадим новый QWheelEvent. В конструктор вместо angleDelta
передадим рассчитанную дельту, а вместо pixelDelta передадим пустой QPoint:

return { _event->position(), _event->globalPosition(), {}, delta, _event->buttons(), _event->modifiers(), _event->phase(), _event->inverted(), _event->source() };

Добавление динамических свойств. Как говорилось ранее, обрабатывать события мы будем для QScrollBar. Следовательно, мы должны выставлять динамическое свойство для скроллбара QAbstractScrollArea. В классе MyApp сделан статический метод:

static void setRequireCustomWheelEvent(QScrollBar* _slider, bool _on = true)
{ 
    _slider->setProperty("RequireCustomWheelEvent", _on);
}

Чтобы добавить динамическое свойство для вашего View, просто передайте в этот метод указатель на горизонтальный или вертикальный скроллбар и булев флаг.

Заключение




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

В этой статье мы разработали элегантное решение, которое устраняет проблему системной скорости скролла на MacOS при использовании PC-мыши. Теперь игроки с PC-мышью не сломают себе указательный палец, усердно скролля контент и пытаясь попасть в топы. Они будут наслаждаться теми же благами, что и люди с девайсами от Apple.

Комментарии (6)


  1. OldHabrReader
    05.04.2023 10:13

    и движение курсора тоже от виндовх мышек тоже чуть иначе, скооость - без проблем, но плавность не та...


    1. GendOSIC Автор
      05.04.2023 10:13

      Если правильно понял о чем речь. Плавность прокрути, как от девайсов Apple, можно обеспечить, прикрутив анимацию на изменение значений Scroll Bar. Сделать, что то по типу kinetic scroll. У нас это используется. И на это тоже хорошо ложится реализация, описанная в статье


  1. VADemon
    05.04.2023 10:13
    +1

    Всё так хорошо начиналось, но ссылки на багтрекер Qt я не увидел, а заведенный баг и там не нашел. Баг ведь?


    1. GendOSIC Автор
      05.04.2023 10:13

      Скорее всего баг. А может быть умышленное поведение со стороны Qt. О том, чтобы завести баг - я не думал, но все можно сделать. Но разве отсутствие заведенного бага, ухудшает статью?)


      1. VADemon
        05.04.2023 10:13
        +1

        Нет, со статьей все хорошо, но мне так фраза здесь понравилась :) Мне именно такие статьи и нравятся, где описана проблема и спокойное пошаговое решение, рассмотренные варианты. Это на фоне "классных" статей где трах-бах, смотрите какие мы крутые, всё зашибись, "подпишитесь на нас".


        1. GendOSIC Автор
          05.04.2023 10:13

          Спасибо большое. Рад, что статья понравилась!