fix(animations): restore auto-style support for removed DOM nodes (#18787)
PR Close #18787
This commit is contained in:
parent
29aa8b33df
commit
70628112e8
|
@ -967,16 +967,39 @@ export class TransitionAnimationEngine {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// PRE STAGE: fill the ! styles
|
// this is a special case for nodes that will be removed (either by)
|
||||||
const preStylesMap = allPreStyleElements.size ?
|
// having their own leave animations or by being queried in a container
|
||||||
cloakAndComputeStyles(
|
// that will be removed once a parent animation is complete. The idea
|
||||||
this.driver, enterNodesWithoutAnimations, allPreStyleElements, PRE_STYLE) :
|
// here is that * styles must be identical to ! styles because of
|
||||||
new Map<any, ɵStyleData>();
|
// backwards compatibility (* is also filled in by default in many places).
|
||||||
|
// Otherwise * styles will return an empty value or auto since the element
|
||||||
|
// that is being getComputedStyle'd will not be visible (since * = destination)
|
||||||
|
const replaceNodes = allLeaveNodes.filter(node => {
|
||||||
|
return replacePostStylesAsPre(node, allPreStyleElements, allPostStyleElements);
|
||||||
|
});
|
||||||
|
|
||||||
// POST STAGE: fill the * styles
|
// POST STAGE: fill the * styles
|
||||||
const postStylesMap = cloakAndComputeStyles(
|
const [postStylesMap, allLeaveQueriedNodes] = cloakAndComputeStyles(
|
||||||
this.driver, leaveNodesWithoutAnimations, allPostStyleElements, AUTO_STYLE);
|
this.driver, leaveNodesWithoutAnimations, allPostStyleElements, AUTO_STYLE);
|
||||||
|
|
||||||
|
allLeaveQueriedNodes.forEach(node => {
|
||||||
|
if (replacePostStylesAsPre(node, allPreStyleElements, allPostStyleElements)) {
|
||||||
|
replaceNodes.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PRE STAGE: fill the ! styles
|
||||||
|
const [preStylesMap] = allPreStyleElements.size ?
|
||||||
|
cloakAndComputeStyles(
|
||||||
|
this.driver, enterNodesWithoutAnimations, allPreStyleElements, PRE_STYLE) :
|
||||||
|
[new Map<any, ɵStyleData>()];
|
||||||
|
|
||||||
|
replaceNodes.forEach(node => {
|
||||||
|
const post = postStylesMap.get(node);
|
||||||
|
const pre = preStylesMap.get(node);
|
||||||
|
postStylesMap.set(node, { ...post, ...pre } as any);
|
||||||
|
});
|
||||||
|
|
||||||
const rootPlayers: TransitionAnimationPlayer[] = [];
|
const rootPlayers: TransitionAnimationPlayer[] = [];
|
||||||
const subPlayers: TransitionAnimationPlayer[] = [];
|
const subPlayers: TransitionAnimationPlayer[] = [];
|
||||||
queuedInstructions.forEach(entry => {
|
queuedInstructions.forEach(entry => {
|
||||||
|
@ -1413,9 +1436,10 @@ function cloakElement(element: any, value?: string) {
|
||||||
|
|
||||||
function cloakAndComputeStyles(
|
function cloakAndComputeStyles(
|
||||||
driver: AnimationDriver, elements: any[], elementPropsMap: Map<any, Set<string>>,
|
driver: AnimationDriver, elements: any[], elementPropsMap: Map<any, Set<string>>,
|
||||||
defaultStyle: string): Map<any, ɵStyleData> {
|
defaultStyle: string): [Map<any, ɵStyleData>, any[]] {
|
||||||
const cloakVals = elements.map(element => cloakElement(element));
|
const cloakVals = elements.map(element => cloakElement(element));
|
||||||
const valuesMap = new Map<any, ɵStyleData>();
|
const valuesMap = new Map<any, ɵStyleData>();
|
||||||
|
const failedElements: any[] = [];
|
||||||
|
|
||||||
elementPropsMap.forEach((props: Set<string>, element: any) => {
|
elementPropsMap.forEach((props: Set<string>, element: any) => {
|
||||||
const styles: ɵStyleData = {};
|
const styles: ɵStyleData = {};
|
||||||
|
@ -1426,13 +1450,14 @@ function cloakAndComputeStyles(
|
||||||
// by a parent animation element being detached.
|
// by a parent animation element being detached.
|
||||||
if (!value || value.length == 0) {
|
if (!value || value.length == 0) {
|
||||||
element[REMOVAL_FLAG] = NULL_REMOVED_QUERIED_STATE;
|
element[REMOVAL_FLAG] = NULL_REMOVED_QUERIED_STATE;
|
||||||
|
failedElements.push(element);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
valuesMap.set(element, styles);
|
valuesMap.set(element, styles);
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.forEach((element, i) => cloakElement(element, cloakVals[i]));
|
elements.forEach((element, i) => cloakElement(element, cloakVals[i]));
|
||||||
return valuesMap;
|
return [valuesMap, failedElements];
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -1539,3 +1564,20 @@ function objEquals(a: {[key: string]: any}, b: {[key: string]: any}): boolean {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replacePostStylesAsPre(
|
||||||
|
element: any, allPreStyleElements: Map<any, Set<string>>,
|
||||||
|
allPostStyleElements: Map<any, Set<string>>): boolean {
|
||||||
|
const postEntry = allPostStyleElements.get(element);
|
||||||
|
if (!postEntry) return false;
|
||||||
|
|
||||||
|
let preEntry = allPreStyleElements.get(element);
|
||||||
|
if (preEntry) {
|
||||||
|
postEntry.forEach(data => preEntry !.add(data));
|
||||||
|
} else {
|
||||||
|
allPreStyleElements.set(element, postEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
allPostStyleElements.delete(element);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
|
@ -1087,59 +1087,61 @@ export function main() {
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should animate removals of nodes to the `void` state for each animation trigger', () => {
|
it('should animate removals of nodes to the `void` state for each animation trigger, but treat all auto styles as pre styles',
|
||||||
@Component({
|
() => {
|
||||||
selector: 'ani-cmp',
|
@Component({
|
||||||
template: `
|
selector: 'ani-cmp',
|
||||||
|
template: `
|
||||||
<div *ngIf="exp" class="ng-if" [@trig1]="exp2" @trig2></div>
|
<div *ngIf="exp" class="ng-if" [@trig1]="exp2" @trig2></div>
|
||||||
`,
|
`,
|
||||||
animations: [
|
animations: [
|
||||||
trigger('trig1', [transition('state => void', [animate(1000, style({opacity: 0}))])]),
|
trigger(
|
||||||
trigger('trig2', [transition(':leave', [animate(1000, style({width: '0px'}))])])
|
'trig1', [transition('state => void', [animate(1000, style({opacity: 0}))])]),
|
||||||
]
|
trigger('trig2', [transition(':leave', [animate(1000, style({width: '0px'}))])])
|
||||||
})
|
]
|
||||||
class Cmp {
|
})
|
||||||
public exp = true;
|
class Cmp {
|
||||||
public exp2 = 'state';
|
public exp = true;
|
||||||
}
|
public exp2 = 'state';
|
||||||
|
}
|
||||||
|
|
||||||
TestBed.configureTestingModule({declarations: [Cmp]});
|
TestBed.configureTestingModule({declarations: [Cmp]});
|
||||||
|
|
||||||
const engine = TestBed.get(ɵAnimationEngine);
|
const engine = TestBed.get(ɵAnimationEngine);
|
||||||
const fixture = TestBed.createComponent(Cmp);
|
const fixture = TestBed.createComponent(Cmp);
|
||||||
const cmp = fixture.componentInstance;
|
const cmp = fixture.componentInstance;
|
||||||
cmp.exp = true;
|
cmp.exp = true;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
engine.flush();
|
engine.flush();
|
||||||
resetLog();
|
resetLog();
|
||||||
|
|
||||||
const element = getDOM().querySelector(fixture.nativeElement, '.ng-if');
|
const element = getDOM().querySelector(fixture.nativeElement, '.ng-if');
|
||||||
assertHasParent(element, true);
|
assertHasParent(element, true);
|
||||||
|
|
||||||
cmp.exp = false;
|
cmp.exp = false;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
engine.flush();
|
engine.flush();
|
||||||
|
|
||||||
assertHasParent(element, true);
|
assertHasParent(element, true);
|
||||||
|
|
||||||
expect(getLog().length).toEqual(2);
|
expect(getLog().length).toEqual(2);
|
||||||
|
|
||||||
const player2 = getLog().pop() !;
|
const player2 = getLog().pop() !;
|
||||||
const player1 = getLog().pop() !;
|
const player1 = getLog().pop() !;
|
||||||
|
|
||||||
expect(player2.keyframes).toEqual([
|
expect(player2.keyframes).toEqual([
|
||||||
{width: AUTO_STYLE, offset: 0},
|
{width: PRE_STYLE, offset: 0},
|
||||||
{width: '0px', offset: 1},
|
{width: '0px', offset: 1},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(player1.keyframes).toEqual([
|
expect(player1.keyframes).toEqual([
|
||||||
{opacity: AUTO_STYLE, offset: 0}, {opacity: '0', offset: 1}
|
{opacity: PRE_STYLE, offset: 0}, {opacity: '0', offset: 1}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
player2.finish();
|
player2.finish();
|
||||||
player1.finish();
|
player1.finish();
|
||||||
assertHasParent(element, false);
|
assertHasParent(element, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly cancel all existing animations when a removal occurs', () => {
|
it('should properly cancel all existing animations when a removal occurs', () => {
|
||||||
@Component({
|
@Component({
|
||||||
|
|
|
@ -5,8 +5,9 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
import {animate, query, state, style, transition, trigger} from '@angular/animations';
|
import {animate, group, query, state, style, transition, trigger} from '@angular/animations';
|
||||||
import {AnimationDriver, ɵAnimationEngine, ɵWebAnimationsDriver, ɵWebAnimationsPlayer, ɵsupportsWebAnimations} from '@angular/animations/browser';
|
import {AnimationDriver, ɵAnimationEngine, ɵWebAnimationsDriver, ɵWebAnimationsPlayer, ɵsupportsWebAnimations} from '@angular/animations/browser';
|
||||||
|
import {TransitionAnimationPlayer} from '@angular/animations/browser/src/render/transition_animation_engine';
|
||||||
import {AnimationGroupPlayer} from '@angular/animations/src/players/animation_group_player';
|
import {AnimationGroupPlayer} from '@angular/animations/src/players/animation_group_player';
|
||||||
import {Component} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||||
|
@ -127,7 +128,7 @@ export function main() {
|
||||||
template: `
|
template: `
|
||||||
<div [@myAnimation]="exp" #parent>
|
<div [@myAnimation]="exp" #parent>
|
||||||
<div *ngFor="let item of items" class="child" style="line-height:20px">
|
<div *ngFor="let item of items" class="child" style="line-height:20px">
|
||||||
- {{ item }}
|
- {{ item }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
@ -177,6 +178,164 @@ export function main() {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should treat * styles as ! when a removal animation is being rendered', () => {
|
||||||
|
@Component({
|
||||||
|
selector: 'ani-cmp',
|
||||||
|
styles: [`
|
||||||
|
.box {
|
||||||
|
width: 500px;
|
||||||
|
overflow:hidden;
|
||||||
|
background:orange;
|
||||||
|
line-height:300px;
|
||||||
|
font-size:100px;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<button (click)="toggle()">Open / Close</button>
|
||||||
|
<hr />
|
||||||
|
<div *ngIf="exp" @slide class="box">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
animations: [trigger(
|
||||||
|
'slide',
|
||||||
|
[
|
||||||
|
state('void', style({height: '0px'})),
|
||||||
|
state('*', style({height: '*'})),
|
||||||
|
transition('* => *', animate('500ms')),
|
||||||
|
])]
|
||||||
|
})
|
||||||
|
class Cmp {
|
||||||
|
exp = false;
|
||||||
|
|
||||||
|
toggle() { this.exp = !this.exp; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [Cmp]});
|
||||||
|
|
||||||
|
const engine = TestBed.get(ɵAnimationEngine);
|
||||||
|
const fixture = TestBed.createComponent(Cmp);
|
||||||
|
const cmp = fixture.componentInstance;
|
||||||
|
|
||||||
|
cmp.exp = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
let player = engine.players[0] !;
|
||||||
|
let webPlayer = player.getRealPlayer() as ɵWebAnimationsPlayer;
|
||||||
|
expect(webPlayer.keyframes).toEqual([
|
||||||
|
{height: '0px', offset: 0},
|
||||||
|
{height: '300px', offset: 1},
|
||||||
|
]);
|
||||||
|
player.finish();
|
||||||
|
|
||||||
|
cmp.exp = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
player = engine.players[0] !;
|
||||||
|
webPlayer = player.getRealPlayer() as ɵWebAnimationsPlayer;
|
||||||
|
expect(webPlayer.keyframes).toEqual([
|
||||||
|
{height: '300px', offset: 0},
|
||||||
|
{height: '0px', offset: 1},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat * styles as ! for queried items that are collected in a container that is being removed',
|
||||||
|
() => {
|
||||||
|
@Component({
|
||||||
|
selector: 'my-app',
|
||||||
|
styles: [`
|
||||||
|
.list .outer {
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.list .inner {
|
||||||
|
line-height:50px;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<button (click)="empty()">Empty</button>
|
||||||
|
<button (click)="middle()">Middle</button>
|
||||||
|
<button (click)="full()">Full</button>
|
||||||
|
<hr />
|
||||||
|
<div [@list]="exp" class="list">
|
||||||
|
<div *ngFor="let item of items" class="outer">
|
||||||
|
<div class="inner">
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
animations: [
|
||||||
|
trigger('list', [
|
||||||
|
transition(':enter', []),
|
||||||
|
transition('* => empty', [
|
||||||
|
query(':leave', [
|
||||||
|
animate(500, style({ height: '0px' }))
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
transition('* => full', [
|
||||||
|
query(':enter', [
|
||||||
|
style({ height: '0px' }),
|
||||||
|
animate(500, style({ height: '*' }))
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
class Cmp {
|
||||||
|
items: any[] = [];
|
||||||
|
|
||||||
|
get exp() { return this.items.length ? 'full' : 'empty'; }
|
||||||
|
|
||||||
|
empty() { this.items = []; }
|
||||||
|
|
||||||
|
full() { this.items = [0, 1, 2, 3, 4]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [Cmp]});
|
||||||
|
|
||||||
|
const engine = TestBed.get(ɵAnimationEngine);
|
||||||
|
const fixture = TestBed.createComponent(Cmp);
|
||||||
|
const cmp = fixture.componentInstance;
|
||||||
|
|
||||||
|
cmp.empty();
|
||||||
|
fixture.detectChanges();
|
||||||
|
let player = engine.players[0] !as TransitionAnimationPlayer;
|
||||||
|
player.finish();
|
||||||
|
|
||||||
|
cmp.full();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
player = engine.players[0] !as TransitionAnimationPlayer;
|
||||||
|
let queriedPlayers = (player.getRealPlayer() as AnimationGroupPlayer).players;
|
||||||
|
expect(queriedPlayers.length).toEqual(5);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (i = 0; i < queriedPlayers.length; i++) {
|
||||||
|
let player = queriedPlayers[i] as ɵWebAnimationsPlayer;
|
||||||
|
expect(player.keyframes).toEqual([
|
||||||
|
{height: '0px', offset: 0},
|
||||||
|
{height: '50px', offset: 1},
|
||||||
|
]);
|
||||||
|
player.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
cmp.empty();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
player = engine.players[0] !as TransitionAnimationPlayer;
|
||||||
|
queriedPlayers = (player.getRealPlayer() as AnimationGroupPlayer).players;
|
||||||
|
expect(queriedPlayers.length).toEqual(5);
|
||||||
|
|
||||||
|
for (i = 0; i < queriedPlayers.length; i++) {
|
||||||
|
let player = queriedPlayers[i] as ɵWebAnimationsPlayer;
|
||||||
|
expect(player.keyframes).toEqual([
|
||||||
|
{height: '50px', offset: 0},
|
||||||
|
{height: '0px', offset: 1},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should compute intermediate styles properly when an animation is cancelled', () => {
|
it('should compute intermediate styles properly when an animation is cancelled', () => {
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ani-cmp',
|
selector: 'ani-cmp',
|
||||||
|
|
Loading…
Reference in New Issue