From abfd9a8c0b850424ebf26da0841f25ecacfd8407 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 13 Oct 2023 20:10:27 +0100 Subject: [PATCH] Toggle lightbox controls on tap (#1687) * Make the lightbox controls animation smoother * Toggle controls on tap * Disable pointer events when hidden --- .../ImageItem/ImageItem.android.tsx | 7 ++ .../components/ImageItem/ImageItem.ios.tsx | 117 +++++++++--------- .../components/ImageItem/ImageItem.tsx | 1 + src/view/com/lightbox/ImageViewing/index.tsx | 19 ++- 4 files changed, 81 insertions(+), 63 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 51352486..7c7ad061 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -34,11 +34,13 @@ const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (isZoomed: boolean) => void isScrollViewBeingDragged: boolean } const ImageItem = ({ imageSrc, + onTap, onZoom, onRequestClose, isScrollViewBeingDragged, @@ -227,6 +229,10 @@ const ImageItem = ({ panTranslation.value = {x: 0, y: 0} }) + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + const doubleTap = Gesture.Tap() .numberOfTaps(2) .onEnd(e => { @@ -297,6 +303,7 @@ const ImageItem = ({ dismissSwipePan, Gesture.Simultaneous(pinch, pan), doubleTap, + singleTap, ) const isLoading = !isLoaded || !imageDimensions 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 cd550670..f73f355a 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,16 +6,9 @@ * */ -import React, {useCallback, useState} from 'react' +import React, {useState} from 'react' -import { - Dimensions, - StyleSheet, - View, - NativeSyntheticEvent, - NativeTouchEvent, - TouchableWithoutFeedback, -} from 'react-native' +import {Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' import Animated, { interpolate, @@ -25,13 +18,13 @@ import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import {ImageSource, Dimensions as ImageDimensions} 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') @@ -41,15 +34,14 @@ const MIN_DOUBLE_TAP_SCALE = 2 type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -let lastTapTS: number | null = null - -const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { +const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { const scrollViewRef = useAnimatedRef() const translationY = useSharedValue(0) const [loaded, setLoaded] = useState(false) @@ -71,12 +63,18 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { - translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y + const nextIsScaled = e.zoomScale > 1 + translationY.value = nextIsScaled ? 0 : e.contentOffset.y + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) + } }, onEndDrag(e) { const velocityY = e.velocity?.y ?? 0 const nextIsScaled = e.zoomScale > 1 - runOnJS(handleZoom)(nextIsScaled) + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) + } if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { runOnJS(onRequestClose)() } @@ -88,43 +86,46 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { setScaled(nextIsScaled) } - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent) => { - const nowTS = new Date().getTime() - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + function handleDoubleTap(absoluteX: number, absoluteY: number) { + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } - if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { - let nextZoomRect = { - x: 0, - y: 0, - width: SCREEN.width, - height: SCREEN.height, - } + const willZoom = !scaled + if (willZoom) { + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + absoluteX, + absoluteY, + ) + } - 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, + }) + } - // @ts-ignore - scrollResponderRef?.scrollResponderZoomTo({ - ...nextZoomRect, // This rect is in screen coordinates - animated: true, - }) - } else { - lastTapTS = nowTS - } - }, - [imageDimensions, scaled, scrollViewRef], - ) + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + const {absoluteX, absoluteY} = e + runOnJS(handleDoubleTap)(absoluteX, absoluteY) + }) + + const composedGesture = Gesture.Exclusive(doubleTap, singleTap) return ( - + { contentContainerStyle={styles.imageScrollContainer} onScroll={scrollHandler}> {(!loaded || !imageDimensions) && } - - setLoaded(true)} - /> - + accessibilityHint="" + onLoad={() => setLoaded(true)} + /> - + ) } diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 35be96e4..16688b82 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -7,6 +7,7 @@ import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean } diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 78d16f8a..b6835793 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -43,24 +43,36 @@ function ImageViewing({ const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) + const [showControls, setShowControls] = useState(true) const animatedHeaderStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), transform: [ { - translateY: withClampedSpring(isScaled ? -300 : 0), + translateY: withClampedSpring(showControls ? 0 : -30), }, ], })) const animatedFooterStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), transform: [ { - translateY: withClampedSpring(isScaled ? 300 : 0), + translateY: withClampedSpring(showControls ? 0 : 30), }, ], })) + const onTap = useCallback(() => { + setShowControls(show => !show) + }, []) + const onZoom = useCallback((nextIsScaled: boolean) => { setIsScaled(nextIsScaled) + if (nextIsScaled) { + setShowControls(false) + } }, []) const edges = useMemo(() => { @@ -105,6 +117,7 @@ function ImageViewing({ {images.map(imageSrc => ( ( function withClampedSpring(value: any) { 'worklet' - return withSpring(value, {overshootClamping: true}) + return withSpring(value, {overshootClamping: true, stiffness: 300}) } export default EnhancedImageViewing