Working with touch events

Working with touch events

touchLast 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.

So why do we need touch events?

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:

 

  • Delayed event dispatch: mouse events are usually fired with a delay, which makes the app feel unresponsive.
  • Mousemove doesn’t track, only a single emulated mousemove event is fired. This makes impossible to make complex UI interfaces with gestures.

 

 

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.

The click delay

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 slides mentioned before.

A common technique to prevent the delay is to bind the handler to both touchend and clickevents. 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:

 

  • Calling it on touchstart will kill native page scroll
  • Calling it on touchend will kill momentum scroll on some devices
  • It does not prevent at all emulated events on Android 4.x stock browsers

 

The solution we are using in Mobiscroll does the following:

  1. Attach touchstarttouchend and click events
  2. Remember start and end coordinates
  3. Call the handler e.preventDefault() on touchend, but only if movement was less then 20px in any direction (so the user did not have the intention to scroll)
  4. Set a flag to prevent executing the handler again in the emulated click event
  5. Start a timeout which will clear the flag in case when the click event was not emulated

Putting everything together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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();
});

Working with gestures

When we started building the Listview, we imagined a widget with heavy gesture support. And this is where it shines.

slide-16-touch-gestures

We needed support of following:

 

  • Use native vertical scrolling
  • Handle left and right swipe gestures, disable page scroll during swipe gestures
  • Handle tap
  • Handle tap and hold (activate sort mode)

 

Native scroll

If we want to keep using native touch scrolling, this involves that we cannot calle.preventDefault() on any of the touch events unconditionally.

Horizontal swipe

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 calling e.preventDefault(). The following thresholds are used:

 

  • If the horizontal movement is more than 7px, we consider it a swipe
  • If the vertical movement is more than 10px, we consider it a scroll

 

 

Tap

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.

Tap & hold

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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)

Problems that are still unsolved

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 triggertouchend – 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:

 

  • sort will work with the above mentioned sort handle only (by calling preventDefault()on touchstart if dragged by the handle)
  • while swiping an item left or right vertical scroll will still work

 

 

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.

Tags: