From e1972e156d0e2e94287d8010b9a9f741f8a5ec74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Fri, 15 Aug 2014 21:16:43 -0400 Subject: [PATCH 1/3] feat($animate): coalesce concurrent class-based animations within a digest loop All class-based animation methods (addClass, removeClass and setClass) on $animate are now processed after the next digest occurs. This fix prevents any sequencing errors from occuring from excessive calls to $animate.addClass or $animate.remoteClass. BREAKING CHANGE All directive code that expects any addClass, removeClass or setClass animations to kick off immediately after being called must now be aware that the animation will only take place once the next digest has kicked off. Also note that successive calls to $animate.addClass, $animate.removeClass or $animate.setClass will be grouped together and will not cancel the former class-based animation (once the digest has passed). --- src/ng/animate.js | 6 +- src/ng/compile.js | 11 +- src/ng/directive/ngClass.js | 12 +- src/ngAnimate/animate.js | 178 +++++++++++++++----- test/ng/compileSpec.js | 7 +- test/ng/directive/ngClassSpec.js | 10 +- test/ngAnimate/animateSpec.js | 234 ++++++++++++++++++++++++++- test/ngRoute/directive/ngViewSpec.js | 5 +- 8 files changed, 395 insertions(+), 68 deletions(-) diff --git a/src/ng/animate.js b/src/ng/animate.js index f404eccc5461..e8cca8fcaa2d 100644 --- a/src/ng/animate.js +++ b/src/ng/animate.js @@ -234,10 +234,8 @@ var $AnimateProvider = ['$provide', function($provide) { * CSS classes have been set on the element */ setClass : function(element, add, remove, done) { - forEach(element, function (element) { - jqLiteAddClass(element, add); - jqLiteRemoveClass(element, remove); - }); + this.addClass(element, add); + this.removeClass(element, remove); async(done); return noop; }, diff --git a/src/ng/compile.js b/src/ng/compile.js index 175efc13211e..6b3e075b4a43 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -720,14 +720,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { */ $updateClass : function(newClasses, oldClasses) { var toAdd = tokenDifference(newClasses, oldClasses); - var toRemove = tokenDifference(oldClasses, newClasses); + if (toAdd && toAdd.length) { + $animate.addClass(this.$$element, toAdd); + } - if(toAdd.length === 0) { + var toRemove = tokenDifference(oldClasses, newClasses); + if (toRemove && toRemove.length) { $animate.removeClass(this.$$element, toRemove); - } else if(toRemove.length === 0) { - $animate.addClass(this.$$element, toAdd); - } else { - $animate.setClass(this.$$element, toAdd, toRemove); } }, diff --git a/src/ng/directive/ngClass.js b/src/ng/directive/ngClass.js index c9550187576f..71dfb81f9c28 100644 --- a/src/ng/directive/ngClass.js +++ b/src/ng/directive/ngClass.js @@ -56,15 +56,13 @@ function classDirective(name, selector) { function updateClasses (oldClasses, newClasses) { var toAdd = arrayDifference(newClasses, oldClasses); var toRemove = arrayDifference(oldClasses, newClasses); - toRemove = digestClassCounts(toRemove, -1); toAdd = digestClassCounts(toAdd, 1); - - if (toAdd.length === 0) { - $animate.removeClass(element, toRemove); - } else if (toRemove.length === 0) { + toRemove = digestClassCounts(toRemove, -1); + if(toAdd && toAdd.length) { $animate.addClass(element, toAdd); - } else { - $animate.setClass(element, toAdd, toRemove); + } + if(toRemove && toRemove.length) { + $animate.removeClass(element, toRemove); } } diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index eeffc16f4e1d..f6a2fa21de11 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -366,6 +366,7 @@ angular.module('ngAnimate', ['ng']) var noop = angular.noop; var forEach = angular.forEach; var selectors = $animateProvider.$$selectors; + var isArray = angular.isArray; var ELEMENT_NODE = 1; var NG_ANIMATE_STATE = '$$ngAnimateState'; @@ -394,6 +395,10 @@ angular.module('ngAnimate', ['ng']) return extractElementNode(elm1) == extractElementNode(elm2); } + function isEmpty(val) { + return !val && val.length === 0; + } + $provide.decorator('$animate', ['$delegate', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', function($delegate, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document) { @@ -419,10 +424,14 @@ angular.module('ngAnimate', ['ng']) return classNameFilter.test(className); }; - function blockElementAnimations(element) { + function classBasedAnimationsBlocked(element, setter) { var data = element.data(NG_ANIMATE_STATE) || {}; - data.running = true; - element.data(NG_ANIMATE_STATE, data); + if (setter) { + data.running = true; + data.structural = true; + element.data(NG_ANIMATE_STATE, data); + } + return data.disabled || (data.running && data.structural); } function runAnimationPostDigest(fn) { @@ -435,6 +444,51 @@ angular.module('ngAnimate', ['ng']) }; } + function resolveElementClasses(element, cache, runningAnimations) { + runningAnimations = runningAnimations || {}; + var map = {}; + + forEach(cache.add, function(className) { + if(className && className.length) { + map[className] = map[className] || 0; + map[className]++; + } + }); + + forEach(cache.remove, function(className) { + if(className && className.length) { + map[className] = map[className] || 0; + map[className]--; + } + }); + + var lookup = []; + forEach(runningAnimations, function(data, selector) { + forEach(selector.split(' '), function(s) { + lookup[s]=data; + }); + }); + + var toAdd = [], toRemove = []; + forEach(map, function(status, className) { + var hasClass = element.hasClass(className); + var matchingAnimation = lookup[className] || {}; + if (status < 0) { + //does it have the class or will it have the class + if(hasClass || matchingAnimation.event == 'addClass') { + toRemove.push(className); + } + } else if (status > 0) { + //is the class missing or will it be removed? + if(!hasClass || matchingAnimation.event == 'removeClass') { + toAdd.push(className); + } + } + }); + + return (toAdd.length + toRemove.length) > 0 && [toAdd.join(' '), toRemove.join(' ')]; + } + function lookup(name) { if (name) { var matches = [], @@ -473,17 +527,26 @@ angular.module('ngAnimate', ['ng']) return; } + var classNameAdd, classNameRemove; + if(isArray(className)) { + classNameAdd = className[0]; + classNameRemove = className[1]; + if (isEmpty(classNameAdd)) { + className = classNameRemove; + animationEvent = 'removeClass'; + } else if(isEmpty(classNameRemove)) { + className = classNameAdd; + animationEvent = 'addClass'; + } else { + className = classNameAdd + ' ' + classNameRemove; + } + } + var isSetClassOperation = animationEvent == 'setClass'; var isClassBased = isSetClassOperation || animationEvent == 'addClass' || animationEvent == 'removeClass'; - var classNameAdd, classNameRemove; - if(angular.isArray(className)) { - classNameAdd = className[0]; - classNameRemove = className[1]; - className = classNameAdd + ' ' + classNameRemove; - } var currentClassName = element.attr('class'); var classes = currentClassName + ' ' + className; @@ -665,7 +728,7 @@ angular.module('ngAnimate', ['ng']) parentElement = prepareElement(parentElement); afterElement = prepareElement(afterElement); - blockElementAnimations(element); + classBasedAnimationsBlocked(element, true); $delegate.enter(element, parentElement, afterElement); return runAnimationPostDigest(function() { return performAnimation('enter', 'ng-enter', stripCommentsFromElement(element), parentElement, afterElement, noop, doneCallback); @@ -707,7 +770,7 @@ angular.module('ngAnimate', ['ng']) element = angular.element(element); cancelChildAnimations(element); - blockElementAnimations(element); + classBasedAnimationsBlocked(element, true); this.enabled(false, element); return runAnimationPostDigest(function() { return performAnimation('leave', 'ng-leave', stripCommentsFromElement(element), null, null, function() { @@ -756,7 +819,7 @@ angular.module('ngAnimate', ['ng']) afterElement = prepareElement(afterElement); cancelChildAnimations(element); - blockElementAnimations(element); + classBasedAnimationsBlocked(element, true); $delegate.move(element, parentElement, afterElement); return runAnimationPostDigest(function() { return performAnimation('move', 'ng-move', stripCommentsFromElement(element), parentElement, afterElement, noop, doneCallback); @@ -794,11 +857,7 @@ angular.module('ngAnimate', ['ng']) * @return {function} the animation cancellation function */ addClass : function(element, className, doneCallback) { - element = angular.element(element); - element = stripCommentsFromElement(element); - return performAnimation('addClass', className, element, null, null, function() { - $delegate.addClass(element, className); - }, doneCallback); + return this.setClass(element, className, [], doneCallback); }, /** @@ -832,11 +891,7 @@ angular.module('ngAnimate', ['ng']) * @return {function} the animation cancellation function */ removeClass : function(element, className, doneCallback) { - element = angular.element(element); - element = stripCommentsFromElement(element); - return performAnimation('removeClass', className, element, null, null, function() { - $delegate.removeClass(element, className); - }, doneCallback); + return this.setClass(element, [], className, doneCallback); }, /** @@ -868,11 +923,54 @@ angular.module('ngAnimate', ['ng']) * @return {function} the animation cancellation function */ setClass : function(element, add, remove, doneCallback) { + var STORAGE_KEY = '$$animateClasses'; element = angular.element(element); element = stripCommentsFromElement(element); - return performAnimation('setClass', [add, remove], element, null, null, function() { - $delegate.setClass(element, add, remove); - }, doneCallback); + + if(classBasedAnimationsBlocked(element)) { + return $delegate.setClass(element, add, remove, doneCallback); + } + + add = isArray(add) ? add : add.split(' '); + remove = isArray(remove) ? remove : remove.split(' '); + doneCallback = doneCallback || noop; + + var cache = element.data(STORAGE_KEY); + if (cache) { + cache.callbacks.push(doneCallback); + cache.add = cache.add.concat(add); + cache.remove = cache.remove.concat(remove); + + //the digest cycle will combine all the animations into one function + return; + } else { + element.data(STORAGE_KEY, cache = { + callbacks : [doneCallback], + add : add, + remove : remove + }); + } + + return runAnimationPostDigest(function() { + var cache = element.data(STORAGE_KEY); + var callbacks = cache.callbacks; + + element.removeData(STORAGE_KEY); + + var state = element.data(NG_ANIMATE_STATE) || {}; + var classes = resolveElementClasses(element, cache, state.active); + return !classes + ? $$asyncCallback(onComplete) + : performAnimation('setClass', classes, element, null, null, function() { + $delegate.setClass(element, classes[0], classes[1]); + }, onComplete); + + function onComplete() { + forEach(callbacks, function(fn) { + fn(); + }); + } + }); }, /** @@ -931,6 +1029,7 @@ angular.module('ngAnimate', ['ng']) return noopCancel; } + animationEvent = runner.event; className = runner.className; var elementEvents = angular.element._data(runner.node); elementEvents = elementEvents && elementEvents.events; @@ -939,25 +1038,11 @@ angular.module('ngAnimate', ['ng']) parentElement = afterElement ? afterElement.parent() : element.parent(); } - var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; - var runningAnimations = ngAnimateState.active || {}; - var totalActiveAnimations = ngAnimateState.totalActive || 0; - var lastAnimation = ngAnimateState.last; - - //only allow animations if the currently running animation is not structural - //or if there is no animation running at all - var skipAnimations; - if (runner.isClassBased) { - skipAnimations = ngAnimateState.running || - ngAnimateState.disabled || - (lastAnimation && !lastAnimation.isClassBased); - } - //skip the animation if animations are disabled, a parent is already being animated, //the element is not currently attached to the document body or then completely close //the animation if any matching animations are not found at all. //NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case an animation was found. - if (skipAnimations || animationsDisabled(element, parentElement)) { + if (animationsDisabled(element, parentElement)) { fireDOMOperation(); fireBeforeCallbackAsync(); fireAfterCallbackAsync(); @@ -965,7 +1050,12 @@ angular.module('ngAnimate', ['ng']) return noopCancel; } + var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; + var runningAnimations = ngAnimateState.active || {}; + var totalActiveAnimations = ngAnimateState.totalActive || 0; + var lastAnimation = ngAnimateState.last; var skipAnimation = false; + if(totalActiveAnimations > 0) { var animationsToCancel = []; if(!runner.isClassBased) { @@ -1000,9 +1090,6 @@ angular.module('ngAnimate', ['ng']) } } - runningAnimations = ngAnimateState.active || {}; - totalActiveAnimations = ngAnimateState.totalActive || 0; - if(runner.isClassBased && !runner.isSetClassOperation && !skipAnimation) { skipAnimation = (animationEvent == 'addClass') == element.hasClass(className); //opposite of XOR } @@ -1015,6 +1102,9 @@ angular.module('ngAnimate', ['ng']) return noopCancel; } + runningAnimations = ngAnimateState.active || {}; + totalActiveAnimations = ngAnimateState.totalActive || 0; + if(animationEvent == 'leave') { //there's no need to ever remove the listener since the element //will be removed (destroyed) after the leave animation ends or @@ -1708,7 +1798,7 @@ angular.module('ngAnimate', ['ng']) function suffixClasses(classes, suffix) { var className = ''; - classes = angular.isArray(classes) ? classes : classes.split(/\s+/); + classes = isArray(classes) ? classes : classes.split(/\s+/); forEach(classes, function(klass, i) { if(klass && klass.length > 0) { className += (i > 0 ? ' ' : '') + klass + suffix; diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index 83e899345607..a27c2790f806 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -6019,9 +6019,12 @@ describe('$compile', function() { $rootScope.$digest(); data = $animate.queue.shift(); - expect(data.event).toBe('setClass'); + expect(data.event).toBe('addClass'); expect(data.args[1]).toBe('dice'); - expect(data.args[2]).toBe('rice'); + + data = $animate.queue.shift(); + expect(data.event).toBe('removeClass'); + expect(data.args[1]).toBe('rice'); expect(element.hasClass('ice')).toBe(true); expect(element.hasClass('dice')).toBe(true); diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index e789d141440d..9d4c92b3247d 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -391,7 +391,8 @@ describe('ngClass animations', function() { $rootScope.val = 'two'; $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('setClass'); + expect($animate.queue.shift().event).toBe('addClass'); + expect($animate.queue.shift().event).toBe('removeClass'); expect($animate.queue.length).toBe(0); }); }); @@ -506,9 +507,12 @@ describe('ngClass animations', function() { $rootScope.$digest(); item = $animate.queue.shift(); - expect(item.event).toBe('setClass'); + expect(item.event).toBe('addClass'); expect(item.args[1]).toBe('three'); - expect(item.args[2]).toBe('two'); + + item = $animate.queue.shift(); + expect(item.event).toBe('removeClass'); + expect(item.args[1]).toBe('two'); expect($animate.queue.length).toBe(0); }); diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index e674a08bfd63..926368f818e1 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -112,12 +112,14 @@ describe("ngAnimate", function() { angular.element(document.body).append($rootElement); $animate.addClass(elm1, 'klass'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(1); $animate.enabled(false); $animate.addClass(elm1, 'klass2'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(1); @@ -126,18 +128,21 @@ describe("ngAnimate", function() { elm1.append(elm2); $animate.addClass(elm2, 'klass'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(2); $animate.enabled(false, elm1); $animate.addClass(elm2, 'klass2'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(2); var root = angular.element($rootElement[0]); $rootElement.addClass('animated'); $animate.addClass(root, 'klass2'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(3); }); @@ -162,6 +167,7 @@ describe("ngAnimate", function() { var elm1 = $compile('
')($rootScope); $animate.addClass(elm1, 'klass2'); + $rootScope.$digest(); expect(count).toBe(0); }); }); @@ -193,6 +199,7 @@ describe("ngAnimate", function() { expect(captured).toBe(false); $animate.addClass(element, 'red'); + $rootScope.$digest(); $animate.triggerReflow(); expect(captured).toBe(true); @@ -200,6 +207,7 @@ describe("ngAnimate", function() { $animate.enabled(false); $animate.addClass(element, 'blue'); + $rootScope.$digest(); $animate.triggerReflow(); expect(captured).toBe(false); @@ -395,6 +403,7 @@ describe("ngAnimate", function() { child.addClass('ng-hide'); expect(child).toBeHidden(); $animate.removeClass(child, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); expect(child.hasClass('ng-hide-remove')).toBe(true); @@ -412,6 +421,7 @@ describe("ngAnimate", function() { $rootScope.$digest(); expect(child).toBeShown(); $animate.addClass(child, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); expect(child.hasClass('ng-hide-add')).toBe(true); @@ -450,6 +460,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { child.attr('class','classify no'); $animate.setClass(child, 'yes', 'no'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child.hasClass('yes')).toBe(true); @@ -488,6 +499,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { child.attr('class','classify no'); $animate.setClass(child[0], 'yes', 'no'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child.hasClass('yes')).toBe(true); @@ -529,6 +541,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { child.attr('class','classify no'); $animate.setClass(child, 'yes', 'no'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child.hasClass('yes')).toBe(true); @@ -568,6 +581,7 @@ describe("ngAnimate", function() { //hide $animate.addClass(child, 'ng-hide'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child.attr('class')).toContain('ng-hide-add'); expect(child.attr('class')).toContain('ng-hide-add-active'); @@ -575,6 +589,7 @@ describe("ngAnimate", function() { //show $animate.removeClass(child, 'ng-hide'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child.attr('class')).toContain('ng-hide-remove'); expect(child.attr('class')).toContain('ng-hide-remove-active'); @@ -645,6 +660,7 @@ describe("ngAnimate", function() { //addClass fn = $animate.addClass(child, 'ng-hide'); + $rootScope.$digest(); $animate.triggerReflow(); expect(captures.addClass).toBeUndefined(); @@ -654,6 +670,7 @@ describe("ngAnimate", function() { //removeClass fn = $animate.removeClass(child, 'ng-hide'); + $rootScope.$digest(); $animate.triggerReflow(); expect(captures.removeClass).toBeUndefined(); @@ -664,6 +681,7 @@ describe("ngAnimate", function() { //setClass child.addClass('red'); fn = $animate.setClass(child, 'blue', 'red'); + $rootScope.$digest(); $animate.triggerReflow(); expect(captures.setClass).toBeUndefined(); @@ -695,12 +713,14 @@ describe("ngAnimate", function() { element.text('123'); expect(element.text()).toBe('123'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); expect(element.text()).toBe('123'); $animate.enabled(true); element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); } @@ -716,6 +736,7 @@ describe("ngAnimate", function() { expect(element).toBeShown(); $animate.addClass(child, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { expect(child).toBeShown(); } @@ -750,6 +771,8 @@ describe("ngAnimate", function() { child.attr('style', 'width: 20px'); $animate.addClass(child, 'ng-hide'); + $rootScope.$digest(); + $animate.leave(child); $rootScope.$digest(); @@ -772,6 +795,7 @@ describe("ngAnimate", function() { child.addClass('custom-delay ng-hide'); $animate.removeClass(child, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); @@ -805,12 +829,14 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { $animate.addClass(element, 'hide'); + $rootScope.$digest(); expect(element).toHaveClass('ng-animate'); $animate.triggerReflow(); $animate.removeClass(element, 'hide'); + $rootScope.$digest(); expect(addClassDoneSpy).toHaveBeenCalled(); $animate.triggerReflow(); @@ -836,6 +862,7 @@ describe("ngAnimate", function() { expect(completed).toBe(false); $animate.addClass(child, 'green'); + $rootScope.$digest(); expect(element.hasClass('green')); expect(completed).toBe(false); @@ -865,6 +892,7 @@ describe("ngAnimate", function() { $animate.enabled(false, element); $animate.addClass(element, 'capture'); + $rootScope.$digest(); expect(element.hasClass('capture')).toBe(true); expect(capture).not.toBe(true); }); @@ -876,7 +904,11 @@ describe("ngAnimate", function() { element.append(child); $animate.addClass(child, 'custom-delay'); + $rootScope.$digest(); + $animate.addClass(child, 'custom-long-delay'); + $rootScope.$digest(); + $animate.triggerReflow(); expect(child.hasClass('animation-cancelled')).toBe(false); @@ -888,13 +920,17 @@ describe("ngAnimate", function() { it("should NOT clobber all data on an element when animation is finished", - inject(function($animate) { + inject(function($animate, $rootScope) { child.css('display','none'); element.data('foo', 'bar'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); + $animate.addClass(element, 'ng-hide'); + $rootScope.$digest(); + expect(element.data('foo')).toEqual('bar'); })); @@ -903,6 +939,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { $animate.addClass(element, 'custom-delay custom-long-delay'); + $rootScope.$digest(); $animate.triggerReflow(); $timeout.flush(2000); $timeout.flush(20000); @@ -926,6 +963,7 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); @@ -1004,6 +1042,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.animations) { $animate.triggerReflow(); browserTrigger(element,'animationend', { timeStamp: Date.now() + 4000, elapsedTime: 4 }); @@ -1029,6 +1068,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.animations) { $animate.triggerReflow(); browserTrigger(element,'animationend', { timeStamp: Date.now() + 6000, elapsedTime: 6 }); @@ -1056,6 +1096,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.transitions) { $animate.triggerReflow(); browserTrigger(element,'animationend', { timeStamp : Date.now() + 20000, elapsedTime: 10 }); @@ -1077,6 +1118,7 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); expect(element).toBeShown(); })); @@ -1093,6 +1135,7 @@ describe("ngAnimate", function() { element.addClass('custom'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if($sniffer.animations) { $animate.triggerReflow(); @@ -1102,6 +1145,8 @@ describe("ngAnimate", function() { element.removeClass('ng-hide'); $animate.addClass(element, 'ng-hide'); + $rootScope.$digest(); + expect(element.hasClass('ng-hide-remove')).toBe(false); //added right away if($sniffer.animations) { //cleanup some pending animations @@ -1279,6 +1324,7 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); expect(element).toBeShown(); $animate.enabled(true); @@ -1287,6 +1333,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.transitions) { $animate.triggerReflow(); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); @@ -1310,6 +1357,7 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.transitions) { $animate.triggerReflow(); @@ -1340,6 +1388,8 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); + expect(element).toBeShown(); $animate.enabled(true); @@ -1347,6 +1397,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.transitions) { $animate.triggerReflow(); var now = Date.now(); @@ -1376,6 +1427,7 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); $animate.triggerReflow(); @@ -1399,6 +1451,7 @@ describe("ngAnimate", function() { ss.addRule('.on', style); element = $compile(html('
'))($rootScope); $animate.addClass(element, 'on'); + $rootScope.$digest(); $animate.triggerReflow(); @@ -1424,6 +1477,7 @@ describe("ngAnimate", function() { expect(element).toBeHidden(); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if ($sniffer.transitions) { $animate.triggerReflow(); } @@ -1449,6 +1503,7 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -1461,6 +1516,7 @@ describe("ngAnimate", function() { expect(element).toBeShown(); $animate.addClass(element, 'ng-hide'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -1505,12 +1561,14 @@ describe("ngAnimate", function() { element = $compile(html('
1
'))($rootScope); $animate.addClass(element, 'my-class'); + $rootScope.$digest(); expect(element.attr('style')).not.toMatch(/transition.*?:\s*none/); expect(element.hasClass('my-class')).toBe(false); expect(element.hasClass('my-class-add')).toBe(true); $animate.triggerReflow(); + $rootScope.$digest(); expect(element.attr('style')).not.toMatch(/transition.*?:\s*none/); expect(element.hasClass('my-class')).toBe(true); @@ -1626,6 +1684,7 @@ describe("ngAnimate", function() { element = $compile(html('
foo
'))($rootScope); $animate.addClass(element, 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); //reflow expect(element.hasClass('some-class-add-active')).toBe(true); @@ -1792,11 +1851,13 @@ describe("ngAnimate", function() { element = $compile(html('
foo
'))($rootScope); $animate.addClass(element, 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); //reflow expect(element.hasClass('some-class-add-active')).toBe(true); $animate.removeClass(element, 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); //second reflow @@ -2035,11 +2096,13 @@ describe("ngAnimate", function() { $animate.addClass(element, 'on', function() { signature += 'A'; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.removeClass(element, 'on', function() { signature += 'B'; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -2062,6 +2125,7 @@ describe("ngAnimate", function() { $animate.setClass(element, 'on', 'off', function() { signature += 'Z'; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -2101,6 +2165,7 @@ describe("ngAnimate", function() { $animate.addClass(element, 'klass', function() { steps.push(['done', 'klass', 'addClass']); }); + $rootScope.$digest(); $animate.triggerCallbacks(); @@ -2176,6 +2241,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide', function() { flag = true; }); + $rootScope.$digest(); $animate.triggerCallbacks(); expect(flag).toBe(true); @@ -2199,6 +2265,7 @@ describe("ngAnimate", function() { $animate.addClass(element, 'ng-hide', function() { flag = true; }); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -2222,6 +2289,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide', function() { flag = true; }); + $rootScope.$digest(); $animate.triggerCallbacks(); expect(flag).toBe(true); @@ -2245,9 +2313,11 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide', function() { signature += 'A'; }); + $rootScope.$digest(); $animate.addClass(element, 'ng-hide', function() { signature += 'B'; }); + $rootScope.$digest(); $animate.addClass(element, 'ng-hide'); //earlier animation cancelled if($sniffer.transitions) { @@ -2292,6 +2362,7 @@ describe("ngAnimate", function() { //skipped animations captured = 'none'; $animate.removeClass(element, 'some-class'); + $rootScope.$digest(); expect(element.hasClass('some-class')).toBe(false); expect(captured).toBe('none'); @@ -2299,18 +2370,21 @@ describe("ngAnimate", function() { captured = 'nothing'; $animate.addClass(element, 'some-class'); + $rootScope.$digest(); expect(captured).toBe('nothing'); expect(element.hasClass('some-class')).toBe(true); //actual animations captured = 'none'; $animate.removeClass(element, 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(false); expect(captured).toBe('removeClass-some-class'); captured = 'nothing'; $animate.addClass(element, 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(true); expect(captured).toBe('addClass-some-class'); @@ -2326,6 +2400,7 @@ describe("ngAnimate", function() { //skipped animations captured = 'none'; $animate.removeClass(element[0], 'some-class'); + $rootScope.$digest(); expect(element.hasClass('some-class')).toBe(false); expect(captured).toBe('none'); @@ -2333,18 +2408,21 @@ describe("ngAnimate", function() { captured = 'nothing'; $animate.addClass(element[0], 'some-class'); + $rootScope.$digest(); expect(captured).toBe('nothing'); expect(element.hasClass('some-class')).toBe(true); //actual animations captured = 'none'; $animate.removeClass(element[0], 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(false); expect(captured).toBe('removeClass-some-class'); captured = 'nothing'; $animate.addClass(element[0], 'some-class'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(true); expect(captured).toBe('addClass-some-class'); @@ -2359,11 +2437,13 @@ describe("ngAnimate", function() { var element = jqLite(parent.find('span')); $animate.addClass(element,'klass'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(true); $animate.removeClass(element,'klass'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(false); @@ -2385,6 +2465,7 @@ describe("ngAnimate", function() { $animate.addClass(element,'klass', function() { signature += 'A'; }); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(true); @@ -2392,6 +2473,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'klass', function() { signature += 'B'; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -2418,6 +2500,7 @@ describe("ngAnimate", function() { $animate.addClass(element,'klass', function() { signature += '1'; }); + $rootScope.$digest(); if($sniffer.transitions) { expect(element.hasClass('klass-add')).toBe(true); @@ -2433,6 +2516,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'klass', function() { signature += '2'; }); + $rootScope.$digest(); if($sniffer.transitions) { expect(element.hasClass('klass-remove')).toBe(true); @@ -2465,6 +2549,7 @@ describe("ngAnimate", function() { $animate.addClass(element,'klassy', function() { signature += 'X'; }); + $rootScope.$digest(); $animate.triggerReflow(); $timeout.flush(500); @@ -2474,6 +2559,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'klassy', function() { signature += 'Y'; }); + $rootScope.$digest(); $animate.triggerReflow(); $timeout.flush(3000); @@ -2497,6 +2583,7 @@ describe("ngAnimate", function() { $animate.addClass(element[0],'klassy', function() { signature += 'X'; }); + $rootScope.$digest(); $animate.triggerReflow(); $timeout.flush(500); @@ -2506,6 +2593,7 @@ describe("ngAnimate", function() { $animate.removeClass(element[0],'klassy', function() { signature += 'Y'; }); + $rootScope.$digest(); $animate.triggerReflow(); $timeout.flush(3000); @@ -2534,6 +2622,7 @@ describe("ngAnimate", function() { $animate.addClass(element,'klass', function() { signature += 'd'; }); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -2550,6 +2639,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'klass', function() { signature += 'b'; }); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -2585,6 +2675,8 @@ describe("ngAnimate", function() { flag = true; }); + $rootScope.$digest(); + if($sniffer.transitions) { $animate.triggerReflow(); expect(element.hasClass('one-add')).toBe(true); @@ -2630,6 +2722,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'one two', function() { flag = true; }); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); @@ -3004,14 +3097,17 @@ describe("ngAnimate", function() { var element = html($compile('
')($rootScope)); $animate.addClass(element, 'super'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.data('classify')).toBe('add-super'); $animate.removeClass(element, 'super'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.data('classify')).toBe('remove-super'); $animate.addClass(element, 'superguy'); + $rootScope.$digest(); $animate.triggerReflow(); expect(element.data('classify')).toBe('add-superguy'); }); @@ -3139,6 +3235,7 @@ describe("ngAnimate", function() { $animate.triggerCallbacks(); $animate.addClass(child, 'something'); + $rootScope.$digest(); if($sniffer.transitions) { $animate.triggerReflow(); } @@ -3155,8 +3252,115 @@ describe("ngAnimate", function() { expect(child.hasClass('something-add-active')).toBe(false); } }); + + }); + + it('should coalesce all class-based animation calls together into a single animation', function() { + var log = []; + var track = function(name) { + return function() { + log.push({ name : name, className : arguments[1] }); + }; + }; + module(function($animateProvider) { + $animateProvider.register('.animate', function() { + return { + addClass : track('addClass'), + removeClass : track('removeClass') + }; + }); + }); + inject(function($rootScope, $animate, $compile, $rootElement, $document) { + $animate.enabled(true); + + var element = $compile('
')($rootScope); + $rootElement.append(element); + angular.element($document[0].body).append($rootElement); + + $animate.addClass(element, 'one'); + $animate.addClass(element, 'two'); + $animate.removeClass(element, 'three'); + $animate.removeClass(element, 'four'); + $animate.setClass(element, 'four five', 'two'); + + $rootScope.$digest(); + $animate.triggerReflow(); + + expect(log.length).toBe(2); + expect(log[0]).toEqual({ name : 'addClass', className : 'one five' }); + expect(log[1]).toEqual({ name : 'removeClass', className : 'three' }); + }); + }); + + it('should call class-based animation callbacks in the correct order when animations are skipped', function() { + var continueAnimation; + module(function($animateProvider) { + $animateProvider.register('.animate', function() { + return { + addClass : function(element, className, done) { + continueAnimation = done; + } + }; + }); + }); + inject(function($rootScope, $animate, $compile, $rootElement, $document) { + $animate.enabled(true); + + var element = $compile('
')($rootScope); + $rootElement.append(element); + angular.element($document[0].body).append($rootElement); + + var log = ''; + $animate.addClass(element, 'one', function() { + log += 'A'; + }); + $rootScope.$digest(); + + $animate.addClass(element, 'one', function() { + log += 'B'; + }); + $rootScope.$digest(); + $animate.triggerCallbacks(); + + $animate.triggerReflow(); + continueAnimation(); + $animate.triggerCallbacks(); + expect(log).toBe('BA'); + }); }); + it('should skip class-based animations when add class and remove class cancel each other out', function() { + var spy = jasmine.createSpy(); + module(function($animateProvider) { + $animateProvider.register('.animate', function() { + return { + addClass : spy, + removeClass : spy, + }; + }); + }); + inject(function($rootScope, $animate, $compile) { + $animate.enabled(true); + + var element = $compile('
')($rootScope); + + var count = 0; + var callback = function() { + count++; + }; + + $animate.addClass(element, 'on', callback); + $animate.addClass(element, 'on', callback); + $animate.removeClass(element, 'on', callback); + $animate.removeClass(element, 'on', callback); + + $rootScope.$digest(); + $animate.triggerCallbacks(); + + expect(spy).not.toHaveBeenCalled(); + expect(count).toBe(4); + }); + }); it("should wait until a queue of animations are complete before performing a reflow", inject(function($rootScope, $compile, $timeout, $sniffer, $animate) { @@ -3217,6 +3421,7 @@ describe("ngAnimate", function() { $animate.enabled(true, element); $animate.addClass(child, 'awesome'); + $rootScope.$digest(); $animate.triggerReflow(); expect(childAnimated).toBe(true); @@ -3224,6 +3429,7 @@ describe("ngAnimate", function() { $animate.enabled(false, element); $animate.addClass(child, 'super'); + $rootScope.$digest(); $animate.triggerReflow(); expect(childAnimated).toBe(false); @@ -3283,6 +3489,7 @@ describe("ngAnimate", function() { continueAnimation(); $animate.addClass(child1, 'test'); + $rootScope.$digest(); $animate.triggerReflow(); expect(child1.hasClass('test')).toBe(true); @@ -3305,6 +3512,7 @@ describe("ngAnimate", function() { $animate.triggerCallbacks(); $animate.addClass(child2, 'testing'); + $rootScope.$digest(); expect(intercepted).toBe('move'); continueAnimation(); @@ -3445,9 +3653,11 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); $animate.addClass(element, 'green'); + $rootScope.$digest(); expect(element.hasClass('green-add')).toBe(true); $animate.addClass(element, 'red'); + $rootScope.$digest(); expect(element.hasClass('red-add')).toBe(true); expect(element.hasClass('green')).toBe(false); @@ -3529,13 +3739,17 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); $animate.addClass(element, 'on'); + $rootScope.$digest(); expect(currentAnimation).toBe('addClass'); currentFn(); currentAnimation = null; $animate.removeClass(element, 'on'); + $rootScope.$digest(); + $animate.addClass(element, 'on'); + $rootScope.$digest(); expect(currentAnimation).toBe('addClass'); }); @@ -3557,11 +3771,13 @@ describe("ngAnimate", function() { $rootElement.addClass('animated'); $animate.addClass($rootElement, 'green'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(1); $animate.addClass($rootElement, 'red'); + $rootScope.$digest(); $animate.triggerReflow(); expect(count).toBe(2); @@ -3592,6 +3808,8 @@ describe("ngAnimate", function() { $rootElement.append(element); $animate.addClass(element, 'red'); + $rootScope.$digest(); + $animate.triggerReflow(); expect(steps).toEqual(['before','after']); @@ -3648,12 +3866,14 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); $animate.removeClass(element, 'base-class one two'); + $rootScope.$digest(); //still true since we're before the reflow expect(element.hasClass('base-class')).toBe(true); //this will cancel the remove animation $animate.addClass(element, 'base-class one two'); + $rootScope.$digest(); //the cancellation was a success and the class was removed right away expect(element.hasClass('base-class')).toBe(false); @@ -3694,6 +3914,7 @@ describe("ngAnimate", function() { expect(capturedProperty).toBe('none'); $animate.addClass(element, 'trigger-class'); + $rootScope.$digest(); $animate.triggerReflow(); @@ -3719,6 +3940,7 @@ describe("ngAnimate", function() { var animationKey = $sniffer.vendorPrefix == 'Webkit' ? 'WebkitAnimation' : 'animation'; $animate.addClass(element, 'trigger-class'); + $rootScope.$digest(); expect(node.style[animationKey]).not.toContain('none'); @@ -3765,6 +3987,7 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); $animate.addClass(element, 'some-klass'); + $rootScope.$digest(); var prop = $sniffer.vendorPrefix == 'Webkit' ? 'WebkitAnimation' : 'animation'; @@ -3967,6 +4190,7 @@ describe("ngAnimate", function() { $animate.addClass(element, 'on', function() { ready = true; }); + $rootScope.$digest(); $animate.triggerReflow(); browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 1 }); @@ -3981,6 +4205,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'on', function() { ready = true; }); + $rootScope.$digest(); $animate.triggerReflow(); browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 1 }); @@ -4006,6 +4231,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'on', function() { ready = true; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -4030,9 +4256,12 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'on', function() { signature += 'A'; }); + $rootScope.$digest(); + $animate.addClass(element, 'on', function() { signature += 'B'; }); + $rootScope.$digest(); $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -4066,7 +4295,10 @@ describe("ngAnimate", function() { expect(cancelReflowCallback).not.toHaveBeenCalled(); $animate.addClass(element, 'fast'); + $rootScope.$digest(); + $animate.addClass(element, 'smooth'); + $rootScope.$digest(); $animate.triggerReflow(); expect(cancelReflowCallback).toHaveBeenCalled(); diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index 2aa286e85f5e..9a41252fb998 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -773,7 +773,10 @@ describe('ngView animations', function() { $rootScope.klass = 'boring'; $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('setClass'); + expect($animate.queue.shift().event).toBe('addClass'); + expect($animate.queue.shift().event).toBe('removeClass'); + + $animate.triggerReflow(); expect(item.hasClass('classy')).toBe(false); expect(item.hasClass('boring')).toBe(true); From 1f527d9d508a7c1c592b4bb2855977b3216cbf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Fri, 15 Aug 2014 23:45:29 -0400 Subject: [PATCH 2/3] feat($animate): use promises instead of callbacks for animations The $animate service (both the service inside of ng and ngAnimate) now makes use of promises instead of callback functions. BREAKING CHANGE Any existing directive code that makes use of animation callbacks provided directly into any of the methods available $animate must now be converted to use promises instead. Also, the `cancel` function (which was the return value from each of the $animate methods) is now returned as a member function on the returned animation promise. ```js //before var cancelFn = $animate.enter(element, container, null, callbackFn); cancelFn(); //cancels the animation //after (without cancel) var promise = $animate.enter(element, container).then(callbackFn); //after (with cancellation method) var promise = $animate.enter(element, container); promise.then(callbackFn); promise.cancel(); //cancels the animation ``` --- src/ng/animate.js | 96 ++++++------ src/ng/directive/ngIf.js | 2 +- src/ng/directive/ngInclude.js | 4 +- src/ng/directive/ngSwitch.js | 2 +- src/ngAnimate/animate.js | 148 ++++++++++-------- src/ngMock/angular-mocks.js | 13 +- src/ngRoute/directive/ngView.js | 4 +- test/.jshintrc | 1 + test/helpers/matchers.js | 5 + test/ng/animateSpec.js | 14 +- test/ng/directive/ngClassSpec.js | 2 +- test/ng/directive/ngIfSpec.js | 14 +- test/ng/directive/ngIncludeSpec.js | 14 +- test/ng/directive/ngSwitchSpec.js | 14 +- test/ngAnimate/animateSpec.js | 214 ++++++++++++++------------- test/ngRoute/directive/ngViewSpec.js | 14 +- 16 files changed, 294 insertions(+), 267 deletions(-) diff --git a/src/ng/animate.js b/src/ng/animate.js index e8cca8fcaa2d..584358366dc8 100644 --- a/src/ng/animate.js +++ b/src/ng/animate.js @@ -81,10 +81,20 @@ var $AnimateProvider = ['$provide', function($provide) { return this.$$classNameFilter; }; - this.$get = ['$timeout', '$$asyncCallback', function($timeout, $$asyncCallback) { - - function async(fn) { - fn && $$asyncCallback(fn); + this.$get = ['$$q', '$$asyncCallback', function($$q, $$asyncCallback) { + + var currentDefer; + function asyncPromise() { + // only serve one instance of a promise in order to save CPU cycles + if (!currentDefer) { + currentDefer = $$q.defer(); + currentDefer.promise.cancel = noop; //ngAnimate.$animate provides this + $$asyncCallback(function() { + currentDefer.resolve(); + currentDefer = null; + }); + } + return currentDefer.promise; } /** @@ -112,22 +122,19 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#enter * @kind function * @description Inserts the element into the DOM either after the `after` element or - * as the first child within the `parent` element. Once complete, the done() callback - * will be fired (if provided). + * as the first child within the `parent` element. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be inserted into the DOM * @param {DOMElement} parent the parent element which will append the element as * a child (if the after element is not present) * @param {DOMElement} after the sibling element which will append the element * after itself - * @param {Function=} done callback function that will be called after the element has been - * inserted into the DOM + * @return {Promise} the animation callback promise */ - enter : function(element, parent, after, done) { - after - ? after.after(element) - : parent.prepend(element); - async(done); - return noop; + enter : function(element, parent, after) { + after ? after.after(element) + : parent.prepend(element); + return asyncPromise(); }, /** @@ -135,16 +142,14 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#leave * @kind function - * @description Removes the element from the DOM. Once complete, the done() callback will be - * fired (if provided). + * @description Removes the element from the DOM. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be removed from the DOM - * @param {Function=} done callback function that will be called after the element has been - * removed from the DOM + * @return {Promise} the animation callback promise */ - leave : function(element, done) { + leave : function(element) { element.remove(); - async(done); - return noop; + return asyncPromise(); }, /** @@ -153,8 +158,8 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#move * @kind function * @description Moves the position of the provided element within the DOM to be placed - * either after the `after` element or inside of the `parent` element. Once complete, the - * done() callback will be fired (if provided). + * either after the `after` element or inside of the `parent` element. When the function + * is called a promise is returned that will be resolved at a later time. * * @param {DOMElement} element the element which will be moved around within the * DOM @@ -162,13 +167,12 @@ var $AnimateProvider = ['$provide', function($provide) { * inserted into (if the after element is not present) * @param {DOMElement} after the sibling element where the element will be * positioned next to - * @param {Function=} done the callback function (if provided) that will be fired after the - * element has been moved to its new position + * @return {Promise} the animation callback promise */ - move : function(element, parent, after, done) { + move : function(element, parent, after) { // Do not remove element before insert. Removing will cause data associated with the // element to be dropped. Insert will implicitly do the remove. - return this.enter(element, parent, after, done); + return this.enter(element, parent, after); }, /** @@ -176,23 +180,21 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#addClass * @kind function - * @description Adds the provided className CSS class value to the provided element. Once - * complete, the done() callback will be fired (if provided). + * @description Adds the provided className CSS class value to the provided element. + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * added to it * @param {string} className the CSS class which will be added to the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been added to the element + * @return {Promise} the animation callback promise */ - addClass : function(element, className, done) { + addClass : function(element, className) { className = !isString(className) ? (isArray(className) ? className.join(' ') : '') : className; forEach(element, function (element) { jqLiteAddClass(element, className); }); - async(done); - return noop; + return asyncPromise(); }, /** @@ -201,22 +203,20 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#removeClass * @kind function * @description Removes the provided className CSS class value from the provided element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * removed from it * @param {string} className the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been removed from the element + * @return {Promise} the animation callback promise */ - removeClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; + removeClass : function(element, className) { + className = !isString(className) + ? (isArray(className) ? className.join(' ') : '') + : className; forEach(element, function (element) { jqLiteRemoveClass(element, className); }); - async(done); - return noop; + return asyncPromise(); }, /** @@ -225,19 +225,17 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#setClass * @kind function * @description Adds and/or removes the given CSS classes to and from the element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have its CSS classes changed * removed from it * @param {string} add the CSS classes which will be added to the element * @param {string} remove the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * CSS classes have been set on the element + * @return {Promise} the animation callback promise */ - setClass : function(element, add, remove, done) { + setClass : function(element, add, remove) { this.addClass(element, add); this.removeClass(element, remove); - async(done); - return noop; + return asyncPromise(); }, enabled : noop diff --git a/src/ng/directive/ngIf.js b/src/ng/directive/ngIf.js index b4c569fecbbe..34cec3696fa4 100644 --- a/src/ng/directive/ngIf.js +++ b/src/ng/directive/ngIf.js @@ -113,7 +113,7 @@ var ngIfDirective = ['$animate', function($animate) { } if(block) { previousElements = getBlockElements(block.clone); - $animate.leave(previousElements, function() { + $animate.leave(previousElements).then(function() { previousElements = null; }); block = null; diff --git a/src/ng/directive/ngInclude.js b/src/ng/directive/ngInclude.js index 7b0d020bf5f2..8aea896b2dd8 100644 --- a/src/ng/directive/ngInclude.js +++ b/src/ng/directive/ngInclude.js @@ -198,7 +198,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope = null; } if(currentElement) { - $animate.leave(currentElement, function() { + $animate.leave(currentElement).then(function() { previousElement = null; }); previousElement = currentElement; @@ -228,7 +228,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' // directives to non existing elements. var clone = $transclude(newScope, function(clone) { cleanupLastIncludeContent(); - $animate.enter(clone, null, $element, afterAnimation); + $animate.enter(clone, null, $element).then(afterAnimation); }); currentScope = newScope; diff --git a/src/ng/directive/ngSwitch.js b/src/ng/directive/ngSwitch.js index 1eb1d32a4e21..4644e3194712 100644 --- a/src/ng/directive/ngSwitch.js +++ b/src/ng/directive/ngSwitch.js @@ -155,7 +155,7 @@ var ngSwitchDirective = ['$animate', function($animate) { var selected = getBlockElements(selectedElements[i].clone); selectedScopes[i].$destroy(); previousElements[i] = selected; - $animate.leave(selected, function() { + $animate.leave(selected).then(function() { previousElements.splice(i, 1); }); } diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index f6a2fa21de11..5ed202f56d08 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -264,7 +264,7 @@ * * Stagger animations are currently only supported within CSS-defined animations. * - *

JavaScript-defined Animations

+ * ## JavaScript-defined Animations * In the event that you do not want to use CSS3 transitions or CSS3 animations or if you wish to offer animations on browsers that do not * yet support CSS transitions/animations, then you can make use of JavaScript animations defined inside of your AngularJS module. * @@ -399,8 +399,9 @@ angular.module('ngAnimate', ['ng']) return !val && val.length === 0; } - $provide.decorator('$animate', ['$delegate', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', - function($delegate, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document) { + $provide.decorator('$animate', + ['$delegate', '$$q', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', + function($delegate, $$q, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document) { var globalAnimationCounter = 0; $rootElement.data(NG_ANIMATE_STATE, rootAnimateState); @@ -435,13 +436,16 @@ angular.module('ngAnimate', ['ng']) } function runAnimationPostDigest(fn) { - var cancelFn; - $rootScope.$$postDigest(function() { - cancelFn = fn(); - }); - return function() { + var cancelFn, defer = $$q.defer(); + defer.promise.cancel = function() { cancelFn && cancelFn(); }; + $rootScope.$$postDigest(function() { + cancelFn = fn(function() { + defer.resolve(); + }); + }); + return defer.promise; } function resolveElementClasses(element, cache, runningAnimations) { @@ -673,7 +677,7 @@ angular.module('ngAnimate', ['ng']) /** * @ngdoc service * @name $animate - * @kind function + * @kind object * * @description * The `$animate` service provides animation detection support while performing DOM operations (enter, leave and move) as well as during addClass and removeClass operations. @@ -687,6 +691,45 @@ angular.module('ngAnimate', ['ng']) * Requires the {@link ngAnimate `ngAnimate`} module to be installed. * * Please visit the {@link ngAnimate `ngAnimate`} module overview page learn more about how to use animations in your application. + * ## Callback Promises + * With AngularJS 1.3, each of the animation methods, on the `$animate` service, return a promise when called. The + * promise itself is then resolved once the animation has completed itself, has been cancelled or has been + * skipped due to animations being disabled. (Note that even if the animation is cancelled it will still + * call the resolve function of the animation.) + * + * ```js + * $animate.enter(element, container).then(function() { + * //...this is called once the animation is complete... + * }); + * ``` + * + * Also note that, due to the nature of the callback promise, if any Angular-specific code (like changing the scope, + * location of the page, etc...) is executed within the callback promise then be sure to wrap the code using + * `$scope.$apply(...)`; + * + * ```js + * $animate.leave(element).then(function() { + * $scope.$apply(function() { + * $location.path('/new-page'); + * }); + * }); + * ``` + * + * An animation can also be cancelled by calling the `cancel()` method on the returned promise. + * + * ```js + * var promise = $animate.addClass(element, 'super-long-animation').then(function() { + * //this will still be called even if cancelled + * }); + * + * element.on('click', function() { + * //tooo lazy to wait for the animation to end + * promise.cancel(); + * }); + * ``` + * + * (Keep in mind that the cancel function is unique to `$animate` and promises in general due not provide support + * for calling cancel.) * */ return { @@ -715,23 +758,22 @@ angular.module('ngAnimate', ['ng']) * | 10. the .ng-enter-active class is added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-enter ng-enter-active" | * | 11. $animate waits for the animation to complete (via events and timeout) | class="my-animation ng-animate ng-enter ng-enter-active" | * | 12. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | - * | 13. The doneCallback() callback is fired (if provided) | class="my-animation" | + * | 13. The returned promise is resolved. | class="my-animation" | * * @param {DOMElement} element the element that will be the focus of the enter animation * @param {DOMElement} parentElement the parent element of the element that will be the focus of the enter animation * @param {DOMElement} afterElement the sibling element (which is the previous element) of the element that will be the focus of the enter animation - * @param {function()=} doneCallback the callback function that will be called once the animation is complete - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - enter : function(element, parentElement, afterElement, doneCallback) { + enter : function(element, parentElement, afterElement) { element = angular.element(element); parentElement = prepareElement(parentElement); afterElement = prepareElement(afterElement); classBasedAnimationsBlocked(element, true); $delegate.enter(element, parentElement, afterElement); - return runAnimationPostDigest(function() { - return performAnimation('enter', 'ng-enter', stripCommentsFromElement(element), parentElement, afterElement, noop, doneCallback); + return runAnimationPostDigest(function(done) { + return performAnimation('enter', 'ng-enter', stripCommentsFromElement(element), parentElement, afterElement, noop, done); }); }, @@ -760,22 +802,21 @@ angular.module('ngAnimate', ['ng']) * | 10. $animate waits for the animation to complete (via events and timeout) | class="my-animation ng-animate ng-leave ng-leave-active" | * | 11. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | * | 12. The element is removed from the DOM | ... | - * | 13. The doneCallback() callback is fired (if provided) | ... | + * | 13. The returned promise is resolved. | ... | * * @param {DOMElement} element the element that will be the focus of the leave animation - * @param {function()=} doneCallback the callback function that will be called once the animation is complete - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - leave : function(element, doneCallback) { + leave : function(element) { element = angular.element(element); cancelChildAnimations(element); classBasedAnimationsBlocked(element, true); this.enabled(false, element); - return runAnimationPostDigest(function() { + return runAnimationPostDigest(function(done) { return performAnimation('leave', 'ng-leave', stripCommentsFromElement(element), null, null, function() { $delegate.leave(element); - }, doneCallback); + }, done); }); }, @@ -805,15 +846,14 @@ angular.module('ngAnimate', ['ng']) * | 10. the .ng-move-active class is added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-move ng-move-active" | * | 11. $animate waits for the animation to complete (via events and timeout) | class="my-animation ng-animate ng-move ng-move-active" | * | 12. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | - * | 13. The doneCallback() callback is fired (if provided) | class="my-animation" | + * | 13. The returned promise is resolved. | class="my-animation" | * * @param {DOMElement} element the element that will be the focus of the move animation * @param {DOMElement} parentElement the parentElement element of the element that will be the focus of the move animation * @param {DOMElement} afterElement the sibling element (which is the previous element) of the element that will be the focus of the move animation - * @param {function()=} doneCallback the callback function that will be called once the animation is complete - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - move : function(element, parentElement, afterElement, doneCallback) { + move : function(element, parentElement, afterElement) { element = angular.element(element); parentElement = prepareElement(parentElement); afterElement = prepareElement(afterElement); @@ -821,8 +861,8 @@ angular.module('ngAnimate', ['ng']) cancelChildAnimations(element); classBasedAnimationsBlocked(element, true); $delegate.move(element, parentElement, afterElement); - return runAnimationPostDigest(function() { - return performAnimation('move', 'ng-move', stripCommentsFromElement(element), parentElement, afterElement, noop, doneCallback); + return runAnimationPostDigest(function(done) { + return performAnimation('move', 'ng-move', stripCommentsFromElement(element), parentElement, afterElement, noop, done); }); }, @@ -849,15 +889,14 @@ angular.module('ngAnimate', ['ng']) * | 7. $animate waits for the animation to complete (via events and timeout) | class="my-animation super super-add super-add-active" | * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation super" | * | 9. The super class is kept on the element | class="my-animation super" | - * | 10. The doneCallback() callback is fired (if provided) | class="my-animation super" | + * | 10. The returned promise is resolved. | class="my-animation super" | * * @param {DOMElement} element the element that will be animated * @param {string} className the CSS class that will be added to the element and then animated - * @param {function()=} doneCallback the callback function that will be called once the animation is complete - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - addClass : function(element, className, doneCallback) { - return this.setClass(element, className, [], doneCallback); + addClass : function(element, className) { + return this.setClass(element, className, []); }, /** @@ -882,16 +921,15 @@ angular.module('ngAnimate', ['ng']) * | 6. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation super ng-animate super-remove" | * | 7. $animate waits for the animation to complete (via events and timeout) | class="my-animation ng-animate super-remove super-remove-active" | * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | - * | 9. The doneCallback() callback is fired (if provided) | class="my-animation" | + * | 9. The returned promise is resolved. | class="my-animation" | * * * @param {DOMElement} element the element that will be animated * @param {string} className the CSS class that will be animated and then removed from the element - * @param {function()=} doneCallback the callback function that will be called once the animation is complete - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - removeClass : function(element, className, doneCallback) { - return this.setClass(element, [], className, doneCallback); + removeClass : function(element, className) { + return this.setClass(element, [], className); }, /** @@ -911,65 +949,53 @@ angular.module('ngAnimate', ['ng']) * | 5. the .on, .on-add-active and .off-remove-active classes are added and .off is removed (this triggers the CSS transition/animation) | class="my-animation ng-animate on on-add on-add-active off-remove off-remove-active” | * | 6. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-animate on on-add on-add-active off-remove off-remove-active" | * | 7. $animate waits for the animation to complete (via events and timeout) | class="my-animation ng-animate on on-add on-add-active off-remove off-remove-active" | - * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | - * | 9. The doneCallback() callback is fired (if provided) | class="my-animation" | + * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation on" | + * | 9. The returned promise is resolved. | class="my-animation on" | * * @param {DOMElement} element the element which will have its CSS classes changed * removed from it * @param {string} add the CSS classes which will be added to the element * @param {string} remove the CSS class which will be removed from the element - * @param {function=} done the callback function (if provided) that will be fired after the * CSS classes have been set on the element - * @return {function} the animation cancellation function + * @return {Promise} the animation callback promise */ - setClass : function(element, add, remove, doneCallback) { + setClass : function(element, add, remove) { var STORAGE_KEY = '$$animateClasses'; element = angular.element(element); element = stripCommentsFromElement(element); if(classBasedAnimationsBlocked(element)) { - return $delegate.setClass(element, add, remove, doneCallback); + return $delegate.setClass(element, add, remove); } add = isArray(add) ? add : add.split(' '); remove = isArray(remove) ? remove : remove.split(' '); - doneCallback = doneCallback || noop; var cache = element.data(STORAGE_KEY); if (cache) { - cache.callbacks.push(doneCallback); cache.add = cache.add.concat(add); cache.remove = cache.remove.concat(remove); //the digest cycle will combine all the animations into one function - return; + return cache.promise; } else { element.data(STORAGE_KEY, cache = { - callbacks : [doneCallback], add : add, remove : remove }); } - return runAnimationPostDigest(function() { + return cache.promise = runAnimationPostDigest(function(done) { var cache = element.data(STORAGE_KEY); - var callbacks = cache.callbacks; - element.removeData(STORAGE_KEY); var state = element.data(NG_ANIMATE_STATE) || {}; var classes = resolveElementClasses(element, cache, state.active); return !classes - ? $$asyncCallback(onComplete) + ? done() : performAnimation('setClass', classes, element, null, null, function() { $delegate.setClass(element, classes[0], classes[1]); - }, onComplete); - - function onComplete() { - forEach(callbacks, function(fn) { - fn(); - }); - } + }, done); }); }, @@ -1179,11 +1205,7 @@ angular.module('ngAnimate', ['ng']) function fireDoneCallbackAsync() { fireDOMCallback('close'); - if(doneCallback) { - $$asyncCallback(function() { - doneCallback(); - }); - } + doneCallback(); } //it is less complicated to use a flag than managing and canceling diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index e3be73835e4d..2f10af742672 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -767,14 +767,21 @@ angular.mock.animate = angular.module('ngAnimateMock', ['ng']) }; }); - $provide.decorator('$animate', ['$delegate', '$$asyncCallback', - function($delegate, $$asyncCallback) { + $provide.decorator('$animate', ['$delegate', '$$asyncCallback', '$timeout', '$browser', + function($delegate, $$asyncCallback, $timeout, $browser) { var animate = { queue : [], enabled : $delegate.enabled, - triggerCallbacks : function() { + triggerCallbackEvents : function() { $$asyncCallback.flush(); }, + triggerCallbackPromise : function() { + $timeout.flush(0); + }, + triggerCallbacks : function() { + this.triggerCallbackEvents(); + this.triggerCallbackPromise(); + }, triggerReflow : function() { angular.forEach(reflowQueue, function(fn) { fn(); diff --git a/src/ngRoute/directive/ngView.js b/src/ngRoute/directive/ngView.js index 02ae2aeb3c81..ba61a6cd1e54 100644 --- a/src/ngRoute/directive/ngView.js +++ b/src/ngRoute/directive/ngView.js @@ -206,7 +206,7 @@ function ngViewFactory( $route, $anchorScroll, $animate) { currentScope = null; } if(currentElement) { - $animate.leave(currentElement, function() { + $animate.leave(currentElement).then(function() { previousElement = null; }); previousElement = currentElement; @@ -229,7 +229,7 @@ function ngViewFactory( $route, $anchorScroll, $animate) { // function is called before linking the content, which would apply child // directives to non existing elements. var clone = $transclude(newScope, function(clone) { - $animate.enter(clone, null, currentElement || $element, function onNgViewEnter () { + $animate.enter(clone, null, currentElement || $element).then(function onNgViewEnter () { if (angular.isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { $anchorScroll(); diff --git a/test/.jshintrc b/test/.jshintrc index 8be371609fdb..b352fca2bcc8 100644 --- a/test/.jshintrc +++ b/test/.jshintrc @@ -51,6 +51,7 @@ "isBoolean": false, "trim": false, "isElement": false, + "isPromiseLike": false, "makeMap": false, "map": false, "size": false, diff --git a/test/helpers/matchers.js b/test/helpers/matchers.js index 421feb061de8..2cb34c6872ab 100644 --- a/test/helpers/matchers.js +++ b/test/helpers/matchers.js @@ -50,6 +50,11 @@ beforeEach(function() { toBePristine: cssMatcher('ng-pristine', 'ng-dirty'), toBeUntouched: cssMatcher('ng-untouched', 'ng-touched'), toBeTouched: cssMatcher('ng-touched', 'ng-untouched'), + toBeAPromise: function() { + this.message = valueFn( + "Expected object " + (this.isNot ? "not ": "") + "to be a promise"); + return isPromiseLike(this.actual); + }, toBeShown: function() { this.message = valueFn( "Expected element " + (this.isNot ? "": "not ") + "to have 'ng-hide' class"); diff --git a/test/ng/animateSpec.js b/test/ng/animateSpec.js index 7ab12b08f244..9e167dc35589 100644 --- a/test/ng/animateSpec.js +++ b/test/ng/animateSpec.js @@ -57,18 +57,18 @@ describe("$animate", function() { expect(element).toBeHidden(); })); - it("should run each method and return a noop function", inject(function($animate, $document) { + it("should run each method and return a promise", inject(function($animate, $document) { var element = jqLite('
'); var move = jqLite('
'); var parent = jqLite($document[0].body); parent.append(move); - expect($animate.enter(element, parent)).toBe(noop); - expect($animate.move(element, move)).toBe(noop); - expect($animate.addClass(element, 'on')).toBe(noop); - expect($animate.addClass(element, 'off')).toBe(noop); - expect($animate.setClass(element, 'on', 'off')).toBe(noop); - expect($animate.leave(element)).toBe(noop); + expect($animate.enter(element, parent)).toBeAPromise(); + expect($animate.move(element, move)).toBeAPromise(); + expect($animate.addClass(element, 'on')).toBeAPromise(); + expect($animate.removeClass(element, 'off')).toBeAPromise(); + expect($animate.setClass(element, 'on', 'off')).toBeAPromise(); + expect($animate.leave(element)).toBeAPromise(); })); it("should add and remove classes on SVG elements", inject(function($animate) { diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 9d4c92b3247d..a00708f18d5d 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -441,7 +441,7 @@ describe('ngClass animations', function() { $compile(element)($rootScope); var enterComplete = false; - $animate.enter(element, $rootElement, null, function() { + $animate.enter(element, $rootElement, null).then(function() { enterComplete = true; }); diff --git a/test/ng/directive/ngIfSpec.js b/test/ng/directive/ngIfSpec.js index f952500ec90d..b4f421667657 100755 --- a/test/ng/directive/ngIfSpec.js +++ b/test/ng/directive/ngIfSpec.js @@ -315,14 +315,12 @@ describe('ngIf animations', function () { it('should destroy the previous leave animation if a new one takes place', function() { module(function($provide) { - $provide.value('$animate', { - enabled : function() { return true; }, - leave : function() { - //DOM operation left blank - }, - enter : function(element, parent) { - parent.append(element); - } + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; }); }); inject(function ($compile, $rootScope, $animate) { diff --git a/test/ng/directive/ngIncludeSpec.js b/test/ng/directive/ngIncludeSpec.js index 828c6d2056fb..1d2f1ec4c89a 100644 --- a/test/ng/directive/ngIncludeSpec.js +++ b/test/ng/directive/ngIncludeSpec.js @@ -693,14 +693,12 @@ describe('ngInclude animations', function() { it('should destroy the previous leave animation if a new one takes place', function() { module(function($provide) { - $provide.value('$animate', { - enabled : function() { return true; }, - leave : function() { - //DOM operation left blank - }, - enter : function(element, parent, after) { - angular.element(after).after(element); - } + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; }); }); inject(function ($compile, $rootScope, $animate, $templateCache) { diff --git a/test/ng/directive/ngSwitchSpec.js b/test/ng/directive/ngSwitchSpec.js index 5e48d68f19f3..f7b4e4422f00 100644 --- a/test/ng/directive/ngSwitchSpec.js +++ b/test/ng/directive/ngSwitchSpec.js @@ -397,14 +397,12 @@ describe('ngSwitch animations', function() { it('should destroy the previous leave animation if a new one takes place', function() { module(function($provide) { - $provide.value('$animate', { - enabled : function() { return true; }, - leave : function() { - //DOM operation left blank - }, - enter : function(element, parent, after) { - angular.element(after).after(element); - } + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; }); }); inject(function ($compile, $rootScope, $animate, $templateCache) { diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index 926368f818e1..f959b2c938fd 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -566,7 +566,7 @@ describe("ngAnimate", function() { expect(child.attr('class')).toContain('ng-enter'); expect(child.attr('class')).toContain('ng-enter-active'); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //move element.append(after); @@ -577,7 +577,7 @@ describe("ngAnimate", function() { expect(child.attr('class')).toContain('ng-move'); expect(child.attr('class')).toContain('ng-move-active'); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //hide $animate.addClass(child, 'ng-hide'); @@ -631,72 +631,72 @@ describe("ngAnimate", function() { }); inject(function($animate, $sniffer, $rootScope, $timeout) { - var fn; + var promise; $animate.enabled(true); $rootScope.$digest(); element[0].removeChild(child[0]); child.addClass('track-me'); //enter - fn = $animate.enter(child, element); + promise = $animate.enter(child, element); $rootScope.$digest(); $animate.triggerReflow(); expect(captures.enter).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.enter).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //move element.append(after); - fn = $animate.move(child, element, after); + promise = $animate.move(child, element, after); $rootScope.$digest(); $animate.triggerReflow(); expect(captures.move).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.move).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //addClass - fn = $animate.addClass(child, 'ng-hide'); + promise = $animate.addClass(child, 'ng-hide'); $rootScope.$digest(); $animate.triggerReflow(); expect(captures.addClass).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.addClass).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //removeClass - fn = $animate.removeClass(child, 'ng-hide'); + promise = $animate.removeClass(child, 'ng-hide'); $rootScope.$digest(); $animate.triggerReflow(); expect(captures.removeClass).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.removeClass).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //setClass child.addClass('red'); - fn = $animate.setClass(child, 'blue', 'red'); + promise = $animate.setClass(child, 'blue', 'red'); $rootScope.$digest(); $animate.triggerReflow(); expect(captures.setClass).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.setClass).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //leave - fn = $animate.leave(child); + promise = $animate.leave(child); $rootScope.$digest(); expect(captures.leave).toBeUndefined(); - fn(); + promise.cancel(); expect(captures.leave).toBeTruthy(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); }); }); @@ -844,7 +844,7 @@ describe("ngAnimate", function() { expect(element).toHaveClass('ng-animate'); removeClassDone(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element).not.toHaveClass('ng-animate'); }); @@ -854,7 +854,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { var completed = false; - $animate.enter(child, element, null, function() { + $animate.enter(child, element, null).then(function() { completed = true; }); $rootScope.$digest(); @@ -870,7 +870,7 @@ describe("ngAnimate", function() { $animate.triggerReflow(); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(completed).toBe(true); })); @@ -1945,7 +1945,7 @@ describe("ngAnimate", function() { expect(element.hasClass('ng-enter')).toBe(true); expect(element.hasClass('ng-enter-active')).toBe(true); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 22000, elapsedTime: 22 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); } expect(element.hasClass('abc')).toBe(true); @@ -1959,7 +1959,7 @@ describe("ngAnimate", function() { expect(element.hasClass('ng-enter')).toBe(true); expect(element.hasClass('ng-enter-active')).toBe(true); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 11000, elapsedTime: 11 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); } expect(element.hasClass('xyz')).toBe(true); })); @@ -2029,12 +2029,12 @@ describe("ngAnimate", function() { body.append($rootElement); var flag = false; - $animate.enter(element, parent, null, function() { + $animate.enter(element, parent, null).then(function() { flag = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); })); @@ -2049,12 +2049,12 @@ describe("ngAnimate", function() { body.append($rootElement); var flag = false; - $animate.leave(element, function() { + $animate.leave(element).then(function() { flag = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); })); @@ -2070,12 +2070,12 @@ describe("ngAnimate", function() { body.append($rootElement); var flag = false; - $animate.move(element, parent, parent2, function() { + $animate.move(element, parent, parent2).then(function() { flag = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); expect(element.parent().id).toBe(parent2.id); @@ -2093,19 +2093,19 @@ describe("ngAnimate", function() { body.append($rootElement); var signature = ''; - $animate.addClass(element, 'on', function() { + $animate.addClass(element, 'on').then(function() { signature += 'A'; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.removeClass(element, 'on', function() { + $animate.removeClass(element, 'on').then(function() { signature += 'B'; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('AB'); })); @@ -2122,13 +2122,13 @@ describe("ngAnimate", function() { expect(element.hasClass('off')).toBe(true); var signature = ''; - $animate.setClass(element, 'on', 'off', function() { + $animate.setClass(element, 'on', 'off').then(function() { signature += 'Z'; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('Z'); expect(element.hasClass('on')).toBe(true); @@ -2162,27 +2162,29 @@ describe("ngAnimate", function() { steps.push(['close', data.className, data.event]); }); - $animate.addClass(element, 'klass', function() { + $animate.addClass(element, 'klass').then(function() { steps.push(['done', 'klass', 'addClass']); }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackEvents(); expect(steps.pop()).toEqual(['before', 'klass', 'addClass']); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackEvents(); expect(steps.pop()).toEqual(['after', 'klass', 'addClass']); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackEvents(); expect(steps.shift()).toEqual(['close', 'klass', 'addClass']); + $animate.triggerCallbackPromise(); + expect(steps.shift()).toEqual(['done', 'klass', 'addClass']); })); @@ -2208,7 +2210,7 @@ describe("ngAnimate", function() { $animate.enter(element, parent); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackEvents(); expect(steps.shift()).toEqual(['before', 'ng-enter', 'enter']); expect(steps.shift()).toEqual(['after', 'ng-enter', 'enter']); @@ -2238,12 +2240,12 @@ describe("ngAnimate", function() { body.append($rootElement); var flag = false; - $animate.removeClass(element, 'ng-hide', function() { + $animate.removeClass(element, 'ng-hide').then(function() { flag = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); })); @@ -2262,7 +2264,7 @@ describe("ngAnimate", function() { var element = parent.find('span'); var flag = false; - $animate.addClass(element, 'ng-hide', function() { + $animate.addClass(element, 'ng-hide').then(function() { flag = true; }); $rootScope.$digest(); @@ -2271,7 +2273,7 @@ describe("ngAnimate", function() { $animate.triggerReflow(); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); })); @@ -2286,12 +2288,12 @@ describe("ngAnimate", function() { element.addClass('custom'); var flag = false; - $animate.removeClass(element, 'ng-hide', function() { + $animate.removeClass(element, 'ng-hide').then(function() { flag = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(flag).toBe(true); })); @@ -2310,11 +2312,11 @@ describe("ngAnimate", function() { var element = parent.find('span'); var signature = ''; - $animate.removeClass(element, 'ng-hide', function() { + $animate.removeClass(element, 'ng-hide').then(function() { signature += 'A'; }); $rootScope.$digest(); - $animate.addClass(element, 'ng-hide', function() { + $animate.addClass(element, 'ng-hide').then(function() { signature += 'B'; }); $rootScope.$digest(); @@ -2324,7 +2326,7 @@ describe("ngAnimate", function() { $animate.triggerReflow(); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 9 }); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('AB'); })); }); @@ -2462,7 +2464,7 @@ describe("ngAnimate", function() { var signature = ''; - $animate.addClass(element,'klass', function() { + $animate.addClass(element,'klass').then(function() { signature += 'A'; }); $rootScope.$digest(); @@ -2470,13 +2472,13 @@ describe("ngAnimate", function() { expect(element.hasClass('klass')).toBe(true); - $animate.removeClass(element,'klass', function() { + $animate.removeClass(element,'klass').then(function() { signature += 'B'; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('klass')).toBe(false); expect(signature).toBe('AB'); })); @@ -2497,7 +2499,7 @@ describe("ngAnimate", function() { var signature = ''; - $animate.addClass(element,'klass', function() { + $animate.addClass(element,'klass').then(function() { signature += '1'; }); $rootScope.$digest(); @@ -2510,10 +2512,10 @@ describe("ngAnimate", function() { browserTrigger(element,'transitionend', { timeStamp: Date.now() + 3000, elapsedTime: 3 }); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); //this cancels out the older animation - $animate.removeClass(element,'klass', function() { + $animate.removeClass(element,'klass').then(function() { signature += '2'; }); $rootScope.$digest(); @@ -2529,7 +2531,7 @@ describe("ngAnimate", function() { browserTrigger(element,'transitionend', { timeStamp: Date.now() + 3000, elapsedTime: 3 }); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('klass')).toBe(false); expect(signature).toBe('12'); @@ -2546,7 +2548,7 @@ describe("ngAnimate", function() { var signature = ''; - $animate.addClass(element,'klassy', function() { + $animate.addClass(element,'klassy').then(function() { signature += 'X'; }); $rootScope.$digest(); @@ -2556,7 +2558,7 @@ describe("ngAnimate", function() { expect(element.hasClass('klassy')).toBe(true); - $animate.removeClass(element,'klassy', function() { + $animate.removeClass(element,'klassy').then(function() { signature += 'Y'; }); $rootScope.$digest(); @@ -2566,7 +2568,7 @@ describe("ngAnimate", function() { expect(element.hasClass('klassy')).toBe(false); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('XY'); })); @@ -2580,7 +2582,7 @@ describe("ngAnimate", function() { var signature = ''; - $animate.addClass(element[0],'klassy', function() { + $animate.addClass(element[0],'klassy').then(function() { signature += 'X'; }); $rootScope.$digest(); @@ -2590,7 +2592,7 @@ describe("ngAnimate", function() { expect(element.hasClass('klassy')).toBe(true); - $animate.removeClass(element[0],'klassy', function() { + $animate.removeClass(element[0],'klassy').then(function() { signature += 'Y'; }); $rootScope.$digest(); @@ -2600,7 +2602,7 @@ describe("ngAnimate", function() { expect(element.hasClass('klassy')).toBe(false); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('XY'); })); @@ -2619,7 +2621,7 @@ describe("ngAnimate", function() { var signature = ''; - $animate.addClass(element,'klass', function() { + $animate.addClass(element,'klass').then(function() { signature += 'd'; }); $rootScope.$digest(); @@ -2633,10 +2635,10 @@ describe("ngAnimate", function() { expect(element.hasClass('klass-add-active')).toBe(false); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('klass')).toBe(true); - $animate.removeClass(element,'klass', function() { + $animate.removeClass(element,'klass').then(function() { signature += 'b'; }); $rootScope.$digest(); @@ -2650,7 +2652,7 @@ describe("ngAnimate", function() { expect(element.hasClass('klass-remove-active')).toBe(false); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('klass')).toBe(false); expect(signature).toBe('db'); @@ -2671,7 +2673,7 @@ describe("ngAnimate", function() { var element = jqLite(parent.find('span')); var flag = false; - $animate.addClass(element,'one two', function() { + $animate.addClass(element,'one two').then(function() { flag = true; }); @@ -2692,7 +2694,7 @@ describe("ngAnimate", function() { expect(element.hasClass('two-add-active')).toBe(false); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('one')).toBe(true); expect(element.hasClass('two')).toBe(true); @@ -2719,7 +2721,7 @@ describe("ngAnimate", function() { expect(element.hasClass('two')).toBe(true); var flag = false; - $animate.removeClass(element,'one two', function() { + $animate.removeClass(element,'one two').then(function() { flag = true; }); $rootScope.$digest(); @@ -2739,7 +2741,7 @@ describe("ngAnimate", function() { expect(element.hasClass('two-remove-active')).toBe(false); } - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(element.hasClass('one')).toBe(false); expect(element.hasClass('two')).toBe(false); @@ -2901,7 +2903,7 @@ describe("ngAnimate", function() { child.addClass('usurper'); $animate.leave(child); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(child.hasClass('ng-enter')).toBe(false); expect(child.hasClass('ng-enter-active')).toBe(false); @@ -2955,10 +2957,10 @@ describe("ngAnimate", function() { // if($sniffer.transitions) { // expect(element.hasClass('on')).toBe(false); // expect(element.hasClass('on-add')).toBe(true); - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // } // - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // // expect(element.hasClass('on')).toBe(true); // expect(element.hasClass('on-add')).toBe(false); @@ -2971,7 +2973,7 @@ describe("ngAnimate", function() { // $timeout.flush(10000); // } // - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // expect(element.hasClass('on')).toBe(false); // expect(element.hasClass('on-remove')).toBe(false); // expect(element.hasClass('on-remove-active')).toBe(false); @@ -3014,11 +3016,11 @@ describe("ngAnimate", function() { // // if($sniffer.transitions) { // expect(element).toBeShown(); //still showing - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // expect(element).toBeShown(); // $timeout.flush(5555); // } - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // expect(element).toBeHidden(); // // expect(element.hasClass('showing')).toBe(false); @@ -3027,11 +3029,11 @@ describe("ngAnimate", function() { // // if($sniffer.transitions) { // expect(element).toBeHidden(); - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // expect(element).toBeHidden(); // $timeout.flush(5580); // } - // $animate.triggerCallbacks(); + // $animate.triggerCallbackPromise(); // expect(element).toBeShown(); // // expect(element.hasClass('showing')).toBe(true); @@ -3311,20 +3313,20 @@ describe("ngAnimate", function() { angular.element($document[0].body).append($rootElement); var log = ''; - $animate.addClass(element, 'one', function() { + $animate.addClass(element, 'one').then(function() { log += 'A'; }); $rootScope.$digest(); - $animate.addClass(element, 'one', function() { + $animate.addClass(element, 'one').then(function() { log += 'B'; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); $animate.triggerReflow(); continueAnimation(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(log).toBe('BA'); }); }); @@ -3349,13 +3351,13 @@ describe("ngAnimate", function() { count++; }; - $animate.addClass(element, 'on', callback); - $animate.addClass(element, 'on', callback); - $animate.removeClass(element, 'on', callback); - $animate.removeClass(element, 'on', callback); + $animate.addClass(element, 'on').then(callback); + $animate.addClass(element, 'on').then(callback); + $animate.removeClass(element, 'on').then(callback); + $animate.removeClass(element, 'on').then(callback); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(spy).not.toHaveBeenCalled(); expect(count).toBe(4); @@ -4065,7 +4067,7 @@ describe("ngAnimate", function() { forEach(element.children(), function(kid) { browserTrigger(kid, 'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); $rootScope.items = []; $rootScope.$digest(); @@ -4101,12 +4103,12 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); var enterDone = false; - $animate.enter(element, $rootElement, null, function() { + $animate.enter(element, $rootElement).then(function() { enterDone = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(captures['enter']).toBeUndefined(); expect(enterDone).toBe(true); @@ -4114,12 +4116,12 @@ describe("ngAnimate", function() { element.addClass('prefixed-animation'); var leaveDone = false; - $animate.leave(element, function() { + $animate.leave(element).then(function() { leaveDone = true; }); $rootScope.$digest(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(captures['leave']).toBe(true); expect(leaveDone).toBe(true); @@ -4157,14 +4159,14 @@ describe("ngAnimate", function() { var element = upperElement.find('span'); var leaveDone = false; - $animate.leave(element, function() { + $animate.leave(element).then(function() { leaveDone = true; }); $rootScope.$digest(); $animate.triggerCallbacks(); - expect(captures['leave']).toBe(true); + expect(captures.leave).toBe(true); expect(leaveDone).toBe(true); }); }); @@ -4187,7 +4189,7 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); var ready = false; - $animate.addClass(element, 'on', function() { + $animate.addClass(element, 'on').then(function() { ready = true; }); $rootScope.$digest(); @@ -4198,18 +4200,18 @@ describe("ngAnimate", function() { browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 5 }); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(ready).toBe(true); ready = false; - $animate.removeClass(element, 'on', function() { + $animate.removeClass(element, 'on').then(function() { ready = true; }); $rootScope.$digest(); $animate.triggerReflow(); browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 1 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(ready).toBe(true); })); @@ -4228,13 +4230,13 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); var ready = false; - $animate.removeClass(element, 'on', function() { + $animate.removeClass(element, 'on').then(function() { ready = true; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(ready).toBe(true); })); @@ -4253,22 +4255,22 @@ describe("ngAnimate", function() { jqLite($document[0].body).append($rootElement); var signature = ''; - $animate.removeClass(element, 'on', function() { + $animate.removeClass(element, 'on').then(function() { signature += 'A'; }); $rootScope.$digest(); - $animate.addClass(element, 'on', function() { + $animate.addClass(element, 'on').then(function() { signature += 'B'; }); $rootScope.$digest(); $animate.triggerReflow(); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('A'); browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2000 }); - $animate.triggerCallbacks(); + $animate.triggerCallbackPromise(); expect(signature).toBe('AB'); })); diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index 9a41252fb998..a832a7b32cc8 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -846,14 +846,12 @@ describe('ngView animations', function() { it('should destroy the previous leave animation if a new one takes place', function() { module(function($provide) { - $provide.value('$animate', { - enabled : function() { return true; }, - leave : function() { - //DOM operation left blank - }, - enter : function(element, parent, after) { - angular.element(after).after(element); - } + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; }); }); inject(function ($compile, $rootScope, $animate, $location) { From 36d52af55a9ecfe0ab3d07ded59449ea8e3c339f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Sat, 16 Aug 2014 11:21:56 -0400 Subject: [PATCH 3/3] fix($animate): use $timeout to handle the delay within staggering animations When transition-delay and animation-delay were used to drive the staggering animation the result was unpredictable at times due to the browser not being able to register the generated delay styles in time. This caused a hard to track down bug that didn't have a solid solution when styles were being used. This fix ensures that stagger delays are handled by the $timeout service. Closes #7228 Closes #7547 Closes #8297 Closes #8547 BREAKING CHANGE If any stagger code consisted of having BOTH transition staggers and delay staggers together then that will not work the same way. Angular will no instead choose the highest stagger delay value and set the timeout to wait for that before applying the active CSS class. --- src/ngAnimate/animate.js | 67 +++++++----- test/ngAnimate/animateSpec.js | 185 ++++++++++++++++++++++++---------- 2 files changed, 175 insertions(+), 77 deletions(-) diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 5ed202f56d08..de119f62bdea 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -1586,14 +1586,15 @@ angular.module('ngAnimate', ['ng']) } var activeClassName = ''; + var pendingClassName = ''; forEach(className.split(' '), function(klass, i) { - activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; + var prefix = (i > 0 ? ' ' : '') + klass; + activeClassName += prefix + '-active'; + pendingClassName += prefix + '-pending'; }); - element.addClass(activeClassName); var eventCacheKey = elementData.cacheKey + ' ' + activeClassName; var timings = getElementAnimationDetails(element, eventCacheKey); - var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); if(maxDuration === 0) { element.removeClass(activeClassName); @@ -1603,8 +1604,6 @@ angular.module('ngAnimate', ['ng']) } var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay); - var stagger = elementData.stagger; - var itemIndex = elementData.itemIndex; var maxDelayTime = maxDelay * ONE_SECOND; var style = '', appliedStyles = []; @@ -1618,19 +1617,31 @@ angular.module('ngAnimate', ['ng']) } } + var itemIndex = elementData.itemIndex; + var stagger = elementData.stagger; + + var staggerStyle, staggerTime = 0; if(itemIndex > 0) { + var transitionStaggerDelay = 0; if(stagger.transitionDelay > 0 && stagger.transitionDuration === 0) { - var delayStyle = timings.transitionDelayStyle; - style += CSS_PREFIX + 'transition-delay: ' + - prepareStaggerDelay(delayStyle, stagger.transitionDelay, itemIndex) + '; '; - appliedStyles.push(CSS_PREFIX + 'transition-delay'); + transitionStaggerDelay = stagger.transitionDelay * itemIndex; } + var animationStaggerDelay = 0; if(stagger.animationDelay > 0 && stagger.animationDuration === 0) { - style += CSS_PREFIX + 'animation-delay: ' + - prepareStaggerDelay(timings.animationDelayStyle, stagger.animationDelay, itemIndex) + '; '; - appliedStyles.push(CSS_PREFIX + 'animation-delay'); + animationStaggerDelay = stagger.animationDelay * itemIndex; + + staggerStyle = CSS_PREFIX + 'animation-play-state'; + appliedStyles.push(staggerStyle); + + style += staggerStyle + ':paused;'; } + + staggerTime = Math.round(Math.max(transitionStaggerDelay, animationStaggerDelay) * 100) / 100; + } + + if (!staggerTime) { + element.addClass(activeClassName); } if(appliedStyles.length > 0) { @@ -1643,6 +1654,21 @@ angular.module('ngAnimate', ['ng']) var startTime = Date.now(); var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; + var animationTime = (maxDelay + maxDuration) * CLOSING_TIME_BUFFER; + var totalTime = (staggerTime + animationTime) * ONE_SECOND; + + var staggerTimer; + if(staggerTime > 0) { + element.addClass(pendingClassName); + staggerTimer = $timeout(function() { + staggerTimer = null; + element.addClass(activeClassName); + element.removeClass(pendingClassName); + if (staggerStyle) { + element.css(staggerStyle, ''); + } + }, staggerTime * ONE_SECOND, false); + } element.on(css3AnimationEvents, onAnimationProgress); elementData.closeAnimationFns.push(function() { @@ -1650,10 +1676,6 @@ angular.module('ngAnimate', ['ng']) activeAnimationComplete(); }); - var staggerTime = itemIndex * (Math.max(stagger.animationDelay, stagger.transitionDelay) || 0); - var animationTime = (maxDelay + maxDuration) * CLOSING_TIME_BUFFER; - var totalTime = (staggerTime + animationTime) * ONE_SECOND; - elementData.running++; animationCloseHandler(element, totalTime); return onEnd; @@ -1664,6 +1686,10 @@ angular.module('ngAnimate', ['ng']) function onEnd(cancelled) { element.off(css3AnimationEvents, onAnimationProgress); element.removeClass(activeClassName); + element.removeClass(pendingClassName); + if (staggerTimer) { + $timeout.cancel(staggerTimer); + } animateClose(element, className); var node = extractElementNode(element); for (var i in appliedStyles) { @@ -1693,15 +1719,6 @@ angular.module('ngAnimate', ['ng']) } } - function prepareStaggerDelay(delayStyle, staggerDelay, index) { - var style = ''; - forEach(delayStyle.split(','), function(val, i) { - style += (i > 0 ? ',' : '') + - (index * staggerDelay + parseInt(val, 10)) + 's'; - }); - return style; - } - function animateBefore(animationEvent, element, className, calculationDecorator) { if(animateSetup(animationEvent, element, className, calculationDecorator)) { return function(cancelled) { diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index f959b2c938fd..505db6cbc1be 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -5,6 +5,17 @@ describe("ngAnimate", function() { beforeEach(module('ngAnimate')); beforeEach(module('ngAnimateMock')); + function getMaxValue(prop, element, $window) { + var node = element[0]; + var cs = $window.getComputedStyle(node); + var prop0 = 'webkit' + prop.charAt(0).toUpperCase() + prop.substr(1); + var values = (cs[prop0] || cs[prop]).split(/\s*,\s*/); + var maxDelay = 0; + forEach(values, function(value) { + maxDelay = Math.max(parseFloat(value) || 0, maxDelay); + }); + return maxDelay; + } it("should disable animations on bootstrap for structural animations even after the first digest has passed", function() { var hasBeenAnimated = false; @@ -1161,7 +1172,7 @@ describe("ngAnimate", function() { ); - it("should stagger the items when the correct CSS class is provided", + it("should pause the playstate when performing a stagger animation", inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { if(!$sniffer.animations) return; @@ -1198,10 +1209,9 @@ describe("ngAnimate", function() { $animate.triggerReflow(); expect(elements[0].attr('style')).toBeFalsy(); - expect(elements[1].attr('style')).toMatch(/animation-delay: 0\.1\d*s/); - expect(elements[2].attr('style')).toMatch(/animation-delay: 0\.2\d*s/); - expect(elements[3].attr('style')).toMatch(/animation-delay: 0\.3\d*s/); - expect(elements[4].attr('style')).toMatch(/animation-delay: 0\.4\d*s/); + for(i=1;i<5;i++) { + expect(elements[i].attr('style')).toMatch(/animation-play-state:\s*paused/); + } //final closing timeout $timeout.flush(); @@ -1220,10 +1230,9 @@ describe("ngAnimate", function() { $timeout.verifyNoPendingTasks(); expect(elements[0].attr('style')).toBeFalsy(); - expect(elements[1].attr('style')).not.toMatch(/animation-delay: 0\.1\d*s/); - expect(elements[2].attr('style')).not.toMatch(/animation-delay: 0\.2\d*s/); - expect(elements[3].attr('style')).not.toMatch(/animation-delay: 0\.3\d*s/); - expect(elements[4].attr('style')).not.toMatch(/animation-delay: 0\.4\d*s/); + for(i=1;i<5;i++) { + expect(elements[i].attr('style')).not.toMatch(/animation-play-state:\s*paused/); + } })); @@ -1255,23 +1264,28 @@ describe("ngAnimate", function() { $rootScope.$digest(); expect(elements[0].attr('style')).toBeUndefined(); - expect(elements[1].attr('style')).toMatch(/animation:.*?none/); - expect(elements[2].attr('style')).toMatch(/animation:.*?none/); - expect(elements[3].attr('style')).toMatch(/animation:.*?none/); + for(i = 1; i < 4; i++) { + expect(elements[i].attr('style')).toMatch(/animation:.*?none/); + } $animate.triggerReflow(); expect(elements[0].attr('style')).toBeUndefined(); - expect(elements[1].attr('style')).not.toMatch(/animation:.*?none/); - expect(elements[1].attr('style')).toMatch(/animation-delay: 0.2\d*s/); - expect(elements[2].attr('style')).not.toMatch(/animation:.*?none/); - expect(elements[2].attr('style')).toMatch(/animation-delay: 0.4\d*s/); - expect(elements[3].attr('style')).not.toMatch(/animation:.*?none/); - expect(elements[3].attr('style')).toMatch(/animation-delay: 0.6\d*s/); + for(i = 1; i < 4; i++) { + expect(elements[i].attr('style')).not.toMatch(/animation:.*?none/); + expect(elements[i].attr('style')).toMatch(/animation-play-state:\s*paused/); + } + + $timeout.flush(800); + + for(i = 1; i < 4; i++) { + expect(elements[i].attr('style')).not.toMatch(/animation:.*?none/); + expect(elements[i].attr('style')).not.toMatch(/animation-play-state/); + } })); it("should stagger items when multiple animation durations/delays are defined", - inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $window) { if(!$sniffer.transitions) return; @@ -1298,10 +1312,19 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.triggerReflow(); - expect(elements[0].attr('style')).toBeFalsy(); - expect(elements[1].attr('style')).toMatch(/animation-delay: 1\.1\d*s,\s*2\.1\d*s/); - expect(elements[2].attr('style')).toMatch(/animation-delay: 1\.2\d*s,\s*2\.2\d*s/); - expect(elements[3].attr('style')).toMatch(/animation-delay: 1\.3\d*s,\s*2\.3\d*s/); + for(i = 1; i < 4; i++) { + expect(elements[i]).not.toHaveClass('ng-enter-active'); + expect(elements[i]).toHaveClass('ng-enter-pending'); + expect(getMaxValue('animationDelay', elements[i], $window)).toBe(2); + } + + $timeout.flush(300); + + for(i = 1; i < 4; i++) { + expect(elements[i]).toHaveClass('ng-enter-active'); + expect(elements[i]).not.toHaveClass('ng-enter-pending'); + expect(getMaxValue('animationDelay', elements[i], $window)).toBe(2); + } })); }); @@ -1577,7 +1600,7 @@ describe("ngAnimate", function() { })); it("should stagger the items when the correct CSS class is provided", - inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $browser) { if(!$sniffer.transitions) return; @@ -1612,11 +1635,8 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.triggerReflow(); - expect(elements[0].attr('style')).toBeFalsy(); - expect(elements[1].attr('style')).toMatch(/transition-delay: 0\.1\d*s/); - expect(elements[2].attr('style')).toMatch(/transition-delay: 0\.2\d*s/); - expect(elements[3].attr('style')).toMatch(/transition-delay: 0\.3\d*s/); - expect(elements[4].attr('style')).toMatch(/transition-delay: 0\.4\d*s/); + expect($browser.deferredFns.length).toEqual(5); //4 staggers + 1 combined timeout + $timeout.flush(); for(i = 0; i < 5; i++) { dealoc(elements[i]); @@ -1629,16 +1649,12 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.triggerReflow(); - expect(elements[0].attr('style')).toBeFalsy(); - expect(elements[1].attr('style')).not.toMatch(/transition-delay: 0\.1\d*s/); - expect(elements[2].attr('style')).not.toMatch(/transition-delay: 0\.2\d*s/); - expect(elements[3].attr('style')).not.toMatch(/transition-delay: 0\.3\d*s/); - expect(elements[4].attr('style')).not.toMatch(/transition-delay: 0\.4\d*s/); + expect($browser.deferredFns.length).toEqual(0); //no animation was triggered })); it("should stagger items when multiple transition durations/delays are defined", - inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $window) { if(!$sniffer.transitions) return; @@ -1665,11 +1681,19 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.triggerReflow(); - expect(elements[0].attr('style')).toMatch(/transition-duration: 1\d*s,\s*3\d*s;/); - expect(elements[0].attr('style')).not.toContain('transition-delay'); - expect(elements[1].attr('style')).toMatch(/transition-delay: 2\.1\d*s,\s*4\.1\d*s/); - expect(elements[2].attr('style')).toMatch(/transition-delay: 2\.2\d*s,\s*4\.2\d*s/); - expect(elements[3].attr('style')).toMatch(/transition-delay: 2\.3\d*s,\s*4\.3\d*s/); + for(i = 1; i < 4; i++) { + expect(elements[i]).not.toHaveClass('ng-enter-active'); + expect(elements[i]).toHaveClass('ng-enter-pending'); + expect(getMaxValue('transitionDelay', elements[i], $window)).toBe(4); + } + + $timeout.flush(300); + + for(i = 1; i < 4; i++) { + expect(elements[i]).toHaveClass('ng-enter-active'); + expect(elements[i]).not.toHaveClass('ng-enter-pending'); + expect(getMaxValue('transitionDelay', elements[i], $window)).toBe(4); + } })); @@ -1811,20 +1835,22 @@ describe("ngAnimate", function() { $animate.triggerReflow(); //reflow expect(element.children().length).toBe(5); - for(i = 0; i < 5; i++) { - expect(kids[i].hasClass('ng-enter-active')).toBe(true); + for(i = 1; i < 5; i++) { + expect(kids[i]).not.toHaveClass('ng-enter-active'); + expect(kids[i]).toHaveClass('ng-enter-pending'); } - $timeout.flush(7500); + $timeout.flush(2000); - for(i = 0; i < 5; i++) { - expect(kids[i].hasClass('ng-enter-active')).toBe(true); + for(i = 1; i < 5; i++) { + expect(kids[i]).toHaveClass('ng-enter-active'); + expect(kids[i]).not.toHaveClass('ng-enter-pending'); } //(stagger * index) + (duration + delay) * 150% //0.5 * 4 + 5 * 1.5 = 9500; - //9500 - 7500 = 2000 - $timeout.flush(1999); //remove 1999 more + //9500 - 2000 - 7499 = 1 + $timeout.flush(7499); for(i = 0; i < 5; i++) { expect(kids[i].hasClass('ng-enter-active')).toBe(true); @@ -1837,6 +1863,52 @@ describe("ngAnimate", function() { } })); + it("should cancel all the existing stagger timers when the animation is cancelled", + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $browser) { + + if (!$sniffer.transitions) return; + + ss.addRule('.entering-element.ng-enter', + '-webkit-transition:5s linear all;' + + 'transition:5s linear all;'); + + ss.addRule('.entering-element.ng-enter-stagger', + '-webkit-transition-delay:1s;' + + 'transition-delay:1s;'); + + var cancellations = []; + element = $compile(html('
'))($rootScope); + var kids = []; + for(var i = 0; i < 5; i++) { + kids.push(angular.element('
')); + cancellations.push($animate.enter(kids[i], element).cancel); + } + $rootScope.$digest(); + + $animate.triggerReflow(); //reflow + expect(element.children().length).toBe(5); + + for(i = 1; i < 5; i++) { + expect(kids[i]).not.toHaveClass('ng-enter-active'); + expect(kids[i]).toHaveClass('ng-enter-pending'); + } + + expect($browser.deferredFns.length).toEqual(5); //4 staggers + 1 combined timeout + + forEach(cancellations, function(fn) { + fn(); + }); + + for(i = 1; i < 5; i++) { + expect(kids[i]).not.toHaveClass('ng-enter'); + expect(kids[i]).not.toHaveClass('ng-enter-active'); + expect(kids[i]).not.toHaveClass('ng-enter-pending'); + } + + //the staggers are gone, but the global timeout remains + expect($browser.deferredFns.length).toEqual(1); + })); + it("should not allow the closing animation to close off a successive animation midway", inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { @@ -1873,7 +1945,7 @@ describe("ngAnimate", function() { it("should apply staggering to both transitions and keyframe animations when used within the same animation", - inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $browser) { if(!$sniffer.transitions) return; @@ -1903,14 +1975,23 @@ describe("ngAnimate", function() { $rootScope.$digest(); $animate.triggerReflow(); + expect($browser.deferredFns.length).toEqual(3); //2 staggers + 1 combined timeout expect(elements[0].attr('style')).toBeFalsy(); + expect(elements[1].attr('style')).toMatch(/animation-play-state:\s*paused/); + expect(elements[2].attr('style')).toMatch(/animation-play-state:\s*paused/); - expect(elements[1].attr('style')).toMatch(/transition-delay:\s+1.1\d*/); - expect(elements[1].attr('style')).toMatch(/animation-delay: 1\.2\d*s,\s*2\.2\d*s/); + for(i = 1; i < 3; i++) { + expect(elements[i]).not.toHaveClass('ng-enter-active'); + expect(elements[i]).toHaveClass('ng-enter-pending'); + } + + $timeout.flush(0.4 * 1000); - expect(elements[2].attr('style')).toMatch(/transition-delay:\s+1.2\d*/); - expect(elements[2].attr('style')).toMatch(/animation-delay: 1\.4\d*s,\s*2\.4\d*s/); + for(i = 1; i < 3; i++) { + expect(elements[i]).toHaveClass('ng-enter-active'); + expect(elements[i]).not.toHaveClass('ng-enter-pending'); + } for(i = 0; i < 3; i++) { browserTrigger(elements[i],'transitionend', { timeStamp: Date.now() + 22000, elapsedTime: 22000 });