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
<PROJECT_ROOT>/\.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\\($\\|[^(]\\|(\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(<VERSION>\\)? *\\(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!
[](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
<p align="center">
<img src="https://raw.githubusercontent.com/antonKalinin/react-native-image-view/master/static/demoV2.gif" height="400" />
</p>
## 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,
},
];
<ImageView
images={images}
imageIndex={0}
isVisible={this.state.isImageViewVisible}
renderFooter={(currentImage) => (<View><Text>My footer</Text></View>)}
/>
```
#### [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
<PROJECT_ROOT>/\.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\\($\\|[^(]\\|(\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(<VERSION>\\)? *\\(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 (
<View style={styles.footer}>
<Text style={styles.footerText}>{title}</Text>
<TouchableOpacity
style={styles.footerButton}
onPress={() => {
const imageLikes = likes[title] + 1;
this.setState({likes: {...likes, [title]: imageLikes}});
}}
>
<Text style={styles.footerText}>♥</Text>
<Text style={[styles.footerText, {marginLeft: 7}]}>
{likes[title]}
</Text>
</TouchableOpacity>
</View>
);
}
render() {
const {isImageViewVisible, activeTab, imageIndex} = this.state;
const images = tabs[activeTab].images || [];
return (
<View style={styles.container}>
<View>
{images.map((image, index) => (
<TouchableOpacity
key={image.title}
onPress={() => {
this.setState({
imageIndex: index,
isImageViewVisible: true,
});
}}
>
<Image
style={{width, height: 200}}
source={image.source}
resizeMode="cover"
/>
</TouchableOpacity>
))}
</View>
<View style={styles.tabs}>
{tabs.map(({title}, index) => (
<TouchableOpacity
style={styles.tab}
key={title}
onPress={() => {
this.setState({
activeTab: index,
});
}}
>
<Text
style={[
styles.tabTitle,
index === activeTab &&
styles.tabTitleActive,
]}
>
{title}
</Text>
</TouchableOpacity>
))}
</View>
<ImageView
glideAlways
images={images}
imageIndex={imageIndex}
animationType="fade"
isVisible={isImageViewVisible}
renderFooter={this.renderFooter}
onClose={() => this.setState({isImageViewVisible: false})}
onImageChange={index => {
console.log(index);
}}
/>
</View>
);
}
}
================================================
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<ControlType> | boolean,
next?: ComponentType<ControlType> | boolean,
prev?: ComponentType<ControlType> | 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<PropsType, StateType> {
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<ImageType>, 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<ImageSizeType>): Array<ImageType> => {
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 (
<View
style={styles.imageContainer}
onStartShouldSetResponder={(): boolean => true}
>
<Animated.Image
resizeMode="cover"
source={image.source}
style={this.getImageStyle(image, index)}
onLoad={(): void => this.onImageLoaded(index)}
{...this.panResponder.panHandlers}
/>
{!loaded && <ActivityIndicator style={styles.loading} />}
</View>
);
};
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 (
<Modal
transparent
visible={isVisible}
animationType={animationType}
onRequestClose={this.close}
supportedOrientations={['portrait', 'landscape']}
>
<Animated.View
style={[
{backgroundColor: animatedBackgroundColor},
styles.underlay,
]}
/>
<Animated.View
style={[
styles.header,
{
transform: headerTranslate,
},
]}
>
<SafeAreaView style={{flex: 1}}>
{!!close &&
React.createElement(close, {onPress: this.close})}
</SafeAreaView>
</Animated.View>
<FlatList
horizontal
pagingEnabled
data={images}
scrollEnabled={scrollEnabled}
scrollEventThrottle={16}
style={styles.container}
ref={this.onFlatListRender}
renderSeparator={() => 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 && (
<Animated.View
style={[styles.footer, {transform: footerTranslate}]}
onLayout={event => {
this.footerHeight = event.nativeEvent.layout.height;
}}
>
{typeof renderFooter === 'function' &&
images[imageIndex] &&
renderFooter(images[imageIndex])}
</Animated.View>
)}
</Modal>
);
}
}
================================================
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: () => *}) => (
<TouchableOpacity
hitSlop={HIT_SLOP}
style={styles.closeButton}
onPress={onPress}
>
<Text style={styles.closeButton__text}>×</Text>
</TouchableOpacity>
);
================================================
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: () => *}) => (
<TouchableOpacity
hitSlop={HIT_SLOP}
style={styles.nextButton}
onPress={onPress}
>
<Text style={styles.nextButton__text}>›</Text>
</TouchableOpacity>
);
================================================
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: () => *}) => (
<TouchableOpacity
hitSlop={HIT_SLOP}
style={styles.prevButton}
onPress={onPress}
>
<Text style={styles.prevButton__text}>‹</Text>
</TouchableOpacity>
);
================================================
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<ControlType>,
next?: ComponentType<ControlType>,
prev?: ComponentType<ControlType>,
};
export type TouchType = {
pageX: number,
pageY: number,
};
export type NativeEventType = {
touches: Array<TouchType>,
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<TouchType>): 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<ImageType> = []) {
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;
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
SYMBOL INDEX (20 symbols across 7 files)
FILE: example/App.js
class App (line 123) | class App extends Component {
method constructor (line 124) | constructor(props) {
method renderFooter (line 141) | renderFooter({title}) {
method render (line 163) | render() {
FILE: src/ImageView.js
constant IMAGE_SPEED_FOR_CLOSE (line 46) | const IMAGE_SPEED_FOR_CLOSE = 1.1;
constant SCALE_MAXIMUM (line 47) | const SCALE_MAXIMUM = 5;
constant HEADER_HEIGHT (line 48) | const HEADER_HEIGHT = 60;
constant SCALE_MAX_MULTIPLIER (line 49) | const SCALE_MAX_MULTIPLIER = 3;
constant FREEZE_SCROLL_DISTANCE (line 50) | const FREEZE_SCROLL_DISTANCE = 15;
constant BACKGROUND_OPACITY_MULTIPLIER (line 51) | const BACKGROUND_OPACITY_MULTIPLIER = 0.003;
class ImageView (line 94) | class ImageView extends Component<PropsType, StateType> {
method if (line 592) | if (nextImages.length === 0) {
FILE: src/controls/Close.js
constant HIT_SLOP (line 5) | const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15};
FILE: src/controls/Next.js
constant HIT_SLOP (line 5) | const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15};
FILE: src/controls/Prev.js
constant HIT_SLOP (line 5) | const HIT_SLOP = {top: 15, left: 15, right: 15, bottom: 15};
FILE: src/styles.js
constant HEADER_HEIGHT (line 3) | const HEADER_HEIGHT = 60;
function createStyles (line 5) | function createStyles({screenWidth, screenHeight}) {
FILE: src/utils.js
constant SCALE_EPSILON (line 15) | const SCALE_EPSILON = 0.01;
constant SCALE_MULTIPLIER (line 16) | const SCALE_MULTIPLIER = 1.2;
function fetchImageSize (line 102) | function fetchImageSize(images: Array<ImageType> = []) {
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (60K chars).
[
{
"path": ".eslintrc",
"chars": 889,
"preview": "{\n \"parser\": \"babel-eslint\",\n \"extends\": [\n \"airbnb\",\n \"plugin:flowtype/recommended\",\n \"plugin:jest/recommend"
},
{
"path": ".flowconfig",
"chars": 1482,
"preview": "[ignore]\n; We fork some components by platform\n.*/*[.]android.js\n\n; Ignore \"BUCK\" generated dirs\n<PROJECT_ROOT>/\\.buckd/"
},
{
"path": ".gitignore",
"chars": 25,
"preview": "node_modules/\n.idea\n.expo"
},
{
"path": ".prettierrc",
"chars": 136,
"preview": "{\n \"bracketSpacing\": false,\n \"parser\": \"flow\",\n \"printWidth\": 80,\n \"singleQuote\": true,\n \"tabWidth\": 4,\n \"trailing"
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2017 Anton Kalinin\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 3813,
"preview": "## If you are using React Native >= 0.59.0 it's recommended to use similar package https://github.com/jobtoday/react-nat"
},
{
"path": "example/.eslintrc",
"chars": 1170,
"preview": "{\n \"extends\": [\n \"airbnb\",\n \"prettier\",\n \"prettier/flowtype\",\n \"prettier/react\"\n ],\n \"rules"
},
{
"path": "example/.flowconfig",
"chars": 1482,
"preview": "[ignore]\n; We fork some components by platform\n.*/*[.]android.js\n\n; Ignore \"BUCK\" generated dirs\n<PROJECT_ROOT>/\\.buckd/"
},
{
"path": "example/.gitignore",
"chars": 70,
"preview": "node_modules/**/*\n.expo/*\nnpm-debug.*\nyarn.lock\nImageView.js\ntypes.js\n"
},
{
"path": "example/.watchmanconfig",
"chars": 3,
"preview": "{}\n"
},
{
"path": "example/App.js",
"chars": 6481,
"preview": "import React, {Component} from 'react';\nimport {\n Text,\n View,\n Image,\n TouchableOpacity,\n StyleSheet,\n "
},
{
"path": "example/app.json",
"chars": 513,
"preview": "{\n \"expo\": {\n \"name\": \"react-native-image-view\",\n \"description\": \"React Native modal image view with pinch zoom\","
},
{
"path": "example/babel.config.js",
"chars": 117,
"preview": "module.exports = function(api) {\n api.cache(true);\n return {\n presets: ['babel-preset-expo'],\n };\n};\n"
},
{
"path": "example/metro.config.js",
"chars": 376,
"preview": "const path = require('path');\n\nmodule.exports = {\n resolver: {\n extraNodeModules: new Proxy(\n {},\n "
},
{
"path": "example/package.json",
"chars": 807,
"preview": "{\n \"main\": \"node_modules/expo/AppEntry.js\",\n \"private\": true,\n \"dependencies\": {\n \"expo\": \"^35.0.0\",\n \"react\": "
},
{
"path": "package.json",
"chars": 1416,
"preview": "{\n \"name\": \"react-native-image-view\",\n \"version\": \"2.1.9\",\n \"description\": \"React Native modal image view with pinch "
},
{
"path": "src/ImageView.js",
"chars": 27038,
"preview": "// @flow\n\nimport React, {Component, type Node, type ComponentType} from 'react';\nimport {\n ActivityIndicator,\n Ani"
},
{
"path": "src/controls/Close.js",
"chars": 903,
"preview": "// @flow\nimport React from 'react';\nimport {StyleSheet, Text, TouchableOpacity} from 'react-native';\n\nconst HIT_SLOP = {"
},
{
"path": "src/controls/Next.js",
"chars": 910,
"preview": "// @flow\nimport React from 'react';\nimport {StyleSheet, Text, TouchableOpacity} from 'react-native';\n\nconst HIT_SLOP = {"
},
{
"path": "src/controls/Prev.js",
"chars": 909,
"preview": "// @flow\nimport React from 'react';\nimport {StyleSheet, Text, TouchableOpacity} from 'react-native';\n\nconst HIT_SLOP = {"
},
{
"path": "src/controls/index.js",
"chars": 131,
"preview": "// @flow\nexport {default as Close} from './Close';\nexport {default as Prev} from './Prev';\nexport {default as Next} from"
},
{
"path": "src/styles.js",
"chars": 1049,
"preview": "import {StyleSheet} from 'react-native';\n\nconst HEADER_HEIGHT = 60;\n\nexport default function createStyles({screenWidth, "
},
{
"path": "src/types.js",
"chars": 1060,
"preview": "// @flow\nimport {type ComponentType} from 'react';\n\nexport type ControlType = {\n onPress: () => void,\n};\n\nexport type"
},
{
"path": "src/utils.js",
"chars": 4609,
"preview": "// @flow\nimport {Image, PanResponder} from 'react-native';\n\nimport {\n type EventType,\n type GestureState,\n type"
}
]
About this extraction
This page contains the full source code of the antonKalinin/react-native-image-view GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (55.1 KB), approximately 13.5k tokens, and a symbol index with 20 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.