46
votes

I have a scrollable list on a mobile device. They want people to be able to scroll the list via swiping, and also select a row by tapping.

The catch is combining the two. I don't want a row to be selected if you are actually scrolling the list. Here's what I've found:

Doesn't trigger when scrolling:

  • click
  • mouseup

Does trigger when scrolling:

  • mousedown
  • touchstart
  • touchend

The simple solution is to just stick with the click event. But what we're finding is that on certain blackberry devices, there is a VERY noticeable lag between touchstart and it then triggering either click or mouseup. This delay is significant enough to make it unusable on those devices.

So that leaves us with the other options. However, with those options, you can scroll the list without triggering the row you touched to start the scroll.

What is the best practice here to resolve this?

12
I have the same issue, I have added a bounty to your question.MacMac
had some of the same issues, ended up building native.Davin Tryon
I'll write up an answer. What we eventually did was log the position at touchstart, and then log the position at touchend. If they were significantly different, we did nothing as we assumed the intention was to scroll. If they are close, we then check the box assuming it was meant to be a tap. To still allow for click events (needed for keyboards) we would add a class on touchstart that would temporarily block any click events.DA.
Can you give an example of how to do this with jQuery?MacMac
You there, buddy? Drop a reply when you can.MacMac

12 Answers

61
votes
var touchmoved;
$('button').on('touchend', function(e){
    if(touchmoved != true){
        // button click action
    }
}).on('touchmove', function(e){
    touchmoved = true;
}).on('touchstart', function(){
    touchmoved = false;
});
23
votes

What you basically want to do is to detect what is a swipe and what is a click.

We may set some conditions:

  1. Swipe is when you touch at point p1, then move your finger to point p2 while still having the finger on the screen, then releaseing.
  2. A click is when you tap start tapping and end tapping on the same element.

So, if you store the coordinates of where your touchStart occured, you can measure the difference at touchEnd. If the change is large enough, consider it a swipe, otherwise, consider it a click.

Also, if you want to do it really neat, you can also detect which element you are "hovering" over with your finger during a touchMove, and if you're not still at the element on which you started the click, you can run a clickCancel method which removes highlights etc.

// grab an element which you can click just as an example
var clickable = document.getElementById("clickableItem"),
// set up some variables that we need for later
currentElement,
clickedElement;

// set up touchStart event handler
var onTouchStart = function(e) {
    // store which element we're currently clicking on
    clickedElement = this;
    // listen to when the user moves finger
    this.addEventListener("touchMove" onTouchMove);
    // add listener to when touch end occurs
    this.addEventListener("touchEnd", onTouchEnd);
};
// when the user swipes, update element positions to swipe
var onTouchMove = function(e) {
    // ... do your scrolling here

    // store current element
    currentElement = document.elementFromPoint(x, y);
    // if the current element is no longer the same as we clicked from the beginning, remove highlight
    if(clickedElement !== currentElement) {
        removeHighlight(clickedElement);
    }
};
// this is what is executed when the user stops the movement
var onTouchEnd = function(e) {
    if(clickedElement === currentElement) {
        removeHighlight(clickedElement);
        // .... execute click action
    }

    // clean up event listeners
    this.removeEventListener("touchMove" onTouchMove);
    this.removeEventListener("touchEnd", onTouchEnd);
};
function addHighlight(element) {
    element.className = "highlighted";
}
function removeHighlight(element) {
    element.className = "";
}
clickable.addEventListener("touchStart", onTouchStart);

Then, you will have to add listeners to you scrollable element also, but there you won't have to worry about what happens if the finger has moved inbetween touchStart and touchEnd.

var scrollable = document.getElementById("scrollableItem");

// set up touchStart event handler
var onTouchStartScrollable = function(e) {
    // listen to when the user moves finger
    this.addEventListener("touchMove" onTouchMoveScrollable);
    // add listener to when touch end occurs
    this.addEventListener("touchEnd", onTouchEndScrollable);
};
// when the user swipes, update element positions to swipe
var onTouchMoveScrollable = function(e) {
    // ... do your scrolling here
};
// this is what is executed when the user stops the movement
var onTouchEndScrollable = function(e) {
    // clean up event listeners
    this.removeEventListener("touchMove" onTouchMoveScrollable);
    this.removeEventListener("touchEnd", onTouchEndScrollable);
};
scrollable.addEventListener("touchStart", onTouchStartScrollable);

// Simon A.

17
votes

Here's what I eventually came up with to allow for a list of items to be scrollable via swipe, but also each item to be 'triggerable' via a tap. In addition, you can still use with a keyboard (using onclick).

I think this is similar to Netlight_Digital_Media's answer. I need to study that one a bit more.

$(document)
// log the position of the touchstart interaction
.bind('touchstart', function(e){ 
  touchStartPos = $(window).scrollTop();
})
// log the position of the touchend interaction
.bind('touchend', function(e){
  // calculate how far the page has moved between
  // touchstart and end. 
  var distance = touchStartPos - $(window).scrollTop();

  var $clickableItem; // the item I want to be clickable if it's NOT a swipe

  // adding this class for devices that
  // will trigger a click event after
  // the touchend event finishes. This 
  // tells the click event that we've 
  // already done things so don't repeat

  $clickableItem.addClass("touched");      

  if (distance > 20 || distance < -20){
        // the distance was more than 20px
        // so we're assuming they intended
        // to swipe to scroll the list and
        // not selecting a row. 
    } else {
        // we'll assume it was a tap 
        whateverFunctionYouWantToTriggerOnTapOrClick()
    }
});


$($clickableItem).live('click',function(e){
 // for any non-touch device, we need 
 // to still apply a click event
 // but we'll first check to see
 // if there was a previous touch
 // event by checking for the class
 // that was left by the touch event.
if ($(this).hasClass("touched")){
  // this item's event was already triggered via touch
  // so we won't call the function and reset this for
  // the next touch by removing the class
  $(this).removeClass("touched");
} else {
  // there wasn't a touch event. We're
  // instead using a mouse or keyboard
  whateverFunctionYouWantToTriggerOnTapOrClick()
}
});
6
votes

Quoting from DA.:

This is a working example:

var touch_pos;
$(document).on('touchstart', '.action-feature', function(e) {
  e.preventDefault();
  touch_pos = $(window).scrollTop();
}).on('click touchend', '.action-feature', function(e) {
  e.preventDefault();
  if(e.type=='touchend' && (Math.abs(touch_pos-$(window).scrollTop())>3)) return;
  alert("only accessed when it's a click or not a swipe");
});
3
votes

Some of these solutions worked for me, but in the end I found that this lightweight library was simpler to setup.

Tocca.js: https://github.com/GianlucaGuarini/Tocca.js

It's quite flexible and detects touch as well as swipe, double-tap etc.

2
votes

I had the same problem, here's a quick solution which works for me

$(document).on('touchstart', 'button', function(evt){ 
    var oldScrollTop = $(window).scrollTop();
    window.setTimeout( function() {
        var newScrollTop = $(window).scrollTop();
        if (Math.abs(oldScrollTop-newScrollTop)<3) $button.addClass('touchactive');
    }, 200);
});

basically instead of handling touchstart immediately, wait for some milliseconds (200ms in this example), then check the scroll position, had scrollposition changed, then we need not to handle touchstart.

1
votes

I came across this elegant solution that works like a charm using jQuery. My problem was preventing list items from calling their touch start event during scrolling. This should also work for swiping.

  1. bind touchstart to each item that will be scrolled or swiped using a class 'listObject'

    $('.listObject').live('touchstart', touchScroll);
    
  2. Then to each item assign a data-object attr defining the function to be called

    <button class='listObject' data-object=alert('You are alerted !')>Alert Me</button>
    

The following function will effectively differentiate between a tap and scrolling or swiping.

function touchScroll(e){

    var objTarget = $(event.target);

    if(objTarget.attr('data-object')){
        var fn = objTarget.attr('data-object'); //function to call if tapped    
    }   

    if(!touchEnabled){// default if not touch device
        eval(fn);
        console.log("clicked", 1);
        return;
    }

    $(e.target).on('touchend', function(e){
        eval(fn); //trigger the function
        console.log("touchEnd")      
        $(e.target).off('touchend');
    });

    $(e.target).on('touchmove', function(e){
        $(e.target).off('touchend');
        console.log("moved")
    }); 

}
1
votes

I came up with this, since i wanted a global event that also can prevent linking dynamically. So preventDefault() must be callable in the event listener, for this the touch event is referenced.

The detection is like most solutions posted here so far. On the end of the touch check if the element is still the same or if we moved some amount.

$('li a').on('ontouchstart' in document.documentElement === true ? 'touchClick' : 'click', handleClick);

if('ontouchstart' in document.documentElement === true) {
    var clickedEl = null;
    var touchClickEvent = $.Event('touchClick');
    var validClick = false;
    var pos = null;
    var moveTolerance = 15;
    $(window).on('touchstart', function(e) {
        /* only handle touch with one finger */
        if(e.changedTouches.length === 1) {
            clickedEl = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
            pos = {x: e.touches[0].clientX, y: e.touches[0].clientY};
            validClick = true;
        } else {
            validClick = false;
        }
    }).on("touchmove", function(e) {
        var currentEl = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
        if(
            e.changedTouches.length > 1 ||
            (!$(clickedEl).is(currentEl) && !$.contains(clickedEl, currentEl)) ||
            (Math.abs(pos.y - e.touches[0].clientY) > moveTolerance && Math.abs(pos.x - e.touches[0].clientX) > moveTolerance)
        ) {
            validClick = false;
        }
    }).on("touchend", function(e) {
        if(validClick) {
            /* this allowes calling of preventDefault on the touch chain */
            touchClickEvent.originalEvent = e;
            /* target is used in jQuery event delegation */
            touchClickEvent.target = e.target;
            $(clickedEl).trigger(touchClickEvent);
        }
    });
}
0
votes

jQuery Mobile has a .tap() event which seems to have the behavior you'd expect:

The jQuery Mobile tap event triggers after a quick, complete touch event that occurs on a single target object. It is the gesture equivalent of a standard click event that is triggered on the release state of the touch gesture.

This might not necessarily answer the question, but might be a useful alternative to some.

0
votes

I did this with a bit of a different work around. It's definitely not very elegant and certainly not suited to most situations, but it worked for me.

I have been using jQuery's toggleSlide() to open and close input divs, firing the slide on touchstart. The problem was that when the user wanted to scroll, the touched div would open up. To stop this from happening, (or to reverse it before the user noticed) I added a touchslide event to the document which would close the last touched div.

In more depth, here is a code snippet:

var lastTouched;

document.addEventListener('touchmove',function(){
    lastTouched.hide();
});

$('#button').addEventListener('touchstart',function(){
    $('#slide').slideToggle();
    lastTouched = $('#slide');
});

The global variable stores the last touched div, and if the user swipes, the document.touchmove event hides that div. Sometimes you get a flicker of a div poking out but it works for what I need it to, and is simple enough for me to come up with.

0
votes

I use this bit of code so that buttons are only triggered (on touchend) if not being swiped on:

var startY;
var yDistance;

function touchHandler(event) {
    touch = event.changedTouches[0];
    event.preventDefault();
}

$('.button').on("touchstart", touchHandler, true);
$('.button').on("touchmove", touchHandler, true);

$('.button').on("touchstart", function(){
    startY = touch.clientY;
});

$('.button').on('touchend', function(){

    yDistance = startY - touch.clientY;

    if(Math.abs(yDist) < 30){

        //button response here, only if user is not swiping
        console.log("button pressed")
    }
});
0
votes

if you want to do this for multiple elements and also need mouse and pointer events:

var elems = $('YOURMULTISELECTOR'); // selector for multiple elements
elems.unbind('mousdown pointerdown touchstart touchmove mouseup pointerup touchend');
var elem = null;
elems.on('mousdown pointerdown touchstart', function (e) {
    elem = yourSingleSelector(e);
}).on('touchmove', function (e) {
    elem = null;                
}).on('mouseup pointerup touchend', function (e) { 
    if (elem == yourSingleSelector(e)) {                    
        // do something
    }
});