Un evento per il CSSposition:sticky

TL;DR

Ecco un segreto: potresti non aver bisogno di eventi scroll nella tua prossima app. Con un IntersectionObserver, ti mostro come attivare un evento personalizzato quando gli elementi position:sticky vengono corretti o quando non vengono più applicati. Il tutto senza ricorrere ai listener di scorrimento. C'è anche una fantastica demo che lo dimostra:

Visualizza demo | Fonte

Presentazione dell'evento sticky-change

Una delle limitazioni pratiche dell'utilizzo della posizione persistente CSS è che non fornisce un indicatore della piattaforma per sapere quando la proprietà è attiva. In altre parole, non c'è alcun evento in cui sia possibile sapere quando un elemento diventa fisso o smette di essere permanente.

Nell'esempio seguente, correggi un <div class="sticky"> di 10 px dalla parte superiore del contenitore principale:

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

Non sarebbe bello se il browser comunicasse quando gli elementi colpiscono quel segno? A quanto pare non sono l'unico a pensarlo. Un indicatore per position:sticky potrebbe sbloccare una serie di casi d'uso:

  1. Applica un'ombra a un banner mentre si fissa.
  2. Mentre un utente legge i tuoi contenuti, registra gli hit di Analytics per conoscerne l'avanzamento.
  3. Mentre un utente scorre la pagina, aggiorna un widget Sommario mobile alla sezione corrente.

Tenendo a mente questi casi d'uso, abbiamo creato un obiettivo finale: creare un evento che si attiva quando un elemento position:sticky viene corretto. Chiamiamolo evento 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;
});

La demo utilizza questo evento per intestazioni di ombra quando vengono corrette. Inoltre, aggiorna il nuovo titolo nella parte superiore della pagina.

Nella demo, gli effetti vengono applicati senza eventi di scorrimento.

Vuoi scorrere gli effetti senza eventi di scorrimento?

Struttura della pagina.
Struttura della pagina.

Togliamo un po' di terminologia in modo da poter fare riferimento a questi nomi nel resto del post:

  1. Contenitore a scorrimento: l'area dei contenuti (area visibile) contenente l'elenco dei "post del blog".
  2. Intestazioni: titolo blu in ogni sezione che contiene position:sticky.
  3. Sezioni persistenti: ogni sezione di contenuti. Il testo che scorre sotto le intestazioni fisse.
  4. "Modalità persistente": quando all'elemento viene applicato position:sticky.

Per sapere quale intestazione entra in "modalità persistente", abbiamo bisogno di un modo per determinare l'offset di scorrimento del container di scorrimento. Questo ci darebbe modo di calcolare l'intestazione attualmente visualizzata. Tuttavia, l'operazione può essere complicata senza gli eventi scroll. L'altro problema è che position:sticky rimuove l'elemento dal layout quando viene risolto.

Pertanto, senza eventi di scorrimento, abbiamo perso la possibilità di eseguire calcoli relativi al layout nelle intestazioni.

Aggiunta di un DOM dumby per determinare la posizione di scorrimento

Invece degli eventi scroll, utilizzeremo un IntersectionObserver per determinare quando le headers entrano ed escono dalla modalità persistente. L'aggiunta di due nodi (noti anche come sentinelle) in ogni sezione permanente, uno in alto e uno in basso, agirà da tappa per capire la posizione di scorrimento. Quando questi indicatori entrano ed escono dal container, la loro visibilità cambia e Intersection Observer attiva un callback.

Senza elementi sentinella
Gli elementi sentinella nascosti.

Abbiamo bisogno di due sentinelle per coprire quattro casi di scorrimento verso l'alto e verso il basso:

  1. Scorrimento verso il basso: l'intestazione diventa fissa quando la sua sentinella superiore attraversa la parte superiore del container.
  2. Scorrere verso il basso: intestazione lascia la modalità persistente quando raggiunge la parte inferiore della sezione e la sentinella inferiore attraversa la parte superiore del container.
  3. Scorrimento verso l'alto: intestazione lascia la modalità persistente quando la parte superiore della sentinella torna visibile dall'alto.
  4. Scorrere verso l'alto: l'intestazione diventa fissa quando la sentinella inferiore torna alla vista dall'alto.

È utile vedere uno screencast di 1-4 nell'ordine in cui avvengono:

Gli osservatori di intersezione attivano i callback quando le sentinelle entrano o escono dal container di scorrimento.

Il CSS

Le sentinelle sono posizionate nella parte superiore e inferiore di ogni sezione. .sticky_sentinel--top si trova nella parte superiore dell'intestazione, mentre .sticky_sentinel--bottom si trova in fondo alla sezione:

La sentinella inferiore sta per raggiungere la soglia.
Posizione degli elementi sentinella superiore e inferiore.
: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;
}

Configurare gli osservatori delle intersezioni

Gli osservatori delle intersezioni osservano in modo asincrono i cambiamenti nell'intersezione di un elemento target e nell'area visibile del documento o di un contenitore principale. Nel nostro caso, osserviamo le intersezioni con un contenitore principale.

La salsa magica è IntersectionObserver. Ogni sentinella riceve un IntersectionObserver per osservare la sua visibilità intersezione all'interno del container di scorrimento. Quando una sentinella scorre nell'area visibile, sappiamo che un'intestazione diventa fissa o non è più fissa. Allo stesso modo, quando una sentinella esce dall'area visibile.

Per prima cosa, ho impostato degli osservatori per le sentinelle di intestazione e piè di pagina:

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

Quindi, ho aggiunto un osservatore per attivarsi quando gli elementi .sticky_sentinel--top passano nella parte superiore del container di scorrimento (in entrambe le direzioni). La funzione observeHeaders crea le sentinelle superiori e le aggiunge a ogni sezione. L'osservatore calcola l'intersezione della sentinella con la parte superiore del container e decide se entra o esce dall'area visibile. Queste informazioni determinano se l'intestazione della sezione viene applicata o meno.

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

L'osservatore è configurato con threshold: [0], quindi il suo callback si attiva non appena la sentinella diventa visibile.

Il processo è simile per la sentinella inferiore (.sticky_sentinel--bottom). Viene creato un secondo osservatore che si attiva quando i piè di pagina passano attraverso la parte inferiore del container a scorrimento. La funzione observeFooters crea i nodi sentinel e li collega a ogni sezione. L'osservatore calcola l'intersezione della sentinella con il fondo del container e decide se entra o esce. Queste informazioni determinano se l'intestazione della sezione viene applicata o meno.

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

L'osservatore è configurato con threshold: [1], quindi il suo callback viene attivato quando l'intero nodo è nella visualizzazione.

Infine, ci sono le due utilità per attivare l'evento personalizzato sticky-change e generare le sentinelle:

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

È tutto.

Demo finale

Abbiamo creato un evento personalizzato quando gli elementi con position:sticky sono stati corretti e abbiamo aggiunto effetti di scorrimento senza l'utilizzo di eventi scroll.

Visualizza demo | Fonte

Conclusione

Mi chiedevo spesso se IntersectionObserver sarebbe stato uno strumento utile per sostituire alcuni dei pattern di UI basati su eventi di scroll che si sono sviluppati nel corso degli anni. La risposta è sì e no. La semantica dell'API IntersectionObserver rende difficile da usare per tutto. Ma, come ho mostrato qui, puoi usarlo per alcune tecniche interessanti.

Un altro modo per rilevare i cambiamenti di stile?

In effetti, no. Era necessario un modo per osservare le modifiche di stile su un elemento DOM. Sfortunatamente, le API della piattaforma web non offrono nulla che ti consenta di osservare i cambiamenti di stile.

MutationObserver sarebbe una prima scelta logica, ma non funziona nella maggior parte dei casi. Ad esempio, nella demo, riceveremo un callback quando la classe sticky viene aggiunta a un elemento, ma non quando lo stile calcolato dell'elemento cambia. Ricorda che la classe sticky è già stata dichiarata durante il caricamento pagina.

In futuro, potrebbe essere utile un'estensione"Style Mutation Observer" di Mutation Observer per osservare le modifiche agli stili calcolati di un elemento. position: sticky.