├── 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 | --------------------------------------------------------------------------------