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