mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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)