diff options
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components/ImageItem')
3 files changed, 141 insertions, 67 deletions
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index f5e858209..927657baf 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -36,23 +36,11 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (isZoomed: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: Props) => { +const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) const imageDimensions = useImageDimensions(imageSrc) const [translate, scale] = getImageTransform(imageDimensions, SCREEN) @@ -72,17 +60,10 @@ const ImageItem = ({ [onZoom], ) - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) - const [panHandlers, scaleValue, translateValue] = usePanResponder({ initialScale: scale || 1, initialTranslate: translate || {x: 0, y: 0}, onZoom: onZoomPerformed, - doubleTapToZoomEnabled, - onLongPress: onLongPressHandler, - delayLongPress, }) const imagesStyles = getImageStyles( @@ -126,11 +107,9 @@ const ImageItem = ({ showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} - {...(swipeToCloseEnabled && { - onScroll, - onScrollEndDrag, - })}> + scrollEnabled={true} + onScroll={onScroll} + onScrollEndDrag={onScrollEndDrag}> <AnimatedImage {...panHandlers} source={imageSrc} 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 03bf45af1..f379df22f 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -16,57 +16,45 @@ import { View, NativeScrollEvent, NativeSyntheticEvent, + NativeTouchEvent, TouchableWithoutFeedback, } from 'react-native' import {Image} from 'expo-image' -import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' import useImageDimensions from '../../hooks/useImageDimensions' import {getImageStyles, getImageTransform} from '../../utils' import {ImageSource} from '../../@types' import {ImageLoading} from './ImageLoading' +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 type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: Props) => { +let lastTapTS: number | null = null + +const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { const scrollViewRef = useRef<ScrollView>(null) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const handleDoubleTap = useDoubleTapToZoom( - scrollViewRef, - scaled, - SCREEN, - imageDimensions, - ) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) + + // TODO: It's not valid to reinitialize Animated values during render. + // This is a bug. const scrollValueY = new Animated.Value(0) const scaleValue = new Animated.Value(scale || 1) const translateValue = new Animated.ValueXY(translate) @@ -91,15 +79,11 @@ const ImageItem = ({ onZoom(currentScaled) setScaled(currentScaled) - if ( - !currentScaled && - swipeToCloseEnabled && - Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY - ) { + if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { onRequestClose() } }, - [onRequestClose, onZoom, swipeToCloseEnabled], + [onRequestClose, onZoom], ) const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { @@ -112,9 +96,40 @@ const ImageItem = ({ scrollValueY.setValue(offsetY) } - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) + const handleDoubleTap = useCallback( + (event: NativeSyntheticEvent<NativeTouchEvent>) => { + const nowTS = new Date().getTime() + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } + + const willZoom = !scaled + if (willZoom) { + const {pageX, pageY} = event.nativeEvent + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + pageX, + pageY, + ) + } + + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + ...nextZoomRect, // This rect is in screen coordinates + animated: true, + }) + } else { + lastTapTS = nowTS + } + }, + [imageDimensions, scaled], + ) return ( <View> @@ -126,17 +141,13 @@ const ImageItem = ({ showsVerticalScrollIndicator={false} maximumZoomScale={maxScrollViewZoom} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} + scrollEnabled={true} + onScroll={onScroll} onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1} - {...(swipeToCloseEnabled && { - onScroll, - })}> + scrollEventThrottle={1}> {(!loaded || !imageDimensions) && <ImageLoading />} <TouchableWithoutFeedback - onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} - onLongPress={onLongPressHandler} - delayLongPress={delayLongPress} + onPress={handleDoubleTap} accessibilityRole="image" accessibilityLabel={imageSrc.alt} accessibilityHint=""> @@ -161,4 +172,92 @@ const styles = StyleSheet.create({ }, }) +const getZoomRectAfterDoubleTap = ( + imageDimensions: {width: number; height: number} | null, + touchX: number, + touchY: number, +): { + x: number + y: number + width: number + height: number +} => { + if (!imageDimensions) { + return { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } + } + + // First, let's figure out how much we want to zoom in. + // We want to try to zoom in at least close enough to get rid of black bars. + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const zoom = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_ZOOM, + ) + // 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. + + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. + // We already know the zoom level, so this gives us the rectangle size. + let rectWidth = SCREEN.width / zoom + let rectHeight = SCREEN.height / zoom + + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. + // We don't want to introduce new black bars or make existing black bars unbalanced. + let minX = 0 + let minY = 0 + let maxX = SCREEN.width - rectWidth + let maxY = SCREEN.height - rectHeight + if (imageAspect >= screenAspect) { + // The image has horizontal black bars. Exclude them from the safe area. + const renderedHeight = SCREEN.width / imageAspect + const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2 + minY += horizontalBarHeight + maxY -= horizontalBarHeight + } else { + // The image has vertical black bars. Exclude them from the safe area. + const renderedWidth = SCREEN.height * imageAspect + const verticalBarWidth = (SCREEN.width - renderedWidth) / 2 + minX += verticalBarWidth + maxX -= verticalBarWidth + } + + // Finally, we can position the rect according to its size and the safe area. + let rectX + if (maxX >= minX) { + // Content fills the screen horizontally so we have horizontal wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectX = touchX - touchX / zoom + rectX = Math.min(rectX, maxX) + rectX = Math.max(rectX, minX) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectX = SCREEN.width / 2 - rectWidth / 2 + } + let rectY + if (maxY >= minY) { + // Content fills the screen vertically so we have vertical wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectY = touchY - touchY / zoom + rectY = Math.min(rectY, maxY) + rectY = Math.max(rectY, minY) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectY = SCREEN.height / 2 - rectHeight / 2 + } + + return { + x: rectX, + y: rectY, + height: rectHeight, + width: rectWidth, + } +} + export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index fd377dde2..82ee86d7d 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -8,10 +8,6 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const ImageItem = (_props: Props) => { |