Phản hồi theo thời gian thực về tính năng cố định vị trí cuộn

Tổng quan

Người dùng mong muốn nhận được phản hồi trực quan ngay lập tức khi tương tác với các phần tử trên giao diện người dùng như băng chuyền hoặc thư viện. Tính năng cố định vị trí cuộn truyền thống chỉ cung cấp thông tin phản hồi sau khi cử chỉ cuộn hoàn tất và phần tử ổn định. Bằng cách sử dụng Sự kiện cố định vị trí cuộn, cụ thể là scrollsnapchanging, bạn có thể cung cấp ý kiến phản hồi theo thời gian thực trong cử chỉ cuộn, làm nổi bật mục tiêu cố định vị trí đang chờ xử lý trước khi người dùng thả thao tác chạm hoặc chuột.

Triển khai

1. Nghe scrollsnapchanging

Đính kèm trình nghe sự kiện cho scrollsnapchanging vào vùng chứa cuộn. Sự kiện này kích hoạt khi trình duyệt xác định rằng một mục tiêu đính kèm mới có khả năng được chọn.

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

Ví dụ này sử dụng snapTargetInline vì thư viện cuộn theo chiều ngang. Nếu vùng chứa cuộn của bạn cuộn theo chiều dọc, hãy sử dụng snapTargetBlock.

2. Nghe scrollsnapchange

Để hoàn tất trạng thái khi cử chỉ cuộn hoàn tất và phần tử thực sự bám dính, hãy theo dõi sự kiện scrollsnapchange. Bạn cần thực hiện bước này để thiết lập trạng thái hoạt động cuối cùng.

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. Đồng bộ hoá trạng thái ban đầu

Khi trang tải, vị trí cuộn có thể được trình duyệt khôi phục (ví dụ: thông qua việc duyệt qua nhật ký hoặc đường liên kết neo). Cả sự kiện scrollsnapchange và scroll đều không tự động kích hoạt. Chạy quy trình kiểm tra hình học một lần để đồng bộ hoá giao diện người dùng với vị trí cuộn ban đầu.

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

Chiến lược dự phòng

Đối với các trình duyệt không hỗ trợ scrollsnapchanging, theo mặc định, giao diện người dùng sẽ không cung cấp phản hồi nhanh trong cử chỉ cuộn và giao diện người dùng được liên kết sẽ không đồng bộ hoá với nội dung.

BẮT BUỘC: Cung cấp phương án dự phòng cho các trình duyệt không hỗ trợ, nếu không, giao diện người dùng được liên kết sẽ không đồng bộ hoá với nội dung.

NÊN mô phỏng bản chất theo thời gian thực của scrollsnapchanging bằng trình nghe sự kiện scroll kết hợp với requestAnimationFrame và các phép tính khoảng cách hình học để xác định mục tiêu bám dính gần nhất trong khi người dùng đang cuộn.

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

Dự phòng hình học scroll + requestAnimationFrame mô phỏng chặt chẽ hành vi của tính năng dự đoán chụp nhanh gốc, bao gồm cả việc xử lý thao tác cuộn có lập trình một cách chính xác. Vì các hàm như scrollIntoView tự nhiên kích hoạt các sự kiện scroll trong quá trình thực thi, nên giao diện người dùng sẽ luôn được đồng bộ hoá mượt mà trong suốt ảnh động cuộn mà không cần thêm logic tuỳ chỉnh.