Bluesky app fork with some witchin' additions 馃挮
at main 417 lines 11 kB view raw
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}