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
This commit is contained in:
Matias Niemelä
2014-02-14 04:02:46 -05:00
committed by Igor Minar
parent cf5e463abd
commit 4f84f6b3e4
9 changed files with 345 additions and 281 deletions

View File

@@ -9,3 +9,8 @@
ng\:form {
display: block;
}
.ng-animate-block-transitions {
transition:0s all!important;
-webkit-transition:0s all!important;
}

View File

@@ -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
};
}];

View File

@@ -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);
}
},
/**

View File

@@ -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);
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);
}
}
//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;
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;
}
element.data(NG_ANIMATE_CSS_DATA_KEY, {
running : formerData.running || 0,
itemIndex : itemIndex,
stagger : stagger,
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 activeClassName = '';
timings.transitionDuration > 0 ?
blockTransitions(element) :
var isCurrentlyAnimating = formerData.running > 0 || animationEvent == 'setClass';
if(transitionDuration > 0) {
blockTransitions(element, className, isCurrentlyAnimating);
}
if(animationDuration > 0) {
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,
stagger : stagger,
itemIndex : itemIndex
});
}
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);
}
};

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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('<div></div>')($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';
});
$timeout.flush(1);
expect(signature).toBe('AB');
$animate.triggerReflow();
$timeout.flush(1);
expect(signature).toBe('A');
browserTrigger(element, 'transitionend', { timeStamp: Date.now(), elapsedTime: 2000 });
$timeout.flush(1);
expect(signature).toBe('ABC');
expect(signature).toBe('AB');
}));
});
});

View File

@@ -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);