Reputation: 1792
I am trying to create a component that let you zoom in and out on an image using pinch and then move the image around in an area. The end goal being to display the image of a map and explore it. The map will always be an image.
The image is contained in a View for which I set up a panResponder. The panResponder differentiates the pinch and the touch event and call the appropriate function.
I managed to make work the "pinch to zoom" feature as well as the "move around" feature, but the latter seems clumsy to me. I suspect that the calculation I do are slightly off, it's not my strong suit.
What would be the best way to implement it ?
Here is the whole code :
import React, {
Component,
} from 'react';
import {
Dimensions,
AppRegistry,
StyleSheet,
Text,
View,
Image,
TouchableOpacity,
PanResponder,
} from 'react-native';
const screenWidth = Dimensions.get('window').width;
const screenHeight = Dimensions.get('window').height;
const img = require('image!cars');
const scaleStep = 0.1;
const DIR_IN = 'IN';
const DIR_OUT = 'OUT';
class reactzoom extends Component {
constructor(props) {
super(props);
this.state = {
zoom: 1,
x: 0,
y: 0,
distance: 0,
isZooming: false,
isMoving: false,
};
}
getDimensionsToFitArea(image, areaDimensions) {
const verticalFactor = areaDimensions.height / image.height;
const horizontalFactor = areaDimensions.width / image.width;
const imageFactor = Math.min(verticalFactor, horizontalFactor);
return {
width: image.width * imageFactor,
height: image.height * imageFactor,
};
}
setZoom(zoom) {
this.setState({
zoom: zoom,
});
}
zoomIn() {
const newZoom = this.state.zoom + scaleStep;
this.setZoom(newZoom);
}
zoomOut() {
let newZoom = this.state.zoom - (scaleStep * 1.5);
if (newZoom < 1) {
newZoom = 1;
this.resetCenter();
}
this.setZoom(newZoom);
}
cancelZoom() {
this.setZoom(1);
this.resetCenter();
}
resetCenter() {
this.setCenter(0, 0);
}
setCenter(x, y) {
let newX = 0;
let newY = 0;
if (this.state.zoom > 1) {
const imgAreaDimensions = this.getImageAreaDimensions();
if (x != 0 && y != 0) {
newX = (imgAreaDimensions.width / 2) - x;
newY = (imgAreaDimensions.height / 2) - y;
}
}
const newState = {
x: newX,
y: newY,
};
this.setState(newState);
}
getImageAreaDimensions() {
return {
width: screenWidth,
height: screenHeight / 2
};
}
processTouch(x, y) {
if (!this.state.isMoving) {
this.setState({
isMoving: true,
initialX: x,
initialY: y,
pathDoneX: 0,
pathDoneY: 0,
});
} else {
const path = calcPath(this.state.initialX, this.state.initialY, x, y);
const newX = this.state.initialX - path.x;
const newY = this.state.initialY - path.y;
this.setCenter(newX, newY);
this.setState({
pathDoneX: this.state.pathDoneX + path.x,
pathDoneY: this.state.pathDoneY + path.y,
});
}
}
processPinch(x1, y1, x2, y2) {
const distance = calcDistance(x1, y1, x2, y2);
const center = calcCenter(x1, y1, x2, y2);
const direction = (distance > this.state.distance) ? DIR_IN : DIR_OUT;
if (!this.state.isZooming) {
if (direction === DIR_IN) {
this.setCenter(center.x, center.y);
}
}
else {
(direction === DIR_IN) ? this.zoomIn() : this.zoomOut();
}
this.setState({
distance: distance,
isZooming: true,
});
}
componentWillMount() {
this._panResponder = PanResponder.create({
oneStartShouldSetPanResponderCapture: () => true,
oneMoveShouldSetPanResponder: () => true,
oneMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: () => { },
onPanResponderMove: (evt) => {
const touches = evt.nativeEvent.touches;
if (touches.length === 2) {
this.processPinch(touches[0].pageX, touches[0].pageY,
touches[1].pageX, touches[1].pageY);
} else if (touches.length === 1 && !this.state.isZooming) {
this.processTouch(touches[0].pageX, touches[0].pageY);
}
},
onPanResponderTerminationRequest: () => false,
onPanResponderRelease: () => {
this.setState({
isZooming: false,
isMoving: false,
});
},
onPanResponderTerminate: () => { },
onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
return (Math.abs(gestureState.dx) > 2) || (Math.abs(gestureState.dy) > 2)
},
});
}
render() {
const imgAreaDimensions = this.getImageAreaDimensions();
const imgDimensions = this.getDimensionsToFitArea(img, {
width: imgAreaDimensions.width,
height: imgAreaDimensions.height,
});
return (
<View style={styles.container}>
<View
style={{
borderWidth: 1,
borderColor: '#000000',
width: imgAreaDimensions.width,
height: imgAreaDimensions.height,
}}
{...this._panResponder.panHandlers}
>
<Image
style={{
width: imgDimensions.width,
height: imgDimensions.height,
transform: [
{ translateX: this.state.x },
{ translateY: this.state.y },
{ scaleX: this.state.zoom },
{ scaleY: this.state.zoom }
]
}}
source={img}
/>
</View>
<TouchableOpacity
style={styles.button}
onPress={() => this.cancelZoom() }
>
<Text>~</Text>
</TouchableOpacity>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
button: {
borderColor: '#000000',
borderWidth: 1,
padding: 25,
paddingBottom: 5,
paddingTop: 5,
alignSelf: 'stretch',
alignItems: 'center',
margin: 1,
height: 50,
}
});
AppRegistry.registerComponent('reactzoom', () => reactzoom);
function calcPath(x1, y1, x2, y2) {
return {
x: x2 - x1,
y: y2 - y1,
};
}
function calcDistance(x1, y1, x2, y2) {
const dx = Math.abs(x1 - x2);
const dy = Math.abs(y1 - y2);
return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}
function calcCenter(x1, y1, x2, y2) {
function middle(p1, p2) {
return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2;
}
return {
x: middle(x1, x2),
y: middle(y1, y2),
};
}
Upvotes: 0
Views: 8009
Reputation: 8013
It's probably due to the fact that you are using setState
calls to handle the location and the zooming of the map. The ratio at which the PanResponder
's handlers are called is simply too high to handling using setState
because that causes the render
function to be called and makes React reconciliation algorithm kick in.
What you can do to fix this is to make use of the Animated
components and call setValue
on the needed values. I slightly adapted your code (but did not test it) to give you a hint in the right direction.
class ReactZoom extends Component {
constructor(props) {
super(props);
// these values are not kept in the state
// because we will manipulate these directly
this.zoom = new Animated.Value(1);
this.x = new Animated.Value(0);
this.y = new Animated.Value(0);
...
}
setZoom(zoom) {
// change the zoom immediately
this.zoom.setValue(zoom);
}
setCenter(x, y) {
let newX = 0;
let newY = 0;
if (this.state.zoom > 1) {
const imgAreaDimensions = this.getImageAreaDimensions();
if (x != 0 && y != 0) {
newX = (imgAreaDimensions.width / 2) - x;
newY = (imgAreaDimensions.height / 2) - y;
}
}
// this make the center move directly
this.x.setValue(newX);
this.y.setValue(newY);
}
render() {
...
{/* We make use of an Animated.Image because now we can
manipulate the animated values directly without the need to re-render
*/}
<Animated.Image
style={{
width: imgDimensions.width,
height: imgDimensions.height,
transform: [
{ translateX: this.x },
{ translateY: this.y },
{ scaleX: this.zoom },
{ scaleY: this.zoom }
]
}}
source={img}
/>
...
}
}
Upvotes: 1