Masukan Real-Time Pengepasan Posisi Scroll

Ringkasan

Pengguna mengharapkan masukan visual langsung saat berinteraksi dengan elemen UI seperti carousel atau galeri. Pengepasan posisi scroll tradisional hanya memberikan masukan setelah gestur scroll selesai dan elemen berhenti. Dengan menggunakan Peristiwa Pengepasan Posisi Scroll, khususnya scrollsnapchanging, Anda dapat memberikan masukan real-time selama gestur scroll, yang menandai target pengepasan posisi yang tertunda sebelum pengguna melepaskan sentuhan atau mouse.

Penerapan

1. Dengarkan scrollsnapchanging

Lampirkan pemroses peristiwa untuk scrollsnapchanging ke penampung scroll. Peristiwa ini diaktifkan saat browser menentukan bahwa target snap baru kemungkinan akan dipilih.

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');
});

Contoh ini menggunakan snapTargetInline karena galeri di-scroll secara horizontal. Jika penampung scroll Anda di-scroll secara vertikal, gunakan snapTargetBlock.

2. Dengarkan scrollsnapchange

Untuk menyelesaikan status saat gestur scroll selesai dan elemen benar-benar diposisikan, proses peristiwa scrollsnapchange. Hal ini diperlukan untuk menetapkan status aktif akhir.

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. Status awal sinkronisasi

Saat halaman dimuat, posisi scroll mungkin dipulihkan oleh browser (misalnya, melalui penjelajahan histori atau link anchor). Peristiwa scrollsnapchange dan scroll tidak akan diaktifkan secara otomatis. Jalankan pemeriksaan geometris satu kali untuk menyinkronkan UI dengan posisi scroll awal.

// 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 });
}

Strategi penggantian

Untuk browser yang tidak mendukung scrollsnapchanging, UI tidak akan memberikan masukan langsung selama gestur scroll secara default, dan UI yang ditautkan akan tidak sinkron dengan konten.

WAJIB: Berikan penggantian untuk browser yang tidak mendukung, atau UI yang ditautkan akan tidak sinkron dengan konten.

LAKUKAN simulasi sifat real-time scrollsnapchanging dengan pemroses peristiwa scroll yang digabungkan dengan requestAnimationFrame dan penghitungan jarak geometris untuk menentukan target snap terdekat saat pengguna aktif men-scroll.

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();
  });
}

Fallback scroll + requestAnimationFrame geometris sangat mirip dengan perilaku prediksi snap native, termasuk menangani scroll terprogram dengan benar. Karena fungsi seperti scrollIntoView secara alami memicu peristiwa scroll selama eksekusinya, UI akan tetap disinkronkan dengan lancar di seluruh animasi scroll tanpa memerlukan logika kustom tambahan.