Skip to content

Instantly share code, notes, and snippets.

@brianpkelley
Forked from iammerrick/PinchZoomPan.js
Last active December 3, 2019 09:11
Show Gist options
  • Save brianpkelley/a5f57c5c3fc3b6e20cbdca294e49bbdd to your computer and use it in GitHub Desktop.
Save brianpkelley/a5f57c5c3fc3b6e20cbdca294e49bbdd to your computer and use it in GitHub Desktop.
React Pinch + Zoom + Pan with mouse support

Pinch + Zoom + Pan

This is a standalone pinch zoom pan React component with no other dependencies.

Demo

Open on Mobile

Features

  1. Pinch to zoom
  2. Breaking just out of min and max zooms and settling back
  3. Double tap to reset
  4. All interaction happens in the pinch zoom pan component and gets virtually computed to your underlying display.
  5. Thanks to the beauty of function-as-child components, you choose how the x, y and scale are applied. I recommend CSS3 to get that sweet hardware acceleration, you know what I'm talking about?
  6. Panning after zoom with limits sets by the size of the scaled content.
import React from 'react';
const MIN_SCALE = 0.75;
const MAX_SCALE = 2;
const SETTLE_RANGE = 0.001;
const ADDITIONAL_LIMIT = 0.2;
const DOUBLE_TAP_THRESHOLD = 300;
const ANIMATION_SPEED = 0.04;
const RESET_ANIMATION_SPEED = 0.08;
const INITIAL_X = 0;
const INITIAL_Y = 0;
const INITIAL_SCALE = 1;
const SCALE_INCREMENT = 0.075;
const settle = (val, target, range) => {
const lowerRange = val > target - range && val < target;
const upperRange = val < target + range && val > target;
return lowerRange || upperRange ? target : val;
};
const inverse = (x) => x * -1;
const getPointFromEvt = (evt, element) => {
const rect = element.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top,
};
};
const getMidpoint = (pointA, pointB) => ({
x: (pointA.x + pointB.x) / 2,
y: (pointA.y + pointB.y) / 2,
});
const getDistanceBetweenPoints = (pointA, pointB) => (
Math.sqrt(Math.pow(pointA.y - pointB.y, 2) + Math.pow(pointA.x - pointB.x, 2))
);
const between = (min, max, value) => Math.min(max, Math.max(min, value));
class PinchZoomPan extends React.Component {
constructor() {
super(...arguments);
this.initialState = this.getInititalState();
this.state = this.initialState;
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.handleWheel = this.handleWheel.bind(this);
this.handleTapStart = this.handleTapStart.bind(this);
this.handlePanMove = this.handlePanMove.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
}
zoomTo(scale, midpoint) {
const frame = () => {
if (this.state.scale === scale) return null;
const distance = scale - this.state.scale;
const targetScale = this.state.scale + (ANIMATION_SPEED * distance);
this.zoom(settle(targetScale, scale, SETTLE_RANGE), midpoint);
this.animation = requestAnimationFrame(frame);
};
this.animation = requestAnimationFrame(frame);
}
reset() {
const frame = () => {
if (this.state.scale === INITIAL_SCALE && this.state.x === INITIAL_X && this.state.y === INITIAL_Y) return null;
const distance = INITIAL_SCALE - this.state.scale;
const distanceX = INITIAL_X - this.state.x;
const distanceY = INITIAL_Y - this.state.y;
const targetScale = settle(this.state.scale + (RESET_ANIMATION_SPEED * distance), INITIAL_SCALE, SETTLE_RANGE);
const targetX = settle(this.state.x + (RESET_ANIMATION_SPEED * distanceX), INITIAL_X, SETTLE_RANGE);
const targetY = settle(this.state.y + (RESET_ANIMATION_SPEED * distanceY), INITIAL_Y, SETTLE_RANGE);
const nextWidth = this.props.width * targetScale;
const nextHeight = this.props.height * targetScale;
this.setState({
x: targetX,
y: targetY,
scale: targetScale,
width: nextWidth,
height: nextHeight,
}, () => {
this.animation = requestAnimationFrame(frame);
});
};
this.animation = requestAnimationFrame(frame);
}
getInititalState() {
return {
x: INITIAL_X,
y: INITIAL_Y,
scale: INITIAL_SCALE,
width: this.props.width,
height: this.props.height,
};
}
handleTouchStart(event) {
this.animation && cancelAnimationFrame(this.animation);
if (event.touches.length == 2) this.handlePinchStart(event);
if (event.touches.length == 1) this.handleTapStart(event);
}
handleTouchMove(event) {
if (event.touches.length == 2) this.handlePinchMove(event);
if (event.touches.length == 1) this.handlePanMove(event);
}
handleTouchEnd(event) {
if (event.touches.length > 0) return null;
if (this.state.scale > MAX_SCALE) return this.zoomTo(MAX_SCALE, this.lastMidpoint);
if (this.state.scale < MIN_SCALE) return this.zoomTo(MIN_SCALE, this.lastMidpoint);
if (this.lastTouchEnd && this.lastTouchEnd + DOUBLE_TAP_THRESHOLD > event.timeStamp) {
this.reset();
}
this.lastTouchEnd = event.timeStamp;
}
handleTapStart(event) {
const tapPoint = event.touches && event.touches.length ? event.touches[0] : event;
this.lastPanPoint = getPointFromEvt( tapPoint, this.container);
if ( !event.touches ) {
//onMouseMove={this.handlePanMove}
//onMouseUp={this.handleTapEnd}
window.document.documentElement.addEventListener( 'mousemove', this.handlePanMove );
window.document.documentElement.addEventListener( 'mouseup', this.handleMouseUp );
}
this.isPanning = true;
}
handleMouseDown(event) {
event.preventDefault();
event.stopPropagation();
const tapPoint = event;
this.lastPanPoint = getPointFromEvt( tapPoint, this.container);
window.document.documentElement.addEventListener( 'mousemove', this.handlePanMove );
window.document.documentElement.addEventListener( 'mouseup', this.handleMouseUp );
this.setState({
isPanning: true
});
}
handleMouseUp( event ) {
this.setState({
isPanning: false
});
window.document.documentElement.removeEventListener('mousemove', this.handlePanMove );
window.document.documentElement.removeEventListener('mouseup', this.handleMouseUp );
}
handlePanMove(event) {
if ( this.state.scale <= 1 || !this.state.isPanning ) {
return null;
}
event.preventDefault();
const tapPoint = event.touches && event.touches.length ? event.touches[0] : event;
const point = getPointFromEvt(tapPoint, this.container);
const nextX = this.state.x + point.x - this.lastPanPoint.x;
const nextY = this.state.y + point.y - this.lastPanPoint.y;
this.setState({
x: between((this.props.width - this.state.width)/2, (this.state.width - this.props.width)/2 , nextX),
y: between((this.props.height - this.state.height)/2, (this.state.height - this.props.height)/2, nextY),
});
this.lastPanPoint = point;
}
handlePinchStart(event) {
const pointA = getPointFromEvt(event.touches[0], this.container);
const pointB = getPointFromEvt(event.touches[1], this.container);
this.lastDistance = getDistanceBetweenPoints(pointA, pointB);
}
handlePinchMove(event) {
event.preventDefault();
const pointA = getPointFromEvt(event.touches[0], this.container);
const pointB = getPointFromEvt(event.touches[1], this.container);
const distance = getDistanceBetweenPoints(pointA, pointB);
const midpoint = getMidpoint(pointA, pointB);
const scale = between(MIN_SCALE - ADDITIONAL_LIMIT, MAX_SCALE + ADDITIONAL_LIMIT, this.state.scale * (distance / this.lastDistance));
this.zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
}
handleWheel( event ) {
const rect = this.container.getBoundingClientRect();
const scale = between(MIN_SCALE - ADDITIONAL_LIMIT, MAX_SCALE + ADDITIONAL_LIMIT, this.state.scale + (( event.deltaY > 0 ? 1 : -1 ) * SCALE_INCREMENT ));
const pointA = getPointFromEvt( event.nativeEvent, this.container );
const pointB = {x: pointA.x, y: pointA.y };
const midpoint = getMidpoint( pointA, pointB);
console.log( "WHEEL EVENT", midpoint );
this.zoom( scale, midpoint );
this.lastMidpoint = midpoint;
}
zoom(scale, midpoint) {
console.log( 'TEST', this.props, this.initialState );
let nextState;
if ( this.props.width != this.initialState.width || this.props.height != this.initialState.height ) {
this.initialState = this.getInititalState();
this.initialState.scale = this.state.scale;
this.initialState.x = this.state.x || this.initialState.x;
this.initialState.y = this.state.y || this.initialState.y;
// console.log( 'SET STATE', this.initialState );
// this.setState( this.initialState );
nextState = this.initialState;
} else {
nextState = this.state;
}
const nextWidth = this.props.width * scale;
const nextHeight = this.props.height * scale;
const tempX = nextState.x + (inverse(midpoint.x * scale) * ((nextWidth - nextState.width) / nextWidth));
const tempY = nextState.y + (inverse(midpoint.y * scale) * ((nextHeight - nextState.height) / nextHeight));
const nextX = between((this.props.width - nextWidth)/2, (nextWidth - this.props.width)/2 , tempX);
const nextY = between((this.props.height - nextHeight)/2, (nextHeight - this.props.height)/2, tempY);
console.log( nextState.x + ' + (' + inverse( midpoint.x * scale ) + ' * ' + ( nextWidth - nextState.width ) +' / ' + nextWidth + ')', nextState.x + (inverse(midpoint.x * scale) * ((nextWidth - nextState.width) / nextWidth)) );
this.setState({
width: nextWidth,
height: nextHeight,
x: nextX,
y: nextY,
scale,
midpoint
});
}
render() {
console.log( "IS PANNING", this.state.isPanning )
const className = this.state.isPanning ? 'panning' : '';
return (
<div
ref={(ref) => this.container = ref}
className={className}
// TOUCH EVENTS
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
// MOUSE EVENTS
onMouseDown={this.handleMouseDown}
onWheel={this.handleWheel}
style={{
overflow: 'hidden',
width: this.props.width,
height: this.props.height,
}}
>
{this.props.children(this.state.x, this.state.y, this.state.scale)}
</div>
);
}
}
PinchZoomPan.propTypes = {
children: React.PropTypes.func.isRequired,
};
export default PinchZoomPan;
import React from 'react';
const Usage = ({width, height}) => (
<div>
<PinchZoomPan width={width} height={height}>
{(x, y, scale) => (
<img
src={`https://placekitten.com/${width}/${height}`}
style={{
pointerEvents: scale === 1 ? 'auto' : 'none',
transform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`,
transformOrigin: '0 0',
}} />
)}
</PinchZoomPan>
</div>
);
export default Usage;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment