rn-zoomable
Version:
A zoomable image component like Instagram
247 lines (213 loc) • 6.46 kB
JavaScript
// @flow
/* global requestAnimationFrame */
import React, { Component } from "react";
import PropTypes from "prop-types";
import autobind from "class-autobind";
import ReactNative, {
View,
Animated,
PanResponder,
Easing,
StyleSheet
} from "react-native";
import FlexImage from "./FlexImage";
import getDistance from "./helpers/getDistance";
import getScale from "./helpers/getScale";
import measureNode from "./helpers/measureNode";
import type { Measurement } from "./types/Measurement";
import type { Touch } from "./types/Touch";
type Event = {
nativeEvent: {
touches: Array<Touch>
}
};
type GestureState = {
stateID: string,
dx: number,
dy: number
};
type Props = {
style?: StyleSheet.Styles,
source: { uri: string },
isDragging: boolean,
onGestureStart: ({ photoURI: string, measurement: Measurement }) => void,
onGestureRelease: () => void,
hideDuration?: number,
renderCaption?: React.ComponentType<*>
};
type Context = {
gesturePosition: Animated.ValueXY,
scaleValue: Animated.Value,
getScrollPosition: () => number
};
export default class ZoomableImage extends Component {
props: Props;
context: Context;
_parent: ?Object;
_zoomableImage: ?Object;
_gestureHandler: Object;
_initialTouches: Array<Object>;
_selectedPhotoMeasurement: Measurement;
_gestureInProgress: ?string;
_opacity: Animated.Value;
static contextTypes = {
gesturePosition: PropTypes.object,
scaleValue: PropTypes.object,
getScrollPosition: PropTypes.func
};
static defaultProps = {
hideDuration: 250
};
constructor() {
super(...arguments);
autobind(this);
this._generatePanHandlers();
this._initialTouches = [];
this._opacity = new Animated.Value(1);
this.state = {
zoomOut: true
};
}
render() {
let { style, source, renderCaption } = this.props;
return (
<View ref={parentNode => (this._parent = parentNode)}>
{renderCaption && renderCaption()}
<Animated.View
ref={node => (this._zoomableImage = node)}
{...this._gestureHandler.panHandlers}
style={{ opacity: this._opacity }}
>
<FlexImage style={style} source={source} />
</Animated.View>
</View>
);
}
_generatePanHandlers() {
this._gestureHandler = PanResponder.create({
onStartShouldSetResponderCapture: () => true,
onStartShouldSetPanResponderCapture: (event: Event) => {
return event.nativeEvent.touches.length === 2;
},
onMoveShouldSetResponderCapture: () => true,
onMoveShouldSetPanResponderCapture: (event: Event) => {
return event.nativeEvent.touches.length === 2;
},
onPanResponderGrant: this._startGesture,
onPanResponderMove: this._onGestureMove,
onPanResponderRelease: this._onGestureRelease,
onPanResponderTerminationRequest: () => {
return this._gestureInProgress == null;
},
onPanResponderTerminate: (event, gestureState) => {
return this._onGestureRelease(event, gestureState);
}
});
}
async _startGesture(event: Event, gestureState: GestureState) {
// Sometimes gesture start happens two or more times rapidly.
if (this._gestureInProgress) {
return;
}
this._gestureInProgress = gestureState.stateID;
let { source, onGestureStart } = this.props;
let { gesturePosition, getScrollPosition } = this.context;
let { touches } = event.nativeEvent;
this._initialTouches = touches;
let selectedPhotoMeasurement = await this._measureSelectedPhoto();
this._selectedPhotoMeasurement = selectedPhotoMeasurement;
onGestureStart({
photoURI: source.uri,
measurement: selectedPhotoMeasurement
});
gesturePosition.setValue({
x: 0,
y: 0
});
gesturePosition.setOffset({
x: 0,
y: selectedPhotoMeasurement.y - getScrollPosition()
});
Animated.timing(this._opacity, {
toValue: 0,
duration: 200
}).start();
}
_onGestureMove(event: Event, gestureState: GestureState) {
let { touches } = event.nativeEvent;
if (!this._gestureInProgress) {
return;
}
if (touches.length < 2) {
// Trigger a realease
this._onGestureRelease(event, gestureState);
return;
}
// for moving photo around
let { gesturePosition, scaleValue } = this.context;
let { dx, dy } = gestureState;
gesturePosition.x.setValue(dx);
gesturePosition.y.setValue(dy);
// for scaling photo
let currentDistance = getDistance(touches);
let initialDistance = getDistance(this._initialTouches);
let newScale = getScale(currentDistance, initialDistance);
scaleValue.setValue(newScale);
}
_onGestureRelease(event, gestureState: GestureState) {
if (this._gestureInProgress !== gestureState.stateID) {
return;
}
this._gestureInProgress = null;
this._initialTouches = [];
let { onGestureRelease } = this.props;
let { gesturePosition, scaleValue, getScrollPosition } = this.context;
// set to initial position and scale
Animated.parallel([
Animated.timing(gesturePosition.x, {
toValue: 0,
duration: this.props.hideDuration,
easing: Easing.ease,
useNativeDriver: true
}),
Animated.timing(gesturePosition.y, {
toValue: 0,
duration: this.props.hideDuration,
easing: Easing.ease,
useNativeDriver: true
}),
Animated.timing(scaleValue, {
toValue: 1,
duration: this.props.hideDuration,
easing: Easing.ease,
useNativeDriver: true
})
]).start(() => {
gesturePosition.setOffset({
x: 0,
y:
(this._selectedPhotoMeasurement &&
this._selectedPhotoMeasurement.y) ||
0 - getScrollPosition()
});
this._opacity.setValue(1);
requestAnimationFrame(() => {
onGestureRelease();
});
});
}
async _measureSelectedPhoto() {
let parent = ReactNative.findNodeHandle(this._parent);
let zoomableImage = ReactNative.findNodeHandle(this._zoomableImage);
let [parentMeasurement, photoMeasurement] = await Promise.all([
measureNode(parent),
measureNode(zoomableImage)
]);
return {
x: photoMeasurement.x,
y: parentMeasurement.y + photoMeasurement.y,
w: photoMeasurement.w,
h: photoMeasurement.h
};
}
}