wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

React Nativeでスクロールとタッチイベントとスワイプイベントを同時に使用する

memoirの不具合フィードバックで以下の報告があったので修正

  • 上下スクロールしていると、意図せずスワイプイベントが発火して日付移動してしまう
  • タッチインベントを設定している箇所でスワイプイベントが発火しない

PR

github.com

実装

元々はreact-native-swipe-gesturesを使用してスワイプイベントを実装していたが、2年前から更新されてなかったので移行えを検討

github.com

検索したところ、react-native-gesture-handlerで可能なことが分かったので実装

github.com

react-native-gesture-handlerの PanGestureHandlerで実装

docs.swmansion.com

コードは以下のようになった

src/components/organisms/Home/GestureRecognizerWrap.tsx

import React, { memo, useCallback } from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import View from 'components/atoms/View';
import dayjs from 'lib/dayjs';
import { ConnectedType } from 'components/pages/Home/Connected';
import {
  GestureHandlerRootView,
  PanGestureHandler,
  ScrollView,
  HandlerStateChangeEvent,
} from 'react-native-gesture-handler';

type Props = {
  date: string;
  children: React.ReactNode;
} & Pick<ConnectedType, 'onChangeDate' | 'items'>;

const velocityThreshold = 0.3;
const directionalOffsetThreshold = 80;

const isValidSwipe = (velocity: number, directionalOffset: number) => {
  return (
    Math.abs(velocity) > velocityThreshold &&
    Math.abs(directionalOffset) < directionalOffsetThreshold
  );
};

const GestureRecognizerWrap: React.FC<Props> = (props) => {
  const onSwipeLeft = useCallback(() => {
    props.onChangeDate(dayjs(props.date).add(1, 'day').format('YYYY-MM-DD'));
  }, [props]);

  const onSwipeRight = useCallback(() => {
    props.onChangeDate(dayjs(props.date).add(-1, 'day').format('YYYY-MM-DD'));
  }, [props]);

  const onPanGestureEvent = useCallback(
    (event: HandlerStateChangeEvent<any>) => {
      const { nativeEvent } = event;

      if (Math.abs(nativeEvent.velocityY) > 300) {
        return;
      }

      if (!isValidSwipe(nativeEvent.velocityX, nativeEvent.translationX)) {
        return;
      }

      if (nativeEvent.velocityX > 0) {
        onSwipeRight();
      } else {
        onSwipeLeft();
      }
    },
    [onSwipeRight, onSwipeLeft]
  );

  const style: ViewStyle[] = [styles.inner];
  if (props.items.length > 3) {
    style.push({ paddingBottom: (props.items.length - 2) * 55 });
  }

  return (
    <View style={styles.root}>
      <GestureHandlerRootView>
        <PanGestureHandler onActivated={onPanGestureEvent}>
          <ScrollView removeClippedSubviews style={styles.scroll}>
            <View style={style}>{props.children}</View>
          </ScrollView>
        </PanGestureHandler>
      </GestureHandlerRootView>
    </View>
  );
};

export default memo<React.FC<Props>>(GestureRecognizerWrap);

const styles = StyleSheet.create({
  inner: {
    height: '100%',
  },
  scroll: {
    height: '100%',
  },
  root: {
    height: '100%',
  },
});

コードの説明をすると、スワイプのイベントが欲しいコンポーネントを以下でラップする

      <GestureHandlerRootView>
        <PanGestureHandler onActivated={onPanGestureEvent}>
            ....
        </PanGestureHandler>
      </GestureHandlerRootView>

PanGestureHandlerのonActivatedでジェスチャーのイベントを取得できるので、その情報を元に右スワイプ、左スワイプを判定

  const onPanGestureEvent = useCallback(
    (event: HandlerStateChangeEvent<any>) => {
      const { nativeEvent } = event;

      if (Math.abs(nativeEvent.velocityY) > 300) {
        return;
      }

      if (!isValidSwipe(nativeEvent.velocityX, nativeEvent.translationX)) {
        return;
      }

      if (nativeEvent.velocityX > 0) {
        onSwipeRight();
      } else {
        onSwipeLeft();
      }
    },
    [onSwipeRight, onSwipeLeft]
  );

また、スワイプとスクロールが同時に発生しないようにreact-native-gesture-handlerScrollViewを使用

import {
  GestureHandlerRootView,
  PanGestureHandler,
  ScrollView,   // ← こちらを使用
  HandlerStateChangeEvent,
} from 'react-native-gesture-handler';

■ 参考 - https://github.com/software-mansion/react-native-gesture-handler/issues/420#issuecomment-592686502

これで、スクロールとタッチイベントとスワイプイベントが同時に存在する画面でも快適動作できるようになった。