Tworzenie dynamicznego nagłówka z obserwatorem skrzyżowania
Opublikowany: 2022-03-10Intersection Observer API to API JavaScript, które umożliwia nam obserwację elementu i wykrywanie, kiedy przechodzi on przez określony punkt w przewijanym kontenerze — często (ale nie zawsze) przez port widoku — wyzwalając funkcję zwrotną.
Intersection Observer może być uważany za bardziej wydajny niż nasłuchiwanie zdarzeń przewijania w głównym wątku, ponieważ jest asynchroniczny, a wywołanie zwrotne zostanie uruchomione tylko wtedy, gdy obserwowany element osiągnie określony próg, zamiast za każdym razem, gdy pozycja przewijania jest aktualizowana. W tym artykule omówimy przykład wykorzystania obserwatora skrzyżowań do zbudowania stałego komponentu nagłówka, który zmienia się, gdy przecina się z różnymi sekcjami strony internetowej.
Podstawowe użycie
Aby użyć obserwatora przecięcia, musimy najpierw utworzyć nowego obserwatora, który przyjmuje dwa parametry: obiekt z opcjami obserwatora i funkcję zwrotną, którą chcemy wykonać za każdym razem, gdy obserwowany element (znany jako cel obserwatora) przecina się z korzeniem (przewijający się kontener, który musi być przodkiem elementu docelowego).
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)
Kiedy stworzymy naszego obserwatora, musimy poinstruować go, aby obserwował element docelowy:
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Każda z wartości opcji może zostać pominięta, ponieważ powrócą one do swoich wartości domyślnych:
const options = { rootMargin: '0px', threshold: 1.0 }
Jeśli nie określono katalogu głównego, zostanie on sklasyfikowany jako rzutnia przeglądarki. Powyższy przykład kodu pokazuje wartości domyślne zarówno dla rootMargin
, jak i threshold
. Mogą być one trudne do zwizualizowania, dlatego warto wyjaśnić:
rootMargin
Wartość rootMargin
przypomina trochę dodawanie marginesów CSS do elementu głównego — i, podobnie jak marginesy, może przyjmować wiele wartości, w tym wartości ujemne. Element docelowy zostanie uznany za przecinający się względem marginesów.
Oznacza to, że element może być technicznie zaklasyfikowany jako „przecinający się”, nawet gdy jest poza widokiem (jeśli naszym rootem przewijania jest widok).
rootMargin
domyślnie wynosi 0px
, ale może przyjmować ciąg składający się z wielu wartości, tak jak przy użyciu właściwości margin
w CSS.
threshold
threshold
może składać się z pojedynczej wartości lub tablicy wartości z zakresu od 0 do 1. Reprezentuje on proporcję elementu, który musi znajdować się w granicach głównych, aby można go było uznać za przecinający się . Używając domyślnej wartości 1, wywołanie zwrotne zostanie uruchomione, gdy 100% elementu docelowego będzie widoczne w katalogu głównym.
Za pomocą tych opcji nie zawsze łatwo jest zwizualizować, kiedy element zostanie sklasyfikowany jako widoczny. Zbudowałem małe narzędzie, które pomoże uporać się z obserwatorem przecięcia.
Tworzenie nagłówka
Teraz, gdy zrozumieliśmy podstawowe zasady, zacznijmy tworzyć nasz dynamiczny nagłówek. Zaczniemy od strony internetowej podzielonej na sekcje. Ten obraz przedstawia pełny układ strony, którą będziemy budować:
Zamieściłem demo na końcu tego artykułu, więc możesz od razu przejść do niego, jeśli chcesz rozszyfrować kod. (Istnieje również repozytorium Github.)
Każda sekcja ma minimalną wysokość 100vh
(chociaż mogą być dłuższe, w zależności od zawartości). Nasz nagłówek jest zamocowany na górze strony i pozostaje na miejscu podczas przewijania strony (przy użyciu position: fixed
). Sekcje mają różnokolorowe tło, a kiedy spotykają się z nagłówkiem, kolory nagłówka zmieniają się, dopasowując się do kolorów sekcji. Istnieje również znacznik pokazujący bieżącą sekcję, w której znajduje się użytkownik, który przesuwa się, gdy nadejdzie następna sekcja. Aby ułatwić nam przejście od razu do odpowiedniego kodu, przygotowałem minimalne demo z naszym punktem wyjścia (zanim zaczniemy używać API Intersection Observer), na wypadek, gdybyś chciał kontynuować.
Narzut
Zaczniemy od kodu HTML naszego nagłówka. Będzie to dość prosty nagłówek z linkiem do domu i nawigacją, nic szczególnie wymyślnego, ale użyjemy kilku atrybutów danych: data-header
dla samego nagłówka (abyśmy mogli kierować element za pomocą JS) oraz trzy linki kotwiczące z linkiem do data-link
atrybutu , który po kliknięciu przewinie użytkownika do odpowiedniej sekcji:
<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>
Następnie HTML dla reszty naszej strony, która jest podzielona na sekcje. Dla zwięzłości zamieściłem tylko części związane z artykułem, ale pełne znaczniki są zawarte w demonstracji. Każda sekcja zawiera atrybut danych określający nazwę koloru tła oraz id
odpowiadający jednemu z linków kotwicy w nagłówku:
<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>
Umieścimy nasz nagłówek w CSS tak, aby pozostał u góry strony podczas przewijania przez użytkownika:
header { position: fixed; width: 100%; }
Dodamy również naszym sekcjom minimalną wysokość i wyśrodkujemy zawartość. (Ten kod nie jest konieczny do działania obserwatora skrzyżowania, służy tylko do projektowania).
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
Ostrzeżenie iframe
Podczas tworzenia tego demo Codepen natknąłem się na kłopotliwy problem polegający na tym, że mój kod obserwatora przecięcia, który powinien działać idealnie, nie uruchamiał wywołania zwrotnego we właściwym punkcie przecięcia, ale zamiast tego uruchamiał się, gdy element docelowy przecinał się z krawędzią rzutni. Po odrobinie drapania w głowę zdałem sobie sprawę, że dzieje się tak, ponieważ w Codepen zawartość jest ładowana w ramce iframe, która jest traktowana inaczej. (Patrz rozdział dokumentacji MDN na temat przycinania i prostokąt przecięcia, aby uzyskać szczegółowe informacje.)
Jako obejście, w demie możemy umieścić nasze znaczniki w innym elemencie, który będzie działał jako kontener przewijania — główny w naszych opcjach IO — zamiast widoku przeglądarki, jak można by się spodziewać:
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Jeśli chcesz zobaczyć, jak używać rzutni jako katalogu głównego zamiast tego samego demo, jest to zawarte w repozytorium Github.
CSS
W naszym CSS zdefiniujemy kilka niestandardowych właściwości dla kolorów, których używamy. Zdefiniujemy również dwie dodatkowe właściwości niestandardowe dla tekstu nagłówka i kolorów tła oraz ustawimy kilka wartości początkowych. (Później zaktualizujemy te dwie niestandardowe właściwości dla różnych sekcji).
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
W naszym nagłówku użyjemy tych właściwości niestandardowych:
header { background-color: var(--headerBg); color: var(--headerText); }
Ustawimy również kolory dla naszych różnych sekcji. Używam atrybutów danych jako selektorów, ale możesz równie łatwo użyć klasy, jeśli wolisz.
[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); }
Możemy również ustawić kilka stylów dla naszego nagłówka, gdy każda sekcja jest widoczna:
/* 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); }
Istnieje silniejszy argument za użyciem atrybutów danych, ponieważ zamierzamy przełączać atrybut data-theme
nagłówka na każdym skrzyżowaniu.
Tworzenie obserwatora
Teraz, gdy mamy już skonfigurowany podstawowy kod HTML i CSS dla naszej strony, możemy utworzyć obserwatora, który będzie obserwował każdą z naszych sekcji, które pojawiają się w widoku. Chcemy wywołać wywołanie zwrotne, gdy sekcja zetknie się z dolną częścią nagłówka podczas przewijania strony. Oznacza to, że musimy ustawić ujemny margines główny, który odpowiada wysokości nagłówka.
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 }
Ustawiamy próg 0 , ponieważ chcemy, aby uruchamiał się, jeśli jakakolwiek część sekcji przecina się z marginesem głównym.
Przede wszystkim utworzymy wywołanie zwrotne, aby zmienić wartość data-theme
w nagłówku. (Jest to prostsze niż dodawanie i usuwanie klas, zwłaszcza gdy nasz element nagłówka może mieć zastosowane inne klasy).
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Następnie utworzymy obserwatora, który będzie obserwował przecinające się sekcje:
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Teraz powinniśmy zobaczyć, jak nasze kolory nagłówków aktualizują się, gdy każda sekcja spotyka się z nagłówkiem.
Możesz jednak zauważyć, że kolory nie aktualizują się poprawnie podczas przewijania w dół. W rzeczywistości nagłówek jest za każdym razem aktualizowany o kolory z poprzedniej sekcji! Z drugiej strony przewijanie w górę działa idealnie. Musimy określić kierunek przewijania i odpowiednio zmienić zachowanie.
Znajdowanie kierunku przewijania
Ustawimy zmienną w naszym JS dla kierunku przewijania, z początkową wartością 'up'
, a drugą dla ostatniej znanej pozycji przewijania ( prevYPosition
). Następnie, w ramach wywołania zwrotnego, jeśli pozycja przewijania jest większa niż poprzednia wartość, możemy ustawić wartość direction
jako 'down'
lub 'up'
jeśli odwrotnie.
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() /* ... */ }) }
Stworzymy również nową funkcję do aktualizacji kolorów nagłówka, przekazując w sekcji docelowej jako argument:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Jak dotąd nie powinniśmy zobaczyć żadnych zmian w zachowaniu naszego nagłówka. Ale teraz, gdy znamy kierunek przewijania, możemy przekazać inny cel dla naszej funkcji updateColors()
. Jeśli kierunek przewijania jest w górę, użyjemy celu wpisu. Jeśli nie działa, użyjemy następnej sekcji (jeśli istnieje).
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) }) }
Jest jednak jeszcze jeden problem: nagłówek zostanie zaktualizowany nie tylko wtedy, gdy sekcja trafi w nagłówek, ale gdy następny element pojawi się w widoku na dole rzutni. Dzieje się tak, ponieważ nasz obserwator uruchamia wywołanie zwrotne dwukrotnie: raz, gdy element wchodzi, i ponownie, gdy wychodzi.
Aby określić, czy nagłówek powinien zostać zaktualizowany, możemy użyć klucza isIntersecting
z obiektu entry
. Utwórzmy kolejną funkcję, która zwróci wartość logiczną określającą, czy kolory nagłówka powinny być aktualizowane:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Zaktualizujemy odpowiednio naszą onIntersect()
:
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) }) }
Teraz nasze kolory powinny się poprawnie zaktualizować. Możemy ustawić przejście CSS, aby efekt był trochę ładniejszy:
header { transition: background-color 200ms, color 200ms; }
Dodawanie znacznika dynamicznego
Następnie dodamy znacznik do nagłówka, który aktualizuje jego pozycję, gdy przewijamy do różnych sekcji. Możemy do tego użyć pseudoelementu, więc nie musimy niczego dodawać do naszego kodu HTML. Damy mu kilka prostych stylów CSS, aby umieścić go w lewym górnym rogu nagłówka i nadać mu kolor tła. Używamy do tego currentColor
, ponieważ przyjmie on wartość koloru tekstu nagłówka:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Możemy użyć niestandardowej właściwości dla szerokości, z domyślną wartością 0. Użyjemy również niestandardowej właściwości dla wartości x przetłumacz. Ustawimy ich wartości w naszej funkcji zwrotnej podczas przewijania przez użytkownika.
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); }
Teraz możemy napisać funkcję, która zaktualizuje szerokość i położenie znacznika w punkcie przecięcia:
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`) }
Możemy wywołać funkcję jednocześnie aktualizując kolory:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
Musimy również ustawić początkową pozycję znacznika, aby nie pojawił się znikąd. Kiedy dokument zostanie załadowany, wywołamy funkcję updateMarker()
, używając pierwszej sekcji jako celu:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Na koniec dodajmy przejście CSS, aby znacznik przesuwał się po nagłówku od jednego łącza do drugiego. Gdy przenosimy właściwość width
, możemy użyć will-change
aby umożliwić przeglądarce przeprowadzanie optymalizacji.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Płynne przewijanie
Na koniec, byłoby miło, gdyby użytkownik klikał link, był płynnie przewijany w dół strony, zamiast przeskakiwać do sekcji. W dzisiejszych czasach możemy to zrobić dobrze w naszym CSS, nie jest wymagany JS! Aby uzyskać bardziej przystępne wrażenia, dobrze jest uszanować preferencje ruchu użytkownika, wdrażając płynne przewijanie tylko wtedy, gdy nie określił preferencji zmniejszonego ruchu w ustawieniach systemu:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
Ostateczne demo
Połączenie wszystkich powyższych kroków razem daje pełne demo.
Obsługa przeglądarki
Intersection Observer jest szeroko obsługiwany w nowoczesnych przeglądarkach. W razie potrzeby można go wypełniać w starszych przeglądarkach — ale wolę stosować podejście progresywnego ulepszania tam, gdzie to możliwe. W przypadku naszego nagłówka udostępnienie prostej, niezmiennej wersji dla nieobsługujących przeglądarek nie byłoby zbyt szkodliwe dla wygody użytkownika.
Aby wykryć, czy obserwator skrzyżowania jest obsługiwany, możemy użyć:
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 */ }
Zasoby
Przeczytaj więcej o obserwatorze skrzyżowań:
- Obszerna dokumentacja, z kilkoma praktycznymi przykładami z MDN
- Narzędzie wizualizacji obserwatora skrzyżowań
- Timing Element Visibility z Intersection Observer API – kolejny samouczek z MDN, który pokazuje, jak można wykorzystać IO do śledzenia widoczności reklam
- Ten artykuł autorstwa Denysa Miszunowa obejmuje inne zastosowania IO, w tym leniwe ładowanie zasobów. Chociaż jest to teraz mniej potrzebne (dzięki atrybutowi
loading
), wciąż jest wiele do nauczenia się tutaj.