Skip to content

Commit 7d8d0a4

Browse files
matskojamesdaily
authored andcommitted
fix($animate): ensure addClass/removeClass animations do not snap during reflow
Closes angular#4892
1 parent d6b5092 commit 7d8d0a4

File tree

2 files changed

+57
-4
lines changed

2 files changed

+57
-4
lines changed

src/ngAnimate/animate.js

+27-4
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,8 @@ angular.module('ngAnimate', ['ng'])
549549
and the onComplete callback will be fired once the animation is fully complete.
550550
*/
551551
function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) {
552-
var classes = (element.attr('class') || '') + ' ' + className;
552+
var currentClassName = element.attr('class') || '';
553+
var classes = currentClassName + ' ' + className;
553554
var animationLookup = (' ' + classes).replace(/\s+/g,'.');
554555
if (!parentElement) {
555556
parentElement = afterElement ? afterElement.parent() : element.parent();
@@ -602,21 +603,42 @@ angular.module('ngAnimate', ['ng'])
602603
return;
603604
}
604605

606+
//this value will be searched for class-based CSS className lookup. Therefore,
607+
//we prefix and suffix the current className value with spaces to avoid substring
608+
//lookups of className tokens
609+
var futureClassName = ' ' + currentClassName + ' ';
605610
if(ngAnimateState.running) {
606611
//if an animation is currently running on the element then lets take the steps
607612
//to cancel that animation and fire any required callbacks
608613
$timeout.cancel(ngAnimateState.closeAnimationTimeout);
609614
cleanup(element);
610615
cancelAnimations(ngAnimateState.animations);
611-
(ngAnimateState.done || noop)(true);
616+
617+
//if the class is removed during the reflow then it will revert the styles temporarily
618+
//back to the base class CSS styling causing a jump-like effect to occur. This check
619+
//here ensures that the domOperation is only performed after the reflow has commenced
620+
if(ngAnimateState.beforeComplete) {
621+
(ngAnimateState.done || noop)(true);
622+
} else if(isClassBased && !ngAnimateState.structural) {
623+
//class-based animations will compare element className values after cancelling the
624+
//previous animation to see if the element properties already contain the final CSS
625+
//class and if so then the animation will be skipped. Since the domOperation will
626+
//be performed only after the reflow is complete then our element's className value
627+
//will be invalid. Therefore the same string manipulation that would occur within the
628+
//DOM operation will be performed below so that the class comparison is valid...
629+
futureClassName = ngAnimateState.event == 'removeClass' ?
630+
futureClassName.replace(ngAnimateState.className, '') :
631+
futureClassName + ngAnimateState.className + ' ';
632+
}
612633
}
613634

614635
//There is no point in perform a class-based animation if the element already contains
615636
//(on addClass) or doesn't contain (on removeClass) the className being animated.
616637
//The reason why this is being called after the previous animations are cancelled
617638
//is so that the CSS classes present on the element can be properly examined.
618-
if((animationEvent == 'addClass' && element.hasClass(className)) ||
619-
(animationEvent == 'removeClass' && !element.hasClass(className))) {
639+
var classNameToken = ' ' + className + ' ';
640+
if((animationEvent == 'addClass' && futureClassName.indexOf(classNameToken) >= 0) ||
641+
(animationEvent == 'removeClass' && futureClassName.indexOf(classNameToken) == -1)) {
620642
fireDOMOperation();
621643
fireDoneCallbackAsync();
622644
return;
@@ -628,6 +650,7 @@ angular.module('ngAnimate', ['ng'])
628650

629651
element.data(NG_ANIMATE_STATE, {
630652
running:true,
653+
event:animationEvent,
631654
className:className,
632655
structural:!isClassBased,
633656
animations:animations,

test/ngAnimate/animateSpec.js

+30
Original file line numberDiff line numberDiff line change
@@ -2515,6 +2515,36 @@ describe("ngAnimate", function() {
25152515
expect(element.hasClass('yellow-add')).toBe(true);
25162516
}));
25172517

2518+
it("should cancel and perform the dom operation only after the reflow has run",
2519+
inject(function($compile, $rootScope, $animate, $sniffer, $timeout) {
2520+
2521+
if (!$sniffer.transitions) return;
2522+
2523+
ss.addRule('.green-add', '-webkit-transition:1s linear all;' +
2524+
'transition:1s linear all;');
2525+
2526+
ss.addRule('.red-add', '-webkit-transition:1s linear all;' +
2527+
'transition:1s linear all;');
2528+
2529+
var element = $compile('<div></div>')($rootScope);
2530+
$rootElement.append(element);
2531+
jqLite($document[0].body).append($rootElement);
2532+
2533+
$animate.addClass(element, 'green');
2534+
expect(element.hasClass('green-add')).toBe(true);
2535+
2536+
$animate.addClass(element, 'red');
2537+
expect(element.hasClass('red-add')).toBe(true);
2538+
2539+
expect(element.hasClass('green')).toBe(false);
2540+
expect(element.hasClass('red')).toBe(false);
2541+
2542+
$timeout.flush();
2543+
2544+
expect(element.hasClass('green')).toBe(true);
2545+
expect(element.hasClass('red')).toBe(true);
2546+
}));
2547+
25182548
it('should enable and disable animations properly on the root element', function() {
25192549
var count = 0;
25202550
module(function($animateProvider) {

0 commit comments

Comments
 (0)