Construirea unui antet dinamic cu Observatorul de intersecție

Publicat: 2022-03-10
Rezumat rapid ↬ Ați avut vreodată nevoie să construiți o interfață de utilizare în care o componentă a paginii trebuie să răspundă la elemente pe măsură ce acestea sunt derulate la un anumit prag în fereastra de vizualizare - sau poate în și în afara ferestrei de vizualizare? În JavaScript, atașarea unui ascultător de evenimente pentru a declanșa în mod constant un apel invers la derulare poate fi intensă de performanță și, dacă este folosită neînțelept, poate crea o experiență de utilizator lenta. Dar există o modalitate mai bună cu Intersection Observer.

API-ul Intersection Observer este un API JavaScript care ne permite să observăm un element și să detectăm când trece de un punct specificat într-un container de defilare - adesea (dar nu întotdeauna) fereastra - declanșând o funcție de apel invers.

Intersection Observer poate fi considerat mai performant decât ascultarea evenimentelor de defilare pe firul principal, deoarece este asincron, iar apelul invers se va declanșa numai atunci când elementul pe care îl observăm atinge pragul specificat, în schimb de fiecare dată când poziția de defilare este actualizată. În acest articol, vom parcurge un exemplu despre cum putem folosi Intersection Observer pentru a construi o componentă de antet fixă ​​care se schimbă atunci când se intersectează cu diferite secțiuni ale paginii web.

Utilizare de bază

Pentru a folosi Intersection Observer, trebuie să creăm mai întâi un nou observator, care ia doi parametri: un obiect cu opțiunile observatorului și funcția de apel invers pe care dorim să o executăm ori de câte ori elementul pe care îl observăm (cunoscut ca ținta observatorului) se intersectează. cu rădăcina (containerul de derulare, care trebuie să fie un strămoș al elementului țintă).

 const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0 } const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry)) } const observer = new IntersectionObserver(callback, options)

Când ne-am creat observatorul, trebuie să îi instruim să urmărească un element țintă:

 const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)

Oricare dintre valorile opțiunilor poate fi omisă, deoarece vor reveni la valorile implicite:

 const options = { rootMargin: '0px', threshold: 1.0 }

Dacă nu este specificată nicio rădăcină, atunci aceasta va fi clasificată ca port de vizualizare a browserului. Exemplul de cod de mai sus arată valorile implicite atât pentru rootMargin , cât și pentru threshold . Acestea pot fi greu de vizualizat, așa că merită explicate:

rootMargin

Valoarea rootMargin este un pic ca adăugarea de margini CSS la elementul rădăcină - și, la fel ca marjele, poate lua mai multe valori, inclusiv valori negative. Elementul țintă va fi considerat a fi intersectând în raport cu marginile.

Rădăcina de defilare cu valori pozitive și negative ale marginii rădăcinii. Pătratul portocaliu este poziționat în punctul în care ar fi clasificat ca „intersectând”, presupunând o valoare de prag implicită de 1. (Previzualizare mare)

Aceasta înseamnă că un element poate fi clasificat din punct de vedere tehnic ca „intersectând” chiar și atunci când nu este vizibil (dacă rădăcina noastră de defilare este fereastra).

Pătratul portocaliu se intersectează cu rădăcina, chiar dacă se află în afara zonei vizibile. (Previzualizare mare)

rootMargin valoarea implicită de 0px , dar poate lua un șir format din mai multe valori, la fel ca folosind proprietatea margin în CSS.

threshold

threshold poate consta dintr-o singură valoare sau o matrice de valori între 0 și 1. Reprezintă proporția elementului care trebuie să fie în limitele rădăcinii pentru ca acesta să fie considerat intersectând . Folosind valoarea implicită de 1, apelul invers se va declanșa atunci când 100% din elementul țintă este vizibil în rădăcină.

Pragurile de 1, 0 și, respectiv, 0,5 duc la declanșarea apelului înapoi atunci când sunt vizibile 100%, 0% și 50%. (Previzualizare mare)

Nu este întotdeauna ușor să vizualizați când un element va fi clasificat ca vizibil folosind aceste opțiuni. Am creat un mic instrument pentru a vă ajuta să vă familiarizați cu Intersection Observer.

Mai multe după săritură! Continuați să citiți mai jos ↓

Crearea antetului

Acum că am înțeles principiile de bază, să începem să construim antetul nostru dinamic. Vom începe cu o pagină web împărțită în secțiuni. Această imagine arată aspectul complet al paginii pe care o vom construi:

(Previzualizare mare)

Am inclus o demonstrație la sfârșitul acestui articol, așa că nu ezitați să treceți direct la el dacă doriți să anulați codul. (Există și un depozit Github.)

Fiecare secțiune are o înălțime minimă de 100vh (deși pot fi mai lungi, în funcție de conținut). Antetul nostru este fixat în partea de sus a paginii și rămâne pe loc pe măsură ce utilizatorul derulează (folosind position: fixed ). Secțiunile au fundaluri colorate diferite, iar atunci când se întâlnesc cu antetul, culorile antetului se schimbă pentru a le completa pe cele ale secțiunii. Există, de asemenea, un marcator pentru a afișa secțiunea curentă în care se află utilizatorul, care glisează de-a lungul când sosește următoarea secțiune. Pentru a ne ușura să ajungem direct la codul relevant, am creat o demonstrație minimă cu punctul nostru de pornire (înainte de a începe să folosim API-ul Intersection Observer), în cazul în care doriți să urmați.

Markup

Vom începe cu HTML-ul pentru antetul nostru. Acesta va fi un antet destul de simplu, cu o legătură acasă și navigare, nimic deosebit de elegant, dar vom folosi câteva atribute de date: data-header pentru antetul în sine (deci să putem viza elementul cu JS) , și trei link-uri de ancorare cu atributul data-link , care vor derula utilizatorul la secțiunea relevantă atunci când se va face clic:

 <header data-header> <nav class="header__nav"> <div class="header__left-content"> <a href="#0">Home</a> </div> <ul class="header__list"> <li> <a href="#about-us" data-link>About us</a> </li> <li> <a href="#flavours" data-link>The flavours</a> </li> <li> <a href="#get-in-touch" data-link>Get in touch</a> </li> </ul> </nav> </header>

Apoi, HTML pentru restul paginii noastre, care este împărțit în secțiuni. Pentru concizie, am inclus doar părțile relevante pentru articol, dar markupul complet este inclus în demonstrație. Fiecare secțiune include un atribut de date care specifică numele culorii de fundal și un id care corespunde unuia dintre linkurile de ancorare din antet:

 <main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>

Ne vom poziționa antetul cu CSS astfel încât să rămână fix în partea de sus a paginii pe măsură ce utilizatorul derulează:

 header { position: fixed; width: 100%; }

De asemenea, vom acorda secțiunilor noastre o înălțime minimă și vom centra conținutul. (Acest cod nu este necesar pentru ca Observatorul de intersecție să funcționeze, este doar pentru design.)

 section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }

Avertisment iframe

În timpul construirii acestei demonstrații Codepen, m-am confruntat cu o problemă nedumerită în care codul meu Intersection Observer care ar fi trebuit să funcționeze perfect nu reușea să declanșeze apelul înapoi în punctul corect al intersecției, ci în schimb să declanșeze atunci când elementul țintă se intersecta cu marginea ferestrei de vizualizare. După un pic de zgârietură, mi-am dat seama că acest lucru se datorează faptului că în Codepen conținutul este încărcat într-un iframe, care este tratat diferit. (Consultați secțiunea din documentele MDN despre tăiere și dreptunghiul de intersecție pentru detalii complete.)

Ca o soluție, în demonstrație ne putem împacheta marcajul într-un alt element, care va acționa ca container de defilare - rădăcina în opțiunile noastre IO - mai degrabă decât fereastra de vizualizare a browserului, așa cum ne-am putea aștepta:

 <div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>

Dacă doriți să vedeți cum să utilizați fereastra ca rădăcină în schimb pentru aceeași demonstrație, aceasta este inclusă în depozitul Github.

CSS

În CSS-ul nostru vom defini câteva proprietăți personalizate pentru culorile pe care le folosim. De asemenea, vom defini două proprietăți personalizate suplimentare pentru textul antetului și culorile de fundal și vom seta câteva valori inițiale. (Vom actualiza mai târziu aceste două proprietăți personalizate pentru diferite secțiuni.)

 :root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }

Vom folosi aceste proprietăți personalizate în antetul nostru:

 header { background-color: var(--headerBg); color: var(--headerText); }

De asemenea, vom seta culorile pentru diferitele noastre secțiuni. Folosesc atributele de date ca selectoare, dar ai putea folosi la fel de ușor o clasă dacă preferi.

 [data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla); } [data-section="mint"] { background-color: var(--mint); color: var(--chocolate); } [data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate); } [data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla); }

De asemenea, putem seta câteva stiluri pentru antetul nostru atunci când fiecare secțiune este vizualizată:

 /* Header */ [data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla); } [data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate); } [data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla); }

Există un caz mai puternic pentru utilizarea atributelor de date aici, deoarece vom comuta atributul data-theme al antetului la fiecare intersecție.

Crearea Observatorului

Acum că avem HTML și CSS de bază configurați pentru pagina noastră, putem crea un observator care să urmărească fiecare dintre secțiunile noastre care apar. Dorim să declanșăm un apel invers ori de câte ori o secțiune intră în contact cu partea de jos a antetului în timp ce derulăm în jos pe pagină. Aceasta înseamnă că trebuie să setăm o marjă negativă a rădăcinii care să corespundă înălțimii antetului.

 const header = document.querySelector('[data-header]') const sections = [...document.querySelectorAll('[data-section]')] const scrollRoot = document.querySelector('[data-scroller]') const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0 }

Setăm un prag de 0 , deoarece vrem să se declanșeze dacă orice parte a secțiunii se intersectează cu marginea rădăcinii.

În primul rând, vom crea un apel invers pentru a modifica valoarea data-theme a antetului. (Acest lucru este mai simplu decât adăugarea și eliminarea claselor, mai ales atunci când elementul nostru antet poate avea alte clase aplicate.)

 /* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }

Apoi vom crea observatorul pentru a urmări secțiunile care se intersectează:

 /* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })

Acum ar trebui să vedem actualizarea culorilor antetului când fiecare secțiune întâlnește antetul.

Vezi stiloul [Happy Face Ice Cream Parlour – Pasul 2](https://codepen.io/smashingmag/pen/poPgpjZ) de Michelle Barker.

Vedeți înghețată Happy Face – Pasul 2 de Michelle Barker.

Cu toate acestea, este posibil să observați că culorile nu se actualizează corect pe măsură ce derulăm în jos. De fapt, antetul se actualizează de fiecare dată cu culorile secțiunii anterioare! Derulând în sus, pe de altă parte, funcționează perfect. Trebuie să determinăm direcția de derulare și să schimbăm comportamentul în consecință.

Găsirea direcției de defilare

Vom seta o variabilă în JS-ul nostru pentru direcția de defilare, cu o valoare inițială de 'up' și o alta pentru ultima poziție de derulare cunoscută ( prevYPosition ). Apoi, în cadrul callback-ului, dacă poziția de defilare este mai mare decât valoarea anterioară, putem seta valoarea direction ca 'down' sau 'up' dacă invers.

 let direction = 'up' let prevYPosition = 0 const setScrollDirection = () => { if (scrollRoot.scrollTop > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop } const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ }) }

De asemenea, vom crea o nouă funcție pentru a actualiza culorile antetului, trecând în secțiunea țintă ca argument:

 const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }

Până acum nu ar trebui să vedem nicio schimbare în comportamentul antetului nostru. Dar acum că știm direcția de derulare, putem trece într-o țintă diferită pentru funcția noastră updateColors() . Dacă direcția de defilare este în sus, vom folosi ținta de intrare. Dacă este în jos, vom folosi secțiunea următoare (dacă există una).

 const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target } } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) }) }

Mai există o problemă, totuși: antetul se va actualiza nu numai când secțiunea lovește antetul, ci și când următorul element apare în partea de jos a ferestrei de vizualizare. Acest lucru se datorează faptului că observatorul nostru declanșează apelul invers de două ori: o dată când elementul intră și din nou când iese.

Pentru a determina dacă antetul ar trebui să se actualizeze, putem folosi tasta isIntersecting din obiectul de entry . Să creăm o altă funcție pentru a returna o valoare booleană pentru a stabili dacă culorile antetului ar trebui să se actualizeze:

 const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }

Vom actualiza onIntersect() în consecință:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Do nothing if no need to update */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) }) }

Acum culorile noastre ar trebui să se actualizeze corect. Putem seta o tranziție CSS, astfel încât efectul să fie puțin mai frumos:

 header { transition: background-color 200ms, color 200ms; } 

Vezi stiloul [Happy Face Ice Cream Parlour – Pasul 3](https://codepen.io/smashingmag/pen/bGWEaEa) de Michelle Barker.

Vedeți înghețată Happy Face – Pasul 3 de Michelle Barker.

Adăugarea marcatorului dinamic

În continuare, vom adăuga un marcator la antet care își actualizează poziția pe măsură ce derulăm la diferitele secțiuni. Putem folosi un pseudo-element pentru aceasta, deci nu trebuie să adăugăm nimic la HTML-ul nostru. Îi vom oferi un stil CSS simplu pentru a-l poziționa în partea stângă sus a antetului și îi vom da o culoare de fundal. Folosim currentColor pentru aceasta, deoarece va prelua valoarea culorii textului antetului:

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }

Putem folosi o proprietate personalizată pentru lățime, cu o valoare implicită de 0. Vom folosi și o proprietate personalizată pentru valoarea translate x. Vom seta valorile pentru acestea în funcția noastră de apel invers pe măsură ce utilizatorul derulează.

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0); }

Acum putem scrie o funcție care va actualiza lățimea și poziția markerului în punctul de intersecție:

 const updateMarker = (target) => { const id = target.id /* Do nothing if no target ID */ if (!id) return /* Find the corresponding nav link, or use the first one */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Get the values and set the custom properties */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`) }

Putem apela funcția în același timp când actualizăm culorile:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }

De asemenea, va trebui să setăm o poziție inițială pentru marker, astfel încât să nu apară din senin. Când documentul este încărcat, vom apela funcția updateMarker() , folosind prima secțiune ca țintă:

 document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })

În cele din urmă, să adăugăm o tranziție CSS, astfel încât marcatorul să alunece peste antet de la un link la altul. Pe măsură ce trecem la proprietatea width , putem folosi will-change pentru a permite browserului să efectueze optimizări.

 header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }

Defilare lină

Pentru o ultimă atingere, ar fi bine ca, atunci când un utilizator dă clic pe un link, acesta să fie derulat fără probleme în jos pe pagină, în loc să sară la secțiune. În zilele noastre o putem face chiar în CSS-ul nostru, nu este nevoie de JS! Pentru o experiență mai accesibilă, este o idee bună să respectați preferințele de mișcare ale utilizatorului, implementând doar derularea lină dacă acesta nu a specificat o preferință pentru mișcare redusă în setările sistemului:

 @media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }

Demo finală

Adunând toți pașii de mai sus împreună, rezultă o demonstrație completă.

Vedeți Pen [Happy Face Ice Cream Parlour – Exemplu Observator de intersecție](https://codepen.io/smashingmag/pen/XWRXVXQ) de Michelle Barker.

Vedeți exemplul Pen Happy Face Ice Cream Parlour – Observator de intersecție de Michelle Barker.

Suport pentru browser

Intersection Observer este acceptat pe scară largă în browserele moderne. Acolo unde este necesar, poate fi completat polivalent pentru browserele mai vechi - dar prefer să adopt o abordare de îmbunătățire progresivă acolo unde este posibil. În cazul antetului nostru, nu ar fi foarte dăunător experienței utilizatorului să oferim o versiune simplă, neschimbată pentru browsere care nu acceptă.

Pentru a detecta dacă Intersection Observer este acceptat, putem folosi următoarele:

 if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Code to execute if IO is supported */ } else { /* Code to execute if not supported */ }

Resurse

Citiți mai multe despre Intersection Observer:

  • Documentație extinsă, cu câteva exemple practice de la MDN
  • Instrument de vizualizare Intersection Observer
  • Timing Element Visibility cu API-ul Intersection Observer – un alt tutorial de la MDN, care analizează modul în care IO poate fi utilizat pentru a urmări vizibilitatea anunțurilor
  • Acest articol al lui Denys Mishunov acoperă alte utilizări pentru IO, inclusiv activele cu încărcare leneră. Deși acest lucru este mai puțin necesar acum (mulțumită atributului de loading ), mai sunt multe de învățat aici.