react-native-zoomable-box
Version:
A simple Zoomable box using react-native-gesture-handler. Compatible with react-native v0.60
394 lines (357 loc) • 12.4 kB
JavaScript
import React, { Component } from "react";
import { Animated, BackHandler, Easing } from "react-native";
import { PanGestureHandler, PinchGestureHandler, State, TapGestureHandler } from "react-native-gesture-handler";
const USE_NATIVE_DRIVER = true;
const SWIPE_COMPLETE_DIRECTION = {
X: "x",
Y: "y",
BOTH: "both",
};
type Props = {
style?: object,
backHandler?: Function,
onSwipeComplete?: Function,
} & Partial<DefaultProps>;
type DefaultProps = {
backToDefault: boolean,
swipeCompleteDirection: "x" | "y" | "both",
swipeThreshold: number,
doubleTapScale: number,
maxScale: number,
doubleTap: boolean,
animationTiming: number,
maxDoubleTapDist: number,
};
const defaultProps: DefaultProps = {
backToDefault: true,
swipeCompleteDirection: "y",
swipeThreshold: 100,
doubleTapScale: 4,
maxScale: 4,
doubleTap: false,
animationTiming: 250,
maxDoubleTapDist: 25,
};
class ZoomableBox extends Component<Props> {
static defaultProps = defaultProps;
panRef = React.createRef();
constructor(props) {
super(props);
this.state = {
isImagePinched: false,
};
}
componentDidMount() {
if (this.props.backHandler) {
this.backHandler = BackHandler.addEventListener("hardwareBackPress", () => {
if (this.pinchScaleValue !== 1 || this.lastScaleValue !== 1) {
this.backToDefault();
return true;
} else {
this.props.backHandler &&
this.props.backHandler({ translateX: this.translateX, translateY: this.translateY, scale: this.scale });
}
});
}
}
componentWillUnmount() {
this.backHandler && this.backHandler.remove();
}
isSingleSwipe = !this.props.backToDefault;
pinchScale = new Animated.Value(1);
baseScale = new Animated.Value(1);
translateX = new Animated.Value(0);
translateY = new Animated.Value(0);
scale = Animated.multiply(this.baseScale, this.pinchScale);
pinchScaleValue = 1;
lastScaleValue = 1;
swipingDirection = null;
lastTranslate = {
x: 0,
y: 0,
};
startFocal = {
x: 0,
y: 0,
};
center = {
x: 0,
y: 0,
};
image = {
width: 0,
height: 0,
};
calculateTranslateXForScale = scale => {
return ((1 - scale) * (this.startFocal.x - this.centerX)) / (this.lastScaleValue * scale) + this.lastTranslate.x;
};
calculateTranslateYForScale = scale => {
return ((1 - scale) * (this.startFocal.y - this.centerY)) / (this.lastScaleValue * scale) + this.lastTranslate.y;
};
onPinchGestureEvent = ({ nativeEvent }) => {
const { scale } = nativeEvent;
this.setState({ isImagePinched: true });
if (this.isSingleSwipe) {
this.pinchScale.setValue(scale);
this.translateX.setValue(this.calculateTranslateXForScale(scale));
this.translateY.setValue(this.calculateTranslateYForScale(scale));
} else {
this.pinchScaleValue = scale;
}
};
setScaleToMaxScale = () => {
this.lastScaleValue = this.props.maxScale;
this.animate(this.baseScale, this.lastScaleValue);
this.animate(this.pinchScale, 1);
this.animate(this.translateX, this.lastTranslate.x);
this.animate(this.translateY, this.lastTranslate.y);
};
zoomEnded = scale => {
this.lastScaleValue *= scale;
this.baseScale.setValue(this.lastScaleValue);
this.pinchScale.setValue(1);
this.lastTranslate.x += ((1 - scale) * (this.startFocal.x - this.centerX)) / this.lastScaleValue;
this.lastTranslate.y += ((1 - scale) * (this.startFocal.y - this.centerY)) / this.lastScaleValue;
};
zoomedOverMaxScale = scale => {
return this.lastScaleValue * scale >= this.props.maxScale;
};
onPinchHandlerStateChange = ({ nativeEvent: { scale, focalX, focalY, oldState } }) => {
if (oldState === State.BEGAN) {
this.startFocal.x = focalX;
this.startFocal.y = focalY;
} else if (oldState === State.ACTIVE) {
if (this.isSingleSwipe) {
if (this.zoomedOverMaxScale(scale)) {
this.setScaleToMaxScale();
} else {
this.zoomEnded(scale);
}
this.checkBorders(0, 0);
if (this.props.backToDefault || this.lastScaleValue <= 1.2) {
this.backToDefault();
}
}
}
};
onPanGestureEvent = ({ nativeEvent }) => {
const { translationX, translationY } = nativeEvent;
if (this.isSingleSwipe) {
if (this.lastScaleValue === 1) {
if (translationX && translationY && !this.swipingDirection) {
this.swipingDirection = Math.abs(translationX) > Math.abs(translationY) ? "x" : "y";
}
if (this.swipingDirection === "x") {
this.translateX.setValue(translationX / this.lastScaleValue + this.lastTranslate.x);
} else if (this.swipingDirection === "y") {
this.translateY.setValue(translationY / this.lastScaleValue + this.lastTranslate.y);
}
} else {
this.translateX.setValue(translationX / this.lastScaleValue + this.lastTranslate.x);
this.translateY.setValue(translationY / this.lastScaleValue + this.lastTranslate.y);
}
} else {
this.pinchScale.setValue(this.pinchScaleValue);
this.translateX.setValue(
this.calculateTranslateXForScale(this.pinchScaleValue) + translationX / this.pinchScaleValue,
);
this.translateY.setValue(
this.calculateTranslateYForScale(this.pinchScaleValue) + translationY / this.pinchScaleValue,
);
}
};
isTranslatedOverRightBorder = translationX => {
return (
this.lastTranslate.x + translationX / this.lastScaleValue <
((1 - this.lastScaleValue) * this.image.width) / (2 * this.lastScaleValue)
);
};
isTranslatedOverLeftBorder = translationX => {
return (
this.lastTranslate.x + translationX / this.lastScaleValue >
((this.lastScaleValue - 1) * this.image.width) / (2 * this.lastScaleValue)
);
};
isTranslatedOverBottomBorder = translationY => {
return (
this.lastTranslate.y + translationY / this.lastScaleValue <
((1 - this.lastScaleValue) * this.image.height) / (2 * this.lastScaleValue)
);
};
isTranslatedOverTopBorder = translationY => {
return (
this.lastTranslate.y + translationY / this.lastScaleValue >
((this.lastScaleValue - 1) * this.image.height) / (2 * this.lastScaleValue)
);
};
translateToRightBorder = () => {
this.lastTranslate.x = ((1 - this.lastScaleValue) * this.image.width) / (2 * this.lastScaleValue);
this.animate(this.translateX, this.lastTranslate.x);
};
translateToTopBorder() {
this.lastTranslate.y = ((this.lastScaleValue - 1) * this.image.height) / (2 * this.lastScaleValue);
this.animate(this.translateY, this.lastTranslate.y);
}
translateToBottomBorder() {
this.lastTranslate.y = ((1 - this.lastScaleValue) * this.image.height) / (2 * this.lastScaleValue);
this.animate(this.translateY, this.lastTranslate.y);
}
translateToLeftBorder() {
this.lastTranslate.x = ((this.lastScaleValue - 1) * this.image.width) / (2 * this.lastScaleValue);
this.animate(this.translateX, this.lastTranslate.x);
}
checkBorders = (translationX, translationY) => {
if (this.isTranslatedOverRightBorder(translationX)) {
this.translateToRightBorder();
} else if (this.isTranslatedOverLeftBorder(translationX)) {
this.translateToLeftBorder();
} else {
this.lastTranslate.x += translationX / this.lastScaleValue;
}
if (this.isTranslatedOverBottomBorder(translationY)) {
this.translateToBottomBorder();
} else if (this.isTranslatedOverTopBorder(translationY)) {
this.translateToTopBorder();
} else {
this.lastTranslate.y += translationY / this.lastScaleValue;
}
};
onPanHandlerStateChange = ({ nativeEvent: { translationY, velocityY, velocityX, translationX, state } }) => {
if (state === State.END) {
if (
this.lastScaleValue === 1 &&
((this.swipingDirection === SWIPE_COMPLETE_DIRECTION.Y &&
this.props.swipeCompleteDirection !== SWIPE_COMPLETE_DIRECTION.X &&
Math.abs(translationY) > this.props.swipeThreshold) ||
(this.swipingDirection === SWIPE_COMPLETE_DIRECTION.X &&
this.props.swipeCompleteDirection !== SWIPE_COMPLETE_DIRECTION.Y &&
Math.abs(translationX) > this.props.swipeThreshold))
) {
return this.onSwipeComplete({
translateX: this.translateX,
translateY: this.translateY,
scale: this.scale,
translationX,
translationY,
velocityY,
velocityX,
swipeDirection: this.swipingDirection,
});
} else {
this.checkBorders(translationX, translationY);
}
if (this.props.backToDefault || this.lastScaleValue <= 1.2) {
this.backToDefault();
}
}
};
zoomImageOnDoubleTap() {
this.lastTranslate.x = this.calculateTranslateXForScale(this.props.doubleTapScale);
this.lastTranslate.y = this.calculateTranslateYForScale(this.props.doubleTapScale);
this.lastScaleValue = this.props.doubleTapScale;
this.setState({ isImagePinched: true });
this.animate(this.baseScale, this.lastScaleValue);
this.animate(this.pinchScale, 1);
this.animate(this.translateX, this.lastTranslate.x);
this.animate(this.translateY, this.lastTranslate.y);
}
onDoubleTap = ({ nativeEvent: { x, y, state } }) => {
if (state === State.ACTIVE) {
if (this.props.doubleTap) {
this.startFocal.x = x;
this.startFocal.y = y;
if (this.lastScaleValue > 1) {
this.backToDefault();
} else {
this.zoomImageOnDoubleTap();
}
}
}
};
backToDefault = () => {
this.swipingDirection = null;
this.pinchScaleValue = 1;
this.lastScaleValue = 1;
this.animate(this.pinchScale, 1);
this.animate(this.baseScale, 1);
this.lastTranslate.x = 0;
this.lastTranslate.y = 0;
this.animate(this.translateX, 0);
this.animate(this.translateY, 0, this.props.animationTiming, () => {
this.setState({ isImagePinched: false });
});
};
onSwipeComplete = this.props.onSwipeComplete || this.backToDefault;
animate = (animatedValue, toValue, duration = this.props.animationTiming, cb) => {
animatedValue.stopAnimation(() => {
Animated.timing(animatedValue, {
toValue: toValue,
duration,
easing: Easing.linear,
useNativeDriver: USE_NATIVE_DRIVER,
}).start(cb);
});
};
render() {
const {
backToDefault,
onSwipeComplete,
swipeThreshold,
maxScale,
doubleTap,
doubleTapScale,
style,
animationTiming,
maxDoubleTapDist,
children,
backHandler,
...restProps
} = this.props;
return (
<TapGestureHandler maxDist={maxDoubleTapDist} onHandlerStateChange={this.onDoubleTap} numberOfTaps={2}>
<PanGestureHandler
ref={this.panRef}
onGestureEvent={this.onPanGestureEvent}
onHandlerStateChange={this.onPanHandlerStateChange}
minDist={10}
minPointers={this.isSingleSwipe ? 1 : 2}
maxPointers={this.isSingleSwipe ? 1 : 2}
avgTouches>
<PinchGestureHandler
simultaneousHandlers={this.panRef}
onGestureEvent={this.onPinchGestureEvent}
onHandlerStateChange={this.onPinchHandlerStateChange}>
<Animated.View
{...restProps}
onLayout={({
nativeEvent: {
layout: { width, height },
},
}) => {
this.centerX = width / 2;
this.centerY = height / 2;
this.image = {
width,
height,
};
}}
style={[
{
transform: [
{ perspective: 200 },
{ scale: this.isSingleSwipe ? this.scale : this.pinchScale },
{ translateX: this.translateX },
{ translateY: this.translateY },
],
},
style,
]}>
{children}
</Animated.View>
</PinchGestureHandler>
</PanGestureHandler>
</TapGestureHandler>
);
}
}
export default ZoomableBox;