Mapbox-gl-js: Support multitouch two-finger panning

Created on 24 May 2016  Â·  38Comments  Â·  Source: mapbox/mapbox-gl-js

When using a multi touch trackpad _on OSX_ I would let the two finger slide gestures result in map panning instead of zooming. That gesture is very intuitive, and allows panning with inertia, which is easier to use than dragging the map, although both options should be available of course. Zooming could already be done by using the pinch gesture.

This is implemented by Apple as well, in their default Maps application on OSX. In my opinion it would be a great improvement.

How to implement this? Could handlers be customised / overridden? Or should it be implemented in this library itself?

feature

Most helpful comment

My workaround (allows scrolling via 1 finger touch):

map.dragPan.disable();
map.scrollZoom.disable();

map.on('touchstart', event => {
    const e = event.originalEvent;
    if (e && 'touches' in e) {
        if (e.touches.length > 1) {
            this.map.dragPan.enable();
        } else {
            this.map.dragPan.disable();
        }
    }
});

All 38 comments

We have this behavior by default in the Mapbox OS X SDK for Cocoa applications, following MapKit’s lead. On the Web, Safari has a nonstandard GestureEvent API to handle pinch and rotation gestures; I think it’s also used for two-finger panning gestures too.

On second thought, multitouch gestures are currently supported by GL JS in MobileSafari, in touch_zoom_rotate.js. Perhaps it could be generalized for desktop browsers too.

Firstly I thought pinch is already implemented, but probably it just works because it's interpreted as scroll gesture and therefore zooms in/out. Now it makes sense why that gesture not working smoothly.

It would be great to generalise touch_zoom_rotate.js for desktop. I will take a look at it.

It looks like it isn't possible to detect pinch and rotate touch gestures on desktop browsers yet.

https://hammerjs.github.io/browser-support/

Desktop Safari isn’t included in that table; as I mentioned above, it does support a proprietary GestureEvent API for these gestures. Granted, desktop Safari has a lower usage share than some of the browsers listed there.

What about the default scroll event to implement panning?

It looks like it isn't possible to detect pinch and rotate touch gestures on desktop browsers yet.

On the desktop, Internet Explorer, Edge, and Safari support these gestures via proprietary MSGestureEvent and GestureEvent APIs. I missed MSGestureEvent in https://github.com/mapbox/mapbox-gl-js/issues/2618#issuecomment-221308892.

What about the default scroll event to implement panning?

That would leave no gesture to zoom the map. ArcGIS maps do this (gestures for scrolling but not zooming), but while it’s comfortable enough for multitouch mouse users, I find it maddening when trying to navigate the map with a multitouch trackpad.

May I +1 this? Two finger gestures on mobile Safari and Chrome are separate into either a zoom intent or a drag intent. It'd be great to see this combined into zoom + pan gesture.

Please also consider Chrome on Desktop / ChromeOS, laptop/desktop devices can have multi touch.

Thanks.

Yep.. + 1

+1 on this

+1

we would be glad to review a PR implementing this feature if anyone would like to contribute!

I ended up writing this:

// two-finger-pan.js
/**
 * Two Finger Mapbox Panning
 * new twoFingerMapboxPan(<mapbox instance>)
 */
const state = {
  mapbox: null,
  panStart: { x: 0, y: 0 }
}

export default (mapbox) => {
  state.mapbox = mapbox
  state.mapbox.getContainer().addEventListener('touchstart', touchStart, false)
  state.mapbox.getContainer().addEventListener('touchmove', touchMove, false)
  if ('ontouchstart' in document.documentElement) state.mapbox.dragPan.disable()
}

function touchStart (event) {
  if (event.touches.length === 2) {
    event.stopImmediatePropagation()
    event.preventDefault()

    let x = 0
    let y = 0

    for (let touch of Array.from(event.touches)) {
      x += touch.screenX
      y += touch.screenY
    }

    state.panStart.x = x / event.touches.length
    state.panStart.y = y / event.touches.length
  }
}

function touchMove (event, callback) {
  if (event.touches.length === 2) {
    event.stopImmediatePropagation()
    event.preventDefault()

    let x = 0
    let y = 0

    for (let touch of Array.from(event.touches)) {
      x += touch.screenX
      y += touch.screenY
    }

    const movex = (x / event.touches.length) - state.panStart.x
    const movey = (y / event.touches.length) - state.panStart.y

    state.panStart.x = x / event.touches.length
    state.panStart.y = y / event.touches.length

    state.mapbox.panBy(
      [
        (movex * 1) / -1,
        (movey * 1) / -1
      ],
      { animate: false }
    )
  }
}

// Map.js
import twoFingerMapboxPan from './two-finger-pan'
twoFingerMapboxPan(mapbox)

@pea this solution is great for two finger pan but then completely disables pinch to zoom.

i added in this

const state = {
    mapbox: null,
    panStart: {
        x: 0,
        y: 0
    },
    scale: 1
}

and then in touchMove

if (state.scale == event.scale) {
            event.stopImmediatePropagation()
            event.preventDefault();
        }

        state.scale = event.scale;

This allows for pinch to zoom and two finger panning

Ah you're probably right. I think pinch zooming can be incorporated into the above code so I'll revisit it when I have time.

Would be great if all this could be included in Mapbox as standard though.

@pea Sorry edited my message before seeing your reply.
I fixed the solution:
here it is, you'll notice the new "scale" property in the state

Cheers!

// two-finger-pan.js
/**
 * Two Finger Mapbox Panning
 * new twoFingerMapboxPan(<mapbox instance>)
 */
const state = {
    mapbox: null,
    panStart: {
        x: 0,
        y: 0
    },
    scale: 1
}

export default (mapbox) => {
    state.mapbox = mapbox
    state.mapbox.getContainer().addEventListener('touchstart', touchStart, false)
    state.mapbox.getContainer().addEventListener('touchmove', touchMove, false)
    if ('ontouchstart' in document.documentElement) state.mapbox.dragPan.disable()
}

function touchStart(event) {
    if (event.touches.length === 2) {
        event.stopImmediatePropagation()
        event.preventDefault()

        let x = 0
        let y = 0

        for (let touch of Array.from(event.touches)) {
            x += touch.screenX
            y += touch.screenY
        }

        state.panStart.x = x / event.touches.length
        state.panStart.y = y / event.touches.length
    }
}

function touchMove(event, callback) {
    if (event.touches.length === 2) {
        if (state.scale == event.scale) {
            event.stopImmediatePropagation()
            event.preventDefault();
        }

        state.scale = event.scale;

        let x = 0
        let y = 0

        for (let touch of Array.from(event.touches)) {
            x += touch.screenX
            y += touch.screenY
        }

        const movex = (x / event.touches.length) - state.panStart.x
        const movey = (y / event.touches.length) - state.panStart.y

        state.panStart.x = x / event.touches.length
        state.panStart.y = y / event.touches.length

        state.mapbox.panBy(
            [
                (movex * 1) / -1,
                (movey * 1) / -1
            ], {
                animate: false
            }
        )
    }
}

Instead of wrapping full clause block with if {} better do

if (event.touches.length !== 2) {
  return
}

// here goes your piece that should work for several fingers logic handling

What do people think about expanding the scope of the touch zoom rotate handler to include all 2 finger touch gestures? It could disambuguate between intent to zoom, pan, rotate, and pitch. Ultimately it seems like we want to be able to move around the map like this demo lets you do: https://rawgit.com/axelpale/nudged/master/examples/nudged-gesture/index.html

I mainly want to prevent focus or scroll traps on devices. 2 finger touch gestures may be helpful with that. Scrolling on touchscreens, via wheel or touchpad should not lead to people panning or zooming the map accidentally and then having problems to scroll the surrounding page. With leafletjs one can do e.g.

var map = L.map(id, { dragging: !L.Browser.mobile, scrollWheelZoom: false });
map.on('focus', function() { map.scrollWheelZoom.enable(); });
map.on('blur', function() { map.scrollWheelZoom.disable(); });

I wonder how I'd do that with mapboxgl-js.

Code of @pea and @northkode as mapbox control on npm:

https://github.com/bravecow/mapbox-gl-multitouch

We use it in production. Thanks 👍

@bravecow it's a good start but on desktop browsers (non touch) completely disables dragging and on mobile pinching no longer works.

This issue should really be a priority as the current behavior really breaks scrolling up and down a page with a map on mobile devices.

Google Maps behavior works great, Mapbox-GL should aim for that.

@jxlarrea definitely you are right. this is just small workaround to allow users to scroll pages on mobile. we all are waiting for resolving this issue.

@bravecow my workaround is a bit more dirty but in my opinion a bit more effective. Initially scrollZoom and dragPan are disabled. Once the user clicks/taps, zooms or use any of the controls of the map, dragPan becomes enabled.

This way, when the page loads the user is able to scroll through the page and only when the user clicks the map (which means they intent to interact with it), drag/pan becomes enabled. At least this way the map retains its pinch to zoom behavior.

```
let map = new mapboxgl.Map({
container: 'map',
center: [center.lng, center.lat],
zoom: 4,
scrollZoom: false,
dragPan: false,
minZoom: 0,
maxZoom: 11,
attributionControl: false,
fullScreenControl: true,
style: 'mystyle'
});


    let clickFunc = function (e) {
        map.dragPan.enable();
        map.off('click', clickFunc);
    };

    let zoomFunc = function (e) {
        if (e.source !== 'fitBounds') {
            map.dragPan.enable();
            map.off('zoomend', zoomFunc);
        }
    };

    map.on('click', clickFunc);
    map.on('zoomend', zoomFunc);

```

Here's my work-around:

this.map.on('dragstart', (event) => {
    if (event.originalEvent && 'touches' in event.originalEvent && event.originalEvent.touches.length >= 2) {
        console.log('legit, num touches >= 2');
    } else {
        this.map.dragPan.disable();
        this.map.dragPan.enable();
        console.log('not legit, disable causes cancellation of drag');
    }
});

@pea this solution is great for two finger pan but then completely disables pinch to zoom.

i added in this

const state = {
    mapbox: null,
    panStart: {
        x: 0,
        y: 0
    },
    scale: 1
}

and then in touchMove

if (state.scale == event.scale) {
            event.stopImmediatePropagation()
            event.preventDefault();
        }

        state.scale = event.scale;

This allows for pinch to zoom and two finger panning

Only zoom in is working but it doesn't zoom out.
event.scale is always undefined.

Any news on this? I am trying @pea solution, but I don't know how to install it (I am js newbie).

@northkode sadly the changes you made still won't allow Pinch to zoom. The dragging is working but zooming not. Any idea? I can't seem to figure it out

I am using a map plugin for a CMS, referencing mapbox styles. This plugin doesn't allow to pan with two fingers (only) on mobile, so I thought I just go back to implementing Mapbox GL JS manually again, hoping this would be possible. Seems, here, it is the same issue.

Please, implement this, as I really don't want to keep using the ugly and not very customizable GMaps embeds, which seems the only way to not scroll-lock pages with maps on them.

Mapbox is of no use for responsive websites this way and – in my opinion – should make this feature a top priority for the devs.

@jxlarrea Thank you for your solution, it is working well. I was quite surprised this was not already implemented by Mapbox as it seems like basic UX functionality for mobile.

@jxlarrea Thank you for your solution, it is working well. I was quite surprised this was not already implemented by Mapbox as it seems like basic UX functionality for mobile.

@puremana glad it helped. It has a couple of drawbacks but it behaves infinitely better than the default implementation. I'm still amazed and, frankly, disappointed that after all these years they still haven't implemented proper multi touch / panning support.

Adding onto @jxlarrea 's solution, we only wanted this functionality to occur on mobile so we used

        let mapData = {
            container: 'map',
            style: 'mapbox://styles/mapbox/streets-v11',
            center: [center.lng, center.lat],
            zoom: 6,
            scrollZoom: false,
            attributionControl: false,
            fullScreenControl: true
        };
        if (/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent)) {
            mapData["dragPan"] = false;
        }

        var map = new mapboxgl.Map(mapData);

       // jxlarrea's code below...

As workaround we can disable pan drag on one finger touch and re-enable when detected two finger touch. (inspired by @woutervh- solution)

  const isTouchEvent = e => e.originalEvent && "touches" in e.originalEvent;
  const isTwoFingerTouch = e => e.originalEvent.touches.length >= 2;

  map.on("dragstart", event => {
    if (isTouchEvent(event) && !isTwoFingerTouch(event)) {
       map.dragPan.disable();
    }
  });

  // Drag events not emited after dragPan disabled, so I use touch event here
  map.on("touchstart", event => {
      if (isTouchEvent(event) && isTwoFingerTouch(event)) {
       map.dragPan.enable();
    }
  });

Sandbox:
https://codesandbox.io/s/react-mapbox-gl-cdzbz

My workaround (allows scrolling via 1 finger touch):

map.dragPan.disable();
map.scrollZoom.disable();

map.on('touchstart', event => {
    const e = event.originalEvent;
    if (e && 'touches' in e) {
        if (e.touches.length > 1) {
            this.map.dragPan.enable();
        } else {
            this.map.dragPan.disable();
        }
    }
});

It would be nice to have a simple option to do this.

For future searches that lead here (I am using react-map-gl so be aware.

To make scrolling better on mobile, set dragPan and touchAction like so:

<ReactMapGL
      mapboxApiAccessToken={TOKEN}
      mapStyle={mapStyle}
      dragPan={false} // <-- 
      touchAction={'pan-y'} // <-- 
    >

By default, the map captures all touch interactions. touchAction prop is useful for mobile applications to unblock default scrolling behaviour. For example, use the combination dragPan: false and touchAction: 'pan-y' to allow vertical page scroll when dragging over the map.

Docs: https://visgl.github.io/react-map-gl/docs/api-reference/interactive-map

I solved the mobile scroll issue in this way:

if (/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent)) {
    var map = this._map;
    map.dragPan.disable();
    map.scrollZoom.disable();
    map.touchPitch.disable()
    map.on('touchstart', function(e) {
        var oe = e.originalEvent;
        if (oe && 'touches' in oe) {
            if (oe.touches.length > 1) {
                oe.stopImmediatePropagation();
                map.dragPan.enable();
            } else {
                map.dragPan.disable();
            }
        }
    });
}

+1

+1

Was this page helpful?
0 / 5 - 0 ratings