diff options
| author | Brian Ford | 2013-08-09 10:02:48 -0700 | 
|---|---|---|
| committer | Igor Minar | 2013-08-09 11:54:35 -0700 | 
| commit | 94ec84e7b9c89358dc00e4039009af9e287bbd05 (patch) | |
| tree | b06c84f1329a8e4fd6ce75b6bbde64bdf4d0e60e /src/ngTouch/directive/ngClick.js | |
| parent | 0d17838a0881376be3c226a68242b5d74dac208b (diff) | |
| download | angular.js-94ec84e7b9c89358dc00e4039009af9e287bbd05.tar.bz2 | |
chore(ngMobile): rename module ngTouch and file to angular-touch.js
BREAKING CHANGE: since all the code in the ngMobile module is touch related,
we are renaming the module to ngTouch.
To migrate, please replace all references to "ngMobile" with "ngTouch" and
"angular-mobile.js" to "angular-touch.js".
Closes #3526
Diffstat (limited to 'src/ngTouch/directive/ngClick.js')
| -rw-r--r-- | src/ngTouch/directive/ngClick.js | 272 | 
1 files changed, 272 insertions, 0 deletions
| diff --git a/src/ngTouch/directive/ngClick.js b/src/ngTouch/directive/ngClick.js new file mode 100644 index 00000000..f1d8ccaa --- /dev/null +++ b/src/ngTouch/directive/ngClick.js @@ -0,0 +1,272 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ngTouch.directive:ngClick + * + * @description + * A more powerful replacement for the default ngClick designed to be used on touchscreen + * devices. Most mobile browsers wait about 300ms after a tap-and-release before sending + * the click event. This version handles them immediately, and then prevents the + * following click event from propagating. + * + * This directive can fall back to using an ordinary click event, and so works on desktop + * browsers as well as mobile. + * + * This directive also sets the CSS class `ng-click-active` while the element is being held + * down (by a mouse click or touch) so you can restyle the depressed element if you wish. + * + * @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-click="count = count + 1" ng-init="count=0"> +          Increment +        </button> +        count: {{ count }} +      </doc:source> +    </doc:example> + */ + +ngTouch.config(['$provide', function($provide) { +  $provide.decorator('ngClickDirective', ['$delegate', function($delegate) { +    // drop the default ngClick directive +    $delegate.shift(); +    return $delegate; +  }]); +}]); + +ngTouch.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 ACTIVE_CLASS_NAME = 'ng-click-active'; +  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(); + +    // Blur focused form elements +    event.target && event.target.blur(); +  } + + +  // 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 clickHandler = $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.removeClass(ACTIVE_CLASS_NAME); +    } + +    element.on('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; +      } + +      element.addClass(ACTIVE_CLASS_NAME); + +      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.on('touchmove', function(event) { +      resetState(); +    }); + +    element.on('touchcancel', function(event) { +      resetState(); +    }); + +    element.on('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(); +        } + +        if (!angular.isDefined(attr.disabled) || attr.disabled === false) { +          element.triggerHandler('click', event); +        } +      } + +      resetState(); +    }); + +    // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click +    // something else nearby. +    element.onclick = function(event) { }; + +    // Actual click handler. +    // There are three different kinds of clicks, only two of which reach this point. +    // - On desktop browsers without touch events, their clicks will always come here. +    // - On mobile browsers, the simulated "fast" click will call this. +    // - But the browser's follow-up slow click will be "busted" before it reaches this handler. +    // Therefore it's safe to use this directive on both mobile and desktop. +    element.on('click', function(event) { +      scope.$apply(function() { +        clickHandler(scope, {$event: event}); +      }); +    }); + +    element.on('mousedown', function(event) { +      element.addClass(ACTIVE_CLASS_NAME); +    }); + +    element.on('mousemove mouseup', function(event) { +      element.removeClass(ACTIVE_CLASS_NAME); +    }); + +  }; +}]); + | 
