Ein Ereignis für das CSS-Objekt „position:sticky“

Eric Bidelman

Kurzfassung

Hier ein Geheimnis: Möglicherweise benötigen Sie in Ihrer nächsten App keine scroll-Ereignisse. Mit einem IntersectionObserver zeige ich Ihnen, wie Sie ein benutzerdefiniertes Ereignis auslösen können, wenn position:sticky-Elemente korrigiert werden oder wenn sie nicht mehr sichtbar sind. Und das alles ohne Scroll-Listener. Es gibt sogar eine interessante Demo, die dies beweist:

Demo ansehen | Quelle

Vorstellung des sticky-change-Events

Eine der praktischen Einschränkungen bei der Verwendung der fixierten Position von CSS besteht darin, dass sie kein Plattformsignal liefert, um festzustellen, wann die Property aktiv ist. Mit anderen Worten: Es gibt kein Ereignis, bei dem ein Element fixiert wird oder aufhört.

Im folgenden Beispiel wird ein <div class="sticky"> korrigiert, das 10 Pixel vom oberen Rand des übergeordneten Containers entfernt ist:

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

Wäre es nicht schön, wenn der Browser Ihnen mitteilen würde, wenn die Elemente diese Markierung treffen? Anscheinend bin ich nicht der einzige, der dies denkt. Ein Signal für position:sticky könnte eine Reihe von Anwendungsfällen entsperren:

  1. Einen Schlagschatten auf ein fixiertes Banner anwenden
  2. Während ein Nutzer Ihre Inhalte liest, können Sie Analytics-Treffer aufzeichnen, um den Fortschritt zu ermitteln.
  3. Aktualisieren Sie ein unverankertes Inhaltsverzeichnis-Widget, wenn der Nutzer auf der Seite scrollt, auf den aktuellen Abschnitt.

Unter Berücksichtigung dieser Anwendungsfälle haben wir uns ein Ziel gesetzt: ein Ereignis zu erstellen, das ausgelöst wird, wenn ein position:sticky-Element behoben wird. Nennen wir es 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;
});

In der Demo wird dieses Ereignis verwendet, um einen Schlagschatten mit einem Schlagschatten zu versehen, wenn diese fixiert sind. Außerdem wird der neue Titel oben auf der Seite aktualisiert.

In der Demo werden Effekte ohne Scroll-Ereignisse angewendet.

Scrolleffekte ohne Scroll-Ereignisse?

Struktur der Seite.
Struktur der Seite.

Lassen Sie uns einige Begriffe aus dem Weg gehen, damit ich mich im Rest des Posts auf diese Namen beziehen kann:

  1. Scrollcontainer: der Inhaltsbereich (sichtbarer Darstellungsbereich), der die Liste der "Blogposts" enthält.
  2. Headers: blauer Titel in jedem Abschnitt mit position:sticky.
  3. Fixierte Abschnitte: jeder Inhaltsabschnitt. den Text, der unter den fixierten Kopfzeilen scrollt.
  4. Fixierter Modus – wenn position:sticky auf das Element angewendet wird

Damit Sie wissen, welcher header in den fixierten Modus wechselt, müssen Sie den Scrollversatz des Scrollcontainers ermitteln können. Dadurch könnten wir den aktuell angezeigten header berechnen. Das ist ohne scroll-Ereignisse aber ziemlich knifflig. Das andere Problem ist, dass position:sticky das Element aus dem Layout entfernt, sobald es behoben ist.

Ohne Scroll-Ereignisse ist es also nicht mehr möglich, Layout-bezogene Berechnungen für die Kopfzeilen durchzuführen.

Dumby-DOM hinzufügen, um die Scrollposition zu bestimmen

Anstelle von scroll-Ereignissen verwenden wir IntersectionObserver, um zu bestimmen, wann headers den fixierten Modus aktivieren und beenden. Wenn Sie jedem fixierten Abschnitt zwei Knoten (auch als Sentinels bezeichnet) hinzufügen – einen oben und einen unten –, dienen sie als Wegpunkte zum Ermitteln der Scrollposition. Wenn diese Markierungen den Container betreten und wieder verlassen, ändert sich ihre Sichtbarkeit und Intersection Observer löst einen Callback aus.

Ohne Sentinel-Elemente
Die verborgenen Sentinel-Elemente.

Wir benötigen zwei Sentinels, die vier Fälle abdecken, in denen nach oben und unten gescrollt wird:

  1. Nach unten scrollen: header wird fixiert, wenn der oberste Sentinel den oberen Bereich des Containers durchquert.
  2. Nach unten scrollen: header beendet den fixierten Modus, wenn er den unteren Teil des Abschnitts erreicht und der untere Sentinel den oberen Bereich des Containers überquert.
  3. Nach oben scrollen: header beendet den fixierten Modus, wenn der oberste Sentinel von oben in den Ansichtsbereich zurückscrollt.
  4. Nach oben scrollen: header wird fixiert, wenn der untere Sentinel von oben wieder in die Ansicht übergeht.

Es ist hilfreich, sich einen Screencast von 1 bis 4 in der angegebenen Reihenfolge anzusehen:

Intersection Observer lösen Callbacks aus, wenn die Sentinels den Scroll-Container betreten oder verlassen.

Das Preisvergleichsportal

Die Schilde befinden sich oben und unten in jedem Abschnitt. .sticky_sentinel--top befindet sich ganz oben im Header, während .sticky_sentinel--bottom am Ende des Abschnitts steht:

Der untere Sentinel erreicht seine Grenze.
Position der oberen und unteren Sentinel-Elemente.
: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;
}

Intersection Observer einrichten

Intersection Observer beobachten asynchron Änderungen an der Schnittmenge eines Zielelements und des Darstellungsbereichs des Dokuments oder eines übergeordneten Containers. In unserem Fall beobachten wir Schnittpunkte mit einem übergeordneten Container.

Die magische Soße ist IntersectionObserver. Jeder Sentinel erhält ein IntersectionObserver, um seine Schnittmengensichtbarkeit innerhalb des Scrollcontainer zu beobachten. Wenn ein Sentinel in den sichtbaren Darstellungsbereich scrollt, wissen wir, dass der Header fixiert wurde oder nicht mehr fixiert ist. Ähnlich verhält es sich, wenn ein Sentinel den Darstellungsbereich verlässt.

Zuerst habe ich Beobachter für die Kopf- und Fußzeilen-Sentinels eingerichtet:

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

Dann habe ich einen Beobachter hinzugefügt, der ausgelöst wird, wenn .sticky_sentinel--top-Elemente den oberen Bereich des scrolling-Containers (in beide Richtungen) durchlaufen. Die Funktion observeHeaders erstellt die wichtigsten Sentinels und fügt sie jedem Abschnitt hinzu. Der Beobachter berechnet die Schnittmenge des Sentinels mit der Oberseite des Containers und entscheidet, ob er in den Darstellungsbereich gelangt oder ihn verlässt. Anhand dieser Informationen wird festgelegt, ob die Abschnittsüberschrift verankert ist oder nicht.

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

Der Beobachter ist mit threshold: [0] konfiguriert, sodass sein Callback ausgelöst wird, sobald der Sentinel sichtbar wird.

Der Vorgang ist für den unteren Sentinel ähnlich (.sticky_sentinel--bottom). Es wird ein zweiter Beobachter erstellt, der ausgelöst wird, wenn die Fußzeilen den unteren Scrollcontainer durchqueren. Die Funktion observeFooters erstellt die Sentinel-Knoten und hängt sie mit jedem Abschnitt an. Der Beobachter berechnet den Schnittpunkt des Sentinels mit dem Boden des Containers und entscheidet, ob er eintritt oder ihn verlässt. Anhand dieser Informationen wird festgelegt, ob die Abschnittsüberschrift verankert ist oder nicht.

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

Der Beobachter ist mit threshold: [1] konfiguriert, sodass sein Callback ausgelöst wird, wenn sich der gesamte Knoten im sichtbaren Bereich befindet.

Zum Schluss gibt es noch zwei Dienstprogramme, mit denen Sie das benutzerdefinierte sticky-change-Ereignis auslösen und die Sentinels generieren können:

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

Fertig!

Letzte Demo

Wir haben ein benutzerdefiniertes Ereignis erstellt, bei dem Elemente mit position:sticky fixiert werden und Scroll-Effekte hinzugefügt haben, ohne scroll-Ereignisse zu verwenden.

Demo ansehen | Quelle

Fazit

Ich habe mich oft gefragt, ob IntersectionObserver ein hilfreiches Tool wäre, um einige der scroll-ereignisbasierten UI-Muster zu ersetzen, die sich im Laufe der Jahre entwickelt haben. Sie stellt sich heraus, dass die Antwort „Ja“ und „Nein“ lautet. Aufgrund ihrer Semantik ist die IntersectionObserver API schwierig, sie für alles zu verwenden. Aber wie ich hier gezeigt habe, können Sie es für einige interessante Techniken verwenden.

Eine weitere Möglichkeit, Stiländerungen zu erkennen?

Nein. Wir brauchten jedoch eine Möglichkeit, Stiländerungen bei einem DOM-Element zu beobachten. Leider gibt es in den APIs der Webplattform keine Möglichkeit, Stiländerungen zu beobachten.

MutationObserver wäre eine logische erste Wahl, funktioniert aber in den meisten Fällen nicht. In der Demo würden wir beispielsweise einen Callback erhalten, wenn einem Element die Klasse sticky hinzugefügt wird, aber nicht, wenn sich der berechnete Stil des Elements ändert. Die Klasse sticky wurde bereits beim Seitenaufbau deklariert.

In Zukunft kann eine Erweiterung Style Mutation Observer für Mutation Observers nützlich sein, um Änderungen an den berechneten Stilen eines Elements zu beobachten. position: sticky.