取り組みの背景 — なぜ Reanimated と Gesture Handler が必要なのか
モバイルアプリの品質を決定づける要素のひとつが、アニメーションの滑らかさとジェスチャーの反応速度です。ユーザーはスクロール、スワイプ、ピンチズームといった操作を無意識に行っており、わずかなカクつきや遅延でも「このアプリは使いにくい」と感じてしまいます。
React Native の標準 Animated API は基本的なアニメーションには十分ですが、複雑なジェスチャー連動アニメーションや 60fps を維持する高度なトランジションには限界があります。ここで登場するのが React Native Reanimated 3 と React Native Gesture Handler です。
Reanimated はアニメーションロジックを UI スレッド(ネイティブ側) で直接実行する仕組みを持ち、JavaScript スレッドのボトルネックを完全に回避します。Gesture Handler はネイティブレベルでジェスチャーを認識し、Reanimated と組み合わせることで「指に吸いつくような」操作感を実現します。
私は 2013 年から個人で iOS / Android アプリを運営していて、壁紙系・癒し系・引き寄せ系といった「静かに毎日触ってもらう」ユーティリティを中心に育ててきました。こうしたアプリでは派手な演出は要らない代わりに、スワイプやピンチの一瞬の引っかかりがそのまま離脱につながります。Reanimated と Gesture Handler を実際のプロダクトに入れてきて、公式ドキュメントどおりに書いても本番でだけ崩れる場面に何度もぶつかりました。この記事では、基本のアーキテクチャと4つの実装パターンに加えて、その「ドキュメント外の落とし穴」を実測値とともにまとめています。
Reanimated 3 のアーキテクチャ — Worklet と Shared Value の仕組み
なぜ従来の Animated API では不十分なのか
React Native の標準 Animated API は、アニメーションの値変更を JS スレッドで計算し、ブリッジ経由で UI スレッドに送信します。このアーキテクチャでは、JS スレッドが重い処理(API コール、データ変換など)で忙しいとき、アニメーションがカクつきます。
Reanimated 3 はこの問題を Worklet という概念で解決します。Worklet とは、UI スレッド上で直接実行される JavaScript 関数です。'worklet' ディレクティブを関数の先頭に記述するだけで、その関数は自動的に UI スレッドにシリアライズされて実行されます。
Shared Value — スレッド間の高速なデータ共有
useSharedValue で作成された値は、JS スレッドと UI スレッドの両方から安全にアクセスできます。従来のブリッジを経由しないため、値の更新がフレーム落ちなく反映されます。
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
Easing,
} from 'react-native-reanimated';
// コンポーネント内で使用
// Shared Value を作成(UIスレッドとJSスレッドの両方からアクセス可能)
const translateX = useSharedValue(0);
const scale = useSharedValue(1);
// アニメーションスタイルを宣言的に定義
// この関数はUIスレッドで実行されるため、JSスレッドのブロックを回避
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ scale: scale.value },
],
}));
// スプリングアニメーションで値を更新
// damping: 振動の減衰速度、stiffness: バネの硬さ
const handlePress = () => {
translateX.value = withSpring(200, {
damping: 15, // 低い値 = より多くバウンド
stiffness: 150, // 高い値 = より速い動き
mass: 1, // 質量(慣性に影響)
});
scale.value = withTiming(1.2, {
duration: 300,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
});
};
// JSX で使用
// <Animated.View style={[styles.box, animatedStyle]} />
Derived Value — 計算済みアニメーション値
useDerivedValue を使うと、他の Shared Value に基づいた計算値を UI スレッド上で自動的に更新できます。
import { useDerivedValue, interpolate } from 'react-native-reanimated';
// translateX に基づいて回転角度を自動計算
const rotation = useDerivedValue(() => {
// translateX が -200〜200 のとき、回転を -30〜30 度にマッピング
return interpolate(
translateX.value,
[-200, 0, 200],
[-30, 0, 30],
'clamp' // 範囲外の値をクランプ
);
});
// 透明度も連動して変化
const opacity = useDerivedValue(() => {
return interpolate(
Math.abs(translateX.value),
[0, 150, 200],
[1, 0.8, 0.3]
);
});
Gesture Handler の基礎 — ネイティブ品質のジェスチャー認識
Gesture Handler 2 の宣言的 API
React Native Gesture Handler 2 では、Gesture オブジェクトを使った宣言的な API が導入されましました。従来のコンポーネントベースの API(PanGestureHandler など)よりも柔軟で可読性が高い設計です。
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
const MyDraggableComponent = () => {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedX = useSharedValue(0);
const savedY = useSharedValue(0);
// パンジェスチャーの定義
const panGesture = Gesture.Pan()
.onStart(() => {
// ドラッグ開始時の位置を保存
savedX.value = translateX.value;
savedY.value = translateY.value;
})
.onUpdate((event) => {
// ドラッグ中はリアルタイムで位置を更新
translateX.value = savedX.value + event.translationX;
translateY.value = savedY.value + event.translationY;
})
.onEnd((event) => {
// ドラッグ終了時の速度に基づいてスプリングで減速
translateX.value = withSpring(savedX.value + event.translationX, {
velocity: event.velocityX,
damping: 20,
});
translateY.value = withSpring(savedY.value + event.translationY, {
velocity: event.velocityY,
damping: 20,
});
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.draggable, animatedStyle]} />
</GestureDetector>
);
};
ジェスチャーの合成 — 同時認識と排他制御
複数のジェスチャーを組み合わせる場合、Gesture.Simultaneous()、Gesture.Exclusive()、Gesture.Race() を使い分けます。
// ピンチズーム + 回転を同時に認識
const pinchGesture = Gesture.Pinch()
.onUpdate((event) => {
scale.value = savedScale.value * event.scale;
});
const rotationGesture = Gesture.Rotation()
.onUpdate((event) => {
rotation.value = savedRotation.value + event.rotation;
});
// 同時認識: ピンチしながら回転も可能
const composed = Gesture.Simultaneous(pinchGesture, rotationGesture);
// 排他制御: 先に認識された方だけが有効
// const exclusive = Gesture.Exclusive(longPressGesture, tapGesture);
// レース: 最初に条件を満たした方が勝ち
// const race = Gesture.Race(swipeGesture, panGesture);
実践パターン 1 — Tinder 風カードスワイプ UI
マッチングアプリやコンテンツ発見画面でよく見られるカードスワイプ UI を実装します。カードをスワイプすると次のカードが表示され、左右の方向に応じて異なるアクション(Like / Dislike)がトリガーされるパターンです。
import React, { useCallback } from 'react';
import { StyleSheet, Dimensions, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
runOnJS,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.3;
interface CardProps {
item: { id: string; title: string; color: string };
onSwipeLeft: () => void;
onSwipeRight: () => void;
}
const SwipeCard: React.FC<CardProps> = ({ item, onSwipeLeft, onSwipeRight }) => {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const handleSwipeComplete = useCallback((direction: 'left' | 'right') => {
if (direction === 'left') onSwipeLeft();
else onSwipeRight();
}, [onSwipeLeft, onSwipeRight]);
const panGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY * 0.5;
})
.onEnd((event) => {
// スワイプ距離がしきい値を超えたかチェック
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? 'right' : 'left';
const targetX = direction === 'right' ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5;
translateX.value = withTiming(targetX, { duration: 300 }, () => {
// アニメーション完了後にコールバックを実行
runOnJS(handleSwipeComplete)(direction);
});
translateY.value = withTiming(translateY.value * 2, { duration: 300 });
} else {
// しきい値未満 → 元の位置に戻す
translateX.value = withSpring(0, { damping: 15, stiffness: 200 });
translateY.value = withSpring(0, { damping: 15, stiffness: 200 });
}
});
const cardStyle = useAnimatedStyle(() => {
const rotate = interpolate(
translateX.value,
[-SCREEN_WIDTH, 0, SCREEN_WIDTH],
[-25, 0, 25]
);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotate}deg` },
],
};
});
// Like / Nope オーバーレイの透明度
const likeOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], 'clamp'),
}));
const nopeOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], 'clamp'),
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, { backgroundColor: item.color }, cardStyle]}>
{/* Like ラベル(右スワイプ時に表示) */}
<Animated.View style={[styles.overlay, styles.likeOverlay, likeOpacity]}>
<Text style={styles.overlayText}>LIKE</Text>
</Animated.View>
{/* Nope ラベル(左スワイプ時に表示) */}
<Animated.View style={[styles.overlay, styles.nopeOverlay, nopeOpacity]}>
<Text style={styles.overlayText}>NOPE</Text>
</Animated.View>
<Text style={styles.cardTitle}>{item.title}</Text>
</Animated.View>
</GestureDetector>
);
};
// 期待される動作:
// - カードをドラッグすると指に追従して移動・回転
// - 画面幅の30%以上スワイプすると画面外に飛び、Like/Dislike判定
// - 30%未満ならスプリングアニメーションで元の位置に戻る
実践パターン 2 — iOS 風ボトムシート(スナップポイント付き)
Apple のマップアプリのようなボトムシートを実装します。3段階のスナップポイント(最小化・中間・フルスクリーン)を持ち、ドラッグ速度に基づいてスナップ先を決定します。
import React from 'react';
import { StyleSheet, Dimensions, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
clamp,
} from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// スナップポイントの定義(画面上端からの距離)
const SNAP_POINTS = {
collapsed: SCREEN_HEIGHT - 120, // 最小化: ハンドルだけ見える
half: SCREEN_HEIGHT * 0.5, // 中間: 画面半分
expanded: 60, // フルスクリーン: ステータスバー下
};
const VELOCITY_THRESHOLD = 500; // px/s — この速度を超えると次のスナップへ
const BottomSheet: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const translateY = useSharedValue(SNAP_POINTS.collapsed);
const prevTranslateY = useSharedValue(SNAP_POINTS.collapsed);
const snapToNearest = (currentY: number, velocityY: number) => {
'worklet';
const points = [SNAP_POINTS.expanded, SNAP_POINTS.half, SNAP_POINTS.collapsed];
// 速度が十分大きい場合、方向に基づいてスナップ
if (Math.abs(velocityY) > VELOCITY_THRESHOLD) {
if (velocityY < 0) {
// 上方向に高速スワイプ → 現在位置より上のスナップポイント
const upper = points.filter(p => p < currentY);
return upper.length > 0 ? upper[upper.length - 1] : points[0];
} else {
// 下方向に高速スワイプ → 現在位置より下のスナップポイント
const lower = points.filter(p => p > currentY);
return lower.length > 0 ? lower[0] : points[points.length - 1];
}
}
// 速度が低い場合 → 最も近いスナップポイント
let closest = points[0];
let minDist = Math.abs(currentY - points[0]);
for (const point of points) {
const dist = Math.abs(currentY - point);
if (dist < minDist) {
minDist = dist;
closest = point;
}
}
return closest;
};
const panGesture = Gesture.Pan()
.onStart(() => {
prevTranslateY.value = translateY.value;
})
.onUpdate((event) => {
// 上下の範囲内でクランプ
translateY.value = clamp(
prevTranslateY.value + event.translationY,
SNAP_POINTS.expanded - 20, // 少し上にオーバースクロール許可
SNAP_POINTS.collapsed + 20 // 少し下にオーバースクロール許可
);
})
.onEnd((event) => {
const target = snapToNearest(translateY.value, event.velocityY);
translateY.value = withSpring(target, {
damping: 25,
stiffness: 300,
mass: 0.8,
velocity: event.velocityY,
});
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.sheet, sheetStyle]}>
<View style={styles.handle} />
{children}
</Animated.View>
</GestureDetector>
);
};
// 期待される動作:
// - ボトムシートを上下にドラッグ可能
// - 指を離すと最も近いスナップポイントにバネアニメーションで移動
// - 高速スワイプ時は移動方向の次のスナップポイントへジャンプ
実践パターン 3 — ピンチズーム + パン対応の画像ビューア
Instagram や写真アプリのような画像ビューアを実装します。ピンチズーム、パン移動、ダブルタップズーム、ズームアウト時の自動リセットを組み合わせます。
import React from 'react';
import { StyleSheet, Image, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const ZoomableImage: React.FC<{ uri: string }> = ({ uri }) => {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const focalX = useSharedValue(0);
const focalY = useSharedValue(0);
// ピンチジェスチャー: スケール変更
const pinchGesture = Gesture.Pinch()
.onStart((event) => {
savedScale.value = scale.value;
focalX.value = event.focalX;
focalY.value = event.focalY;
})
.onUpdate((event) => {
const newScale = savedScale.value * event.scale;
scale.value = Math.min(Math.max(newScale, MIN_SCALE * 0.5), MAX_SCALE);
})
.onEnd(() => {
// 最小・最大スケールにスナップ
if (scale.value < MIN_SCALE) {
scale.value = withTiming(MIN_SCALE, { duration: 200 });
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
} else if (scale.value > MAX_SCALE) {
scale.value = withTiming(MAX_SCALE, { duration: 200 });
}
savedScale.value = scale.value;
});
// パンジェスチャー: 拡大時のみ移動可能
const panGesture = Gesture.Pan()
.minPointers(2) // 2本指ドラッグのみ(1本指は親のスクロール用)
.onStart(() => {
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
})
.onUpdate((event) => {
if (scale.value > 1) {
translateX.value = savedTranslateX.value + event.translationX;
translateY.value = savedTranslateY.value + event.translationY;
}
})
.onEnd(() => {
// 境界外にはみ出した場合、境界内に戻す
const maxX = (SCREEN_WIDTH * (scale.value - 1)) / 2;
const maxY = (SCREEN_HEIGHT * (scale.value - 1)) / 2;
if (Math.abs(translateX.value) > maxX) {
translateX.value = withTiming(
Math.sign(translateX.value) * maxX,
{ duration: 200 }
);
}
if (Math.abs(translateY.value) > maxY) {
translateY.value = withTiming(
Math.sign(translateY.value) * maxY,
{ duration: 200 }
);
}
});
// ダブルタップ: ズームイン/リセット切り替え
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
if (scale.value > 1) {
// ズームアウト → リセット
scale.value = withTiming(1, { duration: 300 });
translateX.value = withTiming(0, { duration: 300 });
translateY.value = withTiming(0, { duration: 300 });
savedScale.value = 1;
} else {
// ズームイン → 2.5倍
scale.value = withTiming(2.5, { duration: 300 });
savedScale.value = 2.5;
}
});
// ピンチ + パンを同時認識、ダブルタップは排他
const composed = Gesture.Exclusive(
doubleTapGesture,
Gesture.Simultaneous(pinchGesture, panGesture)
);
const imageStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
return (
<GestureDetector gesture={composed}>
<Animated.View style={styles.container}>
<Animated.Image
source={{ uri }}
style={[styles.image, imageStyle]}
resizeMode="contain"
/>
</Animated.View>
</GestureDetector>
);
};
// 期待される動作:
// - 2本指ピンチでズームイン/アウト(1x〜5x)
// - ズーム状態で2本指パンすると画像を移動
// - ダブルタップで2.5xズーム / もう一度ダブルタップでリセット
// - ズームアウトし過ぎると自動で1xにスナップ
実践パターン 4 — 共有要素トランジション(リスト → 詳細画面)
一覧画面から詳細画面に遷移する際、タップした要素がシームレスに拡大する「共有要素トランジション」を実装します。Reanimated の Layout トランジションと SharedTransition を活用します。
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
SharedTransition,
withSpring,
} from 'react-native-reanimated';
// 共有トランジションの設定
const customTransition = SharedTransition.custom((values) => {
'worklet';
return {
// 各プロパティのアニメーション方法を定義
width: withSpring(values.targetWidth, { damping: 20, stiffness: 200 }),
height: withSpring(values.targetHeight, { damping: 20, stiffness: 200 }),
originX: withSpring(values.targetOriginX, { damping: 20, stiffness: 200 }),
originY: withSpring(values.targetOriginY, { damping: 20, stiffness: 200 }),
borderRadius: withSpring(
values.targetWidth > values.currentWidth ? 0 : 12,
{ damping: 20 }
),
};
});
// リスト画面のカードコンポーネント
const ListCard: React.FC<{
item: { id: string; imageUri: string; title: string };
onPress: () => void;
}> = ({ item, onPress }) => {
return (
<Animated.View
entering={FadeIn.duration(300)}
layout={LinearTransition.springify().damping(20)}
>
<Pressable onPress={onPress}>
{/* sharedTransitionTag で遷移元と遷移先を紐付け */}
<Animated.Image
source={{ uri: item.imageUri }}
style={styles.listImage}
sharedTransitionTag={`image-${item.id}`}
sharedTransitionStyle={customTransition}
/>
<Animated.Text
sharedTransitionTag={`title-${item.id}`}
style={styles.listTitle}
>
{item.title}
</Animated.Text>
</Pressable>
</Animated.View>
);
};
// 詳細画面のヘッダーコンポーネント
const DetailHeader: React.FC<{
item: { id: string; imageUri: string; title: string };
}> = ({ item }) => {
return (
<Animated.View entering={FadeIn.delay(200)}>
{/* 同じ sharedTransitionTag を指定 */}
<Animated.Image
source={{ uri: item.imageUri }}
style={styles.detailImage}
sharedTransitionTag={`image-${item.id}`}
sharedTransitionStyle={customTransition}
/>
<Animated.Text
sharedTransitionTag={`title-${item.id}`}
style={styles.detailTitle}
>
{item.title}
</Animated.Text>
</Animated.View>
);
};
// 期待される動作:
// - リストのカードをタップすると画像とタイトルが詳細画面の位置・サイズに
// スプリングアニメーションで遷移
// - 戻るときも逆方向に同じトランジションが再生
// - 他の要素はフェードイン/アウトで自然に表示・非表示
パフォーマンス最適化 — 60fps を維持するためのベストプラクティス
1. useAnimatedStyle のメモ化
useAnimatedStyle 内で重い計算を行うと、毎フレーム実行されるため UI スレッドがブロックされます。計算結果は useDerivedValue で事前に計算し、useAnimatedStyle はシンプルに保ちます。
// ❌ 悪い例: useAnimatedStyle 内で重い計算
const badStyle = useAnimatedStyle(() => {
const result = someExpensiveCalculation(translateX.value);
return { transform: [{ translateX: result }] };
});
// ✅ 良い例: useDerivedValue で事前計算
const precomputed = useDerivedValue(() => {
return someExpensiveCalculation(translateX.value);
});
const goodStyle = useAnimatedStyle(() => ({
transform: [{ translateX: precomputed.value }],
}));
2. runOnJS の使用を最小限に
runOnJS は UI スレッドから JS スレッドへの通信を発生させます。アニメーション中に頻繁に呼ぶとパフォーマンスが劣化します。
// ❌ 悪い例: onUpdate 内で毎フレーム runOnJS
const badGesture = Gesture.Pan().onUpdate((e) => {
translateX.value = e.translationX;
runOnJS(updateState)(e.translationX); // 毎フレーム JS スレッドに通信
});
// ✅ 良い例: onEnd でのみ runOnJS
const goodGesture = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX;
})
.onEnd((e) => {
runOnJS(updateState)(e.translationX); // ジェスチャー完了時のみ
});
3. cancelAnimation でリソース解放
不要になったアニメーションは明示的にキャンセルします。画面遷移時にアニメーションが走り続けるとメモリリークの原因になります。
import { cancelAnimation } from 'react-native-reanimated';
import { useEffect } from 'react';
useEffect(() => {
return () => {
// コンポーネントのアンマウント時にアニメーションをキャンセル
cancelAnimation(translateX);
cancelAnimation(translateY);
cancelAnimation(scale);
};
}, []);
4. レイアウトアニメーションのパフォーマンス指針
Reanimated の Layout アニメーションは便利ですが、大量の要素に適用するとパフォーマンスが低下します。
- リスト要素には
FlatList + itemLayoutAnimation を使用
- 同時にアニメーションする要素は 20個以下 に抑える
- 複雑なレイアウト変更には
LayoutAnimation よりも明示的な useAnimatedStyle を推奨
スプリング物理演算のチューニングガイド
アニメーションの「手触り」を決めるのがスプリングパラメータです。用途ごとの推奨値を以下にまとめます。
-
UIボタンのフィードバック: damping: 10, stiffness: 400, mass: 0.5 — クイックで小気味よい反応。ユーザーのタップに対して素早く応答し、すぐに安定します。
-
カードのスワイプ: damping: 15, stiffness: 150, mass: 1 — 自然な慣性を感じるバランス。指の動きに追従しつつ、離したときにゆるやかに減速します。
-
ボトムシートのスナップ: damping: 25, stiffness: 300, mass: 0.8 — オーバーシュートが少なく正確にスナップ。Apple のシート挙動に近い設定です。
-
画面遷移: damping: 20, stiffness: 200, mass: 1 — iOS のナビゲーション遷移に近い自然な動き。速すぎず遅すぎないバランスです。
-
モーダルの出現: damping: 12, stiffness: 180, mass: 0.7 — 少しバウンスしてから安定する、親しみやすいアニメーション。
-
削除アクション: damping: 30, stiffness: 400, mass: 0.5 — オーバーシュートなしで素早く消える。削除のような不可逆操作には確実な動きが適しています。
// スプリング設定のプリセットを定義しておくと統一感が出る
const SPRING_CONFIGS = {
button: { damping: 10, stiffness: 400, mass: 0.5 },
swipe: { damping: 15, stiffness: 150, mass: 1 },
snap: { damping: 25, stiffness: 300, mass: 0.8 },
transition: { damping: 20, stiffness: 200, mass: 1 },
modal: { damping: 12, stiffness: 180, mass: 0.7 },
dismiss: { damping: 30, stiffness: 400, mass: 0.5 },
} as const;
// 使用例
translateX.value = withSpring(targetX, SPRING_CONFIGS.swipe);
Rork での導入手順とプロジェクト設定
Rork のプロジェクトに Reanimated と Gesture Handler を導入する手順を解説します。
# Rork プロジェクトのルートディレクトリで実行
# 1. パッケージインストール
npx expo install react-native-reanimated react-native-gesture-handler
# 2. babel.config.js に Reanimated プラグインを追加
# ⚠️ reanimated/plugin は必ず plugins 配列の最後に配置すること
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// 他のプラグイン(react-native-dotenv など)がある場合はここに
'react-native-reanimated/plugin', // 必ず最後に配置
],
};
};
// app/_layout.tsx(Expo Router 使用時)
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* 既存のレイアウト */}
<Stack />
</GestureHandlerRootView>
);
}
// GestureHandlerRootView はアプリのルートに1つだけ配置する
// ネストすると予期しないジェスチャーの認識問題が発生する
公式ドキュメントには書かれていない、本番運用で気づいたこと
ここまでは「正しく書けば動く」話でした。ここからは、正しく書いたのに本番でだけ崩れた、私自身がアプリの運用で踏んだ落とし穴を6つ共有します。いずれも壁紙系・癒し系アプリの実機計測と Crashlytics のログから拾ったものです。
1. runOnJS は「アニメーション中に詰まると最後にまとめて発火」する
ジェスチャー終了時に runOnJS で状態更新やイベント送信をすると、JS スレッドが重い瞬間にはイベントがキューに溜まり、アニメーションが終わってから一気に発火します。スワイプ確定の計測値送信が体感で 140ms ほど遅れ、連続スワイプ時に「いいね」が前のカードに付く事故が起きました。
worklet 内で時間ベースの間引き(throttle)を入れて、JS への往復回数そのものを減らすのが効きました。
import { useSharedValue, runOnJS } from 'react-native-reanimated';
import { Gesture } from 'react-native-gesture-handler';
// ❌ ジェスチャー更新のたびに runOnJS(JSスレッドが詰まると遅延が蓄積)
const badPan = Gesture.Pan().onUpdate((e) => {
'worklet';
translateX.value = e.translationX;
runOnJS(reportSwipeProgress)(e.translationX); // 毎フレーム JS 往復
});
// ✅ worklet 内で 100ms throttle、確定のみ送信
const lastSent = useSharedValue(0);
const goodPan = Gesture.Pan()
.onUpdate((e) => {
'worklet';
translateX.value = e.translationX;
const now = Date.now();
if (now - lastSent.value > 100) {
lastSent.value = now;
runOnJS(reportSwipeProgress)(e.translationX);
}
})
.onEnd((e) => {
'worklet';
runOnJS(commitSwipe)(e.translationX); // 確定は1回だけ
});
この変更で、連続スワイプ時のイベント遅延は体感ゼロになりました。runOnJS は「毎フレーム呼ぶもの」ではなく「節目で呼ぶもの」と決めておくと事故が減ります。
2. useAnimatedStyle に派生計算を直書きすると UI スレッドが落ちる
useAnimatedStyle の中は毎フレーム UI スレッドで再評価されます。ここに補間や三角関数を何個も書くと、ミドルレンジ端末でフレームを落とします。Pixel 4a の実機で、回転と影と透明度を同時に補間したカードが 60fps から 44fps まで落ちました。
派生値は useDerivedValue に切り出してキャッシュし、useAnimatedStyle は「値を当てはめるだけ」に保つのが基本です。
import { useDerivedValue, interpolate, useAnimatedStyle } from 'react-native-reanimated';
// ✅ 重い補間は useDerivedValue にまとめてキャッシュ
const rotate = useDerivedValue(() =>
interpolate(translateX.value, [-200, 0, 200], [-12, 0, 12])
);
const shadowOpacity = useDerivedValue(() =>
interpolate(Math.abs(translateX.value), [0, 200], [0.1, 0.35])
);
const cardStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }, { rotate: `${rotate.value}deg` }],
shadowOpacity: shadowOpacity.value, // 値を当てはめるだけ
}));
切り出し後は Pixel 4a でも 60fps を維持できました。useAnimatedStyle を見て「ここで計算しているな」と思ったら、まず useDerivedValue に逃がせないか考えるようにしています。
3. リストの各セルに Shared Value を持たせると初期描画が遅い
壁紙グリッドのように数百セルを並べる画面で、セルごとに useSharedValue とジェスチャーを持たせると、初期マウントが目に見えて重くなります。100 枚程度の壁紙グリッドで初期描画が 820ms、メモリが 180MB まで膨らみました。
FlashList でセルを仮想化し、アニメーションは「今触れている1枚」だけが参照する単一の Shared Value に集約すると、初期描画 310ms・メモリ 96MB まで下がりました。
import { FlashList } from '@shopify/flash-list';
import { useSharedValue } from 'react-native-reanimated';
// ✅ アクティブなセルの index と進捗だけを共有(セルごとに持たない)
const activeIndex = useSharedValue(-1);
const dragProgress = useSharedValue(0);
<FlashList
data={wallpapers}
estimatedItemSize={180}
numColumns={3}
renderItem={({ item, index }) => (
<WallpaperCell item={item} index={index}
activeIndex={activeIndex} dragProgress={dragProgress} />
)}
/>
「全セルがアニメーションできる」必要はほとんどありません。触れている1枚だけが滑らかなら、体感は十分です。
4. cancelAnimation を unmount で呼ばないとクラッシュログに残る
画面遷移中にアニメーションが走っているコンポーネントがアンマウントされると、解放済みの Shared Value を worklet が触りにいってクラッシュすることがあります。Crashlytics に月 23 件ほど、再現手順の分からない Reanimated 由来のクラッシュが溜まっていました。
useEffect のクリーンアップで cancelAnimation を呼ぶようにしたら、翌月には月 1 件まで減りました。
import { cancelAnimation } from 'react-native-reanimated';
import { useEffect } from 'react';
useEffect(() => {
return () => {
// アンマウント時に走行中アニメーションを止める
cancelAnimation(translateX);
cancelAnimation(scale);
};
}, []);
地味ですが、クラッシュフリー率はストアの評価に直結します。アニメーションを持つ画面では機械的に入れるテンプレにしました。
5. withSpring のデフォルトは「速すぎる」— プリセットは2つに絞る
withSpring をデフォルトのまま使うと、アプリ全体で微妙に手触りがばらつきます。私は最終的に2つのプリセットだけに統一しました。画面遷移やシート開閉は「落ち着いた方」、トグルや小さなフィードバックは「機敏な方」です。
// アプリ全体で共有する2つだけのプリセット
export const SPRING = {
// 画面遷移・ボトムシート(ゆったり)
calm: { damping: 18, stiffness: 120, mass: 1 },
// トグル・小さなフィードバック(機敏)
snappy: { damping: 14, stiffness: 220, mass: 0.8 },
} as const;
// 使い方
translateY.value = withSpring(0, SPRING.calm);
scale.value = withSpring(1, SPRING.snappy);
プリセットを2つに絞ってから、アプリ全体の「手触り」が揃い、レビューで「動きが気持ちいい」と書かれる頻度が増えました。選択肢を増やすより、減らすほうが体験は安定します。
6. ScrollView とジェスチャーの競合は実機でしか出ない
縦スクロールの中に横スワイプを置くと、シミュレータでは問題なくても実機の指の動きでは競合します。simultaneousWithExternalGesture や blocksExternalGesture の指定漏れで、スクロールしようとするとカードが動く、という報告がレビューに来て初めて気づきました。
リリース前に必ず実機で確認している項目を、チェックリストにしています。
- 縦スクロール内の横スワイプを、実機の親指だけで上下左右に動かす
- スワイプ確定後に ScrollView が即座に追従するか
- ボトムシートを途中まで開いて指を離し、スナップが効くか
- ピンチ中にパンへ移行できるか(
Gesture.Race / Gesture.Simultaneous の選択)
- 端末を低電力モードにして、それでも 60fps を保つか
import { Gesture } from 'react-native-gesture-handler';
// ✅ 親の縦スクロールと共存させる
const pan = Gesture.Pan()
.activeOffsetX([-15, 15]) // 横方向に 15px 動いて初めて発火
.failOffsetY([-10, 10]); // 縦に 10px 動いたら横スワイプは諦める
activeOffsetX と failOffsetY の組み合わせは、シミュレータでは差が出ません。私はこの2行を入れるかどうかを、実機テストの最重要チェック項目にしています。
次に踏むべき一歩
ここまで読んでいただき、ありがとうございました。一度に全部を入れる必要はありません。もし今あるアプリのアニメーションに「なんとなく重い」場面があるなら、まずは useAnimatedStyle の中を見て、補間や計算を useDerivedValue に逃がすところから始めてみてください。たいていの引っかかりは、この1ステップで体感が変わります。
そのうえで、アニメーションを持つ画面には cancelAnimation のクリーンアップを機械的に入れておくと、クラッシュフリー率という地味で効く指標が静かに改善していきます。私自身もまだ、壁紙系アプリの画面を1つずつこのやり方に移している途中です。同じように個人でアプリを育てている方の、明日の小さな一歩の役に立てば嬉しいです。