feat(animations): support disabling animations for sub elements

Closes #16483
This commit is contained in:
Matias Niemelä 2017-07-06 10:32:32 -07:00 committed by Jason Aden
parent 3203639d7d
commit 8e28382e4a
7 changed files with 485 additions and 24 deletions

View File

@ -67,16 +67,22 @@ export class AnimationEngine {
this._transitionEngine.removeNode(namespaceId, element, context);
}
setProperty(namespaceId: string, element: any, property: string, value: any): boolean {
// @@property
if (property.charAt(0) == '@') {
process(namespaceId: string, element: any, property: string, value: any): boolean {
switch (property.charAt(0)) {
case '.':
if (property == '.disabled') {
this._transitionEngine.markElementAsDisabled(element, !!value);
}
return false;
case '@':
const [id, action] = parseTimelineCommand(property);
const args = value as any[];
this._timelineEngine.command(id, element, action, args);
return false;
}
default:
return this._transitionEngine.trigger(namespaceId, element, property, value);
}
}
listen(
namespaceId: string, element: any, eventName: string, eventPhase: string,

View File

@ -20,6 +20,8 @@ import {getOrSetAsInMap, listenOnPlayer, makeAnimationEvent, normalizeKeyframes,
const QUEUED_CLASSNAME = 'ng-animate-queued';
const QUEUED_SELECTOR = '.ng-animate-queued';
const DISABLED_CLASSNAME = 'ng-animate-disabled';
const DISABLED_SELECTOR = '.ng-animate-disabled';
const EMPTY_PLAYER_ARRAY: TransitionAnimationPlayer[] = [];
const NULL_REMOVAL_STATE: ElementAnimationState = {
@ -471,6 +473,8 @@ export class TransitionAnimationEngine {
public playersByElement = new Map<any, TransitionAnimationPlayer[]>();
public playersByQueriedElement = new Map<any, TransitionAnimationPlayer[]>();
public statesByElement = new Map<any, {[triggerName: string]: StateValue}>();
public disabledNodes = new Set<any>();
public totalAnimations = 0;
public totalQueuedPlayers = 0;
@ -612,6 +616,18 @@ export class TransitionAnimationEngine {
collectEnterElement(element: any) { this.collectedEnterElements.push(element); }
markElementAsDisabled(element: any, value: boolean) {
if (value) {
if (!this.disabledNodes.has(element)) {
this.disabledNodes.add(element);
addClass(element, DISABLED_CLASSNAME);
}
} else if (this.disabledNodes.has(element)) {
this.disabledNodes.delete(element);
removeClass(element, DISABLED_CLASSNAME);
}
}
removeNode(namespaceId: string, element: any, context: any, doNotRecurse?: boolean): void {
if (!isElementNode(element)) {
this._onRemovalComplete(element, context);
@ -709,6 +725,14 @@ export class TransitionAnimationEngine {
}
this._onRemovalComplete(element, details.setForRemoval);
}
if (this.driver.matchesElement(element, DISABLED_SELECTOR)) {
this.markElementAsDisabled(element, false);
}
this.driver.query(element, DISABLED_SELECTOR, true).forEach(node => {
this.markElementAsDisabled(element, false);
});
}
flush(microtaskId: number = -1) {
@ -766,6 +790,14 @@ export class TransitionAnimationEngine {
const allPreStyleElements = new Map<any, Set<string>>();
const allPostStyleElements = new Map<any, Set<string>>();
const disabledElementsSet = new Set<any>();
this.disabledNodes.forEach(node => {
const nodesThatAreDisabled = this.driver.query(node, QUEUED_SELECTOR, true);
for (let i = 0; i < nodesThatAreDisabled.length; i++) {
disabledElementsSet.add(nodesThatAreDisabled[i]);
}
});
const bodyNode = getBodyNode();
const allEnterNodes: any[] = this.collectedEnterElements.length ?
this.collectedEnterElements.filter(createIsRootFilterFn(this.collectedEnterElements)) :
@ -926,6 +958,11 @@ export class TransitionAnimationEngine {
// this means that it was never consumed by a parent animation which
// means that it is independent and therefore should be set for animation
if (subTimelines.has(element)) {
if (disabledElementsSet.has(element)) {
skippedPlayers.push(player);
return;
}
const innerPlayer = this._buildAnimation(
player.namespaceId, instruction, allPreviousPlayersMap, skippedPlayersMap, preStylesMap,
postStylesMap);

View File

@ -235,24 +235,25 @@ export interface AnimationStaggerMetadata extends AnimationMetadata {
/**
* `trigger` is an animation-specific function that is designed to be used inside of Angular's
animation DSL language. If this information is new, please navigate to the {@link
Component#animations component animations metadata page} to gain a better understanding of
how animations in Angular are used.
* animation DSL language. If this information is new, please navigate to the
* {@link Component#animations component animations metadata page} to gain a better
* understanding of how animations in Angular are used.
*
* `trigger` Creates an animation trigger which will a list of {@link state state} and {@link
transition transition} entries that will be evaluated when the expression bound to the trigger
changes.
* `trigger` Creates an animation trigger which will a list of {@link state state} and
* {@link transition transition} entries that will be evaluated when the expression
* bound to the trigger changes.
*
* Triggers are registered within the component annotation data under the {@link
Component#animations animations section}. An animation trigger can be placed on an element
within a template by referencing the name of the trigger followed by the expression value that the
trigger is bound to (in the form of `[@triggerName]="expression"`.
* Triggers are registered within the component annotation data under the
* {@link Component#animations animations section}. An animation trigger can be placed on an element
* within a template by referencing the name of the trigger followed by the expression value that
the
* trigger is bound to (in the form of `[@triggerName]="expression"`.
*
* ### Usage
*
* `trigger` will create an animation trigger reference based on the provided `name` value. The
provided `animation` value is expected to be an array consisting of {@link state state} and {@link
transition transition} declarations.
* provided `animation` value is expected to be an array consisting of {@link state state} and
* {@link transition transition} declarations.
*
* ```typescript
* @Component({
@ -278,9 +279,65 @@ export interface AnimationStaggerMetadata extends AnimationMetadata {
* ```html
* <!-- somewhere inside of my-component-tpl.html -->
* <div [@myAnimationTrigger]="myStatusExp">...</div>
tools/gulp-tasks/validate-commit-message.js ```
* ```
*
* {@example core/animation/ts/dsl/animation_example.ts region='Component'}
* ## Disable Child Animations
* A special animation control binding called `@.disabled` can be placed on an element which will
then disable animations for any inner animation triggers situated within the element.
*
* When true, the `@.disabled` binding will prevent inner animations from rendering. The example
below shows how to use this feature:
*
* ```ts
* @Component({
* selector: 'my-component',
* template: `
* <div [@.disabled]="isDisabled">
* <div [@childAnimation]="exp"></div>
* </div>
* `,
* animations: [
* trigger("childAnimation", [
* // ...
* ])
* ]
* })
* class MyComponent {
* isDisabled = true;
* exp = '...';
* }
* ```
*
* The `@childAnimation` trigger will not animate because `@.disabled` prevents it from happening
(when true).
*
* Note that `@.disbled` will only disable inner animations (any animations running on the same
element will not be disabled).
*
* ### Disabling Animations Application-wide
* When an area of the template is set to have animations disabled, **all** inner components will
also have their animations disabled as well. This means that all animations for an angular
application can be disabled by placing a host binding set on `@.disabled` on the topmost Angular
component.
*
* ```ts
* import {Component, HostBinding} from '@angular/core';
*
* @Component({
* selector: 'app-component',
* templateUrl: 'app.component.html',
* })
* class AppComponent {
* @HostBinding('@.disabled')
* public animationsDisabled = true;
* }
* ```
*
* ### What about animations that us `query()` and `animateChild()`?
* Despite inner animations being disabled, a parent animation can {@link query query} for inner
elements located in disabled areas of the template and still animate them as it sees fit. This is
also the case for when a sub animation is queried by a parent and then later animated using {@link
animateChild animateChild}.
*
* @experimental Animation support is experimental.
*/

View File

@ -1904,6 +1904,258 @@ export function main() {
}));
});
describe('animation control flags', () => {
describe('[@.disabled]', () => {
it('should disable child animations when set to true', () => {
@Component({
selector: 'if-cmp',
template: `
<div [@.disabled]="disableExp">
<div [@myAnimation]="exp"></div>
</div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
'* => 1, * => 2',
[
animate(1234, style({width: '100px'})),
]),
]),
]
})
class Cmp {
exp: any = false;
disableExp = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
fixture.detectChanges();
resetLog();
cmp.disableExp = true;
cmp.exp = '1';
fixture.detectChanges();
let players = getLog();
expect(players.length).toEqual(0);
cmp.disableExp = false;
cmp.exp = '2';
fixture.detectChanges();
players = getLog();
expect(players.length).toEqual(1);
expect(players[0].totalTime).toEqual(1234);
});
it('should not disable animations for the element that they are disabled on', () => {
@Component({
selector: 'if-cmp',
template: `
<div [@.disabled]="disableExp" [@myAnimation]="exp"></div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
'* => 1, * => 2',
[
animate(1234, style({width: '100px'})),
]),
]),
]
})
class Cmp {
exp: any = false;
disableExp = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
fixture.detectChanges();
resetLog();
cmp.disableExp = true;
cmp.exp = '1';
fixture.detectChanges();
let players = getLog();
expect(players.length).toEqual(1);
expect(players[0].totalTime).toEqual(1234);
resetLog();
cmp.disableExp = false;
cmp.exp = '2';
fixture.detectChanges();
players = getLog();
expect(players.length).toEqual(1);
expect(players[0].totalTime).toEqual(1234);
});
it('should respect inner disabled nodes once a parent becomes enabled', () => {
@Component({
selector: 'if-cmp',
template: `
<div [@.disabled]="disableParentExp">
<div [@.disabled]="disableChildExp">
<div [@myAnimation]="exp"></div>
</div>
</div>
`,
animations: [trigger(
'myAnimation',
[transition('* => 1, * => 2, * => 3', [animate(1234, style({width: '100px'}))])])]
})
class Cmp {
disableParentExp = false;
disableChildExp = false;
exp = '';
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
fixture.detectChanges();
resetLog();
cmp.disableParentExp = true;
cmp.disableChildExp = true;
cmp.exp = '1';
fixture.detectChanges();
let players = getLog();
expect(players.length).toEqual(0);
cmp.disableParentExp = false;
cmp.exp = '2';
fixture.detectChanges();
players = getLog();
expect(players.length).toEqual(0);
cmp.disableChildExp = false;
cmp.exp = '3';
fixture.detectChanges();
players = getLog();
expect(players.length).toEqual(1);
});
it('should properly handle dom operations when disabled', () => {
@Component({
selector: 'if-cmp',
template: `
<div [@.disabled]="disableExp" #parent>
<div *ngIf="exp" @myAnimation></div>
</div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
':enter',
[
style({opacity: 0}),
animate(1234, style({opacity: 1})),
]),
transition(
':leave',
[
animate(1234, style({opacity: 0})),
]),
]),
]
})
class Cmp {
@ViewChild('parent') public parentElm: any;
disableExp = false;
exp = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.disableExp = true;
fixture.detectChanges();
resetLog();
const parent = cmp.parentElm !.nativeElement;
cmp.exp = true;
fixture.detectChanges();
expect(getLog().length).toEqual(0);
expect(parent.childElementCount).toEqual(1);
cmp.exp = false;
fixture.detectChanges();
expect(getLog().length).toEqual(0);
expect(parent.childElementCount).toEqual(0);
});
it('should properly resolve animation event listeners when disabled', fakeAsync(() => {
@Component({
selector: 'if-cmp',
template: `
<div [@.disabled]="disableExp">
<div [@myAnimation]="exp" (@myAnimation.start)="startEvent=$event" (@myAnimation.done)="doneEvent=$event"></div>
</div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
'* => 1, * => 2',
[style({opacity: 0}), animate(9876, style({opacity: 1}))]),
]),
]
})
class Cmp {
disableExp = false;
exp = '';
startEvent: any;
doneEvent: any;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.disableExp = true;
fixture.detectChanges();
resetLog();
expect(cmp.startEvent).toBeFalsy();
expect(cmp.doneEvent).toBeFalsy();
cmp.exp = '1';
fixture.detectChanges();
flushMicrotasks();
expect(cmp.startEvent.totalTime).toEqual(0);
expect(cmp.doneEvent.totalTime).toEqual(0);
cmp.exp = '2';
cmp.disableExp = false;
fixture.detectChanges();
flushMicrotasks();
expect(cmp.startEvent.totalTime).toEqual(9876);
// the done event isn't fired because it's an actual animation
}));
});
});
it('should throw neither state() or transition() are used inside of trigger()', () => {
@Component({
selector: 'if-cmp',

View File

@ -2647,6 +2647,115 @@ export function main() {
]);
});
});
describe('animation control flags', () => {
describe('[@.disabled]', () => {
it('should allow a parent animation to query and animate inner nodes that are in a disabled region',
() => {
@Component({
selector: 'some-cmp',
template: `
<div [@myAnimation]="exp">
<div [@.disabled]="disabledExp">
<div class="header"></div>
<div class="footer"></div>
</div>
</div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
'* => go',
[
query('.header', animate(750, style({opacity: 0}))),
query('.footer', animate(250, style({opacity: 0}))),
]),
]),
]
})
class Cmp {
exp: any = '';
disableExp = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.disableExp = true;
fixture.detectChanges();
resetLog();
cmp.exp = 'go';
fixture.detectChanges();
const players = getLog();
expect(players.length).toEqual(2);
const [p1, p2] = players;
expect(p1.duration).toEqual(750);
expect(p1.element.classList.contains('header'));
expect(p2.duration).toEqual(250);
expect(p2.element.classList.contains('footer'));
});
it('should allow a parent animation to query and animate sub animations that are in a disabled region',
() => {
@Component({
selector: 'some-cmp',
template: `
<div class="parent" [@parentAnimation]="exp">
<div [@.disabled]="disabledExp">
<div class="child" [@childAnimation]="exp"></div>
</div>
</div>
`,
animations: [
trigger(
'parentAnimation',
[
transition(
'* => go',
[
query('@childAnimation', animateChild()),
animate(1000, style({opacity: 0}))
]),
]),
trigger(
'childAnimation',
[
transition('* => go', [animate(500, style({opacity: 0}))]),
]),
]
})
class Cmp {
exp: any = '';
disableExp = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.disableExp = true;
fixture.detectChanges();
resetLog();
cmp.exp = 'go';
fixture.detectChanges();
const players = getLog();
expect(players.length).toEqual(2);
const [p1, p2] = players;
expect(p1.duration).toEqual(500);
expect(p1.element.classList.contains('child'));
expect(p2.duration).toEqual(1000);
expect(p2.element.classList.contains('parent'));
});
});
});
});
}

View File

@ -187,7 +187,7 @@ export class AnimationRenderer extends BaseAnimationRenderer implements Renderer
setProperty(el: any, name: string, value: any): void {
if (name.charAt(0) == '@') {
name = name.substr(1);
this.engine.setProperty(this.namespaceId, el, name, value);
this.engine.process(this.namespaceId, el, name, value);
} else {
this.delegate.setProperty(el, name, value);
}

View File

@ -329,7 +329,7 @@ class MockAnimationEngine extends InjectableAnimationEngine {
this._capture('onRemove', [element]);
}
setProperty(namespaceId: string, element: any, property: string, value: any): boolean {
process(namespaceId: string, element: any, property: string, value: any): boolean {
this._capture('setProperty', [element, property, value]);
return true;
}