Một sự kiện cho CSS position:sticky

Eric Bidelman

TL;DR

Sau đây là một bí mật: Bạn có thể không cần các sự kiện scroll trong ứng dụng tiếp theo. Bằng cách sử dụng IntersectionObserver, tôi sẽ chỉ cho bạn cách kích hoạt một sự kiện tuỳ chỉnh khi các phần tử position:sticky được cố định hoặc khi chúng ngừng hoạt động. Tất cả đều không sử dụng trình nghe cuộn. Thậm chí còn có một bản minh hoạ tuyệt vời để chứng minh điều đó:

Xem bản minh hoạ | Nguồn

Giới thiệu sự kiện sticky-change

Một trong những hạn chế thực tế của việc sử dụng vị trí cố định CSS là vị trí này không cung cấp tín hiệu nền tảng để biết khi nào thuộc tính đang hoạt động. Nói cách khác, không có sự kiện nào để biết khi nào một phần tử trở nên cố định hoặc khi nào nó ngừng cố định.

Lấy ví dụ sau đây sửa <div class="sticky"> 10px ở đầu vùng chứa mẹ:

.sticky {
  position: sticky;
  top: 10px;
}

Chẳng phải sẽ tốt hơn nếu trình duyệt thông báo khi các phần tử chạm vào dấu đó? Rõ ràng là tôi không phải là người duy nhất có suy nghĩ như vậy. Một tín hiệu cho position:sticky có thể mở khoá một số trường hợp sử dụng:

  1. Áp dụng hiệu ứng bóng đổ cho biểu ngữ khi biểu ngữ đang dán.
  2. Khi người dùng đọc qua nội dung của bạn, hãy ghi lại các lượt truy cập phân tích để biết tiến trình của họ.
  3. Khi người dùng cuộn trên trang, hãy cập nhật một tiện ích terms nổi lên cho phần hiện tại.

Với những trường hợp sử dụng này, chúng tôi đã tạo ra một mục tiêu cuối cùng: tạo một sự kiện sẽ kích hoạt khi một phần tử position:sticky được cố định. Hãy gọi sự kiện này là sự kiện sticky-change:

document.addEventListener('sticky-change', e => {
  const header = e.detail.target;  // header became sticky or stopped sticking.
  const sticking = e.detail.stuck; // true when header is sticky.
  header.classList.toggle('shadow', sticking); // add drop shadow when sticking.

  document.querySelector('.who-is-sticking').textContent = header.textContent;
});

Bản bản minh hoạ sử dụng sự kiện này để đặt tiêu đề bóng đổ khi chúng được khắc phục. Thao tác này cũng cập nhật tiêu đề mới ở đầu trang.

Trong bản minh hoạ, các hiệu ứng được áp dụng mà không cần sự kiện cuộn.

Hiệu ứng cuộn mà không dùng sự kiện cuộn?

Cấu trúc của trang.
Cấu trúc của trang.

Hãy cùng trao đổi thêm một số thuật ngữ để tôi có thể tham khảo những tên này trong phần còn lại của bài đăng:

  1. Vùng chứa cuộn – vùng nội dung (khung nhìn hiển thị) chứa danh sách "bài đăng trên blog".
  2. Tiêu đề – tiêu đề màu xanh dương trong mỗi phần có position:sticky.
  3. Phần cố định – mỗi phần nội dung. Văn bản cuộn dưới các tiêu đề cố định.
  4. "Chế độ phím dính" – khi position:sticky đang áp dụng cho phần tử.

Để biết tiêu đề nào chuyển sang "chế độ cố định", chúng ta cần có một số cách xác định độ lệch cuộn của vùng chứa cuộn. Từ đó, chúng ta có cách để tính toán tiêu đề đang xuất hiện. Tuy nhiên, việc đó khá khó thực hiện nếu không có các sự kiện scroll :) Vấn đề khác là position:sticky sẽ xoá phần tử khỏi bố cục khi được khắc phục.

Vì vậy, nếu không có các sự kiện cuộn, chúng tôi sẽ mất khả năng thực hiện các phép tính liên quan đến bố cục trên tiêu đề.

Thêm dumby DOM để xác định vị trí cuộn

Thay vì các sự kiện scroll, chúng ta sẽ sử dụng IntersectionObserver để xác định thời điểm headers chuyển sang và thoát khỏi chế độ cố định. Việc thêm hai nút (còn gọi là lính canh) vào mỗi phần cố định, một ở trên cùng và một ở dưới cùng, sẽ đóng vai trò là điểm tham chiếu để xác định vị trí cuộn. Khi các điểm đánh dấu này vào và rời khỏi vùng chứa, chế độ hiển thị của các điểm đánh dấu đó sẽ thay đổi và Intersection Observer sẽ kích hoạt lệnh gọi lại.

Không có phần tử giám sát hiển thị
Các thành phần giám sát ẩn.

Chúng ta cần hai nhân viên canh để xử lý bốn trường hợp cuộn lên và xuống:

  1. Cuộn xuốngtiêu đề bị cố định khi trọng lực trên cùng của nó chạy qua phía trên của vùng chứa.
  2. Cuộn xuốngtiêu đề để lại chế độ cố định khi di chuyển đến cuối phần và chốt dưới cùng xuyên qua đầu vùng chứa.
  3. Cuộn lêntiêu đề rời khỏi chế độ cố định khi giám sát trên cùng của nó cuộn lại vào khung hiển thị từ trên cùng.
  4. Cuộn lêntiêu đề trở nên cố định khi chốt dưới cùng quay lại nhìn từ trên xuống.

Bạn nên xem bản ghi màn hình từ 1 đến 4 theo thứ tự xuất hiện:

Trình quan sát giao diện kích hoạt lệnh gọi lại khi các chốt bảo vệ tiến vào/rời khỏi vùng chứa cuộn.

CSS

Các chốt được đặt ở đầu và cuối mỗi phần. .sticky_sentinel--top nằm ở đầu tiêu đề trong khi .sticky_sentinel--bottom nằm ở cuối phần đó:

Giám sát dưới cùng đã đạt đến ngưỡng.
Vị trí của các phần tử giám sát trên cùng và dưới cùng.
:root {
  --default-padding: 16px;
  --header-height: 80px;
}
.sticky {
  position: sticky;
  top: 10px; /* adjust sentinel height/positioning based on this position. */
  height: var(--header-height);
  padding: 0 var(--default-padding);
}
.sticky_sentinel {
  position: absolute;
  left: 0;
  right: 0; /* needs dimensions */
  visibility: hidden;
}
.sticky_sentinel--top {
  /* Adjust the height and top values based on your on your sticky top position.
  e.g. make the height bigger and adjust the top so observeHeaders()'s
  IntersectionObserver fires as soon as the bottom of the sentinel crosses the
  top of the intersection container. */
  height: 40px;
  top: -24px;
}
.sticky_sentinel--bottom {
  /* Height should match the top of the header when it's at the bottom of the
  intersection container. */
  height: calc(var(--header-height) + var(--default-padding));
  bottom: 0;
}

Thiết lập Bộ quan sát giao điểm

Trình quan sát giao điểm quan sát không đồng bộ các thay đổi trên giao điểm của một phần tử mục tiêu và khung nhìn tài liệu hoặc vùng chứa mẹ. Trong trường hợp này, chúng ta sẽ quan sát thấy các điểm giao cắt với một vùng chứa mẹ.

Nước sốt kỳ diệu là IntersectionObserver. Mỗi giá trị canh sẽ nhận một IntersectionObserver để quan sát chế độ hiển thị giao điểm của nó trong vùng chứa cuộn. Khi một người canh gác cuộn vào khung nhìn hiển thị, chúng ta sẽ biết một tiêu đề trở thành tiêu đề cố định hoặc không còn cố định. Tương tự như vậy, khi một người canh gác thoát khỏi khung nhìn.

Trước tiên, tôi thiết lập các đối tượng tiếp nhận dữ liệu cho các vị trí giám sát ở đầu trang và chân trang:

/**
 * Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
 * Note: the elements should be children of `container`.
 * @param {!Element} container
 */
function observeStickyHeaderChanges(container) {
  observeHeaders(container);
  observeFooters(container);
}

observeStickyHeaderChanges(document.querySelector('#scroll-container'));

Sau đó, tôi thêm một đối tượng tiếp nhận dữ liệu để kích hoạt khi các phần tử .sticky_sentinel--top đi qua phần đầu của vùng chứa cuộn (theo một trong hai hướng). Hàm observeHeaders tạo các canh bảo vệ hàng đầu và thêm chúng vào từng phần. Trình quan sát tính toán giao điểm của chốt canh với phần trên của vùng chứa và quyết định xem nó vào hay ra khỏi khung nhìn. Thông tin đó xác định liệu tiêu đề của phần có đang được cố định hay không.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--top` become visible/invisible at the top of the container.
 * @param {!Element} container
 */
function observeHeaders(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;

      // Started sticking.
      if (targetInfo.bottom < rootBoundsInfo.top) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.bottom >= rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
       fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [0], root: container});

  // Add the top sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--top');
  sentinels.forEach(el => observer.observe(el));
}

Trình quan sát được định cấu hình với threshold: [0] để lệnh gọi lại của trình quan sát đó kích hoạt ngay khi người quan sát hiển thị.

Quá trình này tương tự như trọng lực dưới cùng (.sticky_sentinel--bottom). Một trình quan sát thứ hai sẽ được tạo để kích hoạt khi chân trang đi qua phần dưới cùng của vùng chứa cuộn. Hàm observeFooters tạo các nút Sentinel và đính kèm các nút đó vào từng phần. Trình quan sát tính toán giao điểm của chốt gác với đáy vùng chứa và quyết định xem nó vào hay ra khỏi vùng chứa. Thông tin đó sẽ xác định liệu tiêu đề phần có cố định hay không.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
 * container.
 * @param {!Element} container
 */
function observeFooters(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;
      const ratio = record.intersectionRatio;

      // Started sticking.
      if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.top < rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
        fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [1], root: container});

  // Add the bottom sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
  sentinels.forEach(el => observer.observe(el));
}

Trình quan sát được định cấu hình bằng threshold: [1] để lệnh gọi lại sẽ kích hoạt khi toàn bộ nút nằm trong khung hiển thị.

Cuối cùng, có hai tiện ích của tôi để kích hoạt sự kiện tuỳ chỉnh sticky-change và tạo người canh:

/**
 * @param {!Element} container
 * @param {string} className
 */
function addSentinels(container, className) {
  return Array.from(container.querySelectorAll('.sticky')).map(el => {
    const sentinel = document.createElement('div');
    sentinel.classList.add('sticky_sentinel', className);
    return el.parentElement.appendChild(sentinel);
  });
}

/**
 * Dispatches the `sticky-event` custom event on the target element.
 * @param {boolean} stuck True if `target` is sticky.
 * @param {!Element} target Element to fire the event on.
 */
function fireEvent(stuck, target) {
  const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
  document.dispatchEvent(e);
}

Vậy là xong!

Bản minh hoạ cuối cùng

Chúng tôi đã tạo một sự kiện tuỳ chỉnh khi các phần tử có position:sticky trở thành cố định và thêm hiệu ứng cuộn mà không sử dụng sự kiện scroll.

Xem bản minh hoạ | Nguồn

Kết luận

Tôi thường tự hỏi liệu IntersectionObserver có phải là một công cụ hữu ích để thay thế một số mẫu giao diện người dùng dựa trên sự kiện scroll đã phát triển trong những năm qua hay không. Hoá ra câu trả lời là có và không. Ngữ nghĩa của IntersectionObserver API khiến bạn gặp khó khăn trong việc sử dụng mọi thứ. Nhưng như tôi đã trình bày ở đây, bạn có thể sử dụng nó cho một số kỹ thuật thú vị.

Cách khác để phát hiện các thay đổi về kiểu?

Thực ra là không. Điều chúng tôi cần là cách quan sát các thay đổi về kiểu trên phần tử DOM. Rất tiếc, không có API nền tảng web nào cho phép bạn xem các thay đổi về kiểu.

MutationObserver sẽ là lựa chọn hợp lý đầu tiên nhưng cách này không hiệu quả trong hầu hết trường hợp. Ví dụ: trong bản minh hoạ, chúng ta sẽ nhận được lệnh gọi lại khi lớp sticky được thêm vào một phần tử, nhưng sẽ không nhận được khi kiểu được tính toán của phần tử đó thay đổi. Hãy nhớ rằng lớp sticky đã được khai báo khi tải trang.

Sau này, phần mở rộng "Style đột biến Observer" cho các Trình quan sát đột biến có thể hữu ích khi quan sát các thay đổi về kiểu đã tính toán của một phần tử. position: sticky.