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 6276a1a1..6ff4dee2 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,159 +1,398 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ +import React, {MutableRefObject, useState} from 'react' -import React, {useCallback, useRef, useState} from 'react' - -import { - Animated, - ScrollView, - Dimensions, - StyleSheet, - NativeScrollEvent, - NativeSyntheticEvent, - NativeMethodsMixin, -} from 'react-native' +import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' - +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useAnimatedStyle, + useAnimatedReaction, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' +import { + GestureDetector, + Gesture, + GestureType, +} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import usePanResponder from '../../hooks/usePanResponder' +import { + createTransform, + readTransform, + applyRounding, + prependPan, + prependPinch, + prependTransform, + TransformMatrix, +} from '../../transforms' +import type {ImageSource, Dimensions as ImageDimensions} from '../../@types' -import {getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' -import {ImageLoading} from './ImageLoading' - -const SWIPE_CLOSE_OFFSET = 75 -const SWIPE_CLOSE_VELOCITY = 1.75 const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height +const MIN_DOUBLE_TAP_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 + +const AnimatedImage = Animated.createAnimatedComponent(Image) +const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (isZoomed: boolean) => void + pinchGestureRef: MutableRefObject + isScrollViewBeingDragged: boolean } - -const AnimatedImage = Animated.createAnimatedComponent(Image) - -const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const imageContainer = useRef(null) +const ImageItem = ({ + imageSrc, + onZoom, + onRequestClose, + isScrollViewBeingDragged, + pinchGestureRef, +}: Props) => { + const [isScaled, setIsScaled] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const scrollValueY = new Animated.Value(0) - const [isLoaded, setLoadEnd] = useState(false) + const committedTransform = useSharedValue(initialTransform) + const panTranslation = useSharedValue({x: 0, y: 0}) + const pinchOrigin = useSharedValue({x: 0, y: 0}) + const pinchScale = useSharedValue(1) + const pinchTranslation = useSharedValue({x: 0, y: 0}) + const dismissSwipeTranslateY = useSharedValue(0) + const containerRef = useAnimatedRef() - const onLoaded = useCallback(() => setLoadEnd(true), []) - const onZoomPerformed = useCallback( - (isZoomed: boolean) => { - onZoom(isZoomed) - if (imageContainer?.current) { - imageContainer.current.setNativeProps({ - scrollEnabled: !isZoomed, - }) + function getCommittedScale(): number { + 'worklet' + const [, , committedScale] = readTransform(committedTransform.value) + return committedScale + } + + // Keep track of when we're entering or leaving scaled rendering. + useAnimatedReaction( + () => { + return pinchScale.value !== 1 || getCommittedScale() !== 1 + }, + (nextIsScaled, prevIsScaled) => { + if (nextIsScaled !== prevIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onZoom], ) - const [panHandlers, scaleValue, translateValue] = usePanResponder({ - initialScale: scale || 1, - initialTranslate: translate || {x: 0, y: 0}, - onZoom: onZoomPerformed, - }) + function handleZoom(nextIsScaled: boolean) { + setIsScaled(nextIsScaled) + onZoom(nextIsScaled) + } - const imagesStyles = getImageStyles( - imageDimensions, - translateValue, - scaleValue, - ) - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.7, 1, 0.7], - }) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} + const animatedStyle = useAnimatedStyle(() => { + // Apply the active adjustments on top of the committed transform before the gestures. + // This is matrix multiplication, so operations are applied in the reverse order. + let t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [translateX, translateY, scale] = readTransform(t) - const onScrollEndDrag = ({ - nativeEvent, - }: NativeSyntheticEvent) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 - - if ( - (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && - offsetY > SWIPE_CLOSE_OFFSET) || - offsetY > SCREEN_HEIGHT / 2 - ) { - onRequestClose() + const dismissDistance = dismissSwipeTranslateY.value + const dismissProgress = Math.min( + Math.abs(dismissDistance) / (SCREEN.height / 2), + 1, + ) + return { + opacity: 1 - dismissProgress, + transform: [ + {translateX}, + {translateY: translateY + dismissDistance}, + {scale}, + ], } + }) + + // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. + // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. + function getExtraTranslationToStayInBounds( + candidateTransform: TransformMatrix, + ) { + 'worklet' + if (!imageDimensions) { + return [0, 0] + } + const [nextTranslateX, nextTranslateY, nextScale] = + readTransform(candidateTransform) + const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) + const clampedTranslateX = clampTranslation( + nextTranslateX, + scaledDimensions.width, + SCREEN.width, + ) + const clampedTranslateY = clampTranslation( + nextTranslateY, + scaledDimensions.height, + SCREEN.height, + ) + const dx = clampedTranslateX - nextTranslateX + const dy = clampedTranslateY - nextTranslateY + return [dx, dy] } - const onScroll = ({nativeEvent}: NativeSyntheticEvent) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + // This is a hack. + // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. + // However, there is no great reliable way to coordinate this yet in RGNH. + // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. + const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { + if (isScrollViewBeingDragged) { + // Steal the gesture (and do nothing, so native ScrollView does its thing). + manager.activate() + return + } + const measurement = measure(containerRef) + if (!measurement || measurement.pageX !== 0) { + // Steal the gesture (and do nothing, so native ScrollView does its thing). + manager.activate() + return + } + // Fail this "fake" gesture so that the gestures after it can proceed. + manager.fail() + }) - scrollValueY.setValue(offsetY) - } + const pinch = Gesture.Pinch() + .withRef(pinchGestureRef) + .onStart(e => { + pinchOrigin.value = { + x: e.focalX - SCREEN.width / 2, + y: e.focalY - SCREEN.height / 2, + } + }) + .onChange(e => { + if (!imageDimensions) { + return + } + // Don't let the picture zoom in so close that it gets blurry. + // Also, like in stock Android apps, don't let the user zoom out further than 1:1. + const committedScale = getCommittedScale() + const maxCommittedScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const minPinchScale = 1 / committedScale + const maxPinchScale = maxCommittedScale / committedScale + const nextPinchScale = Math.min( + Math.max(minPinchScale, e.scale), + maxPinchScale, + ) + pinchScale.value = nextPinchScale + // Zooming out close to the corner could push us out of bounds, which we don't want on Android. + // Calculate where we'll end up so we know how much to translate back to stay in bounds. + const t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [dx, dy] = getExtraTranslationToStayInBounds(t) + if (dx !== 0 || dy !== 0) { + pinchTranslation.value = { + x: pinchTranslation.value.x + dx, + y: pinchTranslation.value.y + dy, + } + } + }) + .onEnd(() => { + // Commit just the pinch. + let t = createTransform() + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pinch. + pinchScale.value = 1 + pinchOrigin.value = {x: 0, y: 0} + pinchTranslation.value = {x: 0, y: 0} + }) + + const pan = Gesture.Pan() + .averageTouches(true) + // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: + .minPointers(isScaled ? 1 : 2) + .onChange(e => { + if (!imageDimensions) { + return + } + const nextPanTranslation = {x: e.translationX, y: e.translationY} + let t = createTransform() + prependPan(t, nextPanTranslation) + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + + // Prevent panning from going out of bounds. + const [dx, dy] = getExtraTranslationToStayInBounds(t) + nextPanTranslation.x += dx + nextPanTranslation.y += dy + panTranslation.value = nextPanTranslation + }) + .onEnd(() => { + // Commit just the pan. + let t = createTransform() + prependPan(t, panTranslation.value) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pan. + panTranslation.value = {x: 0, y: 0} + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + if (!imageDimensions) { + return + } + const committedScale = getCommittedScale() + if (committedScale !== 1) { + // Go back to 1:1 using the identity vector. + let t = createTransform() + committedTransform.value = withClampedSpring(t) + return + } + + // Try to zoom in so that we get rid of the black bars (whatever the orientation was). + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const candidateScale = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_DOUBLE_TAP_SCALE, + ) + // But don't zoom in so close that the picture gets blurry. + const maxScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const scale = Math.min(candidateScale, maxScale) + + // Calculate where we would be if the user pinched into the double tapped point. + // We won't use this transform directly because it may go out of bounds. + const candidateTransform = createTransform() + const origin = { + x: e.absoluteX - SCREEN.width / 2, + y: e.absoluteY - SCREEN.height / 2, + } + prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) + + // Now we know how much we went out of bounds, so we can shoot correctly. + const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform) + const finalTransform = createTransform() + prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) + committedTransform.value = withClampedSpring(finalTransform) + }) + + const dismissSwipePan = Gesture.Pan() + .enabled(!isScaled) + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onUpdate(e => { + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + if (Math.abs(e.velocityY) > 1000) { + dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) + runOnJS(onRequestClose)() + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 700, + damping: 50, + }) + } + }) + + const isLoading = !isLoaded || !imageDimensions return ( - - - {(!isLoaded || !imageDimensions) && } - + + {isLoading && ( + + )} + + setIsLoaded(true)} + /> + + ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + container: { + width: SCREEN.width, + height: SCREEN.height, + overflow: 'hidden', }, - imageScrollContainer: { - height: SCREEN_HEIGHT * 2, + image: { + flex: 1, + }, + loading: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, }, }) -const getImageStyles = ( - image: {width: number; height: number} | null, - translate: Animated.ValueXY, - scale?: Animated.Value, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - - const transform = translate.getTranslateTransform() - - 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, +function getScaledDimensions( + imageDimensions: ImageDimensions, + scale: number, +): ImageDimensions { + 'worklet' + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const isLandscape = imageAspect > screenAspect + if (isLandscape) { + return { + width: scale * SCREEN.width, + height: (scale * SCREEN.width) / imageAspect, + } + } else { + return { + width: scale * SCREEN.height * imageAspect, + height: scale * SCREEN.height, + } } } +function clampTranslation( + value: number, + scaledSize: number, + screenSize: number, +): number { + 'worklet' + // Figure out how much the user should be allowed to pan, and constrain the translation. + const panDistance = Math.max(0, (scaledSize - screenSize) / 2) + const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) + return clampedValue +} + +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true}) +} + export default React.memo(ImageItem) 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 b9f3ae51..598b18ed 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,7 +6,7 @@ * */ -import React, {useCallback, useRef, useState} from 'react' +import React, {MutableRefObject, useCallback, useRef, useState} from 'react' import { Animated, @@ -20,11 +20,11 @@ import { TouchableWithoutFeedback, } from 'react-native' import {Image} from 'expo-image' +import {GestureType} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import {getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' +import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' const DOUBLE_TAP_DELAY = 300 @@ -40,6 +40,8 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void + pinchGestureRef: MutableRefObject + isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) @@ -164,7 +166,7 @@ const styles = StyleSheet.create({ }) const getZoomRectAfterDoubleTap = ( - imageDimensions: {width: number; height: number} | null, + imageDimensions: ImageDimensions | null, touchX: number, touchY: number, ): { @@ -252,7 +254,7 @@ const getZoomRectAfterDoubleTap = ( } const getImageStyles = ( - image: {width: number; height: number} | null, + image: ImageDimensions | null, translate: {readonly x: number; readonly y: number} | undefined, scale?: number, ) => { @@ -275,4 +277,37 @@ const getImageStyles = ( } } +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) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 82ee86d7..898b00c7 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,13 +1,16 @@ // default implementation fallback for web -import React from 'react' +import React, {MutableRefObject} from 'react' import {View} from 'react-native' +import {GestureType} from 'react-native-gesture-handler' import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void + pinchGestureRef: MutableRefObject + isScrollViewBeingDragged: boolean } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts deleted file mode 100644 index 85454e37..00000000 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ /dev/null @@ -1,423 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useEffect} from 'react' -import { - Animated, - Dimensions, - GestureResponderEvent, - GestureResponderHandlers, - NativeTouchEvent, - PanResponder, - PanResponderGestureState, -} from 'react-native' - -import {Position} from '../@types' -import {getImageTranslate} from '../utils' - -const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const ANDROID_BAR_HEIGHT = 24 - -const MIN_ZOOM = 2 -const MAX_SCALE = 2 -const DOUBLE_TAP_DELAY = 300 -const OUT_BOUND_MULTIPLIER = 0.75 - -type Props = { - initialScale: number - initialTranslate: Position - onZoom: (isZoomed: boolean) => void -} - -const usePanResponder = ({ - initialScale, - initialTranslate, - onZoom, -}: Props): Readonly< - [GestureResponderHandlers, Animated.Value, Animated.ValueXY] -> => { - let numberInitialTouches = 1 - let initialTouches: NativeTouchEvent[] = [] - let currentScale = initialScale - let currentTranslate = initialTranslate - let tmpScale = 0 - let tmpTranslate: Position | null = null - let isDoubleTapPerformed = false - let lastTapTS: number | null = null - - // TODO: It's not valid to reinitialize Animated values during render. - // This is a bug. - const scaleValue = new Animated.Value(initialScale) - const translateValue = new Animated.ValueXY(initialTranslate) - - const imageDimensions = getImageDimensionsByTranslate( - initialTranslate, - SCREEN, - ) - - const getBounds = (scale: number) => { - const scaledImageDimensions = { - width: imageDimensions.width * scale, - height: imageDimensions.height * scale, - } - const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN) - - const left = initialTranslate.x - translateDelta.x - const right = left - (scaledImageDimensions.width - SCREEN.width) - const top = initialTranslate.y - translateDelta.y - const bottom = top - (scaledImageDimensions.height - SCREEN.height) - - return [top, left, bottom, right] - } - - const getTransformAfterDoubleTap = ( - touchX: number, - touchY: number, - ): [number, Position] => { - let nextScale = initialScale - let nextTranslateX = initialTranslate.x - let nextTranslateY = initialTranslate.y - - // 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 - let zoom = Math.max( - imageAspect / screenAspect, - screenAspect / imageAspect, - MIN_ZOOM, - ) - // Don't zoom so hard that the original image's pixels become blurry. - zoom = Math.min(zoom, MAX_SCALE / initialScale) - nextScale = initialScale * zoom - - // Next, let's see if we need to adjust the scaled image translation. - // Ideally, we want the tapped point to stay under the finger after the scaling. - const dx = SCREEN.width / 2 - touchX - const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT) - // Before we try to adjust the translation, check how much wiggle room we have. - // We don't want to introduce new black bars or make existing black bars unbalanced. - const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale) - if (leftBound > rightBound) { - // Content fills the screen horizontally so we have horizontal wiggle room. - // Try to keep the tapped point under the finger after zoom. - nextTranslateX += dx * zoom - dx - nextTranslateX = Math.min(nextTranslateX, leftBound) - nextTranslateX = Math.max(nextTranslateX, rightBound) - } - if (topBound > bottomBound) { - // Content fills the screen vertically so we have vertical wiggle room. - // Try to keep the tapped point under the finger after zoom. - nextTranslateY += dy * zoom - dy - nextTranslateY = Math.min(nextTranslateY, topBound) - nextTranslateY = Math.max(nextTranslateY, bottomBound) - } - - return [ - nextScale, - { - x: nextTranslateX, - y: nextTranslateY, - }, - ] - } - - const fitsScreenByWidth = () => - imageDimensions.width * currentScale < SCREEN_WIDTH - const fitsScreenByHeight = () => - imageDimensions.height * currentScale < SCREEN_HEIGHT - - useEffect(() => { - scaleValue.addListener(({value}) => { - if (typeof onZoom === 'function') { - onZoom(value !== initialScale) - } - }) - - return () => scaleValue.removeAllListeners() - }) - - const panResponder = PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: () => true, - onMoveShouldSetPanResponderCapture: () => true, - onPanResponderGrant: ( - _: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - numberInitialTouches = gestureState.numberActiveTouches - - if (gestureState.numberActiveTouches > 1) { - return - } - }, - onPanResponderStart: ( - event: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - initialTouches = event.nativeEvent.touches - numberInitialTouches = gestureState.numberActiveTouches - - if (gestureState.numberActiveTouches > 1) { - return - } - - const tapTS = Date.now() - // Handle double tap event by calculating diff between first and second taps timestamps - - isDoubleTapPerformed = Boolean( - lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, - ) - - if (isDoubleTapPerformed) { - let nextScale = initialScale - let nextTranslate = initialTranslate - - const willZoom = currentScale === initialScale - if (willZoom) { - const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] - ;[nextScale, nextTranslate] = getTransformAfterDoubleTap( - touchX, - touchY, - ) - } - onZoom(willZoom) - - Animated.parallel( - [ - Animated.timing(translateValue.x, { - toValue: nextTranslate.x, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslate.y, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(scaleValue, { - toValue: nextScale, - duration: 300, - useNativeDriver: true, - }), - ], - {stopTogether: false}, - ).start(() => { - currentScale = nextScale - currentTranslate = nextTranslate - }) - - lastTapTS = null - } else { - lastTapTS = Date.now() - } - }, - onPanResponderMove: ( - event: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - // Don't need to handle move because double tap in progress (was handled in onStart) - if (isDoubleTapPerformed) { - return - } - - if ( - numberInitialTouches === 1 && - gestureState.numberActiveTouches === 2 - ) { - numberInitialTouches = 2 - initialTouches = event.nativeEvent.touches - } - - const isTapGesture = - numberInitialTouches === 1 && gestureState.numberActiveTouches === 1 - const isPinchGesture = - numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 - - if (isPinchGesture) { - const initialDistance = getDistanceBetweenTouches(initialTouches) - const currentDistance = getDistanceBetweenTouches( - event.nativeEvent.touches, - ) - - let nextScale = (currentDistance / initialDistance) * currentScale - - /** - * In case image is scaling smaller than initial size -> - * slow down this transition by applying OUT_BOUND_MULTIPLIER - */ - if (nextScale < initialScale) { - nextScale = - nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER - } - - /** - * In case image is scaling down -> move it in direction of initial position - */ - if (currentScale > initialScale && currentScale > nextScale) { - const k = (currentScale - initialScale) / (currentScale - nextScale) - - const nextTranslateX = - nextScale < initialScale - ? initialTranslate.x - : currentTranslate.x - - (currentTranslate.x - initialTranslate.x) / k - - const nextTranslateY = - nextScale < initialScale - ? initialTranslate.y - : currentTranslate.y - - (currentTranslate.y - initialTranslate.y) / k - - translateValue.x.setValue(nextTranslateX) - translateValue.y.setValue(nextTranslateY) - - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} - } - - scaleValue.setValue(nextScale) - tmpScale = nextScale - } - - if (isTapGesture && currentScale > initialScale) { - const {x, y} = currentTranslate - - const {dx, dy} = gestureState - const [topBound, leftBound, bottomBound, rightBound] = - getBounds(currentScale) - - let nextTranslateX = x + dx - let nextTranslateY = y + dy - - if (nextTranslateX > leftBound) { - nextTranslateX = - nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateX < rightBound) { - nextTranslateX = - nextTranslateX - - (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateY > topBound) { - nextTranslateY = - nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateY < bottomBound) { - nextTranslateY = - nextTranslateY - - (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER - } - - if (fitsScreenByWidth()) { - nextTranslateX = x - } - - if (fitsScreenByHeight()) { - nextTranslateY = y - } - - translateValue.x.setValue(nextTranslateX) - translateValue.y.setValue(nextTranslateY) - - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} - } - }, - onPanResponderRelease: () => { - if (isDoubleTapPerformed) { - isDoubleTapPerformed = false - } - - if (tmpScale > 0) { - if (tmpScale < initialScale || tmpScale > MAX_SCALE) { - tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE - Animated.timing(scaleValue, { - toValue: tmpScale, - duration: 100, - useNativeDriver: true, - }).start() - } - - currentScale = tmpScale - tmpScale = 0 - } - - if (tmpTranslate) { - const {x, y} = tmpTranslate - const [topBound, leftBound, bottomBound, rightBound] = - getBounds(currentScale) - - let nextTranslateX = x - let nextTranslateY = y - - if (!fitsScreenByWidth()) { - if (nextTranslateX > leftBound) { - nextTranslateX = leftBound - } else if (nextTranslateX < rightBound) { - nextTranslateX = rightBound - } - } - - if (!fitsScreenByHeight()) { - if (nextTranslateY > topBound) { - nextTranslateY = topBound - } else if (nextTranslateY < bottomBound) { - nextTranslateY = bottomBound - } - } - - Animated.parallel([ - Animated.timing(translateValue.x, { - toValue: nextTranslateX, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslateY, - duration: 100, - useNativeDriver: true, - }), - ]).start() - - currentTranslate = {x: nextTranslateX, y: nextTranslateY} - tmpTranslate = null - } - }, - onPanResponderTerminationRequest: () => false, - onShouldBlockNativeResponder: () => false, - }) - - return [panResponder.panHandlers, scaleValue, translateValue] -} - -const getImageDimensionsByTranslate = ( - translate: Position, - screen: {width: number; height: number}, -): {width: number; height: number} => ({ - width: screen.width - translate.x * 2, - height: screen.height - translate.y * 2, -}) - -const getDistanceBetweenTouches = (touches: NativeTouchEvent[]): number => { - const [a, b] = touches - - if (a == null || b == null) { - return 0 - } - - return Math.sqrt( - Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), - ) -} - -export default usePanResponder diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 4b7208a2..bc2a8a44 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -10,6 +10,7 @@ import React, { ComponentType, + createRef, useCallback, useRef, useMemo, @@ -32,6 +33,7 @@ import ImageItem from './components/ImageItem/ImageItem' import ImageDefaultHeader from './components/ImageDefaultHeader' import {ImageSource} from './@types' +import {ScrollView, GestureType} from 'react-native-gesture-handler' import {Edge, SafeAreaView} from 'react-native-safe-area-context' type Props = { @@ -67,6 +69,8 @@ function ImageViewing({ FooterComponent, }: Props) { const imageList = useRef>(null) + const [isScaled, setIsScaled] = useState(false) + const [isDragging, setIsDragging] = useState(false) const [opacity, setOpacity] = useState(1) const [currentImageIndex, setImageIndex] = useState(imageIndex) const [headerTranslate] = useState( @@ -115,10 +119,9 @@ function ImageViewing({ } } - const onZoom = (isScaled: boolean) => { - // @ts-ignore - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) - toggleBarsVisible(!isScaled) + const onZoom = (nextIsScaled: boolean) => { + toggleBarsVisible(!nextIsScaled) + setIsScaled(false) } const edges = useMemo(() => { @@ -134,6 +137,17 @@ function ImageViewing({ } }, [imageList, imageIndex]) + // This is a hack. + // RNGH doesn't have an easy way to express that pinch of individual items + // should "steal" all pinches from the scroll view. So we're keeping a ref + // to all pinch gestures so that we may give them to . + const [pinchGestureRefs] = useState(new Map()) + for (let imageSrc of images) { + if (!pinchGestureRefs.get(imageSrc)) { + pinchGestureRefs.set(imageSrc, createRef()) + } + } + if (!visible) { return null } @@ -163,6 +177,7 @@ function ImageViewing({ data={images} horizontal pagingEnabled + scrollEnabled={!isScaled || isDragging} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} getItem={(_, index) => images[index]} @@ -177,9 +192,26 @@ function ImageViewing({ onZoom={onZoom} imageSrc={imageSrc} onRequestClose={onRequestCloseEnhanced} + pinchGestureRef={pinchGestureRefs.get(imageSrc)} + isScrollViewBeingDragged={isDragging} /> )} - onMomentumScrollEnd={onScroll} + renderScrollComponent={props => ( + + )} + onScrollBeginDrag={() => { + setIsDragging(true) + }} + onScrollEndDrag={() => { + setIsDragging(false) + }} + onMomentumScrollEnd={e => { + setIsScaled(false) + onScroll(e) + }} //@ts-ignore keyExtractor={(imageSrc, index) => keyExtractor diff --git a/src/view/com/lightbox/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts new file mode 100644 index 00000000..05476678 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/transforms.ts @@ -0,0 +1,98 @@ +import type {Position} from './@types' + +export type TransformMatrix = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +] + +// These are affine transforms. See explanation of every cell here: +// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg + +export function createTransform(): TransformMatrix { + 'worklet' + return [1, 0, 0, 0, 1, 0, 0, 0, 1] +} + +export function applyRounding(t: TransformMatrix) { + 'worklet' + t[2] = Math.round(t[2]) + t[5] = Math.round(t[5]) + // For example: 0.985, 0.99, 0.995, then 1: + t[0] = Math.round(t[0] * 200) / 200 + t[4] = Math.round(t[0] * 200) / 200 +} + +// We're using a limited subset (always scaling and translating while keeping aspect ratio) so +// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching. + +// All write operations are applied in-place to avoid unnecessary allocations. + +export function readTransform(t: TransformMatrix): [number, number, number] { + 'worklet' + const scale = t[0] + const translateX = t[2] + const translateY = t[5] + return [translateX, translateY, scale] +} + +export function prependTranslate(t: TransformMatrix, x: number, y: number) { + 'worklet' + t[2] += t[0] * x + t[1] * y + t[5] += t[3] * x + t[4] * y +} + +export function prependScale(t: TransformMatrix, value: number) { + 'worklet' + t[0] *= value + t[1] *= value + t[3] *= value + t[4] *= value +} + +export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) { + 'worklet' + // In-place matrix multiplication. + const a00 = ta[0], + a01 = ta[1], + a02 = ta[2] + const a10 = ta[3], + a11 = ta[4], + a12 = ta[5] + const a20 = ta[6], + a21 = ta[7], + a22 = ta[8] + ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6] + ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7] + ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8] + ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6] + ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7] + ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8] + ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6] + ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7] + ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8] +} + +export function prependPan(t: TransformMatrix, translation: Position) { + 'worklet' + prependTranslate(t, translation.x, translation.y) +} + +export function prependPinch( + t: TransformMatrix, + scale: number, + origin: Position, + translation: Position, +) { + 'worklet' + prependTranslate(t, translation.x, translation.y) + prependTranslate(t, origin.x, origin.y) + prependScale(t, scale) + prependTranslate(t, -origin.x, -origin.y) +} diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts deleted file mode 100644 index 6fc41163..00000000 --- a/src/view/com/lightbox/ImageViewing/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {Dimensions, Position} from './@types' - -export const getImageTransform = ( - image: Dimensions | null, - screen: Dimensions, -) => { - 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 -} - -export const getImageTranslate = ( - image: Dimensions, - screen: Dimensions, -): Position => { - 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'), - } -}