diff options
Diffstat (limited to 'src/view/com/lightbox/ImageViewing')
13 files changed, 747 insertions, 1205 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) => { diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts deleted file mode 100644 index c21cd7f2c..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts +++ /dev/null @@ -1,47 +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 {Animated} from 'react-native' - -const INITIAL_POSITION = {x: 0, y: 0} -const ANIMATION_CONFIG = { - duration: 200, - useNativeDriver: true, -} - -const useAnimatedComponents = () => { - const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) - const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) - - const toggleVisible = (isVisible: boolean) => { - if (isVisible) { - Animated.parallel([ - Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - ]).start() - } else { - Animated.parallel([ - Animated.timing(headerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: -300, - }), - Animated.timing(footerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: 300, - }), - ]).start() - } - } - - const headerTransform = headerTranslate.getTranslateTransform() - const footerTransform = footerTranslate.getTranslateTransform() - - return [headerTransform, footerTransform, toggleVisible] as const -} - -export default useAnimatedComponents diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts deleted file mode 100644 index ea81d9f1c..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts +++ /dev/null @@ -1,150 +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 React, {useCallback} from 'react' -import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native' - -import {Dimensions} from '../@types' - -const DOUBLE_TAP_DELAY = 300 -const MIN_ZOOM = 2 - -let lastTapTS: number | null = null - -/** - * This is iOS only. - * Same functionality for Android implemented inside usePanResponder hook. - */ -function useDoubleTapToZoom( - scrollViewRef: React.RefObject<ScrollView>, - scaled: boolean, - screen: Dimensions, - imageDimensions: Dimensions | null, -) { - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent<NativeTouchEvent>) => { - const nowTS = new Date().getTime() - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() - - const getZoomRectAfterDoubleTap = ( - 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, - } - } - - 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(pageX, pageY) - } - - // @ts-ignore - scrollResponderRef?.scrollResponderZoomTo({ - ...nextZoomRect, // This rect is in screen coordinates - animated: true, - }) - } else { - lastTapTS = nowTS - } - }, - [imageDimensions, scaled, screen.height, screen.width, scrollViewRef], - ) - - return handleDoubleTap -} - -export default useDoubleTapToZoom diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts index a5b0b6bd4..cb46fd0d9 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -8,11 +8,29 @@ import {useEffect, useState} from 'react' import {Image, ImageURISource} from 'react-native' - -import {createCache} from '../utils' import {Dimensions, ImageSource} from '../@types' const CACHE_SIZE = 50 + +type CacheStorageItem = {key: string; value: any} + +const createCache = (cacheSize: number) => ({ + _storage: [] as CacheStorageItem[], + get(key: string): any { + const {value} = + this._storage.find(({key: storageKey}) => storageKey === key) || {} + + return value + }, + set(key: string, value: any) { + if (this._storage.length >= cacheSize) { + this._storage.shift() + } + + this._storage.push({key, value}) + }, +}) + const imageDimensionsCache = createCache(CACHE_SIZE) const useImageDimensions = (image: ImageSource): Dimensions | null => { @@ -21,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => { // eslint-disable-next-line @typescript-eslint/no-shadow const getImageDimensions = (image: ImageSource): Promise<Dimensions> => { return new Promise(resolve => { - if (typeof image === 'number') { - const cacheKey = `${image}` - let imageDimensions = imageDimensionsCache.get(cacheKey) - - if (!imageDimensions) { - const {width, height} = Image.resolveAssetSource(image) - imageDimensions = {width, height} - imageDimensionsCache.set(cacheKey, imageDimensions) - } - - resolve(imageDimensions) - - return - } - - // @ts-ignore if (image.uri) { const source = image as ImageURISource - const cacheKey = source.uri as string - const imageDimensions = imageDimensionsCache.get(cacheKey) - if (imageDimensions) { resolve(imageDimensions) } else { diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts deleted file mode 100644 index 16430f3aa..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts +++ /dev/null @@ -1,32 +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 {useState} from 'react' -import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' - -import {Dimensions} from '../@types' - -const useImageIndexChange = (imageIndex: number, screen: Dimensions) => { - const [currentImageIndex, setImageIndex] = useState(imageIndex) - const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const { - nativeEvent: { - contentOffset: {x: scrollX}, - }, - } = event - - if (screen.width) { - const nextIndex = Math.round(scrollX / screen.width) - setImageIndex(nextIndex < 0 ? 0 : nextIndex) - } - } - - return [currentImageIndex, onScroll] as const -} - -export default useImageIndexChange diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts deleted file mode 100644 index 3969945bb..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts +++ /dev/null @@ -1,25 +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 {Image} from 'react-native' -import {ImageSource} from '../@types' - -const useImagePrefetch = (images: ImageSource[]) => { - useEffect(() => { - images.forEach(image => { - //@ts-ignore - if (image.uri) { - //@ts-ignore - return Image.prefetch(image.uri) - } - }) - }, [images]) -} - -export default useImagePrefetch 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 7908504ea..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ /dev/null @@ -1,431 +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 { - getDistanceBetweenTouches, - getImageTranslate, - getImageDimensionsByTranslate, -} from '../utils' - -const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const MIN_DIMENSION = Math.min(SCREEN_WIDTH, 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 - doubleTapToZoomEnabled: boolean - onLongPress: () => void - delayLongPress: number -} - -const usePanResponder = ({ - initialScale, - initialTranslate, - onZoom, - doubleTapToZoomEnabled, - onLongPress, - delayLongPress, -}: 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 - let longPressHandlerRef: NodeJS.Timeout | null = null - - const meaningfulShift = MIN_DIMENSION * 0.01 - 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 cancelLongPressHandle = () => { - longPressHandlerRef && clearTimeout(longPressHandlerRef) - } - - const panResponder = PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: () => true, - onMoveShouldSetPanResponderCapture: () => true, - onPanResponderGrant: ( - _: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - numberInitialTouches = gestureState.numberActiveTouches - - if (gestureState.numberActiveTouches > 1) { - return - } - - longPressHandlerRef = setTimeout(onLongPress, delayLongPress) - }, - 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 (doubleTapToZoomEnabled && 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, - ) => { - const {dx, dy} = gestureState - - if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { - cancelLongPressHandle() - } - - // Don't need to handle move because double tap in progress (was handled in onStart) - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { - cancelLongPressHandle() - 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) { - cancelLongPressHandle() - - 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 - // eslint-disable-next-line @typescript-eslint/no-shadow - 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: () => { - cancelLongPressHandle() - - 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] -} - -export default usePanResponder diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts deleted file mode 100644 index 4cd03fe71..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts +++ /dev/null @@ -1,24 +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 {useState} from 'react' - -const useRequestClose = (onRequestClose: () => void) => { - const [opacity, setOpacity] = useState(1) - - return [ - opacity, - () => { - setOpacity(0) - onRequestClose() - setTimeout(() => setOpacity(1), 0) - }, - ] as const -} - -export default useRequestClose diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 1a64fb3af..b6835793d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -8,91 +8,72 @@ // Original code copied and simplified from the link below as the codebase is currently not maintained: // https://github.com/jobtoday/react-native-image-viewing -import React, { - ComponentType, - useCallback, - useRef, - useEffect, - useMemo, -} from 'react' -import { - Animated, - Dimensions, - StyleSheet, - View, - VirtualizedList, - ModalProps, - Platform, -} from 'react-native' -import {ModalsContainer} from '../../modals/Modal' +import React, {ComponentType, useCallback, useMemo, useState} from 'react' +import {StyleSheet, View, Platform} from 'react-native' import ImageItem from './components/ImageItem/ImageItem' import ImageDefaultHeader from './components/ImageDefaultHeader' -import useAnimatedComponents from './hooks/useAnimatedComponents' -import useImageIndexChange from './hooks/useImageIndexChange' -import useRequestClose from './hooks/useRequestClose' import {ImageSource} from './@types' +import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import PagerView from 'react-native-pager-view' type Props = { images: ImageSource[] - keyExtractor?: (imageSrc: ImageSource, index: number) => string - imageIndex: number + initialImageIndex: number visible: boolean onRequestClose: () => void - onLongPress?: (image: ImageSource) => void - onImageIndexChange?: (imageIndex: number) => void - presentationStyle?: ModalProps['presentationStyle'] - animationType?: ModalProps['animationType'] backgroundColor?: string - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean - delayLongPress?: number HeaderComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}> } const DEFAULT_BG_COLOR = '#000' -const DEFAULT_DELAY_LONG_PRESS = 800 -const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width function ImageViewing({ images, - keyExtractor, - imageIndex, + initialImageIndex, visible, onRequestClose, - onLongPress = () => {}, - onImageIndexChange, backgroundColor = DEFAULT_BG_COLOR, - swipeToCloseEnabled, - doubleTapToZoomEnabled, - delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, }: Props) { - const imageList = useRef<VirtualizedList<ImageSource>>(null) - const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) - const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) - const [headerTransform, footerTransform, toggleBarsVisible] = - useAnimatedComponents() - - useEffect(() => { - if (onImageIndexChange) { - onImageIndexChange(currentImageIndex) + 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(showControls ? 0 : -30), + }, + ], + })) + const animatedFooterStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(showControls ? 0 : 30), + }, + ], + })) + + const onTap = useCallback(() => { + setShowControls(show => !show) + }, []) + + const onZoom = useCallback((nextIsScaled: boolean) => { + setIsScaled(nextIsScaled) + if (nextIsScaled) { + setShowControls(false) } - }, [currentImageIndex, onImageIndexChange]) - - const onZoom = useCallback( - (isScaled: boolean) => { - // @ts-ignore - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) - toggleBarsVisible(!isScaled) - }, - [toggleBarsVisible], - ) + }, []) const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -101,12 +82,6 @@ function ImageViewing({ return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area }, []) - const onLayout = useCallback(() => { - if (imageIndex) { - imageList.current?.scrollToIndex({index: imageIndex, animated: false}) - } - }, [imageList, imageIndex]) - if (!visible) { return null } @@ -114,60 +89,47 @@ function ImageViewing({ return ( <SafeAreaView style={styles.screen} - onLayout={onLayout} edges={edges} aria-modal accessibilityViewIsModal> - <ModalsContainer /> - <View style={[styles.container, {opacity, backgroundColor}]}> - <Animated.View style={[styles.header, {transform: headerTransform}]}> + <View style={[styles.container, {backgroundColor}]}> + <Animated.View style={[styles.header, animatedHeaderStyle]}> {typeof HeaderComponent !== 'undefined' ? ( React.createElement(HeaderComponent, { - imageIndex: currentImageIndex, + imageIndex, }) ) : ( - <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} /> + <ImageDefaultHeader onRequestClose={onRequestClose} /> )} </Animated.View> - <VirtualizedList - ref={imageList} - data={images} - horizontal - pagingEnabled - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - getItem={(_, index) => images[index]} - getItemCount={() => images.length} - getItemLayout={(_, index) => ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, - index, - })} - renderItem={({item: imageSrc}) => ( - <ImageItem - onZoom={onZoom} - imageSrc={imageSrc} - onRequestClose={onRequestCloseEnhanced} - onLongPress={onLongPress} - delayLongPress={delayLongPress} - swipeToCloseEnabled={swipeToCloseEnabled} - doubleTapToZoomEnabled={doubleTapToZoomEnabled} - /> - )} - onMomentumScrollEnd={onScroll} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } - /> + <PagerView + scrollEnabled={!isScaled} + initialPage={initialImageIndex} + onPageSelected={e => { + setImageIndex(e.nativeEvent.position) + setIsScaled(false) + }} + onPageScrollStateChanged={e => { + setIsDragging(e.nativeEvent.pageScrollState !== 'idle') + }} + overdrag={true} + style={styles.pager}> + {images.map(imageSrc => ( + <View key={imageSrc.uri}> + <ImageItem + onTap={onTap} + onZoom={onZoom} + imageSrc={imageSrc} + onRequestClose={onRequestClose} + isScrollViewBeingDragged={isDragging} + /> + </View> + ))} + </PagerView> {typeof FooterComponent !== 'undefined' && ( - <Animated.View style={[styles.footer, {transform: footerTransform}]}> + <Animated.View style={[styles.footer, animatedFooterStyle]}> {React.createElement(FooterComponent, { - imageIndex: currentImageIndex, + imageIndex, })} </Animated.View> )} @@ -179,11 +141,18 @@ function ImageViewing({ const styles = StyleSheet.create({ screen: { position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, }, container: { flex: 1, backgroundColor: '#000', }, + pager: { + flex: 1, + }, header: { position: 'absolute', width: '100%', @@ -200,7 +169,12 @@ const styles = StyleSheet.create({ }) const EnhancedImageViewing = (props: Props) => ( - <ImageViewing key={props.imageIndex} {...props} /> + <ImageViewing key={props.initialImageIndex} {...props} /> ) +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true, stiffness: 300}) +} + export default EnhancedImageViewing diff --git a/src/view/com/lightbox/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts new file mode 100644 index 000000000..05476678f --- /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 d56eea4f4..000000000 --- a/src/view/com/lightbox/ImageViewing/utils.ts +++ /dev/null @@ -1,139 +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 {Animated, NativeTouchEvent} from 'react-native' -import {Dimensions, Position} from './@types' - -type CacheStorageItem = {key: string; value: any} - -export const createCache = (cacheSize: number) => ({ - _storage: [] as CacheStorageItem[], - get(key: string): any { - const {value} = - this._storage.find(({key: storageKey}) => storageKey === key) || {} - - return value - }, - set(key: string, value: any) { - if (this._storage.length >= cacheSize) { - this._storage.shift() - } - - this._storage.push({key, value}) - }, -}) - -export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] => - arr.reduce((result, item) => { - const batch = result.pop() || [] - - if (batch.length < batchSize) { - batch.push(item) - result.push(batch) - } else { - result.push(batch, [item]) - } - - return result - }, []) - -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 getImageStyles = ( - image: Dimensions | 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, - } -} - -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'), - } -} - -export const getImageDimensionsByTranslate = ( - translate: Position, - screen: Dimensions, -): Dimensions => ({ - width: screen.width - translate.x * 2, - height: screen.height - translate.y * 2, -}) - -export const getImageTranslateForScale = ( - currentTranslate: Position, - targetScale: number, - screen: Dimensions, -): Position => { - const {width, height} = getImageDimensionsByTranslate( - currentTranslate, - screen, - ) - - const targetImageDimensions = { - width: width * targetScale, - height: height * targetScale, - } - - return getImageTranslate(targetImageDimensions, screen) -} - -export 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), - ) -} |