Двухмерное панорамирование

Веб-функции: scroll-axis-lock overscroll-behavior

Когда пользователь прокручивает двухмерный контейнер прокрутки, браузеры обычно применяют эвристику блокировки оси прокрутки (также известную как «ограждение»). Если жест начинается со значительно большего движения по одной оси, чем по перпендикулярной оси, браузер блокирует прокрутку по этой единственной горизонтальной или вертикальной оси и игнорирует перпендикулярные дельты – часто до конца жеста.

Хотя блокировка осей предотвращает случайный сдвиг в сторону при чтении одномерных потоков, она создает трение на двумерных панорамируемых поверхностях, таких как интерактивные карты, увеличенные изображения, бесконечные холсты, графики узлов и большие сетки данных. Поскольку блокировка сохраняется на протяжении всего жеста, она влияет на любое панорамирование, включающее два направления – независимо от того, панорамируются ли обе оси одновременно (диагональное панорамирование) или одна за другой в рамках одного жеста (например, по изогнутым, круговым или L-образным траекториям). Свойство CSS scroll-axis-lock отключает эту эвристику непосредственно в нативном контейнере прокрутки, позволяя неограниченно панорамировать в двух измерениях, сохраняя при этом прокрутку потока компоновщика, физику импульса, нативные полосы прокрутки и навигацию с помощью клавиатуры.

Реализация

1. Отключить блокировку осей и ограничить прокрутку за пределы контейнера в двумерном контейнере прокрутки

Примените scroll-axis-lock: none непосредственно к элементу, который создает двумерный контейнер прокрутки (overflow: auto или overflow: scroll). Хотя двумерный контейнер прокрутки уже может обновлять дельты прокрутки по обеим осям при значении по умолчанию auto, auto позволяет задействовать эвристику браузера, когда жест начинается преимущественно по одной оси. Если задать для правила scroll-axis-lock: none значение false, то эта функция будет отключена для сенсорных экранов, сенсорных панелей и колес мыши.

Используйте scroll-axis-lock: none вместе с overscroll-behavior: none (или overscroll-behavior: contain) в контейнере прокрутки, чтобы при достижении края не запускалась прокрутка родительской страницы или случайная навигация в браузере (например, горизонтальный жест назад/вперед или потянуть, чтобы обновить).

2. Сохраняйте фокус клавиатуры и доступность

Поскольку на двумерной панорамируемой поверхности часто отсутствуют дочерние элементы управления, на которых можно фокусироваться (например, карта или увеличенное изображение), убедитесь, что пользователи клавиатуры могут фокусироваться на контейнере и панорамировать его с помощью клавиш со стрелками, добавив tabindex="0", соответствующую role и доступную метку, а также видимый индикатор :focus-visible.

Пример кода

<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
     users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
  <div class="pan-surface">
    <!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
  </div>
</div>
.pan-viewport {
  /* Example viewport dimensions; adjust to fit your layout. */
  inline-size: 100%;
  block-size: 500px;

  /* MANDATORY: Establish a scroll container that can overflow on both axes. */
  overflow: auto;

  /* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
     so 2D gestures never get locked to a single axis. */
  scroll-axis-lock: none;

  /* Contain scrolling and prevent accidental swipe navigations at edges
     (use `none` or `contain`). */
  overscroll-behavior: none;
}

/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.pan-surface {
  /* Example surface dimensions larger than .pan-viewport in both axes. */
  inline-size: 3000px;
  block-size: 3000px;
}

Рекомендации и ограничения

  • Применяйте свойство к самому контейнеру прокрутки, а не к родительскому контейнеру. Свойство scroll-axis-lock не наследуется, поэтому его применение к родительскому контейнеру не повлияет на дочерние контейнеры прокрутки.
  • Ограничьте прокрутку и предотвратите случайную навигацию. Добавьте overscroll-behavior: none или overscroll-behavior: contain в контейнер двухмерной прокрутки, чтобы при достижении границы не происходил переход на родительскую страницу, не активировалась навигация назад/вперед в браузере и не срабатывал жест обновления страницы.
  • Вложенные одномерные панели автоматически сохраняют рельсы. Поскольку scroll-axis-lock не наследуется, боковая панель или панель инспектора, вложенная в .pan-viewport, сохраняет поведение scroll-axis-lock: auto по умолчанию.
  • Оставьте auto для стандартных вертикальных фидов, статей и одномерных списков. Фиксация осей предотвращает непреднамеренное перпендикулярное смещение в однонаправленном контенте.
  • Не задавайте touch-action: none безусловно. touch-action определяет, обрабатывает ли браузер сенсорные жесты (и не влияет на ввод с помощью сенсорной панели или колесика), поэтому его настройка отключает встроенную прокрутку с помощью сенсорного экрана. Применяйте его только внутри @supports not (scroll-axis-lock: none) при реализации резервного указателя JavaScript.
  • Панорамируйте с помощью scrollLeft/scrollTop, а не CSS transform. Перемещение поверхности с помощью transform мешает элементу функционировать в качестве контейнера прокрутки, нарушая работу нативных полос прокрутки, фиксации границ, прокрутки с помощью клавиш со стрелками на клавиатуре, событий scroll, IntersectionObserver и анимации на основе прокрутки.

Резервные стратегии

Выберите прогрессивное улучшение или резервный код JavaScript в зависимости от требований вашего приложения.

Вариант 1. Прогрессивное улучшение (без JavaScript)

Используйте прогрессивное улучшение, если сохранение встроенной прокрутки касанием в потоке компоновщика, инерционного импульса и масштабирования щипком важнее, чем устранение блокировки осей в неподдерживаемых браузерах:

  • Поддерживающие браузеры. Браузер не позволяет эвристике блокировки осей ("ограждения") срабатывать в контейнере двухмерной прокрутки во время жестов прокрутки.
  • Неподдерживаемые браузеры. Браузеры, которые не распознают scroll-axis-lock: none, игнорируют эту декларацию и сохраняют стандартное поведение auto при прокрутке в двух направлениях – прокрутка по обеим осям, но с ограничением жестов, которые начинаются преимущественно вдоль одной оси.

Вариант 2. Резервный код JavaScript (wheel + pointermove с scrollLeft/scrollTop)

Если вам нужно неограниченное двумерное панорамирование во всех браузерах, используйте @supports not (scroll-axis-lock: none) и CSS.supports('scroll-axis-lock', 'none'), чтобы условно перехватывать wheel и жесты перетаскивания указателя.

Поскольку wheel (e.deltaX, e.deltaY) и pointermove (e.clientX, e.clientY) предоставляют необработанные, незаблокированные 2D-дельты до того, как механизм прокрутки браузера применит блокировку осей, вы можете применить эти дельты непосредственно к scrollLeft и scrollTop:

  • Почему scrollLeft/scrollTop, а не CSS transform. Обновление scrollLeft и scrollTop позволяет сохранить элемент в качестве контейнера прокрутки. Браузер автоматически фиксирует позиции прокрутки на [0, scrollWidth - clientWidth] и [0, scrollHeight - clientHeight], нативные полосы прокрутки остаются синхронизированными, прокрутка с помощью клавиш со стрелками на клавиатуре продолжает работать, а события scroll и IntersectionObserver запускаются в обычном режиме.
  • Компромиссы. Перехват wheel с помощью { passive: false } переносит прокрутку в основной поток, а установка touch-action: none на сенсорных экранах отключает нативный инерционный импульс после pointerup (а также нативное масштабирование щипком).
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
   and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
  .pan-viewport {
    touch-action: none;
  }
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
  const viewport = document.querySelector('.pan-viewport');
  let lastPointer = null;

  // 1. Trackpad and mouse wheel fallback
  viewport.addEventListener(
    'wheel',
    (e) => {
      e.preventDefault();
      viewport.scrollLeft += e.deltaX;
      viewport.scrollTop += e.deltaY;
    },
    { passive: false },
  );

  // 2. Touch and pointer drag fallback
  viewport.addEventListener('pointerdown', (e) => {
    // Ignore non-primary buttons and clicks on native scrollbars
    const rect = viewport.getBoundingClientRect();
    if (
      !e.isPrimary ||
      e.button !== 0 ||
      e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
      e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
    ) {
      return;
    }
    lastPointer = { x: e.clientX, y: e.clientY };
    viewport.setPointerCapture(e.pointerId);
  });

  viewport.addEventListener('pointermove', (e) => {
    if (!lastPointer) return;
    viewport.scrollLeft -= e.clientX - lastPointer.x;
    viewport.scrollTop -= e.clientY - lastPointer.y;
    lastPointer = { x: e.clientX, y: e.clientY };
  });

  const endPointerPan = () => {
    lastPointer = null;
  };
  viewport.addEventListener('pointerup', endPointerPan);
  viewport.addEventListener('pointercancel', endPointerPan);
}