forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {type 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 // NOTE(haileyok):
118 // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
119 // reanimated doesn't offer great support for disabling y/x axes :/
120 const effectivelyDisabledOffset = 200
121 const panGesture = Gesture.Pan()
122 .activeOffsetX([
123 actions.leftFirst ? -10 : -effectivelyDisabledOffset,
124 actions.rightFirst ? 10 : effectivelyDisabledOffset,
125 ])
126 .activeOffsetY([-effectivelyDisabledOffset, effectivelyDisabledOffset])
127 .onStart(() => {
128 'worklet'
129 isActive.set(true)
130 })
131 .onChange(e => {
132 'worklet'
133 transX.set(e.translationX)
134
135 if (e.translationX < 0) {
136 // Left side
137 if (actions.leftSecond) {
138 if (
139 e.translationX <= -actions.leftSecond.threshold &&
140 !hitSecond.get()
141 ) {
142 runPopAnimation()
143 runOnJS(haptic)()
144 hitSecond.set(true)
145 } else if (
146 hitSecond.get() &&
147 e.translationX > -actions.leftSecond.threshold
148 ) {
149 runPopAnimation()
150 hitSecond.set(false)
151 }
152 }
153
154 if (!hitSecond.get() && actions.leftFirst) {
155 if (
156 e.translationX <= -actions.leftFirst.threshold &&
157 !hitFirst.get()
158 ) {
159 runPopAnimation()
160 runOnJS(haptic)()
161 hitFirst.set(true)
162 } else if (
163 hitFirst.get() &&
164 e.translationX > -actions.leftFirst.threshold
165 ) {
166 hitFirst.set(false)
167 }
168 }
169 } else if (e.translationX > 0) {
170 // Right side
171 if (actions.rightSecond) {
172 if (
173 e.translationX >= actions.rightSecond.threshold &&
174 !hitSecond.get()
175 ) {
176 runPopAnimation()
177 runOnJS(haptic)()
178 hitSecond.set(true)
179 } else if (
180 hitSecond.get() &&
181 e.translationX < actions.rightSecond.threshold
182 ) {
183 runPopAnimation()
184 hitSecond.set(false)
185 }
186 }
187
188 if (!hitSecond.get() && actions.rightFirst) {
189 if (
190 e.translationX >= actions.rightFirst.threshold &&
191 !hitFirst.get()
192 ) {
193 runPopAnimation()
194 runOnJS(haptic)()
195 hitFirst.set(true)
196 } else if (
197 hitFirst.get() &&
198 e.translationX < actions.rightFirst.threshold
199 ) {
200 hitFirst.set(false)
201 }
202 }
203 }
204 })
205 .onEnd(e => {
206 'worklet'
207 if (e.translationX < 0) {
208 if (hitSecond.get() && actions.leftSecond) {
209 runOnJS(actions.leftSecond.action)()
210 } else if (hitFirst.get() && actions.leftFirst) {
211 runOnJS(actions.leftFirst.action)()
212 }
213 } else if (e.translationX > 0) {
214 if (hitSecond.get() && actions.rightSecond) {
215 runOnJS(actions.rightSecond.action)()
216 } else if (hitSecond.get() && actions.rightFirst) {
217 runOnJS(actions.rightFirst.action)()
218 }
219 }
220 transX.set(() => withTiming(0, {duration: 200}))
221 hitFirst.set(false)
222 hitSecond.set(false)
223 isActive.set(false)
224 })
225
226 const composedGesture = Gesture.Simultaneous(panGesture)
227
228 const animatedSliderStyle = useAnimatedStyle(() => {
229 return {
230 transform: [{translateX: clampedTransX.get()}],
231 }
232 })
233
234 const leftSideInterpolation = React.useMemo(() => {
235 return createInterpolation({
236 firstColor: actions.leftFirst?.color,
237 secondColor: actions.leftSecond?.color,
238 firstThreshold: actions.leftFirst?.threshold,
239 secondThreshold: actions.leftSecond?.threshold,
240 side: 'left',
241 })
242 }, [actions.leftFirst, actions.leftSecond])
243
244 const rightSideInterpolation = React.useMemo(() => {
245 return createInterpolation({
246 firstColor: actions.rightFirst?.color,
247 secondColor: actions.rightSecond?.color,
248 firstThreshold: actions.rightFirst?.threshold,
249 secondThreshold: actions.rightSecond?.threshold,
250 side: 'right',
251 })
252 }, [actions.rightFirst, actions.rightSecond])
253
254 const interpolation = React.useMemo<{
255 inputRange: number[]
256 outputRange: ColorValue[]
257 }>(() => {
258 if (!actions.leftFirst) {
259 return rightSideInterpolation!
260 } else if (!actions.rightFirst) {
261 return leftSideInterpolation!
262 } else {
263 return {
264 inputRange: [
265 ...leftSideInterpolation.inputRange,
266 ...rightSideInterpolation.inputRange,
267 ],
268 outputRange: [
269 ...leftSideInterpolation.outputRange,
270 ...rightSideInterpolation.outputRange,
271 ],
272 }
273 }
274 }, [
275 leftSideInterpolation,
276 rightSideInterpolation,
277 actions.leftFirst,
278 actions.rightFirst,
279 ])
280
281 const animatedBackgroundStyle = useAnimatedStyle(() => {
282 return {
283 backgroundColor: interpolateColor(
284 clampedTransX.get(),
285 interpolation.inputRange,
286 // @ts-expect-error - Weird type expected by reanimated, but this is okay
287 interpolation.outputRange,
288 ),
289 }
290 })
291
292 const animatedIconStyle = useAnimatedStyle(() => {
293 const absTransX = Math.abs(clampedTransX.get())
294 return {
295 opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
296 transform: [{scale: iconScale.get()}],
297 }
298 })
299
300 return (
301 <GestureDetector gesture={composedGesture}>
302 <View>
303 <Animated.View
304 style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}>
305 <View
306 style={{
307 flex: 1,
308 marginHorizontal: 12,
309 justifyContent: 'center',
310 alignItems:
311 activeAction === 'leftFirst' || activeAction === 'leftSecond'
312 ? 'flex-end'
313 : 'flex-start',
314 }}>
315 <Animated.View style={[animatedIconStyle]}>
316 {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
317 <actions.leftFirst.icon
318 height={ICON_SIZE}
319 width={ICON_SIZE}
320 style={{
321 color: 'white',
322 }}
323 />
324 ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
325 <actions.leftSecond.icon
326 height={ICON_SIZE}
327 width={ICON_SIZE}
328 style={{color: 'white'}}
329 />
330 ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
331 <actions.rightFirst.icon
332 height={ICON_SIZE}
333 width={ICON_SIZE}
334 style={{color: 'white'}}
335 />
336 ) : activeAction === 'rightSecond' &&
337 actions.rightSecond?.icon ? (
338 <actions.rightSecond.icon
339 height={ICON_SIZE}
340 width={ICON_SIZE}
341 style={{color: 'white'}}
342 />
343 ) : null}
344 </Animated.View>
345 </View>
346 </Animated.View>
347 <Animated.View style={animatedSliderStyle}>{children}</Animated.View>
348 </View>
349 </GestureDetector>
350 )
351}
352
353function createInterpolation({
354 firstColor,
355 secondColor,
356 firstThreshold,
357 secondThreshold,
358 side,
359}: {
360 firstColor?: ColorValue
361 secondColor?: ColorValue
362 firstThreshold?: number
363 secondThreshold?: number
364 side: 'left' | 'right'
365}): {
366 inputRange: number[]
367 outputRange: ColorValue[]
368} {
369 if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
370 throw new Error(
371 'You must provide a second color if you provide a second threshold',
372 )
373 }
374
375 if (!firstThreshold) {
376 return {
377 inputRange: [0],
378 outputRange: ['transparent'],
379 }
380 }
381
382 const offset = side === 'left' ? -20 : 20
383
384 if (side === 'left') {
385 firstThreshold = -firstThreshold
386
387 if (secondThreshold) {
388 secondThreshold = -secondThreshold
389 }
390 }
391
392 let res
393 if (secondThreshold) {
394 res = {
395 inputRange: [
396 0,
397 firstThreshold,
398 firstThreshold + offset - 20,
399 secondThreshold,
400 ],
401 outputRange: ['transparent', firstColor!, firstColor!, secondColor!],
402 }
403 } else {
404 res = {
405 inputRange: [0, firstThreshold],
406 outputRange: ['transparent', firstColor!],
407 }
408 }
409
410 if (side === 'left') {
411 // Reverse the input/output ranges
412 res.inputRange.reverse()
413 res.outputRange.reverse()
414 }
415
416 return res
417}