React Native Gesture Handlerで画像のズームやドラッグができるビュワーを作る
2022/06/10
Table of Contents
はじめに
React Nativeでピンチイン、ピンチアウトでズームできたり、ドラッグで移動できる画像表示ライブラリは色々ありますが、カスタマイズが必要になってくると既存のライブラリでは厳しい場合があります。
そこでReact Native Gesture Handler(以下RNGH)だけで画像ビュワーを作ってみようと思います。
動作イメージ
完成したコード
完成コードは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>
);
}
ポイントとしてはPanGestureHandler
とPinGestureHandler
のrefをお互いのsimultaneousHandlers
に渡しています。
デフォルトでは同時に一つのGesture Handlerしかアクティブになれないため、すでにアクティブになっているGesture Handlerがあれば、他のGesture Handlerは全てキャンセルされます。
複数のGesture Handlerを同時に連携して動かしたい場合は、上記のようにsimultaneousHandlers
に連携するGesture Handlerを渡してあげる必要があります。
あとは、それぞれのイベントで移動した値と拡大縮小した値を状態管理してAnimated.Value()
で用意した各変数へセット、Animated.Image
コンポーネントのスタイルに各値を渡してあげれば完成です。
おわりに
React Native Gesture Handlerで画像ビュワーを作成してみました。
思っていたよりコード量もそこまで多くないので、シンプルなものであれば他のライブラリをつかなくてもこのやり方で問題なさそうです。