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

Commit 707c65d

Browse files
bshepherdsonIgorMinar
authored andcommitted
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.
1 parent d1b49e2 commit 707c65d

File tree

6 files changed

+578
-4
lines changed

6 files changed

+578
-4
lines changed

Gruntfile.js

+8
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ module.exports = function(grunt) {
8585
dest: 'build/angular-loader.js',
8686
src: util.wrap(['src/loader.js'], 'loader')
8787
},
88+
mobile: {
89+
dest: 'build/angular-mobile.js',
90+
src: util.wrap([
91+
'src/ngMobile/mobile.js',
92+
'src/ngMobile/directive/ngClick.js'
93+
], 'module')
94+
},
8895
mocks: {
8996
dest: 'build/angular-mocks.js',
9097
src: ['src/ngMock/angular-mocks.js'],
@@ -125,6 +132,7 @@ module.exports = function(grunt) {
125132
angular: 'build/angular.js',
126133
cookies: 'build/angular-cookies.js',
127134
loader: 'build/angular-loader.js',
135+
mobile: 'build/angular-mobile.js',
128136
resource: 'build/angular-resource.js',
129137
sanitize: 'build/angular-sanitize.js',
130138
bootstrap: 'build/angular-bootstrap.js',

angularFiles.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ angularFiles = {
6969
'src/ngSanitize/directive/ngBindHtml.js',
7070
'src/ngSanitize/filter/linky.js',
7171
'src/ngMock/angular-mocks.js',
72+
'src/ngMobile/mobile.js',
73+
'src/ngMobile/directive/ngClick.js',
7274

7375
'src/bootstrap/bootstrap.js'
7476
],
@@ -106,7 +108,8 @@ angularFiles = {
106108
'test/ngSanitize/*.js',
107109
'test/ngSanitize/directive/*.js',
108110
'test/ngSanitize/filter/*.js',
109-
'test/ngMock/*.js'
111+
'test/ngMock/*.js',
112+
'test/ngMobile/directive/*.js'
110113
],
111114

112115
'jstd': [
@@ -141,9 +144,12 @@ angularFiles = {
141144
'lib/jasmine/jasmine.js',
142145
'lib/jasmine-jstd-adapter/JasmineAdapter.js',
143146
'build/angular.js',
147+
'build/angular-scenario.js',
144148
'src/ngMock/angular-mocks.js',
145149
'src/ngCookies/cookies.js',
146150
'src/ngResource/resource.js',
151+
'src/ngMobile/mobile.js',
152+
'src/ngMobile/directive/ngClick.js',
147153
'src/ngSanitize/sanitize.js',
148154
'src/ngSanitize/directive/ngBindHtml.js',
149155
'src/ngSanitize/filter/linky.js',
@@ -153,7 +159,8 @@ angularFiles = {
153159
'test/ngResource/*.js',
154160
'test/ngSanitize/*.js',
155161
'test/ngSanitize/directive/*.js',
156-
'test/ngSanitize/filter/*.js'
162+
'test/ngSanitize/filter/*.js',
163+
'test/ngMobile/directive/*.js'
157164
],
158165

159166
'jstdPerf': [

src/ngMobile/directive/ngClick.js

+244
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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+

src/ngMobile/mobile.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
3+
/**
4+
* @ngdoc overview
5+
* @name ngMobile
6+
* @description
7+
*/
8+
9+
/*
10+
* Touch events and other mobile helpers by Braden Shepherdson ([email protected])
11+
* Based on jQuery Mobile touch event handling (jquerymobile.com)
12+
*/
13+
14+
// define ngSanitize module and register $sanitize service
15+
var ngMobile = angular.module('ngMobile', []);
16+

src/ngScenario/Scenario.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,10 @@ function callerFile(offset) {
231231
* @param {string} type Optional event type.
232232
* @param {Array.<string>=} keys Optional list of pressed keys
233233
* (valid values: 'alt', 'meta', 'shift', 'ctrl')
234+
* @param {number} x Optional x-coordinate for mouse/touch events.
235+
* @param {number} y Optional y-coordinate for mouse/touch events.
234236
*/
235-
function browserTrigger(element, type, keys) {
237+
function browserTrigger(element, type, keys, x, y) {
236238
if (element && !element.nodeName) element = element[0];
237239
if (!element) return;
238240
if (!type) {
@@ -304,7 +306,9 @@ function browserTrigger(element, type, keys) {
304306
return originalPreventDefault.apply(evnt, arguments);
305307
};
306308

307-
evnt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, pressed('ctrl'), pressed('alt'),
309+
x = x || 0;
310+
y = y || 0;
311+
evnt.initMouseEvent(type, true, true, window, 0, x, y, x, y, pressed('ctrl'), pressed('alt'),
308312
pressed('shift'), pressed('meta'), 0, element);
309313

310314
element.dispatchEvent(evnt);

0 commit comments

Comments
 (0)