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

Kurzfassung

Hier ist ein Geheimnis: Möglicherweise benötigen Sie in Ihrer nächsten App keine scroll-Ereignisse. Mit einem IntersectionObserver zeige ich, wie Sie ein benutzerdefiniertes Ereignis auslösen können, wenn position:sticky-Elemente fixiert werden oder sich nicht mehr anklicken lassen. ganz ohne Scroll-Listener. Hier ist eine Demo, die das beweist:

Demo ansehen | Quelle

Das Ereignis sticky-change

Eine der praktischen Einschränkungen der Verwendung der CSS-Sticky-Position ist, dass kein Plattformsignal dafür vorhanden ist, wann die Property aktiv ist. Mit anderen Worten: Es gibt kein Ereignis, das angibt, wann ein Element fixiert wird oder wann es nicht mehr fixiert ist.

Im folgenden Beispiel wird ein <div class="sticky"> 10 Pixel über dem übergeordneten Container fixiert:

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

Wäre es nicht schön, wenn der Browser anzeigen würde, wann die Elemente diese Markierung erreichen? Offenbar bin ich nicht der Einzige, der so denkt. Ein Signal für position:sticky könnte eine Reihe von Nutzungsfällen ermöglichen:

  1. Wenden Sie einen Schlagschatten auf ein Banner an, während es klebt.
  2. Wenn ein Nutzer sich Ihre Inhalte durchliest, zeichnen Sie Analytics-Treffer auf, um seinen Fortschritt zu verfolgen.
  3. Aktualisieren Sie ein Floating-TOC-Widget auf den aktuellen Abschnitt, während ein Nutzer auf der Seite scrollt.

Unter Berücksichtigung dieser Anwendungsfälle haben wir ein Ziel entwickelt: ein Ereignis erstellen, das ausgelöst wird, wenn ein position:sticky-Element fixiert wird. Nennen wir es das Ereignis 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 Überschriften einen Schatten zu geben, wenn sie fixiert werden. Außerdem wird der neue Titel oben auf der Seite aktualisiert.

In der Demo werden die Effekte ohne Scrollereignisse angewendet.

Scrolleffekte ohne Scrollereignisse?

Struktur der Seite.
Struktur der Seite

Lassen Sie uns einige Terminologie aus dem Weg räumen, damit ich mich im Rest des Beitrags auf diese Namen beziehen kann:

  1. Scrollcontainer: Der Inhaltsbereich (sichtbarer Darstellungsbereich) mit der Liste der „Blogbeiträge“.
  2. Überschriften: Blauer Titel in jedem Abschnitt mit position:sticky.
  3. Fixierte Abschnitte: die einzelnen Inhaltsbereiche. Der Text, der unter den fixierten Überschriften scrollt.
  4. „Fixierter Modus“: position:sticky wird auf das Element angewendet.

Damit wir wissen, welcher Header in den „Sticky-Modus“ wechselt, müssen wir den Scroll-Offset des Scroll-Containers ermitteln. So könnten wir den aktuell angezeigten Header berechnen. Das ist jedoch ohne scroll-Ereignisse ziemlich schwierig. Das andere Problem ist, dass position:sticky das Element aus dem Layout entfernt, wenn es fixiert wird.

Ohne Scroll-Ereignisse können wir also keine layoutbezogenen Berechnungen für die Überschriften mehr ausführen.

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

Anstatt scroll-Ereignisse verwenden wir ein IntersectionObserver, um zu ermitteln, wann Überschriften den angepinnten Modus betreten und verlassen. Wenn Sie jedem fixierten Abschnitt zwei Knoten (auch als Sentinel bezeichnet) hinzufügen, einen oben und einen unten, dienen die Knoten als Wegpunkte zum Ermitteln der Scrollposition. Wenn diese Markierungen den Container betreten und verlassen, ändert sich ihre Sichtbarkeit und der Intersection Observer löst einen Callback aus.

Ohne Anzeige von Sentinel-Elementen
Die ausgeblendeten Sentinel-Elemente.

Wir benötigen zwei Sentinels, um vier Fälle des Scrollens nach oben und unten abzudecken:

  1. Scrolling down: Der header wird fixiert, wenn sich sein oberer Sentinel über dem Container befindet.
  2. Nach unten scrollen: header verlässt den fixierten Modus, wenn er den unteren Rand des Abschnitts erreicht und sein unterer Grenzwert den oberen Rand des Containers kreuzt.
  3. Nach oben scrollen: Der Header verlässt den fixierten Modus, wenn sein oberes Sentinel von oben wieder in den Blick kommt.
  4. Nach oben scrollen: Der Header wird fixiert, wenn der untere Sentinel von oben wieder in den Blick kommt.

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

Intersection Observers lösen Callbacks aus, wenn die Sentinels in den Scroll-Container eintreten bzw. ihn verlassen.

Das Preisvergleichsportal

Die Sentinels werden oben und unten in jedem Abschnitt platziert. .sticky_sentinel--top befindet sich oben in der Überschrift, während .sticky_sentinel--bottom unten im Abschnitt zu sehen ist:

Unterer Grenzwert wird erreicht.
Position der oberen und unteren Grenzelemente.
: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 überwachen asynchron Änderungen an der Schnittmenge eines Zielelements und des Dokument-Viewports oder eines übergeordneten Containers. In unserem Fall sehen wir Schnittpunkte mit einem übergeordneten Container.

Die magische Soße ist IntersectionObserver. Jeder Sentinel erhält ein IntersectionObserver, um die Sichtbarkeit der Schnittmenge innerhalb des Scroll-Containers zu beobachten. Wenn ein Sentinel in den sichtbaren Darstellungsbereich scrollt, wissen wir, dass eine Überschrift fixiert wurde oder nicht mehr fixiert ist. Das Gleiche gilt, wenn ein Sentinel den Darstellungsbereich verlässt.

Zuerst richte ich Beobachter für die Header- und Fußzeilen-Sentinels ein:

/**
 * 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 Scrollcontainer durchlaufen (in beide Richtungen). Mit der Funktion observeHeaders werden die wichtigsten Sentinels erstellt und jedem Abschnitt hinzugefügt. Der Beobachter berechnet die Schnittmenge des Sentinels mit dem oberen Rand des Containers und entscheidet, ob er den Viewport betritt oder verlässt. Anhand dieser Informationen wird festgelegt, ob der Abschnittsheader fixiert 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 Rückruf ausgelöst wird, sobald der Sentinel sichtbar wird.

Für den unteren Grenzwert (.sticky_sentinel--bottom) ist das Verfahren ähnlich. Ein zweiter Beobachter wird erstellt, der ausgelöst wird, wenn die Fußzeilen den unteren Rand des Scrollcontainers erreichen. Die Funktion observeFooters erstellt die Sentinel-Knoten und fügt sie den einzelnen Abschnitten hinzu. Der Beobachter berechnet die Schnittmenge zwischen der Sentinel und dem Boden des Containers und entscheidet, ob er ein- oder ausgeht. Anhand dieser Informationen wird bestimmt, ob die Abschnittsüberschrift fixiert wird.

/**
 * 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 Rückruf ausgelöst wird, wenn der gesamte Knoten im Sichtfeld ist.

Zum Schluss gibt es noch zwei Tools zum Auslösen des benutzerdefinierten Ereignisses sticky-change und zum Generieren der Prüfzeichen:

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

Endgültige Demo

Wir haben ein benutzerdefiniertes Ereignis erstellt, wenn Elemente mit position:sticky fixiert werden, und Scrolleffekte ohne scroll-Ereignisse hinzugefügt.

Demo ansehen | Quellcode

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. Die Antwort lautet „Ja“ und „Nein“. Die Semantik der IntersectionObserver API macht es schwierig, sie für alle Zwecke zu verwenden. Aber wie ich hier gezeigt habe, können Sie es für einige interessante Techniken verwenden.

Gibt es eine andere Möglichkeit, Stiländerungen zu erkennen?

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

Ein MutationObserver wäre eine logische erste Wahl, aber das funktioniert in den meisten Fällen nicht. In der Demo erhalten wir beispielsweise einen Rückruf, 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 Laden der Seite deklariert.

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