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