├── .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 | [](https://badge.fury.io/js/react-native-image-viewing)
6 | [](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 |
--------------------------------------------------------------------------------