diff options
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/hooks')
7 files changed, 681 insertions, 0 deletions
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts new file mode 100644 index 000000000..c21cd7f2c --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts @@ -0,0 +1,47 @@ +/** + * 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 new file mode 100644 index 000000000..92746e951 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts @@ -0,0 +1,65 @@ +/** + * 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 +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, +) { + const handleDoubleTap = useCallback( + (event: NativeSyntheticEvent<NativeTouchEvent>) => { + const nowTS = new Date().getTime() + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { + const {pageX, pageY} = event.nativeEvent + let targetX = 0 + let targetY = 0 + let targetWidth = screen.width + let targetHeight = screen.height + + // Zooming in + // TODO: Add more precise calculation of targetX, targetY based on touch + if (!scaled) { + targetX = pageX / 2 + targetY = pageY / 2 + targetWidth = screen.width / 2 + targetHeight = screen.height / 2 + } + + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + x: targetX, + y: targetY, + width: targetWidth, + height: targetHeight, + animated: true, + }) + } else { + lastTapTS = nowTS + } + }, + [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 new file mode 100644 index 000000000..bab136c50 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -0,0 +1,88 @@ +/** + * 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, useState} from 'react' +import {Image, ImageURISource} from 'react-native' + +import {createCache} from '../utils' +import {Dimensions, ImageSource} from '../@types' + +const CACHE_SIZE = 50 +const imageDimensionsCache = createCache(CACHE_SIZE) + +const useImageDimensions = (image: ImageSource): Dimensions | null => { + const [dimensions, setDimensions] = useState<Dimensions | null>(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 { + // @ts-ignore + Image.getSizeWithHeaders( + source.uri, + source.headers, + (width: number, height: number) => { + imageDimensionsCache.set(cacheKey, {width, height}) + resolve({width, height}) + }, + () => { + resolve({width: 0, height: 0}) + }, + ) + } + } else { + resolve({width: 0, height: 0}) + } + }) + } + + let isImageUnmounted = false + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-shadow + getImageDimensions(image).then(dimensions => { + if (!isImageUnmounted) { + setDimensions(dimensions) + } + }) + + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + isImageUnmounted = true + } + }, [image]) + + return dimensions +} + +export default useImageDimensions diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts new file mode 100644 index 000000000..16430f3aa --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts @@ -0,0 +1,32 @@ +/** + * 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 new file mode 100644 index 000000000..3969945bb --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts @@ -0,0 +1,25 @@ +/** + * 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 new file mode 100644 index 000000000..4600cf1a8 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts @@ -0,0 +1,400 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/** + * 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 {useMemo, useEffect} from 'react' +import { + Animated, + Dimensions, + GestureResponderEvent, + GestureResponderHandlers, + NativeTouchEvent, + PanResponderGestureState, +} from 'react-native' + +import {Position} from '../@types' +import { + createPanResponder, + 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 SCALE_MAX = 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: number | 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 getTranslateInBounds = (translate: Position, scale: number) => { + const inBoundTranslate = {x: translate.x, y: translate.y} + const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) + + if (translate.x > leftBound) { + inBoundTranslate.x = leftBound + } else if (translate.x < rightBound) { + inBoundTranslate.x = rightBound + } + + if (translate.y > topBound) { + inBoundTranslate.y = topBound + } else if (translate.y < bottomBound) { + inBoundTranslate.y = bottomBound + } + + return inBoundTranslate + } + + 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 handlers = { + onGrant: ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + numberInitialTouches = gestureState.numberActiveTouches + + if (gestureState.numberActiveTouches > 1) { + return + } + + longPressHandlerRef = setTimeout(onLongPress, delayLongPress) + }, + onStart: ( + 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) { + const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; + const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] + const targetScale = SCALE_MAX + const nextScale = isScaled ? initialScale : targetScale + const nextTranslate = isScaled + ? initialTranslate + : getTranslateInBounds( + { + x: + initialTranslate.x + + (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), + y: + initialTranslate.y + + (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), + }, + targetScale, + ) + + onZoom(!isScaled) + + 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() + } + }, + onMove: ( + 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} + } + }, + onRelease: () => { + cancelLongPressHandle() + + if (isDoubleTapPerformed) { + isDoubleTapPerformed = false + } + + if (tmpScale > 0) { + if (tmpScale < initialScale || tmpScale > SCALE_MAX) { + tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX + 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 + } + }, + } + + const panResponder = useMemo(() => createPanResponder(handlers), [handlers]) + + 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 new file mode 100644 index 000000000..4cd03fe71 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts @@ -0,0 +1,24 @@ +/** + * 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 |