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