Un evento per il CSSposition:sticky

Eric Bidelman

TL;DR

Ecco un segreto: potresti non aver bisogno di eventi scroll nella tua prossima app. Utilizzando un IntersectionObserver, ti mostro come attivare un evento personalizzato quando gli elementi position:sticky vengono corretti o quando smettono di durare. Il tutto senza l'utilizzo di listener di scorrimento. Esiste persino un'incredibile demo che lo dimostra:

Visualizza demo | Fonte

Presentazione dell'evento sticky-change

Uno dei limiti pratici dell'utilizzo della posizione fissa CSS è che non fornisce un indicatore della piattaforma per sapere quando la proprietà è attiva. In altre parole, non c'è alcun evento per sapere quando un elemento diventa fisso o quando smette di esserlo.

Prendiamo l'esempio seguente, che corregge un valore <div class="sticky"> di 10 px dalla parte superiore del contenitore principale:

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

Non sarebbe bello se il browser avvisasse quando gli elementi raggiungono quel contrassegno? A quanto pare non sono l'unica che pensa di sì. Un indicatore per position:sticky potrebbe sbloccare una serie di casi d'uso:

  1. Applicare un'ombra a un banner quando rimane attaccato.
  2. Mentre un utente legge i tuoi contenuti, registra gli hit di analisi per conoscerne l'avanzamento.
  3. Mentre un utente scorre la pagina, aggiorna un widget Sommario mobile alla sezione corrente.

Tenendo presenti questi casi d'uso, abbiamo creato un obiettivo finale: creare un evento che si attiva quando un elemento position:sticky viene fisso. 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 intestare un'ombra quando diventa corretta. Inoltre, aggiorna il nuovo titolo nella parte superiore della pagina.

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

Effetti di scorrimento senza eventi di scorrimento?

Struttura della pagina.
Struttura della pagina.

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

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

Per sapere quale intestazione entra in "modalità persistente", è necessario un modo per determinare l'offset di scorrimento del container di scorrimento. In questo modo possiamo calcolare l'intestazione attualmente visualizzata. Tuttavia, senza gli eventi scroll, non sarà facile farlo. L'altro problema è che position:sticky rimuove l'elemento dal layout quando viene risolto.

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

Aggiunta del DOM dumby per determinare la posizione di scorrimento

Anziché gli eventi scroll, useremo un IntersectionObserver per determinare quando le headers entrano ed escono dalla modalità persistente. L'aggiunta di due nodi (notinel) in ogni sezione persistente, uno nella parte superiore e uno nella parte inferiore, fungono da punti di percorso per determinare la posizione di scorrimento. Quando questi indicatori entrano ed escono dal container, la loro visibilità cambia e l'Osservatore Intersection attiva un callback.

Senza elementi sentinel mostrati
Gli elementi sentinel nascosti.

Abbiamo bisogno di due sentinelle per trattare 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. Scorrimento verso il basso - header 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: l'intestazione lascia la modalità persistente quando la sentinella superiore scorre di nuovo visualizzata dall'alto.
  4. Scorrimento verso l'alto: l'intestazione diventa fissa quando la sentinella inferiore torna visualizzata dall'alto.

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

Gli osservatori dell'intersezione attivano i callback quando le sentinel entrano/escono dal container di scorrimento.

Il CSS

Le sentinel sono posizionate nella parte superiore e inferiore di ogni sezione. .sticky_sentinel--top si trova sopra l'intestazione, mentre .sticky_sentinel--bottom si trova nella parte inferiore della sezione:

Sentinella inferiore che raggiunge 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;
}

Impostazione degli incroci con gli osservatori

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

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

Innanzitutto, ho configurato gli osservatori per le sentinel 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'));

Poi, ho aggiunto un osservatore che si attiva quando gli elementi .sticky_sentinel--top passano attraverso la parte superiore del container a scorrimento (in entrambe le direzioni). La funzione observeHeaders crea le sentinel principali e le aggiunge a ogni sezione. L'osservatore calcola l'intersezione della sentinel con la parte superiore del container e decide se sta entrando o uscendo dall'area visibile. Queste informazioni determinano se l'intestazione della sezione è fissata 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.

La procedura è simile a quella della 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 Sense e li collega a ogni sezione. L'osservatore calcola l'intersezione della sentinella con il fondo del container e decide se sta entrando o uscendo. Queste informazioni determinano se l'intestazione della sezione è fissata 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], in modo che il callback si attivi quando l'intero nodo è nella vista.

Infine, ci sono le mie due utilità per attivare l'evento personalizzato sticky-change e generare le 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);
}

È tutto.

Demo finale

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

Visualizza demo | Fonte

Conclusione

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

Un altro modo per rilevare le modifiche di stile?

In effetti, no. Quello che ci serviva era un modo per osservare i cambiamenti di stile in un elemento DOM. Sfortunatamente, nelle API della piattaforma web non c'è nulla che ti consenta di guardare le modifiche apportate allo stile.

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

In futuro, un'estensione"Style Mutation Observationr" a Mutation Observationr, per osservare le modifiche agli stili calcolati di un elemento. position: sticky.