【React Native】pressRetentionOffsetの全て:基本から応用、トラブルシューティングまで

2025-06-06

pressRetentionOffsetとは?

pressRetentionOffsetは、ユーザーが画面をタッチして要素(ボタンやテキストなど)を「押している」状態のときに、指がその要素の領域からどれくらい離れても「押されている」状態を維持するかを定義するプロパティです。

言い換えると、ユーザーがボタンをタップして指を押し下げたまま、少し指をずらしても、まだそのボタンが「押された状態」として認識され続ける範囲を設定します。この範囲を超えて指をずらすと、「押された状態」が解除(非アクティブ化)されます。

なぜこれが重要なのか?

スマートフォンの画面は小さく、指で正確にタップするのが難しい場合があります。ユーザーが意図せず指を少しずらしてしまっても、すぐにボタンの「押された状態」が解除されてしまうと、ユーザー体験が悪くなります。

pressRetentionOffsetを使用することで、この「指のずれ」を許容し、より forgiving(寛容な)なタッチ領域を提供できます。これにより、ユーザーはより快適にアプリを操作できるようになります。

設定方法

pressRetentionOffsetは、数値(ピクセル単位)またはRectオブジェクト({ top: number, left: number, bottom: number, right: number })で指定できます。

  • Rectオブジェクトで指定する場合: 上、下、左、右の各方向に対して個別にオフセットを設定できます。
  • 数値で指定する場合: 指定した数値が上下左右すべての方向に適用されます。


import React from 'react';
import { Pressable, Text, StyleSheet } from 'react-native';

const MyPressableText = () => {
  return (
    <Pressable
      onPress={() => console.log('テキストが押されました!')}
      // 指が20ピクセルまでずれても押された状態を維持
      pressRetentionOffset={20} 
      // または、方向ごとに異なるオフセットを設定
      // pressRetentionOffset={{ top: 10, left: 10, bottom: 30, right: 10 }}
      style={({ pressed }) => [
        styles.button,
        pressed ? styles.buttonPressed : styles.buttonNormal,
      ]}
    >
      <Text style={styles.text}>押してみてください</Text>
    </Pressable>
  );
};

const styles = StyleSheet.create({
  button: {
    padding: 15,
    borderRadius: 8,
    margin: 20,
    alignItems: 'center',
    justifyContent: 'center',
  },
  buttonNormal: {
    backgroundColor: '#007bff',
  },
  buttonPressed: {
    backgroundColor: '#0056b3',
  },
  text: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
});

export default MyPressableText;

pressRetentionOffsetと似たプロパティにhitSlopがあります。

  • pressRetentionOffset: ユーザーが既に要素を「押している」状態で、指がどれくらいずれても「押された状態」を維持するかを定義します。これは、タップを「継続」できる領域を定義します。
  • hitSlop: 要素の「タップ可能な領域」を拡張します。ユーザーが要素の実際の境界線の外側をタップしても、その要素がタップされたと見なされます。つまり、タップを「開始」できる領域を広げます。


ここでは、よくあるエラーとトラブルシューティングについて説明します。

pressRetentionOffsetに関するよくあるエラーとトラブルシューティング

pressRetentionOffsetを設定しても効果がない、または期待通りに動作しない

考えられる原因

  • Androidの物理デバイスでの問題(特定バージョン/アーキテクチャ)
    • 一部の古いReact Nativeのバージョンや、新しいアーキテクチャ(New Architecture)を有効にしたAndroidデバイスで、PressableonPressonPressIn/onPressOutが正しく動作しない、あるいはpressRetentionOffsetの挙動が不安定になるという報告がされています。これはフレームワークレベルのバグである可能性があり、最新のReact Nativeバージョンにアップデートすることで解決することがあります。
  • hitSlopとの混同
    • hitSlopはタップ可能な領域を「開始」できる範囲を広げますが、pressRetentionOffsetはタップを「継続」できる範囲です。混同して使っていると、期待する挙動にならないことがあります。
  • 間違ったコンポーネントに適用している
    • Textコンポーネントに直接onPressを適用している場合、内部的にはPressableまたは同等の機能が使用されますが、意図しないコンポーネントにpressRetentionOffsetを適用している可能性があります。Pressableコンポーネントを明示的にラップして使用することをお勧めします。
  • ScrollViewのスクロールと競合している
    • pressRetentionOffsetを設定した要素がScrollViewの内部にある場合、ユーザーが指を少しずらしたときに、スクロールイベントが優先されてしまい、pressRetentionOffsetが機能しないことがあります。
    • 特に、ユーザーが要素を長押しして指を移動させると、onLongPressではなくonPressOutがすぐに発火してしまうケースが報告されています。これはScrollViewのスクロールジェスチャーが優先されるためです。
  • 別の要素がタッチイベントをブロックしている
    • 透明なViewや、position: 'absolute'などで配置された要素が、意図せずPressableな要素の上に重なっている場合があります。z-indexの設定や、開発者ツール(React Native Debuggerなど)の要素インスペクターで、タッチ領域を確認してください。
    • pointerEvents: 'none'が設定されている親要素や祖先要素が存在する場合、その下の要素はタッチイベントを受け取れません。

トラブルシューティング

  1. 要素の重なりとz-indexを確認する
    • backgroundColorを設定して、要素の実際の表示領域を確認します。
    • 開発者ツールで各要素のスタイルを検査し、他の要素が上にかぶっていないか確認します。
  2. ScrollViewとの競合を考慮する
    • もしScrollView内で問題が発生している場合、ScrollViewscrollEnabledプロパティを一時的にfalseにしてテストしてみてください。もしこれで改善されるなら、ジェスチャーハンドリングのロジックを見直す必要があります。
    • より複雑なジェスチャーが必要な場合は、react-native-gesture-handlerライブラリのPanResponderなど、より低レベルのAPIを検討する必要があるかもしれません。onLongPresspressRetentionOffsetが期待通りに動かない場合、PanResponderで独自に長押しと移動のロジックを実装することも可能です。
  3. Pressableを明示的に使用する
    • Textに直接onPressを設定するのではなく、TextPressableでラップして、pressRetentionOffsetPressableに設定します。
    <Pressable
      onPress={() => console.log('Pressed')}
      pressRetentionOffset={20}
    >
      <Text>私のテキスト</Text>
    </Pressable>
    
  4. onPressInとonPressOutで挙動を確認する
    • onPressInonPressOutのコールバック関数でログを出力し、指の動きとこれらのイベントの発火タイミングを確認します。これにより、いつ「押された状態」が解除されているのかがわかります。
  5. 異なるデバイスやエミュレーターでテストする
    • 特定のデバイス(特にAndroidの物理デバイス)でのみ問題が発生する場合、それはOSバージョンやデバイス固有のバグである可能性があります。複数の環境でテストして問題を切り分けましょう。
  6. React Nativeのバージョンを最新にする
    • フレームワークのバグが原因である場合、最新の安定版にアップデートすることで修正されている可能性があります。
  7. hitSlopとの関係を理解する
    • hitSloppressRetentionOffsetは協調して機能します。pressRetentionOffsethitSlopで定義された領域の外側にも適用されるため、両方の設定を適切に考慮することが重要です。

ネストされたTextコンポーネントでの問題

考えられる原因

  • Textコンポーネントの中に別のTextコンポーネントをネストし、それぞれのTextonPresspressRetentionOffsetを設定している場合、予期せぬ挙動になることがあります。過去には、このようなネストされたTextでのタッチイベントが正しく伝播しないバグが報告されていました。
  1. ネストを避けるか、Viewでラップする
    • 基本的には、タッチイベントを処理する要素は独立したPressableTouchableコンポーネントとし、Textをネストするのではなく、Viewなどで構造を分けることを検討してください。
    • 例えば、テキストの一部だけをタップ可能にしたい場合、その部分を別のPressableでラップし、必要に応じてViewでフローティングコンテナを構築します。
  • 公式ドキュメントとGitHub Issuesを参照する
    React Nativeの公式ドキュメントや、React NativeのGitHubリポジトリのIssuesを検索すると、同様の問題に遭遇した開発者の報告や解決策が見つかることがあります。
  • デバッグツールを活用する
    React Native DebuggerやFlipperなどのデバッグツールを使って、コンポーネントツリー、スタイル、イベントの発火状況を詳細に確認することが、問題解決の鍵となります。
  • シンプルなケースからテストする
    pressRetentionOffsetの動作を確認するために、複雑なレイアウトや多数のイベントハンドラを持つコンポーネントではなく、最小限のコンポーネントでテスト用の画面を作成してみましょう。


pressRetentionOffset の基本

pressRetentionOffset は、ユーザーが要素(この場合、TextをラップしたPressable)をタッチしている間、指がどれくらいずれても「押されている状態」を維持するかを定義します。これにより、ユーザーが指を少し動かしてしまっても、タップがキャンセルされるのを防ぎ、ユーザー体験を向上させます。

基本的な考え方

  1. Pressable コンポーネントを使用する
    Textコンポーネント自体に直接pressRetentionOffsetを設定することはできません。TextコンポーネントをPressableコンポーネントでラップし、そのPressablepressRetentionOffsetを設定します。
  2. イベントの視覚化
    onPressInonPressOutonPress のイベントハンドラでログを出力したり、押された状態に応じてスタイルを変更したりすることで、pressRetentionOffset の効果を視覚的に確認できます。

例1:基本的な pressRetentionOffset の適用

この例では、PressableでラップされたTextがあり、指が20ピクセルまでずれても押された状態を維持します。押されている間、背景色が変わります。

import React, { useState } from 'react';
import { Pressable, Text, StyleSheet, View } from 'react-native';

const BasicPressRetentionOffset = () => {
  const [isPressed, setIsPressed] = useState(false);
  const [statusMessage, setStatusMessage] = useState('タッチしてください');

  return (
    <View style={styles.container}>
      <Pressable
        onPressIn={() => {
          setIsPressed(true);
          setStatusMessage('押されています (onPressIn)');
          console.log('onPressIn fired');
        }}
        onPressOut={() => {
          setIsPressed(false);
          setStatusMessage('離されました (onPressOut)');
          console.log('onPressOut fired');
        }}
        onPress={() => {
          setStatusMessage('タップされました (onPress)');
          console.log('onPress fired');
        }}
        // ここで pressRetentionOffset を設定
        // 指が20pxまでずれても押された状態を維持
        pressRetentionOffset={20} 
        style={({ pressed }) => [
          styles.button,
          pressed ? styles.buttonPressed : styles.buttonNormal,
        ]}
      >
        <Text style={styles.buttonText}>
          指をずらして試してください
        </Text>
      </Pressable>
      <Text style={styles.statusText}>状態: {statusMessage}</Text>
      <Text style={styles.descriptionText}>
        指をボタンに置き、少しずらしても青い状態が続くことを確認してください。
        20px以上ずらすと、青い状態が解除されます。
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  button: {
    paddingVertical: 15,
    paddingHorizontal: 30,
    borderRadius: 10,
    marginVertical: 20,
    elevation: 3, // Android shadows
    shadowColor: '#000', // iOS shadows
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  buttonNormal: {
    backgroundColor: '#007bff', // 通常時
  },
  buttonPressed: {
    backgroundColor: '#0056b3', // 押されている時
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  statusText: {
    marginTop: 20,
    fontSize: 16,
    color: '#333',
  },
  descriptionText: {
    marginTop: 30,
    fontSize: 14,
    color: '#666',
    textAlign: 'center',
    lineHeight: 20,
  },
});

export default BasicPressRetentionOffset;

例2:方向ごとに異なる pressRetentionOffset を設定

pressRetentionOffset は、上下左右で異なる値を設定することも可能です。これは、特定の方向にだけ指をずらす余裕を持たせたい場合に便利です。

import React, { useState } from 'react';
import { Pressable, Text, StyleSheet, View } from 'react-native';

const DirectionalPressRetentionOffset = () => {
  const [statusMessage, setStatusMessage] = useState('タッチしてください');

  return (
    <View style={styles.container}>
      <Pressable
        onPressIn={() => {
          setStatusMessage('押されています (onPressIn)');
          console.log('onPressIn fired');
        }}
        onPressOut={() => {
          setStatusMessage('離されました (onPressOut)');
          console.log('onPressOut fired');
        }}
        onPress={() => {
          setStatusMessage('タップされました (onPress)');
          console.log('onPress fired');
        }}
        // 上:10px, 左:10px, 下:50px, 右:10px のオフセットを設定
        pressRetentionOffset={{ top: 10, left: 10, bottom: 50, right: 10 }}
        style={({ pressed }) => [
          styles.button,
          pressed ? styles.buttonPressed : styles.buttonNormal,
        ]}
      >
        <Text style={styles.buttonText}>
          下に大きくずらして試す
        </Text>
      </Pressable>
      <Text style={styles.statusText}>状態: {statusMessage}</Text>
      <Text style={styles.descriptionText}>
        指をボタンに置き、下方向に大きく(約50pxまで)ずらしても青い状態が続くことを確認してください。
        上、左、右方向には10pxしか余裕がありません。
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  button: {
    paddingVertical: 15,
    paddingHorizontal: 30,
    borderRadius: 10,
    marginVertical: 20,
    elevation: 3,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  buttonNormal: {
    backgroundColor: '#28a745', // 通常時
  },
  buttonPressed: {
    backgroundColor: '#218838', // 押されている時
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  statusText: {
    marginTop: 20,
    fontSize: 16,
    color: '#333',
  },
  descriptionText: {
    marginTop: 30,
    fontSize: 14,
    color: '#666',
    textAlign: 'center',
    lineHeight: 20,
  },
});

export default DirectionalPressRetentionOffset;

例3:hitSloppressRetentionOffset の組み合わせ

hitSlop はタップ可能な領域を「拡大」し、pressRetentionOffset はタップが「継続」できる領域を定義します。これらを組み合わせることで、より柔軟なタッチ体験を提供できます。

この例では、ボタンの実際の表示領域の外側(hitSlopで指定された領域)をタップしてもボタンが反応し、さらに指を離さずにpressRetentionOffsetで指定された範囲までずらしても押された状態を維持します。

import React, { useState } from 'react';
import { Pressable, Text, StyleSheet, View } from 'react-native';

const HitSlopAndPressRetention = () => {
  const [statusMessage, setStatusMessage] = useState('タッチしてください');

  return (
    <View style={styles.container}>
      <Pressable
        onPressIn={() => {
          setStatusMessage('押されています (onPressIn)');
          console.log('onPressIn fired');
        }}
        onPressOut={() => {
          setStatusMessage('離されました (onPressOut)');
          console.log('onPressOut fired');
        }}
        onPress={() => {
          setStatusMessage('タップされました (onPress)');
          console.log('onPress fired');
        }}
        // タップ可能な領域を上下左右20px拡大
        hitSlop={20} 
        // 指が上下左右30pxまでずれても押された状態を維持
        pressRetentionOffset={30} 
        style={({ pressed }) => [
          styles.button,
          pressed ? styles.buttonPressed : styles.buttonNormal,
        ]}
      >
        <Text style={styles.buttonText}>
          広範囲をタップ&ずらして試す
        </Text>
      </Pressable>
      <Text style={styles.statusText}>状態: {statusMessage}</Text>
      <Text style={styles.descriptionText}>
        ボタンの見た目より少し外側をタップしてみてください(hitSlopの効果)。
        その後、指を離さずにさらに大きくずらしても、青い状態が続くことを確認してください
        (pressRetentionOffsetの効果)。
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  button: {
    paddingVertical: 15,
    paddingHorizontal: 30,
    borderRadius: 10,
    marginVertical: 20,
    borderWidth: 2, // hitSlopとの関係を視覚化するために追加
    borderColor: 'purple', // hitSlopの領域を想像しやすく
    elevation: 3,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  buttonNormal: {
    backgroundColor: '#ffc107', // 通常時
  },
  buttonPressed: {
    backgroundColor: '#e0a800', // 押されている時
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  statusText: {
    marginTop: 20,
    fontSize: 16,
    color: '#333',
  },
  descriptionText: {
    marginTop: 30,
    fontSize: 14,
    color: '#666',
    textAlign: 'center',
    lineHeight: 20,
  },
});

export default HitSlopAndPressRetention;


pressRetentionOffset は、Pressable コンポーネントに組み込まれた非常に便利な機能ですが、特定の状況下でより細かい制御が必要になったり、異なる要件を満たすために代替のアプローチを検討することがあります。

主な代替手段としては、React Native が提供する低レベルのジェスチャーシステムである PanResponder を利用する方法が挙げられます。

PanResponder の利用 (より高度な制御)

PanResponder は、単一の View コンポーネント上のマルチタッチジェスチャーシステムをカプセル化する API です。これにより、pressRetentionOffset では実現できないような、より複雑でカスタマイズされたタッチ検出ロジックを実装できます。

PanResponder でできること

  • 他のジェスチャーとの統合
    長押し、スワイプ、ピンチなど、複数のジェスチャーを組み合わせた複雑なインタラクションを実現する基盤として利用できます。
  • カスタムの「押された状態」定義
    pressRetentionOffset のように固定のオフセットではなく、指の移動距離や特定の領域に入ったかどうかなど、独自のロジックに基づいて「押された状態」を継続させるかどうかを判断できます。
  • 指の動きを詳細に追跡
    onStartShouldSetPanResponderonMoveShouldSetPanResponderonPanResponderGrantonPanResponderMoveonPanResponderRelease などのイベントハンドラを通じて、指が画面に触れた瞬間から離れるまでの座標、速度、移動量などを正確に取得できます。

PanResponder の基本的な考え方

  1. PanResponder.create() を使用して PanResponder インスタンスを作成します。
  2. 各イベントハンドラで、ジェスチャーを処理するかどうかを決定するロジックを記述します(onStartShouldSetPanResponder など)。
  3. onPanResponderGrant でタッチが開始されたことを検出し、「押された状態」を開始します。
  4. onPanResponderMove で指の移動を追跡し、カスタムロジック(例えば、指が特定の閾値を超えて移動したら「押された状態」を解除する)を実装します。
  5. onPanResponderReleaseonPanResponderTerminate でタッチが終了したことを検出し、「押された状態」を解除します。

PanResponder を使った代替例 (擬似コード)

import React, { useRef, useState } from 'react';
import { View, Text, StyleSheet, PanResponder, Animated } from 'react-native';

const CustomPressRetentionWithPanResponder = () => {
  const [isPressed, setIsPressed] = useState(false);
  const [statusMessage, setStatusMessage] = useState('タッチしてください');
  const pressActiveArea = 50; // 指がこの範囲内なら「押された状態」を維持するカスタム閾値

  const panResponder = useRef(
    PanResponder.create({
      // タッチが開始されたときにジェスチャーを処理するかどうか
      onStartShouldSetPanResponder: () => true,
      // 指が動いたときにジェスチャーを処理するかどうか
      onMoveShouldSetPanResponder: () => true,

      // ジェスチャーが許可されたとき(タッチが開始されたとき)
      onPanResponderGrant: (evt, gestureState) => {
        setIsPressed(true);
        setStatusMessage('押されました (PanResponderGrant)');
        console.log('PanResponderGrant at:', gestureState.x0, gestureState.y0);
        // タッチ開始時の座標を記録 (例: この座標から50px以内なら押された状態)
        this._touchStartX = gestureState.x0;
        this._touchStartY = gestureState.y0;
      },

      // 指が動いているとき
      onPanResponderMove: (evt, gestureState) => {
        // 現在の指の座標と開始時の座標からの距離を計算
        const dx = gestureState.dx; // x方向の移動量
        const dy = gestureState.dy; // y方向の移動量
        const distance = Math.sqrt(dx * dx + dy * dy); // ユークリッド距離

        if (distance > pressActiveArea) {
          // 定義した閾値を超えたら「押された状態」を解除
          if (isPressed) { // 既に押された状態であれば解除
            setIsPressed(false);
            setStatusMessage(`押された状態解除 (距離: ${distance.toFixed(0)}px)`);
            console.log('PanResponderMove: Press state released due to distance');
          }
        } else {
          // 閾値内なら「押された状態」を維持
          if (!isPressed) { // 押されていない状態であれば再度押された状態にする
            setIsPressed(true);
            setStatusMessage(`押されています (距離: ${distance.toFixed(0)}px)`);
            console.log('PanResponderMove: Press state maintained');
          }
        }
      },

      // ジェスチャーが終了したとき(指が離されたとき)
      onPanResponderRelease: (evt, gestureState) => {
        setIsPressed(false);
        setStatusMessage('離されました (PanResponderRelease)');
        console.log('PanResponderRelease');
        
        // 指が離された時の距離に基づいて、onPressに相当する処理を行うか判断
        const distance = Math.sqrt(gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy);
        if (distance <= pressActiveArea) {
          console.log('Custom Tap detected!');
          // ここで通常のonPressのような処理を行う
        }
      },

      // 他のコンポーネントがジェスチャーを奪い取ろうとしたとき
      onPanResponderTerminate: (evt, gestureState) => {
        setIsPressed(false);
        setStatusMessage('ジェスチャーが中断されました (PanResponderTerminate)');
        console.log('PanResponderTerminate');
      },
    })
  ).current;

  return (
    <View style={styles.container}>
      <View
        // PanResponderのプロパティをViewに適用
        {...panResponder.panHandlers}
        style={[
          styles.button,
          isPressed ? styles.buttonPressed : styles.buttonNormal,
        ]}
      >
        <Text style={styles.buttonText}>
          PanResponderでカスタム制御
        </Text>
      </View>
      <Text style={styles.statusText}>状態: {statusMessage}</Text>
      <Text style={styles.descriptionText}>
        指をボタンに置き、ずらしてみてください。
        この例では、開始位置から約 {pressActiveArea}px 以上ずれると「押された状態」が解除されます。
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  button: {
    paddingVertical: 15,
    paddingHorizontal: 30,
    borderRadius: 10,
    marginVertical: 20,
    elevation: 3,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  buttonNormal: {
    backgroundColor: '#ff5722', // 通常時
  },
  buttonPressed: {
    backgroundColor: '#e64a19', // 押されている時
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  statusText: {
    marginTop: 20,
    fontSize: 16,
    color: '#333',
  },
  descriptionText: {
    marginTop: 30,
    fontSize: 14,
    color: '#666',
    textAlign: 'center',
    lineHeight: 20,
  },
});

export default CustomPressRetentionWithPanResponder;

PanResponder のメリットとデメリット

  • デメリット
    • 実装が複雑で、学習コストが高い。
    • シンプルなタップ検出にはオーバーキルとなる場合が多い。
    • パフォーマンスの最適化に注意が必要になることがある。
  • メリット
    • 非常に細かいジェスチャー制御が可能。
    • 複数の指の動きや複雑なパターンを検出できる。
    • カスタムの「押された状態」ロジックを自由に定義できる。

react-native-gesture-handler の利用 (推奨される高レベルな選択肢)

react-native-gesture-handler は、React Native のネイティブジェスチャーシステムを抽象化し、より宣言的で使いやすい API を提供する人気のライブラリです。PanResponder よりも高レベルな抽象化がされており、通常はこれを使用することが推奨されます。

このライブラリには、TapGestureHandlerLongPressGestureHandlerPanGestureHandler など、さまざまな種類のジェスチャーハンドラが含まれています。

react-native-gesture-handler でできること

  • LongPressGestureHandler の minDurationMs と maxPointers
    長押しに関連する設定も可能です。
  • TapGestureHandler の設定
    maxDist などのプロパティを設定することで、タップが認識される際の指の許容移動距離を調整できます。これは pressRetentionOffset に近い挙動を実現できます。
  • PanGestureHandler の利用
    PanResponder と同様に指の移動を追跡できますが、よりシンプルに記述できます。onGestureEventtranslationX/YvelocityX/Y などのプロパティを取得できます。

react-native-gesture-handler を使った代替例 (擬似コード - PanGestureHandler)

// まず、npm install react-native-gesture-handler react-native-reanimated
// およびネイティブリンク(iOS: pod install, Android: rebuild)が必要です。

import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated'; // ジェスチャーハンドラーと連携

const CustomPressRetentionWithGestureHandler = () => {
  const [statusMessage, setStatusMessage] = useState('タッチしてください');
  const pressActiveArea = 50; // 指がこの範囲内なら「押された状態」を維持するカスタム閾値

  const onGestureEvent = Animated.event(
    [{
      nativeEvent: ({ translationX, translationY }) => {
        const distance = Math.sqrt(translationX * translationX + translationY * translationY);
        // ここでカスタムロジックを適用
        if (distance > pressActiveArea) {
            setStatusMessage(`押された状態解除 (距離: ${distance.toFixed(0)}px)`);
        } else {
            setStatusMessage(`押されています (距離: ${distance.toFixed(0)}px)`);
        }
      },
    }],
    { useNativeDriver: true }
  );

  const onHandlerStateChange = ({ nativeEvent }) => {
    if (nativeEvent.state === State.BEGAN) {
      setStatusMessage('押されました (Began)');
    } else if (nativeEvent.state === State.END) {
      const distance = Math.sqrt(nativeEvent.translationX * nativeEvent.translationX + nativeEvent.translationY * nativeEvent.translationY);
      if (distance <= pressActiveArea) {
        setStatusMessage('タップされました (Tap Detected)');
        console.log('Custom Tap Detected via Gesture Handler!');
      } else {
        setStatusMessage('離されました (End)');
      }
    } else if (nativeEvent.state === State.CANCELLED || nativeEvent.state === State.FAILED) {
      setStatusMessage('ジェスチャーが中断されました');
    }
  };

  return (
    <View style={styles.container}>
      <PanGestureHandler
        onGestureEvent={onGestureEvent}
        onHandlerStateChange={onHandlerStateChange}
      >
        <Animated.View // Animated.View を使用することで、よりスムーズなアニメーションが可能
          style={[
            styles.button,
            // isPressed の代わりに、onHandlerStateChange のロジックでスタイルを制御
            // 例えば、nativeEvent.state === State.ACTIVE の場合にスタイルを適用するなど
            // この例ではシンプル化のため、stateによるスタイル変更は省略
          ]}
        >
          <Text style={styles.buttonText}>
            GestureHandlerでカスタム制御
          </Text>
        </Animated.View>
      </PanGestureHandler>
      <Text style={styles.statusText}>状態: {statusMessage}</Text>
      <Text style={styles.descriptionText}>
        指をボタンに置き、ずらしてみてください。
        この例では、開始位置から約 {pressActiveArea}px 以上ずれると「押された状態」のメッセージが変わります。
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  button: {
    paddingVertical: 15,
    paddingHorizontal: 30,
    borderRadius: 10,
    marginVertical: 20,
    elevation: 3,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    backgroundColor: '#673ab7', // 通常時
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  statusText: {
    marginTop: 20,
    fontSize: 16,
    color: '#333',
  },
  descriptionText: {
    marginTop: 30,
    fontSize: 14,
    color: '#666',
    textAlign: 'center',
    lineHeight: 20,
  },
});

export default CustomPressRetentionWithGestureHandler;
  • デメリット
    • 追加のライブラリのインストールとネイティブリンクが必要。
    • Animatedと組み合わせることでより強力になるが、その分学習コストが増える可能性がある。
  • メリット
    • PanResponderよりも宣言的で、より簡単に複雑なジェスチャーを扱える。
    • ネイティブのスレッドでジェスチャーが処理されるため、パフォーマンスが高い。
    • React Native の標準的なジェスチャー検出の問題(特にScrollViewとの競合など)を解決するのに役立つ。
  • より高度な制御が必要な場合
    • react-native-gesture-handlerが最も推奨される代替手段です。ほとんどのカスタムジェスチャーニーズに対応できます。
    • ごく稀に、PanResponderが唯一の解決策となるような、非常に低レベルでカスタマイズされたジェスチャーが必要な場合もあります。
  • シンプルな用途
    ほとんどの場合、PressablepressRetentionOffsetで十分です。