From f452ce74f4fe5ed5aa215b4b18302623f435d0d2 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 10 Oct 2023 10:04:38 +0100 Subject: [PATCH] Refactor iOS lightbox to Reanimated (#1645) * Remove unnecessary transform logic * Switch iOS swipe-to-dimiss to Reanimated --- .../components/ImageItem/ImageItem.ios.tsx | 165 ++++++------------ 1 file changed, 56 insertions(+), 109 deletions(-) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 598b18ed..75e8b0e8 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,20 +6,25 @@ * */ -import React, {MutableRefObject, useCallback, useRef, useState} from 'react' +import React, {MutableRefObject, useCallback, useState} from 'react' import { - Animated, Dimensions, - ScrollView, StyleSheet, View, - NativeScrollEvent, NativeSyntheticEvent, NativeTouchEvent, TouchableWithoutFeedback, } from 'react-native' import {Image} from 'expo-image' +import Animated, { + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' import {GestureType} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' @@ -31,10 +36,8 @@ const DOUBLE_TAP_DELAY = 300 const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_VELOCITY = 1 const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const MIN_ZOOM = 2 -const MAX_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 +const MIN_DOUBLE_TAP_SCALE = 2 type Props = { imageSrc: ImageSource @@ -49,44 +52,42 @@ const AnimatedImage = Animated.createAnimatedComponent(Image) let lastTapTS: number | null = null const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const scrollViewRef = useRef(null) + const scrollViewRef = useAnimatedRef() + const translationY = useSharedValue(0) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const [scrollValueY] = useState(() => new Animated.Value(0)) - const maxScrollViewZoom = MAX_SCALE / (scale || 1) + const maxZoomScale = imageDimensions + ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + : 1 - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.5, 1, 0.5], + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + translationY.value, + [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + [0.5, 1, 0.5], + ), + } }) - const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - const onScrollEndDrag = useCallback( - ({nativeEvent}: NativeSyntheticEvent) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const currentScaled = nativeEvent?.zoomScale > 1 - - onZoom(currentScaled) - setScaled(currentScaled) - - if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - onRequestClose() + const scrollHandler = useAnimatedScrollHandler({ + onScroll(e) { + translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y + }, + onEndDrag(e) { + const velocityY = e.velocity?.y ?? 0 + const nextIsScaled = e.zoomScale > 1 + runOnJS(handleZoom)(nextIsScaled) + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { + runOnJS(onRequestClose)() } }, - [onRequestClose, onZoom], - ) + }) - const onScroll = ({nativeEvent}: NativeSyntheticEvent) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 - - if (nativeEvent?.zoomScale > 1) { - return - } - - scrollValueY.setValue(offsetY) + function handleZoom(nextIsScaled: boolean) { + onZoom(nextIsScaled) + setScaled(nextIsScaled) } const handleDoubleTap = useCallback( @@ -121,23 +122,21 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { lastTapTS = nowTS } }, - [imageDimensions, scaled], + [imageDimensions, scaled, scrollViewRef], ) return ( - + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && } { accessibilityLabel={imageSrc.alt} accessibilityHint=""> setLoaded(true)} /> - + ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, - }, imageScrollContainer: { - height: SCREEN_HEIGHT, + height: SCREEN.height, + }, + listItem: { + width: SCREEN.width, + height: SCREEN.height, + }, + image: { + width: SCREEN.width, + height: SCREEN.height, }, }) @@ -191,7 +195,7 @@ const getZoomRectAfterDoubleTap = ( const zoom = Math.max( imageAspect / screenAspect, screenAspect / imageAspect, - MIN_ZOOM, + MIN_DOUBLE_TAP_SCALE, ) // Unlike in the Android version, we don't constrain the *max* zoom level here. // Instead, this is done in the ScrollView props so that it constraints pinch too. @@ -253,61 +257,4 @@ const getZoomRectAfterDoubleTap = ( } } -const getImageStyles = ( - image: ImageDimensions | null, - translate: {readonly x: number; readonly y: number} | undefined, - scale?: number, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - const transform = [] - if (translate) { - transform.push({translateX: translate.x}) - transform.push({translateY: translate.y}) - } - if (scale) { - // @ts-ignore TODO - is scale incorrect? might need to remove -prf - transform.push({scale}, {perspective: new Animated.Value(1000)}) - } - return { - width: image.width, - height: image.height, - transform, - } -} - -const getImageTransform = ( - image: ImageDimensions | null, - screen: ImageDimensions, -) => { - if (!image?.width || !image?.height) { - return [] as const - } - - const wScale = screen.width / image.width - const hScale = screen.height / image.height - const scale = Math.min(wScale, hScale) - const {x, y} = getImageTranslate(image, screen) - - return [{x, y}, scale] as const -} - -const getImageTranslate = ( - image: ImageDimensions, - screen: ImageDimensions, -): {x: number; y: number} => { - const getTranslateForAxis = (axis: 'x' | 'y'): number => { - const imageSize = axis === 'x' ? image.width : image.height - const screenSize = axis === 'x' ? screen.width : screen.height - - return (screenSize - imageSize) / 2 - } - - return { - x: getTranslateForAxis('x'), - y: getTranslateForAxis('y'), - } -} - export default React.memo(ImageItem)