Repository: antonKalinin/react-native-image-view Branch: master Commit: 2450ec459839 Files: 24 Total size: 55.1 KB Directory structure: gitextract_k_se4hxv/ ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── example/ │ ├── .eslintrc │ ├── .flowconfig │ ├── .gitignore │ ├── .watchmanconfig │ ├── App.js │ ├── app.json │ ├── babel.config.js │ ├── metro.config.js │ └── package.json ├── package.json └── src/ ├── ImageView.js ├── controls/ │ ├── Close.js │ ├── Next.js │ ├── Prev.js │ └── index.js ├── styles.js ├── types.js └── utils.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc ================================================ { "parser": "babel-eslint", "extends": [ "airbnb", "plugin:flowtype/recommended", "plugin:jest/recommended", "prettier", "prettier/react" ], "plugins": [ "flowtype", "jest", "prettier" ], "rules": { "prettier/prettier": "error", "react/jsx-filename-extension": [ 1, { "extensions": [ ".js", ".jsx" ] } ], "import/extensions": "off", "import/no-unresolved": "off", "react/require-default-props": "off", "react/destructuring-assignment": "off", "global-require": "off", "no-restricted-properties": "off", "no-unused-vars": "error", "import/no-extraneous-dependencies": "error", "flowtype/no-weak-types": [ 1, { "any": true, "Function": true, "Object": false } ] }, "env": { "es6": true } } ================================================ FILE: .flowconfig ================================================ [ignore] ; We fork some components by platform .*/*[.]android.js ; Ignore "BUCK" generated dirs /\.buckd/ ; Ignore unexpected extra "@providesModule" .*/node_modules/.*/node_modules/fbjs/.* ; Ignore duplicate module providers ; For RN Apps installed via npm, "Libraries" folder is inside ; "node_modules/react-native" but in the source repo it is in the root .*/Libraries/react-native/React.js ; Ignore polyfills .*/Libraries/polyfills/.* ; Ignore metro .*/node_modules/metro/.* [include] [libs] node_modules/react-native/Libraries/react-native/react-native-interface.js node_modules/react-native/flow/ node_modules/react-native/flow-github/ [options] emoji=true module.system=haste munge_underscores=true module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub' module.file_ext=.js module.file_ext=.jsx module.file_ext=.json module.file_ext=.native.js suppress_type=$FlowIssue suppress_type=$FlowFixMe suppress_type=$FlowFixMeProps suppress_type=$FlowFixMeState suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError [version] ^0.71.0 ================================================ FILE: .gitignore ================================================ node_modules/ .idea .expo ================================================ FILE: .prettierrc ================================================ { "bracketSpacing": false, "parser": "flow", "printWidth": 80, "singleQuote": true, "tabWidth": 4, "trailingComma": "es5" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Anton Kalinin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ## If you are using React Native >= 0.59.0 it's recommended to use similar package https://github.com/jobtoday/react-native-image-viewing as improved and better supported version! [![npm version](https://badge.fury.io/js/react-native-image-view.svg)](https://badge.fury.io/js/react-native-image-view) React Native modal image view with pinch zoom and carousel. Try with expo: https://expo.io/@antonkalinin/react-native-image-view #### Warning: Breaking changes since v2.0.0: - instead of prop `source` => `images` - no title prop for footer, please use `renderFooter` instead ## Installation ```bash yarn add react-native-image-view ``` or ```bash npm install --save react-native-image-view ``` ## Demo

## Usage ```jsx import ImageView from 'react-native-image-view'; const images = [ { source: { uri: 'https://cdn.pixabay.com/photo/2017/08/17/10/47/paris-2650808_960_720.jpg', }, title: 'Paris', width: 806, height: 720, }, ]; (My footer)} /> ``` #### [See example for better understanding](https://github.com/antonKalinin/react-native-image-view/blob/master/example/App.js) ## Props Prop name | Description | Type | Default value | Platform | --------------------|---------------|-----------|---------------|----------| `animationType` | Type of animation modal presented with | "none", "fade", "slide" | "none" | `backgroundColor` | Background color of the modal in HEX (#0099CC) | string | null | `controls` | Config of available controls (see below) | Object | {close: true} | `glideAlways` | Emulates ScrollView glide animation if built-in was not triggered | boolean | false | Android `glideAlwaysDelay` | Defines delay in milliseconds for glideAlways | number | 75 | Android `images` | Array of images to display, see below image item description | array | [] | `imageIndex` | Current index of image to display | number | 0 | `isVisible` | Is modal shown or not | boolean | false | `isTapZoomEnabled` | Zoom image when double tapped | boolean | true | `isPinchZoomEnabled` | Zoom image with pinch gesture | boolean | true | `isSwipeCloseEnabled` | Close modal with swipe up or down | boolean | true | `onClose` | Function called on modal closed | function | none | `onImageChange` | Function called when image is changed | function | none | `renderFooter` | Function returns a footer element | function | none | #### Image item: ```js { source: any, // Image Component source object width: ?number, // Width of full screen image (optional but recommended) height: ?number, // Height of full screen image (optional but recommended) // any other props you need to render your footer } ``` It's recommended to specify width and height to speed up rendering, overwise component needs to fetch images sizes and cache them in images objects passed as props. #### controls prop: ```js type ControlType = React.Component<{onPress: () => void}> | null | boolean, { close: ControlType // Component for close button in up right corner, as onPress prop accepts function to close modal next: ControlType, // Component for next image button, as onPress prop accepts function to scroll to next image prev: ControlType, // Component for previous image button, as onPress prop accepts function to scroll to previous image } ``` To use default components just set `{next: true, prev: true}`, close is showing by default. To create custom controls check src/controls. ### License [MIT](LICENSE) ================================================ FILE: example/.eslintrc ================================================ { "extends": [ "airbnb", "prettier", "prettier/flowtype", "prettier/react" ], "rules": { "indent": ["error", 4], "comma-dangle": ["error", { "arrays": "always-multiline", "objects": "always-multiline", "imports": "always-multiline", "exports": "always-multiline", "functions": "ignore" }], "no-console": 0, "no-restricted-properties": 0, "object-curly-spacing": ["error", "never"], "prettier/prettier": "error", "react/prop-types": ["error", {"customValidators": ["skipUndeclared"]}], "react/jsx-indent-props": ["error", 4], "react/jsx-indent": ["error", 4], "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }] }, "root": true, "plugins": [ "react", "flowtype", "prettier" ], "settings": { "flowtype": { "onlyFilesWithFlowAnnotation": true } }, "parser": "babel-eslint", "parserOptions": { "ecmaFeatures": { "experimentalObjectRestSpread": true } } } ================================================ FILE: example/.flowconfig ================================================ [ignore] ; We fork some components by platform .*/*[.]android.js ; Ignore "BUCK" generated dirs /\.buckd/ ; Ignore unexpected extra "@providesModule" .*/node_modules/.*/node_modules/fbjs/.* ; Ignore duplicate module providers ; For RN Apps installed via npm, "Libraries" folder is inside ; "node_modules/react-native" but in the source repo it is in the root .*/Libraries/react-native/React.js ; Ignore polyfills .*/Libraries/polyfills/.* ; Ignore metro .*/node_modules/metro/.* [include] [libs] node_modules/react-native/Libraries/react-native/react-native-interface.js node_modules/react-native/flow/ node_modules/react-native/flow-github/ [options] emoji=true module.system=haste munge_underscores=true module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub' module.file_ext=.js module.file_ext=.jsx module.file_ext=.json module.file_ext=.native.js suppress_type=$FlowIssue suppress_type=$FlowFixMe suppress_type=$FlowFixMeProps suppress_type=$FlowFixMeState suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError [version] ^0.71.0 ================================================ FILE: example/.gitignore ================================================ node_modules/**/* .expo/* npm-debug.* yarn.lock ImageView.js types.js ================================================ FILE: example/.watchmanconfig ================================================ {} ================================================ FILE: example/App.js ================================================ import React, {Component} from 'react'; import { Text, View, Image, TouchableOpacity, StyleSheet, Dimensions, Platform, } from 'react-native'; // import ImageView from '../src/ImageView'; import ImageView from 'react-native-image-view'; const {width} = Dimensions.get('window'); const cities = [ { source: { uri: 'https://avatars.mds.yandex.net/get-pdb/49816/d9152cc6-bf48-4e44-b2d5-de73b2e94454/s800', }, title: 'London', }, { // eslint-disable-next-line source: require('./assets/spb.jpg'), title: 'St-Petersburg', width: 1200, height: 800, }, { source: { uri: 'https://cdn.pixabay.com/photo/2017/08/17/10/47/paris-2650808_960_720.jpg', }, title: 'Paris', width: 806, height: 720, }, ]; const nature = [ { source: { uri: 'https://images.fineartamerica.com/images/artworkimages/mediumlarge/1/1-forest-in-fog-russian-nature-forest-mist-dmitry-ilyshev.jpg', }, title: 'Switzerland', }, { source: { uri: 'https://i.pinimg.com/564x/a5/1b/63/a51b63c13c7c41fa333b302fc7938f06.jpg', }, title: 'USA', width: 400, height: 800, }, { source: { uri: 'https://guidetoiceland.imgix.net/4935/x/0/top-10-beautiful-waterfalls-of-iceland-8?auto=compress%2Cformat&ch=Width%2CDPR&dpr=1&ixlib=php-2.1.1&w=883&s=1fb8e5e1906e1d18fc6b08108a9dde8d', }, title: 'Iceland', width: 880, height: 590, }, ]; const tabs = [ {title: 'Cities', images: cities}, {title: 'Nature', images: nature}, ]; const styles = StyleSheet.create({ container: { flex: 1, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backgroundColor: '#000', paddingTop: Platform.select({ios: 0, android: 10}), }, tabs: { flexDirection: 'row', }, tab: { flex: 1, height: 30, alignItems: 'center', justifyContent: 'flex-end', }, tabTitle: { color: '#EEE', }, tabTitleActive: { fontWeight: '700', color: '#FFF', }, footer: { width, height: 50, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.4)', paddingHorizontal: 10, paddingVertical: 5, }, footerButton: { flexDirection: 'row', marginLeft: 15, }, footerText: { fontSize: 16, color: '#FFF', textAlign: 'center', }, }); export default class App extends Component { constructor(props) { super(props); this.state = { activeTab: 0, imageIndex: 0, isImageViewVisible: false, likes: [...cities, ...nature].reduce((acc, image) => { acc[image.title] = 0; return acc; }, {}), }; this.renderFooter = this.renderFooter.bind(this); } renderFooter({title}) { const {likes} = this.state; return ( {title} { const imageLikes = likes[title] + 1; this.setState({likes: {...likes, [title]: imageLikes}}); }} > {likes[title]} ); } render() { const {isImageViewVisible, activeTab, imageIndex} = this.state; const images = tabs[activeTab].images || []; return ( {images.map((image, index) => ( { this.setState({ imageIndex: index, isImageViewVisible: true, }); }} > ))} {tabs.map(({title}, index) => ( { this.setState({ activeTab: index, }); }} > {title} ))} this.setState({isImageViewVisible: false})} onImageChange={index => { console.log(index); }} /> ); } } ================================================ FILE: example/app.json ================================================ { "expo": { "name": "react-native-image-view", "description": "React Native modal image view with pinch zoom", "slug": "react-native-image-view", "privacy": "public", "sdkVersion": "35.0.0", "version": "1.0.0", "orientation": "portrait", "primaryColor": "#cccccc", "icon": "./assets/icon.png", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", "backgroundColor": "#ffffff" }, "ios": { "supportsTablet": true } } } ================================================ FILE: example/babel.config.js ================================================ module.exports = function(api) { api.cache(true); return { presets: ['babel-preset-expo'], }; }; ================================================ FILE: example/metro.config.js ================================================ const path = require('path'); module.exports = { resolver: { extraNodeModules: new Proxy( {}, { get: (target, name) => path.join(process.cwd(), `node_modules/${name}`), } ), }, projectRoot: [path.resolve(__dirname)], watchFolders: [path.resolve(__dirname, '../src')], }; ================================================ FILE: example/package.json ================================================ { "main": "node_modules/expo/AppEntry.js", "private": true, "dependencies": { "expo": "^35.0.0", "react": "16.8.6", "react-native": "https://github.com/expo/react-native/archive/sdk-35.0.0.tar.gz", "react-native-image-view": "^2.1.6" }, "devDependencies": { "babel-eslint": "10.0.1", "babel-preset-expo": "7.0.0", "eslint": "5.16.0", "eslint-config-airbnb": "17.1.0", "eslint-config-prettier": "4.3.0", "eslint-plugin-flowtype": "3.9.1", "eslint-plugin-import": "2.17.3", "eslint-plugin-jsx-a11y": "6.2.1", "eslint-plugin-prettier": "3.1.0", "eslint-plugin-react": "7.13.0", "flow-bin": "0.100.0", "prettier": "1.18.2" }, "scripts": { "eslint-check": "eslint --print-config .eslintrc.js | eslint-config-prettier-check" } } ================================================ FILE: package.json ================================================ { "name": "react-native-image-view", "version": "2.1.9", "description": "React Native modal image view with pinch zoom", "main": "src/ImageView", "scripts": { "flow": "flow", "eslint-check": "eslint --print-config .eslintrc.js | eslint-config-prettier-check", "postversion": "git push origin master && git push --tags origin master" }, "repository": { "type": "git", "url": "git+https://github.com/antonKalinin/react-native-image-view.git" }, "peerDependencies": { "react": ">=15.2.0", "react-native": ">=0.44.0" }, "devDependencies": { "babel-eslint": "10.0.1", "eslint": "5.16.0", "eslint-config-airbnb": "17.1.0", "eslint-config-prettier": "4.3.0", "eslint-plugin-flowtype": "3.9.1", "eslint-plugin-import": "2.17.3", "eslint-plugin-jest": "^21.17.0", "eslint-plugin-jsx-a11y": "6.2.1", "eslint-plugin-prettier": "3.1.0", "eslint-plugin-react": "7.13.0", "flow-bin": "0.100.0", "prettier": "1.18.2" }, "keywords": [ "react-native", "image", "zoom", "preview", "modal", "pinch", "component" ], "files": [ "package.json", "readme.md", "src" ], "author": "Anton Kalinin", "license": "MIT", "bugs": { "url": "https://github.com/antonKalinin/react-native-image-view/issues" }, "homepage": "https://github.com/antonKalinin/react-native-image-view#readme" } ================================================ FILE: src/ImageView.js ================================================ // @flow import React, {Component, type Node, type ComponentType} from 'react'; import { ActivityIndicator, Animated, Dimensions, FlatList, Modal, Platform, View, SafeAreaView, } from 'react-native'; import { type ControlType, type ControlsType, type DimensionsType, type EventType, type ImageType, type ImageSizeType, type GestureState, type NativeEventType, type TouchType, type TransitionType, type TranslateType, } from './types'; import { addIndexesToImages, calculateInitialTranslate, fetchImageSize, generatePanHandlers, getImagesWithoutSize, getScale, getDistance, getInitialParams, hexToRgb, isHex, scalesAreEqual, } from './utils'; import createStyles from './styles'; import {Close, Prev, Next} from './controls'; const IMAGE_SPEED_FOR_CLOSE = 1.1; const SCALE_MAXIMUM = 5; const HEADER_HEIGHT = 60; const SCALE_MAX_MULTIPLIER = 3; const FREEZE_SCROLL_DISTANCE = 15; const BACKGROUND_OPACITY_MULTIPLIER = 0.003; const defaultBackgroundColor = [0, 0, 0]; const getScreenDimensions = () => ({ screenWidth: Dimensions.get('window').width, screenHeight: Dimensions.get('window').height, }); let styles = createStyles(getScreenDimensions()); type PropsType = { animationType: 'none' | 'fade' | 'slide', backgroundColor?: string, glideAlways?: boolean, glideAlwaysDelay?: number, images: ImageType[], imageIndex: number, isVisible: boolean, isTapZoomEnabled: boolean, isPinchZoomEnabled: boolean, isSwipeCloseEnabled: boolean, onClose: () => {}, onImageChange: number => {}, renderFooter: ImageType => {}, controls: { close?: ComponentType | boolean, next?: ComponentType | boolean, prev?: ComponentType | boolean, }, }; export type StateType = { images: ImageType[], isVisible: boolean, imageIndex: number, imageScale: number, imageTranslate: {x: number, y: number}, scrollEnabled: boolean, panelsVisible: boolean, isFlatListRerendered: boolean, screenDimensions: {screenWidth: number, screenHeight: number}, }; export default class ImageView extends Component { static defaultProps = { backgroundColor: null, images: [], imageIndex: 0, isTapZoomEnabled: true, isPinchZoomEnabled: true, isSwipeCloseEnabled: true, glideAlways: false, glideAlwaysDelay: 75, controls: {prev: null, next: null}, }; constructor(props: PropsType) { super(props); // calculate initial scale and translate for images const initialScreenDimensions = getScreenDimensions(); this.imageInitialParams = props.images.map(image => getInitialParams(image, initialScreenDimensions) ); this.state = { images: props.images, isVisible: props.isVisible, imageIndex: props.imageIndex, imageScale: 1, imageTranslate: {x: 0, y: 0}, scrollEnabled: true, panelsVisible: true, isFlatListRerendered: false, screenDimensions: initialScreenDimensions, }; this.glideAlwaysTimer = null; this.listRef = null; this.isScrolling = false; this.footerHeight = 0; this.initialTouches = []; this.currentTouchesNum = 0; this.doubleTapTimer = null; this.modalAnimation = new Animated.Value(0); this.modalBackgroundOpacity = new Animated.Value(0); this.headerTranslateValue = new Animated.ValueXY(); this.footerTranslateValue = new Animated.ValueXY(); this.imageScaleValue = new Animated.Value(this.getInitialScale()); const {x, y} = this.getInitialTranslate(); this.imageTranslateValue = new Animated.ValueXY({x, y}); this.panResponder = generatePanHandlers( (event: EventType): void => this.onGestureStart(event.nativeEvent), (event: EventType, gestureState: GestureState): void => this.onGestureMove(event.nativeEvent, gestureState), (event: EventType, gestureState: GestureState): void => this.onGestureRelease(event.nativeEvent, gestureState) ); const imagesWithoutSize = getImagesWithoutSize( addIndexesToImages(props.images) ); if (imagesWithoutSize.length) { Promise.all(fetchImageSize(imagesWithoutSize)).then( this.setSizeForImages ); } } componentDidMount() { styles = createStyles(this.state.screenDimensions); Dimensions.addEventListener('change', this.onChangeDimension); } componentDidUpdate() { const {images, imageIndex, isVisible} = this.state; if ( typeof this.props.isVisible !== 'undefined' && this.props.isVisible !== isVisible ) { this.onNextImagesReceived(this.props.images, this.props.imageIndex); if ( images !== this.props.images || imageIndex !== this.props.imageIndex ) { const imagesWithoutSize = getImagesWithoutSize( addIndexesToImages(this.props.images) ); if (imagesWithoutSize.length) { Promise.all(fetchImageSize(imagesWithoutSize)).then( updatedImages => this.onNextImagesReceived( this.setSizeForImages(updatedImages), this.props.imageIndex ) ); } } this.setState({ isVisible: this.props.isVisible, isFlatListRerendered: false, }); this.modalBackgroundOpacity.setValue(0); if (this.props.isVisible) { Animated.timing(this.modalAnimation, { duration: 400, toValue: 1, }).start(); } } } componentWillUnmount() { Dimensions.removeEventListener('change', this.onChangeDimension); if (this.glideAlwaysTimer) { clearTimeout(this.glideAlwaysTimer); } } onChangeDimension = ({window}: {window: DimensionsType}) => { const screenDimensions = { screenWidth: window.width, screenHeight: window.height, }; this.setState({screenDimensions}); styles = createStyles(screenDimensions); this.onNextImagesReceived(this.props.images, this.state.imageIndex); }; onNextImagesReceived(images: Array, imageIndex: number = 0) { this.imageInitialParams = images.map(image => getInitialParams(image, this.state.screenDimensions) ); const {scale, translate} = this.imageInitialParams[imageIndex] || { scale: 1, translate: {}, }; this.setState({ images, imageIndex, imageScale: scale, imageTranslate: translate, isFlatListRerendered: false, }); this.imageScaleValue.setValue(scale); this.imageTranslateValue.setValue(translate); } // $FlowFixMe onFlatListRender = flatListRef => { const {images, imageIndex, isFlatListRerendered} = this.state; if (flatListRef && !isFlatListRerendered) { this.listRef = flatListRef; this.setState({ isFlatListRerendered: true, }); // Fix for android https://github.com/facebook/react-native/issues/13202 if (images.length > 0) { const nextTick = new Promise(resolve => setTimeout(resolve, 0)); nextTick.then(() => { flatListRef.scrollToIndex({ index: imageIndex, animated: false, }); }); } } }; onNextImage = (event: EventType) => { const {imageIndex} = this.state; const {x} = event.nativeEvent.contentOffset || {x: 0}; const nextImageIndex = Math.round( x / this.state.screenDimensions.screenWidth ); this.isScrolling = Math.ceil(x) % this.state.screenDimensions.screenWidth > 10; if (imageIndex !== nextImageIndex && nextImageIndex >= 0) { const nextImageScale = this.getInitialScale(nextImageIndex); const nextImageTranslate = this.getInitialTranslate(nextImageIndex); this.setState({ imageIndex: nextImageIndex, imageScale: nextImageScale, imageTranslate: nextImageTranslate, }); this.imageScaleValue.setValue(nextImageScale); this.imageTranslateValue.setValue(nextImageTranslate); if (typeof this.props.onImageChange === 'function') { this.props.onImageChange(nextImageIndex); } } }; onGestureStart(event: NativeEventType) { this.initialTouches = event.touches; this.currentTouchesNum = event.touches.length; } /** * If image is moved from its original position * then disable scroll (for ScrollView) */ onGestureMove(event: NativeEventType, gestureState: GestureState) { if (this.isScrolling && this.state.scrollEnabled) { return; } if (this.currentTouchesNum === 1 && event.touches.length === 2) { this.initialTouches = event.touches; } const {isSwipeCloseEnabled, isPinchZoomEnabled} = this.props; const { images, imageIndex, imageScale, imageTranslate, screenDimensions, } = this.state; const {screenHeight} = screenDimensions; const {touches} = event; const {x, y} = imageTranslate; const {dx, dy} = gestureState; const imageInitialScale = this.getInitialScale(); const {height} = images[imageIndex]; if (imageScale !== imageInitialScale) { this.imageTranslateValue.x.setValue(x + dx); } // Do not allow to move image vertically until it fits to the screen if (imageScale * height > screenHeight) { this.imageTranslateValue.y.setValue(y + dy); } // if image not scaled and fits to the screen if ( isSwipeCloseEnabled && scalesAreEqual(imageScale, imageInitialScale) && height * imageInitialScale < screenHeight ) { const backgroundOpacity = Math.abs( dy * BACKGROUND_OPACITY_MULTIPLIER ); this.imageTranslateValue.y.setValue(y + dy); this.modalBackgroundOpacity.setValue( backgroundOpacity > 1 ? 1 : backgroundOpacity ); } const currentDistance = getDistance(touches); const initialDistance = getDistance(this.initialTouches); const scrollEnabled = Math.abs(dy) < FREEZE_SCROLL_DISTANCE; this.setState({scrollEnabled}); if (!initialDistance) { return; } if (!isPinchZoomEnabled || touches.length < 2) { return; } let nextScale = getScale(currentDistance, initialDistance) * imageScale; if (nextScale < imageInitialScale) { nextScale = imageInitialScale; } else if (nextScale > SCALE_MAXIMUM) { nextScale = SCALE_MAXIMUM; } this.imageScaleValue.setValue(nextScale); this.currentTouchesNum = event.touches.length; } onGestureRelease(event: NativeEventType, gestureState: GestureState) { if (this.glideAlwaysTimer) { clearTimeout(this.glideAlwaysTimer); } if (this.props.glideAlways && Platform.OS === 'android') { this.glideAlwaysTimer = setTimeout(() => { this.glideAlwaysTimer = null; // If standard glide is not triggered then emulate it // $FlowFixMe if (this.listRef && this.listRef.scrollToIndex) { this.listRef.scrollToIndex({ index: this.state.imageIndex, animated: true, }); } }, this.props.glideAlwaysDelay); } if (this.isScrolling) { return; } const {imageScale} = this.state; const {isSwipeCloseEnabled, isTapZoomEnabled} = this.props; let {_value: scale} = this.imageScaleValue; const {_value: modalBackgroundOpacity} = this.modalBackgroundOpacity; const {dx, dy, vy} = gestureState; const imageInitialScale = this.getInitialScale(); const imageInitialTranslate = this.getInitialTranslate(); // Position haven't changed, so it just tap if (event && !dx && !dy && scalesAreEqual(imageScale, scale)) { // Double tap timer is launched, its double tap if (isTapZoomEnabled && this.doubleTapTimer) { clearTimeout(this.doubleTapTimer); this.doubleTapTimer = null; scale = scalesAreEqual(imageInitialScale, scale) ? scale * SCALE_MAX_MULTIPLIER : imageInitialScale; Animated.timing(this.imageScaleValue, { toValue: scale, duration: 300, }).start(); this.togglePanels(scale === imageInitialScale); } else { this.doubleTapTimer = setTimeout(() => { this.togglePanels(); this.doubleTapTimer = null; }, 200); } } const {x, y} = this.calculateNextTranslate(dx, dy, scale); const scrollEnabled = scale === this.getInitialScale() && x === imageInitialTranslate.x && y === imageInitialTranslate.y; Animated.parallel( [ modalBackgroundOpacity > 0 ? Animated.timing(this.modalBackgroundOpacity, { toValue: 0, duration: 100, }) : null, Animated.timing(this.imageTranslateValue.x, { toValue: x, duration: 100, }), Animated.timing(this.imageTranslateValue.y, { toValue: y, duration: 100, }), ].filter(Boolean) ).start(); // Close modal with animation if image not scaled and high vertical gesture speed if ( isSwipeCloseEnabled && scale === imageInitialScale && Math.abs(vy) >= IMAGE_SPEED_FOR_CLOSE ) { Animated.timing(this.imageTranslateValue.y, { toValue: y + 400 * vy, duration: 150, }).start(this.close); } this.setState({ imageScale: scale, imageTranslate: {x, y}, scrollEnabled, }); } onImageLoaded(index: number) { const {images} = this.state; images[index] = {...images[index], loaded: true}; this.setState({images}); } onMomentumScrollBegin = () => { this.isScrolling = true; if (this.glideAlwaysTimer) { // If FlatList started gliding then prevent glideAlways scrolling clearTimeout(this.glideAlwaysTimer); } }; onMomentumScrollEnd = () => { this.isScrolling = false; }; getItemLayout = (_: *, index: number): Object => { const {screenWidth} = this.state.screenDimensions; return {length: screenWidth, offset: screenWidth * index, index}; }; getInitialScale(index?: number): number { const imageIndex = index !== undefined ? index : this.state.imageIndex; const imageParams = this.imageInitialParams[imageIndex]; return imageParams ? imageParams.scale : 1; } getInitialTranslate(index?: number): TranslateType { const imageIndex = index !== undefined ? index : this.state.imageIndex; const imageParams = this.imageInitialParams[imageIndex]; return imageParams ? imageParams.translate : {x: 0, y: 0}; } getImageStyle( image: ImageType, index: number ): {width?: number, height?: number, transform?: any, opacity?: number} { const {imageIndex, screenDimensions} = this.state; const {width, height} = image; if (!width || !height) { return {opacity: 0}; } // very strange caching, fix it with changing size to 1 pixel const {x, y} = calculateInitialTranslate( width, height + 1, screenDimensions ); const translateValue = new Animated.ValueXY({x, y}); const transform = index === imageIndex ? this.imageTranslateValue.getTranslateTransform() : translateValue.getTranslateTransform(); const scale = index === imageIndex ? this.imageScaleValue : this.getInitialScale(index); // $FlowFixMe transform.push({scale}); return {width, height, transform}; } getControls = (): ControlsType => { const {close, prev, next} = this.props.controls; const controls = {close: Close, prev: undefined, next: undefined}; if (close === null) { controls.close = null; } if (close) { controls.close = close === true ? Close : close; } if (prev) { controls.prev = prev === true ? Prev : prev; } if (next) { controls.next = next === true ? Next : next; } return controls; }; setSizeForImages = (nextImages: Array): Array => { if (nextImages.length === 0) { return []; } const {images} = this.state; return images.map((image, index) => { const nextImageSize = nextImages.find( nextImage => nextImage.index === index ); /* eslint-disable */ if (nextImageSize) { image.width = nextImageSize.width; image.height = nextImageSize.height; } /* eslint-enable */ return image; }); }; scrollToNext = () => { if (this.listRef && typeof this.listRef.scrollToIndex === 'function') { this.listRef.scrollToIndex({ index: this.state.imageIndex + 1, animated: true, }); } }; scrollToPrev = () => { if (this.listRef && typeof this.listRef.scrollToIndex === 'function') { this.listRef.scrollToIndex({ index: this.state.imageIndex - 1, animated: true, }); } }; imageInitialParams: TransitionType[]; glideAlwaysTimer: ?TimeoutID; listRef: *; isScrolling: boolean; footerHeight: number; initialTouches: TouchType[]; currentTouchesNum: number; doubleTapTimer: ?TimeoutID; modalAnimation: *; modalBackgroundOpacity: *; headerTranslateValue: *; footerTranslateValue: *; imageScaleValue: *; imageTranslateValue: *; panResponder: *; calculateNextTranslate( dx: number, dy: number, scale: number ): {x: number, y: number} { const { images, imageIndex, imageTranslate, screenDimensions, } = this.state; const {x, y} = imageTranslate; const {screenWidth, screenHeight} = screenDimensions; const {width, height} = images[imageIndex]; const imageInitialScale = this.getInitialScale(); const getTranslate = (axis: string): number => { const imageSize = axis === 'x' ? width : height; const screenSize = axis === 'x' ? screenWidth : screenHeight; const leftLimit = (scale * imageSize - imageSize) / 2; const rightLimit = screenSize - imageSize - leftLimit; let nextTranslate = axis === 'x' ? x + dx : y + dy; // Less than the screen if (screenSize > scale * imageSize) { if (width >= height) { nextTranslate = (screenSize - imageSize) / 2; } else { nextTranslate = screenSize / 2 - (imageSize * (scale / imageInitialScale)) / 2; } return nextTranslate; } if (nextTranslate > leftLimit) { nextTranslate = leftLimit; } if (nextTranslate < rightLimit) { nextTranslate = rightLimit; } return nextTranslate; }; return {x: getTranslate('x'), y: getTranslate('y')}; } togglePanels(isVisible?: boolean) { const panelsVisible = typeof isVisible !== 'undefined' ? isVisible : !this.state.panelsVisible; // toggle footer and header this.setState({panelsVisible}); Animated.timing(this.headerTranslateValue.y, { toValue: !panelsVisible ? -(HEADER_HEIGHT + 44) : 0, duration: 200, useNativeDriver: true, }).start(); if (this.footerHeight > 0) { Animated.timing(this.footerTranslateValue.y, { toValue: !panelsVisible ? this.footerHeight : 0, duration: 200, useNativeDriver: true, }).start(); } } listKeyExtractor = (image: ImageType): string => this.state.images.indexOf(image).toString(); close = () => { this.setState({isVisible: false}); if (typeof this.props.onClose === 'function') { this.props.onClose(); } }; renderImage = ({item: image, index}: {item: *, index: number}): * => { const loaded = image.loaded && image.width && image.height; return ( true} > this.onImageLoaded(index)} {...this.panResponder.panHandlers} /> {!loaded && } ); }; render(): Node { const {animationType, renderFooter, backgroundColor} = this.props; const { images, imageIndex, imageScale, isVisible, scrollEnabled, } = this.state; const {close, prev, next} = this.getControls(); const imageInitialScale = this.getInitialScale(); const headerTranslate = this.headerTranslateValue.getTranslateTransform(); const footerTranslate = this.footerTranslateValue.getTranslateTransform(); const rgbBackgroundColor = backgroundColor && isHex(backgroundColor) ? hexToRgb(backgroundColor) : defaultBackgroundColor; const rgb = rgbBackgroundColor.join(','); const animatedBackgroundColor = this.modalBackgroundOpacity.interpolate( { inputRange: [0, 1], outputRange: [`rgba(${rgb}, 0.9)`, `rgba(${rgb}, 0.2)`], } ); const isPrevVisible = imageScale === imageInitialScale && imageIndex > 0; const isNextVisible = imageScale === imageInitialScale && imageIndex < images.length - 1; return ( {!!close && React.createElement(close, {onPress: this.close})} null} keyExtractor={this.listKeyExtractor} onScroll={this.onNextImage} renderItem={this.renderImage} getItemLayout={this.getItemLayout} onMomentumScrollBegin={this.onMomentumScrollBegin} onMomentumScrollEnd={this.onMomentumScrollEnd} /> {prev && isPrevVisible && React.createElement(prev, {onPress: this.scrollToPrev})} {next && isNextVisible && React.createElement(next, {onPress: this.scrollToNext})} {renderFooter && ( { this.footerHeight = event.nativeEvent.layout.height; }} > {typeof renderFooter === 'function' && images[imageIndex] && renderFooter(images[imageIndex])} )} ); } } ================================================ FILE: src/controls/Close.js ================================================ // @flow import React from 'react'; import {StyleSheet, Text, TouchableOpacity} from 'react-native'; const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15}; const styles = StyleSheet.create({ closeButton: { alignSelf: 'flex-end', height: 24, width: 24, borderRadius: 12, backgroundColor: 'rgba(0,0,0,0.2)', alignItems: 'center', justifyContent: 'center', marginTop: 25, marginRight: 15, }, closeButton__text: { backgroundColor: 'transparent', fontSize: 25, lineHeight: 25, color: '#FFF', textAlign: 'center', }, }); export default ({onPress}: {onPress: () => *}) => ( × ); ================================================ FILE: src/controls/Next.js ================================================ // @flow import React from 'react'; import {StyleSheet, Text, TouchableOpacity} from 'react-native'; const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15}; const styles = StyleSheet.create({ nextButton: { position: 'absolute', zIndex: 100, right: 10, top: '50%', height: 32, width: 32, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.3)', alignItems: 'center', justifyContent: 'center', }, nextButton__text: { backgroundColor: 'transparent', fontSize: 25, lineHeight: 25, color: '#FFF', textAlign: 'center', }, }); export default ({onPress}: {onPress: () => *}) => ( ); ================================================ FILE: src/controls/Prev.js ================================================ // @flow import React from 'react'; import {StyleSheet, Text, TouchableOpacity} from 'react-native'; const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15}; const styles = StyleSheet.create({ prevButton: { position: 'absolute', zIndex: 100, left: 10, top: '50%', height: 32, width: 32, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.3)', alignItems: 'center', justifyContent: 'center', }, prevButton__text: { backgroundColor: 'transparent', fontSize: 25, lineHeight: 25, color: '#FFF', textAlign: 'center', }, }); export default ({onPress}: {onPress: () => *}) => ( ); ================================================ FILE: src/controls/index.js ================================================ // @flow export {default as Close} from './Close'; export {default as Prev} from './Prev'; export {default as Next} from './Next'; ================================================ FILE: src/styles.js ================================================ import {StyleSheet} from 'react-native'; const HEADER_HEIGHT = 60; export default function createStyles({screenWidth, screenHeight}) { return StyleSheet.create({ underlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, container: { width: screenWidth, height: screenHeight, }, header: { position: 'absolute', top: 0, left: 0, zIndex: 100, height: HEADER_HEIGHT, width: screenWidth, }, imageContainer: { width: screenWidth, height: screenHeight, overflow: 'hidden', }, loading: { position: 'absolute', top: screenHeight / 2 - 20, alignSelf: 'center', }, footer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 100, }, }); } ================================================ FILE: src/types.js ================================================ // @flow import {type ComponentType} from 'react'; export type ControlType = { onPress: () => void, }; export type ControlsType = { close?: ?ComponentType, next?: ComponentType, prev?: ComponentType, }; export type TouchType = { pageX: number, pageY: number, }; export type NativeEventType = { touches: Array, contentOffset: {x: number, y: number}, }; export type EventType = {nativeEvent: NativeEventType}; export type ImageType = { source: any, width: number, height: number, title: ?string, index: number, }; export type TranslateType = { x: number, y: number, }; export type GestureState = { dx: number, dy: number, vx: number, vy: number, }; export type DimensionsType = {width: number, height: number}; export type ScreenDimensionsType = {screenWidth: number, screenHeight: number}; export type ImageSizeType = DimensionsType & {index: number}; export type TransitionType = {scale: number, translate: TranslateType}; ================================================ FILE: src/utils.js ================================================ // @flow import {Image, PanResponder} from 'react-native'; import { type EventType, type GestureState, type ImageType, type TouchType, type TranslateType, type TransitionType, type DimensionsType, type ScreenDimensionsType, } from './types'; const SCALE_EPSILON = 0.01; const SCALE_MULTIPLIER = 1.2; export const generatePanHandlers = ( onStart: (EventType, GestureState) => *, onMove: (EventType, GestureState) => *, onRelease: (EventType, GestureState) => * ): * => PanResponder.create({ onStartShouldSetPanResponder: (): boolean => true, onStartShouldSetPanResponderCapture: (): boolean => true, onMoveShouldSetPanResponder: (): boolean => true, onMoveShouldSetPanResponderCapture: (): boolean => true, onPanResponderGrant: onStart, onPanResponderMove: onMove, onPanResponderRelease: onRelease, onPanResponderTerminate: onRelease, onPanResponderTerminationRequest: (): void => {}, onShouldBlockNativeResponder: () => false, }); export const getScale = ( currentDistance: number, initialDistance: number ): number => (currentDistance / initialDistance) * SCALE_MULTIPLIER; export const getDistance = (touches: Array): number => { const [a, b] = touches; if (a == null || b == null) { return 0; } return Math.sqrt( Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2) ); }; export const calculateInitialScale = ( imageWidth: number = 0, imageHeight: number = 0, {screenWidth, screenHeight}: ScreenDimensionsType ): number => { const screenRatio = screenHeight / screenWidth; const imageRatio = imageHeight / imageWidth; if (imageWidth > screenWidth || imageHeight > screenHeight) { if (screenRatio > imageRatio) { return screenWidth / imageWidth; } return screenHeight / imageHeight; } return 1; }; export const calculateInitialTranslate = ( imageWidth: number = 0, imageHeight: number = 0, {screenWidth, screenHeight}: ScreenDimensionsType ): TranslateType => { const getTranslate = (axis: string): number => { const imageSize = axis === 'x' ? imageWidth : imageHeight; const screenSize = axis === 'x' ? screenWidth : screenHeight; if (imageWidth >= imageHeight) { return (screenSize - imageSize) / 2; } return screenSize / 2 - imageSize / 2; }; return { x: getTranslate('x'), y: getTranslate('y'), }; }; export const getInitialParams = ( {width, height}: DimensionsType, screenDimensions: Object ): TransitionType => ({ scale: calculateInitialScale(width, height, screenDimensions), translate: calculateInitialTranslate(width, height, screenDimensions), }); export function fetchImageSize(images: Array = []) { return images.reduce((acc, image) => { if ( image.source && image.source.uri && (!image.width || !image.height) ) { const imageSize = new Promise((resolve, reject) => { Image.getSize( image.source.uri, (width, height) => resolve({ width, height, index: image.index, }), reject ); }); acc.push(imageSize); } return acc; }, []); } const shortHexRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const fullHexRegex = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; export const isHex = (color: string): boolean => fullHexRegex.test(color) || shortHexRegex.test(color); export const hexToRgb = (hex: string): number[] => { // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") const input = hex.replace( shortHexRegex, (m, r, g, b) => `${r}${r}${g}${g}${b}${b}` ); const [match, r, g, b] = [].concat(fullHexRegex.exec(input)); if (!match) { return []; } return [parseInt(r, 16), parseInt(g, 16), parseInt(b, 16)]; }; export const addIndexesToImages = (images: ImageType[]): ImageType[] => images.map((image, index) => ({...image, index})); export const getImagesWithoutSize = (images: ImageType[]) => images.filter(({width, height}) => !width || !height); export const scalesAreEqual = (scaleA: number, scaleB: number): boolean => Math.abs(scaleA - scaleB) < SCALE_EPSILON;