Last month I was attending the jQuery Europe conference in Vienna with the Mobiscroll team.
There was a session called Getting touchywhich gave an insight into touch events and talked about why we need them.
There is a lot of ground that the presentation covers, so make sure to check out the slides. I would like to build on top of it and share my experience on the topic.
We don’t always need to use touch events in our apps or websites. Turns out that mostly we can get away of using the regular click events because mobile browsers emulate the mouse events rather well.
While emulation works in many cases, the functionality is limited.
The usual problems are:
There are a ton of resources on the web targeting these issues, so I’m not reinventing the wheel here, I will just share my own personal experience on how I combined and extended different solutions to match our needs while building Mobiscroll.
As you probably know, there is a delay between the actual tap and the firing of the click event. I’m not going into details on why this happens, you can read about it in the slidesmentioned before.
A common technique to prevent the delay is to bind the handler to both touchend
and click
events. The challenge here is to prevent the so called “phantom click”, meaning that if your handler was called on touch end, don’t execute it again when the click event is emulated by the browser. The proposed solution here is to call e.preventDefault()
either on touchstart
ortouchend
which will prevent the firing of emulated mouse events.
However I find this problematic because:
touchstart
will kill native page scrolltouchend
will kill momentum scroll on some devices
The solution we are using in Mobiscroll does the following:
touchstart
, touchend
and click
eventse.preventDefault()
on touchend
, but only if movement was less then 20px in any direction (so the user did not have the intention to scroll)Putting everything together:
var startX, startY, tap; function getCoord(e, c) { return /touch/.test(e.type) ? (e.originalEvent || e).changedTouches[0]['page' + c] : e['page' + c]; } function setTap() { tap = true; setTimeout(function () { tap = false; }, 500); } element.on('touchstart', function (ev) { startX = getCoord(ev, 'X'); startY = getCoord(ev, 'Y'); }).on('touchend', function (ev) { // If movement is less than 20px, execute the handler if (Math.abs(getCoord(ev, 'X') - startX) < 20 && Math.abs(getCoord(ev, 'Y') - startY) < 20) { // Prevent emulated mouse events ev.preventDefault(); handler.call(this, ev); } setTap(); }).on('click', function (ev) { if (!tap) { // If handler was not called on touchend, call it on click; handler.call(this, ev); } ev.preventDefault(); });
When we started building the Listview, we imagined a widget with heavy gesture support. And this is where it shines.
We needed support of following:
If we want to keep using native touch scrolling, this involves that we cannot calle.preventDefault()
on any of the touch events unconditionally.
We need to listen to the touchmove
event and decide on the fly whether it will be a vertical or horizontal swipe. If it’s horizontal swipe, kill the page scroll with callinge.preventDefault()
. The following thresholds are used:
We need to decide if the user is tapping on a list item or intends to swipe or scroll. If movement was less than 5px in any direction tap is being honored. We also need to take care of the duplicate firing of the events, so a flag is being set – similar to the one we used for the click delay.
On touchstart
we will start a timer which activates the “sort/reorder” mode after a delay. This timer is being reset in case of a scroll, swipe, or touchend
.
Let’s take a look at our event handlers. Also note that we attach the touch events and themousedown
to the element itself, while the mousemove
and mouseup
events are attached to the document element dynamically and removed at the end. That is because they behave differently: the touchmove
and touchend
will still be fired if the finger leaves the element, while themousemove
and mouseend
event will not fire once the mouse pointer has left the element.
var touch, action, diffX, diffY, endX, endY, scroll, sort, startX, startY, swipe, function testTouch(e) { if (e.type == 'touchstart') { touch = true; } else if (touch) { touch = false; return false; } return true; } function onStart(ev) { if (testTouch(ev) && !action) { action = true; startX = getCoord(ev, 'X'); startY = getCoord(ev, 'Y'); diffX = 0; diffY = 0; sortTimer = setTimeout(function () { sort = true; }, 200); if (ev.type == 'mousedown') { $(document).on('mousemove', onMove).on('mouseup', onEnd); } } } function onMove(ev) { if (action) { endX = getCoord(ev, 'X'); endY = getCoord(ev, 'Y'); diffX = endX - startX; diffY = endY - startY; if (!sort && !swipe && !scroll) { if (Math.abs(diffY) > 10) { // It's a scroll scroll = true; // Android 4.0 will not fire touchend event $(this).trigger('touchend'); } else if (Math.abs(diffX) > 7) { // It's a swipe swipe = true; } } if (swipe) { ev.preventDefault(); // Kill page scroll // Handle swipe // ... } if (sort) { ev.preventDefault(); // Kill page scroll // Handle sort // .... } if (Math.abs(diffX) > 5 || Math.abs(diffY) > 5) { clearTimeout(sortTimer); } } } function onEnd(ev) { if (action) { action = false; if (swipe) { // Handle swipe end // ... } else if (sort) { // Handle sort end // ... } else if (!scroll && Math.abs(diffX) < 5 && Math.abs(diffY) < 5) { // Tap if (ev.type === 'touchend') { // Prevent phantom clicks ev.preventDefault(); } // Handle tap // ... } swipe = false; sort = false; scroll = false; clearTimeout(sortTimer); if (ev.type == 'mouseup') { $(document).off('mousemove', onMove).off('mouseup', onEnd); } } } element .on('touchstart mousedown', onStart) .on('touchmove', onMove) .on('touchend touchcancel', onEnd)
Android ICS
On Android ICS if no preventDefault
is called on touchstart
or the first touchmove
, furthertouchmove
events and the touchend
will not be fired. As a workaround we need to decide in the first touchmove
if this is a scroll (so we don’t call preventDefault
) and then manually trigger touchend
– see the code above.
Windows Phone
In WP8 there is no way to prevent native scroll on the fly. To be able to listen to touch events, you need to set the following css property:
touch-action: none;
However this will kill all default behavior, like native page scroll. Fortunately this can be fine tuned, so for the Listview we use the following:
touch-action: pan-y;
Which tells to browser that vertical swipe will be handled by the browser, while our code will take care of the horizontal swipe. Unfortunately sorting won’t be working, because it will not prevent native scroll while dragging an element. So in WP8 the only way to implement sorting is to use a “sort handle” element which has the touch-action: none;
rule applied. So when the user “picks” up an item from the sort handle, we know he intends to reorder.
Firefox Mobile
In Firefox Mobile the native scroll can be killed only if preventDefault() is called on thetouchstart
event. Unfortunately at touchstart
we don’t really know if we want scroll or not. This has two consequences:
preventDefault()
on touchstart
if dragged by the handle)
These issues can easily disappear in upcoming browser updates or releases, until then we need to come up with workarounds.
What are your hacks and workarounds for dealing with complex gestures? Let us know in the comment section below.