Peristiwa untuk CSS position:sticky

TL;DR

Berikut ini rahasianya: Anda mungkin tidak memerlukan peristiwa scroll di aplikasi berikutnya. Menggunakan IntersectionObserver, Saya akan menunjukkan cara mengaktifkan peristiwa kustom saat elemen position:sticky menjadi tetap atau saat elemen tersebut berhenti melekat. Semua tanpa penggunaan pemroses scroll. Bahkan ada demo keren untuk membuktikannya:

Lihat demo | Sumber

Memperkenalkan acara sticky-change

Salah satu batasan praktis dalam penggunaan posisi melekat CSS adalah tidak memberikan sinyal platform untuk mengetahui kapan properti aktif. Dengan kata lain, tidak ada peristiwa yang diketahui kapan suatu elemen menjadi {i>sticky<i} atau ketika berhenti melekat.

Ambil contoh berikut, yang memperbaiki <div class="sticky"> 10px dari di bagian atas container induknya:

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

Bukankah lebih menyenangkan jika browser diberi tahu ketika elemen mencapai tanda itu? Sepertinya saya bukan satu-satunya menurut saya demikian. Sinyal untuk position:sticky dapat membuka sejumlah kasus penggunaan:

  1. Terapkan drop shadow ke banner selagi menempel.
  2. Saat pengguna membaca konten Anda, catat hit analitik untuk mengetahui progresif.
  3. Saat pengguna men-scroll halaman, perbarui widget TOC mengambang ke widget bagian.

Dengan mempertimbangkan kasus penggunaan ini, kami telah membuat tujuan akhir: membuat peristiwa yang diaktifkan 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 ini menggunakan {i>event<i} ini ke {i>header<i} sebuah {i> drop shadow<i} ketika menjadi tetap. Hal ini juga memperbarui judul baru di bagian atas halaman.

Dalam demo, efek diterapkan tanpa peristiwa scroll.

Scroll efek tanpa peristiwa scroll?

Struktur halaman.
Struktur halaman.

Kita singkirkan beberapa terminologi agar saya bisa menyebut nama-nama ini pada keseluruhan postingan:

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

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

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

Menambahkan DOM dumby 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 (disebut juga penjaga) di setiap bagian yang melekat, satu di atas dan satu di bagian atas. di bagian bawah, akan bertindak sebagai titik jalan untuk mencari tahu posisi {i>scroll<i}. Karena masuk dan keluar dari penampung, visibilitasnya akan berubah dan Intersection Observer mengaktifkan callback.

Tanpa elemen sentinel yang ditampilkan
Elemen sentinel yang tersembunyi.

Kita memerlukan dua sentinel untuk mencakup empat kasus scroll ke atas dan ke bawah:

  1. Men-scroll ke bawah - header menjadi melekat saat sentinel atasnya bersilangan bagian atas container.
  2. Men-scroll ke bawah - header keluar dari mode lekat saat mencapai bagian bawah bagian dan sentinel bawahnya bersilangan dengan bagian atas kontainer.
  3. Men-scroll ke atas - header keluar dari mode lekat saat sentinel atasnya di-scroll kembali terlihat dari atas.
  4. Men-scroll ke atas - header menjadi lengket saat sentinel bawahnya bersilangan ke belakang terlihat dari atas.

Anda dapat melihat screencast 1-4 secara berurutan:

Intersection Observer mengaktifkan callback saat penjaga masuk/keluar dari container scroll.

CSS

Penjaga ditempatkan di bagian atas dan bawah setiap bagian. .sticky_sentinel--top berada di bagian atas header saat .sticky_sentinel--bottom terletak di bagian bawah bagian:

Sentinel 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 Pengamat Persimpangan

Pengamat Persimpangan secara asinkron mengamati perubahan pada perpotongan elemen target dan area pandang dokumen atau penampung induk. Dalam kasus kita, kita mengamati persimpangan dengan kontainer induk.

Saus ajaibnya adalah IntersectionObserver. Setiap penjaga mendapatkan IntersectionObserver untuk mengamati visibilitas persimpangannya dalam penampung scroll. Ketika sentinel men-scroll ke area pandang yang terlihat, kita tahu header menjadi permanen atau tidak melekat. Demikian juga, ketika penjaga keluar, area pandang.

Pertama, saya menyiapkan observer untuk sentinel {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 diteruskan melalui bagian atas penampung scroll (ke salah satu arah). Fungsi observeHeaders membuat sentinel teratas dan menambahkannya ke setiap bagian. Pengamat menghitung perpotongan antara sentinel dengan di bagian atas kontainer dan memutuskan apakah masuk atau keluar dari area pandang. Bahwa informasi menentukan apakah {i>header<i} 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 saat sentinel terlihat.

Prosesnya serupa untuk sentinel bawah (.sticky_sentinel--bottom). Pengamat kedua dibuat untuk dipicu ketika {i>footer<i} melewati bagian bawah dari container scroll. Fungsi observeFooters membuat sentinel dan melampirkannya ke setiap bagian. Pengamat menghitung perpotongan antara sentinel dengan bagian bawah kontainer dan memutuskan apakah masuk atau keluar. Informasi tersebut menentukan apakah {i>header <i}kemudian 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 terakhir

Kami 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 menggantikan beberapa pola UI berbasis peristiwa scroll yang telah berkembang selama bertahun-tahun. Ternyata jawabannya ya dan tidak. Semantik dari IntersectionObserver API membuatnya sulit digunakan untuk semuanya. Tapi ketika yang telah saya tunjukkan di sini, Anda dapat 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 untuk perubahan gaya smartwatch.

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

Di masa mendatang, "Pengamat Mutasi Gaya" ekstensi ke Mutation Observer mungkin berguna untuk mengamati perubahan pada gaya terkomputasi (komputasi) elemen. position: sticky.