diff options
Diffstat (limited to 'src/view/com/util/gestures/SwipeAndZoom.tsx')
-rw-r--r-- | src/view/com/util/gestures/SwipeAndZoom.tsx | 291 |
1 files changed, 291 insertions, 0 deletions
diff --git a/src/view/com/util/gestures/SwipeAndZoom.tsx b/src/view/com/util/gestures/SwipeAndZoom.tsx new file mode 100644 index 000000000..dc3a9f54c --- /dev/null +++ b/src/view/com/util/gestures/SwipeAndZoom.tsx @@ -0,0 +1,291 @@ +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> + ) +} |