I have a touch display that uses React. I have several buttons that use the onClick
event, looking something like:
<button onClick={() => console.log('onClick')}>Hello!</button>
When I was observing users using this interface on a touch screen, I noticed that sometimes the taps did not register correctly. Users would get frustrated and mash the button harder, which made it register clicks even less.
Testing this using:
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach((x) => window.addEventListener(x, () => console.log(x)))
I found that users were moving their fingers very slightly, enough to trigger a touchmove
event. This touchmove
event effectively cancels the clicks, as clicks require touchstart
and touchend
consecutively, without touchmove
. I tried disabling the touchmove
event using
window.addEventListener('touchmove', (e) => {e.preventDefault(); e.stopImmediatePropagation()}, {passive: false})
...but that didn't really affect much.
I could refactor my code to use onTouchEnd
and onMouseUp
but this seems cumbersome. Is there a way that I can better handle onClick
events for touch devices? Is there a way of overriding the behavior of onClick
so it'll ignore touchmove
events?
Edit: Here's an example with screencast of Chrome's dev tools:
Screencast: https://imgur.com/qMZFdxl
Live demo: https://jsfiddle.net/Lwoact9u/2/
It only seems to occur in Chrome but not Firefox. I'm not sure about other browsers.
I would use a combination of WeakMap
to provide auto cleanup and pointerevent
to provide as much cross compatibility as possible.
pointerup
event, then use touchend
as a backup. This should work both in desktop and any touch device.The throttling of the action, if you need, is left to you. I deliberately left the solution generic, so people who do not use any framework can use it too.
Bonus: you can use setTimeout
or other methods within beginClick
or use pointerleave
to set currentRef
to null and thus "expiring" the event after a reasonable delay or if the pointer goes out of boundaries.
PS: Can confirm that this works on mobile stackoverflow, but due to the way code editor works, a script error is thrown first, then on the immediate following touchend, the action is correctly fired.
/*
WeakMap holds weak ref to elements as keys and functions registered on them via `init` as values. If `Element.remove` or `ParentElement.removeChild` is called, they are auto removed via GC.
The `currentRef`holds the ref to the last element with pointerdown fired on them, which is cleaned up later if any associated action fires.
*/
const wk = new WeakMap(),
currentRef = {value: null};
document.body.setAttribute("draggable", false);
//desktop or touch, do not care which one fires first
document.body.onpointerup = document.body.ontouchend = function(e){
wk.get(currentRef.value)?.(e)
}
//register the element active
function beginClick(e) {
currentRef.value = e.currentTarget;
e.currentTarget.setPointerCapture(e.pointerId);
}
//fire the action and set currentRef to null
function action(f, el, ...args){
return wk.set(el, function(e) {
f.apply(el,[e, ...args]);
el.releasePointerCapture(e.pointerId);
currentRef.value = null;
}).get(el);
}
//initiate everthing
function init(el, f, ...args){
el.onpointerdown = beginClick;
action(f, el, ...args);
}
//register the event on the element of choice
init(
document.getElementById("dabutton"),
function(e, arg1){
console.log(
"Got a click on: ", this.id,
"Pointer ID is: ", e.pointerId,
"arg1 is: ", JSON.stringify(arg1)
);
},
{haha:"hoho"}
)
body {
width: 100vw;
height: 100vh;
}
<button id="dabutton">HEY HEY HEY</button>