Манипулируем скроллом в браузере

19 просмотров
Введение

Событием колеса мыши редко занимаются специально. Обычный <div> со стандартным скроллом прекрасно работает без единой строчки JS. К wheel-событию обращаются, когда браузерного скролла недостаточно: кастомный скроллер, canvas-редактор с зумом колесом, галерея с горизонтальной прокруткой, панорама на карте. И вот тут выясняется, что за простым на вид событием стоит много ньюансов и боли. Здесь мы попробуем разобраться в том, как это все работает и почему именно так.

Отличие событий wheel и scroll

Первое, что стоит подсветить: wheel и scroll это разные события с разной природой.

wheel возникает от физического движения колеса или тачпада и не обязан приводить к прокрутке. Элемент может быть вообще нескроллящимся, а событие всё равно придёт.

scroll, наоборот, срабатывает при любом изменении позиции прокрутки: от клавиатуры, от перетаскивания скроллбара, от scrollTo() и независимо от того, было колесо задействовано или нет.

Спецификация UI Events прямо предупреждает: даже если wheel-событие вызвало прокрутку, значения дельты не обязательно совпадают с тем, насколько именно сдвинулся контент.

Ловушка deltaMode

WheelEvent содержит три числа: deltaX, deltaY, deltaZ. Это горизонтальный, вертикальный сдвиг и «зум» по оси Z (актуален для трёхмерных сцен и жестов масштабирования). Знак определяется правосторонней системой координат: положительная ось Y направлена вниз, получается deltaY > 0 обозначает прокрутку содержимого вниз.

Остается вопрос. В каких единицах измерения приходят эти числа? И тут мы конечно ожидаем пиксели, как что-то стандартное и привычное. Но не стоит забывать про deltaMode, который так же приходит в WheelEvent. Он может быть разных типов:

  • DOM_DELTA_PIXEL - дельта в пикселях;
  • DOM_DELTA_LINE - дельта в «строках» (высота строки считается как font-size);
  • DOM_DELTA_PAGE - дельта в «страницах».

Какой тип придет зависит от движка браузера и системных настроек ОС, но про это чуть позже.

Поведение явно спорное и это показывает реальный случай: сайт privacy.google.com жёстко предполагал пиксели и в Firefox прокрутка у них просто ломалась. Подробнее можно почитать багтрекере Mozilla по ссылке. И кажется на момент написания статьи вопрос остается открытым в связанном баге.

Когда какой режим

Вот тут начинается расхождение, которое видимо стоило веб-разработчикам немало нервов. Chrome и Safari практически во всех конфигурациях ОС отдают DOM_DELTA_PIXEL причем независимо от того, что на самом деле сообщило оборудование. Firefox исторически поступал иначе: отдавал «lossless»-значение от нативного события, и для обычной мыши с дискретным колесом это означало DOM_DELTA_LINE, где deltaY: ±1 за один щелчок колеса.

Разработчик Gecko-реализации WheelEvent в одном из обсуждений в Bugzilla прямо признал: раз Chrome и Edge почти всегда используют DOM_DELTA_PIXEL, множество сайтов, не проверяющих deltaMode, ломаются именно в Firefox. Хотя Firefox единственный соблюдает букву спецификации. В итоге команда Firefox констатировала, что цена совместимости выше, чем ценность «честных» единиц измерения, и начала склоняться к пиксельному режиму по умолчанию.

Для простоты пока можно пользоваться утверждением: в Chrome почти всегда DOM_DELTA_PIXEL, Firefox почти всегда DOM_DELTA_LINE.

Пинч-зум устроен ещё веселее

Пинч-зум(Pinch zoom) - это жест масштабирования содержимого на экране двумя пальцами. Когда пользователь сводит или разводит два пальца на трекпаде, браузеры не создают отдельное «событие зума», а переиспользуют обычный wheel. Chrome и Firefox делают так: присылают wheel-событие, но с флагом ctrlKey: true. По этому флагу разработчик должен догадаться, что это не прокрутка, это зум и взять deltaY не как сдвиг в пикселях, а как готовый коэффициент масштаба. Так исторически делал ещё Internet Explorer, и остальные подхватили это поведение.

Safari пошёл по-своему. У него нет ctrlKey-трюка, но вместо этого с версии 9.1 есть отдельное, собственное событие gesturestart / gesturechange / gestureend, которое сразу приходит с готовыми полями scale (насколько увеличили/уменьшили) и rotation (насколько повернули).

Разных представления операционных систем

То, что физически ощущается пользователем как «один щелчок колеса», кодируется на уровне ОС по-разному.

Для Windows нативное сообщение WM_MOUSEWHEEL даёт дельту, кратную 120 на один щелчок. Это исторический стандарт ещё с Windows 95. Пользовательская настройка «сколько строк прокручивать за один щелчок» интерпретируется браузером самостоятельно, а не ОС. Иными словами, ОС лишь хранит настройку, а применяет её браузер.

В Linux похожая история: одно нативное wheel-событие даёт 120 или −120. Поведение здесь ближе к Windows.

В macOS все сложнее. Значение зависит от того, поддерживает ли устройство непрерывную прокрутку. Трекпад MacBook и «плавные» колёса мышей дают ускоренную, непрерывную дельту (дробные значения, которые меняются в зависимости от скорости жеста). Обычное дискретное колесо старого образца даёт фиксированные 120 на щелчок, без ускорения. Это значит, что на одном и том же Mac одно и то же приложение может получать структурно разные данные в зависимости от того, какое устройство ввода к нему подключено, а достоверно определить, какое именно устройство сгенерировало событие, у веб-страницы возможности нет.

Еще один нюанс. В операционной системе можно изменить настройку «Направление скрола», в зависимости от нее ожидаемо поменяется и знак дельты при движении трекпада.

Трекпад против мыши

Отдельно стоит разница между внешней мышью с механическим или оптическим колесом и трекпадом. Колесо мыши физически дискретно то есть оно устроено так, что регистрирует шаги, и даже «свободно вращающиеся» колёса без выраженных щелчков всё равно дают грубые, ступенчатые события. Трекпад, наоборот, способен генерировать по-настоящему плавный, непрерывный поток дельт. Таким образом, не имея признака устройства в событии wheel, отличить мышь от трекпада все же можно, оценив приходящие дельты. Для трекпада характерны дробные числа и разная скорость скрола. Для колесика мыши события приходят реже, с фиксированным шагом и с большим значением дельты за раз.

Как с этим жить на практике

Практических выводов из всего вышеперечисленного немного:

  1. Всегда проверяйте deltaMode, прежде чем использовать deltaX/deltaY как пиксели. Если он не DOM_DELTA_PIXEL, домножьте на примерную высоту строки (1620px как грубое приближение) или на высоту вьюпорта для режима страниц.
  2. Не полагайтесь на конкретную величину дельты как на признак устройства или скорости. Она регулируется пользовательскими настройками ОС и может меняться даже на одной машине.
  3. Не хардкодьте знак направления прокрутки потому-что он может зависеть настройки операционной системы.
  4. Для пинч-зума разводите Safari и Chrome/Firefox отдельно: gesturestart/gesturechange против wheel с ctrlKey.
  5. Проверяйте, что addEventListener имеет { passive: true }, если не вызываете preventDefault(). Такой подход рекомендует спецификация ради производительности скролла, и без неё браузер вынужден ждать выполнения обработчика перед тем, как начать прокрутку.
К списку статей