diff options
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components')
3 files changed, 547 insertions, 228 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..7c7ad0616 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,157 +1,386 @@ -/** - * 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, {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, { + runOnJS, + useAnimatedRef, + useAnimatedStyle, + useAnimatedReaction, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import usePanResponder from '../../hooks/usePanResponder' - -import {getImageStyles, getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' -import {ImageLoading} from './ImageLoading' +import { + createTransform, + readTransform, + applyRounding, + prependPan, + prependPinch, + prependTransform, + TransformMatrix, +} from '../../transforms' +import type {ImageSource, Dimensions as ImageDimensions} from '../../@types' -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 + onTap: () => void onZoom: (isZoomed: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } - -const AnimatedImage = Animated.createAnimatedComponent(Image) - const ImageItem = ({ imageSrc, + onTap, onZoom, onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, + isScrollViewBeingDragged, }: Props) => { - const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) + 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 onLoaded = useCallback(() => setLoadEnd(true), []) - const onZoomPerformed = useCallback( - (isZoomed: boolean) => { - onZoom(isZoomed) - if (imageContainer?.current) { - imageContainer.current.setNativeProps({ - scrollEnabled: !isZoomed, - }) + 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() + + // Keep track of when we're entering or leaving scaled rendering. + // Note: DO NOT move any logic reading animated values outside this function. + useAnimatedReaction( + () => { + if (pinchScale.value !== 1) { + // We're currently pinching. + return true + } + const [, , committedScale] = readTransform(committedTransform.value) + if (committedScale !== 1) { + // We started from a pinched in state. + return true + } + // We're at rest. + return false + }, + (nextIsScaled, prevIsScaled) => { + if (nextIsScaled !== prevIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [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, - }) + 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 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 dismissDistance = dismissSwipeTranslateY.value + const dismissProgress = Math.min( + Math.abs(dismissDistance) / (SCREEN.height / 2), + 1, + ) + return { + opacity: 1 - dismissProgress, + transform: [ + {translateX}, + {translateY: translateY + dismissDistance}, + {scale}, + ], + } }) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - - const onScrollEndDrag = ({ - nativeEvent, - }: NativeSyntheticEvent<NativeScrollEvent>) => { - 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() + + // 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<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + const pinch = Gesture.Pinch() + .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] = readTransform(committedTransform.value) + 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 - scrollValueY.setValue(offsetY) - } + // 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 singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + if (!imageDimensions) { + return + } + const [, , committedScale] = readTransform(committedTransform.value) + 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 composedGesture = isScrollViewBeingDragged + ? // If the parent is not at rest, provide a no-op gesture. + Gesture.Manual() + : Gesture.Exclusive( + dismissSwipePan, + Gesture.Simultaneous(pinch, pan), + doubleTap, + singleTap, + ) + + const isLoading = !isLoaded || !imageDimensions return ( - <ScrollView - ref={imageContainer} - style={styles.listItem} - pagingEnabled - nestedScrollEnabled - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} - {...(swipeToCloseEnabled && { - onScroll, - onScrollEndDrag, - })}> - <AnimatedImage - {...panHandlers} - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={onLoaded} - accessibilityLabel={imageSrc.alt} - accessibilityHint="" - /> - {(!isLoaded || !imageDimensions) && <ImageLoading />} - </ScrollView> + <Animated.View ref={containerRef} style={styles.container}> + {isLoading && ( + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> + )} + <GestureDetector gesture={composedGesture}> + <AnimatedImage + contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} + style={[styles.image, animatedStyle]} + accessibilityLabel={imageSrc.alt} + accessibilityHint="" + onLoad={() => setIsLoaded(true)} + /> + </GestureDetector> + </Animated.View> ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + container: { + width: SCREEN.width, + height: SCREEN.height, + overflow: 'hidden', + }, + image: { + flex: 1, }, - imageScrollContainer: { - height: SCREEN_HEIGHT * 2, + loading: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, }, }) +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 03bf45af1..f73f355ac 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,159 +6,251 @@ * */ -import React, {useCallback, useRef, useState} from 'react' - -import { - Animated, - Dimensions, - ScrollView, - StyleSheet, - View, - NativeScrollEvent, - NativeSyntheticEvent, - TouchableWithoutFeedback, -} from 'react-native' +import React, {useState} from 'react' + +import {Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' +import Animated, { + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' -import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' import useImageDimensions from '../../hooks/useImageDimensions' -import {getImageStyles, getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' +import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' 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 MAX_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 +const MIN_DOUBLE_TAP_SCALE = 2 type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: Props) => { - const scrollViewRef = useRef<ScrollView>(null) +const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { + const scrollViewRef = useAnimatedRef<Animated.ScrollView>() + const translationY = useSharedValue(0) 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) - const scrollValueY = new Animated.Value(0) - const scaleValue = new Animated.Value(scale || 1) - const translateValue = new Animated.ValueXY(translate) - 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, - translateValue, - scaleValue, - ) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - - const onScrollEndDrag = useCallback( - ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const currentScaled = nativeEvent?.zoomScale > 1 - - onZoom(currentScaled) - setScaled(currentScaled) - - if ( - !currentScaled && - swipeToCloseEnabled && - Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY - ) { - onRequestClose() + + const scrollHandler = useAnimatedScrollHandler({ + onScroll(e) { + const nextIsScaled = e.zoomScale > 1 + translationY.value = nextIsScaled ? 0 : e.contentOffset.y + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onRequestClose, onZoom, swipeToCloseEnabled], - ) + onEndDrag(e) { + const velocityY = e.velocity?.y ?? 0 + const nextIsScaled = e.zoomScale > 1 + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) + } + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { + runOnJS(onRequestClose)() + } + }, + }) + + function handleZoom(nextIsScaled: boolean) { + onZoom(nextIsScaled) + setScaled(nextIsScaled) + } - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + function handleDoubleTap(absoluteX: number, absoluteY: number) { + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } - if (nativeEvent?.zoomScale > 1) { - return + const willZoom = !scaled + if (willZoom) { + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + absoluteX, + absoluteY, + ) } - scrollValueY.setValue(offsetY) + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + ...nextZoomRect, // This rect is in screen coordinates + animated: true, + }) } - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) + 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 ( - <View> - <ScrollView + <GestureDetector gesture={composedGesture}> + <Animated.ScrollView + // @ts-ignore Something's up with the types here ref={scrollViewRef} style={styles.listItem} pinchGestureEnabled showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - maximumZoomScale={maxScrollViewZoom} + maximumZoomScale={maxZoomScale} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} - onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1} - {...(swipeToCloseEnabled && { - onScroll, - })}> + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && <ImageLoading />} - <TouchableWithoutFeedback - onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} - onLongPress={onLongPressHandler} - delayLongPress={delayLongPress} - accessibilityRole="image" + <AnimatedImage + contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} + style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} - accessibilityHint=""> - <AnimatedImage - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={() => setLoaded(true)} - /> - </TouchableWithoutFeedback> - </ScrollView> - </View> + accessibilityHint="" + onLoad={() => setLoaded(true)} + /> + </Animated.ScrollView> + </GestureDetector> ) } const styles = StyleSheet.create({ + imageScrollContainer: { + height: SCREEN.height, + }, listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + width: SCREEN.width, + height: SCREEN.height, }, - imageScrollContainer: { - height: SCREEN_HEIGHT, + image: { + width: SCREEN.width, + height: SCREEN.height, }, }) +const getZoomRectAfterDoubleTap = ( + imageDimensions: ImageDimensions | 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_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. + + // 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..16688b820 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -7,11 +7,9 @@ import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } const ImageItem = (_props: Props) => { |