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. Mithilfe von IntersectionObserver zeige ich, wie Sie ein benutzerdefiniertes Ereignis auslösen können, wenn position:sticky-Elemente fixiert werden oder nicht mehr fixiert sind. ganz ohne Scroll-Listener. Es gibt sogar eine tolle Demo, um dies zu beweisen:

Demo ansehen | Quelle

Jetzt neu: das sticky-change-Ereignis

Eine der praktischen Einschränkungen bei der Verwendung von fixierten CSS-Positionen besteht darin, dass kein Plattformsignal bereitgestellt wird, um festzustellen, ob die Property aktiv ist. Mit anderen Worten: Es gibt kein Ereignis, um festzustellen, wann ein Element haftend wird oder ab wann es nicht mehr haftbar ist.

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

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

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

  1. Wende einen Schlagschatten auf ein fixiertes Banner an.
  2. Erfassen Sie Analytics-Treffer, während ein Nutzer Ihre Inhalte liest, um den Fortschritt zu verfolgen.
  3. Aktualisieren Sie ein Floating-TOC-Widget auf den aktuellen Abschnitt, wenn der Nutzer auf der Seite scrollt.

Unter Berücksichtigung dieser Anwendungsfälle haben wir das Ziel entwickelt: ein Ereignis zu erstellen, das ausgelöst wird, wenn ein position:sticky-Element fixiert wird. Nennen wir es sticky-change-Ereignis:

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

Die Demo verwendet dieses Ereignis, um einen Schlagschatten zu versehen, wenn das Problem behoben wird. Außerdem wird der neue Titel oben auf der Seite aktualisiert.

In der Demo werden Effekte ohne scrollevents angewendet.

Scrolleffekte ohne Scroll-Ereignisse?

Struktur der Seite
Struktur der Seite

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

  1. Scrollcontainer: der Inhaltsbereich (sichtbarer Darstellungsbereich), der die Liste der Blogposts enthält
  2. Überschriften: blaue Titel in jedem Abschnitt, in denen position:sticky enthalten ist
  3. Fixierte Abschnitte: die einzelnen Inhaltsbereiche. Der Text, der unter den fixierten Überschriften scrollt.
  4. Fixierter Modus: Wenn position:sticky auf das Element angewendet wird.

Um zu wissen, welcher header in den fixierten Modus wechselt, müssen Sie den Scroll-Offset des Scrollcontainers bestimmen. Damit ließe sich der aktuell angezeigte header berechnen. Dies ist jedoch ohne scroll-Ereignisse ziemlich schwierig. Das andere Problem besteht darin, dass position:sticky das Element aus dem Layout entfernt, sobald es behoben ist.

Ohne Scroll-Ereignisse können wir keine layoutbezogenen Berechnungen für die Header vornehmen.

Dumby-DOM zur Bestimmung der Scrollposition hinzufügen

Anstelle von scroll-Ereignissen verwenden wir IntersectionObserver, um zu bestimmen, wann headers den fixierten Modus aufrufen und beenden. Wenn Sie jedem fixierten Abschnitt zwei Knoten (auch als „Sentinels“ 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 Sentinel-Elemente
Die ausgeblendeten Sentinel-Elemente.

Wir benötigen zwei Sentinels, um vier Fälle des Hoch- und Herunterscrollens abzudecken:

  1. Scrolling down: Der header wird fixiert, wenn sich sein oberer Sentinel über dem Container befindet.
  2. Scrolling nach untenheader verlässt den fixierten Modus, wenn er den unteren Teil des Abschnitts erreicht und seine untere Sentinel den oberen Bereich des Containers kreuzt.
  3. Scrolling upheader beendet den fixierten Modus, wenn der obere Sentinel von oben zurück in die Ansicht scrollt.
  4. Scrolling up: Der header wird fixiert, wenn der untere Sentinel wieder von oben in die Ansicht rückt.

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

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

Das Preisvergleichsportal

Die Sentinels befinden sich oben und unten in jedem Abschnitt. .sticky_sentinel--top befindet sich oben im Header und .sticky_sentinel--bottom befindet sich unten im Abschnitt:

Unterer Sentinel erreicht seine Schwelle.
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;
}

Beobachten der Kreuzung einrichten

Überschneidungen beobachten Änderungen am Schnittpunkt eines Zielelements und des Darstellungsbereichs des Dokuments oder eines übergeordneten Containers asynchron. 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 ein Header fixiert wird oder nicht mehr fixiert ist. Ähnlich verhält es sich, 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 zwischen der Sentinel und dem oberen Rand des Containers und entscheidet, ob er in den Darstellungsbereich eintritt oder ihn verlässt. Anhand dieser Informationen wird bestimmt, ob die Abschnittsüberschrift 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 Callback ausgelöst wird, sobald der Sentinel sichtbar wird.

Der Vorgang ist für den unteren Sentinel (.sticky_sentinel--bottom) ähnlich. Ein zweiter Beobachter wird erstellt, der ausgelöst wird, wenn die Fußzeilen den unteren Scroll-Container passieren. Mit der Funktion observeFooters werden die Sentinel-Knoten erstellt und an jeden Abschnitt angehängt. 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 Callback ausgelöst wird, wenn sich der gesamte Knoten im Sichtbereich befindet.

Schließlich gibt es meine beiden Dienstprogramme zum Auslösen des benutzerdefinierten Ereignisses sticky-change und zum Generieren der Sentinels:

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

Abschließende Demo

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

Demo ansehen | Quelle

Fazit

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

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

Nein. Wir brauchten eine Möglichkeit, Stiländerungen bei einem DOM-Element zu beobachten. Leider gibt es in den Webplattform-APIs nichts, mit dem sich Stiländerungen beobachten lassen.

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

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.