301 lines
8 KiB
TypeScript
301 lines
8 KiB
TypeScript
import React, {useState} from 'react'
|
|
import {
|
|
Animated,
|
|
GestureResponderEvent,
|
|
I18nManager,
|
|
PanResponder,
|
|
PanResponderGestureState,
|
|
useWindowDimensions,
|
|
View,
|
|
} from 'react-native'
|
|
import {clamp} from 'lodash'
|
|
|
|
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>(Dir.None)
|
|
const [initialDistance, setInitialDistance] = useState<number | undefined>(
|
|
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 (
|
|
<View {...panResponder.panHandlers} style={{flex: 1}}>
|
|
{children}
|
|
</View>
|
|
)
|
|
}
|