forked from angular/angular.js
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ngMobile): add ngMobile module with mobile-specific ngClick
Add a new module ngMobile, with mobile/touch-specific directives. Add ngClick, which overrides the default ngClick. This ngClick uses touch events, which are much faster on mobile. On desktop browsers, ngClick responds to click events, so it can be used for portable sites.
- Loading branch information
1 parent
d1b49e2
commit 707c65d
Showing
6 changed files
with
578 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}); | ||
}); | ||
}); | ||
}; | ||
}]); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
'use strict'; | ||
|
||
/** | ||
* @ngdoc overview | ||
* @name ngMobile | ||
* @description | ||
*/ | ||
|
||
/* | ||
* Touch events and other mobile helpers by Braden Shepherdson ([email protected]) | ||
* Based on jQuery Mobile touch event handling (jquerymobile.com) | ||
*/ | ||
|
||
// define ngSanitize module and register $sanitize service | ||
var ngMobile = angular.module('ngMobile', []); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.