forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * Copyright (c) JOB TODAY S.A. and its affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 */
8
9import {memo, useState} from 'react'
10import {ActivityIndicator, StyleSheet} from 'react-native'
11import {
12 Gesture,
13 GestureDetector,
14 type PanGesture,
15} from 'react-native-gesture-handler'
16import Animated, {
17 runOnJS,
18 type SharedValue,
19 useAnimatedProps,
20 useAnimatedReaction,
21 useAnimatedRef,
22 useAnimatedScrollHandler,
23 useAnimatedStyle,
24 useSharedValue,
25} from 'react-native-reanimated'
26import {useSafeAreaFrame} from 'react-native-safe-area-context'
27import {Image} from 'expo-image'
28
29import {
30 type Dimensions as ImageDimensions,
31 type ImageSource,
32 type LightboxTransforms,
33} from '../../@types'
34
35const MAX_ORIGINAL_IMAGE_ZOOM = 2
36const MIN_SCREEN_ZOOM = 2
37
38type Props = {
39 imageSrc: ImageSource
40 onRequestClose: () => void
41 onTap: () => void
42 onZoom: (scaled: boolean) => void
43 onLoad: (dims: ImageDimensions) => void
44 isScrollViewBeingDragged: boolean
45 showControls: boolean
46 measureSafeArea: () => {
47 x: number
48 y: number
49 width: number
50 height: number
51 }
52 imageAspect: number | undefined
53 imageDimensions: ImageDimensions | undefined
54 dismissSwipePan: PanGesture
55 transforms: Readonly<SharedValue<LightboxTransforms>>
56}
57
58const ImageItem = ({
59 imageSrc,
60 onTap,
61 onZoom,
62 onLoad,
63 showControls,
64 measureSafeArea,
65 imageAspect,
66 imageDimensions,
67 dismissSwipePan,
68 transforms,
69}: Props) => {
70 const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
71 const [scaled, setScaled] = useState(false)
72 const isDragging = useSharedValue(false)
73 const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
74 const maxZoomScale = Math.max(
75 MIN_SCREEN_ZOOM,
76 imageDimensions
77 ? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) *
78 MAX_ORIGINAL_IMAGE_ZOOM
79 : 1,
80 )
81
82 const scrollHandler = useAnimatedScrollHandler({
83 onScroll(e) {
84 'worklet'
85 const nextIsScaled = e.zoomScale > 1
86 if (scaled !== nextIsScaled) {
87 runOnJS(handleZoom)(nextIsScaled)
88 }
89 },
90 onBeginDrag() {
91 'worklet'
92 isDragging.value = true
93 },
94 onEndDrag() {
95 'worklet'
96 isDragging.value = false
97 },
98 })
99
100 function handleZoom(nextIsScaled: boolean) {
101 onZoom(nextIsScaled)
102 setScaled(nextIsScaled)
103 }
104
105 function zoomTo(nextZoomRect: {
106 x: number
107 y: number
108 width: number
109 height: number
110 }) {
111 const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
112 // @ts-ignore
113 scrollResponderRef?.scrollResponderZoomTo({
114 ...nextZoomRect, // This rect is in screen coordinates
115 animated: true,
116 })
117 }
118
119 const singleTap = Gesture.Tap().onEnd(() => {
120 'worklet'
121 runOnJS(onTap)()
122 })
123
124 const doubleTap = Gesture.Tap()
125 .numberOfTaps(2)
126 .onEnd(e => {
127 'worklet'
128 const screenSize = measureSafeArea()
129 const {absoluteX, absoluteY} = e
130 let nextZoomRect = {
131 x: 0,
132 y: 0,
133 width: screenSize.width,
134 height: screenSize.height,
135 }
136 const willZoom = !scaled
137 if (willZoom) {
138 nextZoomRect = getZoomRectAfterDoubleTap(
139 imageAspect,
140 absoluteX,
141 absoluteY,
142 screenSize,
143 )
144 }
145 runOnJS(zoomTo)(nextZoomRect)
146 })
147
148 const composedGesture = Gesture.Exclusive(
149 dismissSwipePan,
150 doubleTap,
151 singleTap,
152 )
153
154 const containerStyle = useAnimatedStyle(() => {
155 const {scaleAndMoveTransform, isHidden} = transforms.get()
156 return {
157 flex: 1,
158 transform: scaleAndMoveTransform,
159 opacity: isHidden ? 0 : 1,
160 }
161 })
162
163 const imageCropStyle = useAnimatedStyle(() => {
164 const screenSize = measureSafeArea()
165 const {cropFrameTransform, borderRadius: br} = transforms.get()
166 return {
167 overflow: 'hidden',
168 transform: cropFrameTransform,
169 borderRadius: br,
170 width: screenSize.width,
171 maxHeight: screenSize.height,
172 alignSelf: 'center',
173 aspectRatio: imageAspect ?? 1 /* force onLoad */,
174 opacity: imageAspect === undefined ? 0 : 1,
175 }
176 })
177
178 const imageStyle = useAnimatedStyle(() => {
179 const {cropContentTransform} = transforms.get()
180 return {
181 transform: cropContentTransform,
182 width: '100%',
183 aspectRatio: imageAspect ?? 1 /* force onLoad */,
184 opacity: imageAspect === undefined ? 0 : 1,
185 }
186 })
187
188 const [showLoader, setShowLoader] = useState(false)
189 const [hasLoaded, setHasLoaded] = useState(false)
190 useAnimatedReaction(
191 () => {
192 return transforms.get().isResting && !hasLoaded
193 },
194 (show, prevShow) => {
195 if (!prevShow && show) {
196 runOnJS(setShowLoader)(true)
197 } else if (prevShow && !show) {
198 runOnJS(setShowLoader)(false)
199 }
200 },
201 )
202
203 const type = imageSrc.type
204 const borderRadius =
205 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
206
207 const scrollViewProps = useAnimatedProps(() => ({
208 // Don't allow bounce at 1:1 rest so it can be swiped away.
209 bounces: scaled || isDragging.value,
210 }))
211
212 return (
213 <GestureDetector gesture={composedGesture}>
214 <Animated.ScrollView
215 // @ts-ignore Something's up with the types here
216 ref={scrollViewRef}
217 pinchGestureEnabled
218 showsHorizontalScrollIndicator={false}
219 showsVerticalScrollIndicator={false}
220 maximumZoomScale={maxZoomScale}
221 onScroll={scrollHandler}
222 style={containerStyle}
223 animatedProps={scrollViewProps}
224 centerContent>
225 {showLoader && (
226 <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
227 )}
228 <Animated.View style={imageCropStyle}>
229 <Animated.View style={imageStyle}>
230 <Image
231 contentFit="contain"
232 source={{uri: imageSrc.uri}}
233 placeholderContentFit="contain"
234 placeholder={{uri: imageSrc.thumbUri}}
235 style={{flex: 1, borderRadius}}
236 accessibilityLabel={imageSrc.alt}
237 accessibilityHint=""
238 enableLiveTextInteraction={showControls && !scaled}
239 accessibilityIgnoresInvertColors
240 onLoad={
241 hasLoaded
242 ? undefined
243 : e => {
244 setHasLoaded(true)
245 onLoad({width: e.source.width, height: e.source.height})
246 }
247 }
248 cachePolicy="memory"
249 />
250 </Animated.View>
251 </Animated.View>
252 </Animated.ScrollView>
253 </GestureDetector>
254 )
255}
256
257const styles = StyleSheet.create({
258 loading: {
259 position: 'absolute',
260 top: 0,
261 left: 0,
262 right: 0,
263 bottom: 0,
264 },
265 image: {
266 flex: 1,
267 },
268})
269
270const getZoomRectAfterDoubleTap = (
271 imageAspect: number | undefined,
272 touchX: number,
273 touchY: number,
274 screenSize: {width: number; height: number},
275): {
276 x: number
277 y: number
278 width: number
279 height: number
280} => {
281 'worklet'
282 if (!imageAspect) {
283 return {
284 x: 0,
285 y: 0,
286 width: screenSize.width,
287 height: screenSize.height,
288 }
289 }
290
291 // First, let's figure out how much we want to zoom in.
292 // We want to try to zoom in at least close enough to get rid of black bars.
293 const screenAspect = screenSize.width / screenSize.height
294 const zoom = Math.max(
295 imageAspect / screenAspect,
296 screenAspect / imageAspect,
297 MIN_SCREEN_ZOOM,
298 )
299 // Unlike in the Android version, we don't constrain the *max* zoom level here.
300 // Instead, this is done in the ScrollView props so that it constraints pinch too.
301
302 // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
303 // We already know the zoom level, so this gives us the rectangle size.
304 let rectWidth = screenSize.width / zoom
305 let rectHeight = screenSize.height / zoom
306
307 // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
308 // We don't want to introduce new black bars or make existing black bars unbalanced.
309 let minX = 0
310 let minY = 0
311 let maxX = screenSize.width - rectWidth
312 let maxY = screenSize.height - rectHeight
313 if (imageAspect >= screenAspect) {
314 // The image has horizontal black bars. Exclude them from the safe area.
315 const renderedHeight = screenSize.width / imageAspect
316 const horizontalBarHeight = (screenSize.height - renderedHeight) / 2
317 minY += horizontalBarHeight
318 maxY -= horizontalBarHeight
319 } else {
320 // The image has vertical black bars. Exclude them from the safe area.
321 const renderedWidth = screenSize.height * imageAspect
322 const verticalBarWidth = (screenSize.width - renderedWidth) / 2
323 minX += verticalBarWidth
324 maxX -= verticalBarWidth
325 }
326
327 // Finally, we can position the rect according to its size and the safe area.
328 let rectX
329 if (maxX >= minX) {
330 // Content fills the screen horizontally so we have horizontal wiggle room.
331 // Try to keep the tapped point under the finger after zoom.
332 rectX = touchX - touchX / zoom
333 rectX = Math.min(rectX, maxX)
334 rectX = Math.max(rectX, minX)
335 } else {
336 // Keep the rect centered on the screen so that black bars are balanced.
337 rectX = screenSize.width / 2 - rectWidth / 2
338 }
339 let rectY
340 if (maxY >= minY) {
341 // Content fills the screen vertically so we have vertical wiggle room.
342 // Try to keep the tapped point under the finger after zoom.
343 rectY = touchY - touchY / zoom
344 rectY = Math.min(rectY, maxY)
345 rectY = Math.max(rectY, minY)
346 } else {
347 // Keep the rect centered on the screen so that black bars are balanced.
348 rectY = screenSize.height / 2 - rectHeight / 2
349 }
350
351 return {
352 x: rectX,
353 y: rectY,
354 height: rectHeight,
355 width: rectWidth,
356 }
357}
358
359export default memo(ImageItem)