mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at rm-broken-strings 412 lines 11 kB view raw
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}