Zdarzenie klasy CSS „position:sticky”

TL;DR

Oto sekret: możesz nie potrzebować zdarzeń scroll w następnej aplikacji. Pokażę, jak za pomocą IntersectionObserver uruchomić zdarzenie niestandardowe, gdy elementy position:sticky zostaną naprawione lub przestaną się przyklejać. A to wszystko bez detektorów przewijania. Możesz też skorzystać z imponującej prezentacji:

Wyświetl prezentację | Źródło

Przedstawiamy wydarzenie sticky-change

Jednym z praktycznych ograniczeń korzystania z przyklejonej pozycji CSS jest to, że nie dostarcza ona sygnału platformy wskazującej, kiedy usługa jest aktywna. Inaczej mówiąc, nie ma żadnego zdarzenia, w którym można określić, kiedy element staje się przyklejony lub gdy przestaje się przyklejać.

Przeanalizujmy ten przykład, który pokazuje, jak element <div class="sticky"> umieszcza się w odległości 10 pikseli od górnej krawędzi kontenera nadrzędnego:

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

Czy nie byłoby dobrze, gdyby przeglądarka informowała użytkowników o tym, że dany element trafia w ten znak? Wygląda na to, że nie tylko ja tak uważam. Sygnał w przypadku position:sticky może pomóc w wielu przypadkach użycia:

  1. Dodaj cień do przyklejonego banera.
  2. Gdy użytkownik czyta Twoje treści, rejestruj działania Analytics, aby śledzić ich postępy.
  3. Gdy użytkownik przewija stronę, zaktualizuj 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, które jest wywoływane, gdy element position:sticky zostanie naprawiony. Nazwijmy to zdarzenie 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;
});

Wersja demonstracyjna używa tego zdarzenia do nagłówka cienia, gdy zostanie naprawiony. Spowoduje to też zaktualizowanie nowego tytułu u góry strony.

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

Efekty przewijania bez zdarzeń przewijania?

Struktura strony.
Struktura strony.

Pozbądźmy się terminologii, aby móc odwoływać się do tych nazw w pozostałej części tego posta:

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

Aby dowiedzieć się, który nagłówek przechodzi w „tryb przyklejony”, potrzebujemy sposobu określenia przesunięcia przewijania kontenera przewijania. Umożliwi nam to obliczenie aktualnie wyświetlanego nagłówka. Bez zdarzeń scroll jest to jednak dość skomplikowane. :) Inny problem polega na tym, że position:sticky usuwa element z układu, gdy zostaje on naprawiony.

Z tego powodu bez zdarzeń przewijania stracisz możliwość wykonywania obliczeń związanych z układem nagłówków.

Dodawanie demonstracyjnego modelu DOM w celu określenia pozycji przewijania

Zamiast zdarzeń scroll będziemy używać tagu IntersectionObserver do określania, kiedy headers mają włączać i wyłączać tryb klawiszy trwałych. Dodanie 2 węzłów w każdej sekcji przyklejonej (jednego u góry i jednego u dołu) będzie stanowić punkty pośrednie przy określaniu pozycji przewijania. Gdy te znaczniki trafiają do kontenera i go opuszczają, ich widoczność zmienia się, a obserwacja intersekcji uruchamia wywołanie zwrotne.

Bez wyświetlanych elementów
Ukryte elementy ostrzeżenia.

Potrzebujemy dwóch strażników, żeby objąć 4 przypadki przewijania w górę i w dół:

  1. Przewinięcie w dółnagłówek staje się przyklejony, gdy górny wskaźnik przecina górną część kontenera.
  2. Przewijanie w dółnagłówek opuszcza tryb klawiszy trwałych, gdy dotrze do dolnej części sekcji, a jej dolny wskaźnik przecina górną część kontenera.
  3. Przewijanie w góręnagłówek opuszcza tryb klawiszy trwałych, gdy jego górny wskaźnik przewija się z góry na widok.
  4. Przewijanie w góręnagłówek staje się przyklejony, gdy dolny słupek przesuwa się z powrotem na widok z góry.

Przydatne jest wyświetlenie screencasta od 1 do 4 w kolejności:

Intersection Observers uruchamia wywołania zwrotne, gdy sygnalizatory wejdą do kontenera przewijania lub go opuszczą.

Usługa porównywania cen

Ostrzeżenia są umieszczone na górze i na dole każdej sekcji. .sticky_sentinel--top znajduje się u góry nagłówka, a .sticky_sentinel--bottom na dole sekcji:

Dolny wskaźnik osiąga próg.
Położenie górnego i dolnego elementu monitorującego.
: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 obserwacji skrzyżowań

Obserwatorzy połączeń asynchronicznie rejestrują zmiany na przecięciu elementu docelowego i widocznego obszaru dokumentu lub kontenera nadrzędnego. W naszym przypadku obserwujemy skrzyżowania z kontenerem nadrzędnym.

Magiczny sos to IntersectionObserver. Każdy wskaźnik otrzymuje IntersectionObserver, który umożliwia obserwowanie widoczności skrzyżowania w kontenerze przewijania. Gdy czujnik przewija się w widocznym obszarze, wiemy, że nagłówek staje się stały lub przestaje się przyklejać. I analogicznie, gdy sygnalizator zamknie widoczny obszar.

Najpierw skonfiguruję obserwatorów alertó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'));

Potem dodałem obserwatora, który uruchamia się, gdy elementy .sticky_sentinel--top przechodzą przez górną część przewijanego kontenera (w dowolnym kierunku). Funkcja observeHeaders tworzy główne wskaźniki i dodaje je do każdej sekcji. Obserwator oblicza przecięcie wskaźnika z górą kontenera i podejmuje decyzję o wejściu do widocznego obszaru czy o zamknięciu go. Na podstawie tych informacji można określić, 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));
}

Pole obserwatora jest skonfigurowane w zasadzie threshold: [0], więc jego wywołanie zwrotne uruchamia się, gdy tylko komunikator stanie się widoczny.

Proces jest podobny w przypadku dolnego wskaźnika (.sticky_sentinel--bottom). Drugi obserwator jest uruchamiany, gdy stopki przechodzą przez dolną część przewijanego kontenera. Funkcja observeFooters tworzy węzły ważne i łączy je do każdej sekcji. Obserwator oblicza przecięcie wskaźnika z dołem kontenera i decyduje, czy trafia do niego, czy z niego wychodzi. Od tej informacji zależy, czy nagłówek sekcji się przykleja.

/**
 * 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 w zasadzie threshold: [1], więc jego wywołanie zwrotne uruchamia się, gdy cały węzeł znajduje się w polu widzenia.

Używam też 2 narzędzi do uruchamiania zdarzenia niestandardowego sticky-change i generowania komunikatów alarmowych:

/**
 * @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.

Ostateczna wersja demonstracyjna

Utworzyliśmy zdarzenie niestandardowe, w którym elementy z atrybutem position:sticky stają się stałe i dodaliśmy efekty przewijania bez użycia zdarzeń scroll.

Wyświetl prezentację | Źródło

Podsumowanie

Często zastanawiam się, czy IntersectionObserver nie byłoby pomocnym narzędziem w zastąpieniu niektórych wzorców interfejsu opartych na zdarzeniach scroll, które rozwijały się od lat. Okazuje się, że odpowiedź brzmi „tak” i „nie”. Semantyka interfejsu API IntersectionObserver utrudnia używanie go we wszystkich zastosowaniach. Ale, jak już tu zobaczyłem, można go wykorzystać z kilkoma interesującymi technikami.

Jak inaczej można wykryć zmiany stylu?

Raczej nie. Potrzebowaliśmy sposobu na obserwowanie zmian stylu w elemencie DOM. Niestety w interfejsach API platformy internetowej nie ma nic, co pozwalałoby obserwować zmiany stylu.

MutationObserver to pierwszy wybór logiczny, ale w większości przypadków się nie sprawdza. Na przykład w wersji demonstracyjnej otrzymamy wywołanie zwrotne po dodaniu klasy sticky do elementu, ale nie po zmianie jego obliczonego stylu. Pamiętaj, że klasa sticky została już zadeklarowana podczas wczytywania strony.

W przyszłości rozszerzenie „Style Mutation Observer” (Obserwacja mutacji) może być przydatne do obserwowania zmian w obliczonych stylach elementu. position: sticky.