Fusic Tech Blog

Fusion of Society, IT and Culture

React Native Gesture Handlerで画像のズームやドラッグができるビュワーを作る
2022/06/10

React Native Gesture Handlerで画像のズームやドラッグができるビュワーを作る

はじめに

React Nativeでピンチイン、ピンチアウトでズームできたり、ドラッグで移動できる画像表示ライブラリは色々ありますが、カスタマイズが必要になってくると既存のライブラリでは厳しい場合があります。

そこでReact Native Gesture Handler(以下RNGH)だけで画像ビュワーを作ってみようと思います。

動作イメージ

React Native Gesture Handler Image Viewer Demo

完成したコード

完成コードはSnackで公開しておりますので、気になる方は実際に動かしてみてください。

react-native-image-viewer-example - Snack

メイン部分のコードは以下です。それぞれ説明していきます。

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

import {
  GestureHandlerRootView,
  PanGestureHandler,
  PinchGestureHandler,
  State,
} from 'react-native-gesture-handler';

const baseScale = new Animated.Value(1);
const pinchScale = new Animated.Value(1);
const translateX = new Animated.Value(0);
const translateY = new Animated.Value(0);

// ズームイベント
const onZoomEvent = Animated.event(
  [
    {
      nativeEvent: { scale: pinchScale },
    },
  ],
  {
    useNativeDriver: true,
  }
);

// ドラッグイベント
const onPanEvent = Animated.event(
  [
    {
      nativeEvent: {
        translationX: translateX,
        translationY: translateY,
      },
    },
  ],
  { useNativeDriver: true }
);

const defaultPosition = { x: 0, y: 0, scale: 1 };

export default function App() {
  const imagePan = React.createRef();
  const imagePinch = React.createRef();
  const [lastPosition, setLastPosition] = useState(defaultPosition);
  const scale = Animated.multiply(baseScale, pinchScale);
  return (
    <View style={styles.container}>
      <GestureHandlerRootView>
        <PanGestureHandler
          ref={imagePan}
          simultaneousHandlers={imagePinch}
          onGestureEvent={onPanEvent}
          onHandlerStateChange={(event) => {
            if (event.nativeEvent.oldState === State.ACTIVE) {
              // ドラッグ終了時点の値を次の開始地点として設定
              const posX = lastPosition.x + event.nativeEvent.translationX;
              const posY = lastPosition.y + event.nativeEvent.translationY;
              setLastPosition({ x: posX, y: posY, scale: lastPosition.scale });
              translateX.setOffset(posX);
              translateX.setValue(0);
              translateY.setOffset(posY);
              translateY.setValue(0);
            }
          }}>
          <Animated.View>
            <PinchGestureHandler
              ref={imagePinch}
              simultaneousHandlers={imagePan}
              onHandlerStateChange={(event) => {
                if (event.nativeEvent.oldState === State.ACTIVE) {
                  // ズーム終了時点の値を次の開始スケール値として設定
                  const lastScale =
                    event.nativeEvent.scale < 1 ? 1 : event.nativeEvent.scale;
                  setLastPosition({ ...lastPosition, scale: lastScale });
                  baseScale.setValue(lastScale);
                  pinchScale.setValue(1);
                }
              }}
              onGestureEvent={onZoomEvent}>
              <Animated.Image
                source={require('./assets/snack-icon.png')}
                resizeMode="contain"
                style={[
                  {
                    width: 300,
                    height: 300,
                  },
                  { transform: [{ scale }, { translateX }, { translateY }] },
                ]}
              />
            </PinchGestureHandler>
          </Animated.View>
        </PanGestureHandler>
      </GestureHandlerRootView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

アニメーションに必要な値の設定

まずはアニメーションを行う上で必要な値を管理する変数を用意します。

const baseScale = new Animated.Value(1);
const pinchScale = new Animated.Value(1);
const translateX = new Animated.Value(0);
const translateY = new Animated.Value(0);
  • baseScale: 現在のスケール値を格納する変数
  • pinchScale: ピンチイベント発生時の値を格納する変数
  • translateX: ドラッグイベント発生時のx値を格納する変数
  • translateY: ドラッグイベント発生時のy値を格納する変数

ここでbaseScaleを用意している理由としては、ピンチインやピンチアウトした時に初期値のスケール値から拡大させるのではなく、すでにピンチアウトしていた時などに前のスケール値から拡大させるからです。

アニメーションイベントの定義

次にズームとドラッグの各イベントを定義します。

// ズームイベント
const onZoomEvent = Animated.event(
  [
    {
      nativeEvent: { scale: pinchScale },
    },
  ],
  {
    useNativeDriver: true,
  }
);

// ドラッグイベント
const onPanEvent = Animated.event(
  [
    {
      nativeEvent: {
        translationX: translateX,
        translationY: translateY,
      },
    },
  ],
  { useNativeDriver: true }
);

ビュワーコンポーネントの作成

最後にメイン部分であるコンポーネントを作成します。

const defaultPosition = { x: 0, y: 0, scale: 1 };

export default function App() {
  const imagePan = React.createRef();
  const imagePinch = React.createRef();
  const [lastPosition, setLastPosition] = useState(defaultPosition);
  const scale = Animated.multiply(baseScale, pinchScale);
  return (
    <View style={styles.container}>
      <GestureHandlerRootView>
        <PanGestureHandler
          ref={imagePan}
          simultaneousHandlers={imagePinch}
          onGestureEvent={onPanEvent}
          onHandlerStateChange={(event) => {
            if (event.nativeEvent.oldState === State.ACTIVE) {
              // ドラッグ終了時点の値を次の開始地点として設定
              const posX = lastPosition.x + event.nativeEvent.translationX;
              const posY = lastPosition.y + event.nativeEvent.translationY;
              setLastPosition({ x: posX, y: posY, scale: lastPosition.scale });
              translateX.setOffset(posX);
              translateX.setValue(0);
              translateY.setOffset(posY);
              translateY.setValue(0);
            }
          }}>
          <Animated.View>
            <PinchGestureHandler
              ref={imagePinch}
              simultaneousHandlers={imagePan}
              onHandlerStateChange={(event) => {
                if (event.nativeEvent.oldState === State.ACTIVE) {
                  // ズーム終了時点の値を次の開始スケール値として設定
                  const lastScale =
                    event.nativeEvent.scale < 1 ? 1 : event.nativeEvent.scale;
                  setLastPosition({ ...lastPosition, scale: lastScale });
                  baseScale.setValue(lastScale);
                  pinchScale.setValue(1);
                }
              }}
              onGestureEvent={onZoomEvent}>
              <Animated.Image
                source={require('./assets/snack-icon.png')}
                resizeMode="contain"
                style={[
                  {
                    width: 300,
                    height: 300,
                  },
                  { transform: [{ scale }, { translateX }, { translateY }] },
                ]}
              />
            </PinchGestureHandler>
          </Animated.View>
        </PanGestureHandler>
      </GestureHandlerRootView>
    </View>
  );
}

ポイントとしてはPanGestureHandlerPinGestureHandlerのrefをお互いのsimultaneousHandlersに渡しています。

デフォルトでは同時に一つのGesture Handlerしかアクティブになれないため、すでにアクティブになっているGesture Handlerがあれば、他のGesture Handlerは全てキャンセルされます。

複数のGesture Handlerを同時に連携して動かしたい場合は、上記のようにsimultaneousHandlersに連携するGesture Handlerを渡してあげる必要があります。

あとは、それぞれのイベントで移動した値と拡大縮小した値を状態管理してAnimated.Value()で用意した各変数へセット、Animated.Imageコンポーネントのスタイルに各値を渡してあげれば完成です。

おわりに

React Native Gesture Handlerで画像ビュワーを作成してみました。

思っていたよりコード量もそこまで多くないので、シンプルなものであれば他のライブラリをつかなくてもこのやり方で問題なさそうです。

参考

Daiki Urata

Daiki Urata

フロントエンド好きなエンジニアです。