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'); + }); + }); + });