|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +/** |
| 4 | + * @ngdoc directive |
| 5 | + * @name ngMobile.directive:ngTap |
| 6 | + * |
| 7 | + * @description |
| 8 | + * Specify custom behavior when element is tapped on a touchscreen device. |
| 9 | + * A tap is a brief, down-and-up touch without much motion. |
| 10 | + * |
| 11 | + * @element ANY |
| 12 | + * @param {expression} ngClick {@link guide/expression Expression} to evaluate |
| 13 | + * upon tap. (Event object is available as `$event`) |
| 14 | + * |
| 15 | + * @example |
| 16 | + <doc:example> |
| 17 | + <doc:source> |
| 18 | + <button ng-tap="count = count + 1" ng-init="count=0"> |
| 19 | + Increment |
| 20 | + </button> |
| 21 | + count: {{ count }} |
| 22 | + </doc:source> |
| 23 | + </doc:example> |
| 24 | + */ |
| 25 | + |
| 26 | +ngMobile.config(function($provide) { |
| 27 | + $provide.decorator('ngClickDirective', function($delegate) { |
| 28 | + // drop the default ngClick directive |
| 29 | + $delegate.shift(); |
| 30 | + return $delegate; |
| 31 | + }); |
| 32 | +}); |
| 33 | + |
| 34 | +ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement', |
| 35 | + function($parse, $timeout, $rootElement) { |
| 36 | + var TAP_DURATION = 750; // Shorter than 750ms is a tap, longer is a taphold or drag. |
| 37 | + var MOVE_TOLERANCE = 12; // 12px seems to work in most mobile browsers. |
| 38 | + var PREVENT_DURATION = 2500; // 2.5 seconds maximum from preventGhostClick call to click |
| 39 | + var CLICKBUSTER_THRESHOLD = 25; // 25 pixels in any dimension is the limit for busting clicks. |
| 40 | + var lastPreventedTime; |
| 41 | + var touchCoordinates; |
| 42 | + |
| 43 | + |
| 44 | + // TAP EVENTS AND GHOST CLICKS |
| 45 | + // |
| 46 | + // Why tap events? |
| 47 | + // Mobile browsers detect a tap, then wait a moment (usually ~300ms) to see if you're |
| 48 | + // double-tapping, and then fire a click event. |
| 49 | + // |
| 50 | + // This delay sucks and makes mobile apps feel unresponsive. |
| 51 | + // So we detect touchstart, touchmove, touchcancel and touchend ourselves and determine when |
| 52 | + // the user has tapped on something. |
| 53 | + // |
| 54 | + // What happens when the browser then generates a click event? |
| 55 | + // The browser, of course, also detects the tap and fires a click after a delay. This results in |
| 56 | + // tapping/clicking twice. So we do "clickbusting" to prevent it. |
| 57 | + // |
| 58 | + // How does it work? |
| 59 | + // We attach global touchstart and click handlers, that run during the capture (early) phase. |
| 60 | + // So the sequence for a tap is: |
| 61 | + // - global touchstart: Sets an "allowable region" at the point touched. |
| 62 | + // - element's touchstart: Starts a touch |
| 63 | + // (- touchmove or touchcancel ends the touch, no click follows) |
| 64 | + // - element's touchend: Determines if the tap is valid (didn't move too far away, didn't hold |
| 65 | + // too long) and fires the user's tap handler. The touchend also calls preventGhostClick(). |
| 66 | + // - preventGhostClick() removes the allowable region the global touchstart created. |
| 67 | + // - The browser generates a click event. |
| 68 | + // - The global click handler catches the click, and checks whether it was in an allowable region. |
| 69 | + // - If preventGhostClick was called, the region will have been removed, the click is busted. |
| 70 | + // - If the region is still there, the click proceeds normally. Therefore clicks on links and |
| 71 | + // other elements without ngTap on them work normally. |
| 72 | + // |
| 73 | + // This is an ugly, terrible hack! |
| 74 | + // Yeah, tell me about it. The alternatives are using the slow click events, or making our users |
| 75 | + // deal with the ghost clicks, so I consider this the least of evils. Fortunately Angular |
| 76 | + // encapsulates this ugly logic away from the user. |
| 77 | + // |
| 78 | + // Why not just put click handlers on the element? |
| 79 | + // We do that too, just to be sure. The problem is that the tap event might have caused the DOM |
| 80 | + // to change, so that the click fires in the same position but something else is there now. So |
| 81 | + // the handlers are global and care only about coordinates and not elements. |
| 82 | + |
| 83 | + // Checks if the coordinates are close enough to be within the region. |
| 84 | + function hit(x1, y1, x2, y2) { |
| 85 | + return Math.abs(x1 - x2) < CLICKBUSTER_THRESHOLD && Math.abs(y1 - y2) < CLICKBUSTER_THRESHOLD; |
| 86 | + } |
| 87 | + |
| 88 | + // Checks a list of allowable regions against a click location. |
| 89 | + // Returns true if the click should be allowed. |
| 90 | + // Splices out the allowable region from the list after it has been used. |
| 91 | + function checkAllowableRegions(touchCoordinates, x, y) { |
| 92 | + for (var i = 0; i < touchCoordinates.length; i += 2) { |
| 93 | + if (hit(touchCoordinates[i], touchCoordinates[i+1], x, y)) { |
| 94 | + touchCoordinates.splice(i, i + 2); |
| 95 | + return true; // allowable region |
| 96 | + } |
| 97 | + } |
| 98 | + return false; // No allowable region; bust it. |
| 99 | + } |
| 100 | + |
| 101 | + // Global click handler that prevents the click if it's in a bustable zone and preventGhostClick |
| 102 | + // was called recently. |
| 103 | + function onClick(event) { |
| 104 | + if (Date.now() - lastPreventedTime > PREVENT_DURATION) { |
| 105 | + return; // Too old. |
| 106 | + } |
| 107 | + |
| 108 | + var touches = event.touches && event.touches.length ? event.touches : [event]; |
| 109 | + var x = touches[0].clientX; |
| 110 | + var y = touches[0].clientY; |
| 111 | + // Work around desktop Webkit quirk where clicking a label will fire two clicks (on the label |
| 112 | + // and on the input element). Depending on the exact browser, this second click we don't want |
| 113 | + // to bust has either (0,0) or negative coordinates. |
| 114 | + if (x < 1 && y < 1) { |
| 115 | + return; // offscreen |
| 116 | + } |
| 117 | + |
| 118 | + // Look for an allowable region containing this click. |
| 119 | + // If we find one, that means it was created by touchstart and not removed by |
| 120 | + // preventGhostClick, so we don't bust it. |
| 121 | + if (checkAllowableRegions(touchCoordinates, x, y)) { |
| 122 | + return; |
| 123 | + } |
| 124 | + |
| 125 | + // If we didn't find an allowable region, bust the click. |
| 126 | + event.stopPropagation(); |
| 127 | + event.preventDefault(); |
| 128 | + } |
| 129 | + |
| 130 | + |
| 131 | + // Global touchstart handler that creates an allowable region for a click event. |
| 132 | + // This allowable region can be removed by preventGhostClick if we want to bust it. |
| 133 | + function onTouchStart(event) { |
| 134 | + var touches = event.touches && event.touches.length ? event.touches : [event]; |
| 135 | + var x = touches[0].clientX; |
| 136 | + var y = touches[0].clientY; |
| 137 | + touchCoordinates.push(x, y); |
| 138 | + |
| 139 | + $timeout(function() { |
| 140 | + // Remove the allowable region. |
| 141 | + for (var i = 0; i < touchCoordinates.length; i += 2) { |
| 142 | + if (touchCoordinates[i] == x && touchCoordinates[i+1] == y) { |
| 143 | + touchCoordinates.splice(i, i + 2); |
| 144 | + return; |
| 145 | + } |
| 146 | + } |
| 147 | + }, PREVENT_DURATION, false); |
| 148 | + } |
| 149 | + |
| 150 | + // On the first call, attaches some event handlers. Then whenever it gets called, it creates a |
| 151 | + // zone around the touchstart where clicks will get busted. |
| 152 | + function preventGhostClick(x, y) { |
| 153 | + if (!touchCoordinates) { |
| 154 | + $rootElement[0].addEventListener('click', onClick, true); |
| 155 | + $rootElement[0].addEventListener('touchstart', onTouchStart, true); |
| 156 | + touchCoordinates = []; |
| 157 | + } |
| 158 | + |
| 159 | + lastPreventedTime = Date.now(); |
| 160 | + |
| 161 | + checkAllowableRegions(touchCoordinates, x, y); |
| 162 | + } |
| 163 | + |
| 164 | + // Actual linking function. |
| 165 | + return function(scope, element, attr) { |
| 166 | + var expressionFn = $parse(attr.ngClick), |
| 167 | + tapping = false, |
| 168 | + tapElement, // Used to blur the element after a tap. |
| 169 | + startTime, // Used to check if the tap was held too long. |
| 170 | + touchStartX, |
| 171 | + touchStartY; |
| 172 | + |
| 173 | + function resetState() { |
| 174 | + tapping = false; |
| 175 | + } |
| 176 | + |
| 177 | + element.bind('touchstart', function(event) { |
| 178 | + tapping = true; |
| 179 | + tapElement = event.target ? event.target : event.srcElement; // IE uses srcElement. |
| 180 | + // Hack for Safari, which can target text nodes instead of containers. |
| 181 | + if(tapElement.nodeType == 3) { |
| 182 | + tapElement = tapElement.parentNode; |
| 183 | + } |
| 184 | + |
| 185 | + startTime = Date.now(); |
| 186 | + |
| 187 | + var touches = event.touches && event.touches.length ? event.touches : [event]; |
| 188 | + var e = touches[0].originalEvent || touches[0]; |
| 189 | + touchStartX = e.clientX; |
| 190 | + touchStartY = e.clientY; |
| 191 | + }); |
| 192 | + |
| 193 | + element.bind('touchmove', function(event) { |
| 194 | + resetState(); |
| 195 | + }); |
| 196 | + |
| 197 | + element.bind('touchcancel', function(event) { |
| 198 | + resetState(); |
| 199 | + }); |
| 200 | + |
| 201 | + element.bind('touchend', function(event) { |
| 202 | + var diff = Date.now() - startTime; |
| 203 | + |
| 204 | + var touches = (event.changedTouches && event.changedTouches.length) ? event.changedTouches : |
| 205 | + ((event.touches && event.touches.length) ? event.touches : [event]); |
| 206 | + var e = touches[0].originalEvent || touches[0]; |
| 207 | + var x = e.clientX; |
| 208 | + var y = e.clientY; |
| 209 | + var dist = Math.sqrt( Math.pow(x - touchStartX, 2) + Math.pow(y - touchStartY, 2) ); |
| 210 | + |
| 211 | + if (tapping && diff < TAP_DURATION && dist < MOVE_TOLERANCE) { |
| 212 | + // Call preventGhostClick so the clickbuster will catch the corresponding click. |
| 213 | + preventGhostClick(x, y); |
| 214 | + |
| 215 | + // Blur the focused element (the button, probably) before firing the callback. |
| 216 | + // This doesn't work perfectly on Android Chrome, but seems to work elsewhere. |
| 217 | + // I couldn't get anything to work reliably on Android Chrome. |
| 218 | + if (tapElement) { |
| 219 | + tapElement.blur(); |
| 220 | + } |
| 221 | + |
| 222 | + scope.$apply(function() { |
| 223 | + // TODO(braden): This is sending the touchend, not a tap or click. Is that kosher? |
| 224 | + expressionFn(scope, {$event: event}); |
| 225 | + }); |
| 226 | + } |
| 227 | + tapping = false; |
| 228 | + }); |
| 229 | + |
| 230 | + // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click |
| 231 | + // something else nearby. |
| 232 | + element.onclick = function(event) { }; |
| 233 | + |
| 234 | + // Fallback click handler. |
| 235 | + // Busted clicks don't get this far, and adding this handler allows ng-tap to be used on |
| 236 | + // desktop as well, to allow more portable sites. |
| 237 | + element.bind('click', function(event) { |
| 238 | + scope.$apply(function() { |
| 239 | + expressionFn(scope, {$event: event}); |
| 240 | + }); |
| 241 | + }); |
| 242 | + }; |
| 243 | +}]); |
| 244 | + |
0 commit comments