From 4f84f6b3e4210ae1eb14728a46d43dd961700a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Fri, 14 Feb 2014 04:02:46 -0500 Subject: [PATCH] fix($animate): ensure $animate doesn't break natural CSS transitions BREAKING CHANGE: ngClass and {{ class }} will now call the `setClass` animation callback instead of addClass / removeClass when both a addClass/removeClass operation is being executed on the element during the animation. Please include the setClass animation callback as well as addClass and removeClass within your JS animations to work with ngClass and {{ class }} directives. Closes #6019 --- css/angular.css | 5 + src/ng/animate.js | 23 ++ src/ng/compile.js | 12 +- src/ngAnimate/animate.js | 491 ++++++++++++++++----------- src/ngMock/angular-mocks.js | 3 +- test/ng/compileSpec.js | 6 +- test/ng/directive/ngClassSpec.js | 10 +- test/ngAnimate/animateSpec.js | 71 +--- test/ngRoute/directive/ngViewSpec.js | 3 +- 9 files changed, 344 insertions(+), 280 deletions(-) diff --git a/css/angular.css b/css/angular.css index b88e61e483e2..2566640ebb2f 100644 --- a/css/angular.css +++ b/css/angular.css @@ -9,3 +9,8 @@ ng\:form { display: block; } + +.ng-animate-block-transitions { + transition:0s all!important; + -webkit-transition:0s all!important; +} diff --git a/src/ng/animate.js b/src/ng/animate.js index fa5b936d7ed5..1961d47be1f3 100644 --- a/src/ng/animate.js +++ b/src/ng/animate.js @@ -222,6 +222,29 @@ var $AnimateProvider = ['$provide', function($provide) { done && $timeout(done, 0, false); }, + /** + * + * @ngdoc function + * @name ng.$animate#setClass + * @methodOf ng.$animate + * @function + * @description Adds and/or removes the given CSS classes to and from the element. + * Once complete, the done() callback will be fired (if provided). + * @param {jQuery/jqLite element} element the element which will it's CSS classes changed + * removed from it + * @param {string} add the CSS classes which will be added to the element + * @param {string} remove the CSS class which will be removed from the element + * @param {function=} done the callback function (if provided) that will be fired after the + * CSS classes have been set on the element + */ + setClass : function(element, add, remove, done) { + forEach(element, function (element) { + jqLiteAddClass(element, add); + jqLiteRemoveClass(element, remove); + }); + done && $timeout(done, 0, false); + }, + enabled : noop }; }]; diff --git a/src/ng/compile.js b/src/ng/compile.js index e4bf230ed68b..ded62ea9a210 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -690,8 +690,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { * @param {string} oldClasses The former CSS className value */ $updateClass : function(newClasses, oldClasses) { - this.$removeClass(tokenDifference(oldClasses, newClasses)); - this.$addClass(tokenDifference(newClasses, oldClasses)); + var toAdd = tokenDifference(newClasses, oldClasses); + var toRemove = tokenDifference(oldClasses, newClasses); + + if(toAdd.length === 0) { + $animate.removeClass(this.$$element, toRemove); + } else if(toRemove.length === 0) { + $animate.addClass(this.$$element, toAdd); + } else { + $animate.setClass(this.$$element, toAdd, toRemove); + } }, /** diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index dba8d3fa3cd1..28a1510a6ef3 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -248,7 +248,9 @@ angular.module('ngAnimate', ['ng']) * Please visit the {@link ngAnimate `ngAnimate`} module overview page learn more about how to use animations in your application. * */ - .factory('$$animateReflow', ['$window', '$timeout', function($window, $timeout) { + .factory('$$animateReflow', ['$window', '$timeout', '$document', + function($window, $timeout, $document) { + var bod = $document[0].body; var requestAnimationFrame = $window.requestAnimationFrame || $window.webkitRequestAnimationFrame || function(fn) { @@ -261,7 +263,10 @@ angular.module('ngAnimate', ['ng']) return $timeout.cancel(timer); }; return function(fn) { - var id = requestAnimationFrame(fn); + var id = requestAnimationFrame(function() { + var a = bod.offsetWidth + 1; + fn(); + }); return function() { cancelAnimationFrame(id); }; @@ -301,6 +306,10 @@ angular.module('ngAnimate', ['ng']) } } + function stripCommentsFromElement(element) { + return angular.element(extractElementNode(element)); + } + function isMatchingElement(elm1, elm2) { return extractElementNode(elm1) == extractElementNode(elm2); } @@ -411,6 +420,7 @@ angular.module('ngAnimate', ['ng']) this.enabled(false, element); $delegate.enter(element, parentElement, afterElement); $rootScope.$$postDigest(function() { + element = stripCommentsFromElement(element); performAnimation('enter', 'ng-enter', element, parentElement, afterElement, noop, doneCallback); }); }, @@ -447,6 +457,7 @@ angular.module('ngAnimate', ['ng']) cancelChildAnimations(element); this.enabled(false, element); $rootScope.$$postDigest(function() { + element = stripCommentsFromElement(element); performAnimation('leave', 'ng-leave', element, null, null, function() { $delegate.leave(element); }, doneCallback); @@ -489,6 +500,7 @@ angular.module('ngAnimate', ['ng']) this.enabled(false, element); $delegate.move(element, parentElement, afterElement); $rootScope.$$postDigest(function() { + element = stripCommentsFromElement(element); performAnimation('move', 'ng-move', element, parentElement, afterElement, noop, doneCallback); }); }, @@ -524,6 +536,7 @@ angular.module('ngAnimate', ['ng']) * @param {function()=} doneCallback the callback function that will be called once the animation is complete */ addClass : function(element, className, doneCallback) { + element = stripCommentsFromElement(element); performAnimation('addClass', className, element, null, null, function() { $delegate.addClass(element, className); }, doneCallback); @@ -560,11 +573,34 @@ angular.module('ngAnimate', ['ng']) * @param {function()=} doneCallback the callback function that will be called once the animation is complete */ removeClass : function(element, className, doneCallback) { + element = stripCommentsFromElement(element); performAnimation('removeClass', className, element, null, null, function() { $delegate.removeClass(element, className); }, doneCallback); }, + /** + * + * @ngdoc function + * @name ng.$animate#setClass + * @methodOf ng.$animate + * @function + * @description Adds and/or removes the given CSS classes to and from the element. + * Once complete, the done() callback will be fired (if provided). + * @param {jQuery/jqLite element} element the element which will it's CSS classes changed + * removed from it + * @param {string} add the CSS classes which will be added to the element + * @param {string} remove the CSS class which will be removed from the element + * @param {function=} done the callback function (if provided) that will be fired after the + * CSS classes have been set on the element + */ + setClass : function(element, add, remove, doneCallback) { + element = stripCommentsFromElement(element); + performAnimation('setClass', [add, remove], element, null, null, function() { + $delegate.setClass(element, add, remove); + }, doneCallback); + }, + /** * @ngdoc function * @name ngAnimate.$animate#enabled @@ -611,7 +647,15 @@ angular.module('ngAnimate', ['ng']) and the onComplete callback will be fired once the animation is fully complete. */ function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) { - var currentClassName, classes, node = extractElementNode(element); + + var classNameAdd, classNameRemove, setClassOperation = animationEvent == 'setClass'; + if(setClassOperation) { + classNameAdd = className[0]; + classNameRemove = className[1]; + className = classNameAdd + ' ' + classNameRemove; + } + + var currentClassName, classes, node = element[0]; if(node) { currentClassName = node.className; classes = currentClassName + ' ' + className; @@ -623,7 +667,7 @@ angular.module('ngAnimate', ['ng']) fireDOMOperation(); fireBeforeCallbackAsync(); fireAfterCallbackAsync(); - closeAnimation(); + fireDoneCallbackAsync(); return; } @@ -635,9 +679,15 @@ angular.module('ngAnimate', ['ng']) parentElement = afterElement ? afterElement.parent() : element.parent(); } - var matches = lookup(animationLookup); - var isClassBased = animationEvent == 'addClass' || animationEvent == 'removeClass'; - var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; + var matches = lookup(animationLookup); + var isClassBased = animationEvent == 'addClass' || + animationEvent == 'removeClass' || + setClassOperation; + var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; + + var runningAnimations = ngAnimateState.active || {}; + var totalActiveAnimations = ngAnimateState.totalActive || 0; + var lastAnimation = ngAnimateState.last; //skip the animation if animations are disabled, a parent is already being animated, //the element is not currently attached to the document body or then completely close @@ -656,7 +706,7 @@ angular.module('ngAnimate', ['ng']) //only add animations if the currently running animation is not structural //or if there is no animation running at all var allowAnimations = isClassBased ? - !ngAnimateState.disabled && (!ngAnimateState.running || !ngAnimateState.structural) : + !ngAnimateState.disabled && (!lastAnimation || lastAnimation.classBased) : true; if(allowAnimations) { @@ -691,54 +741,48 @@ angular.module('ngAnimate', ['ng']) return; } - var ONE_SPACE = ' '; - //this value will be searched for class-based CSS className lookup. Therefore, - //we prefix and suffix the current className value with spaces to avoid substring - //lookups of className tokens - var futureClassName = ONE_SPACE + currentClassName + ONE_SPACE; - if(ngAnimateState.running) { - //if an animation is currently running on the element then lets take the steps - //to cancel that animation and fire any required callbacks - cleanup(element); - cancelAnimations(ngAnimateState.animations); - - //in the event that the CSS is class is quickly added and removed back - //then we don't want to wait until after the reflow to add/remove the CSS - //class since both class animations may run into a race condition. - //The code below will check to see if that is occurring and will - //immediately remove the former class before the reflow so that the - //animation can snap back to the original animation smoothly - var isFullyClassBasedAnimation = isClassBased && !ngAnimateState.structural; - var isRevertingClassAnimation = isFullyClassBasedAnimation && - ngAnimateState.className == className && - animationEvent != ngAnimateState.event; - - //if the class is removed during the reflow then it will revert the styles temporarily - //back to the base class CSS styling causing a jump-like effect to occur. This check - //here ensures that the domOperation is only performed after the reflow has commenced - if(ngAnimateState.beforeComplete || isRevertingClassAnimation) { - (ngAnimateState.done || noop)(true); - } else if(isFullyClassBasedAnimation) { - //class-based animations will compare element className values after cancelling the - //previous animation to see if the element properties already contain the final CSS - //class and if so then the animation will be skipped. Since the domOperation will - //be performed only after the reflow is complete then our element's className value - //will be invalid. Therefore the same string manipulation that would occur within the - //DOM operation will be performed below so that the class comparison is valid... - futureClassName = ngAnimateState.event == 'removeClass' ? - futureClassName.replace(ONE_SPACE + ngAnimateState.className + ONE_SPACE, ONE_SPACE) : - futureClassName + ngAnimateState.className + ONE_SPACE; + var skipAnimation = false; + if(totalActiveAnimations > 0) { + var animationsToCancel = []; + if(!isClassBased) { + if(animationEvent == 'leave' && runningAnimations['ng-leave']) { + skipAnimation = true; + } else { + //cancel all animations when a structural animation takes place + for(var klass in runningAnimations) { + animationsToCancel.push(runningAnimations[klass]); + cleanup(element, klass); + } + runningAnimations = {}; + totalActiveAnimations = 0; + } + } else if(lastAnimation.event == 'setClass') { + animationsToCancel.push(lastAnimation); + cleanup(element, className); + } + else if(runningAnimations[className]) { + var current = runningAnimations[className]; + if(current.event == animationEvent) { + skipAnimation = true; + } else { + animationsToCancel.push(current); + cleanup(element, className); + } + } + + if(animationsToCancel.length > 0) { + angular.forEach(animationsToCancel, function(operation) { + (operation.done || noop)(true); + cancelAnimations(operation.animations); + }); } } - //There is no point in perform a class-based animation if the element already contains - //(on addClass) or doesn't contain (on removeClass) the className being animated. - //The reason why this is being called after the previous animations are cancelled - //is so that the CSS classes present on the element can be properly examined. - var classNameToken = ONE_SPACE + className + ONE_SPACE; - if((animationEvent == 'addClass' && futureClassName.indexOf(classNameToken) >= 0) || - (animationEvent == 'removeClass' && futureClassName.indexOf(classNameToken) == -1)) { - fireDOMOperation(); + if(isClassBased && !setClassOperation && !skipAnimation) { + skipAnimation = (animationEvent == 'addClass') == element.hasClass(className); //opposite of XOR + } + + if(skipAnimation) { fireBeforeCallbackAsync(); fireAfterCallbackAsync(); fireDoneCallbackAsync(); @@ -750,15 +794,21 @@ angular.module('ngAnimate', ['ng']) element.addClass(NG_ANIMATE_CLASS_NAME); var localAnimationCount = globalAnimationCounter++; + lastAnimation = { + classBased : isClassBased, + event : animationEvent, + animations : animations, + done:onBeforeAnimationsComplete + }; + + totalActiveAnimations++; + runningAnimations[className] = lastAnimation; element.data(NG_ANIMATE_STATE, { - running:true, - event:animationEvent, - className:className, - structural:!isClassBased, - animations:animations, - index:localAnimationCount, - done:onBeforeAnimationsComplete + last : lastAnimation, + active : runningAnimations, + index : localAnimationCount, + totalActive : totalActiveAnimations }); //first we run the before animations and when all of those are complete @@ -766,6 +816,11 @@ angular.module('ngAnimate', ['ng']) invokeRegisteredAnimationFns(animations, 'before', onBeforeAnimationsComplete); function onBeforeAnimationsComplete(cancelled) { + var data = element.data(NG_ANIMATE_STATE); + cancelled = cancelled || + !data || !data.active[className] || + (isClassBased && data.active[className].event != animationEvent); + fireDOMOperation(); if(cancelled === true) { closeAnimation(); @@ -775,11 +830,8 @@ angular.module('ngAnimate', ['ng']) //set the done function to the final done function //so that the DOM event won't be executed twice by accident //if the after animation is cancelled as well - var data = element.data(NG_ANIMATE_STATE); - if(data) { - data.done = closeAnimation; - element.data(NG_ANIMATE_STATE, data); - } + var currentAnimation = data.active[className]; + currentAnimation.done = closeAnimation; invokeRegisteredAnimationFns(animations, 'after', closeAnimation); } @@ -802,9 +854,13 @@ angular.module('ngAnimate', ['ng']) } if(animation[phase]) { - animation[endFnName] = isClassBased ? - animation[phase](element, className, animationPhaseCompleted) : - animation[phase](element, animationPhaseCompleted); + if(setClassOperation) { + animation[endFnName] = animation[phase](element, classNameAdd, classNameRemove, animationPhaseCompleted); + } else { + animation[endFnName] = isClassBased ? + animation[phase](element, className, animationPhaseCompleted) : + animation[phase](element, animationPhaseCompleted); + } } else { animationPhaseCompleted(); } @@ -872,12 +928,12 @@ angular.module('ngAnimate', ['ng']) failing would be when a parent HTML tag has a ng-class attribute causing ALL directives below to skip animations during the digest */ if(isClassBased) { - cleanup(element); + cleanup(element, className); } else { $$asyncQueueBuffer(function() { var data = element.data(NG_ANIMATE_STATE) || {}; if(localAnimationCount == data.index) { - cleanup(element); + cleanup(element, className, animationEvent); } }); element.data(NG_ANIMATE_STATE, data); @@ -893,9 +949,11 @@ angular.module('ngAnimate', ['ng']) forEach(node.querySelectorAll('.' + NG_ANIMATE_CLASS_NAME), function(element) { element = angular.element(element); var data = element.data(NG_ANIMATE_STATE); - if(data) { - cancelAnimations(data.animations); - cleanup(element); + if(data && data.active) { + angular.forEach(data.active, function(operation) { + (operation.done || noop)(true); + cancelAnimations(operation.animations); + }); } }); } @@ -912,15 +970,27 @@ angular.module('ngAnimate', ['ng']) }); } - function cleanup(element) { + function cleanup(element, className) { if(isMatchingElement(element, $rootElement)) { if(!rootAnimateState.disabled) { rootAnimateState.running = false; rootAnimateState.structural = false; } - } else { - element.removeClass(NG_ANIMATE_CLASS_NAME); - element.removeData(NG_ANIMATE_STATE); + } else if(className) { + var data = element.data(NG_ANIMATE_STATE) || {}; + + var removeAnimations = className === true; + if(!removeAnimations) { + if(data.active && data.active[className]) { + data.totalActive--; + delete data.active[className]; + } + } + + if(removeAnimations || !data.totalActive) { + element.removeClass(NG_ANIMATE_CLASS_NAME); + element.removeData(NG_ANIMATE_STATE); + } } } @@ -939,7 +1009,7 @@ angular.module('ngAnimate', ['ng']) var isRoot = isMatchingElement(parentElement, $rootElement); var state = isRoot ? rootAnimateState : parentElement.data(NG_ANIMATE_STATE); - var result = state && (!!state.disabled || !!state.running); + var result = state && (!!state.disabled || state.running || state.totalActive > 0); if(isRoot || result) { return result; } @@ -989,74 +1059,57 @@ 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; - var animationCounter = 0; var lookupCache = {}; var parentCounter = 0; var animationReflowQueue = []; - var animationElementQueue = []; var cancelAnimationReflow; - var closingAnimationTime = 0; - var timeOut = false; function afterReflow(element, callback) { if(cancelAnimationReflow) { cancelAnimationReflow(); } - animationReflowQueue.push(callback); - - var node = extractElementNode(element); - element = angular.element(node); - animationElementQueue.push(element); - - var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); - - var stagger = elementData.stagger; - var staggerTime = elementData.itemIndex * (Math.max(stagger.animationDelay, stagger.transitionDelay) || 0); - - var animationTime = (elementData.maxDelay + elementData.maxDuration) * CLOSING_TIME_BUFFER; - closingAnimationTime = Math.max(closingAnimationTime, (staggerTime + animationTime) * ONE_SECOND); - - //by placing a counter we can avoid an accidental - //race condition which may close an animation when - //a follow-up animation is midway in its animation - elementData.animationCount = animationCounter; - cancelAnimationReflow = $$animateReflow(function() { forEach(animationReflowQueue, function(fn) { fn(); }); - //copy the list of elements so that successive - //animations won't conflict if they're added before - //the closing animation timeout has run - var elementQueueSnapshot = []; - var animationCounterSnapshot = animationCounter; - forEach(animationElementQueue, function(elm) { - elementQueueSnapshot.push(elm); - }); - - $timeout(function() { - closeAllAnimations(elementQueueSnapshot, animationCounterSnapshot); - elementQueueSnapshot = null; - }, closingAnimationTime, false); - animationReflowQueue = []; - animationElementQueue = []; cancelAnimationReflow = null; lookupCache = {}; - closingAnimationTime = 0; - animationCounter++; }); } - function closeAllAnimations(elements, count) { + var closingTimer = null; + var closingTimestamp = 0; + var animationElementQueue = []; + function animationCloseHandler(element, totalTime) { + var futureTimestamp = Date.now() + (totalTime * 1000); + if(futureTimestamp <= closingTimestamp) { + return; + } + + $timeout.cancel(closingTimer); + + var node = extractElementNode(element); + element = angular.element(node); + animationElementQueue.push(element); + + closingTimestamp = futureTimestamp; + closingTimer = $timeout(function() { + closeAllAnimations(animationElementQueue); + animationElementQueue = []; + }, totalTime, false); + } + + function closeAllAnimations(elements) { forEach(elements, function(element) { var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); - if(elementData && elementData.animationCount == count) { + if(elementData) { (elementData.closeAnimationFn || noop)(); } }); @@ -1141,12 +1194,12 @@ angular.module('ngAnimate', ['ng']) return parentID + '-' + extractElementNode(element).className; } - function animateSetup(element, className, calculationDecorator) { + function animateSetup(animationEvent, element, className, calculationDecorator) { var cacheKey = getCacheKey(element); var eventCacheKey = cacheKey + ' ' + className; - var stagger = {}; var itemIndex = lookupCache[eventCacheKey] ? ++lookupCache[eventCacheKey].total : 0; + var stagger = {}; if(itemIndex > 0) { var staggerClassName = className + '-stagger'; var staggerCacheKey = cacheKey + ' ' + staggerClassName; @@ -1166,60 +1219,63 @@ angular.module('ngAnimate', ['ng']) element.addClass(className); + var formerData = element.data(NG_ANIMATE_CSS_DATA_KEY) || {}; + var timings = calculationDecorator(function() { return getElementAnimationDetails(element, eventCacheKey); }); - /* there is no point in performing a reflow if the animation - timeout is empty (this would cause a flicker bug normally - in the page. There is also no point in performing an animation - that only has a delay and no duration */ - var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay); - var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); - if(maxDuration === 0) { + var transitionDuration = timings.transitionDuration; + var animationDuration = timings.animationDuration; + if(transitionDuration === 0 && animationDuration === 0) { element.removeClass(className); return false; } - //temporarily disable the transition so that the enter styles - //don't animate twice (this is here to avoid a bug in Chrome/FF). - var activeClassName = ''; - timings.transitionDuration > 0 ? - blockTransitions(element) : - blockKeyframeAnimations(element); - - forEach(className.split(' '), function(klass, i) { - activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; - }); - element.data(NG_ANIMATE_CSS_DATA_KEY, { - className : className, - activeClassName : activeClassName, - maxDuration : maxDuration, - maxDelay : maxDelay, - classes : className + ' ' + activeClassName, - timings : timings, + running : formerData.running || 0, + itemIndex : itemIndex, stagger : stagger, - itemIndex : itemIndex + timings : timings, + closeAnimationFn : angular.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); + } + if(animationDuration > 0) { + blockKeyframeAnimations(element); + } + return true; } - function blockTransitions(element) { - extractElementNode(element).style[TRANSITION_PROP + PROPERTY_KEY] = 'none'; + function isStructuralAnimation(className) { + return className == 'ng-enter' || className == 'ng-move' || className == 'ng-leave'; + } + + 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); + } } function blockKeyframeAnimations(element) { extractElementNode(element).style[ANIMATION_PROP] = 'none 0s'; } - function unblockTransitions(element) { + 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] = ''; } + element.removeClass(NG_ANIMATE_BLOCK_CLASS_NAME); } function unblockKeyframeAnimations(element) { @@ -1230,22 +1286,28 @@ angular.module('ngAnimate', ['ng']) } } - function animateRun(element, className, activeAnimationComplete) { - var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); + function animateRun(animationEvent, element, className, activeAnimationComplete) { var node = extractElementNode(element); + var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); if(node.className.indexOf(className) == -1 || !elementData) { activeAnimationComplete(); return; } - var timings = elementData.timings; + var activeClassName = ''; + forEach(className.split(' '), function(klass, i) { + activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; + }); + var stagger = elementData.stagger; - var maxDuration = elementData.maxDuration; - var activeClassName = elementData.activeClassName; - var maxDelayTime = Math.max(timings.transitionDelay, timings.animationDelay) * ONE_SECOND; + var timings = elementData.timings; + var itemIndex = elementData.itemIndex; + var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); + var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay); + var maxDelayTime = maxDelay * ONE_SECOND; + var startTime = Date.now(); var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; - var itemIndex = elementData.itemIndex; var style = '', appliedStyles = []; if(timings.transitionDuration > 0) { @@ -1287,6 +1349,13 @@ angular.module('ngAnimate', ['ng']) onEnd(); activeAnimationComplete(); }; + + var staggerTime = itemIndex * (Math.max(stagger.animationDelay, stagger.transitionDelay) || 0); + var animationTime = (maxDelay + maxDuration) * CLOSING_TIME_BUFFER; + var totalTime = (staggerTime + animationTime) * ONE_SECOND; + + elementData.running++; + animationCloseHandler(element, totalTime); return onEnd; // This will automatically be called by $animate so @@ -1333,28 +1402,28 @@ angular.module('ngAnimate', ['ng']) return style; } - function animateBefore(element, className, calculationDecorator) { - if(animateSetup(element, className, calculationDecorator)) { + function animateBefore(animationEvent, element, className, calculationDecorator) { + if(animateSetup(animationEvent, element, className, calculationDecorator)) { return function(cancelled) { cancelled && animateClose(element, className); }; } } - function animateAfter(element, className, afterAnimationComplete) { + function animateAfter(animationEvent, element, className, afterAnimationComplete) { if(element.data(NG_ANIMATE_CSS_DATA_KEY)) { - return animateRun(element, className, afterAnimationComplete); + return animateRun(animationEvent, element, className, afterAnimationComplete); } else { animateClose(element, className); afterAnimationComplete(); } } - function animate(element, className, animationComplete) { + function animate(animationEvent, element, className, animationComplete) { //If the animateSetup function doesn't bother returning a //cancellation function then it means that there is no animation //to perform at all - var preReflowCancellation = animateBefore(element, className); + var preReflowCancellation = animateBefore(animationEvent, element, className); if(!preReflowCancellation) { animationComplete(); return; @@ -1367,12 +1436,12 @@ angular.module('ngAnimate', ['ng']) //happen in the first place var cancel = preReflowCancellation; afterReflow(element, function() { - unblockTransitions(element); + 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 - cancel = animateAfter(element, className, animationComplete); + cancel = animateAfter(animationEvent, element, className, animationComplete); }); return function(cancelled) { @@ -1382,54 +1451,59 @@ angular.module('ngAnimate', ['ng']) function animateClose(element, className) { element.removeClass(className); - element.removeData(NG_ANIMATE_CSS_DATA_KEY); + var data = element.data(NG_ANIMATE_CSS_DATA_KEY); + if(data) { + if(data.running) { + data.running--; + } + if(!data.running || data.running === 0) { + element.removeData(NG_ANIMATE_CSS_DATA_KEY); + } + } } return { - allowCancel : function(element, animationEvent, className) { - //always cancel the current animation if it is a - //structural animation - var oldClasses = (element.data(NG_ANIMATE_CSS_DATA_KEY) || {}).classes; - if(!oldClasses || ['enter','leave','move'].indexOf(animationEvent) >= 0) { - return true; - } - - var parentElement = element.parent(); - var clone = angular.element(extractElementNode(element).cloneNode()); - - //make the element super hidden and override any CSS style values - clone.attr('style','position:absolute; top:-9999px; left:-9999px'); - clone.removeAttr('id'); - clone.empty(); - - forEach(oldClasses.split(' '), function(klass) { - clone.removeClass(klass); - }); - - var suffix = animationEvent == 'addClass' ? '-add' : '-remove'; - clone.addClass(suffixClasses(className, suffix)); - parentElement.append(clone); - - var timings = getElementAnimationDetails(clone); - clone.remove(); - - return Math.max(timings.transitionDuration, timings.animationDuration) > 0; - }, - enter : function(element, animationCompleted) { - return animate(element, 'ng-enter', animationCompleted); + return animate('enter', element, 'ng-enter', animationCompleted); }, leave : function(element, animationCompleted) { - return animate(element, 'ng-leave', animationCompleted); + return animate('leave', element, 'ng-leave', animationCompleted); }, move : function(element, animationCompleted) { - return animate(element, 'ng-move', animationCompleted); + return animate('move', element, 'ng-move', animationCompleted); + }, + + 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; + }); + + if(cancellationMethod) { + afterReflow(element, function() { + unblockTransitions(element, className); + unblockKeyframeAnimations(element); + animationCompleted(); + }); + return cancellationMethod; + } + animationCompleted(); }, beforeAddClass : function(element, className, animationCompleted) { - var cancellationMethod = animateBefore(element, suffixClasses(className, '-add'), function(fn) { + 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 @@ -1443,7 +1517,7 @@ angular.module('ngAnimate', ['ng']) if(cancellationMethod) { afterReflow(element, function() { - unblockTransitions(element); + unblockTransitions(element, className); unblockKeyframeAnimations(element); animationCompleted(); }); @@ -1452,12 +1526,19 @@ angular.module('ngAnimate', ['ng']) animationCompleted(); }, + setClass : function(element, add, remove, animationCompleted) { + remove = suffixClasses(remove, '-remove'); + add = suffixClasses(add, '-add'); + var className = remove + ' ' + add; + return animateAfter('setClass', element, className, animationCompleted); + }, + addClass : function(element, className, animationCompleted) { - return animateAfter(element, suffixClasses(className, '-add'), animationCompleted); + return animateAfter('addClass', element, suffixClasses(className, '-add'), animationCompleted); }, beforeRemoveClass : function(element, className, animationCompleted) { - var cancellationMethod = animateBefore(element, suffixClasses(className, '-remove'), function(fn) { + 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. @@ -1471,7 +1552,7 @@ angular.module('ngAnimate', ['ng']) if(cancellationMethod) { afterReflow(element, function() { - unblockTransitions(element); + unblockTransitions(element, className); unblockKeyframeAnimations(element); animationCompleted(); }); @@ -1481,7 +1562,7 @@ angular.module('ngAnimate', ['ng']) }, removeClass : function(element, className, animationCompleted) { - return animateAfter(element, suffixClasses(className, '-remove'), animationCompleted); + return animateAfter('removeClass', element, suffixClasses(className, '-remove'), animationCompleted); } }; diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 6b8868f7501d..ba79fc881643 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -782,7 +782,8 @@ angular.mock.animate = angular.module('ngAnimateMock', ['ng']) } }; - angular.forEach(['enter','leave','move','addClass','removeClass'], function(method) { + angular.forEach( + ['enter','leave','move','addClass','removeClass','setClass'], function(method) { animate[method] = function() { animate.queue.push({ event : method, diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index e9ab15e4b712..98b1650f7706 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -4666,11 +4666,9 @@ describe('$compile', function() { $rootScope.$digest(); data = $animate.queue.shift(); - expect(data.event).toBe('removeClass'); - expect(data.args[1]).toBe('rice'); - data = $animate.queue.shift(); - expect(data.event).toBe('addClass'); + expect(data.event).toBe('setClass'); expect(data.args[1]).toBe('dice'); + expect(data.args[2]).toBe('rice'); expect(element.hasClass('ice')).toBe(true); expect(element.hasClass('dice')).toBe(true); diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index b162fea6ce71..b11c4766c05b 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -335,8 +335,7 @@ describe('ngClass animations', function() { $rootScope.val = 'two'; $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('removeClass'); - expect($animate.queue.shift().event).toBe('addClass'); + expect($animate.queue.shift().event).toBe('setClass'); expect($animate.queue.length).toBe(0); }); }); @@ -450,12 +449,9 @@ describe('ngClass animations', function() { $rootScope.$digest(); item = $animate.queue.shift(); - expect(item.event).toBe('removeClass'); - expect(item.args[1]).toBe('two'); - - item = $animate.queue.shift(); - expect(item.event).toBe('addClass'); + expect(item.event).toBe('setClass'); expect(item.args[1]).toBe('three'); + expect(item.args[2]).toBe('two'); expect($animate.queue.length).toBe(0); }); diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index 8da3d1cbfbce..d11cfa9ece3d 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -491,7 +491,7 @@ describe("ngAnimate", function() { $animate.triggerReflow(); //this is to verify that the existing style is appended with a semicolon automatically - expect(child.attr('style')).toMatch(/width: 20px;.+?/i); + expect(child.attr('style')).toMatch(/width: 20px;.*?/i); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); } @@ -564,7 +564,7 @@ describe("ngAnimate", function() { }); }); - it("should fire the cancel/end function with the correct flag in the parameters", + it("should not apply a cancellation when addClass is done multiple times", inject(function($animate, $rootScope, $sniffer, $timeout) { element.append(child); @@ -572,7 +572,7 @@ describe("ngAnimate", function() { $animate.addClass(child, 'custom-delay'); $animate.addClass(child, 'custom-long-delay'); - expect(child.hasClass('animation-cancelled')).toBe(true); + expect(child.hasClass('animation-cancelled')).toBe(false); expect(child.hasClass('animation-ended')).toBe(false); $timeout.flush(); @@ -764,7 +764,6 @@ describe("ngAnimate", function() { $animate.addClass(element, 'ng-hide'); expect(element.hasClass('ng-hide-remove')).toBe(false); //added right away - if($sniffer.animations) { //cleanup some pending animations $animate.triggerReflow(); expect(element.hasClass('ng-hide-add')).toBe(true); @@ -1472,6 +1471,8 @@ describe("ngAnimate", function() { expect(flag).toBe(true); expect(element.parent().id).toBe(parent2.id); + + dealoc(element); })); @@ -1620,11 +1621,12 @@ describe("ngAnimate", function() { var element = parent.find('span'); var flag = false; - $animate.removeClass(element, 'ng-hide', function() { + $animate.addClass(element, 'ng-hide', function() { flag = true; }); if($sniffer.transitions) { + $animate.triggerReflow(); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); } $timeout.flush(); @@ -2734,42 +2736,6 @@ describe("ngAnimate", function() { }); - it("should cancel an ongoing class-based animation only if the new class contains transition/animation CSS code", - inject(function($compile, $rootScope, $animate, $sniffer, $timeout) { - - if (!$sniffer.transitions) return; - - ss.addRule('.green-add', '-webkit-transition:1s linear all;' + - 'transition:1s linear all;'); - - ss.addRule('.blue-add', 'background:blue;'); - - ss.addRule('.red-add', '-webkit-transition:1s linear all;' + - 'transition:1s linear all;'); - - ss.addRule('.yellow-add', '-webkit-animation: some_animation 4s linear 1s 2 alternate;' + - 'animation: some_animation 4s linear 1s 2 alternate;'); - - var element = $compile('
')($rootScope); - $rootElement.append(element); - jqLite($document[0].body).append($rootElement); - - $animate.addClass(element, 'green'); - expect(element.hasClass('green-add')).toBe(true); - - $animate.addClass(element, 'blue'); - expect(element.hasClass('blue')).toBe(true); - expect(element.hasClass('green-add')).toBe(true); //not cancelled - - $animate.addClass(element, 'red'); - expect(element.hasClass('green-add')).toBe(false); - expect(element.hasClass('red-add')).toBe(true); - - $animate.addClass(element, 'yellow'); - expect(element.hasClass('red-add')).toBe(false); - expect(element.hasClass('yellow-add')).toBe(true); - })); - it("should cancel and perform the dom operation only after the reflow has run", inject(function($compile, $rootScope, $animate, $sniffer, $timeout) { @@ -2837,7 +2803,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'on'); $animate.addClass(element, 'on'); - expect(currentAnimation).toBe(null); + expect(currentAnimation).toBe('addClass'); }); }); @@ -3259,7 +3225,7 @@ describe("ngAnimate", function() { expect(ready).toBe(true); })); - it('should avoid skip animations if the same CSS class is added / removed synchronously before the reflow kicks in', + it('should immediately close the former animation if the same CSS class is added/removed', inject(function($sniffer, $compile, $rootScope, $rootElement, $animate, $timeout) { if (!$sniffer.transitions) return; @@ -3281,28 +3247,15 @@ describe("ngAnimate", function() { signature += 'B'; }); - $timeout.flush(1); - expect(signature).toBe('AB'); - - signature = ''; - $animate.removeClass(element, 'on', function() { - signature += 'A'; - }); - $animate.addClass(element, 'on', function() { - signature += 'B'; - }); - $animate.removeClass(element, 'on', function() { - signature += 'C'; - }); + $animate.triggerReflow(); $timeout.flush(1); - expect(signature).toBe('AB'); + expect(signature).toBe('A'); - $animate.triggerReflow(); browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2000 }); $timeout.flush(1); - expect(signature).toBe('ABC'); + expect(signature).toBe('AB'); })); }); }); diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index 9bcd50b385aa..bf134f82d1c8 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -767,8 +767,7 @@ describe('ngView animations', function() { $rootScope.klass = 'boring'; $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('removeClass'); - expect($animate.queue.shift().event).toBe('addClass'); + expect($animate.queue.shift().event).toBe('setClass'); expect(item.hasClass('classy')).toBe(false); expect(item.hasClass('boring')).toBe(true);