From 05472cb21be17d9f3ae526ea1a4d6ceb4cad5da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Mon, 7 Aug 2017 11:40:04 -0700 Subject: [PATCH] fix(animations): support persisting dynamic styles within animation states (#18468) Closes #18423 Closes #17505 --- .../browser/src/dsl/animation_ast.ts | 1 + .../browser/src/dsl/animation_ast_builder.ts | 47 ++++- .../src/dsl/animation_transition_factory.ts | 59 +++++- .../browser/src/dsl/animation_trigger.ts | 24 +-- .../src/render/transition_animation_engine.ts | 47 ++++- packages/animations/browser/src/util.ts | 25 ++- .../browser/test/dsl/animation_spec.ts | 26 ++- .../test/dsl/animation_trigger_spec.ts | 21 +- packages/animations/src/animation_metadata.ts | 7 +- .../animation/animation_integration_spec.ts | 186 ++++++++++++++++++ .../animations/animations.d.ts | 11 +- 11 files changed, 408 insertions(+), 46 deletions(-) diff --git a/packages/animations/browser/src/dsl/animation_ast.ts b/packages/animations/browser/src/dsl/animation_ast.ts index 54e7ffec86..0a8b1309cd 100644 --- a/packages/animations/browser/src/dsl/animation_ast.ts +++ b/packages/animations/browser/src/dsl/animation_ast.ts @@ -82,6 +82,7 @@ export class AnimateAst extends Ast { export class StyleAst extends Ast { public isEmptyStep = false; + public containsDynamicStyles = false; constructor( public styles: (ɵStyleData|string)[], public easing: string|null, diff --git a/packages/animations/browser/src/dsl/animation_ast_builder.ts b/packages/animations/browser/src/dsl/animation_ast_builder.ts index c541d133aa..3fc8d50d65 100644 --- a/packages/animations/browser/src/dsl/animation_ast_builder.ts +++ b/packages/animations/browser/src/dsl/animation_ast_builder.ts @@ -8,7 +8,7 @@ import {AUTO_STYLE, AnimateTimings, AnimationAnimateChildMetadata, AnimationAnimateMetadata, AnimationAnimateRefMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationOptions, AnimationQueryMetadata, AnimationQueryOptions, AnimationReferenceMetadata, AnimationSequenceMetadata, AnimationStaggerMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata, style, ɵStyleData} from '@angular/animations'; import {getOrSetAsInMap} from '../render/shared'; -import {ENTER_SELECTOR, LEAVE_SELECTOR, NG_ANIMATING_SELECTOR, NG_TRIGGER_SELECTOR, copyObj, normalizeAnimationEntry, resolveTiming, validateStyleParams} from '../util'; +import {ENTER_SELECTOR, LEAVE_SELECTOR, NG_ANIMATING_SELECTOR, NG_TRIGGER_SELECTOR, SUBSTITUTION_EXPR_START, copyObj, extractStyleParams, iteratorToArray, normalizeAnimationEntry, resolveTiming, validateStyleParams} from '../util'; import {AnimateAst, AnimateChildAst, AnimateRefAst, Ast, DynamicTimingAst, GroupAst, KeyframesAst, QueryAst, ReferenceAst, SequenceAst, StaggerAst, StateAst, StyleAst, TimingAst, TransitionAst, TriggerAst} from './animation_ast'; import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; @@ -112,7 +112,35 @@ export class AnimationAstBuilderVisitor implements AnimationDslVisitor { } visitState(metadata: AnimationStateMetadata, context: AnimationAstBuilderContext): StateAst { - return new StateAst(metadata.name, this.visitStyle(metadata.styles, context)); + const styleAst = this.visitStyle(metadata.styles, context); + const astParams = (metadata.options && metadata.options.params) || null; + if (styleAst.containsDynamicStyles) { + const missingSubs = new Set(); + const params = astParams || {}; + styleAst.styles.forEach(value => { + if (isObject(value)) { + const stylesObj = value as any; + Object.keys(stylesObj).forEach(prop => { + extractStyleParams(stylesObj[prop]).forEach(sub => { + if (!params.hasOwnProperty(sub)) { + missingSubs.add(sub); + } + }); + }); + } + }); + if (missingSubs.size) { + const missingSubsArr = iteratorToArray(missingSubs.values()); + context.errors.push( + `state("${metadata.name}", ...) must define default values for all the following style substitutions: ${missingSubsArr.join(', ')}`); + } + } + + const stateAst = new StateAst(metadata.name, styleAst); + if (astParams) { + stateAst.options = {params: astParams}; + } + return stateAst; } visitTransition(metadata: AnimationTransitionMetadata, context: AnimationAstBuilderContext): @@ -206,6 +234,7 @@ export class AnimationAstBuilderVisitor implements AnimationDslVisitor { styles.push(metadata.styles); } + let containsDynamicStyles = false; let collectedEasing: string|null = null; styles.forEach(styleData => { if (isObject(styleData)) { @@ -215,9 +244,21 @@ export class AnimationAstBuilderVisitor implements AnimationDslVisitor { collectedEasing = easing as string; delete styleMap['easing']; } + if (!containsDynamicStyles) { + for (let prop in styleMap) { + const value = styleMap[prop]; + if (value.toString().indexOf(SUBSTITUTION_EXPR_START) >= 0) { + containsDynamicStyles = true; + break; + } + } + } } }); - return new StyleAst(styles, collectedEasing, metadata.offset); + + const ast = new StyleAst(styles, collectedEasing, metadata.offset); + ast.containsDynamicStyles = containsDynamicStyles; + return ast; } private _validateStyleAst(ast: StyleAst, context: AnimationAstBuilderContext): void { diff --git a/packages/animations/browser/src/dsl/animation_transition_factory.ts b/packages/animations/browser/src/dsl/animation_transition_factory.ts index 390f77d32f..4eea313a8b 100644 --- a/packages/animations/browser/src/dsl/animation_transition_factory.ts +++ b/packages/animations/browser/src/dsl/animation_transition_factory.ts @@ -9,38 +9,51 @@ import {AnimationOptions, ɵStyleData} from '@angular/animations'; import {AnimationDriver} from '../render/animation_driver'; import {getOrSetAsInMap} from '../render/shared'; -import {iteratorToArray, mergeAnimationOptions} from '../util'; +import {copyObj, interpolateParams, iteratorToArray, mergeAnimationOptions} from '../util'; -import {TransitionAst} from './animation_ast'; +import {StyleAst, TransitionAst} from './animation_ast'; import {buildAnimationTimelines} from './animation_timeline_builder'; import {TransitionMatcherFn} from './animation_transition_expr'; import {AnimationTransitionInstruction, createTransitionInstruction} from './animation_transition_instruction'; import {ElementInstructionMap} from './element_instruction_map'; +const EMPTY_OBJECT = {}; + export class AnimationTransitionFactory { constructor( private _triggerName: string, public ast: TransitionAst, - private _stateStyles: {[stateName: string]: ɵStyleData}) {} + private _stateStyles: {[stateName: string]: AnimationStateStyles}) {} match(currentState: any, nextState: any): boolean { return oneOrMoreTransitionsMatch(this.ast.matchers, currentState, nextState); } + buildStyles(stateName: string, params: {[key: string]: any}, errors: any[]) { + const backupStateStyler = this._stateStyles['*']; + const stateStyler = this._stateStyles[stateName]; + const backupStyles = backupStateStyler ? backupStateStyler.buildStyles(params, errors) : {}; + return stateStyler ? stateStyler.buildStyles(params, errors) : backupStyles; + } + build( driver: AnimationDriver, element: any, currentState: any, nextState: any, - options?: AnimationOptions, + currentOptions?: AnimationOptions, nextOptions?: AnimationOptions, subInstructions?: ElementInstructionMap): AnimationTransitionInstruction { - const animationOptions = mergeAnimationOptions(this.ast.options || {}, options || {}); + const errors: any[] = []; + + const transitionAnimationParams = this.ast.options && this.ast.options.params || EMPTY_OBJECT; + const currentAnimationParams = currentOptions && currentOptions.params || EMPTY_OBJECT; + const currentStateStyles = this.buildStyles(currentState, currentAnimationParams, errors); + const nextAnimationParams = nextOptions && nextOptions.params || EMPTY_OBJECT; + const nextStateStyles = this.buildStyles(nextState, nextAnimationParams, errors); - const backupStateStyles = this._stateStyles['*'] || {}; - const currentStateStyles = this._stateStyles[currentState] || backupStateStyles; - const nextStateStyles = this._stateStyles[nextState] || backupStateStyles; const queriedElements = new Set(); const preStyleMap = new Map(); const postStyleMap = new Map(); const isRemoval = nextState === 'void'; - const errors: any[] = []; + const animationOptions = {params: {...transitionAnimationParams, ...nextAnimationParams}}; + const timelines = buildAnimationTimelines( driver, element, this.ast.animation, currentStateStyles, nextStateStyles, animationOptions, subInstructions, errors); @@ -75,3 +88,31 @@ function oneOrMoreTransitionsMatch( matchFns: TransitionMatcherFn[], currentState: any, nextState: any): boolean { return matchFns.some(fn => fn(currentState, nextState)); } + +export class AnimationStateStyles { + constructor(private styles: StyleAst, private defaultParams: {[key: string]: any}) {} + + buildStyles(params: {[key: string]: any}, errors: string[]): ɵStyleData { + const finalStyles: ɵStyleData = {}; + const combinedParams = copyObj(this.defaultParams); + Object.keys(params).forEach(key => { + const value = params[key]; + if (value != null) { + combinedParams[key] = value; + } + }); + this.styles.styles.forEach(value => { + if (typeof value !== 'string') { + const styleObj = value as any; + Object.keys(styleObj).forEach(prop => { + let val = styleObj[prop]; + if (val.length > 1) { + val = interpolateParams(val, combinedParams, errors); + } + finalStyles[prop] = val; + }); + } + }); + return finalStyles; + } +} diff --git a/packages/animations/browser/src/dsl/animation_trigger.ts b/packages/animations/browser/src/dsl/animation_trigger.ts index 19cfdeaad5..9d820e25c2 100644 --- a/packages/animations/browser/src/dsl/animation_trigger.ts +++ b/packages/animations/browser/src/dsl/animation_trigger.ts @@ -7,10 +7,11 @@ */ import {ɵStyleData} from '@angular/animations'; -import {copyStyles} from '../util'; +import {copyStyles, interpolateParams} from '../util'; + +import {SequenceAst, StyleAst, TransitionAst, TriggerAst} from './animation_ast'; +import {AnimationStateStyles, AnimationTransitionFactory} from './animation_transition_factory'; -import {SequenceAst, TransitionAst, TriggerAst} from './animation_ast'; -import {AnimationTransitionFactory} from './animation_transition_factory'; /** * @experimental Animation support is experimental. @@ -25,16 +26,12 @@ export function buildTrigger(name: string, ast: TriggerAst): AnimationTrigger { export class AnimationTrigger { public transitionFactories: AnimationTransitionFactory[] = []; public fallbackTransition: AnimationTransitionFactory; - public states: {[stateName: string]: ɵStyleData} = {}; + public states: {[stateName: string]: AnimationStateStyles} = {}; constructor(public name: string, public ast: TriggerAst) { ast.states.forEach(ast => { - const obj = this.states[ast.name] = {}; - ast.style.styles.forEach(styleTuple => { - if (typeof styleTuple == 'object') { - copyStyles(styleTuple as ɵStyleData, false, obj); - } - }); + const defaultParams = (ast.options && ast.options.params) || {}; + this.states[ast.name] = new AnimationStateStyles(ast.style, defaultParams); }); balanceProperties(this.states, 'true', '1'); @@ -53,10 +50,15 @@ export class AnimationTrigger { const entry = this.transitionFactories.find(f => f.match(currentState, nextState)); return entry || null; } + + matchStyles(currentState: any, params: {[key: string]: any}, errors: any[]): ɵStyleData { + return this.fallbackTransition.buildStyles(currentState, params, errors); + } } function createFallbackTransition( - triggerName: string, states: {[stateName: string]: ɵStyleData}): AnimationTransitionFactory { + triggerName: string, + states: {[stateName: string]: AnimationStateStyles}): AnimationTransitionFactory { const matchers = [(fromState: any, toState: any) => true]; const animation = new SequenceAst([]); const transition = new TransitionAst(matchers, animation); diff --git a/packages/animations/browser/src/render/transition_animation_engine.ts b/packages/animations/browser/src/render/transition_animation_engine.ts index b0be786100..721f1bd75b 100644 --- a/packages/animations/browser/src/render/transition_animation_engine.ts +++ b/packages/animations/browser/src/render/transition_animation_engine.ts @@ -66,6 +66,8 @@ export class StateValue { public value: string; public options: AnimationOptions; + get params(): {[key: string]: any} { return this.options.params as{[key: string]: any}; } + constructor(input: any) { const isObj = input && input.hasOwnProperty('value'); const value = isObj ? input['value'] : input; @@ -213,7 +215,24 @@ export class AnimationTransitionNamespace { // The removal arc here is special cased because the same element is triggered // twice in the event that it contains animations on the outer/inner portions // of the host container - if (!isRemoval && fromState.value === toState.value) return; + if (!isRemoval && fromState.value === toState.value) { + // this means that despite the value not changing, some inner params + // have changed which means that the animation final styles need to be applied + if (!objEquals(fromState.params, toState.params)) { + const errors: any[] = []; + const fromStyles = trigger.matchStyles(fromState.value, fromState.params, errors); + const toStyles = trigger.matchStyles(toState.value, toState.params, errors); + if (errors.length) { + this._engine.reportError(errors); + } else { + this._engine.afterFlush(() => { + eraseStyles(element, fromStyles); + setStyles(element, toStyles); + }); + } + } + return; + } const playersOnElement: TransitionAnimationPlayer[] = getOrSetAsInMap(this._engine.playersByElement, element, []); @@ -664,7 +683,7 @@ export class TransitionAnimationEngine { private _buildInstruction(entry: QueueInstruction, subTimelines: ElementInstructionMap) { return entry.transition.build( this.driver, entry.element, entry.fromState.value, entry.toState.value, - entry.toState.options, subTimelines); + entry.fromState.options, entry.toState.options, subTimelines); } destroyInnerAnimations(containerElement: any) { @@ -781,6 +800,11 @@ export class TransitionAnimationEngine { } } + reportError(errors: string[]) { + throw new Error( + `Unable to process animations due to the following failed trigger transitions\n ${errors.join("\n")}`); + } + private _flushAnimations(cleanupFns: Function[], microtaskId: number): TransitionAnimationPlayer[] { const subTimelines = new ElementInstructionMap(); @@ -901,14 +925,14 @@ export class TransitionAnimationEngine { } if (erroneousTransitions.length) { - let msg = `Unable to process animations due to the following failed trigger transitions\n`; + const errors: string[] = []; erroneousTransitions.forEach(instruction => { - msg += `@${instruction.triggerName} has failed due to:\n`; - instruction.errors !.forEach(error => { msg += `- ${error}\n`; }); + errors.push(`@${instruction.triggerName} has failed due to:\n`); + instruction.errors !.forEach(error => errors.push(`- ${error}\n`)); }); allPlayers.forEach(player => player.destroy()); - throw new Error(msg); + this.reportError(errors); } // these can only be detected here since we have a map of all the elements @@ -1491,3 +1515,14 @@ function _flattenGroupPlayersRecur(players: AnimationPlayer[], finalPlayers: Ani } } } + +function objEquals(a: {[key: string]: any}, b: {[key: string]: any}): boolean { + const k1 = Object.keys(a); + const k2 = Object.keys(b); + if (k1.length != k2.length) return false; + for (let i = 0; i < k1.length; i++) { + const prop = k1[i]; + if (!b.hasOwnProperty(prop) || a[prop] !== b[prop]) return false; + } + return true; +} diff --git a/packages/animations/browser/src/util.ts b/packages/animations/browser/src/util.ts index a6dbb75f56..dbdfa4f213 100644 --- a/packages/animations/browser/src/util.ts +++ b/packages/animations/browser/src/util.ts @@ -9,6 +9,8 @@ import {AnimateTimings, AnimationMetadata, AnimationOptions, sequence, ɵStyleDa export const ONE_SECOND = 1000; +export const SUBSTITUTION_EXPR_START = '{{'; +export const SUBSTITUTION_EXPR_END = '}}'; export const ENTER_CLASSNAME = 'ng-enter'; export const LEAVE_CLASSNAME = 'ng-leave'; export const ENTER_SELECTOR = '.ng-enter'; @@ -151,10 +153,8 @@ export function normalizeAnimationEntry(steps: AnimationMetadata | AnimationMeta export function validateStyleParams( value: string | number, options: AnimationOptions, errors: any[]) { const params = options.params || {}; - if (typeof value !== 'string') return; - - const matches = value.toString().match(PARAM_REGEX); - if (matches) { + const matches = extractStyleParams(value); + if (matches.length) { matches.forEach(varName => { if (!params.hasOwnProperty(varName)) { errors.push( @@ -164,7 +164,22 @@ export function validateStyleParams( } } -const PARAM_REGEX = /\{\{\s*(.+?)\s*\}\}/g; +const PARAM_REGEX = + new RegExp(`${SUBSTITUTION_EXPR_START}\\s*(.+?)\\s*${SUBSTITUTION_EXPR_END}`, 'g'); +export function extractStyleParams(value: string | number): string[] { + let params: string[] = []; + if (typeof value === 'string') { + const val = value.toString(); + + let match: any; + while (match = PARAM_REGEX.exec(val)) { + params.push(match[1] as string); + } + PARAM_REGEX.lastIndex = 0; + } + return params; +} + export function interpolateParams( value: string | number, params: {[name: string]: any}, errors: any[]): string|number { const original = value.toString(); diff --git a/packages/animations/browser/test/dsl/animation_spec.ts b/packages/animations/browser/test/dsl/animation_spec.ts index 75733fd808..bc6da97a92 100644 --- a/packages/animations/browser/test/dsl/animation_spec.ts +++ b/packages/animations/browser/test/dsl/animation_spec.ts @@ -5,7 +5,7 @@ * 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 */ -import {AUTO_STYLE, AnimationMetadata, AnimationMetadataType, animate, animation, group, keyframes, query, sequence, style, transition, trigger, useAnimation, ɵStyleData} from '@angular/animations'; +import {AUTO_STYLE, AnimationMetadata, AnimationMetadataType, animate, animation, group, keyframes, query, sequence, state, style, transition, trigger, useAnimation, ɵStyleData} from '@angular/animations'; import {AnimationOptions} from '@angular/core/src/animation/dsl'; import {Animation} from '../../src/dsl/animation'; @@ -174,6 +174,30 @@ export function main() { validateAndThrowAnimationSequence(steps2); }).toThrowError(/keyframes\(\) must be placed inside of a call to animate\(\)/); }); + + it('should throw if dynamic style substitutions are used without defaults within state() definitions', + () => { + const steps = [state('final', style({ + 'width': '{{ one }}px', + 'borderRadius': '{{ two }}px {{ three }}px', + }))]; + + expect(() => { validateAndThrowAnimationSequence(steps); }) + .toThrowError( + /state\("final", ...\) must define default values for all the following style substitutions: one, two, three/); + + const steps2 = [state( + 'panfinal', style({ + 'color': '{{ greyColor }}', + 'borderColor': '1px solid {{ greyColor }}', + 'backgroundColor': '{{ redColor }}', + }), + {params: {redColor: 'maroon'}})]; + + expect(() => { validateAndThrowAnimationSequence(steps2); }) + .toThrowError( + /state\("panfinal", ...\) must define default values for all the following style substitutions: greyColor/); + }); }); describe('keyframe building', () => { diff --git a/packages/animations/browser/test/dsl/animation_trigger_spec.ts b/packages/animations/browser/test/dsl/animation_trigger_spec.ts index da95bad47c..9db8e2404b 100644 --- a/packages/animations/browser/test/dsl/animation_trigger_spec.ts +++ b/packages/animations/browser/test/dsl/animation_trigger_spec.ts @@ -51,12 +51,14 @@ export function main() { describe('trigger usage', () => { it('should construct a trigger based on the states and transition data', () => { const result = makeTrigger('name', [ - state('on', style({width: 0})), state('off', style({width: 100})), - transition('on => off', animate(1000)), transition('off => on', animate(1000)) + state('on', style({width: 0})), + state('off', style({width: 100})), + transition('on => off', animate(1000)), + transition('off => on', animate(1000)), ]); - expect(result.states).toEqual({'on': {width: 0}, 'off': {width: 100}}); - + expect(result.states['on'].buildStyles({}, [])).toEqual({width: 0}); + expect(result.states['off'].buildStyles({}, [])).toEqual({width: 100}); expect(result.transitionFactories.length).toEqual(2); }); @@ -66,7 +68,9 @@ export function main() { transition('off => on', animate(1000)) ]); - expect(result.states).toEqual({'on': {width: 50}, 'off': {width: 50}}); + + expect(result.states['on'].buildStyles({}, [])).toEqual({width: 50}); + expect(result.states['off'].buildStyles({}, [])).toEqual({width: 50}); }); it('should find the first transition that matches', () => { @@ -145,7 +149,7 @@ export function main() { 'a => b', [style({height: '{{ a }}'}), animate(1000, style({height: '{{ b }}'}))], buildParams({a: '100px', b: '200px'}))]); - const trans = buildTransition(result, element, 'a', 'b', buildParams({a: '300px'})) !; + const trans = buildTransition(result, element, 'a', 'b', {}, buildParams({a: '300px'})) !; const keyframes = trans.timelines[0].keyframes; expect(keyframes).toEqual([{height: '300px', offset: 0}, {height: '200px', offset: 1}]); @@ -219,11 +223,12 @@ export function main() { function buildTransition( trigger: AnimationTrigger, element: any, fromState: any, toState: any, - params?: AnimationOptions): AnimationTransitionInstruction|null { + fromOptions?: AnimationOptions, toOptions?: AnimationOptions): AnimationTransitionInstruction| + null { const trans = trigger.matchTransition(fromState, toState) !; if (trans) { const driver = new MockAnimationDriver(); - return trans.build(driver, element, fromState, toState, params) !; + return trans.build(driver, element, fromState, toState, fromOptions, toOptions) !; } return null; } diff --git a/packages/animations/src/animation_metadata.ts b/packages/animations/src/animation_metadata.ts index cabf6dd429..d0f907508b 100755 --- a/packages/animations/src/animation_metadata.ts +++ b/packages/animations/src/animation_metadata.ts @@ -105,6 +105,7 @@ export interface AnimationTriggerMetadata extends AnimationMetadata { export interface AnimationStateMetadata extends AnimationMetadata { name: string; styles: AnimationStyleMetadata; + options?: {params: {[name: string]: any}}; } /** @@ -567,8 +568,10 @@ export function style( * * @experimental Animation support is experimental. */ -export function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata { - return {type: AnimationMetadataType.State, name, styles}; +export function state( + name: string, styles: AnimationStyleMetadata, + options?: {params: {[name: string]: any}}): AnimationStateMetadata { + return {type: AnimationMetadataType.State, name, styles, options}; } /** diff --git a/packages/core/test/animation/animation_integration_spec.ts b/packages/core/test/animation/animation_integration_spec.ts index b6c9ddcee6..e7e5ee5186 100644 --- a/packages/core/test/animation/animation_integration_spec.ts +++ b/packages/core/test/animation/animation_integration_spec.ts @@ -1526,6 +1526,60 @@ export function main() { expect(players.length).toEqual(0); }); + it('should update the final state styles when params update even if the expression hasn\'t changed', + fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ `, + animations: [ + trigger( + 'myAnimation', + [ + state('*', style({color: '{{ color }}'}), {params: {color: 'black'}}), + transition('* => 1', animate(500)) + ]), + ] + }) + class Cmp { + public exp: any; + public color: string|null; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = '1'; + cmp.color = 'red'; + fixture.detectChanges(); + const player = getLog()[0] !; + const element = player.element; + player.finish(); + + flushMicrotasks(); + expect(getDOM().hasStyle(element, 'color', 'red')).toBeTruthy(); + + cmp.exp = '1'; + cmp.color = 'blue'; + fixture.detectChanges(); + resetLog(); + + flushMicrotasks(); + expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy(); + + cmp.exp = '1'; + cmp.color = null; + fixture.detectChanges(); + resetLog(); + + flushMicrotasks(); + expect(getDOM().hasStyle(element, 'color', 'black')).toBeTruthy(); + })); + it('should substitute in values if the provided state match is an object with values', () => { @Component({ selector: 'ani-cmp', @@ -1563,6 +1617,138 @@ export function main() { ]); }); + it('should retain substituted styles on the element once the animation is complete if referenced in the final state', + fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ `, + animations: [ + trigger( + 'myAnimation', + [ + state( + 'start', style({ + color: '{{ color }}', + fontSize: '{{ fontSize }}px', + width: '{{ width }}' + }), + {params: {color: 'red', fontSize: '200', width: '10px'}}), + + state( + 'final', + style( + {color: '{{ color }}', fontSize: '{{ fontSize }}px', width: '888px'}), + {params: {color: 'green', fontSize: '50', width: '100px'}}), + + transition('start => final', animate(500)), + ]), + ] + }) + class Cmp { + public exp: any; + public color: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'start'; + cmp.color = 'red'; + fixture.detectChanges(); + resetLog(); + + cmp.exp = 'final'; + cmp.color = 'blue'; + fixture.detectChanges(); + + const players = getLog(); + expect(players.length).toEqual(1); + const [p1] = players; + + expect(p1.keyframes).toEqual([ + {color: 'red', fontSize: '200px', width: '10px', offset: 0}, + {color: 'blue', fontSize: '50px', width: '888px', offset: 1} + ]); + + const element = p1.element; + p1.finish(); + flushMicrotasks(); + + expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy(); + expect(getDOM().hasStyle(element, 'fontSize', '50px')).toBeTruthy(); + expect(getDOM().hasStyle(element, 'width', '888px')).toBeTruthy(); + })); + + it('should only evaluate final state param substitutions from the expression and state values and not from the transition options ', + fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ `, + animations: [ + trigger( + 'myAnimation', + [ + state( + 'start', style({ + width: '{{ width }}', + height: '{{ height }}', + }), + {params: {width: '0px', height: '0px'}}), + + state( + 'final', style({ + width: '{{ width }}', + height: '{{ height }}', + }), + {params: {width: '100px', height: '100px'}}), + + transition( + 'start => final', [animate(500)], + {params: {width: '333px', height: '666px'}}), + ]), + ] + }) + class Cmp { + public exp: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'start'; + fixture.detectChanges(); + resetLog(); + + cmp.exp = 'final'; + fixture.detectChanges(); + + const players = getLog(); + expect(players.length).toEqual(1); + const [p1] = players; + + expect(p1.keyframes).toEqual([ + {width: '0px', height: '0px', offset: 0}, + {width: '100px', height: '100px', offset: 1}, + ]); + + const element = p1.element; + p1.finish(); + flushMicrotasks(); + + expect(getDOM().hasStyle(element, 'width', '100px')).toBeTruthy(); + expect(getDOM().hasStyle(element, 'height', '100px')).toBeTruthy(); + })); + it('should not flush animations twice when an inner component runs change detection', () => { @Component({ selector: 'outer-cmp', diff --git a/tools/public_api_guard/animations/animations.d.ts b/tools/public_api_guard/animations/animations.d.ts index d395e86ebf..45dd78afa3 100644 --- a/tools/public_api_guard/animations/animations.d.ts +++ b/tools/public_api_guard/animations/animations.d.ts @@ -151,6 +151,11 @@ export interface AnimationStaggerMetadata extends AnimationMetadata { /** @experimental */ export interface AnimationStateMetadata extends AnimationMetadata { name: string; + options?: { + params: { + [name: string]: any; + }; + }; styles: AnimationStyleMetadata; } @@ -221,7 +226,11 @@ export declare function sequence(steps: AnimationMetadata[], options?: Animation export declare function stagger(timings: string | number, animation: AnimationMetadata | AnimationMetadata[]): AnimationStaggerMetadata; /** @experimental */ -export declare function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata; +export declare function state(name: string, styles: AnimationStyleMetadata, options?: { + params: { + [name: string]: any; + }; +}): AnimationStateMetadata; /** @experimental */ export declare function style(tokens: '*' | {