├── .DS_Store ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── ImageViewer.js ├── index.js └── utils.js └── package.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahen94/react-native-image-fit/db03605457de2f3b48b26e7eae302fc3bfade716/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Shahen Hovhannisyan 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-image-fit 2 | ImageViewer component for RN 3 | 4 | ### Installation 5 | 6 | ```sh 7 | $ npm install --save react-native-image-fit 8 | ``` 9 | or 10 | 11 | ```sh 12 | $ yarn add react-native-image-fit 13 | ``` 14 | 15 | ### Usage 16 | 17 | ```javascript 18 | import { ImageViewer } from 'react-native-image-fit'; 19 | 20 | export const App = () => ( 21 | null} 26 | onPress={(opening) => console.log(opening)} 27 | mainImageStyle={styles.someStyle} 28 | zoomedImageStyle={styles.zoomedImageStyle} 29 | mainImageProps={{ 30 | resizeMode: 'contain' 31 | }} 32 | zoomedImageProps={{ 33 | resizeMode: 'contain' 34 | }} 35 | /> 36 | ) 37 | ``` 38 | 39 | ### ImageViewer Component example 40 | 41 | ![ezgif-3352117320](https://cloud.githubusercontent.com/assets/13334788/19832054/dc83a9f4-9e2a-11e6-9023-ccd80fb944b5.gif) 42 | 43 | 44 | ## 45 | If this project was helpful to you, please 46 | Buy Me A Coffee 47 | 48 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { ImageViewer } from './lib'; -------------------------------------------------------------------------------- /lib/ImageViewer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { Component, PropTypes } from 'react'; 4 | 5 | import { 6 | StyleSheet, 7 | View, 8 | Image, 9 | Animated, 10 | PanResponder, 11 | Dimensions, 12 | TouchableWithoutFeedback, 13 | Modal, 14 | InteractionManager 15 | } from 'react-native'; 16 | import { backgroundValueCalculation } from './utils'; 17 | 18 | const AnimatedImage = Animated.createAnimatedComponent(Image); 19 | const { width, height } = Dimensions.get('window'); 20 | 21 | const LAYOUT_ENUM = { 22 | X: 'x', 23 | Y: 'y' 24 | }; 25 | 26 | const BACKGROUND_VALUES = { 27 | MAX: 100, 28 | MIN: 0 29 | }; 30 | 31 | const DOUBLE_TAP_MILISECONDS = 200; 32 | 33 | export class ImageViewer extends Component { 34 | static propTypes = { 35 | // common 36 | source: Image.propTypes.source, 37 | disabled: PropTypes.bool, 38 | doubleTapEnabled: PropTypes.bool, 39 | // main image 40 | mainImageStyle: Image.propTypes.style, 41 | mainImageProps: PropTypes.object, 42 | // zoomed image 43 | zoomedImageStyle: Image.propTypes.style, 44 | zoomedImageProps: PropTypes.object, 45 | 46 | // required if it's a local image 47 | imageWidth: PropTypes.number, 48 | imageHeight: PropTypes.number, 49 | 50 | // callbacks 51 | onMove: PropTypes.func, 52 | onPress: PropTypes.func, 53 | onClose: PropTypes.func, 54 | 55 | // back button 56 | closeOnBack: PropTypes.bool, 57 | }; 58 | 59 | static defaultProps = { 60 | doubleTapEnabled: true, 61 | imageWidth: width, 62 | imageHeight: height / 2, 63 | closeOnBack: true 64 | }; 65 | 66 | constructor(props, context) { 67 | super(props, context); 68 | 69 | this.state = { 70 | openModal: false, 71 | scale: new Animated.Value(1), 72 | layout: new Animated.ValueXY({ x: 0, y: 0 }), 73 | backgroundOpacity: new Animated.Value(BACKGROUND_VALUES.MIN), 74 | mainImageOpacity: new Animated.Value(1) 75 | }; 76 | 77 | this.panResponder = null; 78 | this.layoutListener = null; 79 | 80 | this._imageSize = { 81 | width: typeof props.source !== 'object' ? props.imageWidth : null, 82 | height: typeof props.source !== 'object' ? props.imageHeight : null, 83 | }; 84 | 85 | this._layoutX = 0; 86 | this._layoutY = 0; 87 | this._lastMovedX = 0; 88 | this._lastMovedY = 0; 89 | this._modalClosing = 0; 90 | this._doubleTapTimeout = null; 91 | this._isScaled = false; 92 | this._isAnimatingToCenter = false; 93 | this._zoomedImageSize = { 94 | width: null, 95 | height: null 96 | }; 97 | 98 | this.handleMove = this.handleMove.bind(this); 99 | this.handleRelease = this.handleRelease.bind(this); 100 | this.toggleModal = this.toggleModal.bind(this); 101 | this.handleSetPanResponder = this.handleSetPanResponder.bind(this); 102 | this.handleLayoutChange = this.handleLayoutChange.bind(this); 103 | } 104 | 105 | componentWillMount() { 106 | const { source } = this.props; 107 | 108 | this.state.layout.x.addListener((animated) => this.handleLayoutChange(animated, LAYOUT_ENUM.X)); 109 | this.state.layout.y.addListener((animated) => this.handleLayoutChange(animated, LAYOUT_ENUM.Y)); 110 | 111 | this.panResponder = PanResponder.create({ 112 | onStartShouldSetPanResponder: this.handleSetPanResponder, 113 | onMoveShouldSetPanResponder: () => true, 114 | onPanResponderMove: this.handleMove, 115 | onPanResponderRelease: this.handleRelease, 116 | onPanResponderTerminate: this.handleRelease 117 | }); 118 | 119 | if (typeof source === 'object' && typeof source.uri === 'string') { 120 | Image.prefetch(source.uri); 121 | Image.getSize(source.uri, (width, height) => { 122 | this._imageSize = { width, height }; 123 | }); 124 | } 125 | } 126 | componentWillUnmount() { 127 | this.state.layout.x.removeAllListeners(); 128 | this.state.layout.y.removeAllListeners(); 129 | } 130 | handleMove(e, gestureState) { 131 | if (typeof this.props.onMove === 'function') { 132 | this.props.onMove(e, gestureState); 133 | } 134 | 135 | const currentScaleSizes = { 136 | width: this._zoomedImageSize.width * 2, 137 | height: this._zoomedImageSize.height * 2 138 | }; 139 | 140 | const modifiedGestureState = Object.assign({}, gestureState, { 141 | dx: this._lastMovedX + gestureState.dx, 142 | dy: this._lastMovedY + gestureState.dy 143 | }); 144 | 145 | Animated.event([null, { 146 | dx: this.state.layout.x, 147 | dy: this.state.layout.y 148 | }])(e, modifiedGestureState); 149 | } 150 | 151 | handleLayoutChange(animated, axis) { 152 | switch(axis) { 153 | case LAYOUT_ENUM.X: 154 | this._layoutX = animated.value; 155 | break; 156 | case LAYOUT_ENUM.Y: 157 | this._layoutY = animated.value; 158 | break; 159 | } 160 | 161 | if (this._modalClosing || this._isScaled || this._isAnimatingToCenter) { 162 | return; 163 | } 164 | 165 | const value = backgroundValueCalculation(this._layoutY, this._layoutX, BACKGROUND_VALUES); 166 | 167 | Animated.timing(this.state.backgroundOpacity, { 168 | toValue: value, 169 | duration: 1 170 | }).start(); 171 | } 172 | 173 | handleSetPanResponder() { 174 | const currMil = Date.now(); 175 | 176 | if (!!this._doubleTapTimeout && 177 | (currMil - this._doubleTapTimeout <= DOUBLE_TAP_MILISECONDS) && 178 | this.props.doubleTapEnabled 179 | ) { 180 | const value = this._isScaled ? 1 : 2; 181 | this._isAnimatingToCenter = this._isScaled; 182 | this._isScaled = !this._isScaled; 183 | 184 | Animated.timing(this.state.scale, { 185 | toValue: value, 186 | duration: 100 187 | }).start(() => { 188 | this._isAnimatingToCenter = false; 189 | if (!this._isScaled) { 190 | this._lastMovedY = 0; 191 | this._lastMovedX = 0; 192 | } 193 | }); 194 | } 195 | this._doubleTapTimeout = currMil; 196 | 197 | return true; 198 | } 199 | 200 | handleRelease() { 201 | const value = backgroundValueCalculation(this._layoutY, this._layoutX, BACKGROUND_VALUES); 202 | const resetAnimation = Animated.timing(this.state.layout, { 203 | toValue: { x: 0, y: 0 }, 204 | duration: 150 205 | }); 206 | 207 | if (this._isScaled) { 208 | this._lastMovedY = this._layoutY; 209 | this._lastMovedX = this._layoutX; 210 | return; 211 | } 212 | 213 | const resetBackgroundAnimation = Animated.timing(this.state.backgroundOpacity, { 214 | toValue: BACKGROUND_VALUES.MAX, 215 | duration: 150 216 | }); 217 | 218 | const cleanBackgroundAnimation = Animated.sequence([ 219 | Animated.timing(this.state.backgroundOpacity, { 220 | toValue: BACKGROUND_VALUES.MIN, 221 | duration: 150 222 | }), 223 | Animated.timing(this.state.mainImageOpacity, { 224 | toValue: 1, 225 | duration: 50 226 | }) 227 | ]); 228 | 229 | const animations = []; 230 | animations.push(resetAnimation); 231 | 232 | const shouldCloseModal = value <= 0; 233 | 234 | if (!this._isAnimatingToCenter && shouldCloseModal) { 235 | this._modalClosing = true; 236 | animations.push(cleanBackgroundAnimation); 237 | } 238 | 239 | animations.forEach(animation => animation.start()); 240 | if (!this._isAnimatingToCenter && shouldCloseModal) { 241 | InteractionManager.runAfterInteractions(() => this.toggleModal()); 242 | } 243 | } 244 | 245 | toggleModal() { 246 | const shouldOpen = !this.state.openModal; 247 | 248 | if (this.props.disabled) { 249 | return; 250 | } 251 | if (typeof this.props.onPress === 'function') { 252 | this.props.onPress(shouldOpen); 253 | } 254 | if (shouldOpen) { 255 | this._modalClosing = false; 256 | this.state.backgroundOpacity.setValue(BACKGROUND_VALUES.MAX); 257 | } else { 258 | this.state.backgroundOpacity.setValue(BACKGROUND_VALUES.MIN); 259 | // call prop 260 | if(typeof this.props.onClose === 'function'){ 261 | this.props.onClose() 262 | } 263 | } 264 | this.state.mainImageOpacity.setValue(shouldOpen ? 0 : 1); 265 | this.setState({ 266 | openModal: shouldOpen 267 | }); 268 | } 269 | 270 | render() { 271 | const { 272 | source, 273 | mainImageStyle, 274 | mainImageProps, 275 | zoomedImageStyle, 276 | zoomedImageProps 277 | } = this.props; 278 | 279 | const { 280 | backgroundOpacity, 281 | openModal, 282 | scale 283 | } = this.state; 284 | 285 | if (this._imageSize.width / width > this._imageSize.height / height) { 286 | this._zoomedImageSize.width = width; 287 | this._zoomedImageSize.height = width / this._imageSize.width * this._imageSize.height 288 | } else { 289 | this._zoomedImageSize.height = height; 290 | this._zoomedImageSize.width = height / this._imageSize.width * this._imageSize.height; 291 | } 292 | 293 | const interpolatedColor = backgroundOpacity.interpolate({ 294 | inputRange: [BACKGROUND_VALUES.MIN, BACKGROUND_VALUES.MAX], 295 | outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 1)'] 296 | }) 297 | 298 | return ( 299 | 300 | 303 | 313 | 314 | null} 318 | transparent={true} 319 | > 320 | 329 | 344 | 345 | 346 | 347 | ); 348 | } 349 | } 350 | 351 | const styles = StyleSheet.create({ 352 | background: { 353 | position: 'absolute', 354 | top: 0, 355 | left: 0, 356 | right: 0, 357 | width, 358 | height, 359 | bottom: 0, 360 | }, 361 | image: { 362 | width: 200, 363 | height: 200, 364 | } 365 | }); 366 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | export { ImageViewer } from './ImageViewer'; -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | export const getPitagorasZ = (x, y) => ( 2 | Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) 3 | ); 4 | 5 | export const backgroundValueCalculation = (x, y, BACKGROUND_VALUES) => ( 6 | 4 / 3 * BACKGROUND_VALUES.MAX - getPitagorasZ(x, y) 7 | ); 8 | 9 | export const pow2abs = (a, b) => ( 10 | Math.pow(Math.abs(a - b), 2) 11 | ); 12 | 13 | export const centerCoords = (touches) => { 14 | const finger1 = touches[0]; 15 | const finger2 = touches[2]; 16 | 17 | return { 18 | x: (finger1.pageX + finger2.pageX) / 2, 19 | y: (finger1.pageY + finger2.pageY) / 2 20 | }; 21 | }; 22 | 23 | export const distance = (touches) => { 24 | const finger1 = touches[0]; 25 | const finger2 = touches[1]; 26 | 27 | return Math.sqrt( 28 | pow2abs(finger1.pageX, finger2.pageX) + 29 | pow2abs(finger1.pageY, finger2.pageY) 30 | ); 31 | }; 32 | 33 | export const toDegree = (radian) => ( 34 | radian * 180 / Math.PI 35 | ); 36 | 37 | export const angle = (touches) => { 38 | const finger1 = touches[0]; 39 | const finger2 = touches[1]; 40 | const mathAtan2 = Math.atan2( 41 | finger2.pageY - finger1.pageY, 42 | finger2.pageX -finger1.pageX 43 | ); 44 | 45 | let degree = toDegree(mathAtan2); 46 | 47 | if (degree < 0) { 48 | deg += 360; 49 | } 50 | 51 | return degree; 52 | }; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-image-fit", 3 | "version": "0.9.10", 4 | "description": "ImageViewer for RN", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "react-native", 11 | "react", 12 | "shapes", 13 | "android", 14 | "ios", 15 | "mobile", 16 | "Image", 17 | "ImageViewer" 18 | ], 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:shahen94/react-native-image-fit.git" 23 | }, 24 | "author": "Shahen Hovhannisyan ", 25 | "peerDependencies": { 26 | "react-native": ">=0.29" 27 | } 28 | } 29 | --------------------------------------------------------------------------------