import React, {useState} from 'react' import { Animated, GestureResponderEvent, I18nManager, PanResponder, PanResponderGestureState, useWindowDimensions, View, } from 'react-native' import {clamp} from 'lodash' import {s} from 'lib/styles' export enum Dir { None, Up, Down, Left, Right, Zoom, } interface Props { panX: Animated.Value panY: Animated.Value zoom: Animated.Value canSwipeLeft?: boolean canSwipeRight?: boolean canSwipeUp?: boolean canSwipeDown?: boolean swipeEnabled?: boolean zoomEnabled?: boolean hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture horzDistThresholdDivisor?: number vertDistThresholdDivisor?: number useNativeDriver?: boolean onSwipeStart?: () => void onSwipeStartDirection?: (dir: Dir) => void onSwipeEnd?: (dir: Dir) => void children: React.ReactNode } export function SwipeAndZoom({ panX, panY, zoom, canSwipeLeft = false, canSwipeRight = false, canSwipeUp = false, canSwipeDown = false, swipeEnabled = false, zoomEnabled = false, hasPriority = false, horzDistThresholdDivisor = 1.75, vertDistThresholdDivisor = 1.75, useNativeDriver = false, onSwipeStart, onSwipeStartDirection, onSwipeEnd, children, }: Props) { const winDim = useWindowDimensions() const [dir, setDir] = useState(Dir.None) const [initialDistance, setInitialDistance] = useState( undefined, ) const swipeVelocityThreshold = 35 const swipeHorzDistanceThreshold = winDim.width / horzDistThresholdDivisor const swipeVertDistanceThreshold = winDim.height / vertDistThresholdDivisor const isMovingHorizontally = ( _: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { return ( Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) && Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25) ) } const isMovingVertically = ( _: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { return ( Math.abs(gestureState.dy) > Math.abs(gestureState.dx * 1.25) && Math.abs(gestureState.vy) > Math.abs(gestureState.vx * 1.25) ) } const canDir = (d: Dir) => { if (d === Dir.Left) { return canSwipeLeft } if (d === Dir.Right) { return canSwipeRight } if (d === Dir.Up) { return canSwipeUp } if (d === Dir.Down) { return canSwipeDown } if (d === Dir.Zoom) { return zoomEnabled } return false } const isHorz = (d: Dir) => d === Dir.Left || d === Dir.Right const isVert = (d: Dir) => d === Dir.Up || d === Dir.Down const canMoveScreen = ( event: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { if (zoomEnabled && gestureState.numberActiveTouches === 2) { return true } else if (swipeEnabled && gestureState.numberActiveTouches === 1) { const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx const dy = gestureState.dy const willHandle = (isMovingHorizontally(event, gestureState) && ((dx > 0 && canSwipeLeft) || (dx < 0 && canSwipeRight))) || (isMovingVertically(event, gestureState) && ((dy > 0 && canSwipeUp) || (dy < 0 && canSwipeDown))) return willHandle } return false } const startGesture = () => { setDir(Dir.None) onSwipeStart?.() // reset all state panX.stopAnimation() // @ts-expect-error: _value is private, but docs use it as well panX.setOffset(panX._value) panY.stopAnimation() // @ts-expect-error: _value is private, but docs use it as well panY.setOffset(panY._value) zoom.stopAnimation() // @ts-expect-error: _value is private, but docs use it as well zoom.setOffset(zoom._value) setInitialDistance(undefined) } const respondToGesture = ( e: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx const dy = gestureState.dy let newDir = Dir.None if (dir === Dir.None) { // establish if the user is swiping horz or vert, or zooming if (gestureState.numberActiveTouches === 2) { newDir = Dir.Zoom } else if (Math.abs(dx) > Math.abs(dy)) { newDir = dx > 0 ? Dir.Left : Dir.Right } else { newDir = dy > 0 ? Dir.Up : Dir.Down } } else if (isHorz(dir)) { // direction update newDir = dx > 0 ? Dir.Left : Dir.Right } else if (isVert(dir)) { // direction update newDir = dy > 0 ? Dir.Up : Dir.Down } else { newDir = dir } if (newDir === Dir.Zoom) { if (zoomEnabled) { if (gestureState.numberActiveTouches === 2) { // zoom in/out const x0 = e.nativeEvent.touches[0].pageX const x1 = e.nativeEvent.touches[1].pageX const y0 = e.nativeEvent.touches[0].pageY const y1 = e.nativeEvent.touches[1].pageY const zoomDx = Math.abs(x0 - x1) const zoomDy = Math.abs(y0 - y1) const dist = Math.sqrt(zoomDx * zoomDx + zoomDy * zoomDy) / 100 if ( typeof initialDistance === 'undefined' || dist - initialDistance < 0 ) { setInitialDistance(dist) } else { zoom.setValue(dist - initialDistance) } } else { // pan around after zooming panX.setValue(clamp(dx / winDim.width, -1, 1) * -1) panY.setValue(clamp(dy / winDim.height, -1, 1) * -1) } } } else if (isHorz(newDir)) { // swipe left/right panX.setValue( clamp( dx / swipeHorzDistanceThreshold, canSwipeRight ? -1 : 0, canSwipeLeft ? 1 : 0, ) * -1, ) panY.setValue(0) } else if (isVert(newDir)) { // swipe up/down panY.setValue( clamp( dy / swipeVertDistanceThreshold, canSwipeDown ? -1 : 0, canSwipeUp ? 1 : 0, ) * -1, ) panX.setValue(0) } if (!canDir(newDir)) { newDir = Dir.None } if (newDir !== dir) { setDir(newDir) onSwipeStartDirection?.(newDir) } } const finishGesture = ( _: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { const finish = (finalDir: Dir) => () => { if (finalDir !== Dir.None) { onSwipeEnd?.(finalDir) } setDir(Dir.None) panX.flattenOffset() panX.setValue(0) panY.flattenOffset() panY.setValue(0) } if ( isHorz(dir) && (Math.abs(gestureState.dx) > swipeHorzDistanceThreshold / 4 || Math.abs(gestureState.vx) > swipeVelocityThreshold) ) { // horizontal swipe reset Animated.timing(panX, { toValue: dir === Dir.Left ? -1 : 1, duration: 100, useNativeDriver, }).start(finish(dir)) } else if ( isVert(dir) && (Math.abs(gestureState.dy) > swipeVertDistanceThreshold / 8 || Math.abs(gestureState.vy) > swipeVelocityThreshold) ) { // vertical swipe reset Animated.timing(panY, { toValue: dir === Dir.Up ? -1 : 1, duration: 100, useNativeDriver, }).start(finish(dir)) } else { // zoom (or no direction) reset onSwipeEnd?.(Dir.None) Animated.timing(panX, { toValue: 0, duration: 100, useNativeDriver, }).start() Animated.timing(panY, { toValue: 0, duration: 100, useNativeDriver, }).start() Animated.timing(zoom, { toValue: 0, duration: 100, useNativeDriver, }).start() } } const panResponder = PanResponder.create({ onMoveShouldSetPanResponder: canMoveScreen, onPanResponderGrant: startGesture, onPanResponderMove: respondToGesture, onPanResponderTerminate: finishGesture, onPanResponderRelease: finishGesture, onPanResponderTerminationRequest: () => !hasPriority, }) return ( {children} ) }