mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at samuel/fancy-queue 474 lines 14 kB view raw
1import React, {useState} from 'react' 2import {ActivityIndicator, StyleSheet} from 'react-native' 3import { 4 Gesture, 5 GestureDetector, 6 PanGesture, 7} from 'react-native-gesture-handler' 8import Animated, { 9 runOnJS, 10 SharedValue, 11 useAnimatedReaction, 12 useAnimatedRef, 13 useAnimatedStyle, 14 useSharedValue, 15 withSpring, 16} from 'react-native-reanimated' 17import {Image} from 'expo-image' 18 19import type { 20 Dimensions as ImageDimensions, 21 ImageSource, 22 Transform, 23} from '../../@types' 24import { 25 applyRounding, 26 createTransform, 27 prependPan, 28 prependPinch, 29 prependTransform, 30 readTransform, 31 TransformMatrix, 32} from '../../transforms' 33 34const MIN_SCREEN_ZOOM = 2 35const MAX_ORIGINAL_IMAGE_ZOOM = 2 36 37const initialTransform = createTransform() 38 39type Props = { 40 imageSrc: ImageSource 41 onRequestClose: () => void 42 onTap: () => void 43 onZoom: (isZoomed: boolean) => void 44 onLoad: (dims: ImageDimensions) => void 45 isScrollViewBeingDragged: boolean 46 showControls: boolean 47 measureSafeArea: () => { 48 x: number 49 y: number 50 width: number 51 height: number 52 } 53 imageAspect: number | undefined 54 imageDimensions: ImageDimensions | undefined 55 dismissSwipePan: PanGesture 56 transforms: Readonly< 57 SharedValue<{ 58 scaleAndMoveTransform: Transform 59 cropFrameTransform: Transform 60 cropContentTransform: Transform 61 isResting: boolean 62 isHidden: boolean 63 }> 64 > 65} 66const ImageItem = ({ 67 imageSrc, 68 onTap, 69 onZoom, 70 onLoad, 71 isScrollViewBeingDragged, 72 measureSafeArea, 73 imageAspect, 74 imageDimensions, 75 dismissSwipePan, 76 transforms, 77}: Props) => { 78 const [isScaled, setIsScaled] = useState(false) 79 const committedTransform = useSharedValue(initialTransform) 80 const panTranslation = useSharedValue({x: 0, y: 0}) 81 const pinchOrigin = useSharedValue({x: 0, y: 0}) 82 const pinchScale = useSharedValue(1) 83 const pinchTranslation = useSharedValue({x: 0, y: 0}) 84 const containerRef = useAnimatedRef() 85 86 // Keep track of when we're entering or leaving scaled rendering. 87 // Note: DO NOT move any logic reading animated values outside this function. 88 useAnimatedReaction( 89 () => { 90 if (pinchScale.value !== 1) { 91 // We're currently pinching. 92 return true 93 } 94 const [, , committedScale] = readTransform(committedTransform.value) 95 if (committedScale !== 1) { 96 // We started from a pinched in state. 97 return true 98 } 99 // We're at rest. 100 return false 101 }, 102 (nextIsScaled, prevIsScaled) => { 103 if (nextIsScaled !== prevIsScaled) { 104 runOnJS(handleZoom)(nextIsScaled) 105 } 106 }, 107 ) 108 109 function handleZoom(nextIsScaled: boolean) { 110 setIsScaled(nextIsScaled) 111 onZoom(nextIsScaled) 112 } 113 114 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 115 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 116 function getExtraTranslationToStayInBounds( 117 candidateTransform: TransformMatrix, 118 screenSize: {width: number; height: number}, 119 ) { 120 'worklet' 121 if (!imageAspect) { 122 return [0, 0] 123 } 124 const [nextTranslateX, nextTranslateY, nextScale] = 125 readTransform(candidateTransform) 126 const scaledDimensions = getScaledDimensions( 127 imageAspect, 128 nextScale, 129 screenSize, 130 ) 131 const clampedTranslateX = clampTranslation( 132 nextTranslateX, 133 scaledDimensions.width, 134 screenSize.width, 135 ) 136 const clampedTranslateY = clampTranslation( 137 nextTranslateY, 138 scaledDimensions.height, 139 screenSize.height, 140 ) 141 const dx = clampedTranslateX - nextTranslateX 142 const dy = clampedTranslateY - nextTranslateY 143 return [dx, dy] 144 } 145 146 const pinch = Gesture.Pinch() 147 .onStart(e => { 148 'worklet' 149 const screenSize = measureSafeArea() 150 pinchOrigin.value = { 151 x: e.focalX - screenSize.width / 2, 152 y: e.focalY - screenSize.height / 2, 153 } 154 }) 155 .onChange(e => { 156 'worklet' 157 const screenSize = measureSafeArea() 158 if (!imageDimensions) { 159 return 160 } 161 // Don't let the picture zoom in so close that it gets blurry. 162 // Also, like in stock Android apps, don't let the user zoom out further than 1:1. 163 const [, , committedScale] = readTransform(committedTransform.value) 164 const maxCommittedScale = Math.max( 165 MIN_SCREEN_ZOOM, 166 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM, 167 ) 168 const minPinchScale = 1 / committedScale 169 const maxPinchScale = maxCommittedScale / committedScale 170 const nextPinchScale = Math.min( 171 Math.max(minPinchScale, e.scale), 172 maxPinchScale, 173 ) 174 pinchScale.value = nextPinchScale 175 176 // Zooming out close to the corner could push us out of bounds, which we don't want on Android. 177 // Calculate where we'll end up so we know how much to translate back to stay in bounds. 178 const t = createTransform() 179 prependPan(t, panTranslation.value) 180 prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) 181 prependTransform(t, committedTransform.value) 182 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 183 if (dx !== 0 || dy !== 0) { 184 pinchTranslation.value = { 185 x: pinchTranslation.value.x + dx, 186 y: pinchTranslation.value.y + dy, 187 } 188 } 189 }) 190 .onEnd(() => { 191 'worklet' 192 // Commit just the pinch. 193 let t = createTransform() 194 prependPinch( 195 t, 196 pinchScale.value, 197 pinchOrigin.value, 198 pinchTranslation.value, 199 ) 200 prependTransform(t, committedTransform.value) 201 applyRounding(t) 202 committedTransform.value = t 203 204 // Reset just the pinch. 205 pinchScale.value = 1 206 pinchOrigin.value = {x: 0, y: 0} 207 pinchTranslation.value = {x: 0, y: 0} 208 }) 209 210 const pan = Gesture.Pan() 211 .averageTouches(true) 212 // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: 213 .minPointers(isScaled ? 1 : 2) 214 .onChange(e => { 215 'worklet' 216 const screenSize = measureSafeArea() 217 if (!imageDimensions) { 218 return 219 } 220 221 const nextPanTranslation = {x: e.translationX, y: e.translationY} 222 let t = createTransform() 223 prependPan(t, nextPanTranslation) 224 prependPinch( 225 t, 226 pinchScale.value, 227 pinchOrigin.value, 228 pinchTranslation.value, 229 ) 230 prependTransform(t, committedTransform.value) 231 232 // Prevent panning from going out of bounds. 233 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 234 nextPanTranslation.x += dx 235 nextPanTranslation.y += dy 236 panTranslation.value = nextPanTranslation 237 }) 238 .onEnd(() => { 239 'worklet' 240 // Commit just the pan. 241 let t = createTransform() 242 prependPan(t, panTranslation.value) 243 prependTransform(t, committedTransform.value) 244 applyRounding(t) 245 committedTransform.value = t 246 247 // Reset just the pan. 248 panTranslation.value = {x: 0, y: 0} 249 }) 250 251 const singleTap = Gesture.Tap().onEnd(() => { 252 'worklet' 253 runOnJS(onTap)() 254 }) 255 256 const doubleTap = Gesture.Tap() 257 .numberOfTaps(2) 258 .onEnd(e => { 259 'worklet' 260 const screenSize = measureSafeArea() 261 if (!imageDimensions || !imageAspect) { 262 return 263 } 264 const [, , committedScale] = readTransform(committedTransform.value) 265 if (committedScale !== 1) { 266 // Go back to 1:1 using the identity vector. 267 let t = createTransform() 268 committedTransform.value = withClampedSpring(t) 269 return 270 } 271 272 // Try to zoom in so that we get rid of the black bars (whatever the orientation was). 273 const screenAspect = screenSize.width / screenSize.height 274 const candidateScale = Math.max( 275 imageAspect / screenAspect, 276 screenAspect / imageAspect, 277 MIN_SCREEN_ZOOM, 278 ) 279 // But don't zoom in so close that the picture gets blurry. 280 const maxScale = Math.max( 281 MIN_SCREEN_ZOOM, 282 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM, 283 ) 284 const scale = Math.min(candidateScale, maxScale) 285 286 // Calculate where we would be if the user pinched into the double tapped point. 287 // We won't use this transform directly because it may go out of bounds. 288 const candidateTransform = createTransform() 289 const origin = { 290 x: e.absoluteX - screenSize.width / 2, 291 y: e.absoluteY - screenSize.height / 2, 292 } 293 prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) 294 295 // Now we know how much we went out of bounds, so we can shoot correctly. 296 const [dx, dy] = getExtraTranslationToStayInBounds( 297 candidateTransform, 298 screenSize, 299 ) 300 const finalTransform = createTransform() 301 prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) 302 committedTransform.value = withClampedSpring(finalTransform) 303 }) 304 305 const composedGesture = isScrollViewBeingDragged 306 ? // If the parent is not at rest, provide a no-op gesture. 307 Gesture.Manual() 308 : Gesture.Exclusive( 309 dismissSwipePan, 310 Gesture.Simultaneous(pinch, pan), 311 doubleTap, 312 singleTap, 313 ) 314 315 const containerStyle = useAnimatedStyle(() => { 316 const {scaleAndMoveTransform, isHidden} = transforms.value 317 // Apply the active adjustments on top of the committed transform before the gestures. 318 // This is matrix multiplication, so operations are applied in the reverse order. 319 let t = createTransform() 320 prependPan(t, panTranslation.value) 321 prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) 322 prependTransform(t, committedTransform.value) 323 const [translateX, translateY, scale] = readTransform(t) 324 const manipulationTransform = [ 325 {translateX}, 326 {translateY: translateY}, 327 {scale}, 328 ] 329 const screenSize = measureSafeArea() 330 return { 331 opacity: isHidden ? 0 : 1, 332 transform: scaleAndMoveTransform.concat(manipulationTransform), 333 width: screenSize.width, 334 maxHeight: screenSize.height, 335 alignSelf: 'center', 336 aspectRatio: imageAspect ?? 1 /* force onLoad */, 337 } 338 }) 339 340 const imageCropStyle = useAnimatedStyle(() => { 341 const {cropFrameTransform} = transforms.value 342 return { 343 flex: 1, 344 overflow: 'hidden', 345 transform: cropFrameTransform, 346 } 347 }) 348 349 const imageStyle = useAnimatedStyle(() => { 350 const {cropContentTransform} = transforms.value 351 return { 352 flex: 1, 353 transform: cropContentTransform, 354 opacity: imageAspect === undefined ? 0 : 1, 355 } 356 }) 357 358 const [showLoader, setShowLoader] = useState(false) 359 const [hasLoaded, setHasLoaded] = useState(false) 360 useAnimatedReaction( 361 () => { 362 return transforms.value.isResting && !hasLoaded 363 }, 364 (show, prevShow) => { 365 if (show && !prevShow) { 366 runOnJS(setShowLoader)(false) 367 } else if (!prevShow && show) { 368 runOnJS(setShowLoader)(true) 369 } 370 }, 371 ) 372 373 const type = imageSrc.type 374 const borderRadius = 375 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 376 377 return ( 378 <GestureDetector gesture={composedGesture}> 379 <Animated.View 380 ref={containerRef} 381 style={[styles.container]} 382 renderToHardwareTextureAndroid> 383 <Animated.View style={containerStyle}> 384 {showLoader && ( 385 <ActivityIndicator 386 size="small" 387 color="#FFF" 388 style={styles.loading} 389 /> 390 )} 391 <Animated.View style={imageCropStyle}> 392 <Animated.View style={imageStyle}> 393 <Image 394 contentFit="cover" 395 source={{uri: imageSrc.uri}} 396 placeholderContentFit="cover" 397 placeholder={{uri: imageSrc.thumbUri}} 398 accessibilityLabel={imageSrc.alt} 399 onLoad={ 400 hasLoaded 401 ? undefined 402 : e => { 403 setHasLoaded(true) 404 onLoad({width: e.source.width, height: e.source.height}) 405 } 406 } 407 style={{flex: 1, borderRadius}} 408 accessibilityHint="" 409 accessibilityIgnoresInvertColors 410 cachePolicy="memory" 411 /> 412 </Animated.View> 413 </Animated.View> 414 </Animated.View> 415 </Animated.View> 416 </GestureDetector> 417 ) 418} 419 420const styles = StyleSheet.create({ 421 container: { 422 height: '100%', 423 overflow: 'hidden', 424 justifyContent: 'center', 425 }, 426 loading: { 427 position: 'absolute', 428 left: 0, 429 right: 0, 430 top: 0, 431 bottom: 0, 432 justifyContent: 'center', 433 }, 434}) 435 436function getScaledDimensions( 437 imageAspect: number, 438 scale: number, 439 screenSize: {width: number; height: number}, 440): ImageDimensions { 441 'worklet' 442 const screenAspect = screenSize.width / screenSize.height 443 const isLandscape = imageAspect > screenAspect 444 if (isLandscape) { 445 return { 446 width: scale * screenSize.width, 447 height: (scale * screenSize.width) / imageAspect, 448 } 449 } else { 450 return { 451 width: scale * screenSize.height * imageAspect, 452 height: scale * screenSize.height, 453 } 454 } 455} 456 457function clampTranslation( 458 value: number, 459 scaledSize: number, 460 screenSize: number, 461): number { 462 'worklet' 463 // Figure out how much the user should be allowed to pan, and constrain the translation. 464 const panDistance = Math.max(0, (scaledSize - screenSize) / 2) 465 const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) 466 return clampedValue 467} 468 469function withClampedSpring(value: any) { 470 'worklet' 471 return withSpring(value, {overshootClamping: true}) 472} 473 474export default React.memo(ImageItem)