TwitterのiOSアプリには、私がとても気に入っているローディングアニメーションがあります。
アプリの準備が整うと、Twitterのロゴが楽しく拡大し、アプリが現れます。
このローディングアニメーションをReact Nativeで再現する方法を考え出したいと思いました。
それをどのように作るかを理解するためには、まずローディングアニメーションの異なる部分を理解する必要がありました。その微妙な違いを見る一番簡単な方法は、スロー再生することです。
これを構築するためには、いくつか主要な要素を理解する必要があります。
- 鳥を拡大する。
- 鳥が大きくなるにつれて、その下のアプリが表示される
- 最後にアプリをわずかに縮小する
このアニメーションの作り方を理解するのに、かなりの時間がかかりました。
当初、私は、青い背景とTwitterの鳥がアプリの「上」のレイヤーになっていて、鳥が大きくなるにつれて透明になり、下のアプリが現れるという「間違った」仮定から始めました。このアプローチは、Twitterの鳥が透明になると青いレイヤーが見えてしまい、下のアプリが見えないため機能しません!
幸運なことに、読者の皆さんは私と同じようなフラストレーションを経験する必要はありません。この素敵なチュートリアルで、良いところだけをすぐに学べます!
正しい方法
コードに入る前に、これをどのように分解するかを理解することが重要です。この効果を視覚化するために、CodePenで再現しました(数段落後に埋め込まれています)。これにより、異なるレイヤーをインタラクティブに確認できます。
このエフェクトには主に3つのレイヤーがあります。1つ目は青い背景レイヤーです。これはアプリの上に表示されているように見えますが、実際には背面にあります。
次に、真っ白なレイヤーがあります。そして最後に、一番手前に私たちのアプリがあります。
このアニメーションの主な秘訣は、Twitterロゴをmaskとして使用し、アプリと白いレイヤーの両方をマスクすることです。マスキングの詳細については深く掘り下げません。それについてはたくさんのリソースがオンラインにあります。
この文脈でのマスキングの基本は、マスクの不透明なピクセルがマスキング対象のコンテンツを表示し、透明なピクセルがマスキング対象のコンテンツを隠す画像を持つことです。
私たちはTwitterのロゴをマスクとして使用し、真っ白なレイヤーとアプリレイヤーの2つのレイヤーをマスキングします。
アプリを表示させるために、マスクを画面全体より大きくなるまで拡大します。
マスクが拡大する間、アプリレイヤーの不透明度をフェードインさせ、アプリを表示し、その背後にあるソリッドな白いレイヤーを隠します。効果を完成させるために、アプリレイヤーをスケール > 1 で開始し、アニメーションの終了時に1まで縮小させます。その後、二度と見られることのない非アプリレイヤーを非表示にします。
百聞は一見に如かずと言いますが、インタラクティブな視覚化はどれほどの価値があるでしょうか?「Next Step」ボタンでアニメーションをクリックして進んでみてください。レイヤーを表示すると、側面からの視点が得られます。グリッドは透明なレイヤーを視覚化するのに役立ちます。
いよいよReact Nativeについて
さてと。何を構築するのか、そしてアニメーションがどのように機能するのかがわかったので、コードに取り掛かりましょう――皆さんが本当にここにいる理由です。
このパズルの主要なピースは、React NativeのコアコンポーネントであるMaskedViewIOSです。
import {MaskedViewIOS} from 'react-native';
<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;
MaskedViewIOSはmaskElementとchildrenというプロパティを受け取ります。子要素は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>;
これにより、以下に見られるレイヤーが得られます。
さて、アニメーション部分について
これを機能させるために必要なすべてのピースが揃いました。次のステップはそれらをアニメーション化することです。このアニメーションを良い感じにするために、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.Valueを使用するスタイルオブジェクトを使用できるのは、Animated.View、Animated.Text、およびAnimated.Imageだけであることに注意してください。
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になるでしょう。