Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor iOS lightbox to Reanimated (#1645)

* Remove unnecessary transform logic

* Switch iOS swipe-to-dimiss to Reanimated

authored by

dan and committed by
GitHub
f452ce74 832b05b6

+55 -108
+55 -108
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 6 6 * 7 7 */ 8 8 9 - import React, {MutableRefObject, useCallback, useRef, useState} from 'react' 9 + import React, {MutableRefObject, useCallback, useState} from 'react' 10 10 11 11 import { 12 - Animated, 13 12 Dimensions, 14 - ScrollView, 15 13 StyleSheet, 16 14 View, 17 - NativeScrollEvent, 18 15 NativeSyntheticEvent, 19 16 NativeTouchEvent, 20 17 TouchableWithoutFeedback, 21 18 } from 'react-native' 22 19 import {Image} from 'expo-image' 20 + import Animated, { 21 + interpolate, 22 + runOnJS, 23 + useAnimatedRef, 24 + useAnimatedScrollHandler, 25 + useAnimatedStyle, 26 + useSharedValue, 27 + } from 'react-native-reanimated' 23 28 import {GestureType} from 'react-native-gesture-handler' 24 29 25 30 import useImageDimensions from '../../hooks/useImageDimensions' ··· 31 36 const SWIPE_CLOSE_OFFSET = 75 32 37 const SWIPE_CLOSE_VELOCITY = 1 33 38 const SCREEN = Dimensions.get('screen') 34 - const SCREEN_WIDTH = SCREEN.width 35 - const SCREEN_HEIGHT = SCREEN.height 36 - const MIN_ZOOM = 2 37 - const MAX_SCALE = 2 39 + const MAX_ORIGINAL_IMAGE_ZOOM = 2 40 + const MIN_DOUBLE_TAP_SCALE = 2 38 41 39 42 type Props = { 40 43 imageSrc: ImageSource ··· 49 52 let lastTapTS: number | null = null 50 53 51 54 const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { 52 - const scrollViewRef = useRef<ScrollView>(null) 55 + const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 56 + const translationY = useSharedValue(0) 53 57 const [loaded, setLoaded] = useState(false) 54 58 const [scaled, setScaled] = useState(false) 55 59 const imageDimensions = useImageDimensions(imageSrc) 56 - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) 57 - const [scrollValueY] = useState(() => new Animated.Value(0)) 58 - const maxScrollViewZoom = MAX_SCALE / (scale || 1) 60 + const maxZoomScale = imageDimensions 61 + ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 62 + : 1 59 63 60 - const imageOpacity = scrollValueY.interpolate({ 61 - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 62 - outputRange: [0.5, 1, 0.5], 64 + const animatedStyle = useAnimatedStyle(() => { 65 + return { 66 + opacity: interpolate( 67 + translationY.value, 68 + [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 69 + [0.5, 1, 0.5], 70 + ), 71 + } 63 72 }) 64 - const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1) 65 - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} 66 73 67 - const onScrollEndDrag = useCallback( 68 - ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { 69 - const velocityY = nativeEvent?.velocity?.y ?? 0 70 - const currentScaled = nativeEvent?.zoomScale > 1 71 - 72 - onZoom(currentScaled) 73 - setScaled(currentScaled) 74 - 75 - if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { 76 - onRequestClose() 74 + const scrollHandler = useAnimatedScrollHandler({ 75 + onScroll(e) { 76 + translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y 77 + }, 78 + onEndDrag(e) { 79 + const velocityY = e.velocity?.y ?? 0 80 + const nextIsScaled = e.zoomScale > 1 81 + runOnJS(handleZoom)(nextIsScaled) 82 + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { 83 + runOnJS(onRequestClose)() 77 84 } 78 85 }, 79 - [onRequestClose, onZoom], 80 - ) 86 + }) 81 87 82 - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { 83 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 84 - 85 - if (nativeEvent?.zoomScale > 1) { 86 - return 87 - } 88 - 89 - scrollValueY.setValue(offsetY) 88 + function handleZoom(nextIsScaled: boolean) { 89 + onZoom(nextIsScaled) 90 + setScaled(nextIsScaled) 90 91 } 91 92 92 93 const handleDoubleTap = useCallback( ··· 121 122 lastTapTS = nowTS 122 123 } 123 124 }, 124 - [imageDimensions, scaled], 125 + [imageDimensions, scaled, scrollViewRef], 125 126 ) 126 127 127 128 return ( 128 129 <View> 129 - <ScrollView 130 + <Animated.ScrollView 131 + // @ts-ignore Something's up with the types here 130 132 ref={scrollViewRef} 131 133 style={styles.listItem} 132 134 pinchGestureEnabled 133 135 showsHorizontalScrollIndicator={false} 134 136 showsVerticalScrollIndicator={false} 135 - maximumZoomScale={maxScrollViewZoom} 137 + maximumZoomScale={maxZoomScale} 136 138 contentContainerStyle={styles.imageScrollContainer} 137 - scrollEnabled={true} 138 - onScroll={onScroll} 139 - onScrollEndDrag={onScrollEndDrag} 140 - scrollEventThrottle={1}> 139 + onScroll={scrollHandler}> 141 140 {(!loaded || !imageDimensions) && <ImageLoading />} 142 141 <TouchableWithoutFeedback 143 142 onPress={handleDoubleTap} ··· 145 144 accessibilityLabel={imageSrc.alt} 146 145 accessibilityHint=""> 147 146 <AnimatedImage 147 + contentFit="contain" 148 148 source={imageSrc} 149 - style={imageStylesWithOpacity} 149 + style={[styles.image, animatedStyle]} 150 150 onLoad={() => setLoaded(true)} 151 151 /> 152 152 </TouchableWithoutFeedback> 153 - </ScrollView> 153 + </Animated.ScrollView> 154 154 </View> 155 155 ) 156 156 } 157 157 158 158 const styles = StyleSheet.create({ 159 + imageScrollContainer: { 160 + height: SCREEN.height, 161 + }, 159 162 listItem: { 160 - width: SCREEN_WIDTH, 161 - height: SCREEN_HEIGHT, 163 + width: SCREEN.width, 164 + height: SCREEN.height, 162 165 }, 163 - imageScrollContainer: { 164 - height: SCREEN_HEIGHT, 166 + image: { 167 + width: SCREEN.width, 168 + height: SCREEN.height, 165 169 }, 166 170 }) 167 171 ··· 191 195 const zoom = Math.max( 192 196 imageAspect / screenAspect, 193 197 screenAspect / imageAspect, 194 - MIN_ZOOM, 198 + MIN_DOUBLE_TAP_SCALE, 195 199 ) 196 200 // Unlike in the Android version, we don't constrain the *max* zoom level here. 197 201 // Instead, this is done in the ScrollView props so that it constraints pinch too. ··· 250 254 y: rectY, 251 255 height: rectHeight, 252 256 width: rectWidth, 253 - } 254 - } 255 - 256 - const getImageStyles = ( 257 - image: ImageDimensions | null, 258 - translate: {readonly x: number; readonly y: number} | undefined, 259 - scale?: number, 260 - ) => { 261 - if (!image?.width || !image?.height) { 262 - return {width: 0, height: 0} 263 - } 264 - const transform = [] 265 - if (translate) { 266 - transform.push({translateX: translate.x}) 267 - transform.push({translateY: translate.y}) 268 - } 269 - if (scale) { 270 - // @ts-ignore TODO - is scale incorrect? might need to remove -prf 271 - transform.push({scale}, {perspective: new Animated.Value(1000)}) 272 - } 273 - return { 274 - width: image.width, 275 - height: image.height, 276 - transform, 277 - } 278 - } 279 - 280 - const getImageTransform = ( 281 - image: ImageDimensions | null, 282 - screen: ImageDimensions, 283 - ) => { 284 - if (!image?.width || !image?.height) { 285 - return [] as const 286 - } 287 - 288 - const wScale = screen.width / image.width 289 - const hScale = screen.height / image.height 290 - const scale = Math.min(wScale, hScale) 291 - const {x, y} = getImageTranslate(image, screen) 292 - 293 - return [{x, y}, scale] as const 294 - } 295 - 296 - const getImageTranslate = ( 297 - image: ImageDimensions, 298 - screen: ImageDimensions, 299 - ): {x: number; y: number} => { 300 - const getTranslateForAxis = (axis: 'x' | 'y'): number => { 301 - const imageSize = axis === 'x' ? image.width : image.height 302 - const screenSize = axis === 'x' ? screen.width : screen.height 303 - 304 - return (screenSize - imageSize) / 2 305 - } 306 - 307 - return { 308 - x: getTranslateForAxis('x'), 309 - y: getTranslateForAxis('y'), 310 257 } 311 258 } 312 259