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 f5e85820..927657ba 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -36,23 +36,11 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (isZoomed: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: Props) => { +const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { const imageContainer = useRef(null) const imageDimensions = useImageDimensions(imageSrc) const [translate, scale] = getImageTransform(imageDimensions, SCREEN) @@ -72,17 +60,10 @@ const ImageItem = ({ [onZoom], ) - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) - const [panHandlers, scaleValue, translateValue] = usePanResponder({ initialScale: scale || 1, initialTranslate: translate || {x: 0, y: 0}, onZoom: onZoomPerformed, - doubleTapToZoomEnabled, - onLongPress: onLongPressHandler, - delayLongPress, }) const imagesStyles = getImageStyles( @@ -126,11 +107,9 @@ const ImageItem = ({ showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} - {...(swipeToCloseEnabled && { - onScroll, - onScrollEndDrag, - })}> + scrollEnabled={true} + onScroll={onScroll} + onScrollEndDrag={onScrollEndDrag}> void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: Props) => { +let lastTapTS: number | null = null + +const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { const scrollViewRef = useRef(null) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const handleDoubleTap = useDoubleTapToZoom( - scrollViewRef, - scaled, - SCREEN, - imageDimensions, - ) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) + + // TODO: It's not valid to reinitialize Animated values during render. + // This is a bug. const scrollValueY = new Animated.Value(0) const scaleValue = new Animated.Value(scale || 1) const translateValue = new Animated.ValueXY(translate) @@ -91,15 +79,11 @@ const ImageItem = ({ onZoom(currentScaled) setScaled(currentScaled) - if ( - !currentScaled && - swipeToCloseEnabled && - Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY - ) { + if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { onRequestClose() } }, - [onRequestClose, onZoom, swipeToCloseEnabled], + [onRequestClose, onZoom], ) const onScroll = ({nativeEvent}: NativeSyntheticEvent) => { @@ -112,9 +96,40 @@ const ImageItem = ({ scrollValueY.setValue(offsetY) } - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) + const handleDoubleTap = useCallback( + (event: NativeSyntheticEvent) => { + const nowTS = new Date().getTime() + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } + + const willZoom = !scaled + if (willZoom) { + const {pageX, pageY} = event.nativeEvent + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + pageX, + pageY, + ) + } + + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + ...nextZoomRect, // This rect is in screen coordinates + animated: true, + }) + } else { + lastTapTS = nowTS + } + }, + [imageDimensions, scaled], + ) return ( @@ -126,17 +141,13 @@ const ImageItem = ({ showsVerticalScrollIndicator={false} maximumZoomScale={maxScrollViewZoom} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} + scrollEnabled={true} + onScroll={onScroll} onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1} - {...(swipeToCloseEnabled && { - onScroll, - })}> + scrollEventThrottle={1}> {(!loaded || !imageDimensions) && } @@ -161,4 +172,92 @@ const styles = StyleSheet.create({ }, }) +const getZoomRectAfterDoubleTap = ( + imageDimensions: {width: number; height: number} | null, + touchX: number, + touchY: number, +): { + x: number + y: number + width: number + height: number +} => { + if (!imageDimensions) { + return { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } + } + + // First, let's figure out how much we want to zoom in. + // We want to try to zoom in at least close enough to get rid of black bars. + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const zoom = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_ZOOM, + ) + // Unlike in the Android version, we don't constrain the *max* zoom level here. + // Instead, this is done in the ScrollView props so that it constraints pinch too. + + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. + // We already know the zoom level, so this gives us the rectangle size. + let rectWidth = SCREEN.width / zoom + let rectHeight = SCREEN.height / zoom + + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. + // We don't want to introduce new black bars or make existing black bars unbalanced. + let minX = 0 + let minY = 0 + let maxX = SCREEN.width - rectWidth + let maxY = SCREEN.height - rectHeight + if (imageAspect >= screenAspect) { + // The image has horizontal black bars. Exclude them from the safe area. + const renderedHeight = SCREEN.width / imageAspect + const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2 + minY += horizontalBarHeight + maxY -= horizontalBarHeight + } else { + // The image has vertical black bars. Exclude them from the safe area. + const renderedWidth = SCREEN.height * imageAspect + const verticalBarWidth = (SCREEN.width - renderedWidth) / 2 + minX += verticalBarWidth + maxX -= verticalBarWidth + } + + // Finally, we can position the rect according to its size and the safe area. + let rectX + if (maxX >= minX) { + // Content fills the screen horizontally so we have horizontal wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectX = touchX - touchX / zoom + rectX = Math.min(rectX, maxX) + rectX = Math.max(rectX, minX) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectX = SCREEN.width / 2 - rectWidth / 2 + } + let rectY + if (maxY >= minY) { + // Content fills the screen vertically so we have vertical wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectY = touchY - touchY / zoom + rectY = Math.min(rectY, maxY) + rectY = Math.max(rectY, minY) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectY = SCREEN.height / 2 - rectHeight / 2 + } + + return { + x: rectX, + y: rectY, + height: rectHeight, + width: rectWidth, + } +} + export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index fd377dde..82ee86d7 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -8,10 +8,6 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean } const ImageItem = (_props: Props) => { 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 c21cd7f2..00000000 --- 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 ea81d9f1..00000000 --- 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, - scaled: boolean, - screen: Dimensions, - imageDimensions: Dimensions | null, -) { - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent) => { - 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 a5b0b6bd..7f0851af 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 => { 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 16430f3a..00000000 --- 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) => { - 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 3969945b..00000000 --- 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 index 7908504e..85454e37 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts @@ -18,16 +18,11 @@ import { } from 'react-native' import {Position} from '../@types' -import { - getDistanceBetweenTouches, - getImageTranslate, - getImageDimensionsByTranslate, -} from '../utils' +import {getImageTranslate} 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 @@ -39,18 +34,12 @@ 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] > => { @@ -62,9 +51,9 @@ const usePanResponder = ({ 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 + // 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) @@ -155,10 +144,6 @@ const usePanResponder = ({ return () => scaleValue.removeAllListeners() }) - const cancelLongPressHandle = () => { - longPressHandlerRef && clearTimeout(longPressHandlerRef) - } - const panResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, @@ -173,8 +158,6 @@ const usePanResponder = ({ if (gestureState.numberActiveTouches > 1) { return } - - longPressHandlerRef = setTimeout(onLongPress, delayLongPress) }, onPanResponderStart: ( event: GestureResponderEvent, @@ -194,7 +177,7 @@ const usePanResponder = ({ lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, ) - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + if (isDoubleTapPerformed) { let nextScale = initialScale let nextTranslate = initialTranslate @@ -241,15 +224,8 @@ const usePanResponder = ({ 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() + if (isDoubleTapPerformed) { return } @@ -267,8 +243,6 @@ const usePanResponder = ({ numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 if (isPinchGesture) { - cancelLongPressHandle() - const initialDistance = getDistanceBetweenTouches(initialTouches) const currentDistance = getDistanceBetweenTouches( event.nativeEvent.touches, @@ -315,7 +289,7 @@ const usePanResponder = ({ 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) @@ -360,8 +334,6 @@ const usePanResponder = ({ } }, onPanResponderRelease: () => { - cancelLongPressHandle() - if (isDoubleTapPerformed) { isDoubleTapPerformed = false } @@ -428,4 +400,24 @@ const usePanResponder = ({ 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/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts deleted file mode 100644 index 4cd03fe7..00000000 --- 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 1a64fb3a..3b659e2d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -12,12 +12,14 @@ import React, { ComponentType, useCallback, useRef, - useEffect, useMemo, + useState, } from 'react' import { Animated, Dimensions, + NativeSyntheticEvent, + NativeScrollEvent, StyleSheet, View, VirtualizedList, @@ -29,9 +31,6 @@ import {ModalsContainer} from '../../modals/Modal' 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 {Edge, SafeAreaView} from 'react-native-safe-area-context' @@ -41,22 +40,21 @@ type Props = { imageIndex: 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 +const INITIAL_POSITION = {x: 0, y: 0} +const ANIMATION_CONFIG = { + duration: 200, + useNativeDriver: true, +} function ImageViewing({ images, @@ -64,35 +62,63 @@ function ImageViewing({ imageIndex, visible, onRequestClose, - onLongPress = () => {}, - onImageIndexChange, backgroundColor = DEFAULT_BG_COLOR, - swipeToCloseEnabled, - doubleTapToZoomEnabled, - delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, }: Props) { const imageList = useRef>(null) - const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) - const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) - const [headerTransform, footerTransform, toggleBarsVisible] = - useAnimatedComponents() + const [opacity, setOpacity] = useState(1) + const [currentImageIndex, setImageIndex] = useState(imageIndex) - useEffect(() => { - if (onImageIndexChange) { - onImageIndexChange(currentImageIndex) + // TODO: It's not valid to reinitialize Animated values during render. + // This is a bug. + const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) + const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) + + const toggleBarsVisible = (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() } - }, [currentImageIndex, onImageIndexChange]) + } - const onZoom = useCallback( - (isScaled: boolean) => { - // @ts-ignore - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) - toggleBarsVisible(!isScaled) - }, - [toggleBarsVisible], - ) + const onRequestCloseEnhanced = () => { + setOpacity(0) + onRequestClose() + setTimeout(() => setOpacity(1), 0) + } + + const onScroll = (event: NativeSyntheticEvent) => { + const { + nativeEvent: { + contentOffset: {x: scrollX}, + }, + } = event + + if (SCREEN.width) { + const nextIndex = Math.round(scrollX / SCREEN.width) + setImageIndex(nextIndex < 0 ? 0 : nextIndex) + } + } + + const onZoom = (isScaled: boolean) => { + // @ts-ignore + imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) + toggleBarsVisible(!isScaled) + } const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -111,6 +137,8 @@ function ImageViewing({ return null } + const headerTransform = headerTranslate.getTranslateTransform() + const footerTransform = footerTranslate.getTranslateTransform() return ( )} onMomentumScrollEnd={onScroll} diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts index d56eea4f..03f28d61 100644 --- a/src/view/com/lightbox/ImageViewing/utils.ts +++ b/src/view/com/lightbox/ImageViewing/utils.ts @@ -6,42 +6,9 @@ * */ -import {Animated, NativeTouchEvent} from 'react-native' +import {Animated} 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, @@ -97,43 +64,3 @@ export const getImageTranslate = ( 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), - ) -} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 072bfebf..ad66dce3 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -15,94 +15,10 @@ import * as MediaLibrary from 'expo-media-library' export const Lightbox = observer(function Lightbox() { const store = useStores() - const [isAltExpanded, setAltExpanded] = React.useState(false) - const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() - const onClose = React.useCallback(() => { store.shell.closeLightbox() }, [store]) - const saveImageToAlbumWithToasts = React.useCallback( - async (uri: string) => { - if (!permissionResponse || permissionResponse.granted === false) { - Toast.show('Permission to access camera roll is required.') - if (permissionResponse?.canAskAgain) { - requestPermission() - } else { - Toast.show( - 'Permission to access camera roll was denied. Please enable it in your system settings.', - ) - } - return - } - - try { - await saveImageToMediaLibrary({uri}) - Toast.show('Saved to your camera roll.') - } catch (e: any) { - Toast.show(`Failed to save image: ${String(e)}`) - } - }, - [permissionResponse, requestPermission], - ) - - const LightboxFooter = React.useCallback( - ({imageIndex}: {imageIndex: number}) => { - const lightbox = store.shell.activeLightbox - if (!lightbox) { - return null - } - - let altText = '' - let uri = '' - if (lightbox.name === 'images') { - const opts = lightbox as models.ImagesLightbox - uri = opts.images[imageIndex].uri - altText = opts.images[imageIndex].alt || '' - } else if (lightbox.name === 'profile-image') { - const opts = lightbox as models.ProfileImageLightbox - uri = opts.profileView.avatar || '' - } - - return ( - - {altText ? ( - setAltExpanded(!isAltExpanded)} - accessibilityRole="button"> - - {altText} - - - ) : null} - - - - - - ) - }, - [store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts], - ) - if (!store.shell.activeLightbox) { return null } else if (store.shell.activeLightbox.name === 'profile-image') { @@ -132,6 +48,92 @@ export const Lightbox = observer(function Lightbox() { } }) +const LightboxFooter = observer(function LightboxFooter({ + imageIndex, +}: { + imageIndex: number +}) { + const store = useStores() + const [isAltExpanded, setAltExpanded] = React.useState(false) + const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() + + const saveImageToAlbumWithToasts = React.useCallback( + async (uri: string) => { + if (!permissionResponse || permissionResponse.granted === false) { + Toast.show('Permission to access camera roll is required.') + if (permissionResponse?.canAskAgain) { + requestPermission() + } else { + Toast.show( + 'Permission to access camera roll was denied. Please enable it in your system settings.', + ) + } + return + } + + try { + await saveImageToMediaLibrary({uri}) + Toast.show('Saved to your camera roll.') + } catch (e: any) { + Toast.show(`Failed to save image: ${String(e)}`) + } + }, + [permissionResponse, requestPermission], + ) + + const lightbox = store.shell.activeLightbox + if (!lightbox) { + return null + } + + let altText = '' + let uri = '' + if (lightbox.name === 'images') { + const opts = lightbox as models.ImagesLightbox + uri = opts.images[imageIndex].uri + altText = opts.images[imageIndex].alt || '' + } else if (lightbox.name === 'profile-image') { + const opts = lightbox as models.ProfileImageLightbox + uri = opts.profileView.avatar || '' + } + + return ( + + {altText ? ( + setAltExpanded(!isAltExpanded)} + accessibilityRole="button"> + + {altText} + + + ) : null} + + + + + + ) +}) + const styles = StyleSheet.create({ footer: { paddingTop: 16,