Zdarzenie klasy CSS „position:sticky”

TL;DR

Oto sekret: w swojej następnej aplikacji możesz nie potrzebować zdarzeń scroll. Korzystając z IntersectionObserver, pokazuję, jak uruchomić zdarzenie niestandardowe, gdy elementy position:sticky zostaną naprawione lub przestaną się przyklejać. Bez detektorów przewijania. Dostępnych jest nawet świetna wersja demonstracyjna, która to potwierdza:

Zobacz prezentację | Źródło

Przedstawiamy wydarzenie sticky-change

Jednym z praktycznych ograniczeń stosowania stałej pozycji CSS jest to, że nie zapewnia ona sygnału platformy wskazującego, czy usługa jest aktywna. Innymi słowy, nie ma żadnego zdarzenia, na podstawie którego można by sprawdzić, kiedy element przestanie być przyklejony.

Oto przykład, który poprawia element <div class="sticky"> o 10 pikseli od góry kontenera nadrzędnego:

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

Czy nie byłoby miło, gdyby przeglądarka informowała o tym, że elementy dotykają tego znacznika? Wygląda na to, że nie tylko ja tak uważam. Sygnał dla position:sticky może wykorzystać wiele przypadków użycia:

  1. Zastosuj cień do przyklejonego banera.
  2. Gdy użytkownik czyta treści, rejestruj działania analityczne, aby śledzić postępy.
  3. Gdy użytkownik przewija stronę, aktualizuj pływający widżet TOC do bieżącej sekcji.

Mając na uwadze te przypadki użycia, opracowaliśmy cel końcowy: utworzenie zdarzenia wywoływanego po naprawieniu elementu position:sticky. Nazwijmy to zdarzeniem 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;
});

Demonstracja używa tego zdarzenia do nadania nagłówka cieniu po naprawieniu. Zmieni się też nowy tytuł u góry strony.

W wersji demonstracyjnej efekty są stosowane bez zdarzeń przewijania.

Przewijanie bez zdarzeń przewijania?

Struktura strony.
Struktura strony.

Użyjmy terminologii, by przywołać te nazwy w dalszej części posta:

  1. Kontener z przewijaniem – obszar treści (widoczny widoczny obszar) zawierający listę „postów na blogu”.
  2. Nagłówki – niebieski tytuł w każdej sekcji z tagiem position:sticky.
  3. Sekcje przyklejone – każda sekcja treści. Tekst przewijany pod przyklejonymi nagłówkami.
  4. „Tryb przyklejony” – gdy do elementu stosuje się position:sticky.

Aby dowiedzieć się, który nagłówek przechodzi w „tryb przyklejony”, potrzebujemy sposobu na określenie przesunięcia kontenera przewijania. Dzięki temu będziemy mogli obliczyć wyświetlany nagłówek. Robi się to jednak dość trudno bez zdarzeń scroll :) Innym problemem jest to, że position:sticky usuwa element z układu po naprawieniu.

Dlatego bez zdarzeń przewijania utraciliśmy możliwość wykonywania obliczeń związanych z układem nagłówków.

Dodawanie zastępczego modelu DOM w celu określenia pozycji przewijania

Zamiast zdarzeń scroll użyjemy IntersectionObserver do określania, kiedy headers przejdą w tryb przyklejenia lub z niego wyjdą. Dodanie 2 węzłów (czyli strażników) w każdej sekcji przyklejonej, 1 u góry i u dołu, będzie służyć jako punkty pośrednie do ustalania pozycji przewijania. Gdy te znaczniki dochodzą do kontenera i je opuszczają, ich widoczność się zmienia, a obserwacja interakcji wywołuje wywołanie zwrotne.

Bez widocznych elementów wartych
Ukryte elementy ostrzegawcze.

Potrzebujemy 2 strażników, by uwzględnić 4 przypadki przewijania w górę i w dół:

  1. Przewijanie w dółnagłówek staje się przyklejony, gdy jego górny czujnik przecina górną część kontenera.
  2. Przewijanie w dółnagłówek opuszcza tryb przyklejenia, gdy dochodzi do dołu sekcji, a jego dolny element strażniczy przekracza górną część kontenera.
  3. Przewijanie w góręnagłówek wyłącza tryb klawiszy trwałych, gdy górny słupek przewija się z powrotem do widoku z góry.
  4. Przewijanie w góręnagłówek staje się przyklejony, a jego dolny strażnik powraca do widoku z góry.

Warto zobaczyć screencasty 1–4 w kolejności od wystąpienia:

Obserwatorzy interakcji uruchamiają wywołania zwrotne, gdy strażnicy wchodzą do kontenera przewijania lub go opuszczają.

Usługa porównywania cen

Strażnicy znajdują się na górze i na dole każdej sekcji. Element .sticky_sentinel--top znajduje się na górze nagłówka, a .sticky_sentinel--bottom – na dole sekcji:

Dolny poziom wartości zbliża się do progu.
Położenie górnego i dolnego elementu wartowtego.
: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;
}

Konfiguracja obserwatorów skrzyżowań

Obserwatorzy skrzyżowań asynchronicznie monitorują zmiany na przecięciu elementu docelowego z widocznym obszarem dokumentu lub kontenerem nadrzędnym. W naszym przypadku widzimy skrzyżowania z kontenerem nadrzędnym.

Magiczny algorytm to IntersectionObserver. Każdy strażnik otrzymuje IntersectionObserver, aby obserwować jego widoczność w kontenerze przewijania. Gdy czujnik przewija się w widocznym obszarze, wiemy, że nagłówek został naprawiony lub przestanie być przyklejony. Podobnie gdy strażnik opuści widoczny obszar.

Najpierw skonfiguruję obserwatorów dla strażników nagłówka i stopki:

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

Następnie dodałem obserwatora, który będzie się uruchamiać, gdy elementy .sticky_sentinel--top przejdą przez górną część kontenera z przewijaniem (w dowolnym kierunku). Funkcja observeHeaders tworzy strażnicy i dodaje je do każdej sekcji. Obserwator oblicza przecięcie czujnika z górą kontenera i decyduje, czy wchodzi on w widoczny obszar, czy go opuszcza. Ta informacja określa, czy nagłówek sekcji jest przyklejony.

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

Obserwator jest skonfigurowany z użyciem funkcji threshold: [0], tak więc jego wywołanie zwrotne jest uruchamiane, gdy tylko strażnik staje się widoczny.

Proces jest podobny w przypadku dolnego strażnika (.sticky_sentinel--bottom). Drugi obserwator jest tworzony, gdy stopki przechodzą przez dolną część kontenera z przewijaniem. Funkcja observeFooters tworzy węzły ważne i dołącza je do każdej sekcji. Obserwator oblicza przecięcie czujnika z dnem kontenera i decyduje, czy wsiaduje do niego, czy go opuszcza. Ta informacja określa, czy nagłówek sekcji jest przyklejony.

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

Obserwator jest skonfigurowany z użyciem funkcji threshold: [1], tak więc jego wywołanie zwrotne jest uruchamiane, gdy cały węzeł znajduje się w widoku.

Mam jeszcze 2 narzędzia do uruchamiania zdarzenia niestandardowego sticky-change i generowania strażników:

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

Znakomicie.

Ostatnia wersja demonstracyjna

Utworzyliśmy zdarzenie niestandardowe, gdy elementy z elementem position:sticky zostaną naprawione i dodamy efekty przewijania bez użycia zdarzeń scroll.

Zobacz prezentację | Źródło

Podsumowanie

Często zastanawiam się, czy narzędzie IntersectionObserver mogłoby zastąpić niektóre wzorce interfejsu użytkownika oparte na zdarzeniach scroll, które rozwinęły się na przestrzeni lat. Okazuje się, że tak i nie. Semantyka interfejsu API IntersectionObserver sprawia, że jest on trudny w obsłudze do wszystkiego. Ale jak pokazałam, można go używać z kilkoma interesującymi technikami.

Inny sposób wykrywania zmian stylu?

Raczej nie. Potrzebowaliśmy sposobu na obserwowanie zmian stylu elementów DOM. W interfejsach API platformy internetowej nie ma niestety żadnych funkcji, za pomocą których można obserwować zmiany stylu.

MutationObserver to logiczny wybór, który w większości przypadków się nie sprawdza. Na przykład w wersji demonstracyjnej nasze wywołanie zwrotne pojawi się, gdy do elementu zostanie dodana klasa sticky, a nie po zmianie obliczonego stylu elementu. Pamiętaj, że klasa sticky została już zadeklarowana podczas wczytywania strony.

W przyszłości do obserwowania zmian w obliczonych stylach elementu może pomóc rozszerzenie „Style Mutation Observer” (Obserwatorzy mutacji). position: sticky.