From 9d69a0a7c75c937c0a49bb705d31252326b052df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Mon, 4 Nov 2013 16:23:56 -0500 Subject: [PATCH] feat($animate): ensure CSS transitions can work with inherited CSS class definitions BREAKING CHANGE ngAnimate addClass / removeClass animations are now applied right away. This means that as soon as the animation starts the class will be added (addClass) or removed (removeClass) to the element being animated instead of after the -add-active / -remove-active animations are completed. This allows for animations outside of ngAnimate to not conflict with $animate. This commit introduces beforeAddClass and beforeRemoveClass animation event functions and executes any addClass and removeClass event functions AFTER the class has been added or removed (this is opposite functionality of how ngAnimate used to work when performing JS-enabled animations addClass / removeClass animations). If your animation code relies on any animations being performed prior to the class change then simply use the new beforeAddClass and beforeRemoveClass animation event functions. Finally, when animating show and hide animations using CSS transitions or keyframe animations, ng-hide-remove doesn't require `display:block!important` for ng-hide-add anymore. --- src/ngAnimate/animate.js | 642 ++++++++++++++++++++-------------- test/ngAnimate/animateSpec.js | 144 ++++++-- 2 files changed, 501 insertions(+), 285 deletions(-) diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 78be214342d1..f0aec2a69072 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -205,18 +205,21 @@ * ngModule.animation('.my-crazy-animation', function() { * return { * enter: function(element, done) { - * //run the animation - * //!annotate Cancel Animation|This function (if provided) will perform the cancellation of the animation when another is triggered - * return function(element, done) { - * //cancel the animation + * //run the animation here and call done when the animation is complete + * return function(cancelled) { + * //this (optional) function will be called when the animation + * //completes or when the animation is cancelled (the cancelled + * //flag will (be set to true if cancelled). * } * } * leave: function(element, done) { }, * move: function(element, done) { }, - * show: function(element, done) { }, - * hide: function(element, done) { }, + * + * beforeAddClass: function(element, className, done) { }, * addClass: function(element, className, done) { }, - * removeClass: function(element, className, done) { }, + * + * beforeRemoveClass: function(element, className, done) { }, + * removeClass: function(element, className, done) { } * } * }); * @@ -259,6 +262,7 @@ angular.module('ngAnimate', ['ng']) var NG_ANIMATE_STATE = '$$ngAnimateState'; var NG_ANIMATE_CLASS_NAME = 'ng-animate'; var rootAnimateState = {disabled:true}; + $provide.decorator('$animate', ['$delegate', '$injector', '$sniffer', '$rootElement', '$timeout', '$rootScope', '$document', function($delegate, $injector, $sniffer, $rootElement, $timeout, $rootScope, $document) { @@ -319,7 +323,7 @@ angular.module('ngAnimate', ['ng']) * @function * * @description - * Appends the element to the parent element that resides in the document and then runs the enter animation. Once + * Appends the element to the parentElement element that resides in the document and then runs the enter animation. Once * the animation is started, the following CSS classes will be present on the element for the duration of the animation: * * Below is a breakdown of each step that occurs during enter animation: @@ -327,27 +331,25 @@ angular.module('ngAnimate', ['ng']) * | Animation Step | What the element class attribute looks like | * |----------------------------------------------------------------------------------------------|-----------------------------------------------| * | 1. $animate.enter(...) is called | class="my-animation" | - * | 2. element is inserted into the parent element or beside the after element | class="my-animation" | + * | 2. element is inserted into the parentElement element or beside the afterElement element | class="my-animation" | * | 3. $animate runs any JavaScript-defined animations on the element | class="my-animation" | * | 4. the .ng-enter class is added to the element | class="my-animation ng-enter" | * | 5. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-enter" | * | 6. the .ng-enter-active class is added (this triggers the CSS transition/animation) | class="my-animation ng-enter ng-enter-active" | * | 7. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-enter ng-enter-active" | * | 8. The animation ends and both CSS classes are removed from the element | class="my-animation" | - * | 9. The done() callback is fired (if provided) | class="my-animation" | + * | 9. The doneCallback() callback is fired (if provided) | class="my-animation" | * * @param {jQuery/jqLite element} element the element that will be the focus of the enter animation - * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the enter animation - * @param {jQuery/jqLite element} after the sibling element (which is the previous element) of the element that will be the focus of the enter animation - * @param {function()=} done callback function that will be called once the animation is complete + * @param {jQuery/jqLite element} parentElement the parent element of the element that will be the focus of the enter animation + * @param {jQuery/jqLite element} afterElement the sibling element (which is the previous element) of the element that will be the focus of the enter animation + * @param {function()=} doneCallback callback function that will be called once the animation is complete */ - enter : function(element, parent, after, done) { + enter : function(element, parentElement, afterElement, doneCallback) { this.enabled(false, element); - $delegate.enter(element, parent, after); + $delegate.enter(element, parentElement, afterElement); $rootScope.$$postDigest(function() { - performAnimation('enter', 'ng-enter', element, parent, after, function() { - done && $timeout(done, 0, false); - }); + performAnimation('enter', 'ng-enter', element, parentElement, afterElement, noop, doneCallback); }); }, @@ -373,18 +375,18 @@ angular.module('ngAnimate', ['ng']) * | 6. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-leave ng-leave-active | * | 7. The animation ends and both CSS classes are removed from the element | class="my-animation" | * | 8. The element is removed from the DOM | ... | - * | 9. The done() callback is fired (if provided) | ... | + * | 9. The doneCallback() callback is fired (if provided) | ... | * * @param {jQuery/jqLite element} element the element that will be the focus of the leave animation - * @param {function()=} done callback function that will be called once the animation is complete + * @param {function()=} doneCallback callback function that will be called once the animation is complete */ - leave : function(element, done) { + leave : function(element, doneCallback) { cancelChildAnimations(element); this.enabled(false, element); $rootScope.$$postDigest(function() { performAnimation('leave', 'ng-leave', element, null, null, function() { - $delegate.leave(element, done); - }); + $delegate.leave(element); + }, doneCallback); }); }, @@ -395,8 +397,8 @@ angular.module('ngAnimate', ['ng']) * @function * * @description - * Fires the move DOM operation. Just before the animation starts, the animate service will either append it into the parent container or - * add the element directly after the after element if present. Then the move animation will be run. Once + * Fires the move DOM operation. Just before the animation starts, the animate service will either append it into the parentElement container or + * add the element directly after the afterElement element if present. Then the move animation will be run. Once * the animation is started, the following CSS classes will be added for the duration of the animation: * * Below is a breakdown of each step that occurs during move animation: @@ -404,28 +406,26 @@ angular.module('ngAnimate', ['ng']) * | Animation Step | What the element class attribute looks like | * |----------------------------------------------------------------------------------------------|---------------------------------------------| * | 1. $animate.move(...) is called | class="my-animation" | - * | 2. element is moved into the parent element or beside the after element | class="my-animation" | + * | 2. element is moved into the parentElement element or beside the afterElement element | class="my-animation" | * | 3. $animate runs any JavaScript-defined animations on the element | class="my-animation" | * | 4. the .ng-move class is added to the element | class="my-animation ng-move" | * | 5. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-move" | * | 6. the .ng-move-active class is added (this triggers the CSS transition/animation) | class="my-animation ng-move ng-move-active" | * | 7. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-move ng-move-active" | * | 8. The animation ends and both CSS classes are removed from the element | class="my-animation" | - * | 9. The done() callback is fired (if provided) | class="my-animation" | + * | 9. The doneCallback() callback is fired (if provided) | class="my-animation" | * * @param {jQuery/jqLite element} element the element that will be the focus of the move animation - * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the move animation - * @param {jQuery/jqLite element} after the sibling element (which is the previous element) of the element that will be the focus of the move animation - * @param {function()=} done callback function that will be called once the animation is complete + * @param {jQuery/jqLite element} parentElement the parentElement element of the element that will be the focus of the move animation + * @param {jQuery/jqLite element} afterElement the sibling element (which is the previous element) of the element that will be the focus of the move animation + * @param {function()=} doneCallback callback function that will be called once the animation is complete */ - move : function(element, parent, after, done) { + move : function(element, parentElement, afterElement, doneCallback) { cancelChildAnimations(element); this.enabled(false, element); - $delegate.move(element, parent, after); + $delegate.move(element, parentElement, afterElement); $rootScope.$$postDigest(function() { - performAnimation('move', 'ng-move', element, null, null, function() { - done && $timeout(done, 0, false); - }); + performAnimation('move', 'ng-move', element, parentElement, afterElement, noop, doneCallback); }); }, @@ -452,16 +452,16 @@ angular.module('ngAnimate', ['ng']) * | 6. $animate waits for X milliseconds for the animation to complete | class="super-add super-add-active" | * | 7. The animation ends and both CSS classes are removed from the element | class="" | * | 8. The super class is added to the element | class="super" | - * | 9. The done() callback is fired (if provided) | class="super" | + * | 9. The doneCallback() callback is fired (if provided) | class="super" | * * @param {jQuery/jqLite element} element the element that will be animated * @param {string} className the CSS class that will be animated and then attached to the element * @param {function()=} done callback function that will be called once the animation is complete */ - addClass : function(element, className, done) { + addClass : function(element, className, doneCallback) { performAnimation('addClass', className, element, null, null, function() { - $delegate.addClass(element, className, done); - }); + $delegate.addClass(element, className); + }, doneCallback); }, /** @@ -486,16 +486,16 @@ angular.module('ngAnimate', ['ng']) * | 5. the .super-remove-active class is added (this triggers the CSS transition/animation) | class="super super-remove super-remove-active" | * | 6. $animate waits for X milliseconds for the animation to complete | class="super super-remove super-remove-active" | * | 7. The animation ends and both CSS all three classes are removed from the element | class="" | - * | 8. The done() callback is fired (if provided) | class="" | + * | 8. The doneCallback() callback is fired (if provided) | class="" | * * @param {jQuery/jqLite element} element the element that will be animated * @param {string} className the CSS class that will be animated and then removed from the element * @param {function()=} done callback function that will be called once the animation is complete */ - removeClass : function(element, className, done) { + removeClass : function(element, className, doneCallback) { performAnimation('removeClass', className, element, null, null, function() { - $delegate.removeClass(element, className, done); - }); + $delegate.removeClass(element, className); + }, doneCallback); }, /** @@ -516,8 +516,7 @@ angular.module('ngAnimate', ['ng']) case 2: if(value) { cleanup(element); - } - else { + } else { var data = element.data(NG_ANIMATE_STATE) || {}; data.disabled = true; element.data(NG_ANIMATE_STATE, data); @@ -538,28 +537,29 @@ angular.module('ngAnimate', ['ng']) /* all animations call this shared animation triggering function internally. - The event variable refers to the JavaScript animation event that will be triggered + The animationEvent variable refers to the JavaScript animation event that will be triggered and the className value is the name of the animation that will be applied within the - CSS code. Element, parent and after are provided DOM elements for the animation + CSS code. Element, parentElement and afterElement are provided DOM elements for the animation and the onComplete callback will be fired once the animation is fully complete. */ - function performAnimation(event, className, element, parent, after, onComplete) { + function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) { var classes = (element.attr('class') || '') + ' ' + className; var animationLookup = (' ' + classes).replace(/\s+/g,'.'); - if (!parent) { - parent = after ? after.parent() : element.parent(); + if (!parentElement) { + parentElement = afterElement ? afterElement.parent() : element.parent(); } var matches = lookup(animationLookup); - var isClassBased = event == 'addClass' || event == 'removeClass'; + var isClassBased = animationEvent == 'addClass' || animationEvent == 'removeClass'; var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; //skip the animation if animations are disabled, a parent is already being animated, //the element is not currently attached to the document body or then completely close //the animation if any matching animations are not found at all. - //NOTE: IE8 + IE9 should close properly (run done()) in case a NO animation is not found. - if (animationsDisabled(element, parent) || matches.length === 0) { - done(); + //NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case a NO animation is not found. + if (animationsDisabled(element, parentElement) || matches.length === 0) { + domOperation(); + closeAnimation(); return; } @@ -569,9 +569,20 @@ angular.module('ngAnimate', ['ng']) if(!ngAnimateState.running || !(isClassBased && ngAnimateState.structural)) { forEach(matches, function(animation) { //add the animation to the queue to if it is allowed to be cancelled - if(!animation.allowCancel || animation.allowCancel(element, event, className)) { + if(!animation.allowCancel || animation.allowCancel(element, animationEvent, className)) { + var beforeFn, afterFn = animation[animationEvent]; + + //Special case for a leave animation since there is no point in performing an + //animation on a element node that has already been removed from the DOM + if(animationEvent == 'leave') { + beforeFn = afterFn; + afterFn = null; //this must be falsy so that the animation is skipped for leave + } else { + beforeFn = animation['before' + animationEvent.charAt(0).toUpperCase() + animationEvent.substr(1)]; + } animations.push({ - start : animation[event] + before : beforeFn, + after : afterFn }); } }); @@ -580,66 +591,108 @@ angular.module('ngAnimate', ['ng']) //this would mean that an animation was not allowed so let the existing //animation do it's thing and close this one early if(animations.length === 0) { - onComplete && onComplete(); + domOperation(); + fireDoneCallbackAsync(); return; } 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 - $timeout.cancel(ngAnimateState.flagTimer); + $timeout.cancel(ngAnimateState.closeAnimationTimeout); cleanup(element); cancelAnimations(ngAnimateState.animations); - (ngAnimateState.done || noop)(); + (ngAnimateState.done || noop)(true); } //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. - if((event == 'addClass' && element.hasClass(className)) || - (event == 'removeClass' && !element.hasClass(className))) { - onComplete && onComplete(); + if((animationEvent == 'addClass' && element.hasClass(className)) || + (animationEvent == 'removeClass' && !element.hasClass(className))) { + domOperation(); + fireDoneCallbackAsync(); return; } + //the ng-animate class does nothing, but it's here to allow for + //parent animations to find and cancel child animations when needed + element.addClass(NG_ANIMATE_CLASS_NAME); + element.data(NG_ANIMATE_STATE, { running:true, structural:!isClassBased, animations:animations, - done:done + done:onBeforeAnimationsComplete }); - //the ng-animate class does nothing, but it's here to allow for - //parent animations to find and cancel child animations when needed - element.addClass(NG_ANIMATE_CLASS_NAME); + //first we run the before animations and when all of those are complete + //then we perform the DOM operation and run the next set of animations + invokeRegisteredAnimationFns(animations, 'before', onBeforeAnimationsComplete); - forEach(animations, function(animation, index) { - var fn = function() { - progress(index); - }; + function onBeforeAnimationsComplete(cancelled) { + domOperation(); + if(cancelled === true) { + closeAnimation(); + return; + } - if(animation.start) { - animation.endFn = isClassBased ? - animation.start(element, className, fn) : - animation.start(element, fn); - } else { - fn(); + //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); } - }); + invokeRegisteredAnimationFns(animations, 'after', closeAnimation); + } + + function invokeRegisteredAnimationFns(animations, phase, allAnimationFnsComplete) { + var endFnName = phase + 'End'; + forEach(animations, function(animation, index) { + var animationPhaseCompleted = function() { + progress(index, phase); + }; + + //there are no before functions for enter + move since the DOM + //operations happen before the performAnimation method fires + if(phase == 'before' && (animationEvent == 'enter' || animationEvent == 'move')) { + animationPhaseCompleted(); + return; + } - function progress(index) { - animations[index].done = true; - (animations[index].endFn || noop)(); - for(var i=0;i 0) { - aDuration *= parseInt(elementStyles[animationProp + animationIterationCountKey], 10) || 1; - } - - animationDuration = Math.max(aDuration, animationDuration); + if(aDuration > 0) { + aDuration *= parseInt(elementStyles[ANIMATION_PROP + ANIMATION_ITERATION_COUNT_KEY], 10) || 1; } + + animationDuration = Math.max(aDuration, animationDuration); } }); data = { @@ -843,35 +899,32 @@ angular.module('ngAnimate', ['ng']) } function parseMaxTime(str) { - var total = 0, values = angular.isString(str) ? str.split(/\s*,\s*/) : []; + var maxValue = 0; + var values = angular.isString(str) ? + str.split(/\s*,\s*/) : + []; forEach(values, function(value) { - total = Math.max(parseFloat(value) || 0, total); + maxValue = Math.max(parseFloat(value) || 0, maxValue); }); - return total; + return maxValue; } function getCacheKey(element) { - var parent = element.parent(); - var parentID = parent.data(NG_ANIMATE_PARENT_KEY); + var parentElement = element.parent(); + var parentID = parentElement.data(NG_ANIMATE_PARENT_KEY); if(!parentID) { - parent.data(NG_ANIMATE_PARENT_KEY, ++parentCounter); + parentElement.data(NG_ANIMATE_PARENT_KEY, ++parentCounter); parentID = parentCounter; } return parentID + '-' + element[0].className; } - function animate(element, className, done) { + function animateSetup(element, className) { var cacheKey = getCacheKey(element); - if(getElementAnimationDetails(element, cacheKey, true).transitionDuration > 0) { - - done(); - return; - } - var eventCacheKey = cacheKey + ' ' + className; + var stagger = {}; var ii = lookupCache[eventCacheKey] ? ++lookupCache[eventCacheKey].total : 0; - var stagger = {}; if(ii > 0) { var staggerClassName = className + '-stagger'; var staggerCacheKey = cacheKey + ' ' + staggerClassName; @@ -893,107 +946,105 @@ angular.module('ngAnimate', ['ng']) in the page. There is also no point in performing an animation that only has a delay and no duration */ var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); - if(maxDuration > 0) { - var maxDelayTime = Math.max(timings.transitionDelay, timings.animationDelay) * 1000, - startTime = Date.now(), - node = element[0]; - - //temporarily disable the transition so that the enter styles - //don't animate twice (this is here to avoid a bug in Chrome/FF). - if(timings.transitionDuration > 0) { - node.style[transitionProp + propertyKey] = 'none'; - } - - var activeClassName = 'ng-animate-active '; - forEach(className.split(' '), function(klass, i) { - activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; - }); - - var formerStyle, css3AnimationEvents = animationendEvent + ' ' + transitionendEvent; + if(maxDuration === 0) { + element.removeClass(className); + return false; + } - // This triggers a reflow which allows for the transition animation to kick in. - afterReflow(function() { - if(!element.hasClass(className)) { - done(); - return; - } + var node = element[0]; + //temporarily disable the transition so that the enter styles + //don't animate twice (this is here to avoid a bug in Chrome/FF). + if(timings.transitionDuration > 0) { + node.style[TRANSITION_PROP + PROPERTY_KEY] = 'none'; + } - var applyFallbackStyle, style = ''; - if(timings.transitionDuration > 0) { - node.style[transitionProp + propertyKey] = ''; + var activeClassName = 'ng-animate-active '; + forEach(className.split(' '), function(klass, i) { + activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; + }); - var propertyStyle = timings.transitionPropertyStyle; - if(propertyStyle.indexOf('all') == -1) { - applyFallbackStyle = true; - var fallbackProperty = $sniffer.msie ? '-ms-zoom' : 'clip'; - style += prefix + 'transition-property: ' + propertyStyle + ', ' + fallbackProperty + '; '; - style += prefix + 'transition-duration: ' + timings.transitionDurationStyle + ', ' + timings.transitionDuration + 's; '; - } - } + element.data(NG_ANIMATE_CSS_DATA_KEY, { + className : className, + activeClassName : activeClassName, + maxDuration : maxDuration, + classes : className + ' ' + activeClassName, + timings : timings, + stagger : stagger, + ii : ii + }); - if(ii > 0) { - if(stagger.transitionDelay > 0 && stagger.transitionDuration === 0) { - var delayStyle = timings.transitionDelayStyle; - if(applyFallbackStyle) { - delayStyle += ', ' + timings.transitionDelay + 's'; - } + return true; + } - style += prefix + 'transition-delay: ' + - prepareStaggerDelay(delayStyle, stagger.transitionDelay, ii) + '; '; - } + function animateRun(element, className, activeAnimationComplete) { + var data = element.data(NG_ANIMATE_CSS_DATA_KEY); + if(!element.hasClass(className) || !data) { + activeAnimationComplete(); + return; + } - if(stagger.animationDelay > 0 && stagger.animationDuration === 0) { - style += prefix + 'animation-delay: ' + - prepareStaggerDelay(timings.animationDelayStyle, stagger.animationDelay, ii) + '; '; - } - } + var node = element[0]; + var timings = data.timings; + var stagger = data.stagger; + var maxDuration = data.maxDuration; + var activeClassName = data.activeClassName; + var maxDelayTime = Math.max(timings.transitionDelay, timings.animationDelay) * 1000; + var startTime = Date.now(); + var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; + var formerStyle; + var ii = data.ii; + + var applyFallbackStyle, style = ''; + if(timings.transitionDuration > 0) { + node.style[TRANSITION_PROP + PROPERTY_KEY] = ''; + + var propertyStyle = timings.transitionPropertyStyle; + if(propertyStyle.indexOf('all') == -1) { + applyFallbackStyle = true; + var fallbackProperty = $sniffer.msie ? '-ms-zoom' : 'clip'; + style += CSS_PREFIX + 'transition-property: ' + propertyStyle + ', ' + fallbackProperty + '; '; + style += CSS_PREFIX + 'transition-duration: ' + timings.transitionDurationStyle + ', ' + timings.transitionDuration + 's; '; + } + } - if(style.length > 0) { - formerStyle = applyStyle(node, style); + if(ii > 0) { + if(stagger.transitionDelay > 0 && stagger.transitionDuration === 0) { + var delayStyle = timings.transitionDelayStyle; + if(applyFallbackStyle) { + delayStyle += ', ' + timings.transitionDelay + 's'; } - element.addClass(activeClassName); - }); - - element.data(NG_ANIMATE_CLASS_KEY, className + ' ' + activeClassName); - element.on(css3AnimationEvents, onAnimationProgress); - - // This will automatically be called by $animate so - // there is no need to attach this internally to the - // timeout done method. - return function onEnd(cancelled) { - element.off(css3AnimationEvents, onAnimationProgress); - element.removeClass(className); - element.removeClass(activeClassName); - element.removeData(NG_ANIMATE_CLASS_KEY); - if(formerStyle != null) { - formerStyle.length > 0 ? - node.setAttribute('style', formerStyle) : - node.removeAttribute('style'); - } + style += CSS_PREFIX + 'transition-delay: ' + + prepareStaggerDelay(delayStyle, stagger.transitionDelay, ii) + '; '; + } - // Only when the animation is cancelled is the done() - // function not called for this animation therefore - // this must be also called. - if(cancelled) { - done(); - } - }; - } - else { - element.removeClass(className); - done(); + if(stagger.animationDelay > 0 && stagger.animationDuration === 0) { + style += CSS_PREFIX + 'animation-delay: ' + + prepareStaggerDelay(timings.animationDelayStyle, stagger.animationDelay, ii) + '; '; + } } - function prepareStaggerDelay(delayStyle, staggerDelay, index) { - var style = ''; - angular.forEach(delayStyle.split(','), function(val, i) { - style += (i > 0 ? ',' : '') + - (index * staggerDelay + parseInt(val, 10)) + 's'; - }); - return style; + if(style.length > 0) { + formerStyle = applyStyle(node, style); } + element.on(css3AnimationEvents, onAnimationProgress); + element.addClass(activeClassName); + + // This will automatically be called by $animate so + // there is no need to attach this internally to the + // timeout done method. + return function onEnd(cancelled) { + element.off(css3AnimationEvents, onAnimationProgress); + element.removeClass(activeClassName); + animateClose(element, className); + if(formerStyle != null) { + formerStyle.length > 0 ? + node.setAttribute('style', formerStyle) : + node.removeAttribute('style'); + } + }; + function onAnimationProgress(event) { event.stopPropagation(); var ev = event.originalEvent || event; @@ -1006,22 +1057,80 @@ angular.module('ngAnimate', ['ng']) * but we're using elapsedTime instead of the timeStamp on the 2nd * pre-condition since animations sometimes close off early */ if(Math.max(timeStamp - startTime, 0) >= maxDelayTime && ev.elapsedTime >= maxDuration) { - done(); + activeAnimationComplete(); } } + } + function prepareStaggerDelay(delayStyle, staggerDelay, index) { + var style = ''; + forEach(delayStyle.split(','), function(val, i) { + style += (i > 0 ? ',' : '') + + (index * staggerDelay + parseInt(val, 10)) + 's'; + }); + return style; + } + + function animateBefore(element, className) { + if(animateSetup(element, className)) { + return function(cancelled) { + cancelled && animateClose(element, className); + }; + } + } + + function animateAfter(element, className, afterAnimationComplete) { + if(element.data(NG_ANIMATE_CSS_DATA_KEY)) { + return animateRun(element, className, afterAnimationComplete); + } else { + animateClose(element, className); + afterAnimationComplete(); + } + } + + function animate(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); + if(!preReflowCancellation) { + animationComplete(); + return; + } + + //There are two cancellation functions: one is before the first + //reflow animation and the second is during the active state + //animation. The first function will take care of removing the + //data from the element which will not make the 2nd animation + //happen in the first place + var cancel = preReflowCancellation; + afterReflow(function() { + //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); + }); + + return function(cancelled) { + (cancel || noop)(cancelled); + }; + } + + function animateClose(element, className) { + element.removeClass(className); + element.removeData(NG_ANIMATE_CSS_DATA_KEY); } return { - allowCancel : function(element, event, className) { + allowCancel : function(element, animationEvent, className) { //always cancel the current animation if it is a //structural animation - var oldClasses = element.data(NG_ANIMATE_CLASS_KEY); - if(!oldClasses || ['enter','leave','move'].indexOf(event) >= 0) { + var oldClasses = (element.data(NG_ANIMATE_CSS_DATA_KEY) || {}).classes; + if(!oldClasses || ['enter','leave','move'].indexOf(animationEvent) >= 0) { return true; } - var parent = element.parent(); + var parentElement = element.parent(); var clone = angular.element(element[0].cloneNode()); //make the element super hidden and override any CSS style values @@ -1029,33 +1138,56 @@ angular.module('ngAnimate', ['ng']) clone.removeAttr('id'); clone.html(''); - angular.forEach(oldClasses.split(' '), function(klass) { + forEach(oldClasses.split(' '), function(klass) { clone.removeClass(klass); }); - var suffix = event == 'addClass' ? '-add' : '-remove'; + var suffix = animationEvent == 'addClass' ? '-add' : '-remove'; clone.addClass(suffixClasses(className, suffix)); - parent.append(clone); + parentElement.append(clone); var timings = getElementAnimationDetails(clone); clone.remove(); return Math.max(timings.transitionDuration, timings.animationDuration) > 0; }, - enter : function(element, done) { - return animate(element, 'ng-enter', done); + + enter : function(element, animationCompleted) { + return animate(element, 'ng-enter', animationCompleted); + }, + + leave : function(element, animationCompleted) { + return animate(element, 'ng-leave', animationCompleted); + }, + + move : function(element, animationCompleted) { + return animate(element, 'ng-move', animationCompleted); }, - leave : function(element, done) { - return animate(element, 'ng-leave', done); + + beforeAddClass : function(element, className, animationCompleted) { + var cancellationMethod = animateBefore(element, suffixClasses(className, '-add')); + if(cancellationMethod) { + afterReflow(animationCompleted); + return cancellationMethod; + } + animationCompleted(); }, - move : function(element, done) { - return animate(element, 'ng-move', done); + + addClass : function(element, className, animationCompleted) { + return animateAfter(element, suffixClasses(className, '-add'), animationCompleted); }, - addClass : function(element, className, done) { - return animate(element, suffixClasses(className, '-add'), done); + + beforeRemoveClass : function(element, className, animationCompleted) { + var cancellationMethod = animateBefore(element, suffixClasses(className, '-remove')); + if(cancellationMethod) { + afterReflow(animationCompleted); + return cancellationMethod; + } + animationCompleted(); }, - removeClass : function(element, className, done) { - return animate(element, suffixClasses(className, '-remove'), done); + + removeClass : function(element, className, animationCompleted) { + return animateAfter(element, suffixClasses(className, '-remove'), animationCompleted); } }; diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index 66e810081ba7..dee9bcba8604 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -219,7 +219,7 @@ describe("ngAnimate", function() { return function($animate, $compile, $rootScope, $rootElement) { element = $compile('
')($rootScope); - angular.forEach(['.ng-hide-add', '.ng-hide-remove', '.ng-enter', '.ng-leave', '.ng-move'], function(selector) { + forEach(['.ng-hide-add', '.ng-hide-remove', '.ng-enter', '.ng-leave', '.ng-move'], function(selector) { ss.addRule(selector, '-webkit-transition:1s linear all;' + 'transition:1s linear all;'); }); @@ -371,11 +371,10 @@ describe("ngAnimate", function() { expect(child.attr('class')).toContain('ng-leave'); expect(child.attr('class')).toContain('ng-leave-active'); browserTrigger(child,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); - $timeout.flush(); })); it("should not run if animations are disabled", - inject(function($animate, $rootScope) { + inject(function($animate, $rootScope, $timeout, $sniffer) { $animate.enabled(false); @@ -392,6 +391,9 @@ describe("ngAnimate", function() { element.addClass('ng-hide'); $animate.removeClass(element, 'ng-hide'); + if($sniffer.transitions) { + $timeout.flush(); + } expect(element.text()).toBe('memento'); })); @@ -403,7 +405,9 @@ describe("ngAnimate", function() { expect(element).toBeShown(); $animate.addClass(child, 'ng-hide'); - expect(child).toBeShown(); + if($sniffer.transitions) { + expect(child).toBeShown(); + } $animate.leave(child); $rootScope.$digest(); @@ -546,7 +550,7 @@ describe("ngAnimate", function() { describe("Animations", function() { it("should properly detect and make use of CSS Animations", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { ss.addRule('.ng-hide-add', '-webkit-animation: some_animation 4s linear 0s 1 alternate;' + @@ -562,13 +566,14 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.animations) { + $timeout.flush(); browserTrigger(element,'animationend', { timeStamp: Date.now() + 4000, elapsedTime: 4 }); } expect(element).toBeShown(); })); it("should properly detect and make use of CSS Animations with multiple iterations", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { var style = '-webkit-animation-duration: 2s;' + '-webkit-animation-iteration-count: 3;' + @@ -585,13 +590,14 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.animations) { + $timeout.flush(); browserTrigger(element,'animationend', { timeStamp: Date.now() + 6000, elapsedTime: 6 }); } expect(element).toBeShown(); })); it("should fallback to the animation duration if an infinite iteration is provided", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { var style = '-webkit-animation-duration: 2s;' + '-webkit-animation-iteration-count: infinite;' + @@ -608,13 +614,14 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.animations) { + $timeout.flush(); browserTrigger(element,'animationend', { timeStamp: Date.now() + 2000, elapsedTime: 2 }); } expect(element).toBeShown(); })); it("should not consider the animation delay is provided", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { var style = '-webkit-animation-duration: 2s;' + '-webkit-animation-delay: 10s;' + @@ -633,6 +640,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.transitions) { + $timeout.flush(); browserTrigger(element,'animationend', { timeStamp : Date.now() + 20000, elapsedTime: 10 }); } expect(element).toBeShown(); @@ -861,13 +869,14 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.transitions) { + $timeout.flush(); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); } expect(element).toBeShown(); })); it("should skip animations if disabled and run when enabled picking the longest specified duration", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { var style = '-webkit-transition-duration: 1s, 2000ms, 1s;' + '-webkit-transition-property: height, left, opacity;' + @@ -879,13 +888,16 @@ describe("ngAnimate", function() { element = $compile(html('
foo
'))($rootScope); element.addClass('ng-hide'); + $animate.removeClass(element, 'ng-hide'); + if ($sniffer.transitions) { - expect(element).toBeHidden(); + $timeout.flush(); var now = Date.now(); browserTrigger(element,'transitionend', { timeStamp: now + 1000, elapsedTime: 1 }); browserTrigger(element,'transitionend', { timeStamp: now + 1000, elapsedTime: 1 }); browserTrigger(element,'transitionend', { timeStamp: now + 2000, elapsedTime: 2 }); + expect(element.hasClass('ng-animate')).toBe(false); } expect(element).toBeShown(); })); @@ -916,6 +928,7 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.transitions) { + $timeout.flush(); var now = Date.now(); browserTrigger(element,'transitionend', { timeStamp: now + 1000, elapsedTime: 1 }); browserTrigger(element,'transitionend', { timeStamp: now + 3000, elapsedTime: 3 }); @@ -925,7 +938,7 @@ describe("ngAnimate", function() { })); it("should animate for the highest duration", - inject(function($animate, $rootScope, $compile, $sniffer) { + inject(function($animate, $rootScope, $compile, $sniffer, $timeout) { var style = '-webkit-transition:1s linear all 2s;' + 'transition:1s linear all 2s;' + '-webkit-animation:my_ani 10s 1s;' + @@ -941,9 +954,14 @@ describe("ngAnimate", function() { $animate.removeClass(element, 'ng-hide'); if ($sniffer.transitions) { - browserTrigger(element,'animationend', { timeStamp: Date.now() + 11000, elapsedTime: 11 }); + $timeout.flush(); } expect(element).toBeShown(); + if ($sniffer.transitions) { + expect(element.hasClass('ng-animate-active')).toBe(true); + browserTrigger(element,'animationend', { timeStamp: Date.now() + 11000, elapsedTime: 11 }); + expect(element.hasClass('ng-animate-active')).toBe(false); + } })); it("should finish the previous transition when a new animation is started", @@ -1503,12 +1521,13 @@ describe("ngAnimate", function() { }); if($sniffer.transitions) { - $timeout.flush(); - expect(element.hasClass('klass')).toBe(false); expect(element.hasClass('klass-add')).toBe(true); + $timeout.flush(); + expect(element.hasClass('klass')).toBe(true); expect(element.hasClass('klass-add-active')).toBe(true); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 3000, elapsedTime: 3 }); } + $timeout.flush(); //this cancels out the older animation $animate.removeClass(element,'klass', function() { @@ -1516,12 +1535,13 @@ describe("ngAnimate", function() { }); if($sniffer.transitions) { + expect(element.hasClass('klass-remove')).toBe(true); + $timeout.flush(); - expect(element.hasClass('klass')).toBe(true); + expect(element.hasClass('klass')).toBe(false); expect(element.hasClass('klass-add')).toBe(false); expect(element.hasClass('klass-add-active')).toBe(false); - expect(element.hasClass('klass-remove')).toBe(true); browserTrigger(element,'transitionend', { timeStamp: Date.now() + 3000, elapsedTime: 3 }); } $timeout.flush(); @@ -2079,7 +2099,7 @@ describe("ngAnimate", function() { }); }); - it("should skip ngAnimate animations when any pre-existing CSS transitions are present on the element", function() { + it("should not skip ngAnimate animations when any pre-existing CSS transitions are present on the element", function() { inject(function($compile, $rootScope, $animate, $timeout, $sniffer) { if(!$sniffer.transitions) return; @@ -2103,7 +2123,7 @@ describe("ngAnimate", function() { } catch(e) {} - expect(empty).toBe(true); + expect(empty).toBe(false); }); }); @@ -2143,22 +2163,22 @@ describe("ngAnimate", function() { it("should cancel all child animations when a leave or move animation is triggered on a parent element", function() { - var animationState; + var step, animationState; module(function($animateProvider) { $animateProvider.register('.animan', function($timeout) { return { enter : function(element, done) { animationState = 'enter'; - $timeout(done, 0, false); - return function() { - animationState = 'enter-cancel'; + step = done; + return function(cancelled) { + animationState = cancelled ? 'enter-cancel' : animationState; } }, addClass : function(element, className, done) { animationState = 'addClass'; - $timeout(done, 0, false); - return function() { - animationState = 'addClass-cancel'; + step = done; + return function(cancelled) { + animationState = cancelled ? 'addClass-cancel' : animationState; } } }; @@ -2193,14 +2213,17 @@ describe("ngAnimate", function() { } expect(animationState).toBe('enter-cancel'); + $rootScope.$digest(); $timeout.flush(); $animate.addClass(child, 'something'); + if($sniffer.transitions) { + $timeout.flush(); + } expect(animationState).toBe('addClass'); if($sniffer.transitions) { expect(child.hasClass('something-add')).toBe(true); - $timeout.flush(); expect(child.hasClass('something-add-active')).toBe(true); } @@ -2229,7 +2252,7 @@ describe("ngAnimate", function() { $timeout.flush(); expect(element[0].querySelectorAll('.ng-enter-active').length).toBe(5); - angular.forEach(element.children(), function(kid) { + forEach(element.children(), function(kid) { browserTrigger(kid, 'transitionend', { timeStamp: Date.now() + 1000, elapsedTime: 1 }); }); @@ -2401,7 +2424,7 @@ describe("ngAnimate", function() { $timeout.flush(); //called three times since the classname is the same - expect(count).toBe(3); + expect(count).toBe(2); dealoc(element); count = 0; @@ -2414,12 +2437,12 @@ describe("ngAnimate", function() { $rootScope.$digest(); $timeout.flush(); - expect(count).toBe(40); + expect(count).toBe(20); }); }); it("should cancel an ongoing class-based animation only if the new class contains transition/animation CSS code", - inject(function($compile, $rootScope, $animate, $sniffer) { + inject(function($compile, $rootScope, $animate, $sniffer, $timeout) { if (!$sniffer.transitions) return; @@ -2477,4 +2500,65 @@ describe("ngAnimate", function() { }); }); + it('should perform pre and post animations', function() { + var steps = []; + module(function($animateProvider) { + $animateProvider.register('.class-animate', function() { + return { + beforeAddClass : function(element, className, done) { + steps.push('before'); + done(); + }, + addClass : function(element, className, done) { + steps.push('after'); + done(); + } + }; + }); + }); + inject(function($animate, $rootScope, $compile, $rootElement, $timeout) { + $animate.enabled(true); + + var element = $compile('
')($rootScope); + $rootElement.append(element); + + $animate.addClass(element, 'red'); + + expect(steps).toEqual(['before','after']); + }); + }); + + it('should treat the leave event always as a before event and discard the beforeLeave function', function() { + var parentID, steps = []; + module(function($animateProvider) { + $animateProvider.register('.animate', function() { + return { + beforeLeave : function(element, done) { + steps.push('before'); + done(); + }, + leave : function(element, done) { + parentID = element.parent().attr('id'); + steps.push('after'); + done(); + } + }; + }); + }); + inject(function($animate, $rootScope, $compile, $rootElement) { + $animate.enabled(true); + + var element = $compile('
')($rootScope); + var child = $compile('
')($rootScope); + $rootElement.append(element); + element.append(child); + + $animate.leave(child); + $rootScope.$digest(); + + expect(steps).toEqual(['after']); + expect(parentID).toEqual('parentGuy'); + }); + }); + });