Bluesky app fork with some witchin' additions 馃挮
at main 366 lines 10 kB view raw
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)