45aa1a67b0
Co-authored-by: Tomek Zawadzki <tomekzawadzki98@gmail.com> Co-authored-by: vineyardbovines <spencerfpope@gmail.com>
405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
import {useMemo, useState} from 'react'
|
|
import {type ColorValue, Dimensions, View} from 'react-native'
|
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
|
import Animated, {
|
|
clamp,
|
|
interpolate,
|
|
interpolateColor,
|
|
useAnimatedReaction,
|
|
useAnimatedStyle,
|
|
useDerivedValue,
|
|
useReducedMotion,
|
|
useSharedValue,
|
|
withSequence,
|
|
withTiming,
|
|
} from 'react-native-reanimated'
|
|
import {scheduleOnRN} from 'react-native-worklets'
|
|
|
|
import {useHaptics} from '#/lib/haptics'
|
|
import {atoms as a} from '#/alf'
|
|
import {type GestureActions} from './GestureActionView.shared'
|
|
|
|
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] = 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.get(), 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.set(() =>
|
|
withSequence(
|
|
withTiming(1.2, {duration: 175}),
|
|
withTiming(1, {duration: 100}),
|
|
),
|
|
)
|
|
}
|
|
|
|
useAnimatedReaction(
|
|
() => transX,
|
|
() => {
|
|
if (transX.get() === 0) {
|
|
scheduleOnRN(setActiveAction, null)
|
|
} else if (transX.get() < 0) {
|
|
if (
|
|
actions.leftSecond &&
|
|
transX.get() <= -actions.leftSecond.threshold
|
|
) {
|
|
if (activeAction !== 'leftSecond') {
|
|
scheduleOnRN(setActiveAction, 'leftSecond')
|
|
}
|
|
} else if (activeAction !== 'leftFirst') {
|
|
scheduleOnRN(setActiveAction, 'leftFirst')
|
|
}
|
|
} else if (transX.get() > 0) {
|
|
if (
|
|
actions.rightSecond &&
|
|
transX.get() > actions.rightSecond.threshold
|
|
) {
|
|
if (activeAction !== 'rightSecond') {
|
|
scheduleOnRN(setActiveAction, 'rightSecond')
|
|
}
|
|
} else if (activeAction !== 'rightFirst') {
|
|
scheduleOnRN(setActiveAction, 'rightFirst')
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// NOTE(haileyok):
|
|
// 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 :/
|
|
const effectivelyDisabledOffset = 200
|
|
const panGesture = Gesture.Pan()
|
|
.activeOffsetX([
|
|
actions.leftFirst ? -10 : -effectivelyDisabledOffset,
|
|
actions.rightFirst ? 10 : effectivelyDisabledOffset,
|
|
])
|
|
.activeOffsetY([-effectivelyDisabledOffset, effectivelyDisabledOffset])
|
|
.onStart(() => {
|
|
'worklet'
|
|
isActive.set(true)
|
|
})
|
|
.onChange(e => {
|
|
'worklet'
|
|
transX.set(e.translationX)
|
|
|
|
if (e.translationX < 0) {
|
|
// Left side
|
|
if (actions.leftSecond) {
|
|
if (
|
|
e.translationX <= -actions.leftSecond.threshold &&
|
|
!hitSecond.get()
|
|
) {
|
|
runPopAnimation()
|
|
scheduleOnRN(haptic)
|
|
hitSecond.set(true)
|
|
} else if (
|
|
hitSecond.get() &&
|
|
e.translationX > -actions.leftSecond.threshold
|
|
) {
|
|
runPopAnimation()
|
|
hitSecond.set(false)
|
|
}
|
|
}
|
|
|
|
if (!hitSecond.get() && actions.leftFirst) {
|
|
if (
|
|
e.translationX <= -actions.leftFirst.threshold &&
|
|
!hitFirst.get()
|
|
) {
|
|
runPopAnimation()
|
|
scheduleOnRN(haptic)
|
|
hitFirst.set(true)
|
|
} else if (
|
|
hitFirst.get() &&
|
|
e.translationX > -actions.leftFirst.threshold
|
|
) {
|
|
hitFirst.set(false)
|
|
}
|
|
}
|
|
} else if (e.translationX > 0) {
|
|
// Right side
|
|
if (actions.rightSecond) {
|
|
if (
|
|
e.translationX >= actions.rightSecond.threshold &&
|
|
!hitSecond.get()
|
|
) {
|
|
runPopAnimation()
|
|
scheduleOnRN(haptic)
|
|
hitSecond.set(true)
|
|
} else if (
|
|
hitSecond.get() &&
|
|
e.translationX < actions.rightSecond.threshold
|
|
) {
|
|
runPopAnimation()
|
|
hitSecond.set(false)
|
|
}
|
|
}
|
|
|
|
if (!hitSecond.get() && actions.rightFirst) {
|
|
if (
|
|
e.translationX >= actions.rightFirst.threshold &&
|
|
!hitFirst.get()
|
|
) {
|
|
runPopAnimation()
|
|
scheduleOnRN(haptic)
|
|
hitFirst.set(true)
|
|
} else if (
|
|
hitFirst.get() &&
|
|
e.translationX < actions.rightFirst.threshold
|
|
) {
|
|
hitFirst.set(false)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.onEnd(e => {
|
|
'worklet'
|
|
if (e.translationX < 0) {
|
|
if (hitSecond.get() && actions.leftSecond) {
|
|
scheduleOnRN(actions.leftSecond.action)
|
|
} else if (hitFirst.get() && actions.leftFirst) {
|
|
scheduleOnRN(actions.leftFirst.action)
|
|
}
|
|
} else if (e.translationX > 0) {
|
|
if (hitSecond.get() && actions.rightSecond) {
|
|
scheduleOnRN(actions.rightSecond.action)
|
|
} else if (hitSecond.get() && actions.rightFirst) {
|
|
scheduleOnRN(actions.rightFirst.action)
|
|
}
|
|
}
|
|
transX.set(() => withTiming(0, {duration: 200}))
|
|
hitFirst.set(false)
|
|
hitSecond.set(false)
|
|
isActive.set(false)
|
|
})
|
|
|
|
const composedGesture = Gesture.Simultaneous(panGesture)
|
|
|
|
const animatedSliderStyle = useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{translateX: clampedTransX.get()}],
|
|
}
|
|
})
|
|
|
|
const leftSideInterpolation = 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 = 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 = 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.get(),
|
|
interpolation.inputRange,
|
|
// @ts-expect-error - Weird type expected by reanimated, but this is okay
|
|
interpolation.outputRange,
|
|
),
|
|
}
|
|
})
|
|
|
|
const animatedIconStyle = useAnimatedStyle(() => {
|
|
const absTransX = Math.abs(clampedTransX.get())
|
|
return {
|
|
opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
|
|
transform: [{scale: iconScale.get()}],
|
|
}
|
|
})
|
|
|
|
return (
|
|
<GestureDetector gesture={composedGesture}>
|
|
<View>
|
|
<Animated.View style={[a.absolute, a.inset_0, 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
|
|
}
|