From aa4a8face2f2edd9b49bc1aab0d7c16bbf051532 Mon Sep 17 00:00:00 2001 From: sergejkaluckij Date: Tue, 26 Sep 2023 20:17:30 +0700 Subject: [PATCH] Added custom loader, item component, hide on tab header/footer and all opacity for swipe close --- package.json | 3 +- src/ImageViewing.tsx | 52 +++++++++++++++-- .../ImageItem/ImageItem.android.tsx | 52 +++++++++++++---- src/components/ImageItem/ImageItem.d.ts | 30 +++++++--- src/components/ImageItem/ImageItem.ios.tsx | 56 +++++++++++++++---- src/components/ImageItem/ImageLoading.tsx | 20 ++++++- src/hooks/useAnimatedComponents.ts | 44 ++++++++++----- src/hooks/useDoubleTapToZoom.ts | 15 ++++- src/hooks/usePanResponder.ts | 28 ++++++++-- tsconfig-debug.json | 7 +++ 10 files changed, 250 insertions(+), 57 deletions(-) create mode 100644 tsconfig-debug.json diff --git a/package.json b/package.json index 6f8e8955..2eddca42 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ ], "scripts": { "build": "tsc", - "postversion": "yarn build" + "postversion": "yarn build", + "watch": "tsc -p tsconfig-debug.json" }, "peerDependencies": { "react": ">=16.11.0", diff --git a/src/ImageViewing.tsx b/src/ImageViewing.tsx index 3782ee0e..e37f6b92 100644 --- a/src/ImageViewing.tsx +++ b/src/ImageViewing.tsx @@ -15,6 +15,10 @@ import { VirtualizedList, ModalProps, Modal, + TouchableOpacity, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, } from "react-native"; import ImageItem from "./components/ImageItem/ImageItem"; @@ -25,6 +29,7 @@ import useAnimatedComponents from "./hooks/useAnimatedComponents"; import useImageIndexChange from "./hooks/useImageIndexChange"; import useRequestClose from "./hooks/useRequestClose"; import { ImageSource } from "./@types"; +import { SWIPE_CLOSE_OFFSET } from "./components/ImageItem/ImageItem.android"; type Props = { images: ImageSource[]; @@ -34,6 +39,7 @@ type Props = { onRequestClose: () => void; onLongPress?: (image: ImageSource) => void; onImageIndexChange?: (imageIndex: number) => void; + onPress?: (image: ImageSource) => void; presentationStyle?: ModalProps["presentationStyle"]; animationType?: ModalProps["animationType"]; backgroundColor?: string; @@ -42,6 +48,12 @@ type Props = { delayLongPress?: number; HeaderComponent?: ComponentType<{ imageIndex: number }>; FooterComponent?: ComponentType<{ imageIndex: number }>; + LoaderComponent?: ComponentType; + ItemComponent?: ComponentType<{ + onLoad?: () => void; + source: ImageSource; + style: any; + }>; }; const DEFAULT_ANIMATION_TYPE = "fade"; @@ -49,6 +61,10 @@ const DEFAULT_BG_COLOR = "#000"; const DEFAULT_DELAY_LONG_PRESS = 800; const SCREEN = Dimensions.get("screen"); const SCREEN_WIDTH = SCREEN.width; +const OUTPUT_RANGE = Platform.select({ + ios: [0.5, 1, 0.5], + android: [0.7, 1, 0.7], +}); function ImageViewing({ images, @@ -57,6 +73,7 @@ function ImageViewing({ visible, onRequestClose, onLongPress = () => {}, + onPress = () => {}, onImageIndexChange, animationType = DEFAULT_ANIMATION_TYPE, backgroundColor = DEFAULT_BG_COLOR, @@ -66,13 +83,26 @@ function ImageViewing({ delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, + LoaderComponent, + ItemComponent, }: Props) { const imageList = useRef>(null); const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose); const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN); - const [headerTransform, footerTransform, toggleBarsVisible] = + const [headerTransform, footerTransform, setBarsVisible, toggleBarsVisible] = useAnimatedComponents(); + const scrollValueY = new Animated.Value(0); + + const allOpacity = scrollValueY.interpolate({ + inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + outputRange: OUTPUT_RANGE, + }); + + const onScrollOpacity = (offsetY: number) => { + scrollValueY.setValue(offsetY); + }; + useEffect(() => { if (onImageIndexChange) { onImageIndexChange(currentImageIndex); @@ -83,11 +113,21 @@ function ImageViewing({ (isScaled: boolean) => { // @ts-ignore imageList?.current?.setNativeProps({ scrollEnabled: !isScaled }); - toggleBarsVisible(!isScaled); + setBarsVisible(!isScaled); }, [imageList] ); + const onPressHandler = (src: ImageSource) => { + onPress(src); + toggleBarsVisible(); + }; + + const wrapStylesWithOpacity = [ + styles.container, + { opacity: allOpacity, backgroundColor }, + ]; + if (!visible) { return null; } @@ -103,7 +143,7 @@ function ImageViewing({ hardwareAccelerated > - + {typeof HeaderComponent !== "undefined" ? ( React.createElement(HeaderComponent, { @@ -137,9 +177,13 @@ function ImageViewing({ imageSrc={imageSrc} onRequestClose={onRequestCloseEnhanced} onLongPress={onLongPress} + onPress={onPressHandler} delayLongPress={delayLongPress} swipeToCloseEnabled={swipeToCloseEnabled} doubleTapToZoomEnabled={doubleTapToZoomEnabled} + LoaderComponent={LoaderComponent} + ItemComponent={ItemComponent} + onScroll={onScrollOpacity} /> )} onMomentumScrollEnd={onScroll} @@ -161,7 +205,7 @@ function ImageViewing({ })} )} - + ); } diff --git a/src/components/ImageItem/ImageItem.android.tsx b/src/components/ImageItem/ImageItem.android.tsx index f463b0b1..0951bf8c 100644 --- a/src/components/ImageItem/ImageItem.android.tsx +++ b/src/components/ImageItem/ImageItem.android.tsx @@ -6,7 +6,7 @@ * */ -import React, { useCallback, useRef, useState } from "react"; +import React, { ComponentType, useCallback, useRef, useState } from "react"; import { Animated, @@ -25,7 +25,7 @@ import { getImageStyles, getImageTransform } from "../../utils"; import { ImageSource } from "../../@types"; import { ImageLoading } from "./ImageLoading"; -const SWIPE_CLOSE_OFFSET = 75; +export const SWIPE_CLOSE_OFFSET = 75; const SWIPE_CLOSE_VELOCITY = 1.75; const SCREEN = Dimensions.get("window"); const SCREEN_WIDTH = SCREEN.width; @@ -36,9 +36,17 @@ type Props = { onRequestClose: () => void; onZoom: (isZoomed: boolean) => void; onLongPress: (image: ImageSource) => void; + onPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; + onScroll: (offsetY: number) => void; + LoaderComponent?: ComponentType; + ItemComponent?: ComponentType<{ + onLoad?: () => void; + source: ImageSource; + style: any; + }>; }; const ImageItem = ({ @@ -46,9 +54,13 @@ const ImageItem = ({ onZoom, onRequestClose, onLongPress, + onPress, + onScroll, delayLongPress, swipeToCloseEnabled = true, doubleTapToZoomEnabled = true, + LoaderComponent, + ItemComponent, }: Props) => { const imageContainer = useRef(null); const imageDimensions = useImageDimensions(imageSrc); @@ -73,6 +85,10 @@ const ImageItem = ({ onLongPress(imageSrc); }, [imageSrc, onLongPress]); + const onPressHandler = useCallback(() => { + onPress(imageSrc); + }, [imageSrc, onLongPress]); + const [panHandlers, scaleValue, translateValue] = usePanResponder({ initialScale: scale || 1, initialTranslate: translate || { x: 0, y: 0 }, @@ -80,6 +96,7 @@ const ImageItem = ({ doubleTapToZoomEnabled, onLongPress: onLongPressHandler, delayLongPress, + onPress: onPressHandler, }); const imagesStyles = getImageStyles( @@ -108,12 +125,13 @@ const ImageItem = ({ } }; - const onScroll = ({ + const handleScroll = ({ nativeEvent, }: NativeSyntheticEvent) => { const offsetY = nativeEvent?.contentOffset?.y ?? 0; scrollValueY.setValue(offsetY); + onScroll(offsetY); }; return ( @@ -127,17 +145,29 @@ const ImageItem = ({ contentContainerStyle={styles.imageScrollContainer} scrollEnabled={swipeToCloseEnabled} {...(swipeToCloseEnabled && { - onScroll, + onScroll: handleScroll, onScrollEndDrag, })} > - - {(!isLoaded || !imageDimensions) && } + {ItemComponent ? ( + React.createElement(ItemComponent, { + ...panHandlers, + source: imageSrc, + style: imageStylesWithOpacity, + onLoad: onLoaded, + }) + ) : ( + + )} + + {(!isLoaded || !imageDimensions) && ( + + )} ); }; diff --git a/src/components/ImageItem/ImageItem.d.ts b/src/components/ImageItem/ImageItem.d.ts index 57a902e9..1aac2c58 100644 --- a/src/components/ImageItem/ImageItem.d.ts +++ b/src/components/ImageItem/ImageItem.d.ts @@ -15,18 +15,32 @@ declare type Props = { onRequestClose: () => void; onZoom: (isZoomed: boolean) => void; onLongPress: (image: ImageSource) => void; + onPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; + onScroll: (offsetY: number) => void; + LoaderComponent?: ComponentType; + ItemComponent?: ComponentType<{ + onLoad?: () => void; + source: ImageSource; + style: any; + }>; }; -declare const _default: React.MemoExoticComponent<({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled, -}: Props) => JSX.Element>; +declare const _default: React.MemoExoticComponent< + ({ + imageSrc, + onZoom, + onRequestClose, + onLongPress, + onPress, + delayLongPress, + swipeToCloseEnabled, + onScroll, + LoaderComponent, + ItemComponent, + }: Props) => JSX.Element +>; export default _default; diff --git a/src/components/ImageItem/ImageItem.ios.tsx b/src/components/ImageItem/ImageItem.ios.tsx index de85a146..e998911e 100644 --- a/src/components/ImageItem/ImageItem.ios.tsx +++ b/src/components/ImageItem/ImageItem.ios.tsx @@ -6,7 +6,7 @@ * */ -import React, { useCallback, useRef, useState } from "react"; +import React, { ComponentType, useCallback, useRef, useState } from "react"; import { Animated, @@ -38,9 +38,17 @@ type Props = { onRequestClose: () => void; onZoom: (scaled: boolean) => void; onLongPress: (image: ImageSource) => void; + onPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; + onScroll: (offsetY: number) => void; + LoaderComponent?: ComponentType; + ItemComponent?: ComponentType<{ + onLoad?: () => void; + source: ImageSource; + style: any; + }>; }; const ImageItem = ({ @@ -48,15 +56,30 @@ const ImageItem = ({ onZoom, onRequestClose, onLongPress, + onPress, + onScroll, delayLongPress, swipeToCloseEnabled = true, doubleTapToZoomEnabled = true, + LoaderComponent, + ItemComponent, }: 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); + // const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN); + + const onPressHandler = useCallback(() => { + onPress(imageSrc); + }, [imageSrc, onPress]); + + const handleDoubleTap = useDoubleTapToZoom( + scrollViewRef, + scaled, + SCREEN, + onPressHandler + ); const [translate, scale] = getImageTransform(imageDimensions, SCREEN); const scrollValueY = new Animated.Value(0); @@ -68,11 +91,13 @@ const ImageItem = ({ inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], outputRange: [0.5, 1, 0.5], }); + const imagesStyles = getImageStyles( imageDimensions, translateValue, scaleValue ); + const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity }; const onScrollEndDrag = useCallback( @@ -94,7 +119,7 @@ const ImageItem = ({ [scaled] ); - const onScroll = ({ + const handleScroll = ({ nativeEvent, }: NativeSyntheticEvent) => { const offsetY = nativeEvent?.contentOffset?.y ?? 0; @@ -104,6 +129,7 @@ const ImageItem = ({ } scrollValueY.setValue(offsetY); + onScroll(offsetY); }; const onLongPressHandler = useCallback( @@ -127,20 +153,30 @@ const ImageItem = ({ onScrollEndDrag={onScrollEndDrag} scrollEventThrottle={1} {...(swipeToCloseEnabled && { - onScroll, + onScroll: handleScroll, })} > - {(!loaded || !imageDimensions) && } + {(!loaded || !imageDimensions) && ( + + )} - setLoaded(true)} - /> + {ItemComponent ? ( + React.createElement(ItemComponent, { + source: imageSrc, + style: imageStylesWithOpacity, + onLoad: () => setLoaded(true), + }) + ) : ( + setLoaded(true)} + /> + )} diff --git a/src/components/ImageItem/ImageLoading.tsx b/src/components/ImageItem/ImageLoading.tsx index 21f42e4a..f1ab084b 100644 --- a/src/components/ImageItem/ImageLoading.tsx +++ b/src/components/ImageItem/ImageLoading.tsx @@ -6,7 +6,7 @@ * */ -import React from "react"; +import React, { ComponentType } from "react"; import { ActivityIndicator, Dimensions, StyleSheet, View } from "react-native"; @@ -14,9 +14,23 @@ const SCREEN = Dimensions.get("screen"); const SCREEN_WIDTH = SCREEN.width; const SCREEN_HEIGHT = SCREEN.height; -export const ImageLoading = () => ( +type Props = { + color?: string; + size?: number | "small" | "large"; + LoaderComponent?: ComponentType; +}; + +export const ImageLoading = ({ + color = "#FFF", + size = "small", + LoaderComponent, +}: Props) => ( - + {LoaderComponent ? ( + React.createElement(LoaderComponent) + ) : ( + + )} ); diff --git a/src/hooks/useAnimatedComponents.ts b/src/hooks/useAnimatedComponents.ts index cc6e76d1..eb3acbc2 100644 --- a/src/hooks/useAnimatedComponents.ts +++ b/src/hooks/useAnimatedComponents.ts @@ -6,6 +6,7 @@ * */ +import { useRef } from "react"; import { Animated } from "react-native"; const INITIAL_POSITION = { x: 0, y: 0 }; @@ -15,33 +16,50 @@ const ANIMATION_CONFIG = { }; const useAnimatedComponents = () => { - const headerTranslate = new Animated.ValueXY(INITIAL_POSITION); - const footerTranslate = new Animated.ValueXY(INITIAL_POSITION); + const headerTranslate = useRef(new Animated.ValueXY(INITIAL_POSITION)); + const footerTranslate = useRef(new Animated.ValueXY(INITIAL_POSITION)); - const toggleVisible = (isVisible: boolean) => { - if (isVisible) { + const isVisible = useRef(true); + + const setIsVisible = (shouldMakeVisible: boolean) => { + if (shouldMakeVisible) { Animated.parallel([ - Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }), - Animated.timing(footerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }), - ]).start(); + Animated.timing(headerTranslate.current.y, { + ...ANIMATION_CONFIG, + toValue: 0, + }), + Animated.timing(footerTranslate.current.y, { + ...ANIMATION_CONFIG, + toValue: 0, + }), + ]).start(() => (isVisible.current = true)); } else { Animated.parallel([ - Animated.timing(headerTranslate.y, { + Animated.timing(headerTranslate.current.y, { ...ANIMATION_CONFIG, toValue: -300, }), - Animated.timing(footerTranslate.y, { + Animated.timing(footerTranslate.current.y, { ...ANIMATION_CONFIG, toValue: 300, }), - ]).start(); + ]).start(() => (isVisible.current = false)); } }; - const headerTransform = headerTranslate.getTranslateTransform(); - const footerTransform = footerTranslate.getTranslateTransform(); + const toggleIsVisible = () => { + setIsVisible(!isVisible.current); + }; + + const headerTransform = headerTranslate.current.getTranslateTransform(); + const footerTransform = footerTranslate.current.getTranslateTransform(); - return [headerTransform, footerTransform, toggleVisible] as const; + return [ + headerTransform, + footerTransform, + setIsVisible, + toggleIsVisible, + ] as const; }; export default useAnimatedComponents; diff --git a/src/hooks/useDoubleTapToZoom.ts b/src/hooks/useDoubleTapToZoom.ts index 8fcb4a38..69e0589b 100644 --- a/src/hooks/useDoubleTapToZoom.ts +++ b/src/hooks/useDoubleTapToZoom.ts @@ -18,6 +18,8 @@ import { Dimensions } from "../@types"; const DOUBLE_TAP_DELAY = 300; let lastTapTS: number | null = null; +let isWaitingToSendSinglePress = false; + /** * This is iOS only. * Same functionality for Android implemented inside usePanResponder hook. @@ -25,7 +27,8 @@ let lastTapTS: number | null = null; function useDoubleTapToZoom( scrollViewRef: React.RefObject, scaled: boolean, - screen: Dimensions + screen: Dimensions, + onPress: () => void ) { const handleDoubleTap = useCallback( (event: NativeSyntheticEvent) => { @@ -33,6 +36,7 @@ function useDoubleTapToZoom( const scrollResponderRef = scrollViewRef?.current?.getScrollResponder(); if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { + isWaitingToSendSinglePress = false; const { pageX, pageY } = event.nativeEvent; let targetX = 0; let targetY = 0; @@ -58,6 +62,15 @@ function useDoubleTapToZoom( }); } else { lastTapTS = nowTS; + + if (!isWaitingToSendSinglePress) { + isWaitingToSendSinglePress = true; + setTimeout(() => { + if (!isWaitingToSendSinglePress) return; + isWaitingToSendSinglePress = false; + onPress(); + }, DOUBLE_TAP_DELAY); + } } }, [scaled] diff --git a/src/hooks/usePanResponder.ts b/src/hooks/usePanResponder.ts index 202eca8e..bc58cb20 100644 --- a/src/hooks/usePanResponder.ts +++ b/src/hooks/usePanResponder.ts @@ -33,12 +33,15 @@ const SCALE_MAX = 2; const DOUBLE_TAP_DELAY = 300; const OUT_BOUND_MULTIPLIER = 0.75; +let isWaitingToSendSinglePress = false; + type Props = { initialScale: number; initialTranslate: Position; onZoom: (isZoomed: boolean) => void; doubleTapToZoomEnabled: boolean; onLongPress: () => void; + onPress: () => void; delayLongPress: number; }; @@ -48,6 +51,7 @@ const usePanResponder = ({ onZoom, doubleTapToZoomEnabled, onLongPress, + onPress, delayLongPress, }: Props): Readonly< [GestureResponderHandlers, Animated.Value, Animated.ValueXY] @@ -152,6 +156,7 @@ const usePanResponder = ({ ); if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + isWaitingToSendSinglePress = false; const isScaled = currentTranslate.x !== initialTranslate.x; // currentScale !== initialScale; const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]; const targetScale = SCALE_MAX; @@ -199,6 +204,15 @@ const usePanResponder = ({ lastTapTS = null; } else { lastTapTS = Date.now(); + + if (!isWaitingToSendSinglePress) { + isWaitingToSendSinglePress = true; + setTimeout(() => { + if (!isWaitingToSendSinglePress) return; + isWaitingToSendSinglePress = false; + onPress(); + }, DOUBLE_TAP_DELAY); + } } }, onMove: ( @@ -208,11 +222,13 @@ const usePanResponder = ({ const { dx, dy } = gestureState; if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { + isWaitingToSendSinglePress = false; cancelLongPressHandle(); } // Don't need to handle move because double tap in progress (was handled in onStart) if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + isWaitingToSendSinglePress = false; cancelLongPressHandle(); return; } @@ -232,6 +248,7 @@ const usePanResponder = ({ if (isPinchGesture) { cancelLongPressHandle(); + isWaitingToSendSinglePress = false; const initialDistance = getDistanceBetweenTouches(initialTouches); const currentDistance = getDistanceBetweenTouches( @@ -280,9 +297,8 @@ const usePanResponder = ({ if (isTapGesture && currentScale > initialScale) { const { x, y } = currentTranslate; const { dx, dy } = gestureState; - const [topBound, leftBound, bottomBound, rightBound] = getBounds( - currentScale - ); + const [topBound, leftBound, bottomBound, rightBound] = + getBounds(currentScale); let nextTranslateX = x + dx; let nextTranslateY = y + dy; @@ -324,6 +340,7 @@ const usePanResponder = ({ tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; } }, + onRelease: () => { cancelLongPressHandle(); @@ -347,9 +364,8 @@ const usePanResponder = ({ if (tmpTranslate) { const { x, y } = tmpTranslate; - const [topBound, leftBound, bottomBound, rightBound] = getBounds( - currentScale - ); + const [topBound, leftBound, bottomBound, rightBound] = + getBounds(currentScale); let nextTranslateX = x; let nextTranslateY = y; diff --git a/tsconfig-debug.json b/tsconfig-debug.json new file mode 100644 index 00000000..cc7d3b58 --- /dev/null +++ b/tsconfig-debug.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./../app/node_modules/react-native-image-viewing/dist", + "watch": true + } +}