Zenn
📱

React Nativeのジェスチャーを理解する

に公開

この記事はReact Native 全部俺 Advent Calendar 12目の記事です。

https://adventar.org/calendars/10741

このアドベントカレンダーについて

このアドベントカレンダーは @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',
  },
});

このような構造の場合:

  1. いいねボタンがタップされると、まずいいねボタンのonPressが呼ばれます
  2. 通常なら、その後カード全体のonPressも呼ばれます(=詳細画面に遷移してしまう)
  3. しかし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

ログインするとコメントできます