mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
3import {Gesture, GestureDetector} from 'react-native-gesture-handler'
4import Animated, {
5 clamp,
6 interpolate,
7 interpolateColor,
8 runOnJS,
9 useAnimatedReaction,
10 useAnimatedStyle,
11 useDerivedValue,
12 useReducedMotion,
13 useSharedValue,
14 withSequence,
15 withTiming,
16} from 'react-native-reanimated'
17
18import {useHaptics} from '#/lib/haptics'
19
20interface GestureAction {
21 color: ColorValue
22 action: () => void
23 threshold: number
24 icon: React.ElementType
25}
26
27interface GestureActions {
28 leftFirst?: GestureAction
29 leftSecond?: GestureAction
30 rightFirst?: GestureAction
31 rightSecond?: GestureAction
32}
33
34const MAX_WIDTH = Dimensions.get('screen').width
35const ICON_SIZE = 32
36
37export function GestureActionView({
38 children,
39 actions,
40}: {
41 children: React.ReactNode
42 actions: GestureActions
43}) {
44 if (
45 (actions.leftSecond && !actions.leftFirst) ||
46 (actions.rightSecond && !actions.rightFirst)
47 ) {
48 throw new Error(
49 'You must provide the first action before the second action',
50 )
51 }
52
53 const [activeAction, setActiveAction] = React.useState<
54 'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
55 >(null)
56
57 const haptic = useHaptics()
58 const isReducedMotion = useReducedMotion()
59
60 const transX = useSharedValue(0)
61 const clampedTransX = useDerivedValue(() => {
62 const min = actions.leftFirst ? -MAX_WIDTH : 0
63 const max = actions.rightFirst ? MAX_WIDTH : 0
64 return clamp(transX.get(), min, max)
65 })
66
67 const iconScale = useSharedValue(1)
68 const isActive = useSharedValue(false)
69 const hitFirst = useSharedValue(false)
70 const hitSecond = useSharedValue(false)
71
72 const runPopAnimation = () => {
73 'worklet'
74 if (isReducedMotion) {
75 return
76 }
77
78 iconScale.set(() =>
79 withSequence(
80 withTiming(1.2, {duration: 175}),
81 withTiming(1, {duration: 100}),
82 ),
83 )
84 }
85
86 useAnimatedReaction(
87 () => transX,
88 () => {
89 if (transX.get() === 0) {
90 runOnJS(setActiveAction)(null)
91 } else if (transX.get() < 0) {
92 if (
93 actions.leftSecond &&
94 transX.get() <= -actions.leftSecond.threshold
95 ) {
96 if (activeAction !== 'leftSecond') {
97 runOnJS(setActiveAction)('leftSecond')
98 }
99 } else if (activeAction !== 'leftFirst') {
100 runOnJS(setActiveAction)('leftFirst')
101 }
102 } else if (transX.get() > 0) {
103 if (
104 actions.rightSecond &&
105 transX.get() > actions.rightSecond.threshold
106 ) {
107 if (activeAction !== 'rightSecond') {
108 runOnJS(setActiveAction)('rightSecond')
109 }
110 } else if (activeAction !== 'rightFirst') {
111 runOnJS(setActiveAction)('rightFirst')
112 }
113 }
114 },
115 )
116
117 const panGesture = Gesture.Pan()
118 .activeOffsetX([-10, 10])
119 // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
120 // reanimated doesn't offer great support for disabling y/x axes :/
121 .activeOffsetY([-200, 200])
122 .onStart(() => {
123 'worklet'
124 isActive.set(true)
125 })
126 .onChange(e => {
127 'worklet'
128 transX.set(e.translationX)
129
130 if (e.translationX < 0) {
131 // Left side
132 if (actions.leftSecond) {
133 if (
134 e.translationX <= -actions.leftSecond.threshold &&
135 !hitSecond.get()
136 ) {
137 runPopAnimation()
138 runOnJS(haptic)()
139 hitSecond.set(true)
140 } else if (
141 hitSecond.get() &&
142 e.translationX > -actions.leftSecond.threshold
143 ) {
144 runPopAnimation()
145 hitSecond.set(false)
146 }
147 }
148
149 if (!hitSecond.get() && actions.leftFirst) {
150 if (
151 e.translationX <= -actions.leftFirst.threshold &&
152 !hitFirst.get()
153 ) {
154 runPopAnimation()
155 runOnJS(haptic)()
156 hitFirst.set(true)
157 } else if (
158 hitFirst.get() &&
159 e.translationX > -actions.leftFirst.threshold
160 ) {
161 hitFirst.set(false)
162 }
163 }
164 } else if (e.translationX > 0) {
165 // Right side
166 if (actions.rightSecond) {
167 if (
168 e.translationX >= actions.rightSecond.threshold &&
169 !hitSecond.get()
170 ) {
171 runPopAnimation()
172 runOnJS(haptic)()
173 hitSecond.set(true)
174 } else if (
175 hitSecond.get() &&
176 e.translationX < actions.rightSecond.threshold
177 ) {
178 runPopAnimation()
179 hitSecond.set(false)
180 }
181 }
182
183 if (!hitSecond.get() && actions.rightFirst) {
184 if (
185 e.translationX >= actions.rightFirst.threshold &&
186 !hitFirst.get()
187 ) {
188 runPopAnimation()
189 runOnJS(haptic)()
190 hitFirst.set(true)
191 } else if (
192 hitFirst.get() &&
193 e.translationX < actions.rightFirst.threshold
194 ) {
195 hitFirst.set(false)
196 }
197 }
198 }
199 })
200 .onEnd(e => {
201 'worklet'
202 if (e.translationX < 0) {
203 if (hitSecond.get() && actions.leftSecond) {
204 runOnJS(actions.leftSecond.action)()
205 } else if (hitFirst.get() && actions.leftFirst) {
206 runOnJS(actions.leftFirst.action)()
207 }
208 } else if (e.translationX > 0) {
209 if (hitSecond.get() && actions.rightSecond) {
210 runOnJS(actions.rightSecond.action)()
211 } else if (hitSecond.get() && actions.rightFirst) {
212 runOnJS(actions.rightFirst.action)()
213 }
214 }
215 transX.set(() => withTiming(0, {duration: 200}))
216 hitFirst.set(false)
217 hitSecond.set(false)
218 isActive.set(false)
219 })
220
221 const composedGesture = Gesture.Simultaneous(panGesture)
222
223 const animatedSliderStyle = useAnimatedStyle(() => {
224 return {
225 transform: [{translateX: clampedTransX.get()}],
226 }
227 })
228
229 const leftSideInterpolation = React.useMemo(() => {
230 return createInterpolation({
231 firstColor: actions.leftFirst?.color,
232 secondColor: actions.leftSecond?.color,
233 firstThreshold: actions.leftFirst?.threshold,
234 secondThreshold: actions.leftSecond?.threshold,
235 side: 'left',
236 })
237 }, [actions.leftFirst, actions.leftSecond])
238
239 const rightSideInterpolation = React.useMemo(() => {
240 return createInterpolation({
241 firstColor: actions.rightFirst?.color,
242 secondColor: actions.rightSecond?.color,
243 firstThreshold: actions.rightFirst?.threshold,
244 secondThreshold: actions.rightSecond?.threshold,
245 side: 'right',
246 })
247 }, [actions.rightFirst, actions.rightSecond])
248
249 const interpolation = React.useMemo<{
250 inputRange: number[]
251 outputRange: ColorValue[]
252 }>(() => {
253 if (!actions.leftFirst) {
254 return rightSideInterpolation!
255 } else if (!actions.rightFirst) {
256 return leftSideInterpolation!
257 } else {
258 return {
259 inputRange: [
260 ...leftSideInterpolation.inputRange,
261 ...rightSideInterpolation.inputRange,
262 ],
263 outputRange: [
264 ...leftSideInterpolation.outputRange,
265 ...rightSideInterpolation.outputRange,
266 ],
267 }
268 }
269 }, [
270 leftSideInterpolation,
271 rightSideInterpolation,
272 actions.leftFirst,
273 actions.rightFirst,
274 ])
275
276 const animatedBackgroundStyle = useAnimatedStyle(() => {
277 return {
278 backgroundColor: interpolateColor(
279 clampedTransX.get(),
280 interpolation.inputRange,
281 // @ts-expect-error - Weird type expected by reanimated, but this is okay
282 interpolation.outputRange,
283 ),
284 }
285 })
286
287 const animatedIconStyle = useAnimatedStyle(() => {
288 const absTransX = Math.abs(clampedTransX.get())
289 return {
290 opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
291 transform: [{scale: iconScale.get()}],
292 }
293 })
294
295 return (
296 <GestureDetector gesture={composedGesture}>
297 <View>
298 <Animated.View
299 style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}>
300 <View
301 style={{
302 flex: 1,
303 marginHorizontal: 12,
304 justifyContent: 'center',
305 alignItems:
306 activeAction === 'leftFirst' || activeAction === 'leftSecond'
307 ? 'flex-end'
308 : 'flex-start',
309 }}>
310 <Animated.View style={[animatedIconStyle]}>
311 {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
312 <actions.leftFirst.icon
313 height={ICON_SIZE}
314 width={ICON_SIZE}
315 style={{
316 color: 'white',
317 }}
318 />
319 ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
320 <actions.leftSecond.icon
321 height={ICON_SIZE}
322 width={ICON_SIZE}
323 style={{color: 'white'}}
324 />
325 ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
326 <actions.rightFirst.icon
327 height={ICON_SIZE}
328 width={ICON_SIZE}
329 style={{color: 'white'}}
330 />
331 ) : activeAction === 'rightSecond' &&
332 actions.rightSecond?.icon ? (
333 <actions.rightSecond.icon
334 height={ICON_SIZE}
335 width={ICON_SIZE}
336 style={{color: 'white'}}
337 />
338 ) : null}
339 </Animated.View>
340 </View>
341 </Animated.View>
342 <Animated.View style={animatedSliderStyle}>{children}</Animated.View>
343 </View>
344 </GestureDetector>
345 )
346}
347
348function createInterpolation({
349 firstColor,
350 secondColor,
351 firstThreshold,
352 secondThreshold,
353 side,
354}: {
355 firstColor?: ColorValue
356 secondColor?: ColorValue
357 firstThreshold?: number
358 secondThreshold?: number
359 side: 'left' | 'right'
360}): {
361 inputRange: number[]
362 outputRange: ColorValue[]
363} {
364 if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
365 throw new Error(
366 'You must provide a second color if you provide a second threshold',
367 )
368 }
369
370 if (!firstThreshold) {
371 return {
372 inputRange: [0],
373 outputRange: ['transparent'],
374 }
375 }
376
377 const offset = side === 'left' ? -20 : 20
378
379 if (side === 'left') {
380 firstThreshold = -firstThreshold
381
382 if (secondThreshold) {
383 secondThreshold = -secondThreshold
384 }
385 }
386
387 let res
388 if (secondThreshold) {
389 res = {
390 inputRange: [
391 0,
392 firstThreshold,
393 firstThreshold + offset - 20,
394 secondThreshold,
395 ],
396 outputRange: ['transparent', firstColor!, firstColor!, secondColor!],
397 }
398 } else {
399 res = {
400 inputRange: [0, firstThreshold],
401 outputRange: ['transparent', firstColor!],
402 }
403 }
404
405 if (side === 'left') {
406 // Reverse the input/output ranges
407 res.inputRange.reverse()
408 res.outputRange.reverse()
409 }
410
411 return res
412}