about summary refs log tree commit diff
path: root/src/lib/custom-animations/GestureActionView.tsx
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-10-07 11:15:17 -0700
committerGitHub <noreply@github.com>2024-10-07 11:15:17 -0700
commit58b1d9326d7f5f308746e2471f5a7552bb0db250 (patch)
tree5e7a2d671ce4e4a010a1c967711cfd7fa9ec0c7d /src/lib/custom-animations/GestureActionView.tsx
parent8d80f1344df4897cfe4f754d37e654809850b794 (diff)
downloadvoidsky-58b1d9326d7f5f308746e2471f5a7552bb0db250.tar.zst
Swipeable to delete chat, custom swipeable (#5614)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/lib/custom-animations/GestureActionView.tsx')
-rw-r--r--src/lib/custom-animations/GestureActionView.tsx410
1 files changed, 410 insertions, 0 deletions
diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx
new file mode 100644
index 000000000..79e9db8a9
--- /dev/null
+++ b/src/lib/custom-animations/GestureActionView.tsx
@@ -0,0 +1,410 @@
+import React from 'react'
+import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import Animated, {
+  clamp,
+  interpolate,
+  interpolateColor,
+  runOnJS,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useDerivedValue,
+  useReducedMotion,
+  useSharedValue,
+  withSequence,
+  withTiming,
+} from 'react-native-reanimated'
+
+import {useHaptics} from '#/lib/haptics'
+
+interface GestureAction {
+  color: ColorValue
+  action: () => void
+  threshold: number
+  icon: React.ElementType
+}
+
+interface GestureActions {
+  leftFirst?: GestureAction
+  leftSecond?: GestureAction
+  rightFirst?: GestureAction
+  rightSecond?: GestureAction
+}
+
+const MAX_WIDTH = Dimensions.get('screen').width
+const ICON_SIZE = 32
+
+export function GestureActionView({
+  children,
+  actions,
+}: {
+  children: React.ReactNode
+  actions: GestureActions
+}) {
+  if (
+    (actions.leftSecond && !actions.leftFirst) ||
+    (actions.rightSecond && !actions.rightFirst)
+  ) {
+    throw new Error(
+      'You must provide the first action before the second action',
+    )
+  }
+
+  const [activeAction, setActiveAction] = React.useState<
+    'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
+  >(null)
+
+  const haptic = useHaptics()
+  const isReducedMotion = useReducedMotion()
+
+  const transX = useSharedValue(0)
+  const clampedTransX = useDerivedValue(() => {
+    const min = actions.leftFirst ? -MAX_WIDTH : 0
+    const max = actions.rightFirst ? MAX_WIDTH : 0
+    return clamp(transX.value, min, max)
+  })
+
+  const iconScale = useSharedValue(1)
+  const isActive = useSharedValue(false)
+  const hitFirst = useSharedValue(false)
+  const hitSecond = useSharedValue(false)
+
+  const runPopAnimation = () => {
+    'worklet'
+    if (isReducedMotion) {
+      return
+    }
+
+    iconScale.value = withSequence(
+      withTiming(1.2, {duration: 175}),
+      withTiming(1, {duration: 100}),
+    )
+  }
+
+  useAnimatedReaction(
+    () => transX,
+    () => {
+      if (transX.value === 0) {
+        runOnJS(setActiveAction)(null)
+      } else if (transX.value < 0) {
+        if (
+          actions.leftSecond &&
+          transX.value <= -actions.leftSecond.threshold
+        ) {
+          if (activeAction !== 'leftSecond') {
+            runOnJS(setActiveAction)('leftSecond')
+          }
+        } else if (activeAction !== 'leftFirst') {
+          runOnJS(setActiveAction)('leftFirst')
+        }
+      } else if (transX.value > 0) {
+        if (
+          actions.rightSecond &&
+          transX.value > actions.rightSecond.threshold
+        ) {
+          if (activeAction !== 'rightSecond') {
+            runOnJS(setActiveAction)('rightSecond')
+          }
+        } else if (activeAction !== 'rightFirst') {
+          runOnJS(setActiveAction)('rightFirst')
+        }
+      }
+    },
+  )
+
+  const panGesture = Gesture.Pan()
+    .activeOffsetX([-10, 10])
+    // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
+    // reanimated doesn't offer great support for disabling y/x axes :/
+    .activeOffsetY([-200, 200])
+    .onStart(() => {
+      'worklet'
+      isActive.value = true
+    })
+    .onChange(e => {
+      'worklet'
+      transX.value = e.translationX
+
+      if (e.translationX < 0) {
+        // Left side
+        if (actions.leftSecond) {
+          if (
+            e.translationX <= -actions.leftSecond.threshold &&
+            !hitSecond.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitSecond.value = true
+          } else if (
+            hitSecond.value &&
+            e.translationX > -actions.leftSecond.threshold
+          ) {
+            runPopAnimation()
+            hitSecond.value = false
+          }
+        }
+
+        if (!hitSecond.value && actions.leftFirst) {
+          if (
+            e.translationX <= -actions.leftFirst.threshold &&
+            !hitFirst.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitFirst.value = true
+          } else if (
+            hitFirst.value &&
+            e.translationX > -actions.leftFirst.threshold
+          ) {
+            hitFirst.value = false
+          }
+        }
+      } else if (e.translationX > 0) {
+        // Right side
+        if (actions.rightSecond) {
+          if (
+            e.translationX >= actions.rightSecond.threshold &&
+            !hitSecond.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitSecond.value = true
+          } else if (
+            hitSecond.value &&
+            e.translationX < actions.rightSecond.threshold
+          ) {
+            runPopAnimation()
+            hitSecond.value = false
+          }
+        }
+
+        if (!hitSecond.value && actions.rightFirst) {
+          if (
+            e.translationX >= actions.rightFirst.threshold &&
+            !hitFirst.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitFirst.value = true
+          } else if (
+            hitFirst.value &&
+            e.translationX < actions.rightFirst.threshold
+          ) {
+            hitFirst.value = false
+          }
+        }
+      }
+    })
+    .onEnd(e => {
+      'worklet'
+      if (e.translationX < 0) {
+        if (hitSecond.value && actions.leftSecond) {
+          runOnJS(actions.leftSecond.action)()
+        } else if (hitFirst.value && actions.leftFirst) {
+          runOnJS(actions.leftFirst.action)()
+        }
+      } else if (e.translationX > 0) {
+        if (hitSecond.value && actions.rightSecond) {
+          runOnJS(actions.rightSecond.action)()
+        } else if (hitSecond.value && actions.rightFirst) {
+          runOnJS(actions.rightFirst.action)()
+        }
+      }
+      transX.value = withTiming(0, {duration: 200})
+      hitFirst.value = false
+      hitSecond.value = false
+      isActive.value = false
+    })
+
+  const composedGesture = Gesture.Simultaneous(panGesture)
+
+  const animatedSliderStyle = useAnimatedStyle(() => {
+    return {
+      transform: [{translateX: clampedTransX.value}],
+    }
+  })
+
+  const leftSideInterpolation = React.useMemo(() => {
+    return createInterpolation({
+      firstColor: actions.leftFirst?.color,
+      secondColor: actions.leftSecond?.color,
+      firstThreshold: actions.leftFirst?.threshold,
+      secondThreshold: actions.leftSecond?.threshold,
+      side: 'left',
+    })
+  }, [actions.leftFirst, actions.leftSecond])
+
+  const rightSideInterpolation = React.useMemo(() => {
+    return createInterpolation({
+      firstColor: actions.rightFirst?.color,
+      secondColor: actions.rightSecond?.color,
+      firstThreshold: actions.rightFirst?.threshold,
+      secondThreshold: actions.rightSecond?.threshold,
+      side: 'right',
+    })
+  }, [actions.rightFirst, actions.rightSecond])
+
+  const interpolation = React.useMemo<{
+    inputRange: number[]
+    outputRange: ColorValue[]
+  }>(() => {
+    if (!actions.leftFirst) {
+      return rightSideInterpolation!
+    } else if (!actions.rightFirst) {
+      return leftSideInterpolation!
+    } else {
+      return {
+        inputRange: [
+          ...leftSideInterpolation.inputRange,
+          ...rightSideInterpolation.inputRange,
+        ],
+        outputRange: [
+          ...leftSideInterpolation.outputRange,
+          ...rightSideInterpolation.outputRange,
+        ],
+      }
+    }
+  }, [
+    leftSideInterpolation,
+    rightSideInterpolation,
+    actions.leftFirst,
+    actions.rightFirst,
+  ])
+
+  const animatedBackgroundStyle = useAnimatedStyle(() => {
+    return {
+      backgroundColor: interpolateColor(
+        clampedTransX.value,
+        interpolation.inputRange,
+        // @ts-expect-error - Weird type expected by reanimated, but this is okay
+        interpolation.outputRange,
+      ),
+    }
+  })
+
+  const animatedIconStyle = useAnimatedStyle(() => {
+    const absTransX = Math.abs(clampedTransX.value)
+    return {
+      opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
+      transform: [{scale: iconScale.value}],
+    }
+  })
+
+  return (
+    <GestureDetector gesture={composedGesture}>
+      <View>
+        <Animated.View
+          style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}>
+          <View
+            style={{
+              flex: 1,
+              marginHorizontal: 12,
+              justifyContent: 'center',
+              alignItems:
+                activeAction === 'leftFirst' || activeAction === 'leftSecond'
+                  ? 'flex-end'
+                  : 'flex-start',
+            }}>
+            <Animated.View style={[animatedIconStyle]}>
+              {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
+                <actions.leftFirst.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{
+                    color: 'white',
+                  }}
+                />
+              ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
+                <actions.leftSecond.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
+                <actions.rightFirst.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : activeAction === 'rightSecond' &&
+                actions.rightSecond?.icon ? (
+                <actions.rightSecond.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : null}
+            </Animated.View>
+          </View>
+        </Animated.View>
+        <Animated.View style={animatedSliderStyle}>{children}</Animated.View>
+      </View>
+    </GestureDetector>
+  )
+}
+
+function createInterpolation({
+  firstColor,
+  secondColor,
+  firstThreshold,
+  secondThreshold,
+  side,
+}: {
+  firstColor?: ColorValue
+  secondColor?: ColorValue
+  firstThreshold?: number
+  secondThreshold?: number
+  side: 'left' | 'right'
+}): {
+  inputRange: number[]
+  outputRange: ColorValue[]
+} {
+  if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
+    throw new Error(
+      'You must provide a second color if you provide a second threshold',
+    )
+  }
+
+  if (!firstThreshold) {
+    return {
+      inputRange: [0],
+      outputRange: ['transparent'],
+    }
+  }
+
+  const offset = side === 'left' ? -20 : 20
+
+  if (side === 'left') {
+    firstThreshold = -firstThreshold
+
+    if (secondThreshold) {
+      secondThreshold = -secondThreshold
+    }
+  }
+
+  let res
+  if (secondThreshold) {
+    res = {
+      inputRange: [
+        0,
+        firstThreshold,
+        firstThreshold + offset - 20,
+        secondThreshold,
+      ],
+      outputRange: ['transparent', firstColor!, firstColor!, secondColor!],
+    }
+  } else {
+    res = {
+      inputRange: [0, firstThreshold],
+      outputRange: ['transparent', firstColor!],
+    }
+  }
+
+  if (side === 'left') {
+    // Reverse the input/output ranges
+    res.inputRange.reverse()
+    res.outputRange.reverse()
+  }
+
+  return res
+}