├── lib
├── index.ts
└── ImageViewer.tsx
├── .npmignore
├── biome.json
├── tsconfig.json
├── .gitignore
├── LICENSE
├── package.json
└── README.md
/lib/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from "./ImageViewer";
2 | export * from "./ImageViewer";
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Node Modules
2 | **/node_modules
3 | node_modules
4 | # Example
5 | example
6 | # Assets
7 | Assets
8 | assets
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
3 | "linter": {
4 | "enabled": true,
5 | "rules": {
6 | "recommended": true,
7 | "complexity": {
8 | "useSimplifiedLogicExpression": "off"
9 | },
10 | "style": {
11 | "useSingleVarDeclarator": "off",
12 | "useEnumInitializers": "off"
13 | },
14 | "suspicious": {
15 | "noExplicitAny": "off",
16 | "useAwait": "warn"
17 | }
18 | }
19 | },
20 | "files": {
21 | "ignore": ["node_modules/*"]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "es6",
5 | "lib": ["es6"],
6 | "allowJs": true,
7 | "jsx": "react-native",
8 | "noImplicitAny": false,
9 | "incremental": true /* Enable incremental compilation */,
10 | "isolatedModules": true,
11 | "strict": true,
12 | "moduleResolution": "node",
13 | "baseUrl": "./",
14 | "outDir": "build/dist",
15 | "noEmitHelpers": true,
16 | "alwaysStrict": true,
17 | "strictFunctionTypes": true,
18 | "resolveJsonModule": true,
19 | "importHelpers": false,
20 | "experimentalDecorators": true,
21 | "strictPropertyInitialization": false,
22 | "allowSyntheticDefaultImports": true,
23 | "strictNullChecks": true,
24 | "skipDefaultLibCheck": true,
25 | "skipLibCheck": true,
26 | "esModuleInterop": true,
27 | "typeRoots": ["./node_modules/@types", "./@types"],
28 | "declaration": true /* Generates corresponding '.d.ts' file. */,
29 | "sourceMap": true /* Generates corresponding '.map' file. */
30 | },
31 | "exclude": ["node_modules"]
32 | }
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # Xcode
6 | #
7 | build/
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata
17 | *.xccheckout
18 | *.moved-aside
19 | DerivedData
20 | *.hmap
21 | *.ipa
22 | *.xcuserstate
23 |
24 | # Android/IntelliJ
25 | #
26 | build/
27 | .idea
28 | .gradle
29 | local.properties
30 | *.iml
31 |
32 | # Visual Studio Code
33 | #
34 | .vscode/
35 |
36 | # node.js
37 | #
38 | node_modules/
39 | npm-debug.log
40 | yarn-error.log
41 |
42 | # BUCK
43 | buck-out/
44 | \.buckd/
45 | *.keystore
46 | !debug.keystore
47 |
48 | # fastlane
49 | #
50 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
51 | # screenshots whenever they are needed.
52 | # For more information about the recommended setup visit:
53 | # https://docs.fastlane.tools/best-practices/source-control/
54 |
55 | */fastlane/report.xml
56 | */fastlane/Preview.html
57 | */fastlane/screenshots
58 |
59 | # Bundle artifact
60 | *.jsbundle
61 |
62 | # CocoaPods
63 | /ios/Pods/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 André Ribeiro
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.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-reanimated-image-viewer",
3 | "version": "1.0.2",
4 | "description": "A image viewer for React Native created with Reanimated",
5 | "main": "./build/dist/index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "git@github.com:andresribeiro/react-native-reanimated-image-viewer.git"
9 | },
10 | "author": "André Ribeiro",
11 | "bugs": "https://github.com/andresribeiro/react-native-reanimated-image-viewer/issues",
12 | "license": "MIT",
13 | "homepage": "https://github.com/andresribeiro/react-native-reanimated-image-viewer#readme",
14 | "keywords": [
15 | "react-native",
16 | "react",
17 | "reanimated",
18 | "image",
19 | "image-viewer",
20 | "image-viewing",
21 | "viewer",
22 | "viewing",
23 | "pinch",
24 | "pan",
25 | "zoom",
26 | "double-tap",
27 | "rn",
28 | "typescript"
29 | ],
30 | "scripts": {
31 | "build": "cd lib && tsc && npm run copy:package",
32 | "lint": "biome check .",
33 | "lint:fix": "biome format . --write",
34 | "copy:package": "cpx '../package.json' '../build/dist/'"
35 | },
36 | "config": {
37 | "commitizen": {
38 | "path": "cz-emoji"
39 | }
40 | },
41 | "devDependencies": {
42 | "@biomejs/biome": "^1.9.4",
43 | "@commitlint/cli": "^17.4.0",
44 | "@types/react": "^18.0.26",
45 | "@types/react-native": "^0.70.8",
46 | "cpx": "^1.5.0",
47 | "cz-emoji": "^1.3.2-canary.2",
48 | "react": "^18.1.0",
49 | "react-native": "^0.70.6",
50 | "react-native-gesture-handler": "^2.8.0",
51 | "react-native-reanimated": "^2.13.0",
52 | "typescript": "^4.9.4"
53 | },
54 | "peerDependencies": {
55 | "react": "*",
56 | "react-native": "*",
57 | "react-native-gesture-handler": "*",
58 | "react-native-reanimated": "*"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A image viewer for React Native created with Reanimated
2 |
3 | ### Features ✨
4 |
5 | - ⚡ 120 FPS
6 | - 🤏 Pinch to zoom
7 | - 🤞 Double tap
8 | - ✌️ Swipe-to-close
9 | - 📦 Tiny
10 | - 🚀 Created with Typescript
11 | - 💅 Highly customizable
12 |
13 | https://user-images.githubusercontent.com/63297375/210002857-2ab01afa-420a-40c9-9f4c-5df4c2a40a2b.mp4
14 |
15 | ### About 🗞️
16 |
17 | Uses Reanimated and Gesture Handler under the hood. Created for my social network app, [Rybun](https://rybun.com).
18 |
19 | ### Installation ⚙️
20 |
21 | ```bash
22 | yarn add react-native-reanimated-image-viewer
23 | ```
24 |
25 | You will need [Reanimated](https://github.com/software-mansion/react-native-reanimated) and [Gesture Handler](https://github.com/software-mansion/react-native-gesture-handler) installed in your project.
26 |
27 | ### Usage 🔨
28 |
29 | Import the ImageViewer into a new screen. You can also use a Modal, but you will need to [configure the Gesture Handler on Android](https://docs.swmansion.com/react-native-gesture-handler/docs/next/installation#usage-with-modals-on-android).
30 |
31 | ### Example
32 |
33 | ```tsx
34 | import ImageViewer from "react-native-reanimated-image-viewer";
35 | import { GestureHandlerRootView } from "react-native-gesture-handler";
36 |
37 | export default function App() {
38 | const imageUrl = "https://images.pexels.com/photos/994605/pexels-photo-994605.jpeg?auto=compress&cs=tinysrgb&w=2726&h=2047&dpr=1"
39 |
40 | return (
41 |
42 | {}}
44 | />
45 |
46 | );
47 | }
48 |
49 | ```
50 |
51 | ### Props ✍️
52 |
53 | | Property | Default | Type | Required
54 | | ---- | ---- | ---- | ----
55 | | `imageUrl` | `undefined` | `string` | `true`
56 | | `width` | `undefined` | `number` | `true`
57 | | `height` | `undefined` | `number` | `true`
58 | | `onRequestClose` | `undefined` | `() => unknown` | `true`
59 | | `onSingleTap` | `undefined` | `() => unknown` | `false`
60 |
--------------------------------------------------------------------------------
/lib/ImageViewer.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useWindowDimensions } from "react-native";
3 | import { Gesture, GestureDetector } from "react-native-gesture-handler";
4 | import Animated, {
5 | runOnJS,
6 | useAnimatedStyle,
7 | useSharedValue,
8 | withDecay,
9 | withTiming,
10 | } from "react-native-reanimated";
11 |
12 | export type ImageViewerProps = {
13 | imageUrl: string;
14 | width: number;
15 | height: number;
16 | onRequestClose: () => unknown;
17 | onSingleTap?: () => unknown;
18 | };
19 |
20 | const MAX_ZOOM_SCALE = 3;
21 |
22 | export default function ImageViewer({
23 | imageUrl,
24 | width,
25 | height,
26 | onSingleTap,
27 | onRequestClose,
28 | }: ImageViewerProps) {
29 | const dimensions = useWindowDimensions();
30 |
31 | const scale = useSharedValue(1);
32 | const savedScale = useSharedValue(1);
33 |
34 | const translateY = useSharedValue(0);
35 | const savedTranslateY = useSharedValue(0);
36 |
37 | const translateX = useSharedValue(0);
38 | const savedTranslateX = useSharedValue(0);
39 |
40 | const { width: finalWidth, height: finalHeight } = useMemo(() => {
41 | function ruleOfThree(
42 | firstValue: number,
43 | firstResult: number,
44 | secondValue: number,
45 | ) {
46 | const secondResult = (firstResult * secondValue) / firstValue;
47 |
48 | return secondResult;
49 | }
50 |
51 | const resizedBasedOnWidth = {
52 | width: dimensions.width,
53 | height: ruleOfThree(width, dimensions.width, height),
54 | };
55 |
56 | const resizedBasedOnHeight = {
57 | width: ruleOfThree(height, dimensions.height, width),
58 | height: dimensions.height,
59 | };
60 |
61 | if (width === height) {
62 | const smallestScreenDimension = Math.min(
63 | dimensions.width,
64 | dimensions.height,
65 | );
66 |
67 | return {
68 | width: smallestScreenDimension,
69 | height: smallestScreenDimension,
70 | };
71 | }
72 |
73 | if (width > height) {
74 | return resizedBasedOnWidth;
75 | }
76 |
77 | if (resizedBasedOnHeight.width > dimensions.width) {
78 | return resizedBasedOnWidth;
79 | }
80 |
81 | return resizedBasedOnHeight;
82 | }, [width, height, dimensions.width, dimensions.height]);
83 |
84 | const pinchGesture = Gesture.Pinch()
85 | .onStart(() => {
86 | savedScale.value = scale.value;
87 | })
88 | .onUpdate((event) => {
89 | scale.value = savedScale.value * event.scale;
90 | });
91 |
92 | const panGesture = Gesture.Pan()
93 | .onStart(() => {
94 | savedTranslateX.value = translateX.value;
95 | savedTranslateY.value = translateY.value;
96 | })
97 | .onUpdate((event) => {
98 | if (scale.value < 1) {
99 | return;
100 | }
101 |
102 | const realImageWidth = finalWidth * scale.value;
103 |
104 | const maxTranslateX =
105 | realImageWidth <= dimensions.width
106 | ? 0
107 | : (realImageWidth - dimensions.width) / 2;
108 | const minTranslateX =
109 | realImageWidth <= dimensions.width
110 | ? 0
111 | : -(realImageWidth - dimensions.width) / 2;
112 |
113 | const possibleNewTranslateX = savedTranslateX.value + event.translationX;
114 |
115 | if (possibleNewTranslateX > maxTranslateX) {
116 | translateX.value = maxTranslateX;
117 | } else if (possibleNewTranslateX < minTranslateX) {
118 | translateX.value = minTranslateX;
119 | } else {
120 | translateX.value = possibleNewTranslateX;
121 | }
122 |
123 | if (scale.value > 1) {
124 | const realImageHeight = finalHeight * scale.value;
125 |
126 | const maxTranslateY =
127 | realImageHeight <= dimensions.height
128 | ? 0
129 | : (realImageHeight - dimensions.height) / 2;
130 | const minTranslateY =
131 | realImageHeight <= dimensions.height
132 | ? 0
133 | : -(realImageHeight - dimensions.height) / 2;
134 |
135 | const possibleNewTranslateY =
136 | savedTranslateY.value + event.translationY;
137 |
138 | if (possibleNewTranslateY > maxTranslateY) {
139 | translateY.value = maxTranslateY;
140 | } else if (possibleNewTranslateY < minTranslateY) {
141 | translateY.value = minTranslateY;
142 | } else {
143 | translateY.value = possibleNewTranslateY;
144 | }
145 | } else {
146 | translateY.value = savedTranslateY.value + event.translationY;
147 | }
148 | })
149 | .onEnd((event) => {
150 | if (scale.value === 1) {
151 | if (event.translationY < -50) {
152 | if (event.velocityY < -2000 || event.translationY < -200) {
153 | runOnJS(onRequestClose)();
154 |
155 | return;
156 | }
157 | }
158 |
159 | translateY.value = withTiming(0);
160 | translateX.value = withTiming(0);
161 | } else if (scale.value < 1) {
162 | scale.value = withTiming(1);
163 | translateX.value = withTiming(0);
164 | translateY.value = withTiming(0);
165 | } else if (scale.value > MAX_ZOOM_SCALE) {
166 | scale.value = withTiming(MAX_ZOOM_SCALE);
167 | } else {
168 | const realImageWidth = finalWidth * scale.value;
169 |
170 | const maxTranslateX =
171 | realImageWidth <= dimensions.width
172 | ? 0
173 | : (realImageWidth - dimensions.width) / 2;
174 | const minTranslateX =
175 | realImageWidth <= dimensions.width
176 | ? 0
177 | : -(realImageWidth - dimensions.width) / 2;
178 |
179 | translateX.value = withDecay({
180 | velocity: event.velocityX,
181 | clamp: [minTranslateX, maxTranslateX],
182 | });
183 |
184 | const realImageHeight = finalHeight * scale.value;
185 |
186 | const maxTranslateY =
187 | realImageHeight <= dimensions.height
188 | ? 0
189 | : (realImageHeight - dimensions.height) / 2;
190 | const minTranslateY =
191 | realImageHeight <= dimensions.height
192 | ? 0
193 | : -(realImageHeight - dimensions.height) / 2;
194 |
195 | translateY.value = withDecay({
196 | velocity: event.velocityY,
197 | clamp: [minTranslateY, maxTranslateY],
198 | });
199 | }
200 | });
201 |
202 | const singleTap = Gesture.Tap().onEnd(() => {
203 | onSingleTap && runOnJS(onSingleTap)();
204 | });
205 |
206 | const doubleTap = Gesture.Tap()
207 | .onStart((event) => {
208 | if (scale.value > 1) {
209 | scale.value = withTiming(1);
210 | translateX.value = withTiming(0);
211 | translateY.value = withTiming(0);
212 | } else {
213 | scale.value = withTiming(MAX_ZOOM_SCALE);
214 |
215 | const realImageWidth = finalWidth * MAX_ZOOM_SCALE;
216 |
217 | const maxTranslateX = (realImageWidth - dimensions.width) / 2;
218 | const minTranslateX = -(realImageWidth - dimensions.width) / 2;
219 |
220 | const possibleNewTranslateX =
221 | (finalWidth / 2 - event.x) * MAX_ZOOM_SCALE;
222 |
223 | let newTranslateX = 0;
224 |
225 | if (possibleNewTranslateX > maxTranslateX) {
226 | newTranslateX = maxTranslateX;
227 | } else if (possibleNewTranslateX < minTranslateX) {
228 | newTranslateX = minTranslateX;
229 | } else {
230 | newTranslateX = possibleNewTranslateX;
231 | }
232 |
233 | translateX.value = withTiming(newTranslateX);
234 |
235 | const realImageHeight = finalHeight * MAX_ZOOM_SCALE;
236 |
237 | const maxTranslateY =
238 | realImageHeight <= dimensions.height
239 | ? 0
240 | : (realImageHeight - dimensions.height) / 2;
241 | const minTranslateY =
242 | realImageHeight <= dimensions.height
243 | ? 0
244 | : -(realImageHeight - dimensions.height) / 2;
245 |
246 | const possibleNewTranslateY =
247 | (finalHeight / 2 - event.y) * MAX_ZOOM_SCALE;
248 |
249 | let newTranslateY = 0;
250 |
251 | if (possibleNewTranslateY > maxTranslateY) {
252 | newTranslateY = maxTranslateY;
253 | } else if (possibleNewTranslateY < minTranslateY) {
254 | newTranslateY = minTranslateY;
255 | } else {
256 | newTranslateY = possibleNewTranslateY;
257 | }
258 |
259 | translateY.value = withTiming(newTranslateY);
260 | }
261 | })
262 | .numberOfTaps(2);
263 |
264 | const imageContainerAnimatedStyle = useAnimatedStyle(() => {
265 | return {
266 | transform: [
267 | { translateX: translateX.value },
268 | { translateY: translateY.value },
269 | ],
270 | };
271 | });
272 |
273 | const imageAnimatedStyle = useAnimatedStyle(() => {
274 | return {
275 | transform: [
276 | {
277 | scale: scale.value,
278 | },
279 | ],
280 | };
281 | }, []);
282 |
283 | const composedGestures = Gesture.Simultaneous(pinchGesture, panGesture);
284 | const allGestures = Gesture.Exclusive(composedGestures, doubleTap, singleTap);
285 |
286 | return (
287 |
288 |
296 |
297 |
309 |
310 |
311 |
312 | );
313 | }
314 |
--------------------------------------------------------------------------------