Ionic pinch zoom with Gesture import

Hello everybody, I share this in hope that you don’t waste as much time as I did on this, it still needs one more bug to fix but more on that below.

What is this?

I have created a function that enables pinch to zoom using ionics Gesture import, for the Pan and pinch events to a div container with Vertical elements. Since I have googled this a lot a couldn’t find an answer that would let me do this without the content being an image, I found that Ionic actually uses HammerJS, to grab the touch gestures, and wraps them on the “Gesture” import as seen Here

Where can I test it?

https://github.com/p-sebastian/ionic2-pinchzoom

The Magic

There are 3 files needed

  • ~/src/pages/home/home.ts
  • ~/src/pages/home/home.scss
  • ~/src/pages/home/home.html

home.html

The <div #zoom class="zoom"> element is the fixed container that where we’ll attach the events to, and its children are the items that will be zoomed.

home.scss

.zoom to fill the container size, with a fixed position and the important touch-action: none which HammerJS needs. I have added the border color red, to show where the boundaries of each children are, so that we can see it doesn’t overflow.

home.ts

/*
 * @Author: Sebastian Penafiel Torres 
 * @Date: 2017-04-23 19:25:39 
 * @Last Modified by:   Sebastian Penafiel Torres 
 * @Last Modified time: 2017-04-23 19:25:39 
 */

import { Component, ViewChild, ElementRef } from '@angular/core';
import { NavController, Gesture, Content } from 'ionic-angular';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  @ViewChild(Content) content: Content;
  @ViewChild('zoom') zoom: ElementRef;

  constructor(public navCtrl: NavController) {

  }

  ionViewDidEnter(): void {
    // Page must be fully rendered, ionViewDidLoad, doesnt work for this. Because it shows clientHeight without the margin of the header
    this._pinchZoom(this.zoom.nativeElement, this.content);
  }
  
  private _pinchZoom(elm: HTMLElement, content: Content): void {
    const _gesture = new Gesture(elm);

    // max translate x = (container_width - element absolute_width)px
    // max translate y = (container_height - element absolute_height)px
    let ow = 0;
    let oh = 0;
    for (let i = 0; i < elm.children.length; i++) {
      let c = <HTMLElement>elm.children.item(i);
      ow = c.offsetWidth;
      oh += c.offsetHeight;
    }
    const original_x = content.contentWidth - ow;
    const original_y = content.contentHeight - oh;
    let max_x = original_x;
    let max_y = original_y;
    let min_x = 0;
    let min_y = 0;
    let x = 0;
    let y = 0;
    let last_x = 0;
    let last_y = 0;
    let scale = 1;
    let base = scale;
    
    _gesture.listen();
    _gesture.on('pan', onPan);
    _gesture.on('panend', onPanend);
    _gesture.on('pancancel', onPanend);
    // _gesture.on('tap', onTap);
    _gesture.on('pinch', onPinch);
    _gesture.on('pinchend', onPinchend);
    _gesture.on('pinchcancel', onPinchend);

    function onPan(ev) {   
      setCoor(ev.deltaX, ev.deltaY);
      transform();
    }
    function onPanend() {
      // remembers previous position to continue panning.
      last_x = x;
      last_y = y;
    }
    function onTap(ev) {
      if (ev.tapCount === 2) {
        let reset = false;
        scale += .5;
        if (scale > 2) {
          scale = 1;
          reset = true;
        }
        setBounds();
        reset ? transform(max_x/2, max_y/2) : transform();
      }
    }
    function onPinch(ev) {
      // formula to append scale to new scale
      scale = base + (ev.scale * scale - scale)/scale

      setBounds();
      transform();
    }
    function onPinchend(ev) {
      if (scale > 4) {
        scale = 4;
      }
      if (scale < 0.5) {
        scale = 0.5;
      }
      // lets pinch know where the new base will start
      base = scale;
      setBounds();
      transform();
    }
    function setBounds() {
      // I am scaling the container not the elements
      // since container is fixed, the container scales from the middle, while the
      // content scales down and right, with the top and left of the container as boundaries
      // scaled = absolute width * scale - already set width divided by 2;
      let scaled_x = Math.ceil((elm.offsetWidth * scale - elm.offsetWidth) / 2);
      let scaled_y = Math.ceil((elm.offsetHeight * scale - elm.offsetHeight) / 2);
      // for max_x && max_y; adds the value relevant to their overflowed size
      let overflow_x = Math.ceil(original_x * scale - original_x); // returns negative
      let overflow_y = Math.ceil(oh * scale - oh);
      
      max_x = original_x - scaled_x + overflow_x;
      min_x = 0 + scaled_x;
      // remove added height from container
      max_y = original_y + scaled_y - overflow_y;
      min_y = 0 + scaled_y;

      setCoor(-scaled_x, scaled_y);
      console.info(`x: ${x}, scaled_x: ${scaled_x}, y: ${y}, scaled_y: ${scaled_y}`)
    }
    function setCoor(xx: number, yy: number) {
      x = Math.min(Math.max((last_x + xx), max_x), min_x);
      y = Math.min(Math.max((last_y + yy), max_y), min_y);
    }
    // xx && yy are for resetting the position when the scale return to 1.
    function transform(xx?: number, yy?: number) {
      elm.style.webkitTransform = `translate3d(${xx || x}px, ${yy || y}px, 0) scale3d(${scale}, ${scale}, 1)`;
    }
  }

}

It takes the Content to figure out the actual size without the footer and headers getting in the way, the for loop just adds the absolute height of each children of #zoom. The original sizes equal to the viewport that the user sees minus the actual size of the content, since we override the scroll, the viewing of the content which will most likely overflow the bottom and right (because its fixed and can be any size), will be done via panning so we must know how much its overflowing.

the max sizes to pan are the original overflowed size, for now before zooming, and scale will equal to 1 at first.
base is to remember where we left off when pinched to zoom.

We initiate the event listening individually*, panend & pancancel as well as pinchend & pinchcancel are each called depending on the timing when you release the fingers so both must be added, to call their respective onEnd function.

setCoor function

sets the x & y coordinates making sure that it doesn’t go past it’s max

transform function

moves “translate”, and scales the #zoom element, the xx & yy are for resetting the position

onPinchend function

sets the base, and sets the limits of the scale

onPinch function

sets the scale depending where it left off.

setBounds function

// hard to explain.

TO-DO

The part that I am missing is that it zooms to the previous x & y coordinates, I need it to zoom to the center of the pinch.
Also to take account of negative margins the children might have.

Hope this helps somebody.

3 Likes

Thanks I could use it, say, get inspired by it actually, in my app and it’s working :slight_smile:

1 Like

Thanks man, it’s a great start on this functionality. It also clears up that Ionic already uses hammer.js library only that with the pinch event disabled by default. As you mentioned at the end, the zoom needs to be performed at the center of the pinch, also the panning… and I believe some easing-out animation will also be nice as UX. So I’ll get to it.

1 Like

Hi! You can give yourself a solution to the problem " the zoom needs to be performed at the center of the pinch". Help me! Thanks

1 Like

Works with ionic 4 / 5?