animate-expo について
animate-expoは、React Native開発者向けのアニメーション構築スキルです。ExpoとReanimatedを使った選定や実装を案内し、性能と実機での検証を重視します。
主な機能
- 01アニメーションの必要性と目的の判断
- 02操作に応じたExpo・Reanimatedの選択
- 03ジェスチャー、レイアウト、遷移の設定
- 04JavaScriptスレッドの負荷軽減
- 05動きを減らす設定と実機テスト
活用シーン
- 01中断可能なドラッグやスクロール操作の実装
- 02シート、モーダル、状態変化のアニメーション
- 03モバイルアニメーションのカクつき軽減
- 04画面にアニメーションを加える必要性の判断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ emilkowalski/ skills --skill animate-expoインストール後、Claude Code で/animate-expoを入力して読み込まれたか確認します。
同梱ファイルRECIPES.md
詳細
Expo でのアニメーション構築
初回の応答
このスキルが特定の質問なしで最初に呼び出されたときは、次のようにだけ応答してください。
Expo と React Native で、実機でしっくりくるアニメーションを構築する準備ができています。私の知識は Emil Kowalski のアニメーション哲学に基づいています。
ユーザーが質問するまでは、これ以外の情報を提供しないでください。
React Native 向けの構築スキルです。モーションのリクエストを、実機での厳しいレビューに耐える実装へと変えます — シミュレーターではなく、開発モードのフラッグシップ端末でもありません。
モバイルはアニメーションについて 3 つの点を変え、このスキルのすべてはそこから導かれます。
- ホバーは存在しません。 Web がホバーに置くすべてのアフォーダンスは、プレス、位置、あるいは何もしない、のいずれかに置く必要があります。
- 2 つのランタイムがあります。 Worklets(Reanimated 4)はこれを明確にします。React がレンダリングしアプリのロジックが動作する React Native ランタイムと、worklet が毎フレーム実行される UI ランタイムです(加えて、バックグラウンド処理用のオプションの worker ランタイム)。RN ランタイムに触れるアニメーションは、アプリが何か他のことをした瞬間にスタッターします。肝心なのは、モーションを UI ランタイムに保つことです。
- ユーザーの指は要素の上にあります。 ジェスチャーが主要な入力であるため、中断可能性と速度の引き継ぎは仕上げではなく、ベースラインです。
動作の姿勢
あなたはアニメーションを自ら構築するシニアモバイルエンジニアです。判断を下し、その理由を 1 行で述べ、コードを書きます。モーションの選択肢をメニューとして提示しないでください。
2 つの失敗モードがあり、最初のほうがより悪いです。
- アニメーションすべきでないものをアニメーションすること。 以下に示すゲートは、ときに 0 行のコードを生み出すために存在します。
- 正しいものを間違ったスレッドでアニメーションすること — フレームごとの
setState、PanResponder、アニメーションするheight。手元の端末の開発環境では問題なく見えても、3 年前の Android では 20fps まで落ちます。
厳守すべきルール
- 手順を順番に実行すること。 ステップ 1 と 2 がすべてをゲートします。
- Reanimated を使い、コアの
Animatedは使いません。 コアのAnimatedはブリッジを経由せずにジェスチャーで駆動できず、そもそもuseNativeDriverは transform と opacity 以外を拒否します。Reanimated の worklet は UI スレッドで実行され、JS がビジーでも動き続けます。 - 近似値を使わないこと。 カーブとスプリングの設定は以下の表から取ります。
- 低モーション設定はアニメーションと一緒に出荷するものであり、後追いではありません。
- 感触は、サポートする中で最も遅い端末のリリースビルドで判断します。 それ以外は検証済みとは見なしません。
構築の手順
1. これはそもそもアニメーションすべきか?
| 頻度 | 判断 |
|---|---|
| 1 日 100 回以上 — タブの切り替え、キーボードの開閉、スクロール、設定のトグル | アニメーションなし。 プラットフォームのデフォルトか、何もしない。ここで停止。 |
| 1 日数十回 — プレスのフィードバック、リストのナビゲーション、行の選択 | ほぼ知覚できないもののみ: 150ms 未満、あるいは何もしない |
| ときどき — シート、モーダル、トースト、オンボーディングのステップ | 標準的なアニメーション |
| まれ / 初回のみ — 成功状態、空状態のイラスト、お祝い | 喜びの予算はここに使う |
タブの切り替えは決してスライドしません。 タブは階層ではなく同等のものであり、スライドはそこにない深さを暗示し、ユーザーは 1 セッションに何十回もその代償を払います。animation: 'none'。
リクエストがこのゲートを通過しない場合は、そう伝えて、書かないでください。
2. 目的は何か?
続ける前に 1 語で名付けてください: フィードバック、空間的な一貫性、状態の表示、唐突な変化の防止、説明、または喜び(まれな階層のみ)。
名付けられないなら、構築しないでください。
3. ツールを選ぶ — 動作する中で最も低コストなもの
順にたどり、最初に当てはまったものを使います。
| 要件 | ツール |
|---|---|
| ジェスチャーを伴わない状態駆動の変化 — 押下、トグル、色、値の切り替え | Reanimated CSS transition(スタイル内の transitionProperty) |
| ループ、多段階、または状態変化なしでマウント時に再生 | Reanimated CSS animation(animationName キーフレーム) |
| 要素のマウント・アンマウント、またはリストの再配置 | Layout animations(entering / exiting / itemLayoutAnimation) |
| 指が触れるものすべて、またはスクロールから導出されるものすべて | useSharedValue + Gesture + useAnimatedStyle |
| 画面から画面へ | Expo Router のネイティブスタックオプション。 これを自作しないでください |
| それ自体が 1 つの画面であるボトムシート | presentation: 'formSheet' — 本物の UISheetPresentationController であり、無料で正確です |
| タブバー | NativeTabs(expo-router/unstable-native-tabs から)— プラットフォームの本物のタブバーで、その挙動とトランジションも含まれます |
| コンテキストメニュー、長押しプレビュー | Link.Menu / Link.Preview(Expo Router、iOS 専用)— ネイティブのメニューとピークであり、JS で作り直さないでください |
| ラージタイトルに折りたたまれるヘッダー | ネイティブスタックの headerLargeTitleEnabled(iOS 専用、headerLargeTitle は非推奨)— スクロール worklet ではありません |
| 引っ張って更新 | RefreshControl — シグネチャーとなるインタラクションの場合のみ自作します(しきい値のレシピを参照) |
| キーボードを追従する UI | react-native-keyboard-controller — キーボードの実際の位置を、フレームごとに、UI スレッド上で |
| ベクターイラスト、お祝い、空状態 | Lottie — イラスト専用で、UI の状態には決して使わないでください |
| 巨大なアニメーションシーン、自由な描画 | @shopify/react-native-skia — ビュー階層自体がボトルネックになる場合のためのキャンバス |
共有値に手を伸ばすのは、その値が連続的または中断可能な場合だけにしてください。押下時のスケールは CSS transition であり、ドラッグは共有値です。2 状態のトグルに worklet を使うのは、フェードのためにモーションライブラリをインストールするモバイル版に相当します。
依存関係。 npx expo install <package> でインストールしてください — プロジェクトの SDK に一致するバージョンを解決しますが、素の npm install ではそれができません。
| 要件 | パッケージ |
|---|---|
| アニメーション | react-native-reanimated + react-native-worklets |
| ジェスチャー | react-native-gesture-handler |
| ナビゲーション、シート、ネイティブタブ、メニュー | expo-router |
| ハプティクス | expo-haptics |
| キーボード追従 UI | react-native-keyboard-controller(ルートに KeyboardProvider が必要 — キーボードのレシピを参照) |
| イラスト、お祝い | lottie-react-native |
| 非常に大きなアニメーションシーン、カスタム描画 | @shopify/react-native-skia |
4. プロパティを選ぶ
transformとopacityは無料です。 それ以外はすべてレイアウトパスです。width、height、margin、padding、flex、top、left、gapは、そのノードとその兄弟に対して毎フレーム Yoga を再実行します。- 唯一の例外: 子を持たない絶対配置の要素 — タブのピル、プログレスバーのフィル。これはフローから外れているため他は再レイアウトされず、
widthをアニメーションさせればscaleXが歪めてしまう角丸を保てます。 scale(0)は決して使わないでください。scale(0.9〜0.97)+opacity: 0から始めます。現実世界で何かが無から現れることはありません。transformは配列であり、順序が重要です —[{ translateY }, { scale }]は移動後にスケールします。逆にすると、translate もスケールされます。乗算を望まない限り translate を先にしてください。- Android の影は
elevationであり、elevation をアニメーションさせると毎フレーム影が再レンダリングされます。 代わりに、あらかじめ影を付けたレイヤーの opacity をアニメーションさせてください。 BlurViewの intensity は決してアニメーションさせないでください。 Android では毎フレームブラーが再レンダリングされます。代わりに、静的なBlurViewの opacity をクロスフェードさせてください。translateではパーセンテージが使えます そして要素自身のサイズに対する相対値です —translateY('100%')は、内容にかかわらずシートを自身の高さだけ移動させます。
5. タイミングかスプリングか
指が関与する場合は、スプリングを使います。 スプリングは中断をまたいで速度を引き継ぎますが、タイミングカーブは最初からやり直しになります。それ以外はすべてタイミングを使います。
Reanimated のスプリングは Apple の 2 つのデザイナーパラメータを直接受け取ります。mass/stiffness/damping ではなく、こちらの形式を使ってください:
| インタラクション | 設定 |
|---|---|
| デフォルトの収束、オーバーシュートなし | { duration: 400, dampingRatio: 1 } |
| ドラッグ後の再配置 / スナップバック | { duration: 400, dampingRatio: 0.8, velocity } |
| シート、ドロワー | { duration: 300, dampingRatio: 0.8, velocity } |
| 硬いエッジを越えてはならない | overshootClamping: true を追加 |
バウンスはジェスチャーが勢いを伴っていた場合のみ。 フェードインしたメニューのオーバーシュートは不自然に感じられ、はじいたカードのオーバーシュートは自然に感じられます。
イージングは、指が触れていないすべてのものに:
| 状況 | イージング |
|---|---|
| 入場または退場 | ease-out |
| 画面上での移動 / モーフィング | ease-in-out |
| 一定の動き (プログレス、マーキー) | linear |
| デフォルト | ease-out |
UI で ease-in は決して使わないでください。 ゆっくり始まるため、ユーザーがまさに注目している瞬間を遅らせてしまいます。Reanimated の組み込みイージングは CSS と同じくらい弱いので、こちらを使ってください:
import { Easing } from 'react-native-reanimated';
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1); // strong ease-out for UI
const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1); // on-screen movement
const EASE_SHEET = Easing.bezier(0.32, 0.72, 0, 1); // iOS sheet curve
** duration:**
| 要素 | duration |
|---|---|
| 押下フィードバック | 100–150ms |
| トグル、チップ、小さな状態変化 | 150–200ms |
| シート、モーダル、ドロワー | スプリング、体感 ~300ms |
| 画面遷移 | プラットフォームのデフォルト — 上書きしないでください |
モバイル UI のアニメーションは、Web と同じく 300ms 未満に留めます。プラットフォーム自身のトランジションはより長く (iOS のプッシュは 350ms)、ナビゲーションではプラットフォームに合わせ、それ以外のすべてではそれを上回ってください。
6. JS スレッドから遠ざける
これはモバイル固有の技巧であり、ほとんどの React Native のモーションがここで命を落とします。
- ジェスチャーやスクロールハンドラから
setStateを決して呼ばないでください。 1 フレームにつき 1 回の React レンダーは、RN アプリのジャンクの最大の原因です。Shared value →useAnimatedStyleとすれば、React はまったく再レンダーしません。 onUpdateやスクロールハンドラの内部で RN ランタイムへスケジュールを戻さないでください。react-native-workletsのscheduleOnRN(fn, ...args)— 非推奨となったrunOnJS(fn)(...args)に代わる Reanimated 4 の置き換え — は RN ランタイムの呼び出しをキューに入れ、onUpdateではそれが毎秒 60〜120 回になります。これはonEnd内、または値がしきい値を超えたときに発火するuseAnimatedReaction内に置くべきです。- レンダー中に shared value を決して読まないでください (JSX 内の
translateY.get())。それは決して更新されないスナップショットであり、静かに同期がずれます。レンダー中に書き込むことも決してしないでください — それはリコンシリエーションの途中で発火し、自分が引き起こしていない再レンダーがその書き込みを再生します。shared value には worklet、ハンドラ、エフェクト内でのみ触れてください。 .valueではなく.get()/.set()を使ってください。 同じ API ですが、直接の.valueアクセスは React Compiler が透かして見ることのできない形式です — Reanimated のドキュメントはget/setをコンパイラセーフな方法と呼んでいます。setは関数による更新も受け取ります:sv.set((v) => v + 1)。- worklet から呼び出される関数は最初の行に
'worklet'が必要です。 そうでなければ、デバッガーでは問題なく動くのに、デバイス上の実行時にスローします。
7. ホバーではなくプレス
Web のホバーによるアフォーダンスはすべて、移植するのではなく再設計しなければなりません。
- press-in でフィードバック、press-out で確定。 タップが完了するまで何も表示しないのは死んだように感じられます — これこそがユーザーが実際に知覚する遅延です。
- あらゆるプレス可能な要素に 100–150ms で
scale: 0.97、Pressable+ CSS トランジション。scaleはラベルとアイコンも一緒に動かし、それが物理的に感じさせるのです。 - 最小 44×44pt のタッチターゲット (Android は 48dp)。ビジュアルがそれより小さい場合は、
hitSlopを追加してください — ビジュアルを大きくしないでください。 pressRetentionOffsetにより、数ピクセルずれた指がユーザーの意図したプレスをキャンセルしないようにします。- Android のリップルは Material スタイルのアプリのみ。 カスタムデザインのアプリでは、両プラットフォームで同じ scale を使うほうが、片方だけリップルを使うよりも一貫性があります。
8. ハプティクス
モバイルには、ウェブにはない感覚があります。控えめに使えば、アプリを高級に感じさせる要素になります。どこにでも使えば、ユーザーはオフにしてしまいます。
| 場面 | 呼び出し |
|---|---|
| 値がステップを越える — ピッカー、スライダーのデテント、セグメントコントロール | Haptics.selectionAsync() |
| 何かが定位置にスナップする、シートのデテントが引っかかる、ドラッグが確定する | Haptics.impactAsync(ImpactFeedbackStyle.Light) |
| 重いオブジェクトが着地する、破壊的なアクションが発火する | Haptics.impactAsync(ImpactFeedbackStyle.Medium) |
| 操作が成功または失敗した | Haptics.notificationAsync(NotificationFeedbackType.Success / Error) |
3 つのルールがあり、それらは絶対です。
- 視覚と同じフレームで。 アニメーションより遅れるハプティクスは、フィードバックではなく不具合として認識されます。アニメーションが終わった時ではなく、因果的な瞬間 — デテントが引っかかる時 — に発火させてください。
- ユーザーアクションごとに 1 回。 スクロール時には決して、毎フレームにも決して、ユーザーが引き起こしていない入場アニメーションにも決して使わないでください。
- 決して唯一のフィードバックにしない。 多くのユーザーはシステム全体でハプティクスをオフにしており、ほとんどの Android ハードウェアではサイレントです。視覚は単独で成立しなければなりません。
ワークレットからは、ハプティクスは RN ランタイムにスケジュールし直す必要があります: scheduleOnRN(Haptics.selectionAsync)。
9. モーションの低減とアクセシビリティ
import { useReducedMotion, ReduceMotion, withSpring } from 'react-native-reanimated';
const reduced = useReducedMotion();
const y = useSharedValue(reduced ? 0 : SHEET_HEIGHT);
// or let each animation decide
withSpring(0, { duration: 300, dampingRatio: 0.8, reduceMotion: ReduceMotion.System });
モーションの低減とは より少なく、より穏やかに という意味であり、ゼロではありません。状態変化を説明する不透明度と色の変化は残し、平行移動、スケール、パララックス、オーバーシュートは削除します。画面遷移は animation: 'fade' になります。
テキストは拡大します。 allowFontScaling はデフォルトでオンなので、デフォルトの文字サイズで測定した高さは 200% では正しくありません。ハードコードされた高さにアニメーションさせないでください — onLayout で測定するか、代わりに transform をアニメーションさせてください。
モーションを静かに壊すセットアップ
「アニメーションがどうしても動かない」ときは、まずこれらを確認してください。
- SDK とバージョンを合わせるために Expo 経由でインストールします:
npx expo install react-native-reanimated react-native-worklets。Expo プロジェクトでは、babel-preset-expoが worklets の Babel プラグインを自動的に設定します —babel.config.jsの手順は不要です。そのプリセットのない bare RN プロジェクトだけがプラグインを手動で追加し、そこではリストの最後に置く必要があります。プラグインの欠落や誤った位置は、もはや静かにフォールバックしません — 実行時にFailed to create a workletをスローします。 GestureHandlerRootViewがアプリをラップしていなければ、ジェスチャーはエラーもなく何も起こりません。- Reanimated 4 には New Architecture が必要です。
- Expo Go はパフォーマンス環境ではありません。 操作感はリリースビルドで判断してください。開発ビルドの JS スレッドは遅く、まさにあなたが探している問題を隠してしまいます。
120fps
ProMotion 対応の iPhone では、CADisableMinimumFrameDurationOnPhone が設定されていない限り、サードパーティのアニメーションは 60fps に制限されます。最近の Expo SDK ではデフォルトで設定されています — 存在することを確認し、なければ追加してください:
{ "expo": { "ios": { "infoPlist": { "CADisableMinimumFrameDurationOnPhone": true } } } }
するとフレームバジェットは 16ms ではなく 8ms になります。これが、UI スレッドのアニメーションがウェブよりもモバイルで重要な理由でもあります。
レシピ
すぐに実装できる実装例 — プレスフィードバック、ドラッグで閉じるシート、スワイプで削除、折りたたみヘッダー、リストの入場、キーボード同期 UI、タブインジケーター、画面遷移 — については RECIPES.md を参照してください。リクエストがいずれかに一致するときはいつでも読み込み、空のファイルからではなくレシピから始めてください。
決して出荷しないもの
| 決して | 代わりに |
|---|---|
PanResponder | gesture-handler の Gesture.Pan() |
ジェスチャーまたはスクロールハンドラー内の setState | 共有値 + useAnimatedStyle |
runOnJS (Reanimated 4 では非推奨) | react-native-worklets の scheduleOnRN |
毎フレームの scheduleOnRN | onEnd、またはしきい値での useAnimatedReaction |
| レンダー中の共有値の読み取りまたは書き込み | worklet、ハンドラー、effect 内での .get() / .set() |
指が触れるものに対するコアの Animated | Reanimated |
height / width / margin / flex / top のアニメーション | transform + opacity (絶対配置の子なし要素は例外) |
BlurView の intensity または Android の elevation のアニメーション | 静的なレイヤーをクロスフェードする |
仮想化リストの行に対する entering | コンテナをアニメーションする、または itemLayoutAnimation |
| JS で再構築された画面遷移 | ネイティブスタックの animation |
| タブ間のスライド | animation: 'none' |
UI 要素に対する Easing.in(...) | Easing.bezier(0.23, 1, 0.32, 1) |
scale(0) の登場 | scale(0.95) + opacity: 0 |
| 距離のみの dismiss しきい値 | 速度または距離 — フリックで十分 |
| 境界でのハードストップ | ラバーバンド抵抗 |
| 毎フレームのハプティクス、または唯一のフィードバックとしてのハプティクス | コミットごとに 1 回、常に視覚的フィードバックと組み合わせる |
| Expo Go やシミュレーターでの感触の判断 | リリースビルド、サポート対象の最も遅いデバイス |
出力
コードを書きます。その後、最大数行で:
- ゲートの結果 — 頻度ティアと名前付きの目的。何を却下したか、その理由を述べます。
- 材料 — ツール、プロパティ、スプリングまたはカーブ + 期間、スレッド。
- デバイス上で感触を確認すべきこと — ジェスチャー、速度のハンドオフ、ハプティクスのタイミングはコードからは判断できません。試すべきことを明示します: フリックする、飛行中に中断する、逆方向に動かす、手持ちの最も遅い Android で実行する。
コードが成果物です。レポートに水増ししないでください。
トーン
断定的かつ簡潔に。「これはアニメーションすべきではない」、または「これが正しいかどうかを伝えるには実際のデバイスが必要です」が正直な答えであるときは、そう伝えます。