Peristiwa untuk CSS position:sticky

TL;DR (Ringkasan)

Berikut rahasianya: Anda mungkin tidak memerlukan peristiwa scroll di aplikasi berikutnya. Dengan IntersectionObserver, saya menunjukkan cara mengaktifkan peristiwa kustom saat elemen position:sticky telah diperbaiki atau saat elemen tersebut tidak melekat. Semuanya dapat dilakukan tanpa menggunakan pemroses scroll. Bahkan ada demo keren untuk membuktikannya:

Lihat demo | Sumber

Memperkenalkan acara sticky-change

Salah satu batasan praktis penggunaan posisi melekat CSS adalah tidak memberikan sinyal platform untuk mengetahui kapan properti aktif. Dengan kata lain, tidak ada peristiwa yang mengetahui kapan suatu elemen menjadi melekat atau kapan elemen tersebut berhenti melekat.

Ambil contoh berikut, yang memperbaiki <div class="sticky"> 10 piksel dari bagian atas penampung induknya:

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

Bukankah lebih baik jika {i>browser<i} memberi tahu ketika elemen mencapai tanda itu? Sepertinya saya bukan satu-satunya yang berpikir demikian. Sinyal untuk position:sticky dapat membuka sejumlah kasus penggunaan:

  1. Terapkan drop shadow ke banner saat menempel.
  2. Saat pengguna membaca konten, catat hit analisis untuk mengetahui progresnya.
  3. Saat pengguna men-scroll halaman, update widget TOC mengambang ke bagian saat ini.

Dengan mempertimbangkan kasus penggunaan berikut, kita telah membuat sasaran akhir: membuat peristiwa yang aktif saat elemen position:sticky menjadi tetap. Sebut saja peristiwa 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;
});

Demo menggunakan peristiwa ini untuk membuat header drop shadow ketika sudah diperbaiki. Hal ini juga memperbarui judul baru di bagian atas halaman.

Dalam demo, efek diterapkan tanpa peristiwa scroll.

Efek scroll tanpa peristiwa scroll?

Struktur halaman.
Struktur halaman.

Mari kita singkirkan beberapa terminologi agar saya dapat merujuk pada nama-nama ini di seluruh postingan:

  1. Penampung scroll - area konten (area pandang yang terlihat) yang berisi daftar "postingan blog".
  2. Header - judul biru di setiap bagian yang berisi position:sticky.
  3. Bagian melekat - setiap bagian konten. Teks yang di-scroll di bawah header melekat.
  4. "Mode melekat" - saat position:sticky diterapkan ke elemen.

Untuk mengetahui header mana yang memasuki "mode lekat", kita memerlukan beberapa cara untuk menentukan offset scroll container scroll. Hal itu akan memberi kita cara untuk menghitung header yang saat ini ditampilkan. Namun, hal ini menjadi sangat sulit dilakukan tanpa peristiwa scroll :) Masalah lainnya adalah position:sticky menghapus elemen dari tata letak saat sudah diperbaiki.

Jadi tanpa peristiwa scroll, kita kehilangan kemampuan untuk melakukan kalkulasi terkait tata letak pada header.

Menambahkan dumby DOM untuk menentukan posisi scroll

Sebagai ganti peristiwa scroll, kita akan menggunakan IntersectionObserver untuk menentukan kapan headers masuk dan keluar dari mode lekat. Menambahkan dua node (alias sentinel) di setiap bagian melekat, satu di atas dan satu di bawah, akan berfungsi sebagai titik jalan untuk mencari tahu posisi scroll. Saat penanda ini masuk dan keluar dari penampung, visibilitasnya akan berubah dan Intersection Observer mengaktifkan callback.

Tanpa elemen sentinel yang ditampilkan
Elemen sentinel tersembunyi.

Kita membutuhkan dua penjaga untuk mencakup empat kasus scroll ke atas dan ke bawah:

  1. Men-scroll ke bawah - header menjadi melekat saat sentinel atasnya melintasi bagian atas penampung.
  2. Men-scroll ke bawah - header akan keluar dari mode lekat saat mencapai bagian bawah bagian dan sentinel bawahnya melintasi bagian atas penampung.
  3. Men-scroll ke atas - header keluar dari mode lekat saat sentinel teratasnya di-scroll kembali ke tampilan dari atas.
  4. Men-scroll ke atas - header menjadi melekat saat sentinel bawahnya bersilangan kembali ke tampilan dari atas.

Sebaiknya lihat screencast 1-4 sesuai urutannya:

Intersection Observer mengaktifkan callback saat sentinel masuk/meninggalkan container scroll.

CSS

Sentinel diposisikan di bagian atas dan bawah setiap bagian. .sticky_sentinel--top berada di bagian atas header, sementara .sticky_sentinel--bottom berada di bagian bawah bagian:

Penjaga bawah mencapai ambang batasnya.
Posisi elemen sentinel atas dan bawah.
: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;
}

Menyiapkan Intersection Observer

Intersection Observer secara asinkron mengamati perubahan pada persimpangan elemen target dan area pandang dokumen atau penampung induk. Dalam kasus ini, kita mengamati perpotongan dengan penampung induk.

Saus ajaibnya adalah IntersectionObserver. Setiap sentinel mendapatkan IntersectionObserver untuk mengamati visibilitas persimpangannya di dalam container scroll. Saat sentinel men-scroll ke area pandang yang terlihat, kita tahu header menjadi tetap atau tidak lagi melekat. Begitu juga, saat sentinel keluar dari area pandang.

Pertama, saya menyiapkan {i>observer<i} untuk {i>header<i} dan {i>footer<i}:

/**
 * 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'));

Kemudian, saya menambahkan observer untuk diaktifkan saat elemen .sticky_sentinel--top melewati bagian atas container scroll (ke kedua arah). Fungsi observeHeaders membuat sentinel teratas dan menambahkannya ke setiap bagian. Observer menghitung perpotongan sentinel dengan bagian atas penampung dan memutuskan apakah memasuki atau meninggalkan area pandang. Informasi tersebut menentukan apakah header bagian melekat atau tidak.

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

Observer dikonfigurasi dengan threshold: [0] sehingga callback-nya diaktifkan segera setelah sentinel terlihat.

Proses ini serupa untuk sentinel bawah (.sticky_sentinel--bottom). Observer kedua dibuat untuk diaktifkan saat footer melewati bagian bawah penampung scroll. Fungsi observeFooters membuat node sentinel dan melampirkannya ke setiap bagian. Observer menghitung persimpangan sentinel dengan bagian bawah penampung dan memutuskan apakah sentinel masuk atau keluar. Informasi tersebut menentukan apakah header bagian melekat atau tidak.

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

Observer dikonfigurasi dengan threshold: [1] sehingga callback-nya diaktifkan saat seluruh node dalam tampilan.

Terakhir, ada dua utilitas saya untuk mengaktifkan peristiwa kustom sticky-change dan menghasilkan sentinel:

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

Selesai.

Demo akhir

Kita membuat peristiwa kustom saat elemen dengan position:sticky menjadi memperbaiki dan menambahkan efek scroll tanpa menggunakan peristiwa scroll.

Lihat demo | Sumber

Kesimpulan

Saya sering bertanya-tanya apakah IntersectionObserver akan menjadi alat yang berguna untuk mengganti sebagian pola UI berbasis peristiwa scroll yang telah berkembang selama bertahun-tahun. Ternyata jawabannya adalah ya dan tidak. Semantik API IntersectionObserver membuatnya sulit digunakan untuk semuanya. Tapi seperti yang sudah saya tunjukkan, Anda bisa menggunakannya untuk beberapa teknik menarik.

Cara lain untuk mendeteksi perubahan gaya?

Tidak juga. Yang kita butuhkan adalah cara untuk mengamati perubahan gaya pada elemen DOM. Sayangnya, tidak ada apa pun di API platform web yang memungkinkan Anda mengamati perubahan gaya.

MutationObserver akan menjadi pilihan pertama yang logis, tetapi itu tidak berfungsi untuk sebagian besar kasus. Misalnya, dalam demo, kita akan menerima callback saat class sticky ditambahkan ke elemen, tetapi tidak saat gaya terkomputasi elemen berubah. Ingat kembali bahwa class sticky sudah dideklarasikan saat pemuatan halaman.

Di masa mendatang, ekstensi "Style Mutation Observer" untuk Mutation Observers mungkin berguna untuk mengamati perubahan pada gaya terkomputasi elemen. position: sticky.