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)