From 5e0f876c39099adb6a0300c429b8df1f6b544846 Mon Sep 17 00:00:00 2001 From: Braden Shepherdson Date: Fri, 8 Mar 2013 09:38:29 -0500 Subject: feat(ngSwipe): Add ngSwipeRight/Left directives to ngMobile These directives fire an event handler on a touch-and-drag or click-and-drag to the left or right. Includes unit tests and docs update. Manually tested on Chrome 26, IE8, Android Chrome and iOS Safari. --- Gruntfile.js | 3 +- angularFiles.js | 3 + src/ngMobile/directive/ngClick.js | 6 +- src/ngMobile/directive/ngSwipe.js | 175 +++++++++++++++++++++++++++++++++ src/ngMobile/mobile.js | 7 +- test/ngMobile/directive/ngSwipeSpec.js | 110 +++++++++++++++++++++ 6 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 src/ngMobile/directive/ngSwipe.js create mode 100644 test/ngMobile/directive/ngSwipeSpec.js diff --git a/Gruntfile.js b/Gruntfile.js index c318a2a6..ac7e47b6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -89,7 +89,8 @@ module.exports = function(grunt) { dest: 'build/angular-mobile.js', src: util.wrap([ 'src/ngMobile/mobile.js', - 'src/ngMobile/directive/ngClick.js' + 'src/ngMobile/directive/ngClick.js', + 'src/ngMobile/directive/ngSwipe.js' ], 'module') }, mocks: { diff --git a/angularFiles.js b/angularFiles.js index 8384beb9..b2c23a8f 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -73,6 +73,8 @@ angularFiles = { 'src/ngMock/angular-mocks.js', 'src/ngMobile/mobile.js', 'src/ngMobile/directive/ngClick.js', + 'src/ngMobile/directive/ngSwipe.js', + 'src/bootstrap/bootstrap.js' ], @@ -151,6 +153,7 @@ angularFiles = { 'src/ngResource/resource.js', 'src/ngMobile/mobile.js', 'src/ngMobile/directive/ngClick.js', + 'src/ngMobile/directive/ngSwipe.js', 'src/ngSanitize/sanitize.js', 'src/ngSanitize/directive/ngBindHtml.js', 'src/ngSanitize/filter/linky.js', diff --git a/src/ngMobile/directive/ngClick.js b/src/ngMobile/directive/ngClick.js index a52893b1..b3e3007d 100644 --- a/src/ngMobile/directive/ngClick.js +++ b/src/ngMobile/directive/ngClick.js @@ -163,7 +163,7 @@ ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement', // Actual linking function. return function(scope, element, attr) { - var expressionFn = $parse(attr.ngClick), + 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. @@ -221,7 +221,7 @@ ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement', scope.$apply(function() { // TODO(braden): This is sending the touchend, not a tap or click. Is that kosher? - expressionFn(scope, {$event: event}); + clickHandler(scope, {$event: event}); }); } tapping = false; @@ -236,7 +236,7 @@ ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement', // desktop as well, to allow more portable sites. element.bind('click', function(event) { scope.$apply(function() { - expressionFn(scope, {$event: event}); + clickHandler(scope, {$event: event}); }); }); }; diff --git a/src/ngMobile/directive/ngSwipe.js b/src/ngMobile/directive/ngSwipe.js new file mode 100644 index 00000000..6de7aa57 --- /dev/null +++ b/src/ngMobile/directive/ngSwipe.js @@ -0,0 +1,175 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ngMobile.directive:ngSwipeLeft + * + * @description + * Specify custom behavior when an element is swiped to the left on a touchscreen device. + * A leftward swipe is a quick, right-to-left slide of the finger. + * Though ngSwipeLeft is designed for touch-based devices, it will work with a mouse click and drag too. + * + * @element ANY + * @param {expression} ngSwipeLeft {@link guide/expression Expression} to evaluate + * upon left swipe. (Event object is available as `$event`) + * + * @example + + +
+ Some list content, like an email in the inbox +
+
+ + +
+
+
+ */ + +/** + * @ngdoc directive + * @name ngMobile.directive:ngSwipeRight + * + * @description + * Specify custom behavior when an element is swiped to the right on a touchscreen device. + * A rightward swipe is a quick, left-to-right slide of the finger. + * Though ngSwipeRight is designed for touch-based devices, it will work with a mouse click and drag too. + * + * @element ANY + * @param {expression} ngSwipeRight {@link guide/expression Expression} to evaluate + * upon right swipe. (Event object is available as `$event`) + * + * @example + + +
+ Some list content, like an email in the inbox +
+
+ + +
+
+
+ */ + +function makeSwipeDirective(directiveName, direction) { + ngMobile.directive(directiveName, ['$parse', function($parse) { + // The maximum vertical delta for a swipe should be less than 75px. + var MAX_VERTICAL_DISTANCE = 75; + // Vertical distance should not be more than a fraction of the horizontal distance. + var MAX_VERTICAL_RATIO = 0.3; + // At least a 30px lateral motion is necessary for a swipe. + var MIN_HORIZONTAL_DISTANCE = 30; + // The total distance in any direction before we make the call on swipe vs. scroll. + var MOVE_BUFFER_RADIUS = 10; + + function getCoordinates(event) { + var touches = event.touches && event.touches.length ? event.touches : [event]; + var e = (event.changedTouches && event.changedTouches[0]) || + (event.originalEvent && event.originalEvent.changedTouches && + event.originalEvent.changedTouches[0]) || + touches[0].originalEvent || touches[0]; + + return { + x: e.clientX, + y: e.clientY + }; + } + + return function(scope, element, attr) { + var swipeHandler = $parse(attr[directiveName]); + var startCoords, valid; + var totalX, totalY; + var lastX, lastY; + + function validSwipe(event) { + // Check that it's within the coordinates. + // Absolute vertical distance must be within tolerances. + // Horizontal distance, we take the current X - the starting X. + // This is negative for leftward swipes and positive for rightward swipes. + // After multiplying by the direction (-1 for left, +1 for right), legal swipes + // (ie. same direction as the directive wants) will have a positive delta and + // illegal ones a negative delta. + // Therefore this delta must be positive, and larger than the minimum. + if (!startCoords) return false; + var coords = getCoordinates(event); + var deltaY = Math.abs(coords.y - startCoords.y); + var deltaX = (coords.x - startCoords.x) * direction; + return valid && // Short circuit for already-invalidated swipes. + deltaY < MAX_VERTICAL_DISTANCE && + deltaX > 0 && + deltaX > MIN_HORIZONTAL_DISTANCE && + deltaY / deltaX < MAX_VERTICAL_RATIO; + } + + element.bind('touchstart mousedown', function(event) { + startCoords = getCoordinates(event); + valid = true; + totalX = 0; + totalY = 0; + lastX = startCoords.x; + lastY = startCoords.y; + }); + + element.bind('touchcancel', function(event) { + valid = false; + }); + + element.bind('touchmove mousemove', function(event) { + if (!valid) return; + + // Android will send a touchcancel if it thinks we're starting to scroll. + // So when the total distance (+ or - or both) exceeds 10px in either direction, + // we either: + // - On totalX > totalY, we send preventDefault() and treat this as a swipe. + // - On totalY > totalX, we let the browser handle it as a scroll. + + // Invalidate a touch while it's in progress if it strays too far away vertically. + // We don't want a scroll down and back up while drifting sideways to be a swipe just + // because you happened to end up vertically close in the end. + if (!startCoords) return; + var coords = getCoordinates(event); + + if (Math.abs(coords.y - startCoords.y) > MAX_VERTICAL_DISTANCE) { + valid = false; + return; + } + + totalX += Math.abs(coords.x - lastX); + totalY += Math.abs(coords.y - lastY); + + lastX = coords.x; + lastY = coords.y; + + if (totalX < MOVE_BUFFER_RADIUS && totalY < MOVE_BUFFER_RADIUS) { + return; + } + + // One of totalX or totalY has exceeded the buffer, so decide on swipe vs. scroll. + if (totalY > totalX) { + valid = false; + return; + } else { + event.preventDefault(); + } + }); + + element.bind('touchend mouseup', function(event) { + if (validSwipe(event)) { + // Prevent this swipe from bubbling up to any other elements with ngSwipes. + event.stopPropagation(); + scope.$apply(function() { + swipeHandler(scope, {$event:event}); + }); + } + }); + }; + }]); +} + +// Left is negative X-coordinate, right is positive. +makeSwipeDirective('ngSwipeLeft', -1); +makeSwipeDirective('ngSwipeRight', 1); + diff --git a/src/ngMobile/mobile.js b/src/ngMobile/mobile.js index c8fd8843..daa28f5b 100644 --- a/src/ngMobile/mobile.js +++ b/src/ngMobile/mobile.js @@ -4,13 +4,10 @@ * @ngdoc overview * @name ngMobile * @description - */ - -/* - * Touch events and other mobile helpers by Braden Shepherdson (braden.shepherdson@gmail.com) + * Touch events and other mobile helpers. * Based on jQuery Mobile touch event handling (jquerymobile.com) */ -// define ngSanitize module and register $sanitize service +// define ngMobile module var ngMobile = angular.module('ngMobile', []); diff --git a/test/ngMobile/directive/ngSwipeSpec.js b/test/ngMobile/directive/ngSwipeSpec.js new file mode 100644 index 00000000..6bc7d300 --- /dev/null +++ b/test/ngMobile/directive/ngSwipeSpec.js @@ -0,0 +1,110 @@ +'use strict'; + +// Wrapper to abstract over using touch events or mouse events. +var swipeTests = function(description, restrictBrowsers, startEvent, moveEvent, endEvent) { + describe('ngSwipe with ' + description + ' events', function() { + var element; + + if (restrictBrowsers) { + // TODO(braden): Once we have other touch-friendly browsers on CI, allow them here. + // Currently Firefox and IE refuse to fire touch events. + var chrome = /chrome/.test(navigator.userAgent.toLowerCase()); + if (!chrome) { + return; + } + } + + // Skip tests on IE < 9. These versions of IE don't support createEvent(), and so + // we cannot control the (x,y) position of events. + // It works fine in IE 8 under manual testing. + var msie = +((/msie (\d+)/.exec(navigator.userAgent.toLowerCase()) || [])[1]); + if (msie < 9) { + return; + } + + beforeEach(function() { + module('ngMobile'); + }); + + afterEach(function() { + dealoc(element); + }); + + it('should swipe to the left', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, startEvent, [], 100, 20); + browserTrigger(element, endEvent, [], 20, 20); + expect($rootScope.swiped).toBe(true); + })); + + it('should swipe to the right', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, startEvent, [], 20, 20); + browserTrigger(element, endEvent, [], 90, 20); + expect($rootScope.swiped).toBe(true); + })); + + it('should not swipe if you move too far vertically', inject(function($rootScope, $compile, $rootElement) { + element = $compile('
')($rootScope); + $rootElement.append(element); + $rootScope.$digest(); + + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, startEvent, [], 90, 20); + browserTrigger(element, moveEvent, [], 70, 200); + browserTrigger(element, endEvent, [], 20, 20); + + expect($rootScope.swiped).toBeUndefined(); + })); + + it('should not swipe if you slide only a short distance', inject(function($rootScope, $compile, $rootElement) { + element = $compile('
')($rootScope); + $rootElement.append(element); + $rootScope.$digest(); + + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, startEvent, [], 90, 20); + browserTrigger(element, endEvent, [], 80, 20); + + expect($rootScope.swiped).toBeUndefined(); + })); + + it('should not swipe if the swipe leaves the element', inject(function($rootScope, $compile, $rootElement) { + element = $compile('
')($rootScope); + $rootElement.append(element); + $rootScope.$digest(); + + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, startEvent, [], 20, 20); + browserTrigger(element, moveEvent, [], 40, 20); + + expect($rootScope.swiped).toBeUndefined(); + })); + + it('should not swipe if the swipe starts outside the element', inject(function($rootScope, $compile, $rootElement) { + element = $compile('
')($rootScope); + $rootElement.append(element); + $rootScope.$digest(); + + expect($rootScope.swiped).toBeUndefined(); + + browserTrigger(element, moveEvent, [], 10, 20); + browserTrigger(element, endEvent, [], 90, 20); + + expect($rootScope.swiped).toBeUndefined(); + })); + }); +} + +swipeTests('touch', true /* restrictBrowers */, 'touchstart', 'touchmove', 'touchend'); +swipeTests('mouse', false /* restrictBrowers */, 'mousedown', 'mousemove', 'mouseup'); + -- cgit v1.2.3