fix($animate): use $timeout to handle the delay within staggering animations

When transition-delay and animation-delay were used to drive the staggering
animation the result was unpredictable at times due to the browser not being
able to register the generated delay styles in time. This caused a hard to
track down bug that didn't have a solid solution when styles were being used.

This fix ensures that stagger delays are handled by the $timeout service.

Closes #7228
Closes #7547
Closes #8297
Closes #8547

BREAKING CHANGE

If any stagger code consisted of having BOTH transition staggers and delay staggers
together then that will not work the same way. Angular will now instead choose
the highest stagger delay value and set the timeout to wait for that before
applying the active CSS class.
This commit is contained in:
Matias Niemelä
2014-08-16 11:21:56 -04:00
parent bf0f5502b1
commit 23da614043
2 changed files with 200 additions and 124 deletions

View File

@@ -484,12 +484,12 @@ angular.module('ngAnimate', ['ng'])
// the matching CSS class.
if (status < 0) {
//does it have the class or will it have the class
if(hasClass || matchingAnimation.event == 'addClass') {
if (hasClass || matchingAnimation.event == 'addClass') {
toRemove.push(className);
}
} else if (status > 0) {
//is the class missing or will it be removed?
if(!hasClass || matchingAnimation.event == 'removeClass') {
if (!hasClass || matchingAnimation.event == 'removeClass') {
toAdd.push(className);
}
}
@@ -544,7 +544,7 @@ angular.module('ngAnimate', ['ng'])
if (!classNameAdd) {
className = classNameRemove;
animationEvent = 'removeClass';
} else if(!classNameRemove) {
} else if (!classNameRemove) {
className = classNameAdd;
animationEvent = 'addClass';
} else {
@@ -1101,6 +1101,7 @@ angular.module('ngAnimate', ['ng'])
var totalActiveAnimations = ngAnimateState.totalActive || 0;
var lastAnimation = ngAnimateState.last;
var skipAnimation = false;
if (totalActiveAnimations > 0) {
var animationsToCancel = [];
if (!runner.isClassBased) {
@@ -1384,6 +1385,7 @@ angular.module('ngAnimate', ['ng'])
var PROPERTY_KEY = 'Property';
var DELAY_KEY = 'Delay';
var ANIMATION_ITERATION_COUNT_KEY = 'IterationCount';
var ANIMATION_PLAYSTATE_KEY = 'PlayState';
var NG_ANIMATE_PARENT_KEY = '$$ngAnimateKey';
var NG_ANIMATE_CSS_DATA_KEY = '$$ngAnimateCSS3Data';
var ELAPSED_TIME_MAX_DECIMAL_PLACES = 3;
@@ -1455,47 +1457,33 @@ angular.module('ngAnimate', ['ng'])
var transitionDelay = 0;
var animationDuration = 0;
var animationDelay = 0;
var transitionDelayStyle;
var animationDelayStyle;
var transitionDurationStyle;
var transitionPropertyStyle;
//we want all the styles defined before and after
forEach(element, function(element) {
if (element.nodeType == ELEMENT_NODE) {
var elementStyles = $window.getComputedStyle(element) || {};
transitionDurationStyle = elementStyles[TRANSITION_PROP + DURATION_KEY];
var transitionDurationStyle = elementStyles[TRANSITION_PROP + DURATION_KEY];
transitionDuration = Math.max(parseMaxTime(transitionDurationStyle), transitionDuration);
transitionPropertyStyle = elementStyles[TRANSITION_PROP + PROPERTY_KEY];
transitionDelayStyle = elementStyles[TRANSITION_PROP + DELAY_KEY];
var transitionDelayStyle = elementStyles[TRANSITION_PROP + DELAY_KEY];
transitionDelay = Math.max(parseMaxTime(transitionDelayStyle), transitionDelay);
animationDelayStyle = elementStyles[ANIMATION_PROP + DELAY_KEY];
animationDelay = Math.max(parseMaxTime(animationDelayStyle), animationDelay);
var animationDelayStyle = elementStyles[ANIMATION_PROP + DELAY_KEY];
animationDelay = Math.max(parseMaxTime(elementStyles[ANIMATION_PROP + DELAY_KEY]), animationDelay);
var aDuration = parseMaxTime(elementStyles[ANIMATION_PROP + DURATION_KEY]);
if (aDuration > 0) {
aDuration *= parseInt(elementStyles[ANIMATION_PROP + ANIMATION_ITERATION_COUNT_KEY], 10) || 1;
}
animationDuration = Math.max(aDuration, animationDuration);
}
});
data = {
total : 0,
transitionPropertyStyle: transitionPropertyStyle,
transitionDurationStyle: transitionDurationStyle,
transitionDelayStyle: transitionDelayStyle,
transitionDelay: transitionDelay,
transitionDuration: transitionDuration,
animationDelayStyle: animationDelayStyle,
animationDelay: animationDelay,
animationDuration: animationDuration
};
@@ -1571,18 +1559,17 @@ angular.module('ngAnimate', ['ng'])
running : formerData.running || 0,
itemIndex : itemIndex,
blockTransition : blockTransition,
blockAnimation : blockAnimation,
closeAnimationFns : closeAnimationFns
});
var node = extractElementNode(element);
if (blockTransition) {
node.style[TRANSITION_PROP + PROPERTY_KEY] = 'none';
blockTransitions(node, true);
}
if (blockAnimation) {
node.style[ANIMATION_PROP] = 'none 0s';
blockAnimations(node, true);
}
return true;
@@ -1597,22 +1584,43 @@ angular.module('ngAnimate', ['ng'])
}
if (elementData.blockTransition) {
node.style[TRANSITION_PROP + PROPERTY_KEY] = '';
}
if (elementData.blockAnimation) {
node.style[ANIMATION_PROP] = '';
blockTransitions(node, false);
}
var activeClassName = '';
var pendingClassName = '';
forEach(className.split(' '), function(klass, i) {
activeClassName += (i > 0 ? ' ' : '') + klass + '-active';
var prefix = (i > 0 ? ' ' : '') + klass;
activeClassName += prefix + '-active';
pendingClassName += prefix + '-pending';
});
element.addClass(activeClassName);
var style = '';
var appliedStyles = [];
var itemIndex = elementData.itemIndex;
var stagger = elementData.stagger;
var staggerTime = 0;
if (itemIndex > 0) {
var transitionStaggerDelay = 0;
if (stagger.transitionDelay > 0 && stagger.transitionDuration === 0) {
transitionStaggerDelay = stagger.transitionDelay * itemIndex;
}
var animationStaggerDelay = 0;
if (stagger.animationDelay > 0 && stagger.animationDuration === 0) {
animationStaggerDelay = stagger.animationDelay * itemIndex;
appliedStyles.push(CSS_PREFIX + 'animation-play-state');
}
staggerTime = Math.round(Math.max(transitionStaggerDelay, animationStaggerDelay) * 100) / 100;
}
if (!staggerTime) {
element.addClass(activeClassName);
}
var eventCacheKey = elementData.cacheKey + ' ' + activeClassName;
var timings = getElementAnimationDetails(element, eventCacheKey);
var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration);
if (maxDuration === 0) {
element.removeClass(activeClassName);
@@ -1622,46 +1630,36 @@ angular.module('ngAnimate', ['ng'])
}
var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay);
var stagger = elementData.stagger;
var itemIndex = elementData.itemIndex;
var maxDelayTime = maxDelay * ONE_SECOND;
var style = '', appliedStyles = [];
if (timings.transitionDuration > 0) {
var propertyStyle = timings.transitionPropertyStyle;
if (propertyStyle.indexOf('all') == -1) {
style += CSS_PREFIX + 'transition-property: ' + propertyStyle + ';';
style += CSS_PREFIX + 'transition-duration: ' + timings.transitionDurationStyle + ';';
appliedStyles.push(CSS_PREFIX + 'transition-property');
appliedStyles.push(CSS_PREFIX + 'transition-duration');
}
}
if (itemIndex > 0) {
if (stagger.transitionDelay > 0 && stagger.transitionDuration === 0) {
var delayStyle = timings.transitionDelayStyle;
style += CSS_PREFIX + 'transition-delay: ' +
prepareStaggerDelay(delayStyle, stagger.transitionDelay, itemIndex) + '; ';
appliedStyles.push(CSS_PREFIX + 'transition-delay');
}
if (stagger.animationDelay > 0 && stagger.animationDuration === 0) {
style += CSS_PREFIX + 'animation-delay: ' +
prepareStaggerDelay(timings.animationDelayStyle, stagger.animationDelay, itemIndex) + '; ';
appliedStyles.push(CSS_PREFIX + 'animation-delay');
}
}
if (appliedStyles.length > 0) {
//the element being animated may sometimes contain comment nodes in
//the jqLite object, so we're safe to use a single variable to house
//the styles since there is always only one element being animated
var oldStyle = node.getAttribute('style') || '';
node.setAttribute('style', oldStyle + '; ' + style);
if (oldStyle.charAt(oldStyle.length-1) !== ';') {
oldStyle += ';';
}
node.setAttribute('style', oldStyle + ' ' + style);
}
var startTime = Date.now();
var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT;
var animationTime = (maxDelay + maxDuration) * CLOSING_TIME_BUFFER;
var totalTime = (staggerTime + animationTime) * ONE_SECOND;
var staggerTimeout;
if (staggerTime > 0) {
element.addClass(pendingClassName);
staggerTimeout = $timeout(function() {
staggerTimeout = null;
element.addClass(activeClassName);
element.removeClass(pendingClassName);
if (timings.animationDuration > 0) {
blockAnimations(node, false);
}
}, staggerTime * ONE_SECOND, false);
}
element.on(css3AnimationEvents, onAnimationProgress);
elementData.closeAnimationFns.push(function() {
@@ -1669,10 +1667,6 @@ angular.module('ngAnimate', ['ng'])
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;
@@ -1683,6 +1677,10 @@ angular.module('ngAnimate', ['ng'])
function onEnd(cancelled) {
element.off(css3AnimationEvents, onAnimationProgress);
element.removeClass(activeClassName);
element.removeClass(pendingClassName);
if (staggerTimeout) {
$timeout.cancel(staggerTimeout);
}
animateClose(element, className);
var node = extractElementNode(element);
for (var i in appliedStyles) {
@@ -1712,13 +1710,12 @@ angular.module('ngAnimate', ['ng'])
}
}
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 blockTransitions(node, bool) {
node.style[TRANSITION_PROP + PROPERTY_KEY] = bool ? 'none' : '';
}
function blockAnimations(node, bool) {
node.style[ANIMATION_PROP + ANIMATION_PLAYSTATE_KEY] = bool ? 'paused' : '';
}
function animateBefore(animationEvent, element, className, calculationDecorator) {

View File

@@ -5,6 +5,17 @@ describe("ngAnimate", function() {
beforeEach(module('ngAnimate'));
beforeEach(module('ngAnimateMock'));
function getMaxValue(prop, element, $window) {
var node = element[0];
var cs = $window.getComputedStyle(node);
var prop0 = 'webkit' + prop.charAt(0).toUpperCase() + prop.substr(1);
var values = (cs[prop0] || cs[prop]).split(/\s*,\s*/);
var maxDelay = 0;
forEach(values, function(value) {
maxDelay = Math.max(parseFloat(value) || 0, maxDelay);
});
return maxDelay;
}
it("should disable animations on bootstrap for structural animations even after the first digest has passed", function() {
var hasBeenAnimated = false;
@@ -1161,7 +1172,7 @@ describe("ngAnimate", function() {
);
it("should stagger the items when the correct CSS class is provided",
it("should pause the playstate when performing a stagger animation",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) {
if(!$sniffer.animations) return;
@@ -1198,10 +1209,9 @@ describe("ngAnimate", function() {
$animate.triggerReflow();
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).toMatch(/animation-delay: 0\.1\d*s/);
expect(elements[2].attr('style')).toMatch(/animation-delay: 0\.2\d*s/);
expect(elements[3].attr('style')).toMatch(/animation-delay: 0\.3\d*s/);
expect(elements[4].attr('style')).toMatch(/animation-delay: 0\.4\d*s/);
for(i = 1; i < 5; i++) {
expect(elements[i].attr('style')).toMatch(/animation-play-state:\s*paused/);
}
//final closing timeout
$timeout.flush();
@@ -1220,10 +1230,9 @@ describe("ngAnimate", function() {
$timeout.verifyNoPendingTasks();
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).not.toMatch(/animation-delay: 0\.1\d*s/);
expect(elements[2].attr('style')).not.toMatch(/animation-delay: 0\.2\d*s/);
expect(elements[3].attr('style')).not.toMatch(/animation-delay: 0\.3\d*s/);
expect(elements[4].attr('style')).not.toMatch(/animation-delay: 0\.4\d*s/);
for(i=1;i<5;i++) {
expect(elements[i].attr('style')).not.toMatch(/animation-play-state:\s*paused/);
}
}));
@@ -1255,23 +1264,26 @@ describe("ngAnimate", function() {
$rootScope.$digest();
expect(elements[0].attr('style')).toBeUndefined();
expect(elements[1].attr('style')).toMatch(/animation:.*?none/);
expect(elements[2].attr('style')).toMatch(/animation:.*?none/);
expect(elements[3].attr('style')).toMatch(/animation:.*?none/);
for(i = 1; i < 4; i++) {
expect(elements[i].attr('style')).toMatch(/animation-play-state:\s*paused/);
}
$animate.triggerReflow();
expect(elements[0].attr('style')).toBeUndefined();
expect(elements[1].attr('style')).not.toMatch(/animation:.*?none/);
expect(elements[1].attr('style')).toMatch(/animation-delay: 0.2\d*s/);
expect(elements[2].attr('style')).not.toMatch(/animation:.*?none/);
expect(elements[2].attr('style')).toMatch(/animation-delay: 0.4\d*s/);
expect(elements[3].attr('style')).not.toMatch(/animation:.*?none/);
expect(elements[3].attr('style')).toMatch(/animation-delay: 0.6\d*s/);
for(i = 1; i < 4; i++) {
expect(elements[i].attr('style')).toMatch(/animation-play-state:\s*paused/);
}
$timeout.flush(800);
for(i = 1; i < 4; i++) {
expect(elements[i].attr('style')).not.toMatch(/animation-play-state/);
}
}));
it("should stagger items when multiple animation durations/delays are defined",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) {
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $window) {
if(!$sniffer.transitions) return;
@@ -1298,10 +1310,19 @@ describe("ngAnimate", function() {
$rootScope.$digest();
$animate.triggerReflow();
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).toMatch(/animation-delay: 1\.1\d*s,\s*2\.1\d*s/);
expect(elements[2].attr('style')).toMatch(/animation-delay: 1\.2\d*s,\s*2\.2\d*s/);
expect(elements[3].attr('style')).toMatch(/animation-delay: 1\.3\d*s,\s*2\.3\d*s/);
for(i = 1; i < 4; i++) {
expect(elements[i]).not.toHaveClass('ng-enter-active');
expect(elements[i]).toHaveClass('ng-enter-pending');
expect(getMaxValue('animationDelay', elements[i], $window)).toBe(2);
}
$timeout.flush(300);
for(i = 1; i < 4; i++) {
expect(elements[i]).toHaveClass('ng-enter-active');
expect(elements[i]).not.toHaveClass('ng-enter-pending');
expect(getMaxValue('animationDelay', elements[i], $window)).toBe(2);
}
}));
});
@@ -1577,7 +1598,7 @@ describe("ngAnimate", function() {
}));
it("should stagger the items when the correct CSS class is provided",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) {
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $browser) {
if(!$sniffer.transitions) return;
@@ -1612,11 +1633,8 @@ describe("ngAnimate", function() {
$rootScope.$digest();
$animate.triggerReflow();
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).toMatch(/transition-delay: 0\.1\d*s/);
expect(elements[2].attr('style')).toMatch(/transition-delay: 0\.2\d*s/);
expect(elements[3].attr('style')).toMatch(/transition-delay: 0\.3\d*s/);
expect(elements[4].attr('style')).toMatch(/transition-delay: 0\.4\d*s/);
expect($browser.deferredFns.length).toEqual(5); //4 staggers + 1 combined timeout
$timeout.flush();
for(i = 0; i < 5; i++) {
dealoc(elements[i]);
@@ -1629,16 +1647,12 @@ describe("ngAnimate", function() {
$rootScope.$digest();
$animate.triggerReflow();
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).not.toMatch(/transition-delay: 0\.1\d*s/);
expect(elements[2].attr('style')).not.toMatch(/transition-delay: 0\.2\d*s/);
expect(elements[3].attr('style')).not.toMatch(/transition-delay: 0\.3\d*s/);
expect(elements[4].attr('style')).not.toMatch(/transition-delay: 0\.4\d*s/);
expect($browser.deferredFns.length).toEqual(0); //no animation was triggered
}));
it("should stagger items when multiple transition durations/delays are defined",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) {
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $window) {
if(!$sniffer.transitions) return;
@@ -1665,11 +1679,19 @@ describe("ngAnimate", function() {
$rootScope.$digest();
$animate.triggerReflow();
expect(elements[0].attr('style')).toMatch(/transition-duration: 1\d*s,\s*3\d*s;/);
expect(elements[0].attr('style')).not.toContain('transition-delay');
expect(elements[1].attr('style')).toMatch(/transition-delay: 2\.1\d*s,\s*4\.1\d*s/);
expect(elements[2].attr('style')).toMatch(/transition-delay: 2\.2\d*s,\s*4\.2\d*s/);
expect(elements[3].attr('style')).toMatch(/transition-delay: 2\.3\d*s,\s*4\.3\d*s/);
for(i = 1; i < 4; i++) {
expect(elements[i]).not.toHaveClass('ng-enter-active');
expect(elements[i]).toHaveClass('ng-enter-pending');
expect(getMaxValue('transitionDelay', elements[i], $window)).toBe(4);
}
$timeout.flush(300);
for(i = 1; i < 4; i++) {
expect(elements[i]).toHaveClass('ng-enter-active');
expect(elements[i]).not.toHaveClass('ng-enter-pending');
expect(getMaxValue('transitionDelay', elements[i], $window)).toBe(4);
}
}));
@@ -1811,20 +1833,22 @@ describe("ngAnimate", function() {
$animate.triggerReflow(); //reflow
expect(element.children().length).toBe(5);
for(i = 0; i < 5; i++) {
expect(kids[i].hasClass('ng-enter-active')).toBe(true);
for(i = 1; i < 5; i++) {
expect(kids[i]).not.toHaveClass('ng-enter-active');
expect(kids[i]).toHaveClass('ng-enter-pending');
}
$timeout.flush(7500);
$timeout.flush(2000);
for(i = 0; i < 5; i++) {
expect(kids[i].hasClass('ng-enter-active')).toBe(true);
for(i = 1; i < 5; i++) {
expect(kids[i]).toHaveClass('ng-enter-active');
expect(kids[i]).not.toHaveClass('ng-enter-pending');
}
//(stagger * index) + (duration + delay) * 150%
//0.5 * 4 + 5 * 1.5 = 9500;
//9500 - 7500 = 2000
$timeout.flush(1999); //remove 1999 more
//9500 - 2000 - 7499 = 1
$timeout.flush(7499);
for(i = 0; i < 5; i++) {
expect(kids[i].hasClass('ng-enter-active')).toBe(true);
@@ -1837,6 +1861,52 @@ describe("ngAnimate", function() {
}
}));
it("should cancel all the existing stagger timers when the animation is cancelled",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $browser) {
if (!$sniffer.transitions) return;
ss.addRule('.entering-element.ng-enter',
'-webkit-transition:5s linear all;' +
'transition:5s linear all;');
ss.addRule('.entering-element.ng-enter-stagger',
'-webkit-transition-delay:1s;' +
'transition-delay:1s;');
var cancellations = [];
element = $compile(html('<div></div>'))($rootScope);
var kids = [];
for(var i = 0; i < 5; i++) {
kids.push(angular.element('<div class="entering-element"></div>'));
cancellations.push($animate.enter(kids[i], element));
}
$rootScope.$digest();
$animate.triggerReflow(); //reflow
expect(element.children().length).toBe(5);
for(i = 1; i < 5; i++) {
expect(kids[i]).not.toHaveClass('ng-enter-active');
expect(kids[i]).toHaveClass('ng-enter-pending');
}
expect($browser.deferredFns.length).toEqual(5); //4 staggers + 1 combined timeout
forEach(cancellations, function(promise) {
$animate.cancel(promise);
});
for(i = 1; i < 5; i++) {
expect(kids[i]).not.toHaveClass('ng-enter');
expect(kids[i]).not.toHaveClass('ng-enter-active');
expect(kids[i]).not.toHaveClass('ng-enter-pending');
}
//the staggers are gone, but the global timeout remains
expect($browser.deferredFns.length).toEqual(1);
}));
it("should not allow the closing animation to close off a successive animation midway",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout) {
@@ -1873,7 +1943,7 @@ describe("ngAnimate", function() {
it("should apply staggering to both transitions and keyframe animations when used within the same animation",
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement) {
inject(function($animate, $rootScope, $compile, $sniffer, $timeout, $document, $rootElement, $browser) {
if(!$sniffer.transitions) return;
@@ -1903,14 +1973,23 @@ describe("ngAnimate", function() {
$rootScope.$digest();
$animate.triggerReflow();
expect($browser.deferredFns.length).toEqual(3); //2 staggers + 1 combined timeout
expect(elements[0].attr('style')).toBeFalsy();
expect(elements[1].attr('style')).toMatch(/animation-play-state:\s*paused/);
expect(elements[2].attr('style')).toMatch(/animation-play-state:\s*paused/);
expect(elements[1].attr('style')).toMatch(/transition-delay:\s+1.1\d*/);
expect(elements[1].attr('style')).toMatch(/animation-delay: 1\.2\d*s,\s*2\.2\d*s/);
for(i = 1; i < 3; i++) {
expect(elements[i]).not.toHaveClass('ng-enter-active');
expect(elements[i]).toHaveClass('ng-enter-pending');
}
expect(elements[2].attr('style')).toMatch(/transition-delay:\s+1.2\d*/);
expect(elements[2].attr('style')).toMatch(/animation-delay: 1\.4\d*s,\s*2\.4\d*s/);
$timeout.flush(0.4 * 1000);
for(i = 1; i < 3; i++) {
expect(elements[i]).toHaveClass('ng-enter-active');
expect(elements[i]).not.toHaveClass('ng-enter-pending');
}
for(i = 0; i < 3; i++) {
browserTrigger(elements[i],'transitionend', { timeStamp: Date.now() + 22000, elapsedTime: 22000 });