Skip to content

Commit

Permalink
fix(animations): support persisting dynamic styles within animation s…
Browse files Browse the repository at this point in the history
…tates

Closes angular#18423
Closes angular#17505
  • Loading branch information
matsko committed Aug 2, 2017
1 parent 49cd851 commit dca65ce
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 40 deletions.
1 change: 1 addition & 0 deletions packages/animations/browser/src/dsl/animation_ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 44 additions & 3 deletions packages/animations/browser/src/dsl/animation_ast_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
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):
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,53 @@ 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<any>();
const preStyleMap = new Map<any, {[prop: string]: boolean}>();
const postStyleMap = new Map<any, {[prop: string]: boolean}>();
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);
Expand Down Expand Up @@ -75,3 +90,25 @@ 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: any = {...this.defaultParams, ...params};
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;
}
}
20 changes: 9 additions & 11 deletions packages/animations/browser/src/dsl/animation_trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand All @@ -56,7 +53,8 @@ export class AnimationTrigger {
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,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) {
Expand Down
25 changes: 20 additions & 5 deletions packages/animations/browser/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down
26 changes: 25 additions & 1 deletion packages/animations/browser/test/dsl/animation_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 10 additions & 7 deletions packages/animations/browser/test/dsl/animation_trigger_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {MockAnimationDriver} from '../../testing';
import {makeTrigger} from '../shared';

export function main() {
describe('AnimationTrigger', () => {
fdescribe('AnimationTrigger', () => {
// these tests are only mean't to be run within the DOM (for now)
if (typeof Element == 'undefined') return;

Expand Down Expand Up @@ -55,8 +55,8 @@ export function main() {
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);
});

Expand All @@ -66,7 +66,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', () => {
Expand Down Expand Up @@ -145,7 +147,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}]);
Expand Down Expand Up @@ -219,11 +221,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;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/animations/src/animation_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface AnimationTriggerMetadata extends AnimationMetadata {
export interface AnimationStateMetadata extends AnimationMetadata {
name: string;
styles: AnimationStyleMetadata;
options: {params?: {[name: string]: any}}|null;
}

/**
Expand Down Expand Up @@ -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: {[param: string]: any}} | null = null): AnimationStateMetadata {
return {type: AnimationMetadataType.State, name, styles, options};
}

/**
Expand Down
Loading

0 comments on commit dca65ce

Please sign in to comment.