This commit is contained in:
Michele Balistreri
2022-02-02 14:22:07 +01:00
parent 0d70146782
commit 94af89356f
32 changed files with 1203 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
/**
* 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 { ImageURISource, ImageRequireSource } from "react-native";
export declare type Dimensions = {
width: number;
height: number;
};
export declare type Position = {
x: number;
y: number;
};
export declare type ImageSource = ImageURISource | ImageRequireSource;
+7
View File
@@ -0,0 +1,7 @@
/**
* 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.
*
*/
+35
View File
@@ -0,0 +1,35 @@
/**
* 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 { ComponentType } from "react";
import { ModalProps } from "react-native";
import { ImageSource } from "./@types";
declare type Props = {
images: ImageSource[];
keyExtractor?: (imageSrc: ImageSource, index: number) => string;
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;
hideHeaderOnZoom?: boolean;
hideFooterOnZoom?: boolean;
delayLongPress?: number;
HeaderComponent?: ComponentType<{
imageIndex: number;
}>;
FooterComponent?: ComponentType<{
imageIndex: number;
}>;
};
declare const EnhancedImageViewing: (props: Props) => JSX.Element;
export default EnhancedImageViewing;
+86
View File
@@ -0,0 +1,86 @@
/**
* 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, useEffect } from "react";
import { Animated, Dimensions, StyleSheet, View, VirtualizedList, Modal, } from "react-native";
import ImageItem from "./components/ImageItem/ImageItem";
import ImageDefaultHeader from "./components/ImageDefaultHeader";
import StatusBarManager from "./components/StatusBarManager";
import useAnimatedComponents from "./hooks/useAnimatedComponents";
import useImageIndexChange from "./hooks/useImageIndexChange";
import useRequestClose from "./hooks/useRequestClose";
const DEFAULT_ANIMATION_TYPE = "fade";
const DEFAULT_BG_COLOR = "#000";
const DEFAULT_DELAY_LONG_PRESS = 800;
const SCREEN = Dimensions.get("screen");
const SCREEN_WIDTH = SCREEN.width;
const DEFAULT_HIDE_HEADER_ON_ZOOM = true;
const DEFAULT_HIDE_FOOTER_ON_ZOOM = true;
function ImageViewing({ images, keyExtractor, imageIndex, visible, onRequestClose, onLongPress = () => { }, onImageIndexChange, animationType = DEFAULT_ANIMATION_TYPE, backgroundColor = DEFAULT_BG_COLOR, presentationStyle, swipeToCloseEnabled, doubleTapToZoomEnabled, delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, hideHeaderOnZoom = DEFAULT_HIDE_HEADER_ON_ZOOM, hideFooterOnZoom = DEFAULT_HIDE_FOOTER_ON_ZOOM, }) {
const imageList = React.createRef();
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose);
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN);
const [headerTransform, footerTransform, toggleBarsVisible,] = useAnimatedComponents();
useEffect(() => {
if (onImageIndexChange) {
onImageIndexChange(currentImageIndex);
}
}, [currentImageIndex]);
const onZoom = useCallback((isScaled) => {
var _a, _b;
// @ts-ignore
(_b = (_a = imageList) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.setNativeProps({ scrollEnabled: !isScaled });
toggleBarsVisible(!isScaled, hideHeaderOnZoom, hideFooterOnZoom);
}, [imageList, hideHeaderOnZoom, hideFooterOnZoom]);
if (!visible) {
return null;
}
return (<Modal transparent={presentationStyle === "overFullScreen"} visible={visible} presentationStyle={presentationStyle} animationType={animationType} onRequestClose={onRequestCloseEnhanced} supportedOrientations={["portrait"]} hardwareAccelerated>
<StatusBarManager presentationStyle={presentationStyle}/>
<View style={[styles.container, { opacity, backgroundColor }]}>
<Animated.View style={[styles.header, { transform: headerTransform }]}>
{typeof HeaderComponent !== "undefined"
? (React.createElement(HeaderComponent, {
imageIndex: currentImageIndex,
}))
: (<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced}/>)}
</Animated.View>
<VirtualizedList ref={imageList} data={images} horizontal pagingEnabled windowSize={2} initialNumToRender={1} maxToRenderPerBatch={1} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} initialScrollIndex={imageIndex} 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) : imageSrc.uri || `${imageSrc}`}/>
{typeof FooterComponent !== "undefined" && (<Animated.View style={[styles.footer, { transform: footerTransform }]}>
{React.createElement(FooterComponent, {
imageIndex: currentImageIndex,
})}
</Animated.View>)}
</View>
</Modal>);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#000",
},
header: {
position: "absolute",
width: "100%",
zIndex: 1,
top: 0,
},
footer: {
position: "absolute",
width: "100%",
zIndex: 1,
bottom: 0,
},
});
const EnhancedImageViewing = (props) => (<ImageViewing key={props.imageIndex} {...props}/>);
export default EnhancedImageViewing;
+13
View File
@@ -0,0 +1,13 @@
/**
* 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.
*
*/
/// <reference types="react" />
declare type Props = {
onRequestClose: () => void;
};
declare const ImageDefaultHeader: ({ onRequestClose }: Props) => JSX.Element;
export default ImageDefaultHeader;
+39
View File
@@ -0,0 +1,39 @@
/**
* 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 from "react";
import { SafeAreaView, Text, TouchableOpacity, StyleSheet } from "react-native";
const HIT_SLOP = { top: 16, left: 16, bottom: 16, right: 16 };
const ImageDefaultHeader = ({ onRequestClose }) => (<SafeAreaView style={styles.root}>
<TouchableOpacity style={styles.closeButton} onPress={onRequestClose} hitSlop={HIT_SLOP}>
<Text style={styles.closeText}></Text>
</TouchableOpacity>
</SafeAreaView>);
const styles = StyleSheet.create({
root: {
alignItems: "flex-end",
},
closeButton: {
marginRight: 8,
marginTop: 8,
width: 45,
height: 45,
alignItems: "center",
justifyContent: "center",
borderRadius: 22.5,
backgroundColor: "#00000077",
},
closeText: {
lineHeight: 25,
fontSize: 25,
paddingTop: 2,
textAlign: "center",
color: "#FFF",
includeFontPadding: false,
},
});
export default ImageDefaultHeader;
+20
View File
@@ -0,0 +1,20 @@
/**
* 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 from "react";
import { ImageSource } from "../../@types";
declare type Props = {
imageSrc: ImageSource;
onRequestClose: () => void;
onZoom: (isZoomed: boolean) => void;
onLongPress: (image: ImageSource) => void;
delayLongPress: number;
swipeToCloseEnabled?: boolean;
doubleTapToZoomEnabled?: boolean;
};
declare const _default: React.MemoExoticComponent<({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPress, swipeToCloseEnabled, doubleTapToZoomEnabled, }: Props) => JSX.Element>;
export default _default;
+85
View File
@@ -0,0 +1,85 @@
/**
* 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, useCallback } from "react";
import { Animated, Dimensions, StyleSheet, } from "react-native";
import useImageDimensions from "../../hooks/useImageDimensions";
import usePanResponder from "../../hooks/usePanResponder";
import { getImageStyles, getImageTransform } from "../../utils";
import { ImageLoading } from "./ImageLoading";
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 ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPress, swipeToCloseEnabled = true, doubleTapToZoomEnabled = true, }) => {
const imageContainer = React.createRef();
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 = (isZoomed) => {
var _a;
onZoom(isZoomed);
if ((_a = imageContainer) === null || _a === void 0 ? void 0 : _a.current) {
// @ts-ignore
imageContainer.current.setNativeProps({
scrollEnabled: !isZoomed,
});
}
};
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(imageDimensions, translateValue, scaleValue);
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.7, 1, 0.7],
});
const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity };
const onScrollEndDrag = ({ nativeEvent, }) => {
var _a, _b, _c, _d, _e, _f;
const velocityY = (_c = (_b = (_a = nativeEvent) === null || _a === void 0 ? void 0 : _a.velocity) === null || _b === void 0 ? void 0 : _b.y, (_c !== null && _c !== void 0 ? _c : 0));
const offsetY = (_f = (_e = (_d = nativeEvent) === null || _d === void 0 ? void 0 : _d.contentOffset) === null || _e === void 0 ? void 0 : _e.y, (_f !== null && _f !== void 0 ? _f : 0));
if ((Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
offsetY > SWIPE_CLOSE_OFFSET) ||
offsetY > SCREEN_HEIGHT / 2) {
onRequestClose();
}
};
const onScroll = ({ nativeEvent, }) => {
var _a, _b, _c;
const offsetY = (_c = (_b = (_a = nativeEvent) === null || _a === void 0 ? void 0 : _a.contentOffset) === null || _b === void 0 ? void 0 : _b.y, (_c !== null && _c !== void 0 ? _c : 0));
scrollValueY.setValue(offsetY);
};
return (<Animated.ScrollView ref={imageContainer} style={styles.listItem} pagingEnabled nestedScrollEnabled showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} contentContainerStyle={styles.imageScrollContainer} scrollEnabled={swipeToCloseEnabled} {...(swipeToCloseEnabled && {
onScroll,
onScrollEndDrag,
})}>
<Animated.Image {...panHandlers} source={imageSrc} style={imageStylesWithOpacity} onLoad={onLoaded}/>
{(!isLoaded || !imageDimensions) && <ImageLoading />}
</Animated.ScrollView>);
};
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
imageScrollContainer: {
height: SCREEN_HEIGHT * 2,
},
});
export default React.memo(ImageItem);
+20
View File
@@ -0,0 +1,20 @@
/**
* 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 from "react";
import { ImageSource } from "../../@types";
declare type Props = {
imageSrc: ImageSource;
onRequestClose: () => void;
onZoom: (scaled: boolean) => void;
onLongPress: (image: ImageSource) => void;
delayLongPress: number;
swipeToCloseEnabled?: boolean;
doubleTapToZoomEnabled?: boolean;
};
declare const _default: React.MemoExoticComponent<({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPress, swipeToCloseEnabled, doubleTapToZoomEnabled, }: Props) => JSX.Element>;
export default _default;
+79
View File
@@ -0,0 +1,79 @@
/**
* 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, useRef, useState } from "react";
import { Animated, Dimensions, ScrollView, StyleSheet, View, TouchableWithoutFeedback, } from "react-native";
import useDoubleTapToZoom from "../../hooks/useDoubleTapToZoom";
import useImageDimensions from "../../hooks/useImageDimensions";
import { getImageStyles, getImageTransform } from "../../utils";
import { ImageLoading } from "./ImageLoading";
const SWIPE_CLOSE_OFFSET = 75;
const SWIPE_CLOSE_VELOCITY = 1.55;
const SCREEN = Dimensions.get("screen");
const SCREEN_WIDTH = SCREEN.width;
const SCREEN_HEIGHT = SCREEN.height;
const ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPress, swipeToCloseEnabled = true, doubleTapToZoomEnabled = true, }) => {
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 [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 maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1;
const imageOpacity = scrollValueY.interpolate({
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(({ nativeEvent }) => {
var _a, _b, _c, _d;
const velocityY = (_c = (_b = (_a = nativeEvent) === null || _a === void 0 ? void 0 : _a.velocity) === null || _b === void 0 ? void 0 : _b.y, (_c !== null && _c !== void 0 ? _c : 0));
const scaled = ((_d = nativeEvent) === null || _d === void 0 ? void 0 : _d.zoomScale) > 1;
onZoom(scaled);
setScaled(scaled);
if (!scaled &&
swipeToCloseEnabled &&
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
onRequestClose();
}
}, [scaled]);
const onScroll = ({ nativeEvent, }) => {
var _a, _b, _c, _d;
const offsetY = (_c = (_b = (_a = nativeEvent) === null || _a === void 0 ? void 0 : _a.contentOffset) === null || _b === void 0 ? void 0 : _b.y, (_c !== null && _c !== void 0 ? _c : 0));
if (((_d = nativeEvent) === null || _d === void 0 ? void 0 : _d.zoomScale) > 1) {
return;
}
scrollValueY.setValue(offsetY);
};
const onLongPressHandler = useCallback((event) => {
onLongPress(imageSrc);
}, [imageSrc, onLongPress]);
return (<View>
<ScrollView ref={scrollViewRef} style={styles.listItem} pinchGestureEnabled nestedScrollEnabled={true} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} maximumZoomScale={maxScale} contentContainerStyle={styles.imageScrollContainer} scrollEnabled={swipeToCloseEnabled} onScrollEndDrag={onScrollEndDrag} scrollEventThrottle={1} {...(swipeToCloseEnabled && {
onScroll,
})}>
{(!loaded || !imageDimensions) && <ImageLoading />}
<TouchableWithoutFeedback onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} onLongPress={onLongPressHandler} delayLongPress={delayLongPress}>
<Animated.Image source={imageSrc} style={imageStylesWithOpacity} onLoad={() => setLoaded(true)}/>
</TouchableWithoutFeedback>
</ScrollView>
</View>);
};
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
imageScrollContainer: {
height: SCREEN_HEIGHT,
},
});
export default React.memo(ImageItem);
+9
View File
@@ -0,0 +1,9 @@
/**
* 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.
*
*/
/// <reference types="react" />
export declare const ImageLoading: () => JSX.Element;
+30
View File
@@ -0,0 +1,30 @@
/**
* 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 from "react";
import { ActivityIndicator, Dimensions, StyleSheet, View } from "react-native";
const SCREEN = Dimensions.get("screen");
const SCREEN_WIDTH = SCREEN.width;
const SCREEN_HEIGHT = SCREEN.height;
export const ImageLoading = () => (<View style={styles.loading}>
<ActivityIndicator size="small" color="#FFF"/>
</View>);
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
loading: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
alignItems: "center",
justifyContent: "center",
},
imageScrollContainer: {
height: SCREEN_HEIGHT,
},
});
+4
View File
@@ -0,0 +1,4 @@
declare const StatusBarManager: ({ presentationStyle, }: {
presentationStyle?: "fullScreen" | "pageSheet" | "formSheet" | "overFullScreen" | undefined;
}) => null;
export default StatusBarManager;
+14
View File
@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { Platform, StatusBar, } from "react-native";
const StatusBarManager = ({ presentationStyle, }) => {
if (Platform.OS === "ios" || presentationStyle !== "overFullScreen") {
return null;
}
//Can't get an actual state of app status bar with default RN. Gonna rely on "presentationStyle === overFullScreen" prop and guess application status bar state to be visible in this case.
StatusBar.setHidden(true);
useEffect(() => {
return () => StatusBar.setHidden(false);
}, []);
return null;
};
export default StatusBarManager;
+14
View File
@@ -0,0 +1,14 @@
/**
* 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";
declare const useAnimatedComponents: () => readonly [{
[key: string]: Animated.Value;
}[], {
[key: string]: Animated.Value;
}[], (isVisible: boolean, hideHeaderOnZoom: boolean, hideFooterOnZoom: boolean) => void];
export default useAnimatedComponents;
+44
View File
@@ -0,0 +1,44 @@
/**
* 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, hideHeaderOnZoom, hideFooterOnZoom) => {
if (isVisible) {
Animated.parallel([
Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }),
Animated.timing(footerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }),
]).start();
}
else {
const hideHeaderAnimation = hideHeaderOnZoom
? [Animated.timing(headerTranslate.y, {
...ANIMATION_CONFIG,
toValue: -300,
})]
: [];
const hideFooterAnimation = hideFooterOnZoom
? [Animated.timing(footerTranslate.y, {
...ANIMATION_CONFIG,
toValue: 300,
})]
: [];
Animated.parallel([...hideHeaderAnimation, ...hideFooterAnimation]).start();
}
};
const headerTransform = headerTranslate.getTranslateTransform();
const footerTransform = footerTranslate.getTranslateTransform();
return [headerTransform, footerTransform, toggleVisible];
};
export default useAnimatedComponents;
+16
View File
@@ -0,0 +1,16 @@
/**
* 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 from "react";
import { ScrollView, NativeTouchEvent, NativeSyntheticEvent } from "react-native";
import { Dimensions } from "../@types";
/**
* This is iOS only.
* Same functionality for Android implemented inside usePanResponder hook.
*/
declare function useDoubleTapToZoom(scrollViewRef: React.RefObject<ScrollView>, scaled: boolean, screen: Dimensions): (event: NativeSyntheticEvent<NativeTouchEvent>) => void;
export default useDoubleTapToZoom;
+49
View File
@@ -0,0 +1,49 @@
/**
* 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 { useCallback } from "react";
const DOUBLE_TAP_DELAY = 300;
let lastTapTS = null;
/**
* This is iOS only.
* Same functionality for Android implemented inside usePanResponder hook.
*/
function useDoubleTapToZoom(scrollViewRef, scaled, screen) {
const handleDoubleTap = useCallback((event) => {
var _a, _b, _c;
const nowTS = new Date().getTime();
const scrollResponderRef = (_b = (_a = scrollViewRef) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.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
(_c = scrollResponderRef) === null || _c === void 0 ? void 0 : _c.scrollResponderZoomTo({
x: targetX,
y: targetY,
width: targetWidth,
height: targetHeight,
animated: true,
});
}
else {
lastTapTS = nowTS;
}
}, [scaled]);
return handleDoubleTap;
}
export default useDoubleTapToZoom;
+10
View File
@@ -0,0 +1,10 @@
/**
* 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 { Dimensions, ImageSource } from "../@types";
declare const useImageDimensions: (image: ImageSource) => Dimensions | null;
export default useImageDimensions;
+64
View File
@@ -0,0 +1,64 @@
/**
* 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 } from "react-native";
import { createCache } from "../utils";
const CACHE_SIZE = 50;
const imageDimensionsCache = createCache(CACHE_SIZE);
const useImageDimensions = (image) => {
const [dimensions, setDimensions] = useState(null);
const getImageDimensions = (image) => {
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;
const cacheKey = source.uri;
const imageDimensions = imageDimensionsCache.get(cacheKey);
if (imageDimensions) {
resolve(imageDimensions);
}
else {
// @ts-ignore
Image.getSizeWithHeaders(source.uri, source.headers, (width, height) => {
imageDimensionsCache.set(cacheKey, { width, height });
resolve({ width, height });
}, () => {
resolve({ width: 0, height: 0 });
});
}
}
else {
resolve({ width: 0, height: 0 });
}
});
};
let isImageUnmounted = false;
useEffect(() => {
getImageDimensions(image).then((dimensions) => {
if (!isImageUnmounted) {
setDimensions(dimensions);
}
});
return () => {
isImageUnmounted = true;
};
}, [image]);
return dimensions;
};
export default useImageDimensions;
+11
View File
@@ -0,0 +1,11 @@
/**
* 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 { NativeSyntheticEvent, NativeScrollEvent } from "react-native";
import { Dimensions } from "../@types";
declare const useImageIndexChange: (imageIndex: number, screen: Dimensions) => readonly [number, (event: NativeSyntheticEvent<NativeScrollEvent>) => void];
export default useImageIndexChange;
+20
View File
@@ -0,0 +1,20 @@
/**
* 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 useImageIndexChange = (imageIndex, screen) => {
const [currentImageIndex, setImageIndex] = useState(imageIndex);
const onScroll = (event) => {
const { nativeEvent: { contentOffset: { x: scrollX }, }, } = event;
if (screen.width) {
const nextIndex = Math.round(scrollX / screen.width);
setImageIndex(nextIndex < 0 ? 0 : nextIndex);
}
};
return [currentImageIndex, onScroll];
};
export default useImageIndexChange;
+10
View File
@@ -0,0 +1,10 @@
/**
* 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 { ImageSource } from "../@types";
declare const useImagePrefetch: (images: ImageSource[]) => void;
export default useImagePrefetch;
+21
View File
@@ -0,0 +1,21 @@
/**
* 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";
const useImagePrefetch = (images) => {
useEffect(() => {
images.forEach((image) => {
//@ts-ignore
if (image.uri) {
//@ts-ignore
return Image.prefetch(image.uri);
}
});
}, []);
};
export default useImagePrefetch;
+19
View File
@@ -0,0 +1,19 @@
/**
* 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, GestureResponderHandlers } from "react-native";
import { Position } from "../@types";
declare type Props = {
initialScale: number;
initialTranslate: Position;
onZoom: (isZoomed: boolean) => void;
doubleTapToZoomEnabled: boolean;
onLongPress: () => void;
delayLongPress: number;
};
declare const usePanResponder: ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, onLongPress, delayLongPress, }: Props) => readonly [GestureResponderHandlers, Animated.Value, Animated.ValueXY];
export default usePanResponder;
+273
View File
@@ -0,0 +1,273 @@
/**
* 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, } from "react-native";
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;
const usePanResponder = ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, onLongPress, delayLongPress, }) => {
let numberInitialTouches = 1;
let initialTouches = [];
let currentScale = initialScale;
let currentTranslate = initialTranslate;
let tmpScale = 0;
let tmpTranslate = null;
let isDoubleTapPerformed = false;
let lastTapTS = null;
let longPressHandlerRef = 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) => {
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, scale) => {
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: (_, gestureState) => {
numberInitialTouches = gestureState.numberActiveTouches;
if (gestureState.numberActiveTouches > 1)
return;
longPressHandlerRef = setTimeout(onLongPress, delayLongPress);
},
onStart: (event, gestureState) => {
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, gestureState) => {
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;
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;
+9
View File
@@ -0,0 +1,9 @@
/**
* 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.
*
*/
declare const useRequestClose: (onRequestClose: () => void) => readonly [number, () => void];
export default useRequestClose;
+20
View File
@@ -0,0 +1,20 @@
/**
* 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) => {
const [opacity, setOpacity] = useState(1);
return [
opacity,
() => {
setOpacity(0);
onRequestClose();
setTimeout(() => setOpacity(1), 0);
},
];
};
export default useRequestClose;
+8
View File
@@ -0,0 +1,8 @@
/**
* 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.
*
*/
export { default } from "./ImageViewing";
+8
View File
@@ -0,0 +1,8 @@
/**
* 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.
*
*/
export { default } from "./ImageViewing";
+48
View File
@@ -0,0 +1,48 @@
/**
* 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, GestureResponderEvent, PanResponderGestureState, PanResponderInstance, NativeTouchEvent } from "react-native";
import { Dimensions, Position } from "./@types";
declare type CacheStorageItem = {
key: string;
value: any;
};
export declare const createCache: (cacheSize: number) => {
_storage: CacheStorageItem[];
get(key: string): any;
set(key: string, value: any): void;
};
export declare const splitArrayIntoBatches: (arr: any[], batchSize: number) => any[];
export declare const getImageTransform: (image: Dimensions | null, screen: Dimensions) => readonly [] | readonly [{
readonly x: number;
readonly y: number;
}, number];
export declare const getImageStyles: (image: Dimensions | null, translate: Animated.ValueXY, scale?: Animated.Value | undefined) => {
width: number;
height: number;
transform?: undefined;
} | {
width: number;
height: number;
transform: {
[key: string]: Animated.Value;
}[];
};
export declare const getImageTranslate: (image: Dimensions, screen: Dimensions) => Position;
export declare const getImageDimensionsByTranslate: (translate: Position, screen: Dimensions) => Dimensions;
export declare const getImageTranslateForScale: (currentTranslate: Position, targetScale: number, screen: Dimensions) => Position;
declare type HandlerType = (event: GestureResponderEvent, state: PanResponderGestureState) => void;
declare type PanResponderProps = {
onGrant: HandlerType;
onStart?: HandlerType;
onMove: HandlerType;
onRelease?: HandlerType;
onTerminate?: HandlerType;
};
export declare const createPanResponder: ({ onGrant, onStart, onMove, onRelease, onTerminate, }: PanResponderProps) => PanResponderInstance;
export declare const getDistanceBetweenTouches: (touches: NativeTouchEvent[]) => number;
export {};
+101
View File
@@ -0,0 +1,101 @@
/**
* 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, PanResponder, } from "react-native";
export const createCache = (cacheSize) => ({
_storage: [],
get(key) {
const { value } = this._storage.find(({ key: storageKey }) => storageKey === key) || {};
return value;
},
set(key, value) {
if (this._storage.length >= cacheSize) {
this._storage.shift();
}
this._storage.push({ key, value });
},
});
export const splitArrayIntoBatches = (arr, batchSize) => 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, screen) => {
var _a, _b;
if (!((_a = image) === null || _a === void 0 ? void 0 : _a.width) || !((_b = image) === null || _b === void 0 ? void 0 : _b.height)) {
return [];
}
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];
};
export const getImageStyles = (image, translate, scale) => {
var _a, _b;
if (!((_a = image) === null || _a === void 0 ? void 0 : _a.width) || !((_b = image) === null || _b === void 0 ? void 0 : _b.height)) {
return { width: 0, height: 0 };
}
const transform = translate.getTranslateTransform();
if (scale) {
transform.push({ scale }, { perspective: new Animated.Value(1000) });
}
return {
width: image.width,
height: image.height,
transform,
};
};
export const getImageTranslate = (image, screen) => {
const getTranslateForAxis = (axis) => {
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, screen) => ({
width: screen.width - translate.x * 2,
height: screen.height - translate.y * 2,
});
export const getImageTranslateForScale = (currentTranslate, targetScale, screen) => {
const { width, height } = getImageDimensionsByTranslate(currentTranslate, screen);
const targetImageDimensions = {
width: width * targetScale,
height: height * targetScale,
};
return getImageTranslate(targetImageDimensions, screen);
};
export const createPanResponder = ({ onGrant, onStart, onMove, onRelease, onTerminate, }) => PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: onGrant,
onPanResponderStart: onStart,
onPanResponderMove: onMove,
onPanResponderRelease: onRelease,
onPanResponderTerminate: onTerminate,
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false,
});
export const getDistanceBetweenTouches = (touches) => {
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));
};