Pinch and Zoom like Instagram

As you scroll down Instagram or any other social media on your phone, you browse through countless pictures. Some of them are interesting enough that you want to look closer, given the size of the small screen.

It's become intuitive for most people to "pinch" an image to activate a zoom effect. That "pinch and zoom" is not native to browsers. There's some coding gymnastics required to achieve it. I've done the heavy lifting and I thought I'd share the end result.

Try this DEMO on a mobile device.

Demo

Detecting the touch events

First of all, we need a mechanism to detect your fingers. APEX comes with a library called Hammer.js, which is used in some dynamic actions. We will leverage Hammer.js to detect pinching.

const imageElement = document.getElementById("P1_IMAGE");

const hammertime = new Hammer.Manager(imageElement, {
  touchAction: "auto",
  recognizers: [
    [Hammer.Pinch, { enable: true }]
  ]
});

hammertime.on('pinchstart', event => {
  console.log('pinchstart', event);
});

hammertime.on('pinchmove', event => {
  console.log('pinchmove', event);
});

hammertime.on('pinchend', event => {
  console.log('pinchend', event);
});

This snippet listens to touch events on P1_IMAGE. Specifically we want to listen to when a user starts pinching, moves while pinching, and stops pinching.

The Instagram pinching flow goes as follow:

  1. User can pinch and image starts zooming
  2. User can move the zoom to see other parts of the image
  3. When user stops pinch, image scales back to the original ratio

Setting up APEX

APEX Cards with custom CSS Classes for easy targeting

In my demo app, I'm using regular APEX Cards with a URL Column for the image source. On top of it I applied a custom class to make it easier to select those images later in JavaScript.

Preparing the pinch options

  const minScale = 1;
  const maxScale = 4;

  const imageElementWidth = imageElement.offsetWidth;
  const imageElementHeight = imageElement.offsetHeight;

  let imageElementScale = 1;

  let rangeMaxX = 0;
  let rangeMinX = 0;

  let rangeMaxY = 0;
  let rangeMinY = 0;
Variables needed for pinch effect
  1. We are limiting the image scale to zoom for a minimum of 1x and maximum of 4x.
  2. The other variables are used to dynamically calculate the zoom scale

Utility functions

  const updateRange = () => {
    const rangeX = Math.max(0, Math.round(imageElementWidth * imageElementScale) - imageElementWidth);
    const rangeY = Math.max(0, Math.round(imageElementHeight * imageElementScale) - imageElementHeight);

    rangeMaxX = Math.round(rangeX / 2);
    rangeMinX = Math.round(0 - rangeMaxX);

    rangeMaxY = Math.round(rangeY / 2);
    rangeMinY = Math.round(0 - rangeMaxY);
  };

  const updateImage = (deltaX, deltaY) => {
    const imageElementCurrentX = Math.min(Math.max(rangeMinX, deltaX), rangeMaxX) * 2;
    const imageElementCurrentY = Math.min(Math.max(rangeMinY, deltaY), rangeMaxY) * 2;

    const transform = `translate3d(${imageElementCurrentX}px, ${imageElementCurrentY}px, 0) scale(${imageElementScale})`;
    imageElement.style.transform = transform;
    imageElement.style.WebkitTransform = transform;
    imageElement.style.zIndex = "9999";
  };

  const resetImage = () => {
    imageElement.style.transform = "";
    imageElement.style.WebkitTransform = "";
    imageElement.style.zIndex = "";
  };
  1. `updateRange` the X and Y window through which we look at the zoom
  2. `updateImage` zooms in or out the image, with CSS `translate3d` feature
  3. `resetImage` resets everything back to it's original format

Updating our event handlers with new functions

  hammertime.on('pinchstart', event => {
    console.log('pinchstart', event);
  });

  hammertime.on('pinchmove', event => {
    // console.log('pinchmove', event);
    imageElementScale = Math.min(Math.max(minScale, event.scale), maxScale);
    updateRange();
    updateImage(event.deltaX, event.deltaY);
  });

  hammertime.on('pinchend', event => {
    console.log('pinchend', event);
    resetImage();
  });

Putting everything together

Function and Global Variable Declaration:

const pinchZoom = (imageElement) => {
  const minScale = 1;
  const maxScale = 4;

  const imageElementWidth = imageElement.offsetWidth;
  const imageElementHeight = imageElement.offsetHeight;

  let imageElementScale = 1;

  let rangeMaxX = 0;
  let rangeMinX = 0;

  let rangeMaxY = 0;
  let rangeMinY = 0;

  const updateRange = () => {
    const rangeX = Math.max(0, Math.round(imageElementWidth * imageElementScale) - imageElementWidth);
    const rangeY = Math.max(0, Math.round(imageElementHeight * imageElementScale) - imageElementHeight);

    rangeMaxX = Math.round(rangeX / 2);
    rangeMinX = Math.round(0 - rangeMaxX);

    rangeMaxY = Math.round(rangeY / 2);
    rangeMinY = Math.round(0 - rangeMaxY);
  };

  const updateImage = (deltaX, deltaY) => {
    const imageElementCurrentX = Math.min(Math.max(rangeMinX, deltaX), rangeMaxX) * 2;
    const imageElementCurrentY = Math.min(Math.max(rangeMinY, deltaY), rangeMaxY) * 2;

    const transform = `translate3d(${imageElementCurrentX}px, ${imageElementCurrentY}px, 0) scale(${imageElementScale})`;
    imageElement.style.transform = transform;
    imageElement.style.WebkitTransform = transform;
    imageElement.style.zIndex = "9999";
  };

  const resetImage = () => {
    imageElement.style.transform = "";
    imageElement.style.WebkitTransform = "";
    imageElement.style.zIndex = "";
  };

  imageElement.parentNode.style.overflow = 'visible';
  imageElement.classList.add('pz-Image');

  const hammertime = new Hammer.Manager(imageElement, {
    touchAction: "auto",
    recognizers: [
      [Hammer.Pinch, { enable: true }]
    ]
  });

  hammertime.on('pinchstart', event => {
    console.log('pinchstart', event);
  });

  hammertime.on('pinchmove', event => {
    // console.log('pinchmove', event);
    imageElementScale = Math.min(Math.max(minScale, event.scale), maxScale);
    updateRange();
    updateImage(event.deltaX, event.deltaY);
  });

  hammertime.on('pinchend', event => {
    console.log('pinchend', event);
    resetImage();
  });
}

Dynamic action after refresh:

document.querySelectorAll(".pz-Media img:not(.pz-Image)").forEach(element => {
    pinchZoom(element);
});

The final code is quite short and clear.

I love it! What do you think?