diff options
Diffstat (limited to 'src/ngMobile/directive/ngClick.js')
| -rw-r--r-- | src/ngMobile/directive/ngClick.js | 244 | 
1 files changed, 244 insertions, 0 deletions
diff --git a/src/ngMobile/directive/ngClick.js b/src/ngMobile/directive/ngClick.js new file mode 100644 index 00000000..9b8e225c --- /dev/null +++ b/src/ngMobile/directive/ngClick.js @@ -0,0 +1,244 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ngMobile.directive:ngTap + * + * @description + * Specify custom behavior when element is tapped on a touchscreen device. + * A tap is a brief, down-and-up touch without much motion. + * + * @element ANY + * @param {expression} ngClick {@link guide/expression Expression} to evaluate + * upon tap. (Event object is available as `$event`) + * + * @example +    <doc:example> +      <doc:source> +        <button ng-tap="count = count + 1" ng-init="count=0"> +          Increment +        </button> +        count: {{ count }} +      </doc:source> +    </doc:example> + */ + +ngMobile.config(function($provide) { +  $provide.decorator('ngClickDirective', function($delegate) { +    // drop the default ngClick directive +    $delegate.shift(); +    return $delegate; +  }); +}); + +ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement', +    function($parse, $timeout, $rootElement) { +  var TAP_DURATION = 750; // Shorter than 750ms is a tap, longer is a taphold or drag. +  var MOVE_TOLERANCE = 12; // 12px seems to work in most mobile browsers. +  var PREVENT_DURATION = 2500; // 2.5 seconds maximum from preventGhostClick call to click +  var CLICKBUSTER_THRESHOLD = 25; // 25 pixels in any dimension is the limit for busting clicks. +  var lastPreventedTime; +  var touchCoordinates; + + +  // TAP EVENTS AND GHOST CLICKS +  // +  // Why tap events? +  // Mobile browsers detect a tap, then wait a moment (usually ~300ms) to see if you're +  // double-tapping, and then fire a click event. +  // +  // This delay sucks and makes mobile apps feel unresponsive. +  // So we detect touchstart, touchmove, touchcancel and touchend ourselves and determine when +  // the user has tapped on something. +  // +  // What happens when the browser then generates a click event? +  // The browser, of course, also detects the tap and fires a click after a delay. This results in +  // tapping/clicking twice. So we do "clickbusting" to prevent it. +  // +  // How does it work? +  // We attach global touchstart and click handlers, that run during the capture (early) phase. +  // So the sequence for a tap is: +  // - global touchstart: Sets an "allowable region" at the point touched. +  // - element's touchstart: Starts a touch +  // (- touchmove or touchcancel ends the touch, no click follows) +  // - element's touchend: Determines if the tap is valid (didn't move too far away, didn't hold +  //   too long) and fires the user's tap handler. The touchend also calls preventGhostClick(). +  // - preventGhostClick() removes the allowable region the global touchstart created. +  // - The browser generates a click event. +  // - The global click handler catches the click, and checks whether it was in an allowable region. +  //     - If preventGhostClick was called, the region will have been removed, the click is busted. +  //     - If the region is still there, the click proceeds normally. Therefore clicks on links and +  //       other elements without ngTap on them work normally. +  // +  // This is an ugly, terrible hack! +  // Yeah, tell me about it. The alternatives are using the slow click events, or making our users +  // deal with the ghost clicks, so I consider this the least of evils. Fortunately Angular +  // encapsulates this ugly logic away from the user. +  // +  // Why not just put click handlers on the element? +  // We do that too, just to be sure. The problem is that the tap event might have caused the DOM +  // to change, so that the click fires in the same position but something else is there now. So +  // the handlers are global and care only about coordinates and not elements. + +  // Checks if the coordinates are close enough to be within the region. +  function hit(x1, y1, x2, y2) { +    return Math.abs(x1 - x2) < CLICKBUSTER_THRESHOLD && Math.abs(y1 - y2) < CLICKBUSTER_THRESHOLD; +  } + +  // Checks a list of allowable regions against a click location. +  // Returns true if the click should be allowed. +  // Splices out the allowable region from the list after it has been used. +  function checkAllowableRegions(touchCoordinates, x, y) { +    for (var i = 0; i < touchCoordinates.length; i += 2) { +      if (hit(touchCoordinates[i], touchCoordinates[i+1], x, y)) { +        touchCoordinates.splice(i, i + 2); +        return true; // allowable region +      } +    } +    return false; // No allowable region; bust it. +  } + +  // Global click handler that prevents the click if it's in a bustable zone and preventGhostClick +  // was called recently. +  function onClick(event) { +    if (Date.now() - lastPreventedTime > PREVENT_DURATION) { +      return; // Too old. +    } + +    var touches = event.touches && event.touches.length ? event.touches : [event]; +    var x = touches[0].clientX; +    var y = touches[0].clientY; +    // Work around desktop Webkit quirk where clicking a label will fire two clicks (on the label +    // and on the input element). Depending on the exact browser, this second click we don't want +    // to bust has either (0,0) or negative coordinates. +    if (x < 1 && y < 1) { +      return; // offscreen +    } + +    // Look for an allowable region containing this click. +    // If we find one, that means it was created by touchstart and not removed by +    // preventGhostClick, so we don't bust it. +    if (checkAllowableRegions(touchCoordinates, x, y)) { +      return; +    } + +    // If we didn't find an allowable region, bust the click. +    event.stopPropagation(); +    event.preventDefault(); +  } + + +  // Global touchstart handler that creates an allowable region for a click event. +  // This allowable region can be removed by preventGhostClick if we want to bust it. +  function onTouchStart(event) { +    var touches = event.touches && event.touches.length ? event.touches : [event]; +    var x = touches[0].clientX; +    var y = touches[0].clientY; +    touchCoordinates.push(x, y); + +    $timeout(function() { +      // Remove the allowable region. +      for (var i = 0; i < touchCoordinates.length; i += 2) { +        if (touchCoordinates[i] == x && touchCoordinates[i+1] == y) { +          touchCoordinates.splice(i, i + 2); +          return; +        } +      } +    }, PREVENT_DURATION, false); +  } + +  // On the first call, attaches some event handlers. Then whenever it gets called, it creates a +  // zone around the touchstart where clicks will get busted. +  function preventGhostClick(x, y) { +    if (!touchCoordinates) { +      $rootElement[0].addEventListener('click', onClick, true); +      $rootElement[0].addEventListener('touchstart', onTouchStart, true); +      touchCoordinates = []; +    } + +    lastPreventedTime = Date.now(); + +    checkAllowableRegions(touchCoordinates, x, y); +  } + +  // Actual linking function. +  return function(scope, element, attr) { +    var expressionFn = $parse(attr.ngClick), +        tapping = false, +        tapElement,  // Used to blur the element after a tap. +        startTime,   // Used to check if the tap was held too long. +        touchStartX, +        touchStartY; + +    function resetState() { +      tapping = false; +    } + +    element.bind('touchstart', function(event) { +      tapping = true; +      tapElement = event.target ? event.target : event.srcElement; // IE uses srcElement. +      // Hack for Safari, which can target text nodes instead of containers. +      if(tapElement.nodeType == 3) { +        tapElement = tapElement.parentNode; +      } + +      startTime = Date.now(); + +      var touches = event.touches && event.touches.length ? event.touches : [event]; +      var e = touches[0].originalEvent || touches[0]; +      touchStartX = e.clientX; +      touchStartY = e.clientY; +    }); + +    element.bind('touchmove', function(event) { +      resetState(); +    }); + +    element.bind('touchcancel', function(event) { +      resetState(); +    }); + +    element.bind('touchend', function(event) { +      var diff = Date.now() - startTime; + +      var touches = (event.changedTouches && event.changedTouches.length) ? event.changedTouches : +          ((event.touches && event.touches.length) ? event.touches : [event]); +      var e = touches[0].originalEvent || touches[0]; +      var x = e.clientX; +      var y = e.clientY; +      var dist = Math.sqrt( Math.pow(x - touchStartX, 2) + Math.pow(y - touchStartY, 2) ); + +      if (tapping && diff < TAP_DURATION && dist < MOVE_TOLERANCE) { +        // Call preventGhostClick so the clickbuster will catch the corresponding click. +        preventGhostClick(x, y); + +        // Blur the focused element (the button, probably) before firing the callback. +        // This doesn't work perfectly on Android Chrome, but seems to work elsewhere. +        // I couldn't get anything to work reliably on Android Chrome. +        if (tapElement) { +          tapElement.blur(); +        } + +        scope.$apply(function() { +          // TODO(braden): This is sending the touchend, not a tap or click. Is that kosher? +          expressionFn(scope, {$event: event}); +        }); +      } +      tapping = false; +    }); + +    // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click +    // something else nearby. +    element.onclick = function(event) { }; + +    // Fallback click handler. +    // Busted clicks don't get this far, and adding this handler allows ng-tap to be used on +    // desktop as well, to allow more portable sites. +    element.bind('click', function(event) { +      scope.$apply(function() { +        expressionFn(scope, {$event: event}); +      }); +    }); +  }; +}]); +  | 
