Jak wykryć, że element znajduje się w polu widzenia użytkownika?
Jest to dość częsty scenariusz - chcemy aby dana akcja została uruchomiona dopiero wtedy, gdy element znajdzie się w polu widzenia użytkownika. Zazwyczaj jest to przydatne, gdy strona jest tzw. landing page bez żadnych dodatkowych zakładek - cała zawartość znajduje się na jednej, długiej stronie, którą użytkownik scrolluje w dół. W momencie, gdy dana sekcja się pojawia - chcemy uruchomić animację lub podjąć inne działanie,
Dawniej nie było żadnego wygodnego rozwiązania, jednak od pewnego czasu do standardu ECMA Script
wprowadzono klasę IntersectionObserver
. Tutaj możesz sprawdzić kompatybilność API IntersectionObserver z nowoczesnymi przeglądarkami. Jeśli nie zadowala Cię ona, wypróbuj dostępne polyfille, np. ten: intersection-observer-polyfill
Jak działa IntersectionObserver
?
Jest to klasa, której obiekt przyjmuje w konstruktorze callback. Callback ten przyjmuje argument entries
. Są to wszystkie elementy, które obserwuje IntersectionObserver
. Uruchamiany jest za każdym razem, gdy dowolny z elementów zmieni swoją widoczność, a więc zniknie z pola widzenia lub się w nim pojawi.
Dokładna dokumentacja IntersectionObservera
dostępna jest na mdn web docs.
const observer = new IntersectionObserver((entries) => { const element = entries.find((element) => element.id === "myElement"); console.info(element.isIntersecting); }); const myElement = document.getElementById("myElement"); observer.observe(myElement);
Powyższy kod zaloguje true
do konsoli kiedy tylko element o id myElement
pojawi się w polu widzenia użytkownika lub false
, gdy ten element zniknie.
Jak to przekuć na hooka?
W React najwygodniej korzysta się z hooków, dlatego zbudujmy hook który obsłuży IntersectionObserver
function useInViewport<T>(el?: MutableRefObject<T>): boolean { const [visible, setVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver((entries) => { setVisible(entries[0].isIntersecting); }); el?.current && observer.observe(el.current); return () => { el?.current && observer.unobserver(el.current); }; }, [el]); return visible; }
Przyjmuje on ref
do elementu, który chcemy obserwować. Zwraca stan true
/false
w zależności od stanu widoczności. Każda zmiana triggeruje rerender komponentu, dlatego zmiany możemy zastosować na żywo. Dzięki funkcji sprzątającej zwracanej z hooka useEffect
, element przestanie być obserwowany gdy komponent zostanie odmontowany.
Hook można łatwo przerobić tak, aby przyjmował id elementu, a nie jego ref
:
function useInViewport(id: string, remainVisible?: boolean): boolean { const [visible, setVisible] = useState(false); useEffect(() => { const element = document.getElementById(id); const observer = new IntersectionObserver((entries) => { setVisible(entries[0].isIntersecting); }); element && observer.observe(element); return () => { element && observer.unobserver(element); }; }, [el]); return visible; }
Czy potrzebujemy tylu IntersectionObserverów
, co elementów?
Jak pewnie zauważyłeś, callback IntersectionObservera
przyjmuje argument entries
, który jest listą elementów. Możemy stworzyć klasę pomocniczą, która zadba o to, aby istniał tylko jeden IntersectionObserver
, który obsłuży wszystkie elementy na raz.
interface Listener { el: Element; listener: (isIntersecting: boolean) => void; } class IntersectionObserverRunner { private static listeners: Listener[] = []; private static observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const listener = this.listeners.find( (currentListener) => currentListener.el === entry.target, ); listener?.listener(entry.isIntersecting); }); }); static addListener = ( element: Element, listener: (isIntersecting: boolean) => void, ) => { this.observer?.observe(element); this.listeners.push({ el: element, listener }); }; static removeListener = (element: Element) => { this.observer?.unobserve(element); this.listeners.splice( this.listeners.findIndex( (currentListener) => currentListener.el === element, ), 1, ); }; }
No dobra, co tu się właściwie dzieje?
Klasa posiada metody i pola statyczne, co oznacza, że istnieje ich tylko jedna instancja na cały program. Dzięki temu będzie istniał tylko jeden observer zapisany w klasie IntersectionObserverRunner
Posiada on pole listeners
które jest listą obiektów typu Listener
. Każdy z nich posiada element drzewa DOM oraz listener, czyli funkcja, która zostanie uruchomiona gdy element znajdzie się w obszarze widzenia lub z niego zniknie,
W polu observer
znajduje się zainicjalizowana instancja klasy IntersectionObserver
wraz z callbackiem. Callback ten iteruje po wszystkich elementach entries
. Dla każdego z nich znajduje własny listener
na liście listeners
i uruchamia przyporządkowaną mu funkcję.
Dodatkowo, okreslone zostały dwie dodatkowe funkcje:
addListener
- przyjmuje element drzewa DOM oraz funkcję, która ma być dla niego uruchamiana. Dodajelistner
do listy wszystkichlistenerów
i podpina element doIntersectionObservera
removeListener
- usuwalistener
z listy i odłącza element odIntersectionObservera
Teraz możemy dostosować nasz poprzedni hook do klasy IntersectionObserverRunner
:
function useInViewport<T>( el?: MutableRefObject<T>, remainVisible?: boolean, ): boolean { const [visible, setVisible] = useState(false); useEffect(() => { el?.current && IntersectionObserverRunner.addListener(el.current, (isVisible) => setVisible(isVisible || !!remainVisible), ); return () => { el?.current && IntersectionObserverRunner.removeListener(el.current); }; }, [el, remainVisible]); return visible; }
Hook ten działa podobnie do poprzedniego, jednak zamiast tworzyć nowy IntersectionObserver
za każdym wykorzystaniem, używa tego już istniejącego. Różnicą jest dodatkowy parametr remainVisible
- gdy zostanie przekazany jako true
, stan widoczności przestanie się aktualizować po pierwszym pojawieniu się w obszarze widzialnym przez użytkownika - dzięki temu możemy sterować wykonaniem animacji tak, aby uruchomiła się tylko raz.