BetterTyped / react-zoom-pan-pinch

🖼 React library to support easy zoom, pan, pinch on various html dom elements like <img> and <div>
MIT License
1.51k stars 273 forks source link

Multiple simultaneous gestures, two-finger pan (e.g. panning while pinch zooming) #384

Open manuel-minniti opened 1 year ago

manuel-minniti commented 1 year ago

Is your feature request related to a problem? Please describe. Is it possible to do panning while pinch zooming? It only seems to do one at a time.

Describe the solution you'd like Being able to do panning while pinch zooming.

Describe alternatives you've considered This might be possible with a custom transform function.

This is especially useful for mobile devices – think of Google Maps for example.

Most likely related to the following code:

onTouchPanningStart = (event: TouchEvent): void => {
    const { disabled } = this.setup;
    const { onPanningStart } = this.props;

    if (disabled) return;

    const isAllowed = isPanningStartAllowed(this, event);

    if (!isAllowed) return;

    const isDoubleTap = this.lastTouch && +new Date() - this.lastTouch < 200;

    if (isDoubleTap && event.touches.length === 1) {
      this.onDoubleClick(event);
    } else {
      this.lastTouch = +new Date();

      handleCancelAnimation(this);

      const { touches } = event;

      const isPanningAction = touches.length === 1;
      const isPinchAction = touches.length === 2;

      if (isPanningAction) {
        handleCancelAnimation(this);
        handlePanningStart(this, event);
        handleCallback(getContext(this), event, onPanningStart);
      }
      if (isPinchAction) {
        this.onPinchStart(event);
      }
    }
  };

It seems that the corresponding action is determined by the number of touches.

k2xl commented 1 year ago

I wonder if this could be solved with simply changing
const isPanningAction = touches.length === 1; to const isPanningAction = touches.length >= 1;

cerahmed commented 1 year ago

+1 for this feature.

Did anyone find a workaround without changing the package's codebase?

BartholomewIU commented 7 months ago

I manage to solve it, here are the changes:

In file: react-zoom-pan-pinch/src/core/instance.core.ts Add this inside the class ZoomPanPinch:

  public pinchLastCenterX: number = null;
  public pinchLastCenterY: number = null;

Then change this file: react-zoom-pan-pinch/src/core/pinch/pinch.logic.ts

/* eslint-disable no-param-reassign */
import { ReactZoomPanPinchContext } from "../../models";
import { handleCancelAnimation } from "../animations/animations.utils";
import { handleAlignToScaleBounds } from "../zoom/zoom.logic";
import {
  calculatePinchZoom,
  calculateTouchMidPoint,
  getTouchDistance,
} from "./pinch.utils";
import { getMouseBoundedPosition, handleCalculateBounds } from "../bounds/bounds.utils";
import { handleCalculateZoomPositions } from "../zoom/zoom.utils";
import { getPaddingValue } from "../pan/panning.utils";

const getTouchCenter = (event: TouchEvent) => {
  let totalX = 0;
  let totalY = 0;
  // Sum up the positions of all touches
  for (let i = 0; i < 2; i++) {
    totalX += event.touches[i].clientX;
    totalY += event.touches[i].clientY;
  }

  // Calculate the average position
  const x = totalX / 2;
  const y = totalY / 2;

  return { x, y };
}

export const handlePinchStart = (
  contextInstance: ReactZoomPanPinchContext,
  event: TouchEvent,
): void => {
  const distance = getTouchDistance(event);

  contextInstance.pinchStartDistance = distance;
  contextInstance.lastDistance = distance;
  contextInstance.pinchStartScale = contextInstance.transformState.scale;
  contextInstance.isPanning = false;

  const center = getTouchCenter(event);
  contextInstance.pinchLastCenterX = center.x;
  contextInstance.pinchLastCenterY = center.y;

  handleCancelAnimation(contextInstance);
};

export const handlePinchZoom = (
  contextInstance: ReactZoomPanPinchContext,
  event: TouchEvent,
): void => {
  const { contentComponent, pinchStartDistance, wrapperComponent } = contextInstance;
  const { scale } = contextInstance.transformState;
  const { limitToBounds, centerZoomedOut, zoomAnimation, alignmentAnimation } =
    contextInstance.setup;
  const { disabled, size } = zoomAnimation;

  // if one finger starts from outside of wrapper
  if (pinchStartDistance === null || !contentComponent) return;

  const midPoint = calculateTouchMidPoint(event, scale, contentComponent);

  // if touches goes off of the wrapper element
  if (!Number.isFinite(midPoint.x) || !Number.isFinite(midPoint.y)) return;

  const currentDistance = getTouchDistance(event);
  const newScale = calculatePinchZoom(contextInstance, currentDistance);

  const center = getTouchCenter(event);
  // pan should be scale invariant.
  const panX = (center.x - contextInstance.pinchLastCenterX);
  const panY = (center.y - contextInstance.pinchLastCenterY);

  if (
    newScale === scale &&
    0 == panX &&
    0 == panY
  )
    return;

  contextInstance.pinchLastCenterX = center.x;
  contextInstance.pinchLastCenterY = center.y;

  const bounds = handleCalculateBounds(contextInstance, newScale);

  const isPaddingDisabled = disabled || size === 0 || centerZoomedOut;
  const isLimitedToBounds = limitToBounds && isPaddingDisabled;

  const { x, y } = handleCalculateZoomPositions(
    contextInstance,
    midPoint.x,
    midPoint.y,
    newScale,
    bounds,
    isLimitedToBounds,
  );

  contextInstance.pinchMidpoint = midPoint;
  contextInstance.lastDistance = currentDistance;

  const { sizeX, sizeY } = alignmentAnimation;
  const paddingValueX = getPaddingValue(contextInstance, sizeX);
  const paddingValueY = getPaddingValue(contextInstance, sizeY);

  const newPositionX = x + panX;
  const newPositionY = y + panY;
  const { x: finalX, y: finalY } = getMouseBoundedPosition(
    newPositionX,
    newPositionY,
    bounds,
    limitToBounds,
    paddingValueX,
    paddingValueY,
    wrapperComponent,
  );

  contextInstance.setTransformState(newScale, finalX, finalY);
};

export const handlePinchStop = (
  contextInstance: ReactZoomPanPinchContext,
): void => {
  const { pinchMidpoint } = contextInstance;

  contextInstance.velocity = null;
  contextInstance.lastDistance = null;
  contextInstance.pinchMidpoint = null;
  contextInstance.pinchStartScale = null;
  contextInstance.pinchStartDistance = null;
  handleAlignToScaleBounds(contextInstance, pinchMidpoint?.x, pinchMidpoint?.y);
};