React Nativeのジェスチャーを理解する
この記事はReact Native 全部俺 Advent Calendar 12目の記事です。
このアドベントカレンダーについて
このアドベントカレンダーは @itome が全て書いています。
基本的にReact NativeおよびExpoの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はXにお願いします。
React Nativeのジェスチャーを理解する
スマートフォンアプリの操作の大部分は画面に触れることで行います。タップ、スクロール、スワイプ、ピンチイン/アウトなど、これらの操作を総称して「ジェスチャー」と呼びます。
React Nativeでは、これらのジェスチャーを扱うための仕組みとしてGesture Responder Systemが提供されています。ただし開発者がこの仕組みを直接使うことは少なく、多くの場合は標準コンポーネントが提供する機能を使用します。
タップの検知
タップは最も基本的なジェスチャーです。ボタンを押したり、リストの項目を選択したり、メニューを開いたりといった基本的な操作の多くはタップで実現されます。
Buttonコンポーネント
最もシンプルなタップの実装方法はButton
コンポーネントを使うことです:
import { Button } from 'react-native';
const MyButton = () => (
<Button
title="タップしてください"
onPress={() => console.log('タップされました')}
/>
);
しかしButton
コンポーネントには以下のような制限があります:
- カスタムのスタイルを適用できない
- プラットフォーム固有の見た目になる
- 子要素としてテキスト以外を持てない
このため実際のアプリ開発では、より柔軟なTouchable*
系のコンポーネントを使用することが一般的です。
TouchableOpacityコンポーネント
TouchableOpacity
は最も汎用的なタップ可能なコンポーネントです:
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
const CustomButton = () => (
<TouchableOpacity
style={styles.button}
activeOpacity={0.7} // タップ時の透明度(0-1)
onPress={() => console.log('タップされました')}
onPressIn={() => console.log('タップ開始')} // 指が触れた時
onPressOut={() => console.log('タップ終了')} // 指が離れた時
onLongPress={() => console.log('長押し')} // 長押し時
>
<Text style={styles.text}>タップしてください</Text>
</TouchableOpacity>
);
const styles = StyleSheet.create({
button: {
backgroundColor: 'blue',
padding: 15,
borderRadius: 8,
alignItems: 'center',
},
text: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
});
TouchableOpacity
の主な特徴は:
- 任意のスタイルを適用可能
- タップ時に透明度が変化するフィードバック
- 複数の子要素を持てる
- 様々なタップ関連イベントをハンドリング可能
その他のTouchableコンポーネント
特定の用途に特化したTouchableコンポーネントもあります:
import {
TouchableHighlight,
TouchableWithoutFeedback,
Text,
StyleSheet
} from 'react-native';
// タップ時に背景色が変化するボタン
const HighlightButton = () => (
<TouchableHighlight
style={styles.highlightButton}
onPress={() => {}}
underlayColor="#505050" // タップ時の背景色
activeOpacity={0.9} // タップ時の子要素の透明度
>
<Text style={styles.buttonText}>ハイライトボタン</Text>
</TouchableHighlight>
);
// 見た目の変化が一切ないボタン(アニメーション用途など)
const NoFeedbackButton = () => (
<TouchableWithoutFeedback
onPress={() => {}}
// タップ判定の細かい制御が可能
delayPressIn={0} // タップ開始を検知するまでの遅延
delayPressOut={100} // タップ終了を検知するまでの遅延
delayLongPress={500} // 長押しを検知するまでの遅延
>
<Text style={styles.buttonText}>フィードバックなしボタン</Text>
</TouchableWithoutFeedback>
);
const styles = StyleSheet.create({
highlightButton: {
backgroundColor: '#303030',
padding: 15,
borderRadius: 8,
},
buttonText: {
color: 'white',
textAlign: 'center',
},
});
スクロールの検知
スクロールはリストやニュースフィード、長文テキストなど、画面に収まらないコンテンツを表示する際に必須のジェスチャーです。
基本的なスクロール
最もシンプルなスクロールはScrollView
で実装できます:
import { ScrollView, Text, StyleSheet } from 'react-native';
const ScrollableContent = () => (
<ScrollView
style={styles.container}
// スクロールの向きを制限
horizontal={false} // 縦方向のみスクロール可能
// スクロールバーの表示制御
showsVerticalScrollIndicator={true}
showsHorizontalScrollIndicator={false}
// スクロールの挙動調整
bounces={true} // iOS: 端までスクロールした時のバウンス効果
overScrollMode="always" // Android: オーバースクロールの挙動
>
<Text style={styles.text}>
スクロール可能なコンテンツ...
</Text>
</ScrollView>
);
const styles = StyleSheet.create({
container: {
flex: 1,
},
text: {
padding: 16,
fontSize: 16,
},
});
スクロール位置の取得
スクロール位置を使って特定の動作を実装したい場合はonScroll
イベントを使います:
import { ScrollView, View, StyleSheet } from 'react-native';
import { useState } from 'react';
const ScrollableContent = () => {
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
return (
<ScrollView
style={styles.container}
onScroll={event => {
const { x, y } = event.nativeEvent.contentOffset;
// スクロール位置の保存
setScrollPosition({ x, y });
// スクロール方向に応じてヘッダーの表示/非表示
if (y > scrollPosition.y && y > 50) {
setIsHeaderVisible(false); // 下スクロールでヘッダーを隠す
} else {
setIsHeaderVisible(true); // 上スクロールでヘッダーを表示
}
}}
scrollEventThrottle={16} // イベントの発火頻度(ミリ秒)
>
{/* スクロール位置に応じて表示/非表示が切り替わるヘッダー */}
{isHeaderVisible && (
<View style={styles.header}>
<Text>ヘッダー</Text>
</View>
)}
{/* コンテンツ */}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
});
無限スクロール
長いリストを表示する際はFlatList
を使います。ScrollView
に比べて画面に表示されている部分のみをレンダリングするため、パフォーマンスが優れています。
import { FlatList, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
const InfiniteScrollList = () => {
const [items, setItems] = useState([]); // リストアイテム
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// 追加データの読み込み
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newItems = await fetchMoreItems();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems([...items, ...newItems]);
}
} catch (error) {
console.error('データの読み込みに失敗:', error);
} finally {
setLoading(false);
}
};
return (
<FlatList
data={items}
renderItem={({ item }) => <ListItem item={item} />}
// 最下部に近づいたら追加読み込み
onEndReached={loadMore}
onEndReachedThreshold={0.5} // 下から50%の位置で発火
// 読み込み中の表示
ListFooterComponent={() => (
loading ? (
<ActivityIndicator size="large" color="#0000ff" />
) : !hasMore ? (
<Text style={styles.endMessage}>これ以上のアイテムはありません</Text>
) : null
)}
// Pull to Refresh
refreshing={loading}
onRefresh={async () => {
setItems([]);
setHasMore(true);
await loadMore();
}}
/>
);
};
const styles = StyleSheet.create({
endMessage: {
padding: 16,
textAlign: 'center',
color: '#666',
},
});
イベントのBubble Up(イベントの伝播)
ReactやDOMと同様に、React Nativeでもタッチイベントは親要素に伝播します。これを「Bubble Up」と呼びます。例えば以下のような入れ子構造のコンポーネントを考えてみましょう:
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
const PostCard = () => (
<TouchableOpacity
style={styles.card}
onPress={() => {
// この関数は子要素のボタンがタップされた時も呼ばれる
console.log('カードがタップされました');
// 投稿の詳細画面に遷移など
}}
>
<View style={styles.content}>
<Text style={styles.title}>投稿タイトル</Text>
<Text style={styles.body}>投稿本文...</Text>
{/* いいねボタン */}
<TouchableOpacity
style={styles.likeButton}
onPress={(event) => {
// イベントの伝播を止める(親のonPressを呼ばない)
event.stopPropagation();
console.log('いいねボタンがタップされました');
// いいね処理など
}}
>
<Text style={styles.likeText}>♥ いいね</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
const styles = StyleSheet.create({
card: {
backgroundColor: 'white',
borderRadius: 8,
margin: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
content: {
padding: 16,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
body: {
fontSize: 16,
color: '#333',
marginBottom: 16,
},
likeButton: {
alignSelf: 'flex-start',
padding: 8,
backgroundColor: '#ffebee',
borderRadius: 4,
},
likeText: {
color: '#e91e63',
},
});
このような構造の場合:
- いいねボタンがタップされると、まずいいねボタンの
onPress
が呼ばれます - 通常なら、その後カード全体の
onPress
も呼ばれます(=詳細画面に遷移してしまう) - しかし
event.stopPropagation()
を呼ぶことで、イベントの伝播を止めることができます
より細かくイベントの伝播を制御したい場合は、onStartShouldSetResponder
などのメソッドを使うこともできます:
<View
onStartShouldSetResponder={(event) => {
// タッチ開始時にこのViewがイベントを処理するか
return true; // trueを返すと、親要素へのイベント伝播が止まる
}}
onMoveShouldSetResponder={(event) => {
// タッチ移動時にこのViewがイベントを処理するか
return false; // falseを返すと、親要素にイベントが伝播する
}}
>
{/* 子要素 */}
</View>
react-native-gesture-handler
React Nativeのネイティブジェスチャーシステムには以下のような制限があります:
- プラットフォーム間で挙動が異なることがある
- 複雑なジェスチャー(ピンチズームやダブルタップなど)の実装が難しい
- 複数のジェスチャーを同時に処理できない
- ジェスチャーのイベントがJS Threadで処理されるため、パフォーマンスに影響がある
これらの問題を解決するために作られたのがreact-native-gesture-handler
です。
インストール方法
npm install react-native-gesture-handler
インストール後、アプリのルートコンポーネントをGestureHandlerRootView
で囲む必要があります:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
const App = () => (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* アプリのコンテンツ */}
</GestureHandlerRootView>
);
基本的なジェスチャーの実装例
react-native-gesture-handler
では、様々な種類のジェスチャーハンドラーが提供されています:
import {
GestureHandlerRootView,
TapGestureHandler,
PanGestureHandler,
State,
} from 'react-native-gesture-handler';
import Animated, {
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
const GestureExample = () => {
// ドラッグ位置を保持する共有値
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// パンジェスチャー(ドラッグ)のハンドラー
const panGestureEvent = useAnimatedGestureHandler({
// ドラッグ開始時
onStart: (_, context: any) => {
// 現在位置を保存
context.translateX = translateX.value;
context.translateY = translateY.value;
},
// ドラッグ中
onActive: (event, context: any) => {
// 位置を更新
translateX.value = context.translateX + event.translationX;
translateY.value = context.translateY + event.translationY;
},
// ドラッグ終了時
onEnd: () => {
// アニメーションで元の位置に戻る
translateX.value = withSpring(0);
translateY.value = withSpring(0);
},
});
// アニメーションスタイル
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* タップ可能な要素 */}
<TapGestureHandler
onHandlerStateChange={({ nativeEvent }) => {
if (nativeEvent.state === State.ACTIVE) {
console.log('タップされました');
}
}}
numberOfTaps={2} // ダブルタップを検知
>
<Animated.View style={styles.tapArea} />
</TapGestureHandler>
{/* ドラッグ可能な要素 */}
<PanGestureHandler onGestureEvent={panGestureEvent}>
<Animated.View
style={[styles.dragArea, animatedStyle]}
/>
</PanGestureHandler>
</GestureHandlerRootView>
);
};
const styles = StyleSheet.create({
tapArea: {
width: 100,
height: 100,
backgroundColor: 'blue',
margin: 10,
},
dragArea: {
width: 100,
height: 100,
backgroundColor: 'red',
margin: 10,
},
});
複数のジェスチャーを組み合わせる
react-native-gesture-handler
の強みの一つは、複数のジェスチャーを同時に処理できることです:
import {
GestureHandlerRootView,
PinchGestureHandler,
RotationGestureHandler,
State,
} from 'react-native-gesture-handler';
import Animated, {
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';
const ImageViewer = () => {
const scale = useSharedValue(1);
const rotation = useSharedValue(0);
// ピンチズーム
const pinchHandler = useAnimatedGestureHandler({
onActive: (event) => {
scale.value = event.scale;
},
});
// 回転
const rotationHandler = useAnimatedGestureHandler({
onActive: (event) => {
rotation.value = event.rotation;
},
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value },
{ rotate: `${rotation.value}rad` },
],
}));
return (
<GestureHandlerRootView>
<PinchGestureHandler onGestureEvent={pinchHandler}>
<RotationGestureHandler onGestureEvent={rotationHandler}>
<Animated.Image
source={require('./image.png')}
style={[styles.image, animatedStyle]}
/>
</RotationGestureHandler>
</PinchGestureHandler>
</GestureHandlerRootView>
);
};
このようにreact-native-gesture-handler
を使うことで、複雑なジェスチャーも直感的に実装することができます。特にreact-native-reanimated
と組み合わせることで、スムーズなアニメーションを実現できます。
React Navigation や他の多くのライブラリでも採用されているため、React Nativeアプリ開発では事実上の標準ライブラリとなっています。複雑なジェスチャーを実装する際は、積極的に活用することをお勧めします。
Discussion