Search code examples
javascriptgoogle-chrometouch

Using `onClick` for touch taps fails if a user moves their finger slightly during the button press


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.


Solution

  • I would use a combination of WeakMap to provide auto cleanup and pointerevent to provide as much cross compatibility as possible.

    • Register the element on a weakmap with the action(s) you want to fire.
    • Register a handler on the outermost parent to fire the event if any element was found on the WeakMap. If the device does cancel the pointerupevent, 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>