Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
feat(ngMobile): add ngMobile module with mobile-specific ngClick
Browse files Browse the repository at this point in the history
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
bshepherdson authored and IgorMinar committed Mar 14, 2013
1 parent d1b49e2 commit 707c65d
Show file tree
Hide file tree
Showing 6 changed files with 578 additions and 4 deletions.
8 changes: 8 additions & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ module.exports = function(grunt) {
dest: 'build/angular-loader.js',
src: util.wrap(['src/loader.js'], 'loader')
},
mobile: {
dest: 'build/angular-mobile.js',
src: util.wrap([
'src/ngMobile/mobile.js',
'src/ngMobile/directive/ngClick.js'
], 'module')
},
mocks: {
dest: 'build/angular-mocks.js',
src: ['src/ngMock/angular-mocks.js'],
Expand Down Expand Up @@ -125,6 +132,7 @@ module.exports = function(grunt) {
angular: 'build/angular.js',
cookies: 'build/angular-cookies.js',
loader: 'build/angular-loader.js',
mobile: 'build/angular-mobile.js',
resource: 'build/angular-resource.js',
sanitize: 'build/angular-sanitize.js',
bootstrap: 'build/angular-bootstrap.js',
Expand Down
11 changes: 9 additions & 2 deletions angularFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ angularFiles = {
'src/ngSanitize/directive/ngBindHtml.js',
'src/ngSanitize/filter/linky.js',
'src/ngMock/angular-mocks.js',
'src/ngMobile/mobile.js',
'src/ngMobile/directive/ngClick.js',

'src/bootstrap/bootstrap.js'
],
Expand Down Expand Up @@ -106,7 +108,8 @@ angularFiles = {
'test/ngSanitize/*.js',
'test/ngSanitize/directive/*.js',
'test/ngSanitize/filter/*.js',
'test/ngMock/*.js'
'test/ngMock/*.js',
'test/ngMobile/directive/*.js'
],

'jstd': [
Expand Down Expand Up @@ -141,9 +144,12 @@ angularFiles = {
'lib/jasmine/jasmine.js',
'lib/jasmine-jstd-adapter/JasmineAdapter.js',
'build/angular.js',
'build/angular-scenario.js',

This comment has been minimized.

Copy link
@geddski

geddski Mar 19, 2013

Contributor

Something about the inclusion of this line causes the grunt test:modules task to skip all tests... cc @IgorMinar

This comment has been minimized.

Copy link
@geddski

geddski Mar 20, 2013

Contributor

Fixed in #2189

'src/ngMock/angular-mocks.js',
'src/ngCookies/cookies.js',
'src/ngResource/resource.js',
'src/ngMobile/mobile.js',
'src/ngMobile/directive/ngClick.js',
'src/ngSanitize/sanitize.js',
'src/ngSanitize/directive/ngBindHtml.js',
'src/ngSanitize/filter/linky.js',
Expand All @@ -153,7 +159,8 @@ angularFiles = {
'test/ngResource/*.js',
'test/ngSanitize/*.js',
'test/ngSanitize/directive/*.js',
'test/ngSanitize/filter/*.js'
'test/ngSanitize/filter/*.js',
'test/ngMobile/directive/*.js'
],

'jstdPerf': [
Expand Down
244 changes: 244 additions & 0 deletions src/ngMobile/directive/ngClick.js
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});
});
});
};
}]);

16 changes: 16 additions & 0 deletions src/ngMobile/mobile.js
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 (braden.shepherdson@gmail.com)
* Based on jQuery Mobile touch event handling (jquerymobile.com)
*/

// define ngSanitize module and register $sanitize service
var ngMobile = angular.module('ngMobile', []);

8 changes: 6 additions & 2 deletions src/ngScenario/Scenario.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,10 @@ function callerFile(offset) {
* @param {string} type Optional event type.
* @param {Array.<string>=} keys Optional list of pressed keys
* (valid values: 'alt', 'meta', 'shift', 'ctrl')
* @param {number} x Optional x-coordinate for mouse/touch events.
* @param {number} y Optional y-coordinate for mouse/touch events.
*/
function browserTrigger(element, type, keys) {
function browserTrigger(element, type, keys, x, y) {
if (element && !element.nodeName) element = element[0];
if (!element) return;
if (!type) {
Expand Down Expand Up @@ -304,7 +306,9 @@ function browserTrigger(element, type, keys) {
return originalPreventDefault.apply(evnt, arguments);
};

evnt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, pressed('ctrl'), pressed('alt'),
x = x || 0;
y = y || 0;
evnt.initMouseEvent(type, true, true, window, 0, x, y, x, y, pressed('ctrl'), pressed('alt'),
pressed('shift'), pressed('meta'), 0, element);

element.dispatchEvent(evnt);
Expand Down
Loading

2 comments on commit 707c65d

@rodrigoalvesvieira
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@geddski
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit causes the modules unit tests to skip, trying to figure out why (see #2189 ). @shepheb any insight?

Please sign in to comment.