Implementacja animacji ładowania aplikacji Twittera w React Native
Ta strona została przetłumaczona przez PageTurner AI (beta). Nie jest oficjalnie zatwierdzona przez projekt. Znalazłeś błąd? Zgłoś problem →
Animacja ładowania w aplikacji Twittera na iOS bardzo mi się podoba.
Gdy aplikacja jest gotowa, logo Twittera rozwija się w przyjemny sposób, odsłaniając aplikację.
Postanowiłem odtworzyć tę animację ładowania za pomocą React Native.
Aby zrozumieć, jak to zbudować, musiałem najpierw rozłożyć animację na części. Najłatwiej dostrzec subtelności, spowalniając ją.
Musimy rozwiązać kilka kluczowych elementów tej animacji.
-
Skalowanie ptaka (logo).
-
Podczas powiększania ptaka – odsłanianie aplikacji znajdującej się pod spodem
-
Lekkie pomniejszenie aplikacji na końcu animacji
Odtworzenie tej animacji zajęło mi sporo czasu.
Początkowo przyjąłem błędne założenie, że niebieskie tło i ptak Twittera stanowią warstwę na wierzchu aplikacji, a podczas powiększania logo stawało się przeźroczyste, odsłaniając aplikację. To podejście nie działa, bo przeźroczyste logo pokazałoby niebieską warstwę, a nie aplikację pod spodem!
Na szczęście dla was, drodzy czytelnicy, oszczędzę wam tych frustracji. W tym poradniku od razu przejdziemy do konkretów!
Prawidłowe rozwiązanie
Zanim przejdziemy do kodu, warto zrozumieć rozkład warstw. Aby zobrazować ten efekt, odtworzyłem go w CodePen (osadzony poniżej), gdzie możecie interaktywnie zobaczyć poszczególne warstwy.
Efekty tworzą trzy główne warstwy. Pierwsza to niebieska warstwa tła. Choć wydaje się być na wierzchu aplikacji, tak naprawdę znajduje się z tyłu.
Następnie mamy jednolitą białą warstwę. Na samym przodzie znajduje się nasza aplikacja.
Kluczowa sztuczka to użycie logo Twittera jako mask, która zakrywa zarówno aplikację, jak i białą warstwę. Nie będę szczegółowo omawiał maskowania — w sieci znajdziecie mnóstwo zasobów na ten temat.
Podstawowa zasada maskowania: nieprzeźroczyste piksele maski odsłaniają zawartość, którą zakrywają, a przeźroczyste piksele maski ją ukrywają.
Logo Twittera służy jako maska dla dwóch warstw: jednolitej białej warstwy i warstwy aplikacji.
Aby odsłonić aplikację, powiększamy maskę, aż przekroczy rozmiar całego ekranu.
Podczas powiększania maski zwiększamy przezroczystość warstwy aplikacji, odsłaniając ją i ukrywając białą warstwę pod spodem. Na koniec efektu: warstwę aplikacji początkowo skalujemy do rozmiaru >1, a następnie zmniejszamy do 1 pod koniec animacji. Potem ukrywamy warstwy nie-aplikacyjne, bo nie będą już widoczne.
Mówi się, że obraz wart jest 1000 słów. A ile słów warta jest interaktywna wizualizacja? Klikajcie przycisk "Next Step", by przejść przez animację. Widok warstw pokazuje perspektywę boczną. Siatka pomaga wizualizować przeźroczyste warstwy.
Czas na React Native
Dobra. Skoro wiemy, co budujemy i jak działa animacja, pora przejść do kodu — czyli tego, po co tu właściwie jesteście.
Kluczowym elementem jest MaskedViewIOS, rdzenny komponent React Native.
import {MaskedViewIOS} from 'react-native';
<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;
MaskedViewIOS przyjmuje właściwości maskElement i children. Elementy children są maskowane przez maskElement. Warto zauważyć, że maska nie musi być obrazem – może to być dowolny widok. W powyższym przykładzie efektem będzie wyrenderowanie niebieskiego widoku, który będzie widoczny tylko tam, gdzie znajdują się słowa "Basic Mask" z maskElement. Właśnie stworzyliśmy skomplikowany niebieski tekst.
Chcemy wyrenderować naszą niebieską warstwę, a na wierzchu umieścić naszą maskowaną warstwę aplikacji i białą warstwę z logo Twittera.
{
fullScreenBlueLayer;
}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Image source={twitterLogo} />
</View>
}>
{fullScreenWhiteLayer}
<View style={{flex: 1}}>
<MyApp />
</View>
</MaskedViewIOS>;
Da nam to warstwy widoczne poniżej.
Teraz część animowana
Mamy już wszystkie elementy potrzebne do działania, następnym krokiem jest ich animowanie. Aby animacja była płynna, użyjemy Animated API React Native.
Animated pozwala nam deklaratywnie definiować animacje w JavaScript. Domyślnie te animacje działają w JavaScripcie i mówią warstwie natywnej, jakie zmiany wprowadzać w każdej klatce. Mimo że JavaScript będzie próbował aktualizować animację w każdej klatce, prawdopodobnie nie będzie w stanie robić tego wystarczająco szybko, co spowoduje utratę klatek (jank). Tego właśnie nie chcemy!
Animated ma specjalne zachowanie, które pozwala uniknąć tego problemu. Flaga useNativeDriver wysyła definicję animacji z JavaScriptu do warstwy natywnej na początku animacji, pozwalając stronie natywnej przetwarzać aktualizacje bez konieczności komunikacji z JavaScriptem w każdej klatce. Ograniczeniem useNativeDriver jest to, że można animować tylko określony zestaw właściwości, głównie transform i opacity. Nie można animować np. koloru tła za pomocą useNativeDriver – przynajmniej na razie. Z czasem dodamy więcej możliwości, a tymczasem zawsze możesz złożyć PR z brakującymi właściwościami dla swojego projektu, co przysłuży się całej społeczności 😀.
Ponieważ zależy nam na płynności animacji, będziemy działać w ramach tych ograniczeń. Więcej o działaniu useNativeDriver możesz przeczytać w naszym poście na blogu.
Dekonstrukcja naszej animacji
Nasza animacja składa się z 4 elementów:
-
Powiększenie ptaka, odsłaniając aplikację i jednolitą białą warstwę
-
Wygaszenie aplikacji (fade in)
-
Pomniejszenie aplikacji
-
Ukrycie białej i niebieskiej warstwy po zakończeniu
W Animated mamy dwa główne sposoby definiowania animacji. Pierwszy to Animated.timing, który pozwala określić dokładny czas trwania animacji wraz z krzywą wygładzania ruchu. Drugi to fizyczne API jak Animated.spring. Z Animated.spring podajesz parametry jak tarcie i napięcie sprężyny, pozwalając fizyce sterować animacją.
Mamy wiele animacji, które mają działać równocześnie i są ze sobą ściśle powiązane. Na przykład chcemy, aby aplikacja zaczęła wygasać w trakcie odsłaniania maski. Ponieważ te animacje są tak powiązane, użyjemy Animated.timing z pojedynczą wartością Animated.Value.
Animated.Value to opakowanie wartości natywnej, której Animated używa do śledzenia stanu animacji. Zazwyczaj wystarczy jedna taka wartość dla całej animacji. Komponenty używające Animated przechowują tę wartość w stanie.
Ponieważ myślę o tej animacji jako o krokach występujących w różnych momentach jej trwania, zaczniemy od wartości Animated.Value równej 0 (0% ukończenia), a kończymy na 100 (100% ukończenia).
Początkowy stan naszego komponentu będzie następujący.
state = {
loadingProgress: new Animated.Value(0),
};
Gdy będziemy gotowi rozpocząć animację, każemy Animated animować tę wartość do 100.
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true, // This is important!
}).start();
Następnie próbuję określić przybliżone wartości dla różnych elementów animacji w poszczególnych etapach. Poniższa tabela przedstawia różne komponenty animacji oraz proponowane wartości w miarę postępu czasu.

Maska z logiem Twittera powinna zaczynać od skali 1, potem lekko się zmniejszyć, by następnie gwałtownie powiększyć. Zatem przy 10% animacji powinna mieć wartość skali około 0.8, by na końcu osiągnąć skalę 70. Wybór 70 był dość arbitralny - musiała być na tyle duża, by ptak całkowicie odsłonił ekran, a 60 okazało się za małe 😀. Co ciekawe, im wyższa wartość, tym szybciej będzie się wydawało, że maska rośnie, bo musi osiągnąć większy rozmiar w tym samym czasie. Ta wartość wymagała kilku prób, by dobrze współgrała z tym logo. Inne rozmiary logo lub urządzeń będą wymagały innej końcowej skali, by zapewnić pełne odsłonięcie ekranu.
Aplikacja powinna pozostać niewidoczna przez dłuższy czas, przynajmniej do momentu, gdy logo Twittera zacznie rosnąć. Bazując na oryginalnej animacji, chcę zacząć ją pokazywać, gdy ptak jest w połowie skali, i w pełni ujawnić dość szybko. Zatem przy 15% zaczynamy ją pokazywać, a przy 30% całej animacji jest już w pełni widoczna.
Skala aplikacji zaczyna się od 1.1 i zmniejsza do normalnej skali pod koniec animacji.
A teraz w kodzie.
To, co zrobiliśmy powyżej, to mapowanie wartości z postępu animacji (w procentach) na wartości poszczególnych elementów. Robimy to w Animated za pomocą .interpolate. Tworzymy 3 różne obiekty stylów - jeden dla każdego elementu animacji - używając wartości interpolowanych na podstawie this.state.loadingProgress.
const loadingProgress = this.state.loadingProgress;
const opacityClearToVisible = {
opacity: loadingProgress.interpolate({
inputRange: [0, 15, 30],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
// clamp means when the input is 30-100, output should stay at 1
}),
};
const imageScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 10, 100],
outputRange: [1, 0.8, 70],
}),
},
],
};
const appScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 100],
outputRange: [1.1, 1],
}),
},
],
};
Mając te obiekty stylów, możemy ich użyć podczas renderowania fragmentu widoku z początku wpisu. Pamiętaj, że tylko Animated.View, Animated.Text i Animated.Image mogą używać obiektów stylów opartych na Animated.Value.
const fullScreenBlueLayer = (
<View style={styles.fullScreenBlueLayer} />
);
const fullScreenWhiteLayer = (
<View style={styles.fullScreenWhiteLayer} />
);
return (
<View style={styles.fullScreen}>
{fullScreenBlueLayer}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Animated.Image
style={[styles.maskImageStyle, imageScale]}
source={twitterLogo}
/>
</View>
}>
{fullScreenWhiteLayer}
<Animated.View
style={[opacityClearToVisible, appScale, {flex: 1}]}>
{this.props.children}
</Animated.View>
</MaskedViewIOS>
</View>
);
Jupi! Elementy animacji wyglądają teraz tak, jak chcemy. Pozostaje tylko posprzątać niebieską i białą warstwę, które nigdy więcej nie będą widoczne.
Aby wiedzieć, kiedy możemy je usunąć, potrzebujemy znać moment zakończenia animacji. Na szczęście Animated.timing w metodzie .start przyjmuje opcjonalne callbacki wykonywane po zakończeniu animacji.
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start(() => {
this.setState({
animationDone: true,
});
});
Mając w state wartość informującą o zakończeniu animacji, możemy zmodyfikować nasze warstwy.
const fullScreenBlueLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenBlueLayer]} />
);
const fullScreenWhiteLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenWhiteLayer]} />
);
Voilà! Animacja działa, a nieużywane warstwy są czyszczone po jej zakończeniu. Odtworzyliśmy animację ładowania aplikacji Twittera!
Chwila, u mnie nie działa!
Spokojnie, drogi czytelniku. Sam nie cierpię, gdy poradniki podają tylko fragmenty kodu bez pełnego źródła.
Ten komponent został opublikowany na npm i jest dostępny na GitHubie jako react-native-mask-loader. Aby przetestować na telefonie, wersja na Expo jest dostępna tutaj:
Dalsza lektura / Zadania dodatkowe
-
Ta książka to świetne źródło wiedzy o Animated po przeczytaniu dokumentacji React Native.
-
Oryginalna animacja Twittera przyspiesza odsłanianie maski pod koniec. Spróbuj zmodyfikować loader, używając innej funkcji easingu (lub springa!), by lepiej odwzorować to zachowanie.
-
Obecna końcowa skala maski jest zakodowana na sztywno i może nie odsłaniać całej aplikacji na tablecie. Obliczanie końcowej skali na podstawie rozmiaru ekranu i obrazu byłoby świetnym PR-em.
