TwitterのiOSアプリには、私がとても気に入っているローディングアニメーションがあります。
アプリの準備が整うと、Twitterのロゴが楽しく拡大し、アプリが現れます。
このローディングアニメーションをReact Nativeで再現する方法を考え出したいと思いました。
それをどのように作るかを理解するためには、まずローディングアニメーションの異なる部分を理解する必要がありました。その微妙な違いを見る一番簡単な方法は、スロー再生することです。
これを構築するためには、いくつか主要な要素を理解する必要があります。
- 鳥を拡大する。
- 鳥が大きくなるにつれて、その下のアプリが表示される
- 最後にアプリをわずかに縮小する
このアニメーションの作り方を理解するのに、かなりの時間がかかりました。
私は、青い背景とTwitterの鳥がアプリの_上_のレイヤーであり、鳥が大きくなるにつれて透明になり、下のアプリが現れるという_誤った_仮定から始めました。このアプローチは機能しません。なぜなら、Twitterの鳥が透明になると、下のアプリではなく青いレイヤーが表示されるからです!
幸運なことに、読者の皆さんは私と同じようなフラストレーションを経験する必要はありません。この素敵なチュートリアルで、良いところだけをすぐに学べます!
正しい方法
コードに入る前に、これをどのように分解するかを理解することが重要です。この効果を視覚化しやすくするために、CodePen(数パラグラフに埋め込まれています)で再作成したので、さまざまなレイヤーをインタラクティブに確認できます。
このエフェクトには主に3つのレイヤーがあります。1つ目は青い背景レイヤーです。これはアプリの上に表示されているように見えますが、実際には背面にあります。
次に、真っ白なレイヤーがあります。そして最後に、一番手前に私たちのアプリがあります。
このアニメーションの主なトリックは、Twitterのロゴをmask
として使用し、アプリと白いレイヤーの両方をマスクすることです。マスキングの詳細については深くは触れません。これについては多くのリソースがオンラインにあります。
この文脈でのマスキングの基本は、マスクの不透明なピクセルがマスキング対象のコンテンツを表示し、透明なピクセルがマスキング対象のコンテンツを隠す画像を持つことです。
私たちはTwitterのロゴをマスクとして使用し、真っ白なレイヤーとアプリレイヤーの2つのレイヤーをマスキングします。
アプリを表示させるために、マスクを画面全体より大きくなるまで拡大します。
マスクが拡大する間、アプリレイヤーの不透明度を徐々にフェードインさせ、アプリを表示し、その背後にある実線白色レイヤーを非表示にします。効果を完成させるために、アプリレイヤーをスケール>1で開始し、アニメーションが終了するにつれて1にスケールダウンします。その後、非アプリレイヤーは二度と表示されないため非表示にします。
百聞は一見に如かず、と言いますが、インタラクティブな視覚化はどれほどの価値があるでしょうか?「次へ」ボタンでアニメーションを辿ってみましょう。レイヤーを表示すると、横からの視点が得られます。グリッドは透明なレイヤーを視覚化するのに役立ちます。
さて、React Nativeへ
さてと。何を構築するのか、そしてアニメーションがどのように機能するのかがわかったので、コードに取り掛かりましょう――皆さんが本当にここにいる理由です。
このパズルの主要なピースは、React NativeのコアコンポーネントであるMaskedViewIOSです。
import {MaskedViewIOS} from 'react-native';
<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;
MaskedViewIOS
は、maskElement
とchildren
のpropsを取ります。子要素はmaskElement
によってマスクされます。マスクは画像である必要はなく、任意のビューで構いません。上記の例の動作は、青いビューをレンダリングするが、maskElement
の「Basic Mask」という文字がある部分のみで可視化されるというものです。これで複雑な青いテキストができました。
私たちがやりたいことは、青いレイヤーをレンダリングし、その上にTwitterロゴでマスクされたアプリと白いレイヤーをレンダリングすることです。
{
fullScreenBlueLayer;
}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Image source={twitterLogo} />
</View>
}>
{fullScreenWhiteLayer}
<View style={{flex: 1}}>
<MyApp />
</View>
</MaskedViewIOS>;
これにより、以下に見られるレイヤーが得られます。
さて、Animatedの部分へ
これを機能させるために必要なすべてのピースが揃いました。次のステップはそれらをアニメーション化することです。このアニメーションを良い感じにするために、React NativeのAnimated APIを活用します。
AnimatedはJavaScriptでアニメーションを宣言的に定義することを可能にします。デフォルトでは、これらのアニメーションはJavaScriptで実行され、すべてのフレームでネイティブレイヤーに変更を指示します。JavaScriptはすべてのフレームでアニメーションを更新しようとしますが、それを十分に速く行うことができず、フレーム落ち(カクつき)が発生する可能性があります。これは私たちが望んでいるものではありません!
Animatedには、このジャンクなしでアニメーションを実行できるようにする特別な動作があります。AnimatedにはuseNativeDriver
というフラグがあり、アニメーションの開始時にJavaScriptからネイティブにアニメーション定義を送信することで、ネイティブ側がすべてのフレームでJavaScriptに戻る必要なくアニメーションの更新を処理できるようにします。useNativeDriver
の欠点は、transform
とopacity
など、特定のプロパティセットしか更新できないことです。useNativeDriver
で背景色のようなものをアニメーション化することはできません。少なくとも今のところはできませんが、時間の経過とともにさらに追加していきます。もちろん、プロジェクトに必要なプロパティについてはいつでもプルリクエストを送信して、コミュニティ全体に貢献することができます 😀。
このアニメーションをスムーズにしたいので、これらの制約の中で作業します。useNativeDriver
がどのように機能するかの詳細については、発表ブログ記事をご覧ください。
アニメーションの分解
私たちのアニメーションには4つの構成要素があります。
- 鳥を拡大し、アプリと真っ白なレイヤーを表示する
- アプリをフェードインさせる
- アプリを縮小する
- 完了したら、白いレイヤーと青いレイヤーを非表示にする
Animatedには、アニメーションを定義する主な方法が2つあります。1つ目は、Animated.timing
を使用する方法です。これは、アニメーションの実行時間を正確に指定でき、動きを滑らかにするためのイージングカーブも設定できます。もう1つのアプローチは、Animated.spring
などの物理ベースのAPIを使用する方法です。Animated.spring
では、バネの摩擦量や張力などのパラメータを指定し、物理エンジンにアニメーションを実行させます。
複数のアニメーションを同時に実行させたいのですが、それらはすべて密接に関連しています。たとえば、マスクが表示途中の間にアプリのフェードインを開始させたいとします。これらのアニメーションは密接に関連しているため、単一のAnimated.Value
でAnimated.timing
を使用します。
Animated.Value
は、Animatedがアニメーションの状態を知るために使用するネイティブ値のラッパーです。通常、完全なアニメーションに対してはこれを1つだけ持つのが望ましいです。Animatedを使用するほとんどのコンポーネントは、この値をstateに保存します。
私はこのアニメーションを、完全なアニメーションの進行に沿って異なる時点で発生するステップとして考えているので、Animated.Value
を0(0%完了を表す)から始め、値を100(100%完了を表す)で終了させます。
コンポーネントの初期stateは次のようになります。
state = {
loadingProgress: new Animated.Value(0),
};
アニメーションを開始する準備ができたら、Animatedにこの値を100にアニメーションするように指示します。
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start();
それから、アニメーションの異なる部分と、全体の進行の異なる段階でそれらが持つべき値についての大まかな見積もりを考え出します。以下は、アニメーションの異なる部分と、時間の経過とともに異なる時点でそれらの値がどうあるべきかという私の考えの表です。

Twitterの鳥のマスクはスケール1で始まり、サイズが急上昇する前に小さくなります。したがって、アニメーションの10%時点では、スケール値は0.8になり、その後、終了時にスケール70に急上昇します。70という数字は正直かなり恣意的なもので、鳥が画面全体を完全に覆うのに十分な大きさが必要で、60では不十分でした😀。ただし、この部分で興味深いのは、数字が高いほど、同じ時間内にそこに到達する必要があるため、成長が速く見えることです。この数字は、このロゴで見た目を良くするために試行錯誤を繰り返しました。異なるサイズのロゴ/デバイスでは、画面全体が明らかになるように、この最終スケールを異なるものにする必要があります。
アプリはしばらくの間、少なくともTwitterのロゴが小さくなる間は不透明な状態を保つべきです。公式アニメーションに基づくと、鳥が拡大する途中の段階で表示を開始し、かなり早く完全に表示させたいと考えています。したがって、15%で表示を開始し、全体のアニメーションの30%で完全に表示されます。
アプリのスケールは1.1から始まり、アニメーションの終わりまでに通常のスケールに縮小します。
そして、コードへ。
本質的に私たちが行ったことは、アニメーションの進捗パーセンテージからの値を個々の部分の値にマッピングしたことです。それをAnimatedで.interpolate
を使って行います。this.state.loadingProgress
に基づいた補間値を使用して、アニメーションの各部分に対して3つの異なるスタイルオブジェクトを作成します。
const loadingProgress = this.state.loadingProgress;
const opacityClearToVisible = {
opacity: loadingProgress.interpolate({
inputRange: [0, 15, 30],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
}),
};
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],
}),
},
],
};
これらのスタイルオブジェクトができたので、投稿の冒頭にあったビューのコードスニペットをレンダリングするときにそれらを使用できます。Animated.View
、Animated.Text
、Animated.Image
のみが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>
);
やった!これでアニメーションの各部分が望み通りに見えるようになりました。あとは、二度と表示されない青と白のレイヤーをクリーンアップするだけです。
いつクリーンアップできるかを知るためには、アニメーションがいつ完了したかを知る必要があります。幸いなことに、Animated.timing
を呼び出す場所で、.start
はアニメーション完了時に実行されるオプションのコールバックを受け取ります。
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start(() => {
this.setState({
animationDone: true,
});
});
これで、アニメーションが完了したかどうかを知るための値がstate
に入ったので、青と白のレイヤーを修正してそれを使用できます。
const fullScreenBlueLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenBlueLayer]} />
);
const fullScreenWhiteLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenWhiteLayer]} />
);
できました!これでアニメーションが機能し、アニメーションが完了したら不要なレイヤーをクリーンアップします。Twitterアプリのローディングアニメーションを構築しました!
でも待って、私のものは動かない!
心配しないでください、読者の皆さん。私も、ガイドがコードの一部しか提供せず、完成したソースコードをくれないときは嫌なものです。
このコンポーネントはnpmに公開されており、GitHubではreact-native-mask-loaderとして公開されています。お使いの電話で試すには、Expoで利用可能です。
- React Nativeのドキュメントを読んだ後、Animatedについてさらに学ぶにはこのgitbookが素晴らしいリソースです。
- 実際のTwitterアニメーションは、最後に近づくにつれてマスクの表示が速くなるようです。その動作によりよく一致するように、異なるイージング関数(またはspring!)を使用するようにローダーを修正してみてください。
- 現在のマスクの最終スケールはハードコードされており、タブレットではアプリ全体を表示できない可能性があります。画面サイズと画像サイズに基づいて最終スケールを計算することは、素晴らしいPRになるでしょう。