Een evenement voor CSS-positie:sticky

TL; DR

Hier is een geheim: mogelijk heb je geen scroll nodig in je volgende app. Met behulp van een IntersectionObserver laat ik zien hoe je een aangepaste gebeurtenis kunt activeren wanneer position:sticky -elementen vast komen te zitten of wanneer ze niet meer blijven plakken. En dat allemaal zonder het gebruik van scroll-listeners. Er is zelfs een geweldige demo om het te bewijzen:

Bekijk demo | Bron

Maak kennis met het sticky-change -evenement

Een van de praktische beperkingen van het gebruik van CSS sticky position is dat het geen platformsignaal geeft om te weten wanneer de eigenschap actief is . Met andere woorden, er is geen gebeurtenis die weet wanneer een element plakkerig wordt of wanneer het niet langer plakkerig is.

Neem het volgende voorbeeld, dat een <div class="sticky"> 10px vanaf de bovenkant van de bovenliggende container repareert:

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

Zou het niet mooi zijn als de browser zou vertellen wanneer de elementen dat merkteken bereiken? Blijkbaar ben ik niet de enige die er zo over denkt. Een signaal voor position:sticky zou een aantal gebruiksscenario's kunnen ontsluiten:

  1. Breng een slagschaduw aan op een banner terwijl deze blijft plakken.
  2. Terwijl een gebruiker uw inhoud leest, registreert u analytische hits om hun voortgang te kennen.
  3. Terwijl een gebruiker door de pagina scrollt, werkt u een zwevende TOC-widget bij naar de huidige sectie.

Met deze gebruiksscenario's in gedachten hebben we een einddoel bedacht: een gebeurtenis creëren die wordt geactiveerd wanneer een position:sticky -element vast komt te zitten. Laten we het de sticky-change -gebeurtenis noemen:

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

De demo gebruikt deze gebeurtenis om een ​​slagschaduw te headeren wanneer deze wordt opgelost. Het werkt ook de nieuwe titel bovenaan de pagina bij.

In de demo worden effecten toegepast zonder scroll-events.

Scrolleffecten zonder scrollgebeurtenissen?

Structuur van de pagina.
Structuur van de pagina.

Laten we wat terminologie uit de weg ruimen, zodat ik in de rest van het bericht naar deze namen kan verwijzen:

  1. Scrollcontainer - het inhoudsgebied (zichtbare viewport) met de lijst met "blogposts".
  2. Headers - blauwe titel in elke sectie met position:sticky .
  3. Vastgezette secties - elke inhoudssectie. De tekst die onder de vastgezette kopteksten scrolt.
  4. "Sticky-modus" - wanneer position:sticky van toepassing is op het element.

Om te weten welke header in de "plakkerige modus" komt, hebben we een manier nodig om de scroll-offset van de scrollcontainer te bepalen. Dat zou ons een manier geven om de koptekst te berekenen die momenteel wordt weergegeven. Dat wordt echter behoorlijk lastig zonder scroll -gebeurtenissen :) Het andere probleem is dat position:sticky het element uit de lay-out verwijdert wanneer het wordt opgelost.

Zonder scrollgebeurtenissen zijn we dus de mogelijkheid kwijtgeraakt om lay-outgerelateerde berekeningen op de headers uit te voeren .

Domme DOM toevoegen om de scrollpositie te bepalen

In plaats van scroll gebeurtenissen gaan we een IntersectionObserver gebruiken om te bepalen wanneer headers de sticky-modus binnengaan en verlaten. Het toevoegen van twee knooppunten (ook wel Sentinels genoemd) in elke plakkerige sectie , één bovenaan en één onderaan, zullen fungeren als waypoints voor het bepalen van de scrollpositie. Wanneer deze markeringen de container binnenkomen en verlaten, verandert hun zichtbaarheid en wordt door Intersection Observer teruggebeld.

Zonder dat schildwachtelementen zichtbaar zijn
De verborgen schildwachtelementen.

We hebben twee wachters nodig om vier gevallen van op en neer scrollen te dekken:

  1. Naar beneden scrollen : de kop wordt plakkerig wanneer de bovenste schildwacht de bovenkant van de container kruist.
  2. Naar beneden scrollen : de header verlaat de sticky-modus wanneer deze de onderkant van de sectie bereikt en de onderste schildwacht de bovenkant van de container kruist.
  3. Omhoog scrollen - de header verlaat de plakmodus wanneer de bovenste schildwacht vanaf de bovenkant weer in beeld scrolt.
  4. Omhoog scrollen : de kop wordt plakkerig als de onderste schildwacht van bovenaf weer in beeld komt.

Het is handig om een ​​screencast van 1-4 te zien in de volgorde waarin ze plaatsvinden:

Intersection Observers vuren callbacks af wanneer de schildwachten de scrollcontainer binnenkomen/verlaten.

De CSS

De schildwachten bevinden zich aan de boven- en onderkant van elke sectie. .sticky_sentinel--top bevindt zich bovenaan de kop, terwijl .sticky_sentinel--bottom onderaan de sectie rust:

De onderste schildwacht bereikt zijn drempel.
Positie van de bovenste en onderste schildwachtelementen.
: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;
}

Het instellen van de kruispuntwaarnemers

Intersection Observers observeren asynchroon veranderingen in de kruising van een doelelement en de documentviewport of een bovenliggende container. In ons geval observeren we kruispunten met een bovenliggende container.

De magische saus is IntersectionObserver . Elke schildwacht krijgt een IntersectionObserver die de zichtbaarheid van het kruispunt binnen de scrollcontainer waarneemt. Wanneer een Sentinel naar de zichtbare viewport scrollt, weten we dat een header vast komt te zitten of niet meer plakkerig is . Hetzelfde geldt voor wanneer een schildwacht de viewport verlaat.

Eerst heb ik waarnemers ingesteld voor de kop- en voettekstwachters:

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

Vervolgens heb ik een waarnemer toegevoegd om te vuren wanneer .sticky_sentinel--top -elementen door de bovenkant van de scrollende container gaan (in beide richtingen). De functie observeHeaders creëert de bovenste schildwachten en voegt deze toe aan elke sectie. De waarnemer berekent het snijpunt van de schildwacht met de bovenkant van de container en beslist of deze de kijkpoort binnenkomt of verlaat. Die informatie bepaalt of de sectiekop blijft hangen of niet.

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

De waarnemer is geconfigureerd met threshold: [0] dus de callback wordt geactiveerd zodra de schildwacht zichtbaar wordt.

Het proces is vergelijkbaar voor de onderste schildwacht ( .sticky_sentinel--bottom ). Er wordt een tweede waarnemer gemaakt die vuurt wanneer de voetteksten door de onderkant van de scrollcontainer gaan. De functie observeFooters creëert de schildwachtknooppunten en koppelt deze aan elke sectie. De waarnemer berekent het snijpunt van de schildwacht met de bodem van de container en beslist of deze binnenkomt of vertrekt. Die informatie bepaalt of de sectiekop blijft hangen of niet.

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

De waarnemer is geconfigureerd met threshold: [1] dus de callback wordt geactiveerd wanneer het hele knooppunt in zicht is.

Ten slotte zijn er mijn twee hulpprogramma's voor het activeren van de aangepaste sticky-change -gebeurtenis en het genereren van de schildwachten:

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

Dat is het!

Laatste demo

We hebben een aangepaste gebeurtenis gemaakt wanneer elementen met position:sticky vast worden en scrolleffecten toegevoegd zonder het gebruik van scroll .

Bekijk demo | Bron

Conclusie

Ik heb me vaak afgevraagd of IntersectionObserver een nuttig hulpmiddel zou zijn om enkele van de op scroll gebaseerde UI-patronen te vervangen die zich in de loop der jaren hebben ontwikkeld. Het antwoord blijkt ja en nee te zijn. De semantiek van de IntersectionObserver API maakt het moeilijk om voor alles te gebruiken. Maar zoals ik hier heb laten zien, kun je het voor een aantal interessante technieken gebruiken.

Een andere manier om stijlveranderingen te detecteren?

Niet echt. Wat we nodig hadden was een manier om stijlveranderingen op een DOM-element waar te nemen. Helaas is er niets in de API's van het webplatform waarmee u stijlveranderingen kunt bekijken.

Een MutationObserver zou een logische eerste keuze zijn, maar dat werkt in de meeste gevallen niet. In de demo ontvangen we bijvoorbeeld een callback wanneer de sticky -klasse aan een element wordt toegevoegd, maar niet wanneer de berekende stijl van het element verandert. Bedenk dat de sticky -klasse al was gedeclareerd bij het laden van de pagina.

In de toekomst zou een " Style Mutation Observer " -uitbreiding voor Mutation Observers nuttig kunnen zijn om veranderingen in de berekende stijlen van een element te observeren. position: sticky .