From 9cae05ee6b06e910bbcf5a94ea02493f3961c303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 25 Mar 2014 11:57:31 -0400 Subject: [PATCH] fix($animate): make CSS blocking optional for class-based animations $animate attempts places a `transition: none 0s` block on the element when the first CSS class is applied if a transition animation is underway. This works fine for structural animations (enter, leave and move), however, for class-based animations, this poses a big problem. As of this patch, instead of $animate placing the block, it is now the responsibility of the user to place `transition: 0s none` into their class-based transition setup CSS class. This way the animation will avoid all snapping and any will allow $animate to play nicely with class-based transitions that are defined outside of ngAnimate. Closes #6674 Closes #6739 BREAKING CHANGE: Any class-based animation code that makes use of transitions and uses the setup CSS classes (such as class-add and class-remove) must now provide a empty transition value to ensure that its styling is applied right away. In other words if your animation code is expecting any styling to be applied that is defined in the setup class then it will not be applied "instantly" default unless a `transition:0s none` value is present in the styling for that CSS class. This situation is only the case if a transition is already present on the base CSS class once the animation kicks off. --- css/angular.css | 5 - src/ngAnimate/animate.js | 194 +++++++++++----------------------- test/ngAnimate/animateSpec.js | 94 ++++++++++++++-- 3 files changed, 149 insertions(+), 144 deletions(-) diff --git a/css/angular.css b/css/angular.css index 2566640ebb2f..b88e61e483e2 100644 --- a/css/angular.css +++ b/css/angular.css @@ -9,8 +9,3 @@ ng\:form { display: block; } - -.ng-animate-block-transitions { - transition:0s all!important; - -webkit-transition:0s all!important; -} diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 542b678f8316..efdfb4138b8d 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -1020,8 +1020,11 @@ angular.module('ngAnimate', ['ng']) if(parentElement.length === 0) break; var isRoot = isMatchingElement(parentElement, $rootElement); - var state = isRoot ? rootAnimateState : parentElement.data(NG_ANIMATE_STATE); - var result = state && (!!state.disabled || state.running || state.totalActive > 0); + var state = isRoot ? rootAnimateState : (parentElement.data(NG_ANIMATE_STATE) || {}); + var result = state.disabled || state.running + ? true + : state.last && !state.last.isClassBased; + if(isRoot || result) { return result; } @@ -1071,7 +1074,6 @@ angular.module('ngAnimate', ['ng']) var ANIMATION_ITERATION_COUNT_KEY = 'IterationCount'; var NG_ANIMATE_PARENT_KEY = '$$ngAnimateKey'; var NG_ANIMATE_CSS_DATA_KEY = '$$ngAnimateCSS3Data'; - var NG_ANIMATE_BLOCK_CLASS_NAME = 'ng-animate-block-transitions'; var ELAPSED_TIME_MAX_DECIMAL_PLACES = 3; var CLOSING_TIME_BUFFER = 1.5; var ONE_SECOND = 1000; @@ -1211,7 +1213,9 @@ angular.module('ngAnimate', ['ng']) return parentID + '-' + extractElementNode(element).className; } - function animateSetup(animationEvent, element, className, calculationDecorator) { + function animateSetup(animationEvent, element, className) { + var structural = ['ng-enter','ng-leave','ng-move'].indexOf(className) >= 0; + var cacheKey = getCacheKey(element); var eventCacheKey = cacheKey + ' ' + className; var itemIndex = lookupCache[eventCacheKey] ? ++lookupCache[eventCacheKey].total : 0; @@ -1229,85 +1233,44 @@ angular.module('ngAnimate', ['ng']) applyClasses && element.removeClass(staggerClassName); } - /* the animation itself may need to add/remove special CSS classes - * before calculating the anmation styles */ - calculationDecorator = calculationDecorator || - function(fn) { return fn(); }; - element.addClass(className); var formerData = element.data(NG_ANIMATE_CSS_DATA_KEY) || {}; - - var timings = calculationDecorator(function() { - return getElementAnimationDetails(element, eventCacheKey); - }); - + var timings = getElementAnimationDetails(element, eventCacheKey); var transitionDuration = timings.transitionDuration; var animationDuration = timings.animationDuration; - if(transitionDuration === 0 && animationDuration === 0) { + + if(structural && transitionDuration == 0 && animationDuration == 0) { element.removeClass(className); return false; - } + }; + + var blockTransition = structural && transitionDuration > 0; + var blockAnimation = animationDuration > 0 && + stagger.animationDelay > 0 && + stagger.animationDuration === 0; element.data(NG_ANIMATE_CSS_DATA_KEY, { + stagger : stagger, + cacheKey : eventCacheKey, running : formerData.running || 0, itemIndex : itemIndex, - stagger : stagger, - timings : timings, + blockTransition : blockTransition, + blockAnimation : blockAnimation, closeAnimationFn : noop }); - //temporarily disable the transition so that the enter styles - //don't animate twice (this is here to avoid a bug in Chrome/FF). - var isCurrentlyAnimating = formerData.running > 0 || animationEvent == 'setClass'; - if(transitionDuration > 0) { - blockTransitions(element, className, isCurrentlyAnimating); - } - - //staggering keyframe animations work by adjusting the `animation-delay` CSS property - //on the given element, however, the delay value can only calculated after the reflow - //since by that time $animate knows how many elements are being animated. Therefore, - //until the reflow occurs the element needs to be blocked (where the keyframe animation - //is set to `none 0s`). This blocking mechanism should only be set for when a stagger - //animation is detected and when the element item index is greater than 0. - if(animationDuration > 0 && stagger.animationDelay > 0 && stagger.animationDuration === 0) { - blockKeyframeAnimations(element); - } - - return true; - } - - function isStructuralAnimation(className) { - return className == 'ng-enter' || className == 'ng-move' || className == 'ng-leave'; - } + var node = extractElementNode(element); - function blockTransitions(element, className, isAnimating) { - if(isStructuralAnimation(className) || !isAnimating) { - extractElementNode(element).style[TRANSITION_PROP + PROPERTY_KEY] = 'none'; - } else { - element.addClass(NG_ANIMATE_BLOCK_CLASS_NAME); + if(blockTransition) { + node.style[TRANSITION_PROP + PROPERTY_KEY] = 'none'; } - } - function blockKeyframeAnimations(element) { - extractElementNode(element).style[ANIMATION_PROP] = 'none 0s'; - } - - function unblockTransitions(element, className) { - var prop = TRANSITION_PROP + PROPERTY_KEY; - var node = extractElementNode(element); - if(node.style[prop] && node.style[prop].length > 0) { - node.style[prop] = ''; + if(blockAnimation) { + node.style[ANIMATION_PROP] = 'none 0s'; } - element.removeClass(NG_ANIMATE_BLOCK_CLASS_NAME); - } - function unblockKeyframeAnimations(element) { - var prop = ANIMATION_PROP; - var node = extractElementNode(element); - if(node.style[prop] && node.style[prop].length > 0) { - node.style[prop] = ''; - } + return true; } function animateRun(animationEvent, element, className, activeAnimationComplete) { @@ -1318,21 +1281,36 @@ angular.module('ngAnimate', ['ng']) return; } + if(elementData.blockTransition) { + node.style[TRANSITION_PROP + PROPERTY_KEY] = ''; + } + + if(elementData.blockAnimation) { + node.style[ANIMATION_PROP] = ''; + } + var activeClassName = ''; forEach(className.split(' '), function(klass, i) { activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; }); - var stagger = elementData.stagger; - var timings = elementData.timings; - var itemIndex = elementData.itemIndex; + element.addClass(activeClassName); + var eventCacheKey = elementData.eventCacheKey + ' ' + activeClassName; + var timings = getElementAnimationDetails(element, eventCacheKey); + var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); + if(maxDuration == 0) { + element.removeClass(activeClassName); + animateClose(element, className); + activeAnimationComplete(); + return; + } + var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay); + var stagger = elementData.stagger; + var itemIndex = elementData.itemIndex; var maxDelayTime = maxDelay * ONE_SECOND; - var startTime = Date.now(); - var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; - var style = '', appliedStyles = []; if(timings.transitionDuration > 0) { var propertyStyle = timings.transitionPropertyStyle; @@ -1367,8 +1345,10 @@ angular.module('ngAnimate', ['ng']) node.setAttribute('style', oldStyle + ' ' + style); } + var startTime = Date.now(); + var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; + element.on(css3AnimationEvents, onAnimationProgress); - element.addClass(activeClassName); elementData.closeAnimationFn = function() { onEnd(); activeAnimationComplete(); @@ -1460,8 +1440,6 @@ angular.module('ngAnimate', ['ng']) //happen in the first place var cancel = preReflowCancellation; afterReflow(element, function() { - unblockTransitions(element, className); - unblockKeyframeAnimations(element); //once the reflow is complete then we point cancel to //the new cancellation function which will remove all of the //animation properties from the active animation @@ -1502,49 +1480,27 @@ angular.module('ngAnimate', ['ng']) beforeSetClass : function(element, add, remove, animationCompleted) { var className = suffixClasses(remove, '-remove') + ' ' + suffixClasses(add, '-add'); - var cancellationMethod = animateBefore('setClass', element, className, function(fn) { - /* when classes are removed from an element then the transition style - * that is applied is the transition defined on the element without the - * CSS class being there. This is how CSS3 functions outside of ngAnimate. - * http://plnkr.co/edit/j8OzgTNxHTb4n3zLyjGW?p=preview */ - var klass = element.attr('class'); - element.removeClass(remove); - element.addClass(add); - var timings = fn(); - element.attr('class', klass); - return timings; - }); - + var cancellationMethod = animateBefore('setClass', element, className); if(cancellationMethod) { - afterReflow(element, function() { - unblockTransitions(element, className); - unblockKeyframeAnimations(element); - animationCompleted(); - }); + afterReflow(element, animationCompleted); return cancellationMethod; } animationCompleted(); }, beforeAddClass : function(element, className, animationCompleted) { - var cancellationMethod = animateBefore('addClass', element, suffixClasses(className, '-add'), function(fn) { - - /* when a CSS class is added to an element then the transition style that - * is applied is the transition defined on the element when the CSS class - * is added at the time of the animation. This is how CSS3 functions - * outside of ngAnimate. */ - element.addClass(className); - var timings = fn(); - element.removeClass(className); - return timings; - }); + var cancellationMethod = animateBefore('addClass', element, suffixClasses(className, '-add')); + if(cancellationMethod) { + afterReflow(element, animationCompleted); + return cancellationMethod; + } + animationCompleted(); + }, + beforeRemoveClass : function(element, className, animationCompleted) { + var cancellationMethod = animateBefore('removeClass', element, suffixClasses(className, '-remove')); if(cancellationMethod) { - afterReflow(element, function() { - unblockTransitions(element, className); - unblockKeyframeAnimations(element); - animationCompleted(); - }); + afterReflow(element, animationCompleted); return cancellationMethod; } animationCompleted(); @@ -1561,30 +1517,6 @@ angular.module('ngAnimate', ['ng']) return animateAfter('addClass', element, suffixClasses(className, '-add'), animationCompleted); }, - beforeRemoveClass : function(element, className, animationCompleted) { - var cancellationMethod = animateBefore('removeClass', element, suffixClasses(className, '-remove'), function(fn) { - /* when classes are removed from an element then the transition style - * that is applied is the transition defined on the element without the - * CSS class being there. This is how CSS3 functions outside of ngAnimate. - * http://plnkr.co/edit/j8OzgTNxHTb4n3zLyjGW?p=preview */ - var klass = element.attr('class'); - element.removeClass(className); - var timings = fn(); - element.attr('class', klass); - return timings; - }); - - if(cancellationMethod) { - afterReflow(element, function() { - unblockTransitions(element, className); - unblockKeyframeAnimations(element); - animationCompleted(); - }); - return cancellationMethod; - } - animationCompleted(); - }, - removeClass : function(element, className, animationCompleted) { return animateAfter('removeClass', element, suffixClasses(className, '-remove'), animationCompleted); } diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index 204ca9c32114..de728a586514 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -112,11 +112,13 @@ describe("ngAnimate", function() { angular.element(document.body).append($rootElement); $animate.addClass(elm1, 'klass'); + $animate.triggerReflow(); expect(count).toBe(1); $animate.enabled(false); $animate.addClass(elm1, 'klass2'); + $animate.triggerReflow(); expect(count).toBe(1); $animate.enabled(true); @@ -124,16 +126,19 @@ describe("ngAnimate", function() { elm1.append(elm2); $animate.addClass(elm2, 'klass'); + $animate.triggerReflow(); expect(count).toBe(2); $animate.enabled(false, elm1); $animate.addClass(elm2, 'klass2'); + $animate.triggerReflow(); expect(count).toBe(2); var root = angular.element($rootElement[0]); $rootElement.addClass('animated'); $animate.addClass(root, 'klass2'); + $animate.triggerReflow(); expect(count).toBe(3); }); }); @@ -188,12 +193,14 @@ describe("ngAnimate", function() { expect(captured).toBe(false); $animate.addClass(element, 'red'); + $animate.triggerReflow(); expect(captured).toBe(true); captured = false; $animate.enabled(false); $animate.addClass(element, 'blue'); + $animate.triggerReflow(); expect(captured).toBe(false); //clean up the mess @@ -392,6 +399,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { child.attr('class','classify no'); $animate.setClass(child, 'yes', 'no'); + $animate.triggerReflow(); expect(child.hasClass('yes')).toBe(true); expect(child.hasClass('no')).toBe(false); @@ -433,6 +441,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $sniffer, $timeout) { child.attr('class','classify no'); $animate.setClass(child, 'yes', 'no'); + $animate.triggerReflow(); expect(child.hasClass('yes')).toBe(true); expect(child.hasClass('no')).toBe(false); @@ -647,6 +656,7 @@ describe("ngAnimate", function() { $animate.addClass(child, 'custom-delay'); $animate.addClass(child, 'custom-long-delay'); + $animate.triggerReflow(); expect(child.hasClass('animation-cancelled')).toBe(false); expect(child.hasClass('animation-ended')).toBe(false); @@ -672,6 +682,7 @@ describe("ngAnimate", function() { inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { $animate.addClass(element, 'custom-delay custom-long-delay'); + $animate.triggerReflow(); $timeout.flush(2000); $timeout.flush(20000); expect(element.hasClass('custom-delay')).toBe(true); @@ -1183,6 +1194,53 @@ describe("ngAnimate", function() { } })); + it("should place a hard block when a structural CSS transition is run", + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + + if(!$sniffer.transitions) return; + + ss.addRule('.leave-animation.ng-leave', + '-webkit-transition:5s linear all;' + + 'transition:5s linear all;' + + 'opacity:1;'); + + ss.addRule('.leave-animation.ng-leave.ng-leave-active', 'opacity:1'); + + element = $compile(html('
1
'))($rootScope); + + $animate.leave(element); + $rootScope.$digest(); + + expect(element.attr('style')).toMatch(/transition:\s*none/); + + $animate.triggerReflow(); + + expect(element.attr('style')).not.toMatch(/transition:\s*none/); + })); + + it("should not place a hard block when a class-based CSS transition is run", + inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { + + if(!$sniffer.transitions) return; + + ss.addRule('.my-class', '-webkit-transition:5s linear all;' + + 'transition:5s linear all;'); + + element = $compile(html('
1
'))($rootScope); + + $animate.addClass(element, 'my-class'); + + 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(); + + expect(element.attr('style')).not.toMatch(/transition:\s*none/); + expect(element.hasClass('my-class')).toBe(true); + expect(element.hasClass('my-class-add')).toBe(true); + expect(element.hasClass('my-class-add-active')).toBe(true); + })); it("should stagger the items when the correct CSS class is provided", inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) { @@ -1650,10 +1708,12 @@ describe("ngAnimate", function() { $animate.addClass(element, 'on', function() { signature += 'A'; }); + $animate.triggerReflow(); $animate.removeClass(element, 'on', function() { signature += 'B'; }); + $animate.triggerReflow(); $animate.triggerCallbacks(); @@ -1676,6 +1736,7 @@ describe("ngAnimate", function() { signature += 'Z'; }); + $animate.triggerReflow(); $animate.triggerCallbacks(); expect(signature).toBe('Z'); @@ -1917,13 +1978,13 @@ describe("ngAnimate", function() { //actual animations captured = 'none'; $animate.removeClass(element, 'some-class'); - $timeout.flush(); + $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(false); expect(captured).toBe('removeClass-some-class'); captured = 'nothing'; $animate.addClass(element, 'some-class'); - $timeout.flush(); + $animate.triggerReflow(); expect(element.hasClass('some-class')).toBe(true); expect(captured).toBe('addClass-some-class'); })); @@ -1938,10 +1999,12 @@ describe("ngAnimate", function() { var element = jqLite(parent.find('span')); $animate.addClass(element,'klass'); + $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(true); $animate.removeClass(element,'klass'); + $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(false); expect(element.hasClass('klass-remove')).toBe(false); @@ -1962,12 +2025,14 @@ describe("ngAnimate", function() { $animate.addClass(element,'klass', function() { signature += 'A'; }); + $animate.triggerReflow(); expect(element.hasClass('klass')).toBe(true); $animate.removeClass(element,'klass', function() { signature += 'B'; }); + $animate.triggerReflow(); $animate.triggerCallbacks(); expect(element.hasClass('klass')).toBe(false); @@ -2040,6 +2105,7 @@ describe("ngAnimate", function() { $animate.addClass(element,'klassy', function() { signature += 'X'; }); + $animate.triggerReflow(); $timeout.flush(500); @@ -2048,6 +2114,7 @@ describe("ngAnimate", function() { $animate.removeClass(element,'klassy', function() { signature += 'Y'; }); + $animate.triggerReflow(); $timeout.flush(3000); @@ -2546,12 +2613,15 @@ describe("ngAnimate", function() { var element = html($compile('
')($rootScope)); $animate.addClass(element, 'super'); + $animate.triggerReflow(); expect(element.data('classify')).toBe('add-super'); $animate.removeClass(element, 'super'); + $animate.triggerReflow(); expect(element.data('classify')).toBe('remove-super'); $animate.addClass(element, 'superguy'); + $animate.triggerReflow(); expect(element.data('classify')).toBe('add-superguy'); }); }); @@ -2756,12 +2826,14 @@ describe("ngAnimate", function() { $animate.enabled(true, element); $animate.addClass(child, 'awesome'); + $animate.triggerReflow(); expect(childAnimated).toBe(true); childAnimated = false; $animate.enabled(false, element); $animate.addClass(child, 'super'); + $animate.triggerReflow(); expect(childAnimated).toBe(false); $animate.leave(element); @@ -2771,7 +2843,7 @@ describe("ngAnimate", function() { }); - it("should disable all child animations on structural animations until the post animation" + + it("should disable all child animations on structural animations until the post animation " + "timeout has passed as well as all structural animations", function() { var intercepted, continueAnimation; module(function($animateProvider) { @@ -2820,6 +2892,7 @@ describe("ngAnimate", function() { continueAnimation(); $animate.addClass(child1, 'test'); + $animate.triggerReflow(); expect(child1.hasClass('test')).toBe(true); expect(element.children().length).toBe(2); @@ -2877,6 +2950,7 @@ describe("ngAnimate", function() { $rootScope.bool = true; $rootScope.$digest(); + expect(intercepted).toBe(true); }); }); @@ -3014,9 +3088,13 @@ describe("ngAnimate", function() { $rootElement.addClass('animated'); $animate.addClass($rootElement, 'green'); + $animate.triggerReflow(); + expect(count).toBe(1); $animate.addClass($rootElement, 'red'); + $animate.triggerReflow(); + expect(count).toBe(2); }); }); @@ -3045,6 +3123,7 @@ describe("ngAnimate", function() { $rootElement.append(element); $animate.addClass(element, 'red'); + $animate.triggerReflow(); expect(steps).toEqual(['before','after']); }); @@ -3102,20 +3181,18 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'base-class one two'); //still true since we're before the reflow - expect(element.hasClass('base-class')).toBe(false); + expect(element.hasClass('base-class')).toBe(true); //this will cancel the remove animation $animate.addClass(element, 'base-class one two'); - //the cancellation was a success and the class was added right away - //since there was no successive animation for the after animation + //the cancellation was a success and the class was removed right away expect(element.hasClass('base-class')).toBe(false); //the reflow... $animate.triggerReflow(); - //the reflow DOM operation was commenced but it ran before so it - //shouldn't run agaun + //the reflow DOM operation was commenced... expect(element.hasClass('base-class')).toBe(true); })); @@ -3460,6 +3537,7 @@ describe("ngAnimate", function() { ready = true; }); + $animate.triggerReflow(); $animate.triggerCallbacks(); expect(ready).toBe(true); }));