diff --git a/packages/animations/browser/src/render/animation_engine_next.ts b/packages/animations/browser/src/render/animation_engine_next.ts index 3bd0b4b6bd..7f1fef4b03 100644 --- a/packages/animations/browser/src/render/animation_engine_next.ts +++ b/packages/animations/browser/src/render/animation_engine_next.ts @@ -67,15 +67,21 @@ export class AnimationEngine { this._transitionEngine.removeNode(namespaceId, element, context); } - setProperty(namespaceId: string, element: any, property: string, value: any): boolean { - // @@property - if (property.charAt(0) == '@') { - const [id, action] = parseTimelineCommand(property); - const args = value as any[]; - this._timelineEngine.command(id, element, action, args); - return false; + 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); } - return this._transitionEngine.trigger(namespaceId, element, property, value); } listen( diff --git a/packages/animations/browser/src/render/transition_animation_engine.ts b/packages/animations/browser/src/render/transition_animation_engine.ts index 8cd6f715ae..a562eeefc3 100644 --- a/packages/animations/browser/src/render/transition_animation_engine.ts +++ b/packages/animations/browser/src/render/transition_animation_engine.ts @@ -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(); public playersByQueriedElement = new Map(); public statesByElement = new Map(); + public disabledNodes = new Set(); + 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>(); const allPostStyleElements = new Map>(); + const disabledElementsSet = new Set(); + 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); diff --git a/packages/animations/src/animation_metadata.ts b/packages/animations/src/animation_metadata.ts index 59b3abdb53..1cf6157eae 100755 --- a/packages/animations/src/animation_metadata.ts +++ b/packages/animations/src/animation_metadata.ts @@ -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 * *
...
- 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: ` + *
+ *
+ *
+ * `, + * 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. */ diff --git a/packages/core/test/animation/animation_integration_spec.ts b/packages/core/test/animation/animation_integration_spec.ts index 30470b3fb7..52acb25d8a 100644 --- a/packages/core/test/animation/animation_integration_spec.ts +++ b/packages/core/test/animation/animation_integration_spec.ts @@ -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: ` +
+
+
+ `, + 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: ` +
+ `, + 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: ` +
+
+
+
+
+ `, + 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: ` +
+
+
+ `, + 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: ` +
+
+
+ `, + 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', diff --git a/packages/core/test/animation/animation_query_integration_spec.ts b/packages/core/test/animation/animation_query_integration_spec.ts index bb5ba44987..3ba4be8afb 100644 --- a/packages/core/test/animation/animation_query_integration_spec.ts +++ b/packages/core/test/animation/animation_query_integration_spec.ts @@ -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: ` +
+
+
+ +
+
+ `, + 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: ` +
+
+
+
+
+ `, + 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')); + }); + }); + }); }); } diff --git a/packages/platform-browser/animations/src/animation_renderer.ts b/packages/platform-browser/animations/src/animation_renderer.ts index 69caf1b144..3f57a5b9ab 100644 --- a/packages/platform-browser/animations/src/animation_renderer.ts +++ b/packages/platform-browser/animations/src/animation_renderer.ts @@ -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); } diff --git a/packages/platform-browser/test/animation/animation_renderer_spec.ts b/packages/platform-browser/test/animation/animation_renderer_spec.ts index a70c33ae11..ba44b66176 100644 --- a/packages/platform-browser/test/animation/animation_renderer_spec.ts +++ b/packages/platform-browser/test/animation/animation_renderer_spec.ts @@ -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; }