about summary refs log tree commit diff
path: root/src/view/com/util/gestures/SwipeAndZoom.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util/gestures/SwipeAndZoom.tsx')
-rw-r--r--src/view/com/util/gestures/SwipeAndZoom.tsx291
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>
+  )
+}