90

This is a WEB APP not a native app. Please no Objective-C NS commands.

So I need to detect 'pinch' events on iOS. Problem is every plugin or method I see for doing gestures or multi-touch events, is (usually) with jQuery and is a whole additional pluggin for every gesture under the sun. My application is huge, and I am very sensitive to deadwood in my code. All I need is to detect a pinch, and using something like jGesture is just way to bloated for my simple needs.

Additionally, I have a limited understanding of how to detect a pinch manually. I can get the position of both fingers, can't seem to get the mix right to detect this. Does anyone have a simple snippet that JUST detects pinch?

Fresheyeball
  • 28,195
  • 19
  • 94
  • 160

8 Answers8

143

Think about what a pinch event is: two fingers on an element, moving toward or away from each other. Gesture events are, to my knowledge, a fairly new standard, so probably the safest way to go about this is to use touch events like so:

(ontouchstart event)

if (e.touches.length === 2) {
    scaling = true;
    pinchStart(e);
}

(ontouchmove event)

if (scaling) {
    pinchMove(e);
}

(ontouchend event)

if (scaling) {
    pinchEnd(e);
    scaling = false;
}

To get the distance between the two fingers, use the hypot function:

var dist = Math.hypot(
    e.touches[0].pageX - e.touches[1].pageX,
    e.touches[0].pageY - e.touches[1].pageY);
Francisco C
  • 8,871
  • 4
  • 31
  • 41
Jeffrey Sweeney
  • 5,482
  • 4
  • 19
  • 29
  • 1
    Why would you write your own pinch detection? This is native functionality in iOS webkit. This is also not a good implementation as it can't tell the difference between a two-finger swipe and a pinch. Not good advice. – mmaclaurin Nov 08 '12 at 23:05
  • 36
    @mmaclaurin because webkit didn't always have pinch detection (correct me if I'm wrong), not all touchscreens use webkit, and sometimes a swipe event won't need to be detected. The OP wanted a simple solution without deadwood library functions. – Jeffrey Sweeney Nov 08 '12 at 23:58
  • 6
    OP did mention iOS, but this is the best answer when considering other platforms. Except you have left the square root part out of your distance calculation. I put it in. – undefined Nov 16 '12 at 00:05
  • 3
    @BrianMortenson That was intentional; `sqrt` can be expensive, and you generally only need to know that your fingers moved in or out by some magnitude. But.. I did say pythagorean theorem, and I wasn't technically using it ;) – Jeffrey Sweeney Nov 16 '12 at 01:57
  • heh, nobody uses two finger swipe on mobile. maybe on a trackpad and usually not for something 'in' the website, but OS level =) – FlavorScape Aug 18 '14 at 03:42
  • I completely overlooked the Math.sqrt idea (had a bout of laziness and didn't get out a pen and paper to work out the touch interaction), was just what I needed! – jh3y Jan 16 '15 at 17:31
  • 2
    @mmaclaurin Just check if (deltaX * deltaY <= 0) that way you detect all pinch cases and not the two finger swipe. – Dolma Jun 02 '15 at 08:39
  • @JeffreySweeney pinchStart is a function of the Hammer Javascript library, correct? http://hammerjs.github.io/recognizer-pinch/ – Gaucho Sep 02 '16 at 07:38
  • `e.touches` is undefined. I had to replace with `e.originalEvent.touches` – JOATMON May 22 '17 at 15:11
  • You cannot know if scaling had occurred or not, and use can pinch in and out several times and you cannot know the possible rendered end result (zoom-in / out or possibly reset back to `0` scaling). – vsync Sep 02 '17 at 08:19
  • To add to this functionality for major mobile browsers there's a way to detect if scaling actually occurred: `window.visualViewport.scale` https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/scale. However, NOTE that this is experimental! – nikitahl Mar 12 '20 at 16:56
  • What are the pinchStart, pinchMove and pinchEnd functions? Where do you use the dist variabe? – savram Mar 01 '21 at 18:21
73

You want to use the gesturestart, gesturechange, and gestureend events. These get triggered any time 2 or more fingers touch the screen.

Depending on what you need to do with the pinch gesture, your approach will need to be adjusted. The scale multiplier can be examined to determine how dramatic the user's pinch gesture was. See Apple's TouchEvent documentation for details about how the scale property will behave.

node.addEventListener('gestureend', function(e) {
    if (e.scale < 1.0) {
        // User moved fingers closer together
    } else if (e.scale > 1.0) {
        // User moved fingers further apart
    }
}, false);

You could also intercept the gesturechange event to detect a pinch as it happens if you need it to make your app feel more responsive.

Dan Herbert
  • 90,244
  • 46
  • 174
  • 217
  • 62
    I know this question was specifically about iOS but the question title is general "Simplest way to detect a pinch." The gesturestart, gesturechange, and gestureend events are iOS specific and do not work cross platform. They will not fire on Android or any other touch browsers. To do this cross platform use the touchstart, touchmove, and touchend events, like in this answer http://stackoverflow.com/a/11183333/375690. – Phil McCullick Sep 10 '14 at 15:28
  • 6
    @phil If you're looking for the simplest way to support all mobile browsers, you're better off using hammer.js – Dan Herbert Sep 11 '14 at 16:04
  • 4
    I used jQuery `$(selector).on('gestureend',...)`, and had to use `e.originalEvent.scale` instead of `e.scale`. – Chad von Nau Sep 26 '14 at 05:10
  • 3
    @ChadvonNau That's because jQuery's event object is a "normalized W3C event object". The [W3C Event object](http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html) does not include the `scale` property. It's a vendor specific property. While my answer includes the simplest way to accomplish the task with vanilla JS, if you're already using JS frameworks you would be better off using hammer.js as it will provide you with a much better API. – Dan Herbert Sep 26 '14 at 05:21
  • how do I do this for all browsers including ie8 – SuperUberDuper Aug 11 '15 at 13:07
  • 1
    @superuberduper IE8/9 have no way to detect a pinch at all. Touch APIs were not added to IE until IE10. The original question specifically asked about iOS, but to handle this across browsers you should use the hammer.js framework which abstracts away the cross-browser differences. – Dan Herbert Aug 12 '15 at 18:16
  • Just don't prevent default on absolutely everything. Or use a library that calls it on absolutely everything. I've never understood why people feel they have to do that. Always makes swiping scrollable elements a PITA. – Erik Reppen Oct 15 '15 at 02:16
30

Hammer.js all the way! It handles "transforms" (pinches). http://eightmedia.github.com/hammer.js/

But if you wish to implement it youself, i think that Jeffrey's answer is pretty solid.

Bruno
  • 1,330
  • 9
  • 11
  • I had actually just found hammer.js and implemented it before I saw Dan's answer. Hammer is pretty cool. – Fresheyeball Jun 25 '12 at 04:46
  • It looked cool, but the demos weren't that smooth. Zooming in and then trying to pan around felt really janky. – Alex K Oct 04 '13 at 14:46
  • 3
    Worth noting that Hammer has a bucket load of outstanding bugs, some of which are quite severe at time of writing this (Android in particular). Just worth thinking about. – Single Entity Mar 18 '17 at 18:12
  • 3
    Same here, buggy. Tried Hammer, ended up using Jeffrey's solution. – Paul Oct 26 '18 at 18:44
5

Unfortunately, detecting pinch gestures across browsers is a not as simple as one would hope, but HammerJS makes it a lot easier!

Check out the Pinch Zoom and Pan with HammerJS demo. This example has been tested on Android, iOS and Windows Phone.

You can find the source code at Pinch Zoom and Pan with HammerJS.

For your convenience, here is the source code:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport"
        content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
  <title>Pinch Zoom</title>
</head>

<body>

  <div>

    <div style="height:150px;background-color:#eeeeee">
      Ignore this area. Space is needed to test on the iPhone simulator as pinch simulation on the
      iPhone simulator requires the target to be near the middle of the screen and we only respect
      touch events in the image area. This space is not needed in production.
    </div>

    <style>

      .pinch-zoom-container {
        overflow: hidden;
        height: 300px;
      }

      .pinch-zoom-image {
        width: 100%;
      }

    </style>

    <script src="https://hammerjs.github.io/dist/hammer.js"></script>

    <script>

      var MIN_SCALE = 1; // 1=scaling when first loaded
      var MAX_SCALE = 64;

      // HammerJS fires "pinch" and "pan" events that are cumulative in nature and not
      // deltas. Therefore, we need to store the "last" values of scale, x and y so that we can
      // adjust the UI accordingly. It isn't until the "pinchend" and "panend" events are received
      // that we can set the "last" values.

      // Our "raw" coordinates are not scaled. This allows us to only have to modify our stored
      // coordinates when the UI is updated. It also simplifies our calculations as these
      // coordinates are without respect to the current scale.

      var imgWidth = null;
      var imgHeight = null;
      var viewportWidth = null;
      var viewportHeight = null;
      var scale = null;
      var lastScale = null;
      var container = null;
      var img = null;
      var x = 0;
      var lastX = 0;
      var y = 0;
      var lastY = 0;
      var pinchCenter = null;

      // We need to disable the following event handlers so that the browser doesn't try to
      // automatically handle our image drag gestures.
      var disableImgEventHandlers = function () {
        var events = ['onclick', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover',
                      'onmouseup', 'ondblclick', 'onfocus', 'onblur'];

        events.forEach(function (event) {
          img[event] = function () {
            return false;
          };
        });
      };

      // Traverse the DOM to calculate the absolute position of an element
      var absolutePosition = function (el) {
        var x = 0,
          y = 0;

        while (el !== null) {
          x += el.offsetLeft;
          y += el.offsetTop;
          el = el.offsetParent;
        }

        return { x: x, y: y };
      };

      var restrictScale = function (scale) {
        if (scale < MIN_SCALE) {
          scale = MIN_SCALE;
        } else if (scale > MAX_SCALE) {
          scale = MAX_SCALE;
        }
        return scale;
      };

      var restrictRawPos = function (pos, viewportDim, imgDim) {
        if (pos < viewportDim/scale - imgDim) { // too far left/up?
          pos = viewportDim/scale - imgDim;
        } else if (pos > 0) { // too far right/down?
          pos = 0;
        }
        return pos;
      };

      var updateLastPos = function (deltaX, deltaY) {
        lastX = x;
        lastY = y;
      };

      var translate = function (deltaX, deltaY) {
        // We restrict to the min of the viewport width/height or current width/height as the
        // current width/height may be smaller than the viewport width/height

        var newX = restrictRawPos(lastX + deltaX/scale,
                                  Math.min(viewportWidth, curWidth), imgWidth);
        x = newX;
        img.style.marginLeft = Math.ceil(newX*scale) + 'px';

        var newY = restrictRawPos(lastY + deltaY/scale,
                                  Math.min(viewportHeight, curHeight), imgHeight);
        y = newY;
        img.style.marginTop = Math.ceil(newY*scale) + 'px';
      };

      var zoom = function (scaleBy) {
        scale = restrictScale(lastScale*scaleBy);

        curWidth = imgWidth*scale;
        curHeight = imgHeight*scale;

        img.style.width = Math.ceil(curWidth) + 'px';
        img.style.height = Math.ceil(curHeight) + 'px';

        // Adjust margins to make sure that we aren't out of bounds
        translate(0, 0);
      };

      var rawCenter = function (e) {
        var pos = absolutePosition(container);

        // We need to account for the scroll position
        var scrollLeft = window.pageXOffset ? window.pageXOffset : document.body.scrollLeft;
        var scrollTop = window.pageYOffset ? window.pageYOffset : document.body.scrollTop;

        var zoomX = -x + (e.center.x - pos.x + scrollLeft)/scale;
        var zoomY = -y + (e.center.y - pos.y + scrollTop)/scale;

        return { x: zoomX, y: zoomY };
      };

      var updateLastScale = function () {
        lastScale = scale;
      };

      var zoomAround = function (scaleBy, rawZoomX, rawZoomY, doNotUpdateLast) {
        // Zoom
        zoom(scaleBy);

        // New raw center of viewport
        var rawCenterX = -x + Math.min(viewportWidth, curWidth)/2/scale;
        var rawCenterY = -y + Math.min(viewportHeight, curHeight)/2/scale;

        // Delta
        var deltaX = (rawCenterX - rawZoomX)*scale;
        var deltaY = (rawCenterY - rawZoomY)*scale;

        // Translate back to zoom center
        translate(deltaX, deltaY);

        if (!doNotUpdateLast) {
          updateLastScale();
          updateLastPos();
        }
      };

      var zoomCenter = function (scaleBy) {
        // Center of viewport
        var zoomX = -x + Math.min(viewportWidth, curWidth)/2/scale;
        var zoomY = -y + Math.min(viewportHeight, curHeight)/2/scale;

        zoomAround(scaleBy, zoomX, zoomY);
      };

      var zoomIn = function () {
        zoomCenter(2);
      };

      var zoomOut = function () {
        zoomCenter(1/2);
      };

      var onLoad = function () {

        img = document.getElementById('pinch-zoom-image-id');
        container = img.parentElement;

        disableImgEventHandlers();

        imgWidth = img.width;
        imgHeight = img.height;
        viewportWidth = img.offsetWidth;
        scale = viewportWidth/imgWidth;
        lastScale = scale;
        viewportHeight = img.parentElement.offsetHeight;
        curWidth = imgWidth*scale;
        curHeight = imgHeight*scale;

        var hammer = new Hammer(container, {
          domEvents: true
        });

        hammer.get('pinch').set({
          enable: true
        });

        hammer.on('pan', function (e) {
          translate(e.deltaX, e.deltaY);
        });

        hammer.on('panend', function (e) {
          updateLastPos();
        });

        hammer.on('pinch', function (e) {

          // We only calculate the pinch center on the first pinch event as we want the center to
          // stay consistent during the entire pinch
          if (pinchCenter === null) {
            pinchCenter = rawCenter(e);
            var offsetX = pinchCenter.x*scale - (-x*scale + Math.min(viewportWidth, curWidth)/2);
            var offsetY = pinchCenter.y*scale - (-y*scale + Math.min(viewportHeight, curHeight)/2);
            pinchCenterOffset = { x: offsetX, y: offsetY };
          }

          // When the user pinch zooms, she/he expects the pinch center to remain in the same
          // relative location of the screen. To achieve this, the raw zoom center is calculated by
          // first storing the pinch center and the scaled offset to the current center of the
          // image. The new scale is then used to calculate the zoom center. This has the effect of
          // actually translating the zoom center on each pinch zoom event.
          var newScale = restrictScale(scale*e.scale);
          var zoomX = pinchCenter.x*newScale - pinchCenterOffset.x;
          var zoomY = pinchCenter.y*newScale - pinchCenterOffset.y;
          var zoomCenter = { x: zoomX/newScale, y: zoomY/newScale };

          zoomAround(e.scale, zoomCenter.x, zoomCenter.y, true);
        });

        hammer.on('pinchend', function (e) {
          updateLastScale();
          updateLastPos();
          pinchCenter = null;
        });

        hammer.on('doubletap', function (e) {
          var c = rawCenter(e);
          zoomAround(2, c.x, c.y);
        });

      };

    </script>

    <button onclick="zoomIn()">Zoom In</button>
    <button onclick="zoomOut()">Zoom Out</button>

    <div class="pinch-zoom-container">
      <img id="pinch-zoom-image-id" class="pinch-zoom-image" onload="onLoad()"
           src="https://hammerjs.github.io/assets/img/pano-1.jpg">
    </div>


  </div>

</body>
</html>
redgeoff
  • 2,675
  • 1
  • 20
  • 38
5

detect two fingers pinch zoom on any element, easy and w/o hassle with 3rd party libs like Hammer.js (beware, hammer has issues with scrolling!)

function onScale(el, callback) {
    let hypo = undefined;

    el.addEventListener('touchmove', function(event) {
        if (event.targetTouches.length === 2) {
            let hypo1 = Math.hypot((event.targetTouches[0].pageX - event.targetTouches[1].pageX),
                (event.targetTouches[0].pageY - event.targetTouches[1].pageY));
            if (hypo === undefined) {
                hypo = hypo1;
            }
            callback(hypo1/hypo);
        }
    }, false);


    el.addEventListener('touchend', function(event) {
        hypo = undefined;
    }, false);
}
Saman
  • 3,656
  • 2
  • 25
  • 26
Andrey
  • 946
  • 9
  • 12
1

None of these answers achieved what I was looking for, so I wound up writing something myself. I wanted to pinch-zoom an image on my website using my MacBookPro trackpad. The following code (which requires jQuery) seems to work in Chrome and Edge, at least. Maybe this will be of use to someone else.

function setupImageEnlargement(el)
{
    // "el" represents the image element, such as the results of document.getElementByd('image-id')
    var img = $(el);
    $(window, 'html', 'body').bind('scroll touchmove mousewheel', function(e)
    {
        //TODO: need to limit this to when the mouse is over the image in question

        //TODO: behavior not the same in Safari and FF, but seems to work in Edge and Chrome

        if (typeof e.originalEvent != 'undefined' && e.originalEvent != null
            && e.originalEvent.wheelDelta != 'undefined' && e.originalEvent.wheelDelta != null)
        {
            e.preventDefault();
            e.stopPropagation();
            console.log(e);
            if (e.originalEvent.wheelDelta > 0)
            {
                // zooming
                var newW = 1.1 * parseFloat(img.width());
                var newH = 1.1 * parseFloat(img.height());
                if (newW < el.naturalWidth && newH < el.naturalHeight)
                {
                    // Go ahead and zoom the image
                    //console.log('zooming the image');
                    img.css(
                    {
                        "width": newW + 'px',
                        "height": newH + 'px',
                        "max-width": newW + 'px',
                        "max-height": newH + 'px'
                    });
                }
                else
                {
                    // Make image as big as it gets
                    //console.log('making it as big as it gets');
                    img.css(
                    {
                        "width": el.naturalWidth + 'px',
                        "height": el.naturalHeight + 'px',
                        "max-width": el.naturalWidth + 'px',
                        "max-height": el.naturalHeight + 'px'
                    });
                }
            }
            else if (e.originalEvent.wheelDelta < 0)
            {
                // shrinking
                var newW = 0.9 * parseFloat(img.width());
                var newH = 0.9 * parseFloat(img.height());

                //TODO: I had added these data-attributes to the image onload.
                // They represent the original width and height of the image on the screen.
                // If your image is normally 100% width, you may need to change these values on resize.
                var origW = parseFloat(img.attr('data-startwidth'));
                var origH = parseFloat(img.attr('data-startheight'));

                if (newW > origW && newH > origH)
                {
                    // Go ahead and shrink the image
                    //console.log('shrinking the image');
                    img.css(
                    {
                        "width": newW + 'px',
                        "height": newH + 'px',
                        "max-width": newW + 'px',
                        "max-height": newH + 'px'
                    });
                }
                else
                {
                    // Make image as small as it gets
                    //console.log('making it as small as it gets');
                    // This restores the image to its original size. You may want
                    //to do this differently, like by removing the css instead of defining it.
                    img.css(
                    {
                        "width": origW + 'px',
                        "height": origH + 'px',
                        "max-width": origW + 'px',
                        "max-height": origH + 'px'
                    });
                }
            }
        }
    });
}
gcdev
  • 1,133
  • 2
  • 12
  • 27
1

My answer is inspired by Jeffrey's answer. Where that answer gives a more abstract solution, I try to provide more concrete steps on how to potentially implement it. This is simply a guide, one that can be implemented more elegantly. For a more detailed example check out this tutorial by MDN web docs.

HTML:

<div id="zoom_here">....</div>

JS

<script>
var dist1=0;
function start(ev) {
           if (ev.targetTouches.length == 2) {//check if two fingers touched screen
               dist1 = Math.hypot( //get rough estimate of distance between two fingers
                ev.touches[0].pageX - ev.touches[1].pageX,
                ev.touches[0].pageY - ev.touches[1].pageY);                  
           }
    
    }
    function move(ev) {
           if (ev.targetTouches.length == 2 && ev.changedTouches.length == 2) {
                 // Check if the two target touches are the same ones that started
               var dist2 = Math.hypot(//get rough estimate of new distance between fingers
                ev.touches[0].pageX - ev.touches[1].pageX,
                ev.touches[0].pageY - ev.touches[1].pageY);
                //alert(dist);
                if(dist1>dist2) {//if fingers are closer now than when they first touched screen, they are pinching
                  alert('zoom out');
                }
                if(dist1<dist2) {//if fingers are further apart than when they first touched the screen, they are making the zoomin gesture
                   alert('zoom in');
                }
           }
           
    }
        document.getElementById ('zoom_here').addEventListener ('touchstart', start, false);
        document.getElementById('zoom_here').addEventListener('touchmove', move, false);
</script>
Lazarus-CG
  • 48
  • 6
0

The simplest way is to respond to the 'wheel' event.

You need to call ev.preventDefault() to prevent the browser from doing a full screen zoom.

Browsers synthesize the 'wheel' event for pinches on a trackpad, and as a bonus you also handle mouse wheel events. This is the way mapping applications handle it.

More details in my example:

let element = document.getElementById('el');
let scale = 1.0;
element.addEventListener('wheel', (ev) => {
  // This is crucial. Without it, the browser will do a full page zoom
  ev.preventDefault();

  // This is an empirically determined heuristic.
  // Unfortunately I don't know of any way to do this better.
  // Typical deltaY values from a trackpad pinch are under 1.0
  // Typical deltaY values from a mouse wheel are more than 100.
  let isPinch = ev.deltaY < 50;

  if (isPinch) {
    // This is a pinch on a trackpad
    let factor = 1 - 0.01 * ev.deltaY;
    scale *= factor;
    element.innerText = `Pinch: scale is ${scale}`;
  } else {
    // This is a mouse wheel
    let strength = 1.4;
    let factor = ev.deltaY < 0 ? strength : 1.0 / strength;
    scale *= factor;
    element.innerText = `Mouse: scale is ${scale}`;
  }
});
<div id='el' style='width:400px; height:300px; background:#ffa'>
  Scale: 1.0
</div>
Ben Harper
  • 1,661
  • 1
  • 12
  • 13