メインコンテンツへスキップ

React NativeでTwitterのアプリ起動アニメーションを実装する

·12分で読めます
Eli White
Eli White
ソフトウェアエンジニア @ Meta

TwitterのiOSアプリには、私がとても気に入っているローディングアニメーションがあります。

アプリの準備が整うと、Twitterのロゴが楽しく拡大し、アプリが現れます。

このローディングアニメーションをReact Nativeで再現する方法を考え出したいと思いました。


それをどのように作るかを理解するためには、まずローディングアニメーションの異なる部分を理解する必要がありました。その微妙な違いを見る一番簡単な方法は、スロー再生することです。

これを構築するためには、いくつか主要な要素を理解する必要があります。

  1. 鳥を拡大する。
  2. 鳥が大きくなるにつれて、その下のアプリが表示される
  3. 最後にアプリをわずかに縮小する

このアニメーションの作り方を理解するのに、かなりの時間がかかりました。

私は、青い背景とTwitterの鳥がアプリのにあるレイヤーであり、鳥が大きくなるにつれて透明になり、その下のアプリが現れるという間違った仮定から始めました。このアプローチはうまくいきません。なぜなら、Twitterの鳥が透明になると、その下のアプリではなく青いレイヤーが見えてしまうからです!

幸運なことに、読者の皆さんは私と同じようなフラストレーションを経験する必要はありません。この素敵なチュートリアルで、良いところだけをすぐに学べます!


正しい方法

コードに取り掛かる前に、これをどう分解するかを理解することが重要です。このエフェクトを視覚化しやすくするために、CodePenで再現しました(数段落後に埋め込まれています)。これにより、異なるレイヤーをインタラクティブに見ることができます。

このエフェクトには主に3つのレイヤーがあります。1つ目は青い背景レイヤーです。これはアプリの上に表示されているように見えますが、実際には背面にあります。

次に、真っ白なレイヤーがあります。そして最後に、一番手前に私たちのアプリがあります。


このアニメーションの主なトリックは、Twitterのロゴをmaskとして使用し、アプリと白いレイヤーの両方をマスキングすることです。マスキングの詳細については深入りしませんが、それに関する多くのリソースオンラインにあります。

この文脈でのマスキングの基本は、マスクの不透明なピクセルがマスキング対象のコンテンツを表示し、透明なピクセルがマスキング対象のコンテンツを隠す画像を持つことです。

私たちはTwitterのロゴをマスクとして使用し、真っ白なレイヤーとアプリレイヤーの2つのレイヤーをマスキングします。

アプリを表示させるために、マスクを画面全体より大きくなるまで拡大します。

マスクが拡大している間に、アプリレイヤーの不透明度をフェードインさせ、アプリを表示し、その背後にある真っ白なレイヤーを隠します。エフェクトを仕上げるために、アプリレイヤーを1より大きいスケールから開始し、アニメーションが終了するにつれて1に縮小します。その後、二度と表示されることのないアプリ以外のレイヤーを非表示にします。

一枚の絵は千の言葉に値すると言います。では、インタラクティブなビジュアライゼーションはどれほどの言葉に値するでしょうか?「Next Step」ボタンでアニメーションを順に見てください。「Showing the layers」は側面からの視点を提供します。グリッドは透明なレイヤーを視覚化するのに役立ちます。

さて、React Nativeへ

さてと。何を構築するのか、そしてアニメーションがどのように機能するのかがわかったので、コードに取り掛かりましょう――皆さんが本当にここにいる理由です。

このパズルの主要なピースは、React NativeのコアコンポーネントであるMaskedViewIOSです。

import {MaskedViewIOS} from 'react-native';

<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;

MaskedViewIOSは、maskElementchildrenというpropsを受け取ります。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>;

これにより、以下に見られるレイヤーが得られます。

さて、Animatedの部分へ

これを機能させるために必要なすべてのピースが揃いました。次のステップはそれらをアニメーション化することです。このアニメーションを良い感じにするために、React NativeのAnimated APIを活用します。

Animatedを使用すると、アニメーションをJavaScriptで宣言的に定義できます。デフォルトでは、これらのアニメーションはJavaScriptで実行され、毎フレームごとにネイティブレイヤーに変更を加えるよう指示します。JavaScriptは毎フレームアニメーションを更新しようとしますが、十分な速さでそれを行うことができず、フレーム落ち(カクつき)が発生する可能性があります。それは私たちが望むものではありません!

Animatedには、このカクつきなしでアニメーションを実現するための特別な動作があります。AnimatedにはuseNativeDriverというフラグがあり、アニメーションの開始時にアニメーション定義をJavaScriptからネイティブに送信します。これにより、ネイティブ側は毎フレームJavaScriptとの間でやり取りをすることなく、アニメーションの更新を処理できます。useNativeDriverの欠点は、更新できるプロパティが特定のセット、主にtransformopacityに限定されることです。useNativeDriverでは背景色のようなものをアニメーション化することはできません。少なくとも今のところは――私たちは時間とともに追加していきますし、もちろん、あなたのプロジェクトで必要なプロパティがあればいつでもPRを提出して、コミュニティ全体に貢献できます 😀。

このアニメーションを滑らかにしたいので、これらの制約の中で作業します。useNativeDriverが内部でどのように機能するかについての詳細は、それを発表したブログ記事をご覧ください。

アニメーションの分解

私たちのアニメーションには4つの構成要素があります。

  1. 鳥を拡大し、アプリと真っ白なレイヤーを表示する
  2. アプリをフェードインさせる
  3. アプリを縮小する
  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, // This is important!
}).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',
// 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],
}),
},
],
};

これらのスタイルオブジェクトができたので、投稿の前の部分のビュースニペットをレンダリングする際に使用できます。Animated.Valueを使用するスタイルオブジェクトを使用できるのは、Animated.ViewAnimated.TextAnimated.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でこちらから入手できます

さらなる読み物 / 発展課題

  1. React Nativeのドキュメントを読んだ後、Animatedについてさらに学ぶにはこのgitbookが素晴らしいリソースです。
  2. 実際のTwitterアニメーションは、最後に近づくにつれてマスクの表示が速くなるようです。その動作によりよく一致するように、異なるイージング関数(またはspring!)を使用するようにローダーを修正してみてください。
  3. 現在のマスクの最終スケールはハードコードされており、タブレットではアプリ全体を表示できない可能性があります。画面サイズと画像サイズに基づいて最終スケールを計算することは、素晴らしいPRになるでしょう。