Привет, Хабр! Меня зовут Евгений Гудков. Я работаю в компании 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, достаточно легко. Вы можете сделать это тремя способами:
- Если у вас установлен Qt Creator, просто измените системную скорость скролла и убедитесь, что в IDE все по-прежнему.
- Откройте свое любимое (или нет) приложение, написанное на Qt, и убедитесь, что скорость скролла не меняется.
- Скачайте пример с 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.
- Гибкость в управлении свойствами объектов.
Рассмотрим этот вариант подробнее. Основная его идея в следующем:
- Наследование от
QApplication
и переопределение методаnotify
.
- Модификация
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;
}
В этом методе мы проверяем два условия:
- Источник события не синтезирован системой: не Touchpad, Magic Mouse или другие девайсы Apple.
-
Значение дельты от поворота колесика мышки кратно 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)
VADemon
05.04.2023 10:13+1Всё так хорошо начиналось, но ссылки на багтрекер Qt я не увидел, а заведенный баг и там не нашел. Баг ведь?
GendOSIC Автор
05.04.2023 10:13Скорее всего баг. А может быть умышленное поведение со стороны Qt. О том, чтобы завести баг - я не думал, но все можно сделать. Но разве отсутствие заведенного бага, ухудшает статью?)
VADemon
05.04.2023 10:13+1Нет, со статьей все хорошо, но мне так фраза здесь понравилась :) Мне именно такие статьи и нравятся, где описана проблема и спокойное пошаговое решение, рассмотренные варианты. Это на фоне "классных" статей где трах-бах, смотрите какие мы крутые, всё зашибись, "подпишитесь на нас".
OldHabrReader
и движение курсора тоже от виндовх мышек тоже чуть иначе, скооость - без проблем, но плавность не та...
GendOSIC Автор
Если правильно понял о чем речь. Плавность прокрути, как от девайсов Apple, можно обеспечить, прикрутив анимацию на изменение значений Scroll Bar. Сделать, что то по типу kinetic scroll. У нас это используется. И на это тоже хорошо ложится реализация, описанная в статье