Отзывы о привязке прокрутки в реальном времени

Обзор

Пользователи ожидают немедленной визуальной обратной связи при взаимодействии с элементами пользовательского интерфейса, такими как карусели или галереи. Традиционная привязка прокрутки обеспечивает обратную связь только после завершения жеста прокрутки и фиксации элемента. Используя события привязки прокрутки, в частности scrollsnapchanging, вы можете предоставлять обратную связь в реальном времени во время жеста прокрутки, выделяя ожидающую цель привязки до того, как пользователь отпустит касание или мышь.

Реализация

1. Прослушивать язык: scrollsnapchanging

Добавьте прослушиватель событий для scrollsnapchanging в контейнер прокрутки. Это событие активируется, когда браузер определяет, что, скорее всего, будет выбрана новая цель привязки.

const container = document.querySelector('#gallery');
const thumbnails = document.querySelectorAll('.thumbnail');
const items = document.querySelectorAll('.gallery-item');

container.addEventListener('scrollsnapchanging', (event) => {
  // Highlight pending snap target during scroll for real-time feedback.
  const pendingTarget = event.snapTargetInline;
  const index = [...items].indexOf(pendingTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Use lightweight class toggle to avoid layout thrashing during rapid events.
  // Note: aria-current is NOT toggled here. It tracks the settled "current"
  // item, which is updated in the scrollsnapchange handler below.
  thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
  thumbnails[index].classList.add('pending');
});

В этом примере используется snapTargetInline, потому что галерея прокручивается по горизонтали. Если контейнер прокручивается по вертикали, используйте snapTargetBlock.

2. Прослушивать язык: scrollsnapchange

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

container.addEventListener('scrollsnapchange', (event) => {
  // Promote pending state to active on scroll completion.
  const snappedTarget = event.snapTargetInline;
  const index = [...items].indexOf(snappedTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Establish final active state and clean up pending.
  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[index].classList.add('active');
  thumbnails[index].setAttribute('aria-current', 'true');
});

3. Синхронизировать начальное состояние

При загрузке страницы браузер может восстановить позицию прокрутки (например, при переходе по истории или анкорной ссылке). События scrollsnapchange и scroll не будут активироваться автоматически. Выполните однократную геометрическую проверку, чтобы синхронизировать интерфейс с начальной позицией прокрутки.

// Note: For item.offsetLeft to be relative to the container, 
// the container MUST be the offsetParent (e.g., `position: relative`).
const findClosestItemIndex = () => {
  // Center-distance assumes scroll-snap-align: center on items.
  // For start-aligned snap, compare scrollLeft to item.offsetLeft directly.
  const containerCenter = container.scrollLeft + container.clientWidth / 2;
  let closestIndex = 0;
  let minDistance = Infinity;

  items.forEach((item, index) => {
    const itemCenter = item.offsetLeft + item.offsetWidth / 2;
    const distance = Math.abs(containerCenter - itemCenter);
    if (distance < minDistance) {
      minDistance = distance;
      closestIndex = index;
    }
  });
  return closestIndex;
};

const initActiveItem = () => {
  const closestIndex = findClosestItemIndex();
  if (!thumbnails[closestIndex]) return;

  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[closestIndex].classList.add('active');
  thumbnails[closestIndex].setAttribute('aria-current', 'true');
};

if (document.readyState === 'complete') {
  initActiveItem();
} else {
  window.addEventListener('load', initActiveItem, { once: true });
}

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

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

ОБЯЗАТЕЛЬНО. Предусмотрите резервный вариант для браузеров, которые не поддерживают эту функцию, иначе связанный интерфейс будет рассинхронизирован с контентом.

РЕКОМЕНДУЕТСЯ имитировать работу scrollsnapchanging в реальном времени с помощью прослушивателя событий scroll в сочетании с requestAnimationFrame и геометрическими расчетами расстояния, чтобы определить ближайшую цель привязки, пока пользователь активно прокручивает страницу.

if ('onscrollsnapchanging' in Element.prototype) {
  // Use native scroll snap events
} else {
  // Fallback: use scroll + requestAnimationFrame for eager feedback
  // (assumes the same container, thumbnails, items defined in step 1)
  let scrollTimeout;
  let rafId = null;

  const promotePendingToActive = () => {
    const closestIndex = findClosestItemIndex();
    if (!thumbnails[closestIndex]) return;
    thumbnails.forEach((thumb) => {
      thumb.classList.remove('pending', 'active');
      thumb.removeAttribute('aria-current');
    });
    thumbnails[closestIndex].classList.add('active');
    thumbnails[closestIndex].setAttribute('aria-current', 'true');
  };

  container.addEventListener('scroll', () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      rafId = null;
      const closestIndex = findClosestItemIndex();
      if (!thumbnails[closestIndex]) return;

      // DO NOT forget to clean up stale pending classes
      thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
      thumbnails[closestIndex].classList.add('pending');
    });

    // Debounce fallback for browsers that don't support scrollend
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(promotePendingToActive, 100);
  }, { passive: true });

  // Fallback: use Baseline `scrollend` event to promote pending to active cleanly where supported
  container.addEventListener('scrollend', () => {
    clearTimeout(scrollTimeout);
    promotePendingToActive();
  });
}

Геометрический резервный вариант scroll + requestAnimationFrame точно имитирует поведение встроенного прогнозирования привязки, включая правильную обработку программной прокрутки. Поскольку такие функции, как scrollIntoView, естественным образом запускают события scroll во время своего выполнения, пользовательский интерфейс будет плавно синхронизироваться на протяжении всей анимации прокрутки без необходимости в дополнительной пользовательской логике.