Skip to content

Instantly share code, notes, and snippets.

@iammerrick
Last active April 22, 2024 02:54
Show Gist options
  • Save iammerrick/c4bbac856222d65d3a11dad1c42bdcca to your computer and use it in GitHub Desktop.
Save iammerrick/c4bbac856222d65d3a11dad1c42bdcca to your computer and use it in GitHub Desktop.
React Pinch + Zoom + Pan

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 = 1;
const MAX_SCALE = 4;
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 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 getPointFromTouch = (touch, element) => {
const rect = element.getBoundingClientRect();
return {
x: touch.clientX - rect.left,
y: touch.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.state = this.getInititalState();
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.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) {
this.lastPanPoint = getPointFromTouch(event.touches[0], this.container);
}
handlePanMove(event) {
if (this.state.scale === 1) return null;
event.preventDefault();
const point = getPointFromTouch(event.touches[0], 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, 0, nextX),
y: between(this.props.height - this.state.height, 0, nextY),
});
this.lastPanPoint = point;
}
handlePinchStart(event) {
const pointA = getPointFromTouch(event.touches[0], this.container);
const pointB = getPointFromTouch(event.touches[1], this.container);
this.lastDistance = getDistanceBetweenPoints(pointA, pointB);
}
handlePinchMove(event) {
event.preventDefault();
const pointA = getPointFromTouch(event.touches[0], this.container);
const pointB = getPointFromTouch(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;
}
zoom(scale, midpoint) {
const nextWidth = this.props.width * scale;
const nextHeight = this.props.height * scale;
const nextX = this.state.x + (inverse(midpoint.x * scale) * (nextWidth - this.state.width) / nextWidth);
const nextY = this.state.y + (inverse(midpoint.y * scale) * (nextHeight - this.state.height) / nextHeight);
this.setState({
width: nextWidth,
height: nextHeight,
x: nextX,
y: nextY,
scale,
});
}
render() {
return (
<div
ref={(ref) => this.container = ref}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
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;
@kentcdodds
Copy link

thisisgiphy-reaction-audience-26FxyAeZLTTGx3Gi4

@geirman
Copy link

geirman commented Nov 10, 2016

@harryo
Copy link

harryo commented Aug 10, 2017

To ensure that the image fits properly, i.e. if scale < 1, the image stays entirely inside the area, and if scale > 1, there is no blank space inside the area, limit nextX and nextY in function zoom:


zoom (scale, midpoint) {
  ...

  this.setState({
    width: nextWidth,
    height: nextHeight,
    x: checkBetween(0, this.props.width - nextWidth, nextX),
    y: checkBetween(0, this.props.height - nextHeight, nextY),
    scale,
  });
}

where


const checkBetween = (m1, m2, value) => {
  return m1 < m2 ? between(m1, m2, value ) : between(m2, m1, value)
}

@dlewis-irse
Copy link

Thanks for this! Works like a charm.

@dennis-8
Copy link

Hi,

Thank you for the code.

I have a problem and I hope somebody can help me.

I need to center the image I'm zooming.
When I zoom it on my mobile phone the image is moving to the left. I can move it to the center, but would like to know how to zoom and keep it centered.

Here is an example - https://codepen.io/denis-n/pen/aPZzMo

@alex-r89
Copy link

alex-r89 commented Jan 10, 2019

Hi,

I seem to be able to implement this, but unable to test its functionality because the entire view zooms when I pinch (iOS safari), not the image. Does anyone know of a fix for this? Setting a meta tag on the entire page doesn't seem to work, EG <meta name="viewport" content="width=device-width, user-scalable=no" />

Thanks

@lukebertram
Copy link

Is there a reason why lastPanPoint is not stored in the PinchZoom component's local state along with x, y, scale, width, and height?

@andrasnyarai
Copy link

you are mister a hero

@vivekdaramwal
Copy link

Does it work with scrollable content? What if the child element has a PDF with multiple pages and we use it to zoom the content in it? i am going to try this out. If you guys can share some thoughts or findings, it will be helpful.

@vivekdaramwal
Copy link

I cant make it work with scrollable content (pdf).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment