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