forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useState} from 'react'
2import {ActivityIndicator, StyleSheet} from 'react-native'
3import {
4 Gesture,
5 GestureDetector,
6 type PanGesture,
7} from 'react-native-gesture-handler'
8import Animated, {
9 runOnJS,
10 type SharedValue,
11 useAnimatedReaction,
12 useAnimatedRef,
13 useAnimatedStyle,
14 useSharedValue,
15 withSpring,
16} from 'react-native-reanimated'
17import {Image} from 'expo-image'
18
19import {
20 type Dimensions as ImageDimensions,
21 type ImageSource,
22 type LightboxTransforms,
23} from '../../@types'
24import {
25 applyRounding,
26 createTransform,
27 prependPan,
28 prependPinch,
29 prependTransform,
30 readTransform,
31 type 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<SharedValue<LightboxTransforms>>
57}
58const ImageItem = ({
59 imageSrc,
60 onTap,
61 onZoom,
62 onLoad,
63 isScrollViewBeingDragged,
64 measureSafeArea,
65 imageAspect,
66 imageDimensions,
67 dismissSwipePan,
68 transforms,
69}: Props) => {
70 const [isScaled, setIsScaled] = useState(false)
71 const committedTransform = useSharedValue(initialTransform)
72 const panTranslation = useSharedValue({x: 0, y: 0})
73 const pinchOrigin = useSharedValue({x: 0, y: 0})
74 const pinchScale = useSharedValue(1)
75 const pinchTranslation = useSharedValue({x: 0, y: 0})
76 const containerRef = useAnimatedRef()
77
78 // Keep track of when we're entering or leaving scaled rendering.
79 // Note: DO NOT move any logic reading animated values outside this function.
80 useAnimatedReaction(
81 () => {
82 if (pinchScale.get() !== 1) {
83 // We're currently pinching.
84 return true
85 }
86 const [, , committedScale] = readTransform(committedTransform.get())
87 if (committedScale !== 1) {
88 // We started from a pinched in state.
89 return true
90 }
91 // We're at rest.
92 return false
93 },
94 (nextIsScaled, prevIsScaled) => {
95 if (nextIsScaled !== prevIsScaled) {
96 runOnJS(handleZoom)(nextIsScaled)
97 }
98 },
99 )
100
101 function handleZoom(nextIsScaled: boolean) {
102 setIsScaled(nextIsScaled)
103 onZoom(nextIsScaled)
104 }
105
106 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
107 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
108 function getExtraTranslationToStayInBounds(
109 candidateTransform: TransformMatrix,
110 screenSize: {width: number; height: number},
111 ) {
112 'worklet'
113 if (!imageAspect) {
114 return [0, 0]
115 }
116 const [nextTranslateX, nextTranslateY, nextScale] =
117 readTransform(candidateTransform)
118 const scaledDimensions = getScaledDimensions(
119 imageAspect,
120 nextScale,
121 screenSize,
122 )
123 const clampedTranslateX = clampTranslation(
124 nextTranslateX,
125 scaledDimensions.width,
126 screenSize.width,
127 )
128 const clampedTranslateY = clampTranslation(
129 nextTranslateY,
130 scaledDimensions.height,
131 screenSize.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 'worklet'
141 const screenSize = measureSafeArea()
142 pinchOrigin.set({
143 x: e.focalX - screenSize.width / 2,
144 y: e.focalY - screenSize.height / 2,
145 })
146 })
147 .onChange(e => {
148 'worklet'
149 const screenSize = measureSafeArea()
150 if (!imageDimensions) {
151 return
152 }
153 // Don't let the picture zoom in so close that it gets blurry.
154 // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
155 const [, , committedScale] = readTransform(committedTransform.get())
156 const maxCommittedScale = Math.max(
157 MIN_SCREEN_ZOOM,
158 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
159 )
160 const minPinchScale = 1 / committedScale
161 const maxPinchScale = maxCommittedScale / committedScale
162 const nextPinchScale = Math.min(
163 Math.max(minPinchScale, e.scale),
164 maxPinchScale,
165 )
166 pinchScale.set(nextPinchScale)
167
168 // Zooming out close to the corner could push us out of bounds, which we don't want on Android.
169 // Calculate where we'll end up so we know how much to translate back to stay in bounds.
170 const t = createTransform()
171 prependPan(t, panTranslation.get())
172 prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())
173 prependTransform(t, committedTransform.get())
174 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
175 if (dx !== 0 || dy !== 0) {
176 const pt = pinchTranslation.get()
177 pinchTranslation.set({
178 x: pt.x + dx,
179 y: pt.y + dy,
180 })
181 }
182 })
183 .onEnd(() => {
184 'worklet'
185 // Commit just the pinch.
186 let t = createTransform()
187 prependPinch(
188 t,
189 pinchScale.get(),
190 pinchOrigin.get(),
191 pinchTranslation.get(),
192 )
193 prependTransform(t, committedTransform.get())
194 applyRounding(t)
195 committedTransform.set(t)
196
197 // Reset just the pinch.
198 pinchScale.set(1)
199 pinchOrigin.set({x: 0, y: 0})
200 pinchTranslation.set({x: 0, y: 0})
201 })
202
203 const pan = Gesture.Pan()
204 .averageTouches(true)
205 // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
206 .minPointers(isScaled ? 1 : 2)
207 .onChange(e => {
208 'worklet'
209 const screenSize = measureSafeArea()
210 if (!imageDimensions) {
211 return
212 }
213
214 const nextPanTranslation = {x: e.translationX, y: e.translationY}
215 let t = createTransform()
216 prependPan(t, nextPanTranslation)
217 prependPinch(
218 t,
219 pinchScale.get(),
220 pinchOrigin.get(),
221 pinchTranslation.get(),
222 )
223 prependTransform(t, committedTransform.get())
224
225 // Prevent panning from going out of bounds.
226 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
227 nextPanTranslation.x += dx
228 nextPanTranslation.y += dy
229 panTranslation.set(nextPanTranslation)
230 })
231 .onEnd(() => {
232 'worklet'
233 // Commit just the pan.
234 let t = createTransform()
235 prependPan(t, panTranslation.get())
236 prependTransform(t, committedTransform.get())
237 applyRounding(t)
238 committedTransform.set(t)
239
240 // Reset just the pan.
241 panTranslation.set({x: 0, y: 0})
242 })
243
244 const singleTap = Gesture.Tap().onEnd(() => {
245 'worklet'
246 runOnJS(onTap)()
247 })
248
249 const doubleTap = Gesture.Tap()
250 .numberOfTaps(2)
251 .onEnd(e => {
252 'worklet'
253 const screenSize = measureSafeArea()
254 if (!imageDimensions || !imageAspect) {
255 return
256 }
257 const [, , committedScale] = readTransform(committedTransform.get())
258 if (committedScale !== 1) {
259 // Go back to 1:1 using the identity vector.
260 let t = createTransform()
261 committedTransform.set(withClampedSpring(t))
262 return
263 }
264
265 // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
266 const screenAspect = screenSize.width / screenSize.height
267 const candidateScale = Math.max(
268 imageAspect / screenAspect,
269 screenAspect / imageAspect,
270 MIN_SCREEN_ZOOM,
271 )
272 // But don't zoom in so close that the picture gets blurry.
273 const maxScale = Math.max(
274 MIN_SCREEN_ZOOM,
275 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
276 )
277 const scale = Math.min(candidateScale, maxScale)
278
279 // Calculate where we would be if the user pinched into the double tapped point.
280 // We won't use this transform directly because it may go out of bounds.
281 const candidateTransform = createTransform()
282 const origin = {
283 x: e.absoluteX - screenSize.width / 2,
284 y: e.absoluteY - screenSize.height / 2,
285 }
286 prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
287
288 // Now we know how much we went out of bounds, so we can shoot correctly.
289 const [dx, dy] = getExtraTranslationToStayInBounds(
290 candidateTransform,
291 screenSize,
292 )
293 const finalTransform = createTransform()
294 prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
295 committedTransform.set(withClampedSpring(finalTransform))
296 })
297
298 const composedGesture = isScrollViewBeingDragged
299 ? // If the parent is not at rest, provide a no-op gesture.
300 Gesture.Manual()
301 : Gesture.Exclusive(
302 dismissSwipePan,
303 Gesture.Simultaneous(pinch, pan),
304 doubleTap,
305 singleTap,
306 )
307
308 const containerStyle = useAnimatedStyle(() => {
309 const {scaleAndMoveTransform, isHidden} = transforms.get()
310 // Apply the active adjustments on top of the committed transform before the gestures.
311 // This is matrix multiplication, so operations are applied in the reverse order.
312 let t = createTransform()
313 prependPan(t, panTranslation.get())
314 prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
315 prependTransform(t, committedTransform.get())
316 const [translateX, translateY, scale] = readTransform(t)
317 const manipulationTransform = [
318 {translateX},
319 {translateY: translateY},
320 {scale},
321 ]
322 const screenSize = measureSafeArea()
323 return {
324 opacity: isHidden ? 0 : 1,
325 transform: scaleAndMoveTransform.concat(manipulationTransform),
326 width: screenSize.width,
327 maxHeight: screenSize.height,
328 alignSelf: 'center',
329 aspectRatio: imageAspect ?? 1 /* force onLoad */,
330 }
331 })
332
333 const imageCropStyle = useAnimatedStyle(() => {
334 const {cropFrameTransform, borderRadius: br} = transforms.get()
335 return {
336 flex: 1,
337 overflow: 'hidden',
338 transform: cropFrameTransform,
339 borderRadius: br,
340 }
341 })
342
343 const imageStyle = useAnimatedStyle(() => {
344 const {cropContentTransform} = transforms.get()
345 return {
346 flex: 1,
347 transform: cropContentTransform,
348 opacity: imageAspect === undefined ? 0 : 1,
349 }
350 })
351
352 const [showLoader, setShowLoader] = useState(false)
353 const [hasLoaded, setHasLoaded] = useState(false)
354 useAnimatedReaction(
355 () => {
356 return transforms.get().isResting && !hasLoaded
357 },
358 (show, prevShow) => {
359 if (!prevShow && show) {
360 runOnJS(setShowLoader)(true)
361 } else if (prevShow && !show) {
362 runOnJS(setShowLoader)(false)
363 }
364 },
365 )
366
367 const type = imageSrc.type
368 const borderRadius =
369 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
370
371 return (
372 <GestureDetector gesture={composedGesture}>
373 <Animated.View
374 ref={containerRef}
375 style={[styles.container]}
376 renderToHardwareTextureAndroid>
377 <Animated.View style={containerStyle}>
378 {showLoader && (
379 <ActivityIndicator
380 size="small"
381 color="#FFF"
382 style={styles.loading}
383 />
384 )}
385 <Animated.View style={imageCropStyle}>
386 <Animated.View style={imageStyle}>
387 <Image
388 contentFit="contain"
389 source={{uri: imageSrc.uri}}
390 placeholderContentFit="contain"
391 placeholder={{uri: imageSrc.thumbUri}}
392 accessibilityLabel={imageSrc.alt}
393 onLoad={
394 hasLoaded
395 ? undefined
396 : e => {
397 setHasLoaded(true)
398 onLoad({width: e.source.width, height: e.source.height})
399 }
400 }
401 style={{flex: 1, borderRadius}}
402 accessibilityHint=""
403 accessibilityIgnoresInvertColors
404 cachePolicy="memory"
405 />
406 </Animated.View>
407 </Animated.View>
408 </Animated.View>
409 </Animated.View>
410 </GestureDetector>
411 )
412}
413
414const styles = StyleSheet.create({
415 container: {
416 height: '100%',
417 overflow: 'hidden',
418 justifyContent: 'center',
419 },
420 loading: {
421 position: 'absolute',
422 left: 0,
423 right: 0,
424 top: 0,
425 bottom: 0,
426 justifyContent: 'center',
427 },
428})
429
430function getScaledDimensions(
431 imageAspect: number,
432 scale: number,
433 screenSize: {width: number; height: number},
434): ImageDimensions {
435 'worklet'
436 const screenAspect = screenSize.width / screenSize.height
437 const isLandscape = imageAspect > screenAspect
438 if (isLandscape) {
439 return {
440 width: scale * screenSize.width,
441 height: (scale * screenSize.width) / imageAspect,
442 }
443 } else {
444 return {
445 width: scale * screenSize.height * imageAspect,
446 height: scale * screenSize.height,
447 }
448 }
449}
450
451function clampTranslation(
452 value: number,
453 scaledSize: number,
454 screenSize: number,
455): number {
456 'worklet'
457 // Figure out how much the user should be allowed to pan, and constrain the translation.
458 const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
459 const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
460 return clampedValue
461}
462
463function withClampedSpring(value: any) {
464 'worklet'
465 return withSpring(value, {overshootClamping: true})
466}
467
468export default memo(ImageItem)