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

React NativeでTwitterのアプリ読み込みアニメーションを実装する

·11分の読み物
Eli White
Eli White
Metaのソフトウェアエンジニア

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

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

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


これをどのように構築するかを理解するために、最初にローディングアニメーションのさまざまな部分を理解する必要がありました。微妙な部分を見る最も簡単な方法は、アニメーションを遅くすることです。

これには、構築方法を把握する必要があるいくつかの主要な部分があります。

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

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

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

幸運なことに、親愛なる読者の皆さん、私が経験したのと同じ挫折を経験する必要はありません。あなたは、良い部分にスキップするこの素晴らしいチュートリアルを手に入れます!


正しい方法

コードに入る前に、これをどのように分解するかを理解することが重要です。この効果を視覚化するために、CodePen(いくつかの段落に埋め込まれています)で再現したので、さまざまなレイヤーをインタラクティブに見ることができます。

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

次に、白い無地のレイヤーがあります。そして最後に、最前面にアプリがあります。


このアニメーションの主なトリックは、Twitterのロゴをマスクとして使用し、アプリと白いレイヤーの両方をマスクすることです。マスキングの詳細については、深く掘り下げません。豊富なリソースオンラインで提供されています。

このコンテキストでのマスキングの基本は、マスクの不透明なピクセルがマスクしているコンテンツを表示し、マスクの透明なピクセルがマスクしているコンテンツを非表示にすることです。

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は、propsのmaskElementchildrenを受け取ります。childrenはmaskElementによってマスクされます。マスクは画像である必要はなく、任意のビューにすることができます。上記の例の動作は、青いビューをレンダリングしますが、maskElementからの「基本的なマスク」という単語がある場合にのみ表示されます。私たちは複雑な青いテキストを作成しただけです。

私たちが行いたいのは、青いレイヤーをレンダリングし、その上に、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の欠点は、更新できるプロパティの特定のセット、主に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 = {
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 のロゴが小さくなるまでは不透明のままにする必要があります。公式のアニメーションに基づくと、鳥が拡大の中間地点に達したときに表示を開始し、アニメーション全体の 30% の時点で完全に表示されるようにしたいと考えています。したがって、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.ViewAnimated.TextAnimated.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 で入手できます

さらに読む/エクストラ クレジット

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