Fizyka na frontendzie. Jak sprężyny mogą pomóc w tworzeniu efektownych animacji
Zanim jeszcze całkiem pochłonął mnie front-end, miałem okazję studiować fizykę. Nie zawsze było mi z nią po drodze, ale muszę jej oddać to, że żadna inna nauka jaką znam, nie jest w stanie tak opisać otaczającego nas świata jak ona. W aplikacjach internetowych natomiast od zawsze pasjonowały mnie animacje. Internet jest przepełniony ich kreatywnymi przykładami, a ja za każdym razem, gdy na taki przykład trafiam, zastanawiam się: Jak coś takiego zrobić? Jak taka animacja wpływa na użytkownika aplikacji? Co mogę zrobić, żeby wyglądała bardziej naturalnie i przyjaźnie?
Mikołaj Żywczok. Z wykształcenia fizyk, z zamiłowania programista. Obecnie front-end developer w Ericsson. Poza kodowaniem fascynuje go design, UX i dobra kawa. Prywatnie mąż, pasjonat sztuk walki, akrobatyki i gry na gitarze.
Odpowiedzi na wiele z moich pytań znalazłem w dniu, w którym natrafiłem na bibliotekę react-spring
. Pozwala ona tworzyć piękne animacje, wykorzystując do tego fizyczne właściwości sprężyny. Animacja stworzona w ten sposób jest płynna i naturalna – znacznie lepiej oddaje ruch, do którego jesteśmy przyzwyczajeni obserwując otaczający nas świat.
Spis treści
CSS vs. react-spring – porównajmy animacje
Żeby nie być gołosłownym, porównajmy dwie analogiczne animacje, z których pierwsza została stworzona tradycyjnie za pomocą CSS, a druga korzystając z react-spring
:
Animacja stworzona w CSS w sumie jest w porządku, ale „szału nie robi” i wygląda raczej sztucznie. Brakuje jej płynności i naturalności, którą widać na drugiej animacji; tego magicznego składnika, który sprawia, że coś wygląda dla nas lepiej nawet jeśli nie zawsze potrafimy wytłumaczyć, dlaczego. Są to detale, o których rzadko myślimy, pracując nad dużymi projektami, ale najczęściej to właśnie one zadecydują o tym, czy nasz produkt zostanie przez użytkownika pokochany, czy będzie on w jego odczuciu jedynie poprawny i funkcjonalny.
W Ericsson zależy nam na tym, żeby produkty, które tworzymy były wzorcowe i dlatego każdy detal ma znaczenie. Skoro wciąż czytasz ten tekst (co mnie bardzo cieszy), to założę się, że myślisz podobnie.
Zmienne CSS i react-spring, jakie są różnice w modelowaniu?
Żeby lepiej zrozumieć skąd biorą się te różnice w odbiorze, przyjrzyjmy się sposobowi opisu animacji tworzonej za pomocą CSS oraz react-spring
. Zmienne w CSS, które możemy wykorzystać do manipulowania animacją to:
- Duration – określa czas trwania liczony od początku do końca animacji.
- Easing – parametr pozwalający przyspieszyć lub zwolnić animacje odpowiednio na jej początku bądź końcu.
To wszystko. Tworząc animację z wykorzystaniem wyłączenie CSS-a możemy jedynie określić,
w którymś momencie przyspieszyć bądź zwolnić. W przypadku biblioteki react-spring
, która do opisu animacji korzysta z modelu matematycznego sprężyny, mamy trzy parametry służące do modelowania naszej animacji:
1. Masa – odnosi się do wagi poruszającego się elementu. Cięższe elementy będą poruszać się wolniej, ale z większą bezwładnością.
2. Napięcie – odnosi się do energii skumulowanej podczas naciągania sprężyny. Im większa jej wartość, tym animacja będzie bardziej dynamiczna i sprężysta.
3. Tarcie – dotyczy nie tyle samej sprężyny co otoczenia, w jakim się ona znajduje. Przykładowo, naciągnięta sprężyna zanurzona w smole będzie przez nią skutecznie hamowana (duże tarcie), przez co ruch szybko się zakończy. Gdybyśmy natomiast wysłali ją w kosmos, gdzie znalazłaby się w próżni (zerowe tarcie) to jej ruch trwałby w nieskończoność.
Myśląc o zastosowaniu fizycznych właściwości sprężyny w tworzeniu animacji wydawałoby się, że będziemy w stanie uzyskać w ten sposób jedynie skoczne, sprężynujące efekty, ale nic bardziej mylnego, możliwości tworzenia animacji z react-spring
są znacznie większe! Zobaczcie sami:
React-spring tutorial – stwórzmy własną animację!
A teraz, uzbrojeni w całą potrzebną teorię, zobaczmy jak z tradycyjnej animacji CSS zrobić coś wyjątkowego!
import React from "react"; const Raise = ({ height = 0, timing = 150, children }) => { const [isRaised, setIsRaised] = React.useState(false); const style = { display: "inline-block", backfaceVisibility: "hidden", transform: isRaised ? `translateY(-${height}px)` : "translateY(0)", transition: `transform ${timing}ms` }; React.useEffect(() => { if (!isRaised) { return; } const timeoutId = window.setTimeout(() => { setIsRaised(false); }, timing); return () => window.clearTimeout(timeoutId); }, [isRaised, timing]); const trigger = () => setIsRaised(true); return ( <span onMouseEnter={trigger} style={style}> {children} </span> ); }; export default Raise;
Codesandbox: Raise.js
Uff, trochę tego jest, ale spokojnie, zaraz przejdziemy przez niego i wyjaśnimy sobie, o co w nim chodzi. Animacja polega na tym, że po najechaniu kursorem myszy na dany element, ten się unosi. Dodatkowo, w tym samym momencie rusza timer, który po upływie określonego czasu daje sygnał elementowi, żeby ten wrócił do swojego stanu początkowego (nawet jeżeli nasz kursor wciąż znajduje się nad elementem). Informacja o stanie w jakim znajduje się nasz element przechowywana jest w zmiennej isRaised.
import React from "react"; const Raise = ({ height = 0, timing = 150, children }) => { const [isRaised, setIsRaised] = React.useState(false); const style = { display: "inline-block", backfaceVisibility: "hidden", transform: isRaised ? `translateY(-${height}px)` : "translateY(0)", transition: `transform ${timing}ms` }; React.useEffect(() => { if (!isRaised) { return; } const timeoutId = window.setTimeout(() => { setIsRaised(false); }, timing); return () => window.clearTimeout(timeoutId); }, [isRaised, timing]); const trigger = () => setIsRaised(true); return ( <span onMouseEnter={trigger} style={style}> {children} </span> ); }; export default Raise;
Codesandbox: Raise.js
Najechanie kursorem nad animowany element sprawia, że wartość isRaised
zmienia się na true, co z kolei aktywuje naszą animację, a także hook useEffect
, w którym zostaje nastawiony timer, odliczający czas, po którym wartość zmiennej isRaised
zostaje zmieniona z powrotem na false.
Sam efekt polega na zastosowaniu transform: translateY
. Wysokość, na którą ma się unieść element, a także czas trwania animacji możemy kontrolować za pomocą przesłanych propsów (height
i timing
). Dodatkowo ustawiamy display: inline-block
(na elementach display: inline animacja by nie zadziałała), a także backface-visibility: hidden
(dzięki temu pracę potrzebną do wyrenderowania naszej animacji zrzucamy na procesor graficzny, co z kolei przekłada się zwiększenie jej płynności).
import React from "react"; const Raise = ({ height = 0, timing = 150, children }) => { const [isRaised, setIsRaised] = React.useState(false); const style = { display: "inline-block", backfaceVisibility: "hidden", transform: isRaised ? `translateY(-${height}px)` : "translateY(0)", transition: `transform ${timing}ms` }; React.useEffect(() => { if (!isRaised) { return; } const timeoutId = window.setTimeout(() => { setIsRaised(false); }, timing); return () => window.clearTimeout(timeoutId); }, [isRaised, timing]); const trigger = () => setIsRaised(true); return ( <span onMouseEnter={trigger} style={style}> {children} </span> ); }; export default Raise;
Codesandbox: Raise.js
Animowany element, otaczamy tagiem span. Zapytacie pewnie:
– Dlaczego akurat span skoro, każdy szanujący się frontendowiec wie, że eventy powinny być obsługiwane w widocznych elementach, takich jak choćby button? Przecież span jest niemożliwy do zaznaczenia korzystając z klawiatury, a my chcemy pisać kod dostępny dla każdego!
Odpowiem Wam:
Jednakże, w tym przypadku, animacja, którą rozważamy ma charakter czysto wizualny i podejrzewam, że mogłaby być irytująca dla osób poruszających się po stronie za pomocą klawiatury.
Stworzonego przez nas komponentu możemy użyć w następujący sposób:
import React from "react"; import { Icon } from "react-icons-kit"; import { ic_room } from "react-icons-kit/md/ic_room"; import { ic_thumb_up } from "react-icons-kit/md/ic_thumb_up"; import Raise from "./Raise.js"; import "./styles.css"; export default function App() { return ( <div className="App"> <Raise height={20} timing={200}> <Icon icon={ic_room} size={90} className="Icon" /> </Raise> <Raise height={20} timing={200}> <Icon icon={ic_thumb_up} size={90} className="Icon" /> </Raise> </div> ); }
Codesandbox: App.js
Wszystko wyjaśnione, wiemy już jak działa nasz kod, a animacja wygląda w porządku. Jest nieźle, ale wiem, że stać nas na więcej! Doprawmy naszą animację odrobiną magii z pomocą react-spring
.
import React from "react"; import { animated, useSpring } from "react-spring"; const Raise = ({ height = 0, timing = 150, children }) => { const [isRaised, setIsRaised] = React.useState(false); const style = useSpring({ display: "inline-block", backfaceVisibility: "hidden", transform: isRaised ? `translateY(-${height}px)` : "translateY(0px)", config: { mass: 1, tension: 400, friction: 15 } }); React.useEffect(() => { if (!isRaised) { return; } const timeoutId = window.setTimeout(() => { setIsRaised(false); }, timing); return () => { window.clearTimeout(timeoutId); }; }, [isRaised, timing]); const trigger = () => { setIsRaised(true); }; return ( <animated.span onMouseEnter={trigger} style={style}> {children} </animated.span> ); }; export default Raise;
Codesandbox: Raise.js
Co się zmieniło? Po pierwsze, z biblioteki react-spring
importujemy useSpring
i animated
. Hook useSpring pozwala nam opisać animacje korzystając z modelu matematycznego sprężyny, zamiast używanych w CSS krzywych Béziera. Jako parametr przesyłamy do niego obiekt z naszymi lekko zmodyfikowanymi stylami (linijkę z kodem transition: transform
, zamieniamy na obiekt config
, zawierający w sobie informacje na temat masy, napięcia i tarcia). Jako że opisywanie animacji w ten sposób, nie jest wspierane przez przeglądarki (jeszcze), nie możemy takiego obiektu przekazać bezpośrednio tagowi <span>
. Tutaj do akcji wkracza animated, który możemy wykorzystać do stworzenia tagu <animated.span>
. Działa on w sposób analogiczny do zwykłego <span> z tą jednak różnicą, że potrafi rozszyfrować nasz nowy sposób opisu animacji. Oto efekt końcowy, gratulacje!
Jakie jeszcze możliwości daje react-spring?
Przykładowo, nasz komponent można również prostym sposobem przerobić na hook, co przyda nam się szczególnie w przypadku, w którym mając dwa różne elementy. Chcielibyśmy, żeby jeden z nich aktywował animacje tego drugiego (co w przypadku naszego aktualnego komponentu jest niestety niemożliwe).
import React from "react"; import { useSpring } from "react-spring"; const useRaise = ({ height = 10, timing = 200, springConfig = { mass: 1, tension: 400, friction: 15 } }) => { const [isRaised, setIsRaised] = React.useState(false); const style = useSpring({ display: "inline-block", backfaceVisibility: "hidden", transform: isRaised ? `translateY(-${height}px)` : "translateY(0px)", config: springConfig }); React.useEffect(() => { if (!isRaised) { return; } const timeoutId = window.setTimeout(() => { setIsRaised(false); }, timing); return () => window.clearTimeout(timeoutId); }, [isRaised, timing]); const trigger = () => setIsRaised(true); return [style, trigger]; }; export default useRaise;
Codesandbox: useRaise.js
Do naszego hooka całą konfigurację animacji przesyłamy w parametrze, on przeprowadza dla nas odpowiednie obliczenia, a następnie zwraca tablicę zawierającą obiekt ze stylem animacji oraz trigger, za pomocą którego możemy ją aktywować. Zostało nam już tylko go użyć:
import React from "react"; import { animated } from "react-spring"; import { Icon } from "react-icons-kit"; import Button from "@material-ui/core/Button"; import { ic_thumb_up } from "react-icons-kit/md/ic_thumb_up"; import useRaise from "./useRaise.js"; import "./styles.css"; export default function App() { const [style, trigger] = useRaise({ height: 10, timing: 200 }); return ( <div className="App"> <animated.span style={style}> <Icon icon={ic_thumb_up} size={64} className="Icon" /> </animated.span> <Button onClick={trigger} className="Button"> Click me! </Button> </div> ); }
Codesandbox: App.js
Obiekt style
przypisujemy do elementu, który chcemy animować, a trigger do elementu, który ma tę animację wywołać i gotowe. A teraz najlepsze – nasz hook jest ekstra, ale jeśli woleliśmy używać wcześniejszego komponentu, a sytuacja nam na to pozwala, to przecież wcale nie musimy z niego rezygnować. Dodatkowo, wykorzystując nasz hook możemy go odrobinę odchudzić.
import React from "react"; import { animated } from "react-spring"; import useRaise from "./useRaise"; const Raise = ({ children, ...animationConfig }) => { const [style, trigger] = useRaise(animationConfig); return ( <animated.span style={style} onMouseEnter={trigger}> {children} </animated.span> ); }; export default Raise;
Codesandbox: Raise.js
W efekcie możemy korzystać z naszej animacji na dwa sposoby, a cały kod pozostaje zwięzły i wolny od niepotrzebnych duplikatów. W moim odczuciu react-spring
sprawia wrażenie biblioteki bardzo dobrze przemyślanej – jest wydajna, prosta w obsłudze i wszechstronna. Można z niej korzystać zarówno poprzez nowoczesne hook API (tak jak zrobiliśmy to my), jak i class API, w zależności od preferencji i potrzeb developera. Całym sobą zachęcam do eksperymentowania z tą biblioteką, ponieważ jej potencjał jest ogromny (po odrobinę inspiracji odsyłam do sekcji z przykładami na oficjalnej stronie).
Na zakończenie…
Dziękuję za czas, który mi poświęciłeś (mam nadzieje, że w twoim odczuciu nie był to czas stracony). Daj proszę znać, co myślisz na temat tego tekstu w komentarzu lub pisząc do mnie na LinkedInie, a jeśli w trakcie czytania nasunęły Ci się jakieś pytania, też chętnie na nie odpowiem.
Pomyślnego kodowania!
Zdjęcie główne artykułu pochodzi z unsplash.com.