├── .gitignore ├── LICENSE ├── README.md ├── demo.gif ├── example ├── .gitignore ├── App.tsx ├── app.json ├── assets │ ├── icon.png │ └── splash.png ├── babel.config.js ├── components │ ├── ImageFooter.tsx │ ├── ImageHeader.tsx │ └── ImageList.tsx ├── data │ ├── architecture.jpg │ ├── architecture.ts │ ├── city.ts │ ├── food.ts │ └── travel.ts ├── metro.config.js ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── @types │ ├── extensions.d.ts │ └── index.ts ├── ImageViewing.tsx ├── components │ ├── ImageDefaultHeader.tsx │ ├── ImageItem │ │ ├── ImageItem.android.tsx │ │ ├── ImageItem.d.ts │ │ ├── ImageItem.ios.tsx │ │ └── ImageLoading.tsx │ └── StatusBarManager.tsx ├── hooks │ ├── useAnimatedComponents.ts │ ├── useDoubleTapToZoom.ts │ ├── useImageDimensions.ts │ ├── useImageIndexChange.ts │ ├── useImagePrefetch.ts │ ├── usePanResponder.ts │ └── useRequestClose.ts ├── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .expo 4 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 JOB TODAY S.A. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-image-viewing 2 | 3 | > React Native modal component for viewing images as a sliding gallery. 4 | 5 | [![npm version](https://badge.fury.io/js/react-native-image-viewing.svg)](https://badge.fury.io/js/react-native-image-viewing) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | 8 | - 🔥Pinch zoom for both iOS and Android 9 | - 🔥Double tap to zoom for both iOS and Android 10 | - 🔥Supports swipe-to-close animation 11 | - 🔥Custom header and footer components 12 | - 🔥Uses VirtualizedList to optimize image loading and rendering 13 | 14 | Try with Expo: https://expo.io/@antonkalinin/react-native-image-viewing 15 | 16 |

17 | 18 |

19 | 20 | ## Installation 21 | 22 | ```bash 23 | yarn add react-native-image-viewing 24 | ``` 25 | 26 | or 27 | 28 | ```bash 29 | npm install --save react-native-image-viewing 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```jsx 35 | import ImageView from "react-native-image-viewing"; 36 | 37 | const images = [ 38 | { 39 | uri: "https://images.unsplash.com/photo-1571501679680-de32f1e7aad4", 40 | }, 41 | { 42 | uri: "https://images.unsplash.com/photo-1573273787173-0eb81a833b34", 43 | }, 44 | { 45 | uri: "https://images.unsplash.com/photo-1569569970363-df7b6160d111", 46 | }, 47 | ]; 48 | 49 | const [visible, setIsVisible] = useState(false); 50 | 51 | setIsVisible(false)} 56 | /> 57 | ``` 58 | 59 | #### [See Example](https://github.com/jobtoday/react-native-image-viewing/blob/master/example/App.tsx#L62-L80) 60 | 61 | ## Props 62 | 63 | | Prop name | Description | Type | Required | 64 | | ------------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- | 65 | | `images` | Array of images to display | ImageSource[] | true | 66 | | `keyExtractor` | Uniqely identifying each image | (imageSrc: ImageSource, index: number) => string | false | 67 | | `imageIndex` | Current index of image to display | number | true | 68 | | `visible` | Is modal shown or not | boolean | true | 69 | | `onRequestClose` | Function called to close the modal | function | true | 70 | | `onImageIndexChange` | Function called when image index has been changed | function | false | 71 | | `onLongPress` | Function called when image long pressed | function (event: GestureResponderEvent, image: ImageSource) | false | 72 | | `delayLongPress` | Delay in ms, before onLongPress is called: default `800` | number | false | 73 | | `animationType` | Animation modal presented with: default `fade` | `none`, `fade`, `slide` | false | 74 | | `presentationStyle` | Modal presentation style: default: `fullScreen` **Android:** Use `overFullScreen` to hide StatusBar | `fullScreen`, `pageSheet`, `formSheet`, `overFullScreen` | false | 75 | | `backgroundColor` | Background color of the modal in HEX (#000000EE) | string | false | 76 | | `swipeToCloseEnabled` | Close modal with swipe up or down: default `true` | boolean | false | 77 | | `doubleTapToZoomEnabled` | Zoom image by double tap on it: default `true` | boolean | false | 78 | | `HeaderComponent` | Header component, gets current `imageIndex` as a prop | component, function | false | 79 | | `FooterComponent` | Footer component, gets current `imageIndex` as a prop | component, function | false | 80 | 81 | - type ImageSource = ImageURISource | ImageRequireSource 82 | 83 | ## Contributing 84 | 85 | To start contributing clone this repo and then run inside `react-native-image-viewing` folder: 86 | 87 | ```bash 88 | yarn 89 | ``` 90 | 91 | Then go inside `example` folder and run: 92 | 93 | ```bash 94 | yarn & yarn start 95 | ``` 96 | 97 | This will start packager for expo so you can change `/src/ImageViewing` and see changes in expo example app. 98 | 99 | ## License 100 | 101 | [MIT](LICENSE) 102 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobtoday/react-native-image-viewing/8a91a9c370cb0e6820482b2262aacd1e25a718f7/demo.gif -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | web-report/ 12 | 13 | # macOS 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React, { useState } from "react"; 10 | import { 11 | Alert, 12 | Platform, 13 | SafeAreaView, 14 | StatusBar, 15 | StyleSheet, 16 | Text, 17 | View, 18 | } from "react-native"; 19 | import get from "lodash/get"; 20 | import memoize from "lodash/memoize"; 21 | 22 | import ImageViewing from "../src/ImageViewing"; 23 | import ImageList from "./components/ImageList"; 24 | import ImageHeader from "./components/ImageHeader"; 25 | import ImageFooter from "./components/ImageFooter"; 26 | 27 | import { architecture } from "./data/architecture"; 28 | import { travel } from "./data/travel"; 29 | import { city } from "./data/city"; 30 | import { food } from "./data/food"; 31 | 32 | import { ImageSource } from "../src/@types"; 33 | 34 | export default function App() { 35 | const [currentImageIndex, setImageIndex] = useState(0); 36 | const [images, setImages] = useState(architecture); 37 | const [isVisible, setIsVisible] = useState(false); 38 | 39 | const onSelect = (images, index) => { 40 | setImageIndex(index); 41 | setImages(images); 42 | setIsVisible(true); 43 | }; 44 | 45 | const onRequestClose = () => setIsVisible(false); 46 | const getImageSource = memoize((images): ImageSource[] => 47 | images.map((image) => 48 | typeof image.original === "number" 49 | ? image.original 50 | : { uri: image.original as string } 51 | ) 52 | ); 53 | const onLongPress = (image) => { 54 | Alert.alert("Long Pressed", image.uri); 55 | }; 56 | 57 | return ( 58 | 59 | image.thumbnail)} 61 | onPress={(index) => onSelect(travel, index)} 62 | shift={0.25} 63 | /> 64 | image.thumbnail)} 66 | onPress={(index) => onSelect(architecture, index)} 67 | shift={0.75} 68 | /> 69 | 70 | [ react-native-image-viewing ] 71 | 72 | { 82 | const title = get(images, `${imageIndex}.title`); 83 | return ( 84 | 85 | ); 86 | } 87 | : undefined 88 | } 89 | FooterComponent={({ imageIndex }) => ( 90 | 91 | )} 92 | /> 93 | image.thumbnail)} 95 | onPress={(index) => onSelect(food, index)} 96 | shift={0.5} 97 | /> 98 | image.thumbnail)} 100 | onPress={(index) => onSelect(city, index)} 101 | shift={0.75} 102 | /> 103 | 104 | ); 105 | } 106 | 107 | const styles = StyleSheet.create({ 108 | root: { 109 | flex: 1, 110 | backgroundColor: "#000", 111 | ...Platform.select({ 112 | android: { paddingTop: StatusBar.currentHeight }, 113 | default: null, 114 | }), 115 | }, 116 | about: { 117 | flex: 1, 118 | marginTop: -12, 119 | alignItems: "center", 120 | justifyContent: "center", 121 | }, 122 | name: { 123 | textAlign: "center", 124 | fontSize: 24, 125 | fontWeight: "200", 126 | color: "#FFFFFFEE", 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "React Native Image Viewing", 4 | "slug": "react-native-image-viewing", 5 | "description": "React Native modal image view with pinch zoom", 6 | "privacy": "public", 7 | "platforms": ["ios", "android", "web"], 8 | "version": "1.0.0", 9 | "orientation": "portrait", 10 | "icon": "./assets/icon.png", 11 | "splash": { 12 | "image": "./assets/splash.png", 13 | "resizeMode": "contain", 14 | "backgroundColor": "#ffffff" 15 | }, 16 | "updates": { 17 | "fallbackToCacheTimeout": 0 18 | }, 19 | "assetBundlePatterns": ["assets/*"], 20 | "ios": { 21 | "supportsTablet": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobtoday/react-native-image-viewing/8a91a9c370cb0e6820482b2262aacd1e25a718f7/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobtoday/react-native-image-viewing/8a91a9c370cb0e6820482b2262aacd1e25a718f7/example/assets/splash.png -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | module.exports = function(api) { 10 | api.cache(true); 11 | return { 12 | presets: ["babel-preset-expo"] 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /example/components/ImageFooter.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | import { View, Text, StyleSheet } from "react-native"; 11 | 12 | type Props = { 13 | imageIndex: number; 14 | imagesCount: number; 15 | }; 16 | 17 | const ImageFooter = ({ imageIndex, imagesCount }: Props) => ( 18 | 19 | {`${imageIndex + 1} / ${imagesCount}`} 20 | 21 | ); 22 | 23 | const styles = StyleSheet.create({ 24 | root: { 25 | height: 64, 26 | backgroundColor: "#00000077", 27 | alignItems: "center", 28 | justifyContent: "center" 29 | }, 30 | text: { 31 | fontSize: 17, 32 | color: "#FFF" 33 | } 34 | }); 35 | 36 | export default ImageFooter; 37 | -------------------------------------------------------------------------------- /example/components/ImageHeader.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | import { 11 | StyleSheet, 12 | SafeAreaView, 13 | View, 14 | Text, 15 | TouchableOpacity 16 | } from "react-native"; 17 | 18 | type Props = { 19 | title?: string; 20 | onRequestClose: () => void; 21 | }; 22 | 23 | const HIT_SLOP = { top: 16, left: 16, bottom: 16, right: 16 }; 24 | 25 | const ImageHeader = ({ title, onRequestClose }: Props) => ( 26 | 27 | 28 | 29 | {title && {title}} 30 | 35 | 36 | 37 | 38 | 39 | ); 40 | 41 | const styles = StyleSheet.create({ 42 | root: { 43 | backgroundColor: "#00000077" 44 | }, 45 | container: { 46 | flex: 1, 47 | padding: 8, 48 | flexDirection: "row", 49 | justifyContent: "space-between" 50 | }, 51 | space: { 52 | width: 45, 53 | height: 45 54 | }, 55 | closeButton: { 56 | width: 45, 57 | height: 45, 58 | alignItems: "center", 59 | justifyContent: "center" 60 | }, 61 | closeText: { 62 | lineHeight: 25, 63 | fontSize: 25, 64 | paddingTop: 2, 65 | includeFontPadding: false, 66 | color: "#FFF" 67 | }, 68 | text: { 69 | maxWidth: 240, 70 | marginTop: 12, 71 | flex: 1, 72 | flexWrap: "wrap", 73 | textAlign: "center", 74 | fontSize: 17, 75 | lineHeight: 17, 76 | color: "#FFF" 77 | } 78 | }); 79 | 80 | export default ImageHeader; 81 | -------------------------------------------------------------------------------- /example/components/ImageList.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | import { Image, ScrollView, StyleSheet, TouchableOpacity } from "react-native"; 11 | 12 | type Props = { 13 | images: string[]; 14 | onPress: (index: number) => void; 15 | shift?: number; 16 | }; 17 | 18 | const IMAGE_WIDTH = 120; 19 | const IMAGE_HEIGH = 120; 20 | 21 | const ImageList = ({ images, shift = 0, onPress }: Props) => ( 22 | 28 | {images.map((imageUrl, index) => ( 29 | onPress(index)} 34 | > 35 | 36 | 37 | ))} 38 | 39 | ); 40 | 41 | const styles = StyleSheet.create({ 42 | root: { flexGrow: 0 }, 43 | container: { 44 | flex: 0, 45 | paddingLeft: 10, 46 | marginBottom: 10 47 | }, 48 | button: { 49 | marginRight: 10 50 | }, 51 | image: { 52 | width: IMAGE_WIDTH, 53 | height: IMAGE_HEIGH, 54 | borderRadius: 10 55 | } 56 | }); 57 | 58 | export default ImageList; 59 | -------------------------------------------------------------------------------- /example/data/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobtoday/react-native-image-viewing/8a91a9c370cb0e6820482b2262aacd1e25a718f7/example/data/architecture.jpg -------------------------------------------------------------------------------- /example/data/architecture.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export const architecture = [ 10 | { 11 | thumbnail: 12 | "https://images.unsplash.com/photo-1518005020951-eccb494ad742?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=992&q=80", 13 | original: 14 | "https://images.unsplash.com/photo-1518005020951-eccb494ad742?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2965&q=80", 15 | }, 16 | { 17 | thumbnail: 18 | "https://images.unsplash.com/photo-1486718448742-163732cd1544?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=934&q=80", 19 | original: 20 | "https://images.unsplash.com/photo-1486718448742-163732cd1544?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80", 21 | }, 22 | { 23 | thumbnail: 24 | "https://images.unsplash.com/photo-1481026469463-66327c86e544?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1424&q=80", 25 | original: require("./architecture.jpg"), 26 | }, 27 | { 28 | thumbnail: 29 | "https://images.unsplash.com/photo-1492321936769-b49830bc1d1e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=934&q=80", 30 | original: 31 | "https://images.unsplash.com/photo-1492321936769-b49830bc1d1e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2671&q=80", 32 | }, 33 | { 34 | thumbnail: 35 | "https://images.unsplash.com/photo-1494959323928-ac0394595a78?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 36 | original: 37 | "https://images.unsplash.com/photo-1494959323928-ac0394595a78?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=3022&q=80", 38 | }, 39 | { 40 | thumbnail: 41 | "https://images.unsplash.com/photo-1523165945512-d8b058e40514?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 42 | original: 43 | "https://images.unsplash.com/photo-1523165945512-d8b058e40514?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2800&q=80", 44 | }, 45 | { 46 | thumbnail: 47 | "https://images.unsplash.com/photo-1543825603-6d033f9ad143?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1955&q=80", 48 | original: 49 | "https://images.unsplash.com/photo-1543825603-6d033f9ad143?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2855&q=80", 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /example/data/city.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export const city = [ 10 | { 11 | thumbnail: 12 | "https://images.unsplash.com/photo-1445264918150-66a2371142a2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 13 | original: 14 | "https://images.unsplash.com/photo-1445264918150-66a2371142a2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2800&q=80" 15 | }, 16 | { 17 | thumbnail: 18 | "https://images.unsplash.com/photo-1429823040067-2c31b1d637ae?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 19 | original: 20 | "https://images.unsplash.com/photo-1429823040067-2c31b1d637ae?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80" 21 | }, 22 | { 23 | thumbnail: 24 | "https://images.unsplash.com/photo-1480374178950-b2c449be122e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 25 | original: 26 | "https://images.unsplash.com/photo-1480374178950-b2c449be122e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2850&q=80" 27 | }, 28 | { 29 | thumbnail: 30 | "https://images.unsplash.com/photo-1523540383849-4ae4125acd6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 31 | original: 32 | "https://images.unsplash.com/photo-1523540383849-4ae4125acd6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2850&q=80" 33 | }, 34 | { 35 | thumbnail: 36 | "https://images.unsplash.com/photo-1498206005704-36d87df55231?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 37 | original: 38 | "https://images.unsplash.com/photo-1498206005704-36d87df55231?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2886&q=80" 39 | }, 40 | { 41 | thumbnail: 42 | "https://images.unsplash.com/photo-1494523257926-2a9c37a63b9d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=581&q=80", 43 | original: 44 | "https://images.unsplash.com/photo-1494523257926-2a9c37a63b9d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2962&q=80" 45 | } 46 | ]; 47 | -------------------------------------------------------------------------------- /example/data/food.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export const food = [ 10 | { 11 | thumbnail: 12 | "https://images.unsplash.com/photo-1532980400857-e8d9d275d858?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 13 | original: 14 | "https://images.unsplash.com/photo-1532980400857-e8d9d275d858?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80" 15 | }, 16 | { 17 | thumbnail: 18 | "https://images.unsplash.com/photo-1515003197210-e0cd71810b5f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 19 | original: 20 | "https://images.unsplash.com/photo-1515003197210-e0cd71810b5f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2850&q=80" 21 | }, 22 | { 23 | thumbnail: 24 | "https://images.unsplash.com/photo-1482049016688-2d3e1b311543?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=953&q=80", 25 | original: 26 | "https://images.unsplash.com/photo-1482049016688-2d3e1b311543?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2753&q=80" 27 | }, 28 | { 29 | thumbnail: 30 | "https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 31 | original: 32 | "https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80" 33 | }, 34 | { 35 | thumbnail: 36 | "https://images.unsplash.com/photo-1478145046317-39f10e56b5e9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80", 37 | original: 38 | "https://images.unsplash.com/photo-1478145046317-39f10e56b5e9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2734&q=80" 39 | }, 40 | { 41 | thumbnail: 42 | "https://images.unsplash.com/photo-1541795795328-f073b763494e?ixlib=rb-1.2.1&auto=format&fit=crop&w=934&q=80", 43 | original: 44 | "https://images.unsplash.com/photo-1541795795328-f073b763494e?ixlib=rb-1.2.1&auto=format&fit=crop&w=2734&q=80" 45 | } 46 | ]; 47 | -------------------------------------------------------------------------------- /example/data/travel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export const travel = [ 10 | { 11 | title: "Tunnel View, United States", 12 | thumbnail: 13 | "https://images.unsplash.com/photo-1514604426857-abb26d2fb4af?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 14 | original: 15 | "https://images.unsplash.com/photo-1514604426857-abb26d2fb4af?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2400&q=80" 16 | }, 17 | { 18 | title: "Sasamat Lake, Port Moody, Canada", 19 | thumbnail: 20 | "https://images.unsplash.com/photo-1512798738109-af8814836728?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 21 | original: 22 | "https://images.unsplash.com/photo-1512798738109-af8814836728?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2849&q=80" 23 | }, 24 | { 25 | title: "Grigna, Italy", 26 | thumbnail: 27 | "https://images.unsplash.com/photo-1512229146678-994d3f1e31bf?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 28 | original: 29 | "https://images.unsplash.com/photo-1512229146678-994d3f1e31bf?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2849&q=80" 30 | }, 31 | { 32 | title: "Ouchy, Lausanne, Switzerland", 33 | thumbnail: 34 | "https://images.unsplash.com/photo-1490007340748-243aacbf2e86?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 35 | original: 36 | "https://images.unsplash.com/photo-1490007340748-243aacbf2e86?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2851&q=80" 37 | }, 38 | { 39 | title: "White Sands National Monument, United States", 40 | thumbnail: 41 | "https://images.unsplash.com/photo-1497367917223-64c44836be50?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 42 | original: 43 | "https://images.unsplash.com/photo-1497367917223-64c44836be50?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2400&q=80" 44 | }, 45 | { 46 | title: "Pink sunset over a river town", 47 | thumbnail: 48 | "https://images.unsplash.com/photo-1488503618301-156ce946f2e4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=60", 49 | original: 50 | "https://images.unsplash.com/photo-1488503618301-156ce946f2e4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2850&q=80" 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | const path = require("path"); 10 | 11 | module.exports = { 12 | resolver: { 13 | extraNodeModules: new Proxy( 14 | {}, 15 | { 16 | get: (target, name) => path.join(process.cwd(), `node_modules/${name}`) 17 | } 18 | ) 19 | }, 20 | projectRoot: path.resolve(__dirname), 21 | watchFolders: [path.resolve(__dirname, "../src")] 22 | }; 23 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject" 9 | }, 10 | "dependencies": { 11 | "expo": "^44.0.0", 12 | "expo-updates": "~0.11.6", 13 | "react": "17.0.1", 14 | "react-dom": "17.0.1", 15 | "react-native": "https://github.com/expo/react-native/archive/sdk-44.0.0.tar.gz", 16 | "react-native-web": "0.17.1" 17 | }, 18 | "devDependencies": { 19 | "@expo/ngrok": "^4.1.0", 20 | "@types/react": "~17.0.21", 21 | "@types/react-native": "~0.64.12", 22 | "babel-preset-expo": "9.0.2", 23 | "typescript": "~4.3.5" 24 | }, 25 | "private": true 26 | } 27 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react-native", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "noEmit": true, 11 | "skipLibCheck": false, 12 | "resolveJsonModule": true 13 | }, 14 | "extends": "expo/tsconfig.base" 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-image-viewing", 3 | "version": "0.2.2", 4 | "description": "React Native modal component for viewing images as a sliding gallery", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/jobtoday/react-native-image-viewing.git" 10 | }, 11 | "keywords": [ 12 | "react", 13 | "react-native", 14 | "image", 15 | "gallery", 16 | "image-gallery", 17 | "image-viewer" 18 | ], 19 | "scripts": { 20 | "build": "tsc", 21 | "postversion": "yarn build" 22 | }, 23 | "peerDependencies": { 24 | "react": ">=16.11.0", 25 | "react-native": ">=0.61.3" 26 | }, 27 | "files": [ 28 | "dist", 29 | "readme.md", 30 | "package.json" 31 | ], 32 | "author": "Anton Kalinin", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/jobtoday/react-native-image-viewing/issues" 36 | }, 37 | "homepage": "https://github.com/jobtoday/react-native-image-viewing#readme", 38 | "devDependencies": { 39 | "@babel/runtime": "7.7.4", 40 | "@types/react": "16.9.13", 41 | "@types/react-native": "0.60.23", 42 | "react": "16.12.0", 43 | "react-native": "0.61.5", 44 | "typescript": "3.7.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/@types/extensions.d.ts: -------------------------------------------------------------------------------- 1 | import * as rn from "react-native"; 2 | 3 | declare module "react-native" { 4 | class VirtualizedList extends React.Component< 5 | VirtualizedListProps 6 | > {} 7 | } 8 | -------------------------------------------------------------------------------- /src/@types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { ImageURISource, ImageRequireSource } from "react-native"; 10 | 11 | export type Dimensions = { 12 | width: number; 13 | height: number; 14 | }; 15 | 16 | export type Position = { 17 | x: number; 18 | y: number; 19 | }; 20 | 21 | export type ImageSource = ImageURISource | ImageRequireSource; 22 | -------------------------------------------------------------------------------- /src/ImageViewing.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React, { ComponentType, useCallback, useRef, useEffect } from "react"; 10 | import { 11 | Animated, 12 | Dimensions, 13 | StyleSheet, 14 | View, 15 | VirtualizedList, 16 | ModalProps, 17 | Modal, 18 | } from "react-native"; 19 | 20 | import ImageItem from "./components/ImageItem/ImageItem"; 21 | import ImageDefaultHeader from "./components/ImageDefaultHeader"; 22 | import StatusBarManager from "./components/StatusBarManager"; 23 | 24 | import useAnimatedComponents from "./hooks/useAnimatedComponents"; 25 | import useImageIndexChange from "./hooks/useImageIndexChange"; 26 | import useRequestClose from "./hooks/useRequestClose"; 27 | import { ImageSource } from "./@types"; 28 | 29 | type Props = { 30 | images: ImageSource[]; 31 | keyExtractor?: (imageSrc: ImageSource, index: number) => string; 32 | imageIndex: number; 33 | visible: boolean; 34 | onRequestClose: () => void; 35 | onLongPress?: (image: ImageSource) => void; 36 | onImageIndexChange?: (imageIndex: number) => void; 37 | presentationStyle?: ModalProps["presentationStyle"]; 38 | animationType?: ModalProps["animationType"]; 39 | backgroundColor?: string; 40 | swipeToCloseEnabled?: boolean; 41 | doubleTapToZoomEnabled?: boolean; 42 | delayLongPress?: number; 43 | HeaderComponent?: ComponentType<{ imageIndex: number }>; 44 | FooterComponent?: ComponentType<{ imageIndex: number }>; 45 | }; 46 | 47 | const DEFAULT_ANIMATION_TYPE = "fade"; 48 | const DEFAULT_BG_COLOR = "#000"; 49 | const DEFAULT_DELAY_LONG_PRESS = 800; 50 | const SCREEN = Dimensions.get("screen"); 51 | const SCREEN_WIDTH = SCREEN.width; 52 | 53 | function ImageViewing({ 54 | images, 55 | keyExtractor, 56 | imageIndex, 57 | visible, 58 | onRequestClose, 59 | onLongPress = () => {}, 60 | onImageIndexChange, 61 | animationType = DEFAULT_ANIMATION_TYPE, 62 | backgroundColor = DEFAULT_BG_COLOR, 63 | presentationStyle, 64 | swipeToCloseEnabled, 65 | doubleTapToZoomEnabled, 66 | delayLongPress = DEFAULT_DELAY_LONG_PRESS, 67 | HeaderComponent, 68 | FooterComponent, 69 | }: Props) { 70 | const imageList = useRef>(null); 71 | const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose); 72 | const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN); 73 | const [headerTransform, footerTransform, toggleBarsVisible] = 74 | useAnimatedComponents(); 75 | 76 | useEffect(() => { 77 | if (onImageIndexChange) { 78 | onImageIndexChange(currentImageIndex); 79 | } 80 | }, [currentImageIndex]); 81 | 82 | const onZoom = useCallback( 83 | (isScaled: boolean) => { 84 | // @ts-ignore 85 | imageList?.current?.setNativeProps({ scrollEnabled: !isScaled }); 86 | toggleBarsVisible(!isScaled); 87 | }, 88 | [imageList] 89 | ); 90 | 91 | if (!visible) { 92 | return null; 93 | } 94 | 95 | return ( 96 | 105 | 106 | 107 | 108 | {typeof HeaderComponent !== "undefined" ? ( 109 | React.createElement(HeaderComponent, { 110 | imageIndex: currentImageIndex, 111 | }) 112 | ) : ( 113 | 114 | )} 115 | 116 | images[index]} 128 | getItemCount={() => images.length} 129 | getItemLayout={(_, index) => ({ 130 | length: SCREEN_WIDTH, 131 | offset: SCREEN_WIDTH * index, 132 | index, 133 | })} 134 | renderItem={({ item: imageSrc }) => ( 135 | 144 | )} 145 | onMomentumScrollEnd={onScroll} 146 | //@ts-ignore 147 | keyExtractor={(imageSrc, index) => 148 | keyExtractor 149 | ? keyExtractor(imageSrc, index) 150 | : typeof imageSrc === "number" 151 | ? `${imageSrc}` 152 | : imageSrc.uri 153 | } 154 | /> 155 | {typeof FooterComponent !== "undefined" && ( 156 | 159 | {React.createElement(FooterComponent, { 160 | imageIndex: currentImageIndex, 161 | })} 162 | 163 | )} 164 | 165 | 166 | ); 167 | } 168 | 169 | const styles = StyleSheet.create({ 170 | container: { 171 | flex: 1, 172 | backgroundColor: "#000", 173 | }, 174 | header: { 175 | position: "absolute", 176 | width: "100%", 177 | zIndex: 1, 178 | top: 0, 179 | }, 180 | footer: { 181 | position: "absolute", 182 | width: "100%", 183 | zIndex: 1, 184 | bottom: 0, 185 | }, 186 | }); 187 | 188 | const EnhancedImageViewing = (props: Props) => ( 189 | 190 | ); 191 | 192 | export default EnhancedImageViewing; 193 | -------------------------------------------------------------------------------- /src/components/ImageDefaultHeader.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | import { SafeAreaView, Text, TouchableOpacity, StyleSheet } from "react-native"; 11 | 12 | type Props = { 13 | onRequestClose: () => void; 14 | }; 15 | 16 | const HIT_SLOP = { top: 16, left: 16, bottom: 16, right: 16 }; 17 | 18 | const ImageDefaultHeader = ({ onRequestClose }: Props) => ( 19 | 20 | 25 | 26 | 27 | 28 | ); 29 | 30 | const styles = StyleSheet.create({ 31 | root: { 32 | alignItems: "flex-end", 33 | }, 34 | closeButton: { 35 | marginRight: 8, 36 | marginTop: 8, 37 | width: 44, 38 | height: 44, 39 | alignItems: "center", 40 | justifyContent: "center", 41 | borderRadius: 22, 42 | backgroundColor: "#00000077", 43 | }, 44 | closeText: { 45 | lineHeight: 22, 46 | fontSize: 19, 47 | textAlign: "center", 48 | color: "#FFF", 49 | includeFontPadding: false, 50 | }, 51 | }); 52 | 53 | export default ImageDefaultHeader; 54 | -------------------------------------------------------------------------------- /src/components/ImageItem/ImageItem.android.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React, { useCallback, useRef, useState } from "react"; 10 | 11 | import { 12 | Animated, 13 | ScrollView, 14 | Dimensions, 15 | StyleSheet, 16 | NativeScrollEvent, 17 | NativeSyntheticEvent, 18 | NativeMethodsMixin, 19 | } from "react-native"; 20 | 21 | import useImageDimensions from "../../hooks/useImageDimensions"; 22 | import usePanResponder from "../../hooks/usePanResponder"; 23 | 24 | import { getImageStyles, getImageTransform } from "../../utils"; 25 | import { ImageSource } from "../../@types"; 26 | import { ImageLoading } from "./ImageLoading"; 27 | 28 | const SWIPE_CLOSE_OFFSET = 75; 29 | const SWIPE_CLOSE_VELOCITY = 1.75; 30 | const SCREEN = Dimensions.get("window"); 31 | const SCREEN_WIDTH = SCREEN.width; 32 | const SCREEN_HEIGHT = SCREEN.height; 33 | 34 | type Props = { 35 | imageSrc: ImageSource; 36 | onRequestClose: () => void; 37 | onZoom: (isZoomed: boolean) => void; 38 | onLongPress: (image: ImageSource) => void; 39 | delayLongPress: number; 40 | swipeToCloseEnabled?: boolean; 41 | doubleTapToZoomEnabled?: boolean; 42 | }; 43 | 44 | const ImageItem = ({ 45 | imageSrc, 46 | onZoom, 47 | onRequestClose, 48 | onLongPress, 49 | delayLongPress, 50 | swipeToCloseEnabled = true, 51 | doubleTapToZoomEnabled = true, 52 | }: Props) => { 53 | const imageContainer = useRef(null); 54 | const imageDimensions = useImageDimensions(imageSrc); 55 | const [translate, scale] = getImageTransform(imageDimensions, SCREEN); 56 | const scrollValueY = new Animated.Value(0); 57 | const [isLoaded, setLoadEnd] = useState(false); 58 | 59 | const onLoaded = useCallback(() => setLoadEnd(true), []); 60 | const onZoomPerformed = useCallback( 61 | (isZoomed: boolean) => { 62 | onZoom(isZoomed); 63 | if (imageContainer?.current) { 64 | imageContainer.current.setNativeProps({ 65 | scrollEnabled: !isZoomed, 66 | }); 67 | } 68 | }, 69 | [imageContainer] 70 | ); 71 | 72 | const onLongPressHandler = useCallback(() => { 73 | onLongPress(imageSrc); 74 | }, [imageSrc, onLongPress]); 75 | 76 | const [panHandlers, scaleValue, translateValue] = usePanResponder({ 77 | initialScale: scale || 1, 78 | initialTranslate: translate || { x: 0, y: 0 }, 79 | onZoom: onZoomPerformed, 80 | doubleTapToZoomEnabled, 81 | onLongPress: onLongPressHandler, 82 | delayLongPress, 83 | }); 84 | 85 | const imagesStyles = getImageStyles( 86 | imageDimensions, 87 | translateValue, 88 | scaleValue 89 | ); 90 | const imageOpacity = scrollValueY.interpolate({ 91 | inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 92 | outputRange: [0.7, 1, 0.7], 93 | }); 94 | const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity }; 95 | 96 | const onScrollEndDrag = ({ 97 | nativeEvent, 98 | }: NativeSyntheticEvent) => { 99 | const velocityY = nativeEvent?.velocity?.y ?? 0; 100 | const offsetY = nativeEvent?.contentOffset?.y ?? 0; 101 | 102 | if ( 103 | (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && 104 | offsetY > SWIPE_CLOSE_OFFSET) || 105 | offsetY > SCREEN_HEIGHT / 2 106 | ) { 107 | onRequestClose(); 108 | } 109 | }; 110 | 111 | const onScroll = ({ 112 | nativeEvent, 113 | }: NativeSyntheticEvent) => { 114 | const offsetY = nativeEvent?.contentOffset?.y ?? 0; 115 | 116 | scrollValueY.setValue(offsetY); 117 | }; 118 | 119 | return ( 120 | 134 | 140 | {(!isLoaded || !imageDimensions) && } 141 | 142 | ); 143 | }; 144 | 145 | const styles = StyleSheet.create({ 146 | listItem: { 147 | width: SCREEN_WIDTH, 148 | height: SCREEN_HEIGHT, 149 | }, 150 | imageScrollContainer: { 151 | height: SCREEN_HEIGHT * 2, 152 | }, 153 | }); 154 | 155 | export default React.memo(ImageItem); 156 | -------------------------------------------------------------------------------- /src/components/ImageItem/ImageItem.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | import { GestureResponderEvent } from "react-native"; 11 | import { ImageSource } from "../../@types"; 12 | 13 | declare type Props = { 14 | imageSrc: ImageSource; 15 | onRequestClose: () => void; 16 | onZoom: (isZoomed: boolean) => void; 17 | onLongPress: (image: ImageSource) => void; 18 | delayLongPress: number; 19 | swipeToCloseEnabled?: boolean; 20 | doubleTapToZoomEnabled?: boolean; 21 | }; 22 | 23 | declare const _default: React.MemoExoticComponent<({ 24 | imageSrc, 25 | onZoom, 26 | onRequestClose, 27 | onLongPress, 28 | delayLongPress, 29 | swipeToCloseEnabled, 30 | }: Props) => JSX.Element>; 31 | 32 | export default _default; 33 | -------------------------------------------------------------------------------- /src/components/ImageItem/ImageItem.ios.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React, { useCallback, useRef, useState } from "react"; 10 | 11 | import { 12 | Animated, 13 | Dimensions, 14 | ScrollView, 15 | StyleSheet, 16 | View, 17 | NativeScrollEvent, 18 | NativeSyntheticEvent, 19 | TouchableWithoutFeedback, 20 | GestureResponderEvent, 21 | } from "react-native"; 22 | 23 | import useDoubleTapToZoom from "../../hooks/useDoubleTapToZoom"; 24 | import useImageDimensions from "../../hooks/useImageDimensions"; 25 | 26 | import { getImageStyles, getImageTransform } from "../../utils"; 27 | import { ImageSource } from "../../@types"; 28 | import { ImageLoading } from "./ImageLoading"; 29 | 30 | const SWIPE_CLOSE_OFFSET = 75; 31 | const SWIPE_CLOSE_VELOCITY = 1.55; 32 | const SCREEN = Dimensions.get("screen"); 33 | const SCREEN_WIDTH = SCREEN.width; 34 | const SCREEN_HEIGHT = SCREEN.height; 35 | 36 | type Props = { 37 | imageSrc: ImageSource; 38 | onRequestClose: () => void; 39 | onZoom: (scaled: boolean) => void; 40 | onLongPress: (image: ImageSource) => void; 41 | delayLongPress: number; 42 | swipeToCloseEnabled?: boolean; 43 | doubleTapToZoomEnabled?: boolean; 44 | }; 45 | 46 | const ImageItem = ({ 47 | imageSrc, 48 | onZoom, 49 | onRequestClose, 50 | onLongPress, 51 | delayLongPress, 52 | swipeToCloseEnabled = true, 53 | doubleTapToZoomEnabled = true, 54 | }: Props) => { 55 | const scrollViewRef = useRef(null); 56 | const [loaded, setLoaded] = useState(false); 57 | const [scaled, setScaled] = useState(false); 58 | const imageDimensions = useImageDimensions(imageSrc); 59 | const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN); 60 | 61 | const [translate, scale] = getImageTransform(imageDimensions, SCREEN); 62 | const scrollValueY = new Animated.Value(0); 63 | const scaleValue = new Animated.Value(scale || 1); 64 | const translateValue = new Animated.ValueXY(translate); 65 | const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1; 66 | 67 | const imageOpacity = scrollValueY.interpolate({ 68 | inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 69 | outputRange: [0.5, 1, 0.5], 70 | }); 71 | const imagesStyles = getImageStyles( 72 | imageDimensions, 73 | translateValue, 74 | scaleValue 75 | ); 76 | const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity }; 77 | 78 | const onScrollEndDrag = useCallback( 79 | ({ nativeEvent }: NativeSyntheticEvent) => { 80 | const velocityY = nativeEvent?.velocity?.y ?? 0; 81 | const scaled = nativeEvent?.zoomScale > 1; 82 | 83 | onZoom(scaled); 84 | setScaled(scaled); 85 | 86 | if ( 87 | !scaled && 88 | swipeToCloseEnabled && 89 | Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY 90 | ) { 91 | onRequestClose(); 92 | } 93 | }, 94 | [scaled] 95 | ); 96 | 97 | const onScroll = ({ 98 | nativeEvent, 99 | }: NativeSyntheticEvent) => { 100 | const offsetY = nativeEvent?.contentOffset?.y ?? 0; 101 | 102 | if (nativeEvent?.zoomScale > 1) { 103 | return; 104 | } 105 | 106 | scrollValueY.setValue(offsetY); 107 | }; 108 | 109 | const onLongPressHandler = useCallback( 110 | (event: GestureResponderEvent) => { 111 | onLongPress(imageSrc); 112 | }, 113 | [imageSrc, onLongPress] 114 | ); 115 | 116 | return ( 117 | 118 | 133 | {(!loaded || !imageDimensions) && } 134 | 139 | setLoaded(true)} 143 | /> 144 | 145 | 146 | 147 | ); 148 | }; 149 | 150 | const styles = StyleSheet.create({ 151 | listItem: { 152 | width: SCREEN_WIDTH, 153 | height: SCREEN_HEIGHT, 154 | }, 155 | imageScrollContainer: { 156 | height: SCREEN_HEIGHT, 157 | }, 158 | }); 159 | 160 | export default React.memo(ImageItem); 161 | -------------------------------------------------------------------------------- /src/components/ImageItem/ImageLoading.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React from "react"; 10 | 11 | import { ActivityIndicator, Dimensions, StyleSheet, View } from "react-native"; 12 | 13 | const SCREEN = Dimensions.get("screen"); 14 | const SCREEN_WIDTH = SCREEN.width; 15 | const SCREEN_HEIGHT = SCREEN.height; 16 | 17 | export const ImageLoading = () => ( 18 | 19 | 20 | 21 | ); 22 | 23 | const styles = StyleSheet.create({ 24 | listItem: { 25 | width: SCREEN_WIDTH, 26 | height: SCREEN_HEIGHT, 27 | }, 28 | loading: { 29 | width: SCREEN_WIDTH, 30 | height: SCREEN_HEIGHT, 31 | alignItems: "center", 32 | justifyContent: "center", 33 | }, 34 | imageScrollContainer: { 35 | height: SCREEN_HEIGHT, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/StatusBarManager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { 3 | Platform, 4 | ModalProps, 5 | StatusBar, 6 | } from "react-native"; 7 | 8 | const StatusBarManager = ({ 9 | presentationStyle, 10 | }: { 11 | presentationStyle?: ModalProps["presentationStyle"]; 12 | }) => { 13 | if (Platform.OS === "ios" || presentationStyle !== "overFullScreen") { 14 | return null; 15 | } 16 | 17 | //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. 18 | StatusBar.setHidden(true); 19 | 20 | useEffect(() => { 21 | return () => StatusBar.setHidden(false); 22 | }, []); 23 | 24 | return null; 25 | }; 26 | 27 | export default StatusBarManager; 28 | -------------------------------------------------------------------------------- /src/hooks/useAnimatedComponents.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { Animated } from "react-native"; 10 | 11 | const INITIAL_POSITION = { x: 0, y: 0 }; 12 | const ANIMATION_CONFIG = { 13 | duration: 200, 14 | useNativeDriver: true, 15 | }; 16 | 17 | const useAnimatedComponents = () => { 18 | const headerTranslate = new Animated.ValueXY(INITIAL_POSITION); 19 | const footerTranslate = new Animated.ValueXY(INITIAL_POSITION); 20 | 21 | const toggleVisible = (isVisible: boolean) => { 22 | if (isVisible) { 23 | Animated.parallel([ 24 | Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }), 25 | Animated.timing(footerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }), 26 | ]).start(); 27 | } else { 28 | Animated.parallel([ 29 | Animated.timing(headerTranslate.y, { 30 | ...ANIMATION_CONFIG, 31 | toValue: -300, 32 | }), 33 | Animated.timing(footerTranslate.y, { 34 | ...ANIMATION_CONFIG, 35 | toValue: 300, 36 | }), 37 | ]).start(); 38 | } 39 | }; 40 | 41 | const headerTransform = headerTranslate.getTranslateTransform(); 42 | const footerTransform = footerTranslate.getTranslateTransform(); 43 | 44 | return [headerTransform, footerTransform, toggleVisible] as const; 45 | }; 46 | 47 | export default useAnimatedComponents; 48 | -------------------------------------------------------------------------------- /src/hooks/useDoubleTapToZoom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import React, { useCallback } from "react"; 10 | import { 11 | ScrollView, 12 | NativeTouchEvent, 13 | NativeSyntheticEvent, 14 | } from "react-native"; 15 | 16 | import { Dimensions } from "../@types"; 17 | 18 | const DOUBLE_TAP_DELAY = 300; 19 | let lastTapTS: number | null = null; 20 | 21 | /** 22 | * This is iOS only. 23 | * Same functionality for Android implemented inside usePanResponder hook. 24 | */ 25 | function useDoubleTapToZoom( 26 | scrollViewRef: React.RefObject, 27 | scaled: boolean, 28 | screen: Dimensions 29 | ) { 30 | const handleDoubleTap = useCallback( 31 | (event: NativeSyntheticEvent) => { 32 | const nowTS = new Date().getTime(); 33 | const scrollResponderRef = scrollViewRef?.current?.getScrollResponder(); 34 | 35 | if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { 36 | const { pageX, pageY } = event.nativeEvent; 37 | let targetX = 0; 38 | let targetY = 0; 39 | let targetWidth = screen.width; 40 | let targetHeight = screen.height; 41 | 42 | // Zooming in 43 | // TODO: Add more precise calculation of targetX, targetY based on touch 44 | if (!scaled) { 45 | targetX = pageX / 2; 46 | targetY = pageY / 2; 47 | targetWidth = screen.width / 2; 48 | targetHeight = screen.height / 2; 49 | } 50 | 51 | // @ts-ignore 52 | scrollResponderRef?.scrollResponderZoomTo({ 53 | x: targetX, 54 | y: targetY, 55 | width: targetWidth, 56 | height: targetHeight, 57 | animated: true, 58 | }); 59 | } else { 60 | lastTapTS = nowTS; 61 | } 62 | }, 63 | [scaled] 64 | ); 65 | 66 | return handleDoubleTap; 67 | } 68 | 69 | export default useDoubleTapToZoom; 70 | -------------------------------------------------------------------------------- /src/hooks/useImageDimensions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useEffect, useState } from "react"; 10 | import { Image, ImageURISource } from "react-native"; 11 | 12 | import { createCache } from "../utils"; 13 | import { Dimensions, ImageSource } from "../@types"; 14 | 15 | const CACHE_SIZE = 50; 16 | const imageDimensionsCache = createCache(CACHE_SIZE); 17 | 18 | const useImageDimensions = (image: ImageSource): Dimensions | null => { 19 | const [dimensions, setDimensions] = useState(null); 20 | 21 | const getImageDimensions = (image: ImageSource): Promise => { 22 | return new Promise((resolve) => { 23 | if (typeof image == "number") { 24 | const cacheKey = `${image}`; 25 | let imageDimensions = imageDimensionsCache.get(cacheKey); 26 | 27 | if (!imageDimensions) { 28 | const { width, height } = Image.resolveAssetSource(image); 29 | imageDimensions = { width, height }; 30 | imageDimensionsCache.set(cacheKey, imageDimensions); 31 | } 32 | 33 | resolve(imageDimensions); 34 | 35 | return; 36 | } 37 | 38 | // @ts-ignore 39 | if (image.uri) { 40 | const source = image as ImageURISource; 41 | 42 | const cacheKey = source.uri as string; 43 | 44 | const imageDimensions = imageDimensionsCache.get(cacheKey); 45 | 46 | if (imageDimensions) { 47 | resolve(imageDimensions); 48 | } else { 49 | // @ts-ignore 50 | Image.getSizeWithHeaders( 51 | source.uri, 52 | source.headers, 53 | (width: number, height: number) => { 54 | imageDimensionsCache.set(cacheKey, { width, height }); 55 | resolve({ width, height }); 56 | }, 57 | () => { 58 | resolve({ width: 0, height: 0 }); 59 | } 60 | ); 61 | } 62 | } else { 63 | resolve({ width: 0, height: 0 }); 64 | } 65 | }); 66 | }; 67 | 68 | let isImageUnmounted = false; 69 | 70 | useEffect(() => { 71 | getImageDimensions(image).then((dimensions) => { 72 | if (!isImageUnmounted) { 73 | setDimensions(dimensions); 74 | } 75 | }); 76 | 77 | return () => { 78 | isImageUnmounted = true; 79 | }; 80 | }, [image]); 81 | 82 | return dimensions; 83 | }; 84 | 85 | export default useImageDimensions; 86 | -------------------------------------------------------------------------------- /src/hooks/useImageIndexChange.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useState } from "react"; 10 | import { NativeSyntheticEvent, NativeScrollEvent } from "react-native"; 11 | 12 | import { Dimensions } from "../@types"; 13 | 14 | const useImageIndexChange = (imageIndex: number, screen: Dimensions) => { 15 | const [currentImageIndex, setImageIndex] = useState(imageIndex); 16 | const onScroll = (event: NativeSyntheticEvent) => { 17 | const { 18 | nativeEvent: { 19 | contentOffset: { x: scrollX }, 20 | }, 21 | } = event; 22 | 23 | if (screen.width) { 24 | const nextIndex = Math.round(scrollX / screen.width); 25 | setImageIndex(nextIndex < 0 ? 0 : nextIndex); 26 | } 27 | }; 28 | 29 | return [currentImageIndex, onScroll] as const; 30 | }; 31 | 32 | export default useImageIndexChange; 33 | -------------------------------------------------------------------------------- /src/hooks/useImagePrefetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useEffect } from "react"; 10 | import { Image } from "react-native"; 11 | import { ImageSource } from "../@types"; 12 | 13 | const useImagePrefetch = (images: ImageSource[]) => { 14 | useEffect(() => { 15 | images.forEach((image) => { 16 | //@ts-ignore 17 | if (image.uri) { 18 | //@ts-ignore 19 | return Image.prefetch(image.uri); 20 | } 21 | }); 22 | }, []); 23 | }; 24 | 25 | export default useImagePrefetch; 26 | -------------------------------------------------------------------------------- /src/hooks/usePanResponder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useMemo, useEffect, useRef } from "react"; 10 | import { 11 | Animated, 12 | Dimensions, 13 | GestureResponderEvent, 14 | GestureResponderHandlers, 15 | NativeTouchEvent, 16 | PanResponderGestureState, 17 | } from "react-native"; 18 | 19 | import { Position } from "../@types"; 20 | import { 21 | createPanResponder, 22 | getDistanceBetweenTouches, 23 | getImageTranslate, 24 | getImageDimensionsByTranslate, 25 | } from "../utils"; 26 | 27 | const SCREEN = Dimensions.get("window"); 28 | const SCREEN_WIDTH = SCREEN.width; 29 | const SCREEN_HEIGHT = SCREEN.height; 30 | const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT); 31 | 32 | const SCALE_MAX = 2; 33 | const DOUBLE_TAP_DELAY = 300; 34 | const OUT_BOUND_MULTIPLIER = 0.75; 35 | 36 | type Props = { 37 | initialScale: number; 38 | initialTranslate: Position; 39 | onZoom: (isZoomed: boolean) => void; 40 | doubleTapToZoomEnabled: boolean; 41 | onLongPress: () => void; 42 | delayLongPress: number; 43 | }; 44 | 45 | const usePanResponder = ({ 46 | initialScale, 47 | initialTranslate, 48 | onZoom, 49 | doubleTapToZoomEnabled, 50 | onLongPress, 51 | delayLongPress, 52 | }: Props): Readonly< 53 | [GestureResponderHandlers, Animated.Value, Animated.ValueXY] 54 | > => { 55 | let numberInitialTouches = 1; 56 | let initialTouches: NativeTouchEvent[] = []; 57 | let currentScale = initialScale; 58 | let currentTranslate = initialTranslate; 59 | let tmpScale = 0; 60 | let tmpTranslate: Position | null = null; 61 | let isDoubleTapPerformed = false; 62 | let lastTapTS: number | null = null; 63 | let longPressHandlerRef: number | null = null; 64 | 65 | const meaningfulShift = MIN_DIMENSION * 0.01; 66 | const scaleValue = new Animated.Value(initialScale); 67 | const translateValue = new Animated.ValueXY(initialTranslate); 68 | 69 | const imageDimensions = getImageDimensionsByTranslate( 70 | initialTranslate, 71 | SCREEN 72 | ); 73 | 74 | const getBounds = (scale: number) => { 75 | const scaledImageDimensions = { 76 | width: imageDimensions.width * scale, 77 | height: imageDimensions.height * scale, 78 | }; 79 | const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN); 80 | 81 | const left = initialTranslate.x - translateDelta.x; 82 | const right = left - (scaledImageDimensions.width - SCREEN.width); 83 | const top = initialTranslate.y - translateDelta.y; 84 | const bottom = top - (scaledImageDimensions.height - SCREEN.height); 85 | 86 | return [top, left, bottom, right]; 87 | }; 88 | 89 | const getTranslateInBounds = (translate: Position, scale: number) => { 90 | const inBoundTranslate = { x: translate.x, y: translate.y }; 91 | const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale); 92 | 93 | if (translate.x > leftBound) { 94 | inBoundTranslate.x = leftBound; 95 | } else if (translate.x < rightBound) { 96 | inBoundTranslate.x = rightBound; 97 | } 98 | 99 | if (translate.y > topBound) { 100 | inBoundTranslate.y = topBound; 101 | } else if (translate.y < bottomBound) { 102 | inBoundTranslate.y = bottomBound; 103 | } 104 | 105 | return inBoundTranslate; 106 | }; 107 | 108 | const fitsScreenByWidth = () => 109 | imageDimensions.width * currentScale < SCREEN_WIDTH; 110 | const fitsScreenByHeight = () => 111 | imageDimensions.height * currentScale < SCREEN_HEIGHT; 112 | 113 | useEffect(() => { 114 | scaleValue.addListener(({ value }) => { 115 | if (typeof onZoom === "function") { 116 | onZoom(value !== initialScale); 117 | } 118 | }); 119 | 120 | return () => scaleValue.removeAllListeners(); 121 | }); 122 | 123 | const cancelLongPressHandle = () => { 124 | longPressHandlerRef && clearTimeout(longPressHandlerRef); 125 | }; 126 | 127 | const handlers = { 128 | onGrant: ( 129 | _: GestureResponderEvent, 130 | gestureState: PanResponderGestureState 131 | ) => { 132 | numberInitialTouches = gestureState.numberActiveTouches; 133 | 134 | if (gestureState.numberActiveTouches > 1) return; 135 | 136 | longPressHandlerRef = setTimeout(onLongPress, delayLongPress); 137 | }, 138 | onStart: ( 139 | event: GestureResponderEvent, 140 | gestureState: PanResponderGestureState 141 | ) => { 142 | initialTouches = event.nativeEvent.touches; 143 | numberInitialTouches = gestureState.numberActiveTouches; 144 | 145 | if (gestureState.numberActiveTouches > 1) return; 146 | 147 | const tapTS = Date.now(); 148 | // Handle double tap event by calculating diff between first and second taps timestamps 149 | 150 | isDoubleTapPerformed = Boolean( 151 | lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY 152 | ); 153 | 154 | if (doubleTapToZoomEnabled && isDoubleTapPerformed) { 155 | const isScaled = currentTranslate.x !== initialTranslate.x; // currentScale !== initialScale; 156 | const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]; 157 | const targetScale = SCALE_MAX; 158 | const nextScale = isScaled ? initialScale : targetScale; 159 | const nextTranslate = isScaled 160 | ? initialTranslate 161 | : getTranslateInBounds( 162 | { 163 | x: 164 | initialTranslate.x + 165 | (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), 166 | y: 167 | initialTranslate.y + 168 | (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), 169 | }, 170 | targetScale 171 | ); 172 | 173 | onZoom(!isScaled); 174 | 175 | Animated.parallel( 176 | [ 177 | Animated.timing(translateValue.x, { 178 | toValue: nextTranslate.x, 179 | duration: 300, 180 | useNativeDriver: true, 181 | }), 182 | Animated.timing(translateValue.y, { 183 | toValue: nextTranslate.y, 184 | duration: 300, 185 | useNativeDriver: true, 186 | }), 187 | Animated.timing(scaleValue, { 188 | toValue: nextScale, 189 | duration: 300, 190 | useNativeDriver: true, 191 | }), 192 | ], 193 | { stopTogether: false } 194 | ).start(() => { 195 | currentScale = nextScale; 196 | currentTranslate = nextTranslate; 197 | }); 198 | 199 | lastTapTS = null; 200 | } else { 201 | lastTapTS = Date.now(); 202 | } 203 | }, 204 | onMove: ( 205 | event: GestureResponderEvent, 206 | gestureState: PanResponderGestureState 207 | ) => { 208 | const { dx, dy } = gestureState; 209 | 210 | if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { 211 | cancelLongPressHandle(); 212 | } 213 | 214 | // Don't need to handle move because double tap in progress (was handled in onStart) 215 | if (doubleTapToZoomEnabled && isDoubleTapPerformed) { 216 | cancelLongPressHandle(); 217 | return; 218 | } 219 | 220 | if ( 221 | numberInitialTouches === 1 && 222 | gestureState.numberActiveTouches === 2 223 | ) { 224 | numberInitialTouches = 2; 225 | initialTouches = event.nativeEvent.touches; 226 | } 227 | 228 | const isTapGesture = 229 | numberInitialTouches == 1 && gestureState.numberActiveTouches === 1; 230 | const isPinchGesture = 231 | numberInitialTouches === 2 && gestureState.numberActiveTouches === 2; 232 | 233 | if (isPinchGesture) { 234 | cancelLongPressHandle(); 235 | 236 | const initialDistance = getDistanceBetweenTouches(initialTouches); 237 | const currentDistance = getDistanceBetweenTouches( 238 | event.nativeEvent.touches 239 | ); 240 | 241 | let nextScale = (currentDistance / initialDistance) * currentScale; 242 | 243 | /** 244 | * In case image is scaling smaller than initial size -> 245 | * slow down this transition by applying OUT_BOUND_MULTIPLIER 246 | */ 247 | if (nextScale < initialScale) { 248 | nextScale = 249 | nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER; 250 | } 251 | 252 | /** 253 | * In case image is scaling down -> move it in direction of initial position 254 | */ 255 | if (currentScale > initialScale && currentScale > nextScale) { 256 | const k = (currentScale - initialScale) / (currentScale - nextScale); 257 | 258 | const nextTranslateX = 259 | nextScale < initialScale 260 | ? initialTranslate.x 261 | : currentTranslate.x - 262 | (currentTranslate.x - initialTranslate.x) / k; 263 | 264 | const nextTranslateY = 265 | nextScale < initialScale 266 | ? initialTranslate.y 267 | : currentTranslate.y - 268 | (currentTranslate.y - initialTranslate.y) / k; 269 | 270 | translateValue.x.setValue(nextTranslateX); 271 | translateValue.y.setValue(nextTranslateY); 272 | 273 | tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; 274 | } 275 | 276 | scaleValue.setValue(nextScale); 277 | tmpScale = nextScale; 278 | } 279 | 280 | if (isTapGesture && currentScale > initialScale) { 281 | const { x, y } = currentTranslate; 282 | const { dx, dy } = gestureState; 283 | const [topBound, leftBound, bottomBound, rightBound] = getBounds( 284 | currentScale 285 | ); 286 | 287 | let nextTranslateX = x + dx; 288 | let nextTranslateY = y + dy; 289 | 290 | if (nextTranslateX > leftBound) { 291 | nextTranslateX = 292 | nextTranslateX - 293 | (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER; 294 | } 295 | 296 | if (nextTranslateX < rightBound) { 297 | nextTranslateX = 298 | nextTranslateX - 299 | (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER; 300 | } 301 | 302 | if (nextTranslateY > topBound) { 303 | nextTranslateY = 304 | nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER; 305 | } 306 | 307 | if (nextTranslateY < bottomBound) { 308 | nextTranslateY = 309 | nextTranslateY - 310 | (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER; 311 | } 312 | 313 | if (fitsScreenByWidth()) { 314 | nextTranslateX = x; 315 | } 316 | 317 | if (fitsScreenByHeight()) { 318 | nextTranslateY = y; 319 | } 320 | 321 | translateValue.x.setValue(nextTranslateX); 322 | translateValue.y.setValue(nextTranslateY); 323 | 324 | tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; 325 | } 326 | }, 327 | onRelease: () => { 328 | cancelLongPressHandle(); 329 | 330 | if (isDoubleTapPerformed) { 331 | isDoubleTapPerformed = false; 332 | } 333 | 334 | if (tmpScale > 0) { 335 | if (tmpScale < initialScale || tmpScale > SCALE_MAX) { 336 | tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX; 337 | Animated.timing(scaleValue, { 338 | toValue: tmpScale, 339 | duration: 100, 340 | useNativeDriver: true, 341 | }).start(); 342 | } 343 | 344 | currentScale = tmpScale; 345 | tmpScale = 0; 346 | } 347 | 348 | if (tmpTranslate) { 349 | const { x, y } = tmpTranslate; 350 | const [topBound, leftBound, bottomBound, rightBound] = getBounds( 351 | currentScale 352 | ); 353 | 354 | let nextTranslateX = x; 355 | let nextTranslateY = y; 356 | 357 | if (!fitsScreenByWidth()) { 358 | if (nextTranslateX > leftBound) { 359 | nextTranslateX = leftBound; 360 | } else if (nextTranslateX < rightBound) { 361 | nextTranslateX = rightBound; 362 | } 363 | } 364 | 365 | if (!fitsScreenByHeight()) { 366 | if (nextTranslateY > topBound) { 367 | nextTranslateY = topBound; 368 | } else if (nextTranslateY < bottomBound) { 369 | nextTranslateY = bottomBound; 370 | } 371 | } 372 | 373 | Animated.parallel([ 374 | Animated.timing(translateValue.x, { 375 | toValue: nextTranslateX, 376 | duration: 100, 377 | useNativeDriver: true, 378 | }), 379 | Animated.timing(translateValue.y, { 380 | toValue: nextTranslateY, 381 | duration: 100, 382 | useNativeDriver: true, 383 | }), 384 | ]).start(); 385 | 386 | currentTranslate = { x: nextTranslateX, y: nextTranslateY }; 387 | tmpTranslate = null; 388 | } 389 | }, 390 | }; 391 | 392 | const panResponder = useMemo(() => createPanResponder(handlers), [handlers]); 393 | 394 | return [panResponder.panHandlers, scaleValue, translateValue]; 395 | }; 396 | 397 | export default usePanResponder; 398 | -------------------------------------------------------------------------------- /src/hooks/useRequestClose.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useState } from "react"; 10 | 11 | const useRequestClose = (onRequestClose: () => void) => { 12 | const [opacity, setOpacity] = useState(1); 13 | 14 | return [ 15 | opacity, 16 | () => { 17 | setOpacity(0); 18 | onRequestClose(); 19 | setTimeout(() => setOpacity(1), 0); 20 | }, 21 | ] as const; 22 | }; 23 | 24 | export default useRequestClose; 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export { default } from "./ImageViewing"; 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) JOB TODAY S.A. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { 10 | Animated, 11 | GestureResponderEvent, 12 | PanResponder, 13 | PanResponderGestureState, 14 | PanResponderInstance, 15 | NativeTouchEvent, 16 | } from "react-native"; 17 | import { Dimensions, Position } from "./@types"; 18 | 19 | type CacheStorageItem = { key: string; value: any }; 20 | 21 | export const createCache = (cacheSize: number) => ({ 22 | _storage: [] as CacheStorageItem[], 23 | get(key: string): any { 24 | const { value } = 25 | this._storage.find(({ key: storageKey }) => storageKey === key) || {}; 26 | 27 | return value; 28 | }, 29 | set(key: string, value: any) { 30 | if (this._storage.length >= cacheSize) { 31 | this._storage.shift(); 32 | } 33 | 34 | this._storage.push({ key, value }); 35 | }, 36 | }); 37 | 38 | export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] => 39 | arr.reduce((result, item) => { 40 | const batch = result.pop() || []; 41 | 42 | if (batch.length < batchSize) { 43 | batch.push(item); 44 | result.push(batch); 45 | } else { 46 | result.push(batch, [item]); 47 | } 48 | 49 | return result; 50 | }, []); 51 | 52 | export const getImageTransform = ( 53 | image: Dimensions | null, 54 | screen: Dimensions 55 | ) => { 56 | if (!image?.width || !image?.height) { 57 | return [] as const; 58 | } 59 | 60 | const wScale = screen.width / image.width; 61 | const hScale = screen.height / image.height; 62 | const scale = Math.min(wScale, hScale); 63 | const { x, y } = getImageTranslate(image, screen); 64 | 65 | return [{ x, y }, scale] as const; 66 | }; 67 | 68 | export const getImageStyles = ( 69 | image: Dimensions | null, 70 | translate: Animated.ValueXY, 71 | scale?: Animated.Value 72 | ) => { 73 | if (!image?.width || !image?.height) { 74 | return { width: 0, height: 0 }; 75 | } 76 | 77 | const transform = translate.getTranslateTransform(); 78 | 79 | if (scale) { 80 | transform.push({ scale }, { perspective: new Animated.Value(1000) }); 81 | } 82 | 83 | return { 84 | width: image.width, 85 | height: image.height, 86 | transform, 87 | }; 88 | }; 89 | 90 | export const getImageTranslate = ( 91 | image: Dimensions, 92 | screen: Dimensions 93 | ): Position => { 94 | const getTranslateForAxis = (axis: "x" | "y"): number => { 95 | const imageSize = axis === "x" ? image.width : image.height; 96 | const screenSize = axis === "x" ? screen.width : screen.height; 97 | 98 | return (screenSize - imageSize) / 2; 99 | }; 100 | 101 | return { 102 | x: getTranslateForAxis("x"), 103 | y: getTranslateForAxis("y"), 104 | }; 105 | }; 106 | 107 | export const getImageDimensionsByTranslate = ( 108 | translate: Position, 109 | screen: Dimensions 110 | ): Dimensions => ({ 111 | width: screen.width - translate.x * 2, 112 | height: screen.height - translate.y * 2, 113 | }); 114 | 115 | export const getImageTranslateForScale = ( 116 | currentTranslate: Position, 117 | targetScale: number, 118 | screen: Dimensions 119 | ): Position => { 120 | const { width, height } = getImageDimensionsByTranslate( 121 | currentTranslate, 122 | screen 123 | ); 124 | 125 | const targetImageDimensions = { 126 | width: width * targetScale, 127 | height: height * targetScale, 128 | }; 129 | 130 | return getImageTranslate(targetImageDimensions, screen); 131 | }; 132 | 133 | type HandlerType = ( 134 | event: GestureResponderEvent, 135 | state: PanResponderGestureState 136 | ) => void; 137 | 138 | type PanResponderProps = { 139 | onGrant: HandlerType; 140 | onStart?: HandlerType; 141 | onMove: HandlerType; 142 | onRelease?: HandlerType; 143 | onTerminate?: HandlerType; 144 | }; 145 | 146 | export const createPanResponder = ({ 147 | onGrant, 148 | onStart, 149 | onMove, 150 | onRelease, 151 | onTerminate, 152 | }: PanResponderProps): PanResponderInstance => 153 | PanResponder.create({ 154 | onStartShouldSetPanResponder: () => true, 155 | onStartShouldSetPanResponderCapture: () => true, 156 | onMoveShouldSetPanResponder: () => true, 157 | onMoveShouldSetPanResponderCapture: () => true, 158 | onPanResponderGrant: onGrant, 159 | onPanResponderStart: onStart, 160 | onPanResponderMove: onMove, 161 | onPanResponderRelease: onRelease, 162 | onPanResponderTerminate: onTerminate, 163 | onPanResponderTerminationRequest: () => false, 164 | onShouldBlockNativeResponder: () => false, 165 | }); 166 | 167 | export const getDistanceBetweenTouches = ( 168 | touches: NativeTouchEvent[] 169 | ): number => { 170 | const [a, b] = touches; 171 | 172 | if (a == null || b == null) { 173 | return 0; 174 | } 175 | 176 | return Math.sqrt( 177 | Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2) 178 | ); 179 | }; 180 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["esnext"], 4 | "target": "es2019", 5 | "jsx": "react-native", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | "outDir": "dist/", 11 | "types": ["react", "react-native"], 12 | "skipLibCheck": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | --------------------------------------------------------------------------------