From 7aaa746d6434676a2673475a39a229002143f1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 30 Jun 2015 14:51:04 -0400 Subject: [PATCH 1/4] fix($animateCss): make sure that `skipBlocking` avoids the pre-emptive transition-delay styling --- src/ngAnimate/animateCss.js | 9 +++++---- test/ngAnimate/animateCssSpec.js | 11 ++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/ngAnimate/animateCss.js b/src/ngAnimate/animateCss.js index 610b437e5b79..e0d2c06bc5d1 100644 --- a/src/ngAnimate/animateCss.js +++ b/src/ngAnimate/animateCss.js @@ -624,7 +624,7 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { // transition delay to allow for the transition to naturally do it's thing. The beauty here is // that if there is no transition defined then nothing will happen and this will also allow // other transitions to be stacked on top of each other without any chopping them out. - if (isFirst) { + if (isFirst && !options.skipBlocking) { blockTransitions(node, SAFE_FAST_FORWARD_DURATION_VALUE); } @@ -683,12 +683,13 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { } applyAnimationFromStyles(element, options); - if (!flags.blockTransition) { + + if (flags.blockTransition || flags.blockKeyframeAnimation) { + applyBlocking(maxDuration); + } else if (!options.skipBlocking) { blockTransitions(node, false); } - applyBlocking(maxDuration); - // TODO(matsko): for 1.5 change this code to have an animator object for better debugging return { $$willAnimate: true, diff --git a/test/ngAnimate/animateCssSpec.js b/test/ngAnimate/animateCssSpec.js index 262351a3ff34..05dd740ef62b 100644 --- a/test/ngAnimate/animateCssSpec.js +++ b/test/ngAnimate/animateCssSpec.js @@ -1423,7 +1423,7 @@ describe("ngAnimate $animateCss", function() { they('should not place a CSS transition block if options.skipBlocking is provided', ['enter', 'leave', 'move', 'addClass', 'removeClass'], function(event) { - inject(function($animateCss, $rootElement, $$body) { + inject(function($animateCss, $rootElement, $$body, $window) { var element = jqLite('
'); $rootElement.append(element); $$body.append($rootElement); @@ -1441,14 +1441,23 @@ describe("ngAnimate $animateCss", function() { data.event = event; } + var blockSpy = spyOn($window, 'blockTransitions').andCallThrough(); + data.skipBlocking = true; var animator = $animateCss(element, data); + expect(blockSpy).not.toHaveBeenCalled(); + expect(element.attr('style')).toBeFalsy(); animator.start(); triggerAnimationStartFrame(); expect(element.attr('style')).toBeFalsy(); + + // just to prove it works + data.skipBlocking = false; + var animator = $animateCss(element, { addClass: 'test' }); + expect(blockSpy).toHaveBeenCalled(); }); }); From 77a7afc944940e90b9ed912487cf81e201c1a975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 30 Jun 2015 15:27:11 -0400 Subject: [PATCH 2/4] chore(ngAnimate): skip adding the preparation classes when options.$$skipPreparationClasses is present --- src/ngAnimate/animateCss.js | 24 ++++++++++++-------- test/ngAnimate/animateCssSpec.js | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/ngAnimate/animateCss.js b/src/ngAnimate/animateCss.js index e0d2c06bc5d1..6a3a004f860d 100644 --- a/src/ngAnimate/animateCss.js +++ b/src/ngAnimate/animateCss.js @@ -200,8 +200,8 @@ * * `stagger` - A numeric time value representing the delay between successively animated elements * ({@link ngAnimate#css-staggering-animations Click here to learn how CSS-based staggering works in ngAnimate.}) * * `staggerIndex` - The numeric index representing the stagger item (e.g. a value of 5 is equal to the sixth item in the stagger; therefore when a - * `stagger` option value of `0.1` is used then there will be a stagger delay of `600ms`) - * `applyClassesEarly` - Whether or not the classes being added or removed will be used when detecting the animation. This is set by `$animate` when enter/leave/move animations are fired to ensure that the CSS classes are resolved in time. (Note that this will prevent any transitions from occuring on the classes being added and removed.) + * * `stagger` option value of `0.1` is used then there will be a stagger delay of `600ms`) + * * `applyClassesEarly` - Whether or not the classes being added or removed will be used when detecting the animation. This is set by `$animate` when enter/leave/move animations are fired to ensure that the CSS classes are resolved in time. (Note that this will prevent any transitions from occuring on the classes being added and removed.) * * @return {object} an object with start and end methods and details about the animation. * @@ -556,9 +556,9 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { addRemoveClassName = ''; } - var setupClasses = [structuralClassName, addRemoveClassName].join(' ').trim(); - var fullClassName = classes + ' ' + setupClasses; - var activeClasses = pendClasses(setupClasses, '-active'); + var preparationClasses = [structuralClassName, addRemoveClassName].join(' ').trim(); + var fullClassName = classes + ' ' + preparationClasses; + var activeClasses = pendClasses(preparationClasses, '-active'); var hasToStyles = styles.to && Object.keys(styles.to).length > 0; var containsKeyframeAnimation = (options.keyframeStyle || '').length > 0; @@ -567,7 +567,7 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { // unless there a is raw keyframe value that is applied to the element. if (!containsKeyframeAnimation && !hasToStyles - && !setupClasses) { + && !preparationClasses) { return closeAndReturnNoopAnimator(); } @@ -582,10 +582,12 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { }; } else { cacheKey = gcsHashFn(node, fullClassName); - stagger = computeCachedCssStaggerStyles(node, setupClasses, cacheKey, DETECT_STAGGER_CSS_PROPERTIES); + stagger = computeCachedCssStaggerStyles(node, preparationClasses, cacheKey, DETECT_STAGGER_CSS_PROPERTIES); } - $$jqLite.addClass(element, setupClasses); + if (!options.$$skipPreparationClasses) { + $$jqLite.addClass(element, preparationClasses); + } var applyOnlyDuration; @@ -731,7 +733,9 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { animationClosed = true; animationPaused = false; - $$jqLite.removeClass(element, setupClasses); + if (!options.$$skipPreparationClasses) { + $$jqLite.removeClass(element, preparationClasses); + } $$jqLite.removeClass(element, activeClasses); blockKeyframeAnimations(node, false); @@ -858,7 +862,7 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { $$jqLite.addClass(element, activeClasses); if (flags.recalculateTimingStyles) { - fullClassName = node.className + ' ' + setupClasses; + fullClassName = node.className + ' ' + preparationClasses; cacheKey = gcsHashFn(node, fullClassName); timings = computeTimings(node, fullClassName, cacheKey); diff --git a/test/ngAnimate/animateCssSpec.js b/test/ngAnimate/animateCssSpec.js index 05dd740ef62b..2734ada9c506 100644 --- a/test/ngAnimate/animateCssSpec.js +++ b/test/ngAnimate/animateCssSpec.js @@ -1678,6 +1678,45 @@ describe("ngAnimate $animateCss", function() { $rootElement.append(element); })); + describe("[$$skipPreparationClasses]", function() { + it('should not apply and remove the preparation classes to the element when true', + inject(function($animateCss) { + + var options = { + duration: 3000, + to: fakeStyle, + event: 'event', + structural: true, + addClass: 'klass', + $$skipPreparationClasses: true + }; + + var animator = $animateCss(element, options); + + expect(element).not.toHaveClass('klass-add'); + expect(element).not.toHaveClass('ng-event'); + + var runner = animator.start(); + triggerAnimationStartFrame(); + + expect(element).not.toHaveClass('klass-add'); + expect(element).not.toHaveClass('ng-event'); + + expect(element).toHaveClass('klass-add-active'); + expect(element).toHaveClass('ng-event-active'); + + element.addClass('klass-add ng-event'); + + runner.end(); + + expect(element).toHaveClass('klass-add'); + expect(element).toHaveClass('ng-event'); + + expect(element).not.toHaveClass('klass-add-active'); + expect(element).not.toHaveClass('ng-event-active'); + })); + }); + describe("[duration]", function() { it("should be applied for a transition directly", inject(function($animateCss, $rootElement) { var element = jqLite('
'); From 570dfbc672a29507cf75877f2899f0e8e96eab79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 30 Jun 2015 14:19:02 -0400 Subject: [PATCH 3/4] revert: fix(ngAnimate): ensure nested class-based animations are spaced out with a RAF --- angularFiles.js | 1 - src/ngAnimate/animateCss.js | 11 ++- src/ngAnimate/animateQueue.js | 8 +- src/ngAnimate/animation.js | 89 ++++-------------- src/ngAnimate/module.js | 2 - src/ngAnimate/rafScheduler.js | 59 ------------ test/ngAnimate/animationSpec.js | 87 +---------------- test/ngAnimate/rafSchedulerSpec.js | 145 ----------------------------- 8 files changed, 31 insertions(+), 371 deletions(-) delete mode 100644 src/ngAnimate/rafScheduler.js delete mode 100644 test/ngAnimate/rafSchedulerSpec.js diff --git a/angularFiles.js b/angularFiles.js index 3777f94001c9..5893f2bfaa32 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -88,7 +88,6 @@ var angularFiles = { 'ngAnimate': [ 'src/ngAnimate/shared.js', 'src/ngAnimate/body.js', - 'src/ngAnimate/rafScheduler.js', 'src/ngAnimate/animateChildrenDirective.js', 'src/ngAnimate/animateCss.js', 'src/ngAnimate/animateCssDriver.js', diff --git a/src/ngAnimate/animateCss.js b/src/ngAnimate/animateCss.js index 6a3a004f860d..805327e95ffe 100644 --- a/src/ngAnimate/animateCss.js +++ b/src/ngAnimate/animateCss.js @@ -393,9 +393,9 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { var gcsStaggerLookup = createLocalCacheLookup(); this.$get = ['$window', '$$jqLite', '$$AnimateRunner', '$timeout', - '$document', '$sniffer', '$$rAFScheduler', + '$document', '$sniffer', '$$rAF', function($window, $$jqLite, $$AnimateRunner, $timeout, - $document, $sniffer, $$rAFScheduler) { + $document, $sniffer, $$rAF) { var applyAnimationClasses = applyAnimationClassesFactory($$jqLite); @@ -453,10 +453,15 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { } var bod = getDomNode($document).body; + var cancelLastRAFRequest; var rafWaitQueue = []; function waitUntilQuiet(callback) { + if (cancelLastRAFRequest) { + cancelLastRAFRequest(); //cancels the request + } rafWaitQueue.push(callback); - $$rAFScheduler.waitUntilQuiet(function() { + cancelLastRAFRequest = $$rAF(function() { + cancelLastRAFRequest = null; gcsLookup.flush(); gcsStaggerLookup.flush(); diff --git a/src/ngAnimate/animateQueue.js b/src/ngAnimate/animateQueue.js index 9424750a0710..6d856e238ed3 100644 --- a/src/ngAnimate/animateQueue.js +++ b/src/ngAnimate/animateQueue.js @@ -369,9 +369,7 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { return runner; } - if (isStructural) { - closeParentClassBasedAnimations(parent); - } + closeParentClassBasedAnimations(parent); // the counter keeps track of cancelled animations var counter = (existingAnimation.counter || 0) + 1; @@ -430,9 +428,7 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { ? 'setClass' : animationDetails.event; - if (animationDetails.structural) { - closeParentClassBasedAnimations(parentElement); - } + closeParentClassBasedAnimations(parentElement); markElementAnimationState(element, RUNNING_STATE); var realRunner = $$animation(element, event, animationDetails.options); diff --git a/src/ngAnimate/animation.js b/src/ngAnimate/animation.js index b7ec28b5742e..f0fb30abc4e5 100644 --- a/src/ngAnimate/animation.js +++ b/src/ngAnimate/animation.js @@ -19,16 +19,12 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { return element.data(RUNNER_STORAGE_KEY); } - this.$get = ['$$jqLite', '$rootScope', '$injector', '$$AnimateRunner', '$$rAFScheduler', - function($$jqLite, $rootScope, $injector, $$AnimateRunner, $$rAFScheduler) { + this.$get = ['$$jqLite', '$rootScope', '$injector', '$$AnimateRunner', + function($$jqLite, $rootScope, $injector, $$AnimateRunner) { var animationQueue = []; var applyAnimationClasses = applyAnimationClassesFactory($$jqLite); - var totalPendingClassBasedAnimations = 0; - var totalActiveClassBasedAnimations = 0; - var classBasedAnimationsQueue = []; - // TODO(matsko): document the signature in a better way return function(element, event, options) { options = prepareAnimationOptions(options); @@ -57,19 +53,12 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { options.tempClasses = null; } - var classBasedIndex; - if (!isStructural) { - classBasedIndex = totalPendingClassBasedAnimations; - totalPendingClassBasedAnimations += 1; - } - animationQueue.push({ // this data is used by the postDigest code and passed into // the driver step function element: element, classes: classes, event: event, - classBasedIndex: classBasedIndex, structural: isStructural, options: options, beforeStart: beforeStart, @@ -84,16 +73,13 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { if (animationQueue.length > 1) return runner; $rootScope.$$postDigest(function() { - totalActiveClassBasedAnimations = totalPendingClassBasedAnimations; - totalPendingClassBasedAnimations = 0; - classBasedAnimationsQueue.length = 0; - var animations = []; forEach(animationQueue, function(entry) { // the element was destroyed early on which removed the runner // form its storage. This means we can't animate this element // at all and it already has been closed due to destruction. - if (getRunner(entry.element)) { + var elm = entry.element; + if (getRunner(elm) && getDomNode(elm).parentNode) { animations.push(entry); } }); @@ -102,58 +88,23 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { animationQueue.length = 0; forEach(groupAnimations(animations), function(animationEntry) { - if (animationEntry.structural) { - triggerAnimationStart(); - } else { - classBasedAnimationsQueue.push({ - node: getDomNode(animationEntry.element), - fn: triggerAnimationStart - }); - - if (animationEntry.classBasedIndex === totalActiveClassBasedAnimations - 1) { - // we need to sort each of the animations in order of parent to child - // relationships. This ensures that the child classes are applied at the - // right time. - classBasedAnimationsQueue = classBasedAnimationsQueue.sort(function(a,b) { - return b.node.contains(a.node); - }).map(function(entry) { - return entry.fn; - }); - - $$rAFScheduler(classBasedAnimationsQueue); - } - } + // it's important that we apply the `ng-animate` CSS class and the + // temporary classes before we do any driver invoking since these + // CSS classes may be required for proper CSS detection. + animationEntry.beforeStart(); - function triggerAnimationStart() { - // it's important that we apply the `ng-animate` CSS class and the - // temporary classes before we do any driver invoking since these - // CSS classes may be required for proper CSS detection. - animationEntry.beforeStart(); - - var startAnimationFn, closeFn = animationEntry.close; - - // in the event that the element was removed before the digest runs or - // during the RAF sequencing then we should not trigger the animation. - var targetElement = animationEntry.anchors - ? (animationEntry.from.element || animationEntry.to.element) - : animationEntry.element; - - if (getRunner(targetElement) && getDomNode(targetElement).parentNode) { - var operation = invokeFirstDriver(animationEntry); - if (operation) { - startAnimationFn = operation.start; - } - } + var operation = invokeFirstDriver(animationEntry); + var triggerAnimationStart = operation && operation.start; /// TODO(matsko): only recognize operation.start() - if (!startAnimationFn) { - closeFn(); - } else { - var animationRunner = startAnimationFn(); - animationRunner.done(function(status) { - closeFn(!status); - }); - updateAnimationRunners(animationEntry, animationRunner); - } + var closeFn = animationEntry.close; + if (!triggerAnimationStart) { + closeFn(); + } else { + var animationRunner = triggerAnimationStart(); + animationRunner.done(function(status) { + closeFn(!status); + }); + updateAnimationRunners(animationEntry, animationRunner); } }); }); @@ -225,7 +176,7 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { var lookupKey = from.animationID.toString(); if (!anchorGroups[lookupKey]) { var group = anchorGroups[lookupKey] = { - structural: true, + // TODO(matsko): double-check this code beforeStart: function() { fromAnimation.beforeStart(); toAnimation.beforeStart(); diff --git a/src/ngAnimate/module.js b/src/ngAnimate/module.js index 71f20da8e406..0f12f0c24d29 100644 --- a/src/ngAnimate/module.js +++ b/src/ngAnimate/module.js @@ -4,7 +4,6 @@ $$BodyProvider, $$rAFMutexFactory, - $$rAFSchedulerFactory, $$AnimateChildrenDirective, $$AnimateRunnerFactory, $$AnimateQueueProvider, @@ -747,7 +746,6 @@ angular.module('ngAnimate', []) .directive('ngAnimateChildren', $$AnimateChildrenDirective) .factory('$$rAFMutex', $$rAFMutexFactory) - .factory('$$rAFScheduler', $$rAFSchedulerFactory) .factory('$$AnimateRunner', $$AnimateRunnerFactory) diff --git a/src/ngAnimate/rafScheduler.js b/src/ngAnimate/rafScheduler.js deleted file mode 100644 index 1105ff09cdb2..000000000000 --- a/src/ngAnimate/rafScheduler.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -var $$rAFSchedulerFactory = ['$$rAF', function($$rAF) { - var tickQueue = []; - var cancelFn; - - function scheduler(tasks) { - // we make a copy since RAFScheduler mutates the state - // of the passed in array variable and this would be difficult - // to track down on the outside code - tickQueue.push([].concat(tasks)); - nextTick(); - } - - /* waitUntilQuiet does two things: - * 1. It will run the FINAL `fn` value only when an uncancelled RAF has passed through - * 2. It will delay the next wave of tasks from running until the quiet `fn` has run. - * - * The motivation here is that animation code can request more time from the scheduler - * before the next wave runs. This allows for certain DOM properties such as classes to - * be resolved in time for the next animation to run. - */ - scheduler.waitUntilQuiet = function(fn) { - if (cancelFn) cancelFn(); - - cancelFn = $$rAF(function() { - cancelFn = null; - fn(); - nextTick(); - }); - }; - - return scheduler; - - function nextTick() { - if (!tickQueue.length) return; - - var updatedQueue = []; - for (var i = 0; i < tickQueue.length; i++) { - var innerQueue = tickQueue[i]; - runNextTask(innerQueue); - if (innerQueue.length) { - updatedQueue.push(innerQueue); - } - } - tickQueue = updatedQueue; - - if (!cancelFn) { - $$rAF(function() { - if (!cancelFn) nextTick(); - }); - } - } - - function runNextTask(tasks) { - var nextTask = tasks.shift(); - nextTask(); - } -}]; diff --git a/test/ngAnimate/animationSpec.js b/test/ngAnimate/animationSpec.js index d2d48d649e24..8153a572f8a1 100644 --- a/test/ngAnimate/animationSpec.js +++ b/test/ngAnimate/animationSpec.js @@ -296,90 +296,6 @@ describe('$$animation', function() { }; })); - it('should space out multiple ancestorial class-based animations with a RAF in between', - inject(function($rootScope, $$animation, $$rAF) { - - var parent = element; - element = jqLite('
'); - parent.append(element); - - var child = jqLite('
'); - element.append(child); - - $$animation(parent, 'addClass', { addClass: 'blue' }); - $$animation(element, 'addClass', { addClass: 'red' }); - $$animation(child, 'addClass', { addClass: 'green' }); - - $rootScope.$digest(); - - expect(captureLog.length).toBe(1); - expect(capturedAnimation.options.addClass).toBe('blue'); - - $$rAF.flush(); - expect(captureLog.length).toBe(2); - expect(capturedAnimation.options.addClass).toBe('red'); - - $$rAF.flush(); - expect(captureLog.length).toBe(3); - expect(capturedAnimation.options.addClass).toBe('green'); - })); - - it('should properly cancel out pending animations that are spaced with a RAF request before the digest completes', - inject(function($rootScope, $$animation, $$rAF) { - - var parent = element; - element = jqLite('
'); - parent.append(element); - - var child = jqLite('
'); - element.append(child); - - var r1 = $$animation(parent, 'addClass', { addClass: 'blue' }); - var r2 = $$animation(element, 'addClass', { addClass: 'red' }); - var r3 = $$animation(child, 'addClass', { addClass: 'green' }); - - r2.end(); - - $rootScope.$digest(); - - expect(captureLog.length).toBe(1); - expect(capturedAnimation.options.addClass).toBe('blue'); - - $$rAF.flush(); - - expect(captureLog.length).toBe(2); - expect(capturedAnimation.options.addClass).toBe('green'); - })); - - it('should properly cancel out pending animations that are spaced with a RAF request after the digest completes', - inject(function($rootScope, $$animation, $$rAF) { - - var parent = element; - element = jqLite('
'); - parent.append(element); - - var child = jqLite('
'); - element.append(child); - - var r1 = $$animation(parent, 'addClass', { addClass: 'blue' }); - var r2 = $$animation(element, 'addClass', { addClass: 'red' }); - var r3 = $$animation(child, 'addClass', { addClass: 'green' }); - - $rootScope.$digest(); - - r2.end(); - - expect(captureLog.length).toBe(1); - expect(capturedAnimation.options.addClass).toBe('blue'); - - $$rAF.flush(); - expect(captureLog.length).toBe(1); - - $$rAF.flush(); - expect(captureLog.length).toBe(2); - expect(capturedAnimation.options.addClass).toBe('green'); - })); - they('should return a runner that object that contains a $prop() function', ['end', 'cancel', 'then'], function(method) { inject(function($$animation) { @@ -605,7 +521,7 @@ describe('$$animation', function() { })); it("should not group animations into an anchored animation if enter/leave events are NOT used", - inject(function($$animation, $rootScope, $$rAF) { + inject(function($$animation, $rootScope) { fromElement.addClass('shared-class'); fromElement.attr('ng-animate-ref', '1'); @@ -620,7 +536,6 @@ describe('$$animation', function() { }); $rootScope.$digest(); - $$rAF.flush(); expect(captureLog.length).toBe(2); })); diff --git a/test/ngAnimate/rafSchedulerSpec.js b/test/ngAnimate/rafSchedulerSpec.js deleted file mode 100644 index 1f12fee67bbd..000000000000 --- a/test/ngAnimate/rafSchedulerSpec.js +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -describe("$$rAFScheduler", function() { - - beforeEach(module('ngAnimate')); - - it('should accept an array of tasks and run the first task immediately', - inject(function($$rAFScheduler) { - - var taskSpy = jasmine.createSpy(); - var tasks = [taskSpy]; - $$rAFScheduler(tasks); - expect(taskSpy).toHaveBeenCalled(); - })); - - it('should run tasks based on how many RAFs have run in comparison to the task index', - inject(function($$rAFScheduler, $$rAF) { - - var i, tasks = []; - - for (i = 0; i < 5; i++) { - tasks.push(jasmine.createSpy()); - } - - $$rAFScheduler(tasks); - - for (i = 1; i < 5; i++) { - var taskSpy = tasks[i]; - expect(taskSpy).not.toHaveBeenCalled(); - $$rAF.flush(); - expect(taskSpy).toHaveBeenCalled(); - } - })); - - it('should parallelize multiple instances of itself into sequenced RAFs', - inject(function($$rAFScheduler, $$rAF) { - - var spies = { - a: spy(), - b: spy(), - c: spy(), - - x: spy(), - y: spy(), - z: spy() - }; - - var t1 = [spies.a, spies.b, spies.c]; - var t2 = [spies.x, spies.y, spies.z]; - - $$rAFScheduler(t1); - expect(spies.a).toHaveBeenCalled(); - - $$rAF.flush(); - $$rAFScheduler(t2); - - expect(spies.b).toHaveBeenCalled(); - expect(spies.x).toHaveBeenCalled(); - - $$rAF.flush(); - - expect(spies.c).toHaveBeenCalled(); - expect(spies.y).toHaveBeenCalled(); - - $$rAF.flush(); - - expect(spies.z).toHaveBeenCalled(); - - function spy() { - return jasmine.createSpy(); - } - })); - - describe('.waitUntilQuiet', function() { - - it('should run the `last` provided function when a RAF fully passes', - inject(function($$rAFScheduler, $$rAF) { - - var q1 = jasmine.createSpy(); - $$rAFScheduler.waitUntilQuiet(q1); - - expect(q1).not.toHaveBeenCalled(); - - var q2 = jasmine.createSpy(); - $$rAFScheduler.waitUntilQuiet(q2); - - expect(q1).not.toHaveBeenCalled(); - expect(q2).not.toHaveBeenCalled(); - - var q3 = jasmine.createSpy(); - $$rAFScheduler.waitUntilQuiet(q3); - - expect(q1).not.toHaveBeenCalled(); - expect(q2).not.toHaveBeenCalled(); - expect(q3).not.toHaveBeenCalled(); - - $$rAF.flush(); - - expect(q1).not.toHaveBeenCalled(); - expect(q2).not.toHaveBeenCalled(); - expect(q3).toHaveBeenCalled(); - })); - - it('should always execute itself before the next RAF task tick occurs', - inject(function($$rAFScheduler, $$rAF) { - - var log = []; - - var quietFn = logFactory('quiet'); - var tasks = [ - logFactory('task1'), - logFactory('task2'), - logFactory('task3'), - logFactory('task4') - ]; - - $$rAFScheduler(tasks); - expect(log).toEqual(['task1']); - - $$rAFScheduler.waitUntilQuiet(quietFn); - expect(log).toEqual(['task1']); - - $$rAF.flush(); - - expect(log).toEqual(['task1', 'quiet', 'task2']); - - $$rAF.flush(); - - expect(log).toEqual(['task1', 'quiet', 'task2', 'task3']); - - $$rAFScheduler.waitUntilQuiet(quietFn); - - $$rAF.flush(); - - expect(log).toEqual(['task1', 'quiet', 'task2', 'task3', 'quiet', 'task4']); - - function logFactory(token) { - return function() { - log.push(token); - }; - } - })); - }); - -}); From 04ca859fbe701dd316dd591e00eb14ef54e0d1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 30 Jun 2015 17:37:45 -0400 Subject: [PATCH 4/4] fix(ngAnimate): ensure that parent class-based animations are never closed by their children This fix ensures that a structural child animation will never close a parent class based early so that the CSS classes for the child are ready for it to perform its CSS animation. The reasoning for the past for this was because their is a one frame delay before the classes were applied. If a parent and a child animation happen at the same time then the animations may not be picked up for the element since the CSS classes may not have been applied yet. This fix ensures that parent CSS classes are applied in a synchronous manner without the need to run a one RAF wait. The solution to this was to apply the preparation classes during the pre-digest phase and then apply the CSS classes right after with a forced reflow paint. BREAKING CHANGE: CSS classes added/removed by ngAnimate are now applied synchronously once the first digest has passed. The previous behavior involved ngAnimate having to wait for one requestAnimationFrame before CSS classes were added/removed. The CSS classes are now applied directly after the first digest that is triggered after `$animate.addClass`, `$animate.removeClass` or `$animate.setClass` is called. If any of your code relies on waiting for one frame before checking for CSS classes on the element then please change this behavior. If a parent class-based animation, however, is run through a JavaScript animation which triggers an animation for `beforeAddClass` and/or `beforeRemoveClass` then the CSS classes will not be applied in time for the children (and the parent class-based animation will not be cancelled by any child animations). Closes #11975 Closes #12276 --- src/ngAnimate/.jshintrc | 30 ++- src/ngAnimate/animateCss.js | 117 ++------- src/ngAnimate/animateCssDriver.js | 46 +++- src/ngAnimate/animateQueue.js | 50 ++-- src/ngAnimate/animation.js | 161 ++++++++++-- src/ngAnimate/shared.js | 109 ++++++++ test/ngAnimate/animateCssDriverSpec.js | 12 +- test/ngAnimate/animateCssSpec.js | 2 +- test/ngAnimate/animateSpec.js | 213 ++++++++++------ test/ngAnimate/animationSpec.js | 52 ++++ test/ngAnimate/integrationSpec.js | 337 ++++++++++++++++++++++++- 11 files changed, 875 insertions(+), 254 deletions(-) diff --git a/src/ngAnimate/.jshintrc b/src/ngAnimate/.jshintrc index 2ab1d09fa858..ee79a690d2a8 100644 --- a/src/ngAnimate/.jshintrc +++ b/src/ngAnimate/.jshintrc @@ -20,9 +20,30 @@ "isElement": false, "ELEMENT_NODE": false, + "COMMENT_NODE": false, "NG_ANIMATE_CLASSNAME": false, "NG_ANIMATE_CHILDREN_DATA": false, + "ADD_CLASS_SUFFIX": false, + "REMOVE_CLASS_SUFFIX": false, + "EVENT_CLASS_PREFIX": false, + "ACTIVE_CLASS_SUFFIX": false, + + "TRANSITION_DURATION_PROP": false, + "TRANSITION_DELAY_PROP": false, + "TRANSITION_PROP": false, + "PROPERTY_KEY": false, + "DURATION_KEY": false, + "DELAY_KEY": false, + "TIMING_KEY": false, + "ANIMATION_DURATION_PROP": false, + "ANIMATION_DELAY_PROP": false, + "ANIMATION_PROP": false, + "ANIMATION_ITERATION_COUNT_KEY": false, + "SAFE_FAST_FORWARD_DURATION_VALUE": false, + "TRANSITIONEND_EVENT": false, + "ANIMATIONEND_EVENT": false, + "assertArg": false, "isPromiseLike": false, "mergeClasses": false, @@ -38,6 +59,13 @@ "removeFromArray": false, "stripCommentsFromElement": false, "extractElementNode": false, - "getDomNode": false + "getDomNode": false, + + "applyGeneratedPreparationClasses": false, + "clearGeneratedClasses": false, + "blockTransitions": false, + "blockKeyframeAnimations": false, + "applyInlineStyle": false, + "concatWithSpace": false } } diff --git a/src/ngAnimate/animateCss.js b/src/ngAnimate/animateCss.js index 805327e95ffe..ecdfb9436ea5 100644 --- a/src/ngAnimate/animateCss.js +++ b/src/ngAnimate/animateCss.js @@ -208,55 +208,11 @@ * * `start` - The method to start the animation. This will return a `Promise` when called. * * `end` - This method will cancel the animation and remove all applied CSS classes and styles. */ - -// Detect proper transitionend/animationend event names. -var CSS_PREFIX = '', TRANSITION_PROP, TRANSITIONEND_EVENT, ANIMATION_PROP, ANIMATIONEND_EVENT; - -// If unprefixed events are not supported but webkit-prefixed are, use the latter. -// Otherwise, just use W3C names, browsers not supporting them at all will just ignore them. -// Note: Chrome implements `window.onwebkitanimationend` and doesn't implement `window.onanimationend` -// but at the same time dispatches the `animationend` event and not `webkitAnimationEnd`. -// Register both events in case `window.onanimationend` is not supported because of that, -// do the same for `transitionend` as Safari is likely to exhibit similar behavior. -// Also, the only modern browser that uses vendor prefixes for transitions/keyframes is webkit -// therefore there is no reason to test anymore for other vendor prefixes: -// http://caniuse.com/#search=transition -if (window.ontransitionend === undefined && window.onwebkittransitionend !== undefined) { - CSS_PREFIX = '-webkit-'; - TRANSITION_PROP = 'WebkitTransition'; - TRANSITIONEND_EVENT = 'webkitTransitionEnd transitionend'; -} else { - TRANSITION_PROP = 'transition'; - TRANSITIONEND_EVENT = 'transitionend'; -} - -if (window.onanimationend === undefined && window.onwebkitanimationend !== undefined) { - CSS_PREFIX = '-webkit-'; - ANIMATION_PROP = 'WebkitAnimation'; - ANIMATIONEND_EVENT = 'webkitAnimationEnd animationend'; -} else { - ANIMATION_PROP = 'animation'; - ANIMATIONEND_EVENT = 'animationend'; -} - -var DURATION_KEY = 'Duration'; -var PROPERTY_KEY = 'Property'; -var DELAY_KEY = 'Delay'; -var TIMING_KEY = 'TimingFunction'; -var ANIMATION_ITERATION_COUNT_KEY = 'IterationCount'; -var ANIMATION_PLAYSTATE_KEY = 'PlayState'; -var ELAPSED_TIME_MAX_DECIMAL_PLACES = 3; -var CLOSING_TIME_BUFFER = 1.5; var ONE_SECOND = 1000; var BASE_TEN = 10; -var SAFE_FAST_FORWARD_DURATION_VALUE = 9999; - -var ANIMATION_DELAY_PROP = ANIMATION_PROP + DELAY_KEY; -var ANIMATION_DURATION_PROP = ANIMATION_PROP + DURATION_KEY; - -var TRANSITION_DELAY_PROP = TRANSITION_PROP + DELAY_KEY; -var TRANSITION_DURATION_PROP = TRANSITION_PROP + DURATION_KEY; +var ELAPSED_TIME_MAX_DECIMAL_PLACES = 3; +var CLOSING_TIME_BUFFER = 1.5; var DETECT_CSS_PROPERTIES = { transitionDuration: TRANSITION_DURATION_PROP, @@ -274,6 +230,15 @@ var DETECT_STAGGER_CSS_PROPERTIES = { animationDelay: ANIMATION_DELAY_PROP }; +function getCssKeyframeDurationStyle(duration) { + return [ANIMATION_DURATION_PROP, duration + 's']; +} + +function getCssDelayStyle(delay, isKeyframeAnimation) { + var prop = isKeyframeAnimation ? ANIMATION_DELAY_PROP : TRANSITION_DELAY_PROP; + return [prop, delay + 's']; +} + function computeCssStyles($window, element, properties) { var styles = Object.create(null); var detectedStyles = $window.getComputedStyle(element) || {}; @@ -330,37 +295,6 @@ function getCssTransitionDurationStyle(duration, applyOnlyDuration) { return [style, value]; } -function getCssKeyframeDurationStyle(duration) { - return [ANIMATION_DURATION_PROP, duration + 's']; -} - -function getCssDelayStyle(delay, isKeyframeAnimation) { - var prop = isKeyframeAnimation ? ANIMATION_DELAY_PROP : TRANSITION_DELAY_PROP; - return [prop, delay + 's']; -} - -function blockTransitions(node, duration) { - // we use a negative delay value since it performs blocking - // yet it doesn't kill any existing transitions running on the - // same element which makes this safe for class-based animations - var value = duration ? '-' + duration + 's' : ''; - applyInlineStyle(node, [TRANSITION_DELAY_PROP, value]); - return [TRANSITION_DELAY_PROP, value]; -} - -function blockKeyframeAnimations(node, applyBlock) { - var value = applyBlock ? 'paused' : ''; - var key = ANIMATION_PROP + ANIMATION_PLAYSTATE_KEY; - applyInlineStyle(node, [key, value]); - return [key, value]; -} - -function applyInlineStyle(node, styleTuple) { - var prop = styleTuple[0]; - var value = styleTuple[1]; - node.style[prop] = value; -} - function createLocalCacheLookup() { var cache = Object.create(null); return { @@ -392,10 +326,8 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { var gcsLookup = createLocalCacheLookup(); var gcsStaggerLookup = createLocalCacheLookup(); - this.$get = ['$window', '$$jqLite', '$$AnimateRunner', '$timeout', - '$document', '$sniffer', '$$rAF', - function($window, $$jqLite, $$AnimateRunner, $timeout, - $document, $sniffer, $$rAF) { + this.$get = ['$window', '$$jqLite', '$$AnimateRunner', '$timeout', '$$forceReflow', '$sniffer', '$$rAF', + function($window, $$jqLite, $$AnimateRunner, $timeout, $$forceReflow, $sniffer, $$rAF) { var applyAnimationClasses = applyAnimationClassesFactory($$jqLite); @@ -452,7 +384,6 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { return stagger || {}; } - var bod = getDomNode($document).body; var cancelLastRAFRequest; var rafWaitQueue = []; function waitUntilQuiet(callback) { @@ -465,20 +396,14 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { gcsLookup.flush(); gcsStaggerLookup.flush(); - //the line below will force the browser to perform a repaint so - //that all the animated elements within the animation frame will - //be properly updated and drawn on screen. This is required to - //ensure that the preparation animation is properly flushed so that - //the active state picks up from there. DO NOT REMOVE THIS LINE. - //DO NOT OPTIMIZE THIS LINE. THE MINIFIER WILL REMOVE IT OTHERWISE WHICH - //WILL RESULT IN AN UNPREDICTABLE BUG THAT IS VERY HARD TO TRACK DOWN AND - //WILL TAKE YEARS AWAY FROM YOUR LIFE. - var width = bod.offsetWidth + 1; + // DO NOT REMOVE THIS LINE OR REFACTOR OUT THE `pageWidth` variable. + // PLEASE EXAMINE THE `$$forceReflow` service to understand why. + var pageWidth = $$forceReflow(); // we use a for loop to ensure that if the queue is changed // during this looping then it will consider new requests for (var i = 0; i < rafWaitQueue.length; i++) { - rafWaitQueue[i](width); + rafWaitQueue[i](pageWidth); } rafWaitQueue.length = 0; }); @@ -534,20 +459,20 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { var addRemoveClassName = ''; if (isStructural) { - structuralClassName = pendClasses(method, 'ng-', true); + structuralClassName = pendClasses(method, EVENT_CLASS_PREFIX, true); } else if (method) { structuralClassName = method; } if (options.addClass) { - addRemoveClassName += pendClasses(options.addClass, '-add'); + addRemoveClassName += pendClasses(options.addClass, ADD_CLASS_SUFFIX); } if (options.removeClass) { if (addRemoveClassName.length) { addRemoveClassName += ' '; } - addRemoveClassName += pendClasses(options.removeClass, '-remove'); + addRemoveClassName += pendClasses(options.removeClass, REMOVE_CLASS_SUFFIX); } // there may be a situation where a structural animation is combined together @@ -563,7 +488,7 @@ var $AnimateCssProvider = ['$animateProvider', function($animateProvider) { var preparationClasses = [structuralClassName, addRemoveClassName].join(' ').trim(); var fullClassName = classes + ' ' + preparationClasses; - var activeClasses = pendClasses(preparationClasses, '-active'); + var activeClasses = pendClasses(preparationClasses, ACTIVE_CLASS_SUFFIX); var hasToStyles = styles.to && Object.keys(styles.to).length > 0; var containsKeyframeAnimation = (options.keyframeStyle || '').length > 0; diff --git a/src/ngAnimate/animateCssDriver.js b/src/ngAnimate/animateCssDriver.js index 063124a7d89d..c47a27a85c23 100644 --- a/src/ngAnimate/animateCssDriver.js +++ b/src/ngAnimate/animateCssDriver.js @@ -9,8 +9,8 @@ var $$AnimateCssDriverProvider = ['$$animationProvider', function($$animationPro var NG_OUT_ANCHOR_CLASS_NAME = 'ng-anchor-out'; var NG_IN_ANCHOR_CLASS_NAME = 'ng-anchor-in'; - this.$get = ['$animateCss', '$rootScope', '$$AnimateRunner', '$rootElement', '$$body', '$sniffer', - function($animateCss, $rootScope, $$AnimateRunner, $rootElement, $$body, $sniffer) { + this.$get = ['$animateCss', '$rootScope', '$$AnimateRunner', '$rootElement', '$$body', '$sniffer', '$$jqLite', + function($animateCss, $rootScope, $$AnimateRunner, $rootElement, $$body, $sniffer, $$jqLite) { // only browsers that support these properties can render animations if (!$sniffer.animations && !$sniffer.transitions) return noop; @@ -20,13 +20,15 @@ var $$AnimateCssDriverProvider = ['$$animationProvider', function($$animationPro var rootBodyElement = jqLite(bodyNode.parentNode === rootNode ? bodyNode : rootNode); - return function initDriverFn(animationDetails) { + var applyAnimationClasses = applyAnimationClassesFactory($$jqLite); + + return function initDriverFn(animationDetails, onBeforeClassesAppliedCb) { return animationDetails.from && animationDetails.to ? prepareFromToAnchorAnimation(animationDetails.from, animationDetails.to, animationDetails.classes, animationDetails.anchors) - : prepareRegularAnimation(animationDetails); + : prepareRegularAnimation(animationDetails, onBeforeClassesAppliedCb); }; function filterCssClasses(classes) { @@ -170,8 +172,8 @@ var $$AnimateCssDriverProvider = ['$$animationProvider', function($$animationPro } function prepareFromToAnchorAnimation(from, to, classes, anchors) { - var fromAnimation = prepareRegularAnimation(from); - var toAnimation = prepareRegularAnimation(to); + var fromAnimation = prepareRegularAnimation(from, noop); + var toAnimation = prepareRegularAnimation(to, noop); var anchorAnimations = []; forEach(anchors, function(anchor) { @@ -222,24 +224,40 @@ var $$AnimateCssDriverProvider = ['$$animationProvider', function($$animationPro }; } - function prepareRegularAnimation(animationDetails) { + function prepareRegularAnimation(animationDetails, onBeforeClassesAppliedCb) { var element = animationDetails.element; var options = animationDetails.options || {}; + // since the ng-EVENT, class-ADD and class-REMOVE classes are applied inside + // of the animateQueue pre and postDigest stages then there is no need to add + // then them here as well. + options.$$skipPreparationClasses = true; + + // during the pre/post digest stages inside of animateQueue we also performed + // the blocking (transition:-9999s) so there is no point in doing that again. + options.skipBlocking = true; + if (animationDetails.structural) { - // structural animations ensure that the CSS classes are always applied - // before the detection starts. - options.structural = options.applyClassesEarly = true; + options.event = animationDetails.event; // we special case the leave animation since we want to ensure that // the element is removed as soon as the animation is over. Otherwise // a flicker might appear or the element may not be removed at all - options.event = animationDetails.event; - if (options.event === 'leave') { + if (animationDetails.event === 'leave') { options.onDone = options.domOperation; } - } else { - options.event = null; + } + + // we apply the classes right away since the pre-digest took care of the + // preparation classes. + onBeforeClassesAppliedCb(element); + applyAnimationClasses(element, options); + + // We assign the preparationClasses as the actual animation event since + // the internals of $animateCss will just suffix the event token values + // with `-active` to trigger the animation. + if (options.preparationClasses) { + options.event = concatWithSpace(options.event, options.preparationClasses); } var animator = $animateCss(element, options); diff --git a/src/ngAnimate/animateQueue.js b/src/ngAnimate/animateQueue.js index 6d856e238ed3..7eb87ac18074 100644 --- a/src/ngAnimate/animateQueue.js +++ b/src/ngAnimate/animateQueue.js @@ -43,8 +43,8 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { }); rules.skip.push(function(element, newAnimation, currentAnimation) { - // if there is a current animation then skip the class-based animation - return currentAnimation.structural && !newAnimation.structural; + // if there is an ongoing current animation then don't even bother running the class-based animation + return currentAnimation.structural && currentAnimation.state === RUNNING_STATE && !newAnimation.structural; }); rules.cancel.push(function(element, newAnimation, currentAnimation) { @@ -73,7 +73,6 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { var activeAnimationsLookup = new $$HashMap(); var disabledElementsLookup = new $$HashMap(); - var animationsEnabled = null; // Wait until all directive and route-related templates are downloaded and @@ -341,9 +340,14 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { if (existingAnimation.state === RUNNING_STATE) { normalizeAnimationOptions(element, options); } else { + applyGeneratedPreparationClasses(element, isStructural ? event : null, options); + event = newAnimation.event = existingAnimation.event; options = mergeAnimationOptions(element, existingAnimation.options, newAnimation.options); - return runner; + + //we return the same runner since only the option values of this animation will + //be fed into the `existingAnimation`. + return existingAnimation.runner; } } } @@ -369,7 +373,8 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { return runner; } - closeParentClassBasedAnimations(parent); + applyGeneratedPreparationClasses(element, isStructural ? event : null, options); + blockTransitions(node, SAFE_FAST_FORWARD_DURATION_VALUE); // the counter keeps track of cancelled animations var counter = (existingAnimation.counter || 0) + 1; @@ -428,10 +433,11 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { ? 'setClass' : animationDetails.event; - closeParentClassBasedAnimations(parentElement); - markElementAnimationState(element, RUNNING_STATE); - var realRunner = $$animation(element, event, animationDetails.options); + var realRunner = $$animation(element, event, animationDetails.options, function(e) { + blockTransitions(getDomNode(e), false); + }); + realRunner.done(function(status) { close(!status); var animationDetails = activeAnimationsLookup.get(node); @@ -455,6 +461,7 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { } function close(reject) { // jshint ignore:line + clearGeneratedClasses(element, options); applyAnimationClasses(element, options); applyAnimationStyles(element, options); options.domOperation(); @@ -491,33 +498,6 @@ var $$AnimateQueueProvider = ['$animateProvider', function($animateProvider) { return getDomNode(nodeOrElmA) === getDomNode(nodeOrElmB); } - function closeParentClassBasedAnimations(startingElement) { - var parentNode = getDomNode(startingElement); - do { - if (!parentNode || parentNode.nodeType !== ELEMENT_NODE) break; - - var animationDetails = activeAnimationsLookup.get(parentNode); - if (animationDetails) { - examineParentAnimation(parentNode, animationDetails); - } - - parentNode = parentNode.parentNode; - } while (true); - - // since animations are detected from CSS classes, we need to flush all parent - // class-based animations so that the parent classes are all present for child - // animations to properly function (otherwise any CSS selectors may not work) - function examineParentAnimation(node, animationDetails) { - // enter/leave/move always have priority - if (animationDetails.structural || !hasAnimationClasses(animationDetails.options)) return; - - if (animationDetails.state === RUNNING_STATE) { - animationDetails.runner.end(); - } - clearElementAnimationState(node); - } - } - function areAnimationsAllowed(element, parentElement, event) { var bodyElementDetected = isMatchingElement(element, $$body) || element[0].nodeName === 'HTML'; var rootElementDetected = isMatchingElement(element, $rootElement); diff --git a/src/ngAnimate/animation.js b/src/ngAnimate/animation.js index f0fb30abc4e5..73747140fc52 100644 --- a/src/ngAnimate/animation.js +++ b/src/ngAnimate/animation.js @@ -19,14 +19,99 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { return element.data(RUNNER_STORAGE_KEY); } - this.$get = ['$$jqLite', '$rootScope', '$injector', '$$AnimateRunner', - function($$jqLite, $rootScope, $injector, $$AnimateRunner) { + this.$get = ['$$jqLite', '$rootScope', '$injector', '$$AnimateRunner', '$$HashMap', '$$forceReflow', + function($$jqLite, $rootScope, $injector, $$AnimateRunner, $$HashMap, $$forceReflow) { var animationQueue = []; var applyAnimationClasses = applyAnimationClassesFactory($$jqLite); + function sortAnimations(animations) { + var tree = { children: [] }; + var i, lookup = new $$HashMap(); + + // this is done first beforehand so that the hashmap + // is filled with a list of the elements that will be animated + for (i = 0; i < animations.length; i++) { + var animation = animations[i]; + lookup.put(animation.domNode, animations[i] = { + domNode: animation.domNode, + fn: animation.fn, + children: [] + }); + } + + for (i = 0; i < animations.length; i++) { + processNode(animations[i]); + } + + return flatten(tree); + + function processNode(entry) { + if (entry.processed) return entry; + entry.processed = true; + + var elementNode = entry.domNode; + var parentNode = elementNode.parentNode; + lookup.put(elementNode, entry); + + var parentEntry; + while (parentNode) { + parentEntry = lookup.get(parentNode); + if (parentEntry) { + if (!parentEntry.processed) { + parentEntry = processNode(parentEntry); + } + break; + } + parentNode = parentNode.parentNode; + } + + (parentEntry || tree).children.push(entry); + return entry; + } + + function flatten(tree) { + var result = []; + var queue = []; + var i; + + for (i = 0; i < tree.children.length; i++) { + queue.push(tree.children[i]); + } + + var remainingLevelEntries = queue.length; + var nextLevelEntries = 0; + var row = []; + + for (i = 0; i < queue.length; i++) { + var entry = queue[i]; + if (remainingLevelEntries <= 0) { + remainingLevelEntries = nextLevelEntries; + nextLevelEntries = 0; + result = result.concat(row); + row = []; + } + row.push({ + fn: entry.fn, + terminal: entry.children.length === 0 + }); + entry.children.forEach(function(childEntry) { + nextLevelEntries++; + queue.push(childEntry); + }); + remainingLevelEntries--; + } + + if (row.length) { + result = result.concat(row); + } + + return result; + } + } + // TODO(matsko): document the signature in a better way - return function(element, event, options) { + return function(element, event, options, onBeforeClassesAppliedCb) { options = prepareAnimationOptions(options); var isStructural = ['enter', 'move', 'leave'].indexOf(event) >= 0; @@ -81,31 +166,65 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { var elm = entry.element; if (getRunner(elm) && getDomNode(elm).parentNode) { animations.push(entry); + } else { + entry.close(); } }); // now any future animations will be in another postDigest animationQueue.length = 0; - forEach(groupAnimations(animations), function(animationEntry) { - // it's important that we apply the `ng-animate` CSS class and the - // temporary classes before we do any driver invoking since these - // CSS classes may be required for proper CSS detection. - animationEntry.beforeStart(); + var groupedAnimations = groupAnimations(animations); + var toBeSortedAnimations = []; + + forEach(groupedAnimations, function(animationEntry) { + toBeSortedAnimations.push({ + domNode: getDomNode(animationEntry.from ? animationEntry.from.element : animationEntry.element), + fn: function triggerAnimationStart() { + // it's important that we apply the `ng-animate` CSS class and the + // temporary classes before we do any driver invoking since these + // CSS classes may be required for proper CSS detection. + animationEntry.beforeStart(); + + var startAnimationFn, closeFn = animationEntry.close; + + // in the event that the element was removed before the digest runs or + // during the RAF sequencing then we should not trigger the animation. + var targetElement = animationEntry.anchors + ? (animationEntry.from.element || animationEntry.to.element) + : animationEntry.element; + + if (getRunner(targetElement)) { + var operation = invokeFirstDriver(animationEntry, onBeforeClassesAppliedCb); + if (operation) { + startAnimationFn = operation.start; + } + } + + if (!startAnimationFn) { + closeFn(); + } else { + var animationRunner = startAnimationFn(); + animationRunner.done(function(status) { + closeFn(!status); + }); + updateAnimationRunners(animationEntry, animationRunner); + } + } + }); + }); - var operation = invokeFirstDriver(animationEntry); - var triggerAnimationStart = operation && operation.start; /// TODO(matsko): only recognize operation.start() + // we need to sort each of the animations in order of parent to child + // relationships. This ensures that the child classes are applied at the + // right time. + var anim = sortAnimations(toBeSortedAnimations); + var finalLevel = anim.length - 1; - var closeFn = animationEntry.close; - if (!triggerAnimationStart) { - closeFn(); - } else { - var animationRunner = triggerAnimationStart(); - animationRunner.done(function(status) { - closeFn(!status); - }); - updateAnimationRunners(animationEntry, animationRunner); + forEach(anim, function(entry) { + if (!entry.terminal) { + $$forceReflow(); } + entry.fn(); }); }); @@ -230,7 +349,7 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { return matches.join(' '); } - function invokeFirstDriver(animationDetails) { + function invokeFirstDriver(animationDetails, onBeforeClassesAppliedCb) { // we loop in reverse order since the more general drivers (like CSS and JS) // may attempt more elements, but custom drivers are more particular for (var i = drivers.length - 1; i >= 0; i--) { @@ -238,7 +357,7 @@ var $$AnimationProvider = ['$animateProvider', function($animateProvider) { if (!$injector.has(driverName)) continue; // TODO(matsko): remove this check var factory = $injector.get(driverName); - var driver = factory(animationDetails); + var driver = factory(animationDetails, onBeforeClassesAppliedCb); if (driver) { return driver; } diff --git a/src/ngAnimate/shared.js b/src/ngAnimate/shared.js index 43f7c64b19ca..f23c66b2a2b9 100644 --- a/src/ngAnimate/shared.js +++ b/src/ngAnimate/shared.js @@ -16,9 +16,57 @@ var isElement = angular.isElement; var ELEMENT_NODE = 1; var COMMENT_NODE = 8; +var ADD_CLASS_SUFFIX = '-add'; +var REMOVE_CLASS_SUFFIX = '-remove'; +var EVENT_CLASS_PREFIX = 'ng-'; +var ACTIVE_CLASS_SUFFIX = '-active'; + var NG_ANIMATE_CLASSNAME = 'ng-animate'; var NG_ANIMATE_CHILDREN_DATA = '$$ngAnimateChildren'; +// Detect proper transitionend/animationend event names. +var CSS_PREFIX = '', TRANSITION_PROP, TRANSITIONEND_EVENT, ANIMATION_PROP, ANIMATIONEND_EVENT; + +// If unprefixed events are not supported but webkit-prefixed are, use the latter. +// Otherwise, just use W3C names, browsers not supporting them at all will just ignore them. +// Note: Chrome implements `window.onwebkitanimationend` and doesn't implement `window.onanimationend` +// but at the same time dispatches the `animationend` event and not `webkitAnimationEnd`. +// Register both events in case `window.onanimationend` is not supported because of that, +// do the same for `transitionend` as Safari is likely to exhibit similar behavior. +// Also, the only modern browser that uses vendor prefixes for transitions/keyframes is webkit +// therefore there is no reason to test anymore for other vendor prefixes: +// http://caniuse.com/#search=transition +if (window.ontransitionend === undefined && window.onwebkittransitionend !== undefined) { + CSS_PREFIX = '-webkit-'; + TRANSITION_PROP = 'WebkitTransition'; + TRANSITIONEND_EVENT = 'webkitTransitionEnd transitionend'; +} else { + TRANSITION_PROP = 'transition'; + TRANSITIONEND_EVENT = 'transitionend'; +} + +if (window.onanimationend === undefined && window.onwebkitanimationend !== undefined) { + CSS_PREFIX = '-webkit-'; + ANIMATION_PROP = 'WebkitAnimation'; + ANIMATIONEND_EVENT = 'webkitAnimationEnd animationend'; +} else { + ANIMATION_PROP = 'animation'; + ANIMATIONEND_EVENT = 'animationend'; +} + +var DURATION_KEY = 'Duration'; +var PROPERTY_KEY = 'Property'; +var DELAY_KEY = 'Delay'; +var TIMING_KEY = 'TimingFunction'; +var ANIMATION_ITERATION_COUNT_KEY = 'IterationCount'; +var ANIMATION_PLAYSTATE_KEY = 'PlayState'; +var SAFE_FAST_FORWARD_DURATION_VALUE = 9999; + +var ANIMATION_DELAY_PROP = ANIMATION_PROP + DELAY_KEY; +var ANIMATION_DURATION_PROP = ANIMATION_PROP + DURATION_KEY; +var TRANSITION_DELAY_PROP = TRANSITION_PROP + DELAY_KEY; +var TRANSITION_DURATION_PROP = TRANSITION_PROP + DURATION_KEY; + var isPromiseLike = function(p) { return p && p.then ? true : false; } @@ -172,6 +220,11 @@ function mergeAnimationOptions(element, target, newOptions) { var toRemove = (target.removeClass || '') + ' ' + (newOptions.removeClass || ''); var classes = resolveElementClasses(element.attr('class'), toAdd, toRemove); + if (newOptions.preparationClasses) { + target.preparationClasses = concatWithSpace(newOptions.preparationClasses, target.preparationClasses); + delete newOptions.preparationClasses; + } + extend(target, newOptions); if (classes.addClass) { @@ -250,3 +303,59 @@ function resolveElementClasses(existing, toAdd, toRemove) { function getDomNode(element) { return (element instanceof angular.element) ? element[0] : element; } + +function applyGeneratedPreparationClasses(element, event, options) { + var classes = ''; + if (event) { + classes = pendClasses(event, EVENT_CLASS_PREFIX, true); + } + if (options.addClass) { + classes = concatWithSpace(classes, pendClasses(options.addClass, ADD_CLASS_SUFFIX)); + } + if (options.removeClass) { + classes = concatWithSpace(classes, pendClasses(options.removeClass, REMOVE_CLASS_SUFFIX)); + } + if (classes.length) { + options.preparationClasses = classes; + element.addClass(classes); + } +} + +function clearGeneratedClasses(element, options) { + if (options.preparationClasses) { + element.removeClass(options.preparationClasses); + options.preparationClasses = null; + } + if (options.activeClasses) { + element.removeClass(options.activeClasses); + options.activeClasses = null; + } +} + +function blockTransitions(node, duration) { + // we use a negative delay value since it performs blocking + // yet it doesn't kill any existing transitions running on the + // same element which makes this safe for class-based animations + var value = duration ? '-' + duration + 's' : ''; + applyInlineStyle(node, [TRANSITION_DELAY_PROP, value]); + return [TRANSITION_DELAY_PROP, value]; +} + +function blockKeyframeAnimations(node, applyBlock) { + var value = applyBlock ? 'paused' : ''; + var key = ANIMATION_PROP + ANIMATION_PLAYSTATE_KEY; + applyInlineStyle(node, [key, value]); + return [key, value]; +} + +function applyInlineStyle(node, styleTuple) { + var prop = styleTuple[0]; + var value = styleTuple[1]; + node.style[prop] = value; +} + +function concatWithSpace(a,b) { + if (!a) return b; + if (!b) return a; + return a + ' ' + b; +} diff --git a/test/ngAnimate/animateCssDriverSpec.js b/test/ngAnimate/animateCssDriverSpec.js index ec6fcbb7f1fd..fc4f56bc62a2 100644 --- a/test/ngAnimate/animateCssDriverSpec.js +++ b/test/ngAnimate/animateCssDriverSpec.js @@ -69,7 +69,9 @@ describe("ngAnimate $$animateCssDriver", function() { element = jqLite('
'); return function($$animateCssDriver, $document, $window) { - driver = $$animateCssDriver; + driver = function(details, cb) { + return $$animateCssDriver(details, cb || noop); + }; ss = createMockStyleSheet($document, $window); }; })); @@ -97,12 +99,12 @@ describe("ngAnimate $$animateCssDriver", function() { expect(isFunction(runner.start)).toBeTruthy(); })); - it("should signal $animateCss to apply the classes early when animation is structural", inject(function() { - driver({ element: element, structural: true }); - expect(capturedAnimation[1].applyClassesEarly).toBeTruthy(); - + it("should not signal $animateCss to apply the classes early when animation is structural", inject(function() { driver({ element: element }); expect(capturedAnimation[1].applyClassesEarly).toBeFalsy(); + + driver({ element: element, structural: true }); + expect(capturedAnimation[1].applyClassesEarly).toBeFalsy(); })); it("should only set the event value if the animation is structural", inject(function() { diff --git a/test/ngAnimate/animateCssSpec.js b/test/ngAnimate/animateCssSpec.js index 2734ada9c506..b83c77c5046b 100644 --- a/test/ngAnimate/animateCssSpec.js +++ b/test/ngAnimate/animateCssSpec.js @@ -1456,7 +1456,7 @@ describe("ngAnimate $animateCss", function() { // just to prove it works data.skipBlocking = false; - var animator = $animateCss(element, { addClass: 'test' }); + $animateCss(element, { addClass: 'test' }); expect(blockSpy).toHaveBeenCalled(); }); }); diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index f73949138ee2..83113f496587 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -462,6 +462,74 @@ describe("animations", function() { expect(element).not.toHaveClass('green'); })); + they('should apply the $prop CSS class to the element before digest for the given event and remove when complete', + {'ng-enter': 'enter', 'ng-leave': 'leave', 'ng-move': 'move'}, function(event) { + + inject(function($animate, $rootScope, $document, $rootElement) { + $animate.enabled(true); + + var element = jqLite('
'); + var parent = jqLite('
'); + + $rootElement.append(parent); + jqLite($document[0].body).append($rootElement); + + var runner; + if (event === 'leave') { + parent.append(element); + runner = $animate[event](element); + } else { + runner = $animate[event](element, parent); + } + + var expectedClassName = 'ng-' + event; + + expect(element).toHaveClass(expectedClassName); + + $rootScope.$digest(); + expect(element).toHaveClass(expectedClassName); + + runner.end(); + expect(element).not.toHaveClass(expectedClassName); + + dealoc(parent); + }); + }); + + they('should add CSS classes with the $prop suffix when depending on the event and remove when complete', + {'-add': 'add', '-remove': 'remove'}, function(event) { + + inject(function($animate, $rootScope, $document, $rootElement) { + $animate.enabled(true); + + var element = jqLite('
'); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + var classes = 'one two'; + var expectedClasses = ['one-',event,' ','two-', event].join(''); + + var runner; + if (event === 'add') { + runner = $animate.addClass(element, classes); + } else { + element.addClass(classes); + runner = $animate.removeClass(element, classes); + } + + expect(element).toHaveClass(expectedClasses); + + $rootScope.$digest(); + expect(element).toHaveClass(expectedClasses); + + runner.end(); + expect(element).not.toHaveClass(expectedClasses); + + dealoc(element); + }); + }); + they('$prop() should operate using a native DOM element', ['enter', 'move', 'leave', 'addClass', 'removeClass', 'setClass', 'animate'], function(event) { @@ -571,102 +639,76 @@ describe("animations", function() { }); describe('parent animations', function() { - it('should immediately end a pre-digest parent class-based animation if a structural child is active', + they('should not cancel a pre-digest parent class-based animation if a child $prop animation is set to run', + ['structural', 'class-based'], function(animationType) { + inject(function($rootScope, $animate, $$rAF) { + parent.append(element); + var child = jqLite('
'); - parent.append(element); - var child = jqLite('
'); + if (animationType === 'structural') { + $animate.enter(child, element); + } else { + element.append(child); + $animate.addClass(child, 'test'); + } - var itsOver = false; - $animate.addClass(parent, 'abc').done(function() { - itsOver = true; + $animate.addClass(parent, 'abc'); + expect(capturedAnimationHistory.length).toBe(0); + $rootScope.$digest(); + expect(capturedAnimationHistory.length).toBe(2); }); + }); - $animate.enter(child, element); - $$rAF.flush(); - - expect(itsOver).toBe(false); - $rootScope.$digest(); - - expect(parent).toHaveClass('abc'); - expect(itsOver).toBe(true); - })); - - it('should immediately end a parent class-based form animation if a structural child is active', - inject(function($rootScope, $animate, $rootElement, $$rAF, $$AnimateRunner) { - - parent.remove(); - element.remove(); - - parent = jqLite('
'); - $rootElement.append(parent); - - element = jqLite(''); - - $animate.addClass(parent, 'abc'); - $rootScope.$digest(); - - // we do this since the old runner was already closed - overriddenAnimationRunner = new $$AnimateRunner(); + they('should not cancel a post-digest parent class-based animation if a child $prop animation is set to run', + ['structural', 'class-based'], function(animationType) { - $animate.enter(element, parent); - $rootScope.$digest(); + inject(function($rootScope, $animate, $$rAF) { + parent.append(element); + var child = jqLite('
'); - $$rAF.flush(); + $animate.addClass(parent, 'abc'); + $rootScope.$digest(); - expect(parent.attr('data-ng-animate')).toBeFalsy(); - expect(element.attr('data-ng-animate')).toBeTruthy(); - })); + if (animationType === 'structural') { + $animate.enter(child, element); + } else { + element.append(child); + $animate.addClass(child, 'test'); + } - it('should not end a pre-digest parent animation if it does not have any classes to add/remove', - inject(function($rootScope, $animate, $$rAF) { + expect(capturedAnimationHistory.length).toBe(1); - parent.append(element); - var child = jqLite('
'); - var runner = $animate.animate(parent, - { height:'0px' }, - { height:'100px' }); - - var doneCount = 0; - runner.done(function() { - doneCount++; - }); + $rootScope.$digest(); - var runner2 = $animate.enter(child, element); - runner2.done(function() { - doneCount++; + expect(capturedAnimationHistory.length).toBe(2); }); + }); - $rootScope.$digest(); - $$rAF.flush(); - - expect(doneCount).toBe(0); - })); - - it('should immediately end a parent class-based animation if a structural child is active', - inject(function($rootScope, $rootElement, $animate) { + they('should not cancel a post-digest $prop child animation if a class-based parent animation is set to run', + ['structural', 'class-based'], function(animationType) { - parent.append(element); - var child = jqLite('
'); + inject(function($rootScope, $animate, $$rAF) { + parent.append(element); - var isCancelled = false; - overriddenAnimationRunner = extend(defaultFakeAnimationRunner, { - end: function() { - isCancelled = true; + var child = jqLite('
'); + if (animationType === 'structural') { + $animate.enter(child, element); + } else { + element.append(child); + $animate.addClass(child, 'test'); } - }); - $animate.addClass(parent, 'abc'); - $rootScope.$digest(); + $rootScope.$digest(); - // restore the default - overriddenAnimationRunner = defaultFakeAnimationRunner; + $animate.addClass(parent, 'abc'); - $animate.enter(child, element); - $rootScope.$digest(); + expect(capturedAnimationHistory.length).toBe(1); + $rootScope.$digest(); - expect(isCancelled).toBe(true); - })); + expect(capturedAnimationHistory.length).toBe(2); + }); + }); }); it("should NOT clobber all data on an element when animation is finished", @@ -1017,7 +1059,7 @@ describe("animations", function() { expect(doneHandler).toHaveBeenCalled(); })); - it('should skip the class-based animation entirely if there is an active structural animation', + it('should immediately skip the class-based animation if there is an active structural animation', inject(function($animate, $rootScope) { $animate.enter(element, parent); @@ -1026,10 +1068,25 @@ describe("animations", function() { capturedAnimation = null; $animate.addClass(element, 'red'); - $rootScope.$digest(); expect(element).toHaveClass('red'); })); + it('should join the class-based animation into the structural animation if the structural animation is pre-digest', + inject(function($animate, $rootScope) { + + $animate.enter(element, parent); + expect(capturedAnimation).toBeFalsy(); + + $animate.addClass(element, 'red'); + expect(element).not.toHaveClass('red'); + + expect(capturedAnimation).toBeFalsy(); + $rootScope.$digest(); + + expect(capturedAnimation[1]).toBe('enter'); + expect(capturedAnimation[2].addClass).toBe('red'); + })); + it('should issue a new runner instance if a previous structural animation was cancelled', inject(function($animate, $rootScope) { diff --git a/test/ngAnimate/animationSpec.js b/test/ngAnimate/animationSpec.js index 8153a572f8a1..4eab910a1cbb 100644 --- a/test/ngAnimate/animationSpec.js +++ b/test/ngAnimate/animationSpec.js @@ -406,6 +406,28 @@ describe('$$animation', function() { args = removeListen.mostRecentCall.args[0]; expect(args).toBe('$destroy'); })); + + it('should always sort parent-element animations to run in order of parent-to-child DOM structure', + inject(function($$animation, $$rAF, $rootScope) { + + var child = jqLite('
'); + var grandchild = jqLite('
'); + + element.append(child); + child.append(grandchild); + + $$animation(grandchild, 'enter'); + $$animation(child, 'enter'); + $$animation(element, 'enter'); + + expect(captureLog.length).toBe(0); + + $rootScope.$digest(); + + expect(captureLog[0].element).toBe(element); + expect(captureLog[1].element).toBe(child); + expect(captureLog[2].element).toBe(grandchild); + })); }); describe("grouped", function() { @@ -678,6 +700,36 @@ describe('$$animation', function() { expect(runnerLog).toEqual([]); })); + + it('should prepare a parent-element animation to run first before the anchored animation', + inject(function($$animation, $$rAF, $rootScope, $rootElement) { + + fromAnchors[0].attr('ng-animate-ref', 'shared'); + toAnchors[0].attr('ng-animate-ref', 'shared'); + + var parent = jqLite('
'); + parent.append(fromElement); + parent.append(toElement); + $rootElement.append(parent); + + fromElement.addClass('group-1'); + toElement.addClass('group-1'); + + // issued first + $$animation(toElement, 'enter'); + $$animation(fromElement, 'leave'); + + // issued second + $$animation(parent, 'addClass', { addClass: 'red' }); + + expect(captureLog.length).toBe(0); + + $rootScope.$digest(); + + expect(captureLog[0].element).toBe(parent); + expect(captureLog[1].from.element).toBe(fromElement); + expect(captureLog[1].to.element).toBe(toElement); + })); }); }); diff --git a/test/ngAnimate/integrationSpec.js b/test/ngAnimate/integrationSpec.js index f735cdbb3724..d448dc3b2d85 100644 --- a/test/ngAnimate/integrationSpec.js +++ b/test/ngAnimate/integrationSpec.js @@ -4,7 +4,7 @@ describe('ngAnimate integration tests', function() { beforeEach(module('ngAnimate')); - var html, ss; + var element, html, ss; beforeEach(module(function() { return function($rootElement, $document, $$body, $window, $animate) { $animate.enabled(true); @@ -19,6 +19,11 @@ describe('ngAnimate integration tests', function() { }; })); + afterEach(function() { + dealoc(element); + ss.destroy(); + }); + describe('CSS animations', function() { if (!browserSupportsCssAnimations()) return; @@ -26,7 +31,7 @@ describe('ngAnimate integration tests', function() { ['enter', 'leave', 'move', 'addClass', 'removeClass', 'setClass'], function(event) { inject(function($animate, $compile, $rootScope, $rootElement, $$rAF) { - var element = jqLite('
'); + element = jqLite('
'); $compile(element)($rootScope); var className = 'klass'; @@ -132,6 +137,204 @@ describe('ngAnimate integration tests', function() { dealoc(element); })); + + it('should always synchronously add css classes in order for child animations to animate properly', + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + + ss.addRule('.animations-enabled .animate-me.ng-enter', 'transition:2s linear all;'); + + element = jqLite('
'); + var child = jqLite('
'); + + element.append(child); + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + + $rootScope.exp = true; + $rootScope.$digest(); + + child = element.find('div'); + + expect(element).toHaveClass('animations-enabled'); + expect(child).toHaveClass('ng-enter'); + + $$rAF.flush(); + + expect(child).toHaveClass('ng-enter-active'); + + browserTrigger(child, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2 }); + + expect(child).not.toHaveClass('ng-enter-active'); + expect(child).not.toHaveClass('ng-enter'); + })); + + it('should synchronously add/remove ng-class expressions in time for other animations to run on the same element', + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + + ss.addRule('.animate-me.ng-enter.on', 'transition:2s linear all;'); + + element = jqLite('
'); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + + $rootScope.exp = true; + $rootScope.$digest(); + $$rAF.flush(); + + var child = element.find('div'); + + expect(child).not.toHaveClass('on'); + expect(child).not.toHaveClass('ng-enter'); + + $rootScope.exp = false; + $rootScope.$digest(); + + $rootScope.exp = true; + $rootScope.exp2 = true; + $rootScope.$digest(); + + child = element.find('div'); + + expect(child).toHaveClass('on'); + expect(child).toHaveClass('ng-enter'); + + $$rAF.flush(); + + expect(child).toHaveClass('ng-enter-active'); + + browserTrigger(child, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2 }); + + expect(child).not.toHaveClass('ng-enter-active'); + expect(child).not.toHaveClass('ng-enter'); + })); + + it('should animate ng-class and a structural animation in parallel on the same element', + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + + ss.addRule('.animate-me.ng-enter', 'transition:2s linear all;'); + ss.addRule('.animate-me.expand', 'transition:5s linear all; font-size:200px;'); + + element = jqLite('
'); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + + $rootScope.exp = true; + $rootScope.exp2 = true; + $rootScope.$digest(); + + var child = element.find('div'); + + expect(child).toHaveClass('ng-enter'); + expect(child).toHaveClass('expand-add'); + expect(child).toHaveClass('expand'); + + $$rAF.flush(); + + expect(child).toHaveClass('ng-enter-active'); + expect(child).toHaveClass('expand-add-active'); + + browserTrigger(child, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2 }); + + expect(child).not.toHaveClass('ng-enter-active'); + expect(child).not.toHaveClass('ng-enter'); + expect(child).not.toHaveClass('expand-add-active'); + expect(child).not.toHaveClass('expand-add'); + })); + + it('should only issue a reflow for each parent CSS class change that contains ready-to-fire child animations', function() { + module('ngAnimateMock'); + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + element = jqLite( + '
' + + '
' + + '
' + + '{{ item }}' + + '
' + + '
' + + '
' + ); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + $rootScope.$digest(); + expect($animate.reflows).toBe(0); + + $rootScope.exp = true; + $rootScope.items = [1,2,3,4,5,6,7,8,9,10]; + + $rootScope.$digest(); + expect($animate.reflows).toBe(2); + }); + }); + + it('should issue a reflow for each parent class-based animation that contains active child animations', function() { + module('ngAnimateMock'); + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + element = jqLite( + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + ); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + $rootScope.$digest(); + expect($animate.reflows).toBe(0); + + $rootScope.exp = true; + $rootScope.$digest(); + expect($animate.reflows).toBe(2); + }); + }); + + it('should not issue any reflows for class-based animations if none of them have children with queued animations', function() { + module('ngAnimateMock'); + inject(function($animate, $compile, $rootScope, $rootElement, $$rAF, $document) { + element = jqLite( + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + ); + + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + $compile(element)($rootScope); + $rootScope.$digest(); + expect($animate.reflows).toBe(0); + + $rootScope.exp = true; + $rootScope.$digest(); + expect($animate.reflows).toBe(0); + + $rootScope.exp2 = true; + $rootScope.$digest(); + expect($animate.reflows).toBe(0); + }); + }); }); describe('JS animations', function() { @@ -155,7 +358,7 @@ describe('ngAnimate integration tests', function() { }); inject(function($animate, $compile, $rootScope, $rootElement, $$rAF) { - var element = jqLite('
'); + element = jqLite('
'); $compile(element)($rootScope); var className = 'klass'; @@ -213,5 +416,133 @@ describe('ngAnimate integration tests', function() { expect(animationCompleted).toBe(true); }); }); + + they('should not wait for a parent\'s classes to resolve if a $prop is animation used for children', + ['beforeAddClass', 'beforeRemoveClass', 'beforeSetClass'], function(phase) { + + var capturedChildClasses; + var endParentAnimationFn; + + module(function($animateProvider) { + $animateProvider.register('.parent-man', function() { + var animateFactory = {}; + animateFactory[phase] = function(element, addClass, removeClass, done) { + // this will wait until things are over + endParentAnimationFn = done; + }; + return animateFactory; + }); + + $animateProvider.register('.child-man', function() { + return { + enter: function(element, done) { + capturedChildClasses = element.parent().attr('class'); + done(); + } + }; + }); + }); + + inject(function($animate, $compile, $rootScope, $rootElement) { + element = jqLite('
'); + var child = jqLite('
'); + + html(element); + $compile(element)($rootScope); + + $animate.enter(child, element); + switch (phase) { + case 'beforeAddClass': + $animate.addClass(element, 'cool'); + break; + + case 'beforeSetClass': + $animate.setClass(element, 'cool'); + break; + + case 'beforeRemoveClass': + element.addClass('cool'); + $animate.removeClass(element, 'cool'); + break; + } + + $rootScope.$digest(); + + expect(endParentAnimationFn).toBeTruthy(); + + // the spaces are used so that ` cool ` can be matched instead + // of just a substring like `cool-add`. + var safeClassMatchString = ' ' + capturedChildClasses + ' '; + if (phase === 'beforeRemoveClass') { + expect(safeClassMatchString).toContain(' cool '); + } else { + expect(safeClassMatchString).not.toContain(' cool '); + } + }); + }); + + they('should have the parent\'s classes already applied in time for the children if $prop is used', + ['addClass', 'removeClass', 'setClass'], function(phase) { + + var capturedChildClasses; + var endParentAnimationFn; + + module(function($animateProvider) { + $animateProvider.register('.parent-man', function() { + var animateFactory = {}; + animateFactory[phase] = function(element, addClass, removeClass, done) { + // this will wait until things are over + endParentAnimationFn = done; + }; + return animateFactory; + }); + + $animateProvider.register('.child-man', function() { + return { + enter: function(element, done) { + capturedChildClasses = element.parent().attr('class'); + done(); + } + }; + }); + }); + + inject(function($animate, $compile, $rootScope, $rootElement) { + element = jqLite('
'); + var child = jqLite('
'); + + html(element); + $compile(element)($rootScope); + + $animate.enter(child, element); + switch (phase) { + case 'addClass': + $animate.addClass(element, 'cool'); + break; + + case 'setClass': + $animate.setClass(element, 'cool'); + break; + + case 'removeClass': + element.addClass('cool'); + $animate.removeClass(element, 'cool'); + break; + } + + $rootScope.$digest(); + + expect(endParentAnimationFn).toBeTruthy(); + + // the spaces are used so that ` cool ` can be matched instead + // of just a substring like `cool-add`. + var safeClassMatchString = ' ' + capturedChildClasses + ' '; + if (phase === 'removeClass') { + expect(safeClassMatchString).not.toContain(' cool '); + } else { + expect(safeClassMatchString).toContain(' cool '); + } + }); + }); }); });