diff --git a/packages/animations/browser/src/animation_engine.ts b/packages/animations/browser/src/animation_engine.ts index d8088f145d..0bf176d0a8 100644 --- a/packages/animations/browser/src/animation_engine.ts +++ b/packages/animations/browser/src/animation_engine.ts @@ -8,15 +8,19 @@ import {AnimationPlayer, AnimationTriggerMetadata} from '@angular/animations'; export abstract class AnimationEngine { - abstract registerTrigger(trigger: AnimationTriggerMetadata, name?: string): void; - abstract onInsert(element: any, domFn: () => any): void; - abstract onRemove(element: any, domFn: () => any): void; - abstract setProperty(element: any, property: string, value: any): void; + abstract registerTrigger( + componentId: string, namespaceId: string, hostElement: any, name: string, + metadata: AnimationTriggerMetadata): void; + abstract onInsert(namespaceId: string, element: any, parent: any, insertBefore: boolean): void; + abstract onRemove(namespaceId: string, element: any, context: any): void; + abstract setProperty(namespaceId: string, element: any, property: string, value: any): void; abstract listen( - element: any, eventName: string, eventPhase: string, + namespaceId: string, element: any, eventName: string, eventPhase: string, callback: (event: any) => any): () => any; abstract flush(): void; + abstract destroy(namespaceId: string, context: any): void; - get activePlayers(): AnimationPlayer[] { throw new Error('...'); } - get queuedPlayers(): AnimationPlayer[] { throw new Error('...'); } + onRemovalComplete: (delegate: any, element: any) => void; + + public players: AnimationPlayer[]; } diff --git a/packages/animations/browser/src/dsl/animation.ts b/packages/animations/browser/src/dsl/animation.ts index bde7df3b8e..ffc0f2e6ef 100644 --- a/packages/animations/browser/src/dsl/animation.ts +++ b/packages/animations/browser/src/dsl/animation.ts @@ -5,23 +5,19 @@ * 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 {AnimationMetadata, AnimationPlayer, AnimationStyleMetadata, sequence, ɵStyleData} from '@angular/animations'; - -import {AnimationDriver} from '../render/animation_driver'; -import {DomAnimationEngine} from '../render/dom_animation_engine'; +import {AnimationMetadata, AnimationOptions, ɵStyleData} from '@angular/animations'; import {normalizeStyles} from '../util'; - +import {Ast} from './animation_ast'; +import {buildAnimationAst} from './animation_ast_builder'; +import {buildAnimationTimelines} from './animation_timeline_builder'; import {AnimationTimelineInstruction} from './animation_timeline_instruction'; -import {buildAnimationKeyframes} from './animation_timeline_visitor'; -import {validateAnimationSequence} from './animation_validator_visitor'; -import {AnimationStyleNormalizer} from './style_normalization/animation_style_normalizer'; +import {ElementInstructionMap} from './element_instruction_map'; export class Animation { - private _animationAst: AnimationMetadata; + private _animationAst: Ast; constructor(input: AnimationMetadata|AnimationMetadata[]) { - const ast = - Array.isArray(input) ? sequence(input) : input; - const errors = validateAnimationSequence(ast); + const errors: any[] = []; + const ast = buildAnimationAst(input, errors); if (errors.length) { const errorMessage = `animation validation failed:\n${errors.join("\n")}`; throw new Error(errorMessage); @@ -30,26 +26,21 @@ export class Animation { } buildTimelines( - startingStyles: ɵStyleData|ɵStyleData[], - destinationStyles: ɵStyleData|ɵStyleData[]): AnimationTimelineInstruction[] { + element: any, startingStyles: ɵStyleData|ɵStyleData[], + destinationStyles: ɵStyleData|ɵStyleData[], options: AnimationOptions, + subInstructions?: ElementInstructionMap): AnimationTimelineInstruction[] { const start = Array.isArray(startingStyles) ? normalizeStyles(startingStyles) : <ɵStyleData>startingStyles; const dest = Array.isArray(destinationStyles) ? normalizeStyles(destinationStyles) : <ɵStyleData>destinationStyles; - return buildAnimationKeyframes(this._animationAst, start, dest); - } - - // this is only used for development demo purposes for now - private create( - injector: any, element: any, startingStyles: ɵStyleData = {}, - destinationStyles: ɵStyleData = {}): AnimationPlayer { - const instructions = this.buildTimelines(startingStyles, destinationStyles); - - // note the code below is only here to make the tests happy (once the new renderer is - // within core then the code below will interact with Renderer.transition(...)) - const driver: AnimationDriver = injector.get(AnimationDriver); - const normalizer: AnimationStyleNormalizer = injector.get(AnimationStyleNormalizer); - const engine = new DomAnimationEngine(driver, normalizer); - return engine.animateTimeline(element, instructions); + const errors: any = []; + subInstructions = subInstructions || new ElementInstructionMap(); + const result = buildAnimationTimelines( + element, this._animationAst, start, dest, options, subInstructions, errors); + if (errors.length) { + const errorMessage = `animation building failed:\n${errors.join("\n")}`; + throw new Error(errorMessage); + } + return result; } } diff --git a/packages/animations/browser/src/dsl/animation_ast.ts b/packages/animations/browser/src/dsl/animation_ast.ts new file mode 100644 index 0000000000..067b2b87b8 --- /dev/null +++ b/packages/animations/browser/src/dsl/animation_ast.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimateTimings, AnimationOptions, ɵStyleData} from '@angular/animations'; + +const EMPTY_ANIMATION_OPTIONS: AnimationOptions = {}; + +export interface AstVisitor { + visitTrigger(ast: TriggerAst, context: any): any; + visitState(ast: StateAst, context: any): any; + visitTransition(ast: TransitionAst, context: any): any; + visitSequence(ast: SequenceAst, context: any): any; + visitGroup(ast: GroupAst, context: any): any; + visitAnimate(ast: AnimateAst, context: any): any; + visitStyle(ast: StyleAst, context: any): any; + visitKeyframes(ast: KeyframesAst, context: any): any; + visitReference(ast: ReferenceAst, context: any): any; + visitAnimateChild(ast: AnimateChildAst, context: any): any; + visitAnimateRef(ast: AnimateRefAst, context: any): any; + visitQuery(ast: QueryAst, context: any): any; + visitStagger(ast: StaggerAst, context: any): any; + visitTiming(ast: TimingAst, context: any): any; +} + +export abstract class Ast { + abstract visit(ast: AstVisitor, context: any): any; + public options: AnimationOptions = EMPTY_ANIMATION_OPTIONS; + + get params(): {[name: string]: any}|null { return this.options['params'] || null; } +} + +export class TriggerAst extends Ast { + public queryCount: number = 0; + public depCount: number = 0; + + constructor(public name: string, public states: StateAst[], public transitions: TransitionAst[]) { + super(); + } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitTrigger(this, context); } +} + +export class StateAst extends Ast { + constructor(public name: string, public style: StyleAst) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitState(this, context); } +} + +export class TransitionAst extends Ast { + public queryCount: number = 0; + public depCount: number = 0; + + constructor( + public matchers: ((fromState: string, toState: string) => boolean)[], public animation: Ast) { + super(); + } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitTransition(this, context); } +} + +export class SequenceAst extends Ast { + constructor(public steps: Ast[]) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitSequence(this, context); } +} + +export class GroupAst extends Ast { + constructor(public steps: Ast[]) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitGroup(this, context); } +} + +export class AnimateAst extends Ast { + constructor(public timings: TimingAst, public style: StyleAst|KeyframesAst) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitAnimate(this, context); } +} + +export class StyleAst extends Ast { + public isEmptyStep = false; + + constructor( + public styles: (ɵStyleData|string)[], public easing: string|null, + public offset: number|null) { + super(); + } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitStyle(this, context); } +} + +export class KeyframesAst extends Ast { + constructor(public styles: StyleAst[]) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitKeyframes(this, context); } +} + +export class ReferenceAst extends Ast { + constructor(public animation: Ast) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitReference(this, context); } +} + +export class AnimateChildAst extends Ast { + constructor() { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitAnimateChild(this, context); } +} + +export class AnimateRefAst extends Ast { + constructor(public animation: ReferenceAst) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitAnimateRef(this, context); } +} + +export class QueryAst extends Ast { + public originalSelector: string; + + constructor( + public selector: string, public limit: number, public optional: boolean, + public includeSelf: boolean, public animation: Ast) { + super(); + } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitQuery(this, context); } +} + +export class StaggerAst extends Ast { + constructor(public timings: AnimateTimings, public animation: Ast) { super(); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitStagger(this, context); } +} + +export class TimingAst extends Ast { + constructor( + public duration: number, public delay: number = 0, public easing: string|null = null) { + super(); + } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitTiming(this, context); } +} + +export class DynamicTimingAst extends TimingAst { + constructor(public value: string) { super(0, 0, ''); } + + visit(visitor: AstVisitor, context: any): any { return visitor.visitTiming(this, context); } +} diff --git a/packages/animations/browser/src/dsl/animation_ast_builder.ts b/packages/animations/browser/src/dsl/animation_ast_builder.ts new file mode 100644 index 0000000000..db15b9ba88 --- /dev/null +++ b/packages/animations/browser/src/dsl/animation_ast_builder.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, 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 {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'; +import {parseTransitionExpr} from './animation_transition_expr'; + +const SELF_TOKEN = ':self'; +const SELF_TOKEN_REGEX = new RegExp(`\s*${SELF_TOKEN}\s*,?`, 'g'); + +/* + * [Validation] + * The visitor code below will traverse the animation AST generated by the animation verb functions + * (the output is a tree of objects) and attempt to perform a series of validations on the data. The + * following corner-cases will be validated: + * + * 1. Overlap of animations + * Given that a CSS property cannot be animated in more than one place at the same time, it's + * important that this behaviour is detected and validated. The way in which this occurs is that + * each time a style property is examined, a string-map containing the property will be updated with + * the start and end times for when the property is used within an animation step. + * + * If there are two or more parallel animations that are currently running (these are invoked by the + * group()) on the same element then the validator will throw an error. Since the start/end timing + * values are collected for each property then if the current animation step is animating the same + * property and its timing values fall anywhere into the window of time that the property is + * currently being animated within then this is what causes an error. + * + * 2. Timing values + * The validator will validate to see if a timing value of `duration delay easing` or + * `durationNumber` is valid or not. + * + * (note that upon validation the code below will replace the timing data with an object containing + * {duration,delay,easing}. + * + * 3. Offset Validation + * Each of the style() calls are allowed to have an offset value when placed inside of keyframes(). + * Offsets within keyframes() are considered valid when: + * + * - No offsets are used at all + * - Each style() entry contains an offset value + * - Each offset is between 0 and 1 + * - Each offset is greater to or equal than the previous one + * + * Otherwise an error will be thrown. + */ +export function buildAnimationAst( + metadata: AnimationMetadata | AnimationMetadata[], errors: any[]): Ast { + return new AnimationAstBuilderVisitor().build(metadata, errors); +} + +const LEAVE_TOKEN = ':leave'; +const LEAVE_TOKEN_REGEX = new RegExp(LEAVE_TOKEN, 'g'); +const ENTER_TOKEN = ':enter'; +const ENTER_TOKEN_REGEX = new RegExp(ENTER_TOKEN, 'g'); +const ROOT_SELECTOR = ''; + +export class AnimationAstBuilderVisitor implements AnimationDslVisitor { + build(metadata: AnimationMetadata|AnimationMetadata[], errors: any[]): Ast { + const context = new AnimationAstBuilderContext(errors); + this._resetContextStyleTimingState(context); + return visitAnimationNode(this, normalizeAnimationEntry(metadata), context) as Ast; + } + + private _resetContextStyleTimingState(context: AnimationAstBuilderContext) { + context.currentQuerySelector = ROOT_SELECTOR; + context.collectedStyles[ROOT_SELECTOR] = {}; + context.currentTime = 0; + } + + visitTrigger(metadata: AnimationTriggerMetadata, context: AnimationAstBuilderContext): + TriggerAst { + let queryCount = context.queryCount = 0; + let depCount = context.depCount = 0; + const states: StateAst[] = []; + const transitions: TransitionAst[] = []; + metadata.definitions.forEach(def => { + this._resetContextStyleTimingState(context); + if (def.type == AnimationMetadataType.State) { + const stateDef = def as AnimationStateMetadata; + const name = stateDef.name; + name.split(/\s*,\s*/).forEach(n => { + stateDef.name = n; + states.push(this.visitState(stateDef, context)); + }); + stateDef.name = name; + } else if (def.type == AnimationMetadataType.Transition) { + const transition = this.visitTransition(def as AnimationTransitionMetadata, context); + queryCount += transition.queryCount; + depCount += transition.depCount; + transitions.push(transition); + } else { + context.errors.push( + 'only state() and transition() definitions can sit inside of a trigger()'); + } + }); + const ast = new TriggerAst(metadata.name, states, transitions); + ast.options = normalizeAnimationOptions(metadata.options); + ast.queryCount = queryCount; + ast.depCount = depCount; + return ast; + } + + visitState(metadata: AnimationStateMetadata, context: AnimationAstBuilderContext): StateAst { + return new StateAst(metadata.name, this.visitStyle(metadata.styles, context)); + } + + visitTransition(metadata: AnimationTransitionMetadata, context: AnimationAstBuilderContext): + TransitionAst { + context.queryCount = 0; + context.depCount = 0; + const entry = visitAnimationNode(this, normalizeAnimationEntry(metadata.animation), context); + const matchers = parseTransitionExpr(metadata.expr, context.errors); + const ast = new TransitionAst(matchers, entry); + ast.options = normalizeAnimationOptions(metadata.options); + ast.queryCount = context.queryCount; + ast.depCount = context.depCount; + return ast; + } + + visitSequence(metadata: AnimationSequenceMetadata, context: AnimationAstBuilderContext): + SequenceAst { + const ast = new SequenceAst(metadata.steps.map(s => visitAnimationNode(this, s, context))); + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitGroup(metadata: AnimationGroupMetadata, context: AnimationAstBuilderContext): GroupAst { + const currentTime = context.currentTime; + let furthestTime = 0; + const steps = metadata.steps.map(step => { + context.currentTime = currentTime; + const innerAst = visitAnimationNode(this, step, context); + furthestTime = Math.max(furthestTime, context.currentTime); + return innerAst; + }); + + context.currentTime = furthestTime; + const ast = new GroupAst(steps); + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitAnimate(metadata: AnimationAnimateMetadata, context: AnimationAstBuilderContext): + AnimateAst { + const timingAst = constructTimingAst(metadata.timings, context.errors); + context.currentAnimateTimings = timingAst; + + let styles: StyleAst|KeyframesAst; + let styleMetadata: AnimationMetadata = metadata.styles ? metadata.styles : style({}); + if (styleMetadata.type == AnimationMetadataType.Keyframes) { + styles = this.visitKeyframes(styleMetadata as AnimationKeyframesSequenceMetadata, context); + } else { + let styleMetadata = metadata.styles as AnimationStyleMetadata; + let isEmpty = false; + if (!styleMetadata) { + isEmpty = true; + const newStyleData: {[prop: string]: string | number} = {}; + if (timingAst.easing) { + newStyleData['easing'] = timingAst.easing; + } + styleMetadata = style(newStyleData); + } + context.currentTime += timingAst.duration + timingAst.delay; + const styleAst = this.visitStyle(styleMetadata, context); + styleAst.isEmptyStep = isEmpty; + styles = styleAst; + } + + context.currentAnimateTimings = null; + return new AnimateAst(timingAst, styles); + } + + visitStyle(metadata: AnimationStyleMetadata, context: AnimationAstBuilderContext): StyleAst { + const ast = this._makeStyleAst(metadata, context); + this._validateStyleAst(ast, context); + return ast; + } + + private _makeStyleAst(metadata: AnimationStyleMetadata, context: AnimationAstBuilderContext): + StyleAst { + const styles: (ɵStyleData | string)[] = []; + if (Array.isArray(metadata.styles)) { + (metadata.styles as(ɵStyleData | string)[]).forEach(styleTuple => { + if (typeof styleTuple == 'string') { + if (styleTuple == AUTO_STYLE) { + styles.push(styleTuple as string); + } else { + context.errors.push(`The provided style string value ${styleTuple} is not allowed.`); + } + } else { + styles.push(styleTuple as ɵStyleData); + } + }) + } else { + styles.push(metadata.styles); + } + + let collectedEasing: string|null = null; + styles.forEach(styleData => { + if (isObject(styleData)) { + const styleMap = styleData as ɵStyleData; + const easing = styleMap['easing']; + if (easing) { + collectedEasing = easing as string; + delete styleMap['easing']; + } + } + }); + return new StyleAst(styles, collectedEasing, metadata.offset); + } + + private _validateStyleAst(ast: StyleAst, context: AnimationAstBuilderContext): void { + const timings = context.currentAnimateTimings; + let endTime = context.currentTime; + let startTime = context.currentTime; + if (timings && startTime > 0) { + startTime -= timings.duration + timings.delay; + } + + ast.styles.forEach(tuple => { + if (typeof tuple == 'string') return; + + Object.keys(tuple).forEach(prop => { + const collectedStyles = context.collectedStyles[context.currentQuerySelector !]; + const collectedEntry = collectedStyles[prop]; + let updateCollectedStyle = true; + if (collectedEntry) { + if (startTime != endTime && startTime >= collectedEntry.startTime && + endTime <= collectedEntry.endTime) { + context.errors.push( + `The CSS property "${prop}" that exists between the times of "${collectedEntry.startTime}ms" and "${collectedEntry.endTime}ms" is also being animated in a parallel animation between the times of "${startTime}ms" and "${endTime}ms"`); + updateCollectedStyle = false; + } + + // we always choose the smaller start time value since we + // want to have a record of the entire animation window where + // the style property is being animated in between + startTime = collectedEntry.startTime; + } + + if (updateCollectedStyle) { + collectedStyles[prop] = {startTime, endTime}; + } + + if (context.options) { + validateStyleParams(tuple[prop], context.options, context.errors); + } + }); + }); + } + + visitKeyframes(metadata: AnimationKeyframesSequenceMetadata, context: AnimationAstBuilderContext): + KeyframesAst { + if (!context.currentAnimateTimings) { + context.errors.push(`keyframes() must be placed inside of a call to animate()`); + return new KeyframesAst([]); + } + + const MAX_KEYFRAME_OFFSET = 1; + + let totalKeyframesWithOffsets = 0; + const offsets: number[] = []; + let offsetsOutOfOrder = false; + let keyframesOutOfRange = false; + let previousOffset: number = 0; + + const keyframes: StyleAst[] = metadata.steps.map(styles => { + const style = this._makeStyleAst(styles, context); + let offsetVal: number|null = + style.offset != null ? style.offset : consumeOffset(style.styles); + let offset: number = 0; + if (offsetVal != null) { + totalKeyframesWithOffsets++; + offset = style.offset = offsetVal; + } + keyframesOutOfRange = keyframesOutOfRange || offset < 0 || offset > 1; + offsetsOutOfOrder = offsetsOutOfOrder || offset < previousOffset; + previousOffset = offset; + offsets.push(offset); + return style; + }); + + if (keyframesOutOfRange) { + context.errors.push(`Please ensure that all keyframe offsets are between 0 and 1`); + } + + if (offsetsOutOfOrder) { + context.errors.push(`Please ensure that all keyframe offsets are in order`); + } + + const length = metadata.steps.length; + let generatedOffset = 0; + if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) { + context.errors.push(`Not all style() steps within the declared keyframes() contain offsets`); + } else if (totalKeyframesWithOffsets == 0) { + generatedOffset = MAX_KEYFRAME_OFFSET / (length - 1); + } + + const limit = length - 1; + const currentTime = context.currentTime; + const currentAnimateTimings = context.currentAnimateTimings !; + const animateDuration = currentAnimateTimings.duration; + keyframes.forEach((kf, i) => { + const offset = generatedOffset > 0 ? (i == limit ? 1 : (generatedOffset * i)) : offsets[i]; + const durationUpToThisFrame = offset * animateDuration; + context.currentTime = currentTime + currentAnimateTimings.delay + durationUpToThisFrame; + currentAnimateTimings.duration = durationUpToThisFrame; + this._validateStyleAst(kf, context); + kf.offset = offset; + }); + + return new KeyframesAst(keyframes); + } + + visitReference(metadata: AnimationReferenceMetadata, context: AnimationAstBuilderContext): + ReferenceAst { + const entry = visitAnimationNode(this, normalizeAnimationEntry(metadata.animation), context); + const ast = new ReferenceAst(entry); + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitAnimateChild(metadata: AnimationAnimateChildMetadata, context: AnimationAstBuilderContext): + AnimateChildAst { + context.depCount++; + const ast = new AnimateChildAst(); + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitAnimateRef(metadata: AnimationAnimateRefMetadata, context: AnimationAstBuilderContext): + AnimateRefAst { + const animation = this.visitReference(metadata.animation, context); + const ast = new AnimateRefAst(animation); + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitQuery(metadata: AnimationQueryMetadata, context: AnimationAstBuilderContext): QueryAst { + const parentSelector = context.currentQuerySelector !; + const options = (metadata.options || {}) as AnimationQueryOptions; + + context.queryCount++; + context.currentQuery = metadata; + const [selector, includeSelf] = normalizeSelector(metadata.selector); + context.currentQuerySelector = + parentSelector.length ? (parentSelector + ' ' + selector) : selector; + getOrSetAsInMap(context.collectedStyles, context.currentQuerySelector, {}); + + const entry = visitAnimationNode(this, normalizeAnimationEntry(metadata.animation), context); + context.currentQuery = null; + context.currentQuerySelector = parentSelector; + + const ast = new QueryAst(selector, options.limit || 0, !!options.optional, includeSelf, entry); + ast.originalSelector = metadata.selector; + ast.options = normalizeAnimationOptions(metadata.options); + return ast; + } + + visitStagger(metadata: AnimationStaggerMetadata, context: AnimationAstBuilderContext): + StaggerAst { + if (!context.currentQuery) { + context.errors.push(`stagger() can only be used inside of query()`); + } + const timings = metadata.timings === 'full' ? + {duration: 0, delay: 0, easing: 'full'} : + resolveTiming(metadata.timings, context.errors, true); + const animation = + visitAnimationNode(this, normalizeAnimationEntry(metadata.animation), context); + return new StaggerAst(timings, animation); + } +} + +function normalizeSelector(selector: string): [string, boolean] { + const hasAmpersand = selector.split(/\s*,\s*/).find(token => token == SELF_TOKEN) ? true : false; + if (hasAmpersand) { + selector = selector.replace(SELF_TOKEN_REGEX, ''); + } + + selector = selector.replace(ENTER_TOKEN_REGEX, ENTER_SELECTOR) + .replace(LEAVE_TOKEN_REGEX, LEAVE_SELECTOR) + .replace(/@\*/g, NG_TRIGGER_SELECTOR) + .replace(/@\w+/g, match => NG_TRIGGER_SELECTOR + '-' + match.substr(1)) + .replace(/:animating/g, NG_ANIMATING_SELECTOR); + + return [selector, hasAmpersand]; +} + + +function normalizeParams(obj: {[key: string]: any} | any): {[key: string]: any}|null { + return obj ? copyObj(obj) : null; +} + +export type StyleTimeTuple = { + startTime: number; endTime: number; +}; + +export class AnimationAstBuilderContext { + public queryCount: number = 0; + public depCount: number = 0; + public currentTransition: AnimationTransitionMetadata|null = null; + public currentQuery: AnimationQueryMetadata|null = null; + public currentQuerySelector: string|null = null; + public currentAnimateTimings: TimingAst|null = null; + public currentTime: number = 0; + public collectedStyles: {[selectorName: string]: {[propName: string]: StyleTimeTuple}} = {}; + public options: AnimationOptions|null = null; + constructor(public errors: any[]) {} +} + +function consumeOffset(styles: ɵStyleData | string | (ɵStyleData | string)[]): number|null { + if (typeof styles == 'string') return null; + + let offset: number|null = null; + + if (Array.isArray(styles)) { + styles.forEach(styleTuple => { + if (isObject(styleTuple) && styleTuple.hasOwnProperty('offset')) { + const obj = styleTuple as ɵStyleData; + offset = parseFloat(obj['offset'] as string); + delete obj['offset']; + } + }); + } else if (isObject(styles) && styles.hasOwnProperty('offset')) { + const obj = styles as ɵStyleData; + offset = parseFloat(obj['offset'] as string); + delete obj['offset']; + } + return offset; +} + +function isObject(value: any): boolean { + return !Array.isArray(value) && typeof value == 'object'; +} + +function constructTimingAst(value: string | number | AnimateTimings, errors: any[]) { + let timings: AnimateTimings|null = null; + if (value.hasOwnProperty('duration')) { + timings = value as AnimateTimings; + } else if (typeof value == 'number') { + const duration = resolveTiming(value as number, errors).duration; + return new TimingAst(value as number, 0, ''); + } + + const strValue = value as string; + const isDynamic = strValue.split(/\s+/).some(v => v.charAt(0) == '{' && v.charAt(1) == '{'); + if (isDynamic) { + return new DynamicTimingAst(strValue); + } + + timings = timings || resolveTiming(strValue, errors); + return new TimingAst(timings.duration, timings.delay, timings.easing); +} + +function normalizeAnimationOptions(options: AnimationOptions | null): AnimationOptions { + if (options) { + options = copyObj(options); + if (options['params']) { + options['params'] = normalizeParams(options['params']) !; + } + } else { + options = {}; + } + return options; +} diff --git a/packages/animations/browser/src/dsl/animation_dsl_visitor.ts b/packages/animations/browser/src/dsl/animation_dsl_visitor.ts index 92b8e6f0e5..6b2176d0ab 100644 --- a/packages/animations/browser/src/dsl/animation_dsl_visitor.ts +++ b/packages/animations/browser/src/dsl/animation_dsl_visitor.ts @@ -5,35 +5,53 @@ * 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 {AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata} from '@angular/animations'; +import {AnimationAnimateChildMetadata, AnimationAnimateMetadata, AnimationAnimateRefMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationQueryMetadata, AnimationReferenceMetadata, AnimationSequenceMetadata, AnimationStaggerMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata} from '@angular/animations'; export interface AnimationDslVisitor { + visitTrigger(ast: AnimationTriggerMetadata, context: any): any; visitState(ast: AnimationStateMetadata, context: any): any; visitTransition(ast: AnimationTransitionMetadata, context: any): any; visitSequence(ast: AnimationSequenceMetadata, context: any): any; visitGroup(ast: AnimationGroupMetadata, context: any): any; visitAnimate(ast: AnimationAnimateMetadata, context: any): any; visitStyle(ast: AnimationStyleMetadata, context: any): any; - visitKeyframeSequence(ast: AnimationKeyframesSequenceMetadata, context: any): any; + visitKeyframes(ast: AnimationKeyframesSequenceMetadata, context: any): any; + visitReference(ast: AnimationReferenceMetadata, context: any): any; + visitAnimateChild(ast: AnimationAnimateChildMetadata, context: any): any; + visitAnimateRef(ast: AnimationAnimateRefMetadata, context: any): any; + visitQuery(ast: AnimationQueryMetadata, context: any): any; + visitStagger(ast: AnimationStaggerMetadata, context: any): any; } export function visitAnimationNode( visitor: AnimationDslVisitor, node: AnimationMetadata, context: any) { switch (node.type) { + case AnimationMetadataType.Trigger: + return visitor.visitTrigger(node as AnimationTriggerMetadata, context); case AnimationMetadataType.State: - return visitor.visitState(node, context); + return visitor.visitState(node as AnimationStateMetadata, context); case AnimationMetadataType.Transition: - return visitor.visitTransition(node, context); + return visitor.visitTransition(node as AnimationTransitionMetadata, context); case AnimationMetadataType.Sequence: - return visitor.visitSequence(node, context); + return visitor.visitSequence(node as AnimationSequenceMetadata, context); case AnimationMetadataType.Group: - return visitor.visitGroup(node, context); + return visitor.visitGroup(node as AnimationGroupMetadata, context); case AnimationMetadataType.Animate: - return visitor.visitAnimate(node, context); - case AnimationMetadataType.KeyframeSequence: - return visitor.visitKeyframeSequence(node, context); + return visitor.visitAnimate(node as AnimationAnimateMetadata, context); + case AnimationMetadataType.Keyframes: + return visitor.visitKeyframes(node as AnimationKeyframesSequenceMetadata, context); case AnimationMetadataType.Style: - return visitor.visitStyle(node, context); + return visitor.visitStyle(node as AnimationStyleMetadata, context); + case AnimationMetadataType.Reference: + return visitor.visitReference(node as AnimationReferenceMetadata, context); + case AnimationMetadataType.AnimateChild: + return visitor.visitAnimateChild(node as AnimationAnimateChildMetadata, context); + case AnimationMetadataType.AnimateRef: + return visitor.visitAnimateRef(node as AnimationAnimateRefMetadata, context); + case AnimationMetadataType.Query: + return visitor.visitQuery(node as AnimationQueryMetadata, context); + case AnimationMetadataType.Stagger: + return visitor.visitStagger(node as AnimationStaggerMetadata, context); default: throw new Error(`Unable to resolve animation metadata node #${node.type}`); } diff --git a/packages/animations/browser/src/dsl/animation_timeline_builder.ts b/packages/animations/browser/src/dsl/animation_timeline_builder.ts new file mode 100644 index 0000000000..09477bc45d --- /dev/null +++ b/packages/animations/browser/src/dsl/animation_timeline_builder.ts @@ -0,0 +1,873 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, AnimateTimings, AnimationOptions, AnimationQueryOptions, ɵPRE_STYLE as PRE_STYLE, ɵStyleData} from '@angular/animations'; + +import {copyObj, copyStyles, interpolateParams, iteratorToArray, resolveTiming, resolveTimingValue} from '../util'; + +import {AnimateAst, AnimateChildAst, AnimateRefAst, Ast, AstVisitor, DynamicTimingAst, GroupAst, KeyframesAst, QueryAst, ReferenceAst, SequenceAst, StaggerAst, StateAst, StyleAst, TimingAst, TransitionAst, TriggerAst} from './animation_ast'; +import {AnimationTimelineInstruction, createTimelineInstruction} from './animation_timeline_instruction'; +import {ElementInstructionMap} from './element_instruction_map'; + +const ONE_FRAME_IN_MILLISECONDS = 1; + +/* + * The code within this file aims to generate web-animations-compatible keyframes from Angular's + * animation DSL code. + * + * The code below will be converted from: + * + * ``` + * sequence([ + * style({ opacity: 0 }), + * animate(1000, style({ opacity: 0 })) + * ]) + * ``` + * + * To: + * ``` + * keyframes = [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }] + * duration = 1000 + * delay = 0 + * easing = '' + * ``` + * + * For this operation to cover the combination of animation verbs (style, animate, group, etc...) a + * combination of prototypical inheritance, AST traversal and merge-sort-like algorithms are used. + * + * [AST Traversal] + * Each of the animation verbs, when executed, will return an string-map object representing what + * type of action it is (style, animate, group, etc...) and the data associated with it. This means + * that when functional composition mix of these functions is evaluated (like in the example above) + * then it will end up producing a tree of objects representing the animation itself. + * + * When this animation object tree is processed by the visitor code below it will visit each of the + * verb statements within the visitor. And during each visit it will build the context of the + * animation keyframes by interacting with the `TimelineBuilder`. + * + * [TimelineBuilder] + * This class is responsible for tracking the styles and building a series of keyframe objects for a + * timeline between a start and end time. The builder starts off with an initial timeline and each + * time the AST comes across a `group()`, `keyframes()` or a combination of the two wihtin a + * `sequence()` then it will generate a sub timeline for each step as well as a new one after + * they are complete. + * + * As the AST is traversed, the timing state on each of the timelines will be incremented. If a sub + * timeline was created (based on one of the cases above) then the parent timeline will attempt to + * merge the styles used within the sub timelines into itself (only with group() this will happen). + * This happens with a merge operation (much like how the merge works in mergesort) and it will only + * copy the most recently used styles from the sub timelines into the parent timeline. This ensures + * that if the styles are used later on in another phase of the animation then they will be the most + * up-to-date values. + * + * [How Missing Styles Are Updated] + * Each timeline has a `backFill` property which is responsible for filling in new styles into + * already processed keyframes if a new style shows up later within the animation sequence. + * + * ``` + * sequence([ + * style({ width: 0 }), + * animate(1000, style({ width: 100 })), + * animate(1000, style({ width: 200 })), + * animate(1000, style({ width: 300 })) + * animate(1000, style({ width: 400, height: 400 })) // notice how `height` doesn't exist anywhere + * else + * ]) + * ``` + * + * What is happening here is that the `height` value is added later in the sequence, but is missing + * from all previous animation steps. Therefore when a keyframe is created it would also be missing + * from all previous keyframes up until where it is first used. For the timeline keyframe generation + * to properly fill in the style it will place the previous value (the value from the parent + * timeline) or a default value of `*` into the backFill object. Given that each of the keyframe + * styles are objects that prototypically inhert from the backFill object, this means that if a + * value is added into the backFill then it will automatically propagate any missing values to all + * keyframes. Therefore the missing `height` value will be properly filled into the already + * processed keyframes. + * + * When a sub-timeline is created it will have its own backFill property. This is done so that + * styles present within the sub-timeline do not accidentally seep into the previous/future timeline + * keyframes + * + * (For prototypically-inherited contents to be detected a `for(i in obj)` loop must be used.) + * + * [Validation] + * The code in this file is not responsible for validation. That functionality happens with within + * the `AnimationValidatorVisitor` code. + */ +export function buildAnimationTimelines( + rootElement: any, ast: Ast, startingStyles: ɵStyleData = {}, finalStyles: ɵStyleData = {}, + options: AnimationOptions, subInstructions?: ElementInstructionMap, + errors: any[] = []): AnimationTimelineInstruction[] { + return new AnimationTimelineBuilderVisitor().buildKeyframes( + rootElement, ast, startingStyles, finalStyles, options, subInstructions, errors); +} + +export declare type StyleAtTime = { + time: number; value: string | number; +}; + +const DEFAULT_NOOP_PREVIOUS_NODE = {}; +export class AnimationTimelineContext { + public parentContext: AnimationTimelineContext|null = null; + public currentTimeline: TimelineBuilder; + public currentAnimateTimings: AnimateTimings|null = null; + public previousNode: Ast = DEFAULT_NOOP_PREVIOUS_NODE; + public subContextCount = 0; + public options: AnimationOptions = {}; + public currentQueryIndex: number = 0; + public currentQueryTotal: number = 0; + public currentStaggerTime: number = 0; + + constructor( + public element: any, public subInstructions: ElementInstructionMap, public errors: any[], + public timelines: TimelineBuilder[], initialTimeline?: TimelineBuilder) { + this.currentTimeline = initialTimeline || new TimelineBuilder(element, 0); + timelines.push(this.currentTimeline); + } + + get params() { return this.options.params; } + + updateOptions(newOptions: AnimationOptions|null, skipIfExists?: boolean) { + if (!newOptions) return; + + if (newOptions.duration != null) { + this.options.duration = resolveTimingValue(newOptions.duration); + } + + if (newOptions.delay != null) { + this.options.delay = resolveTimingValue(newOptions.delay); + } + + const newParams = newOptions.params; + if (newParams) { + let params: {[name: string]: any} = this.options && this.options.params !; + if (!params) { + params = this.options.params = {}; + } + + Object.keys(params).forEach(name => { + const value = params[name]; + if (!skipIfExists || !newOptions.hasOwnProperty(name)) { + params[name] = value; + } + }); + } + } + + private _copyOptions() { + const options: AnimationOptions = {}; + if (this.options) { + const oldParams = this.options.params; + if (oldParams) { + const params: {[name: string]: any} = options['params'] = {}; + Object.keys(this.options.params).forEach(name => { params[name] = oldParams[name]; }); + } + } + return options; + } + + createSubContext(options: AnimationOptions|null = null, element?: any, newTime?: number): + AnimationTimelineContext { + const target = element || this.element; + const context = new AnimationTimelineContext( + target, this.subInstructions, this.errors, this.timelines, + this.currentTimeline.fork(target, newTime || 0)); + context.previousNode = this.previousNode; + context.currentAnimateTimings = this.currentAnimateTimings; + + context.options = this._copyOptions(); + context.updateOptions(options); + + context.currentQueryIndex = this.currentQueryIndex; + context.currentQueryTotal = this.currentQueryTotal; + context.parentContext = this; + this.subContextCount++; + return context; + } + + transformIntoNewTimeline(newTime?: number) { + this.previousNode = DEFAULT_NOOP_PREVIOUS_NODE; + this.currentTimeline = this.currentTimeline.fork(this.element, newTime); + this.timelines.push(this.currentTimeline); + return this.currentTimeline; + } + + appendInstructionToTimeline( + instruction: AnimationTimelineInstruction, duration: number|null, + delay: number|null): AnimateTimings { + const updatedTimings: AnimateTimings = { + duration: duration != null ? duration : instruction.duration, + delay: this.currentTimeline.currentTime + (delay != null ? delay : 0) + instruction.delay, + easing: '' + }; + const builder = new SubTimelineBuilder( + instruction.element, instruction.keyframes, instruction.preStyleProps, + instruction.postStyleProps, updatedTimings, instruction.stretchStartingKeyframe); + this.timelines.push(builder); + return updatedTimings; + } + + incrementTime(time: number) { + this.currentTimeline.forwardTime(this.currentTimeline.duration + time); + } + + delayNextStep(delay: number) { + // negative delays are not yet supported + if (delay > 0) { + this.currentTimeline.delayNextStep(delay); + } + } +} + +export class AnimationTimelineBuilderVisitor implements AstVisitor { + buildKeyframes( + rootElement: any, ast: Ast, startingStyles: ɵStyleData, finalStyles: ɵStyleData, + options: AnimationOptions, subInstructions?: ElementInstructionMap, + errors: any[] = []): AnimationTimelineInstruction[] { + subInstructions = subInstructions || new ElementInstructionMap(); + const context = new AnimationTimelineContext(rootElement, subInstructions, errors, []); + context.options = options; + context.currentTimeline.setStyles([startingStyles], null, context.errors, options); + + ast.visit(this, context); + + // this checks to see if an actual animation happened + const timelines = context.timelines.filter(timeline => timeline.containsAnimation()); + if (timelines.length && Object.keys(finalStyles).length) { + const tl = timelines[timelines.length - 1]; + if (!tl.allowOnlyTimelineStyles()) { + tl.setStyles([finalStyles], null, context.errors, options); + } + } + + return timelines.length ? timelines.map(timeline => timeline.buildKeyframes()) : + [createTimelineInstruction(rootElement, [], [], [], 0, 0, '', false)]; + } + + visitTrigger(ast: TriggerAst, context: AnimationTimelineContext): any { + // these values are not visited in this AST + } + + visitState(ast: StateAst, context: AnimationTimelineContext): any { + // these values are not visited in this AST + } + + visitTransition(ast: TransitionAst, context: AnimationTimelineContext): any { + // these values are not visited in this AST + } + + visitAnimateChild(ast: AnimateChildAst, context: AnimationTimelineContext): any { + const elementInstructions = context.subInstructions.consume(context.element); + if (elementInstructions) { + const innerContext = context.createSubContext(ast.options); + const startTime = context.currentTimeline.currentTime; + const endTime = this._visitSubInstructions(elementInstructions, innerContext); + if (startTime != endTime) { + // we do this on the upper context because we created a sub context for + // the sub child animations + context.transformIntoNewTimeline(endTime); + } + } + context.previousNode = ast; + } + + visitAnimateRef(ast: AnimateRefAst, context: AnimationTimelineContext): any { + const innerContext = context.createSubContext(ast.options); + innerContext.transformIntoNewTimeline(); + this.visitReference(ast.animation, innerContext); + context.transformIntoNewTimeline(innerContext.currentTimeline.currentTime); + context.previousNode = ast; + } + + private _visitSubInstructions( + instructions: AnimationTimelineInstruction[], context: AnimationTimelineContext): number { + const options = context.options; + const startTime = context.currentTimeline.currentTime; + let furthestTime = startTime; + + // this is a special-case for when a user wants to skip a sub + // animation from being fired entirely. + const duration = options.duration != null ? resolveTimingValue(options.duration) : null; + const delay = options.delay != null ? resolveTimingValue(options.delay) : null; + if (duration !== 0) { + instructions.forEach(instruction => { + const instructionTimings = + context.appendInstructionToTimeline(instruction, duration, delay); + furthestTime = + Math.max(furthestTime, instructionTimings.duration + instructionTimings.delay); + }); + } + + return furthestTime; + } + + visitReference(ast: ReferenceAst, context: AnimationTimelineContext) { + context.updateOptions(ast.options, true); + ast.animation.visit(this, context); + context.previousNode = ast; + } + + visitSequence(ast: SequenceAst, context: AnimationTimelineContext) { + const subContextCount = context.subContextCount; + const options = ast.options; + + if (options && (options.params || options.delay)) { + context.createSubContext(options); + context.transformIntoNewTimeline(); + + if (options.delay != null) { + if (context.previousNode instanceof StyleAst) { + context.currentTimeline.snapshotCurrentStyles(); + context.previousNode = DEFAULT_NOOP_PREVIOUS_NODE; + } + + const delay = resolveTimingValue(options.delay); + context.delayNextStep(delay); + } + } + + if (ast.steps.length) { + ast.steps.forEach(s => s.visit(this, context)); + + // this is here just incase the inner steps only contain or end with a style() call + context.currentTimeline.applyStylesToKeyframe(); + + // this means that some animation function within the sequence + // ended up creating a sub timeline (which means the current + // timeline cannot overlap with the contents of the sequence) + if (context.subContextCount > subContextCount) { + context.transformIntoNewTimeline(); + } + } + + context.previousNode = ast; + } + + visitGroup(ast: GroupAst, context: AnimationTimelineContext) { + const innerTimelines: TimelineBuilder[] = []; + let furthestTime = context.currentTimeline.currentTime; + const delay = ast.options && ast.options.delay ? resolveTimingValue(ast.options.delay) : 0; + + ast.steps.forEach(s => { + const innerContext = context.createSubContext(ast.options); + if (delay) { + innerContext.delayNextStep(delay); + } + + s.visit(this, innerContext); + furthestTime = Math.max(furthestTime, innerContext.currentTimeline.currentTime); + innerTimelines.push(innerContext.currentTimeline); + }); + + // this operation is run after the AST loop because otherwise + // if the parent timeline's collected styles were updated then + // it would pass in invalid data into the new-to-be forked items + innerTimelines.forEach( + timeline => context.currentTimeline.mergeTimelineCollectedStyles(timeline)); + context.transformIntoNewTimeline(furthestTime); + context.previousNode = ast; + } + + visitTiming(ast: TimingAst, context: AnimationTimelineContext): AnimateTimings { + if (ast instanceof DynamicTimingAst) { + const strValue = context.params ? + interpolateParams(ast.value, context.params, context.errors) : + ast.value.toString(); + return resolveTiming(strValue, context.errors); + } else { + return {duration: ast.duration, delay: ast.delay, easing: ast.easing}; + } + } + + visitAnimate(ast: AnimateAst, context: AnimationTimelineContext) { + const timings = context.currentAnimateTimings = this.visitTiming(ast.timings, context); + const timeline = context.currentTimeline; + if (timings.delay) { + context.incrementTime(timings.delay); + timeline.snapshotCurrentStyles(); + } + + const style = ast.style; + if (style instanceof KeyframesAst) { + this.visitKeyframes(style, context); + } else { + context.incrementTime(timings.duration); + this.visitStyle(style as StyleAst, context); + timeline.applyStylesToKeyframe(); + } + + context.currentAnimateTimings = null; + context.previousNode = ast; + } + + visitStyle(ast: StyleAst, context: AnimationTimelineContext) { + const timeline = context.currentTimeline; + const timings = context.currentAnimateTimings !; + + // this is a special case for when a style() call + // directly follows an animate() call (but not inside of an animate() call) + if (!timings && timeline.getCurrentStyleProperties().length) { + timeline.forwardFrame(); + } + + const easing = (timings && timings.easing) || ast.easing; + if (ast.isEmptyStep) { + timeline.applyEmptyStep(easing); + } else { + timeline.setStyles(ast.styles, easing, context.errors, context.options); + } + + context.previousNode = ast; + } + + visitKeyframes(ast: KeyframesAst, context: AnimationTimelineContext) { + const currentAnimateTimings = context.currentAnimateTimings !; + const startTime = (context.currentTimeline !).duration; + const duration = currentAnimateTimings.duration; + const innerContext = context.createSubContext(); + const innerTimeline = innerContext.currentTimeline; + innerTimeline.easing = currentAnimateTimings.easing; + + ast.styles.forEach(step => { + const offset: number = step.offset || 0; + innerTimeline.forwardTime(offset * duration); + innerTimeline.setStyles(step.styles, step.easing, context.errors, context.options); + innerTimeline.applyStylesToKeyframe(); + }); + + // this will ensure that the parent timeline gets all the styles from + // the child even if the new timeline below is not used + context.currentTimeline.mergeTimelineCollectedStyles(innerTimeline); + + // we do this because the window between this timeline and the sub timeline + // should ensure that the styles within are exactly the same as they were before + context.transformIntoNewTimeline(startTime + duration); + context.previousNode = ast; + } + + visitQuery(ast: QueryAst, context: AnimationTimelineContext) { + // in the event that the first step before this is a style step we need + // to ensure the styles are applied before the children are animated + const startTime = context.currentTimeline.currentTime; + const options = (ast.options || {}) as AnimationQueryOptions; + const delay = options.delay ? resolveTimingValue(options.delay) : 0; + + if (delay && (context.previousNode instanceof StyleAst || + (startTime == 0 && context.currentTimeline.getCurrentStyleProperties().length))) { + context.currentTimeline.snapshotCurrentStyles(); + context.previousNode = DEFAULT_NOOP_PREVIOUS_NODE; + } + + let furthestTime = startTime; + const elms = invokeQuery( + context.element, ast.selector, ast.originalSelector, ast.limit, ast.includeSelf, + options.optional ? true : false, context.errors); + + context.currentQueryTotal = elms.length; + let sameElementTimeline: TimelineBuilder|null = null; + elms.forEach((element, i) => { + + context.currentQueryIndex = i; + const innerContext = context.createSubContext(ast.options, element); + if (delay) { + innerContext.delayNextStep(delay); + } + + if (element === context.element) { + sameElementTimeline = innerContext.currentTimeline; + } + + ast.animation.visit(this, innerContext); + + // this is here just incase the inner steps only contain or end + // with a style() call (which is here to signal that this is a preparatory + // call to style an element before it is animated again) + innerContext.currentTimeline.applyStylesToKeyframe(); + + const endTime = innerContext.currentTimeline.currentTime; + furthestTime = Math.max(furthestTime, endTime); + }); + + context.currentQueryIndex = 0; + context.currentQueryTotal = 0; + context.transformIntoNewTimeline(furthestTime); + + if (sameElementTimeline) { + context.currentTimeline.mergeTimelineCollectedStyles(sameElementTimeline); + context.currentTimeline.snapshotCurrentStyles(); + } + + context.previousNode = ast; + } + + visitStagger(ast: StaggerAst, context: AnimationTimelineContext) { + const parentContext = context.parentContext !; + const tl = context.currentTimeline; + const timings = ast.timings; + const duration = Math.abs(timings.duration); + const maxTime = duration * (context.currentQueryTotal - 1); + let delay = duration * context.currentQueryIndex; + + let staggerTransformer = timings.duration < 0 ? 'reverse' : timings.easing; + switch (staggerTransformer) { + case 'reverse': + delay = maxTime - delay; + break; + case 'full': + delay = parentContext.currentStaggerTime; + break; + } + + if (delay) { + context.currentTimeline.delayNextStep(delay); + } + + const startingTime = context.currentTimeline.currentTime; + ast.animation.visit(this, context); + context.previousNode = ast; + + // time = duration + delay + // the reason why this computation is so complex is because + // the inner timeline may either have a delay value or a stretched + // keyframe depending on if a subtimeline is not used or is used. + parentContext.currentStaggerTime = + (tl.currentTime - startingTime) + (tl.startTime - parentContext.currentTimeline.startTime); + } +} + +export class TimelineBuilder { + public duration: number = 0; + public easing: string|null; + private _previousKeyframe: ɵStyleData = {}; + private _currentKeyframe: ɵStyleData = {}; + private _keyframes = new Map(); + private _styleSummary: {[prop: string]: StyleAtTime} = {}; + private _localTimelineStyles: ɵStyleData; + private _globalTimelineStyles: ɵStyleData; + private _pendingStyles: ɵStyleData = {}; + private _backFill: ɵStyleData = {}; + private _currentEmptyStepKeyframe: ɵStyleData|null = null; + + constructor( + public element: any, public startTime: number, + private _elementTimelineStylesLookup?: Map) { + if (!this._elementTimelineStylesLookup) { + this._elementTimelineStylesLookup = new Map(); + } + + this._localTimelineStyles = Object.create(this._backFill, {}); + this._globalTimelineStyles = this._elementTimelineStylesLookup.get(element) !; + if (!this._globalTimelineStyles) { + this._globalTimelineStyles = this._localTimelineStyles; + this._elementTimelineStylesLookup.set(element, this._localTimelineStyles); + } + this._loadKeyframe(); + } + + containsAnimation(): boolean { + switch (this._keyframes.size) { + case 0: + return false; + case 1: + return this.getCurrentStyleProperties().length > 0; + default: + return true; + } + } + + getCurrentStyleProperties(): string[] { return Object.keys(this._currentKeyframe); } + + get currentTime() { return this.startTime + this.duration; } + + delayNextStep(delay: number) { + if (this.duration == 0) { + this.startTime += delay; + } else { + this.forwardTime(this.currentTime + delay); + } + } + + fork(element: any, currentTime?: number): TimelineBuilder { + this.applyStylesToKeyframe(); + return new TimelineBuilder( + element, currentTime || this.currentTime, this._elementTimelineStylesLookup); + } + + private _loadKeyframe() { + if (this._currentKeyframe) { + this._previousKeyframe = this._currentKeyframe; + } + this._currentKeyframe = this._keyframes.get(this.duration) !; + if (!this._currentKeyframe) { + this._currentKeyframe = Object.create(this._backFill, {}); + this._keyframes.set(this.duration, this._currentKeyframe); + } + } + + forwardFrame() { + this.duration += ONE_FRAME_IN_MILLISECONDS; + this._loadKeyframe(); + } + + forwardTime(time: number) { + this.applyStylesToKeyframe(); + this.duration = time; + this._loadKeyframe(); + } + + private _updateStyle(prop: string, value: string|number) { + this._localTimelineStyles[prop] = value; + this._globalTimelineStyles[prop] = value; + this._styleSummary[prop] = {time: this.currentTime, value}; + } + + allowOnlyTimelineStyles() { return this._currentEmptyStepKeyframe !== this._currentKeyframe; } + + applyEmptyStep(easing: string|null) { + if (easing) { + this._previousKeyframe['easing'] = easing; + } + + // special case for animate(duration): + // all missing styles are filled with a `*` value then + // if any destination styles are filled in later on the same + // keyframe then they will override the overridden styles + // We use `_globalTimelineStyles` here because there may be + // styles in previous keyframes that are not present in this timeline + Object.keys(this._globalTimelineStyles).forEach(prop => { + this._backFill[prop] = this._globalTimelineStyles[prop] || AUTO_STYLE; + this._currentKeyframe[prop] = AUTO_STYLE; + }); + this._currentEmptyStepKeyframe = this._currentKeyframe; + } + + setStyles( + input: (ɵStyleData|string)[], easing: string|null, errors: any[], + options?: AnimationOptions) { + if (easing) { + this._previousKeyframe['easing'] = easing; + } + + const params = (options && options.params) || {}; + const styles = flattenStyles(input, this._globalTimelineStyles); + Object.keys(styles).forEach(prop => { + const val = interpolateParams(styles[prop], params, errors); + this._pendingStyles[prop] = val; + if (!this._localTimelineStyles.hasOwnProperty(prop)) { + this._backFill[prop] = this._globalTimelineStyles.hasOwnProperty(prop) ? + this._globalTimelineStyles[prop] : + AUTO_STYLE; + } + this._updateStyle(prop, val); + }); + } + + applyStylesToKeyframe() { + const styles = this._pendingStyles; + const props = Object.keys(styles); + if (props.length == 0) return; + + this._pendingStyles = {}; + + props.forEach(prop => { + const val = styles[prop]; + this._currentKeyframe[prop] = val; + }); + + Object.keys(this._localTimelineStyles).forEach(prop => { + if (!this._currentKeyframe.hasOwnProperty(prop)) { + this._currentKeyframe[prop] = this._localTimelineStyles[prop]; + } + }); + } + + snapshotCurrentStyles() { + Object.keys(this._localTimelineStyles).forEach(prop => { + const val = this._localTimelineStyles[prop]; + this._pendingStyles[prop] = val; + this._updateStyle(prop, val); + }); + } + + getFinalKeyframe() { return this._keyframes.get(this.duration); } + + get properties() { + const properties: string[] = []; + for (let prop in this._currentKeyframe) { + properties.push(prop); + } + return properties; + } + + mergeTimelineCollectedStyles(timeline: TimelineBuilder) { + Object.keys(timeline._styleSummary).forEach(prop => { + const details0 = this._styleSummary[prop]; + const details1 = timeline._styleSummary[prop]; + if (!details0 || details1.time > details0.time) { + this._updateStyle(prop, details1.value); + } + }); + } + + buildKeyframes(): AnimationTimelineInstruction { + this.applyStylesToKeyframe(); + const preStyleProps = new Set(); + const postStyleProps = new Set(); + const isEmpty = this._keyframes.size === 1 && this.duration === 0; + + let finalKeyframes: ɵStyleData[] = []; + this._keyframes.forEach((keyframe, time) => { + const finalKeyframe = copyStyles(keyframe, true); + Object.keys(finalKeyframe).forEach(prop => { + const value = finalKeyframe[prop]; + if (value == PRE_STYLE) { + preStyleProps.add(prop); + } else if (value == AUTO_STYLE) { + postStyleProps.add(prop); + } + }); + if (!isEmpty) { + finalKeyframe['offset'] = time / this.duration; + } + finalKeyframes.push(finalKeyframe); + }); + + const preProps: string[] = preStyleProps.size ? iteratorToArray(preStyleProps.values()) : []; + const postProps: string[] = postStyleProps.size ? iteratorToArray(postStyleProps.values()) : []; + + // special case for a 0-second animation (which is designed just to place styles onscreen) + if (isEmpty) { + const kf0 = finalKeyframes[0]; + const kf1 = copyObj(kf0); + kf0['offset'] = 0; + kf1['offset'] = 1; + finalKeyframes = [kf0, kf1]; + } + + return createTimelineInstruction( + this.element, finalKeyframes, preProps, postProps, this.duration, this.startTime, + this.easing, false); + } +} + +class SubTimelineBuilder extends TimelineBuilder { + public timings: AnimateTimings; + + constructor( + public element: any, public keyframes: ɵStyleData[], public preStyleProps: string[], + public postStyleProps: string[], timings: AnimateTimings, + private _stretchStartingKeyframe: boolean = false) { + super(element, timings.delay); + this.timings = {duration: timings.duration, delay: timings.delay, easing: timings.easing}; + } + + containsAnimation(): boolean { return this.keyframes.length > 1; } + + buildKeyframes(): AnimationTimelineInstruction { + let keyframes = this.keyframes; + let {delay, duration, easing} = this.timings; + if (this._stretchStartingKeyframe && delay) { + const newKeyframes: ɵStyleData[] = []; + const totalTime = duration + delay; + const startingGap = delay / totalTime; + + // the original starting keyframe now starts once the delay is done + const newFirstKeyframe = copyStyles(keyframes[0], false); + newFirstKeyframe['offset'] = 0; + newKeyframes.push(newFirstKeyframe); + + const oldFirstKeyframe = copyStyles(keyframes[0], false); + oldFirstKeyframe['offset'] = roundOffset(startingGap); + newKeyframes.push(oldFirstKeyframe); + + /* + When the keyframe is stretched then it means that the delay before the animation + starts is gone. Instead the first keyframe is placed at the start of the animation + and it is then copied to where it starts when the original delay is over. This basically + means nothing animates during that delay, but the styles are still renderered. For this + to work the original offset values that exist in the original keyframes must be "warped" + so that they can take the new keyframe + delay into account. + + delay=1000, duration=1000, keyframes = 0 .5 1 + + turns into + + delay=0, duration=2000, keyframes = 0 .33 .66 1 + */ + + // offsets between 1 ... n -1 are all warped by the keyframe stretch + const limit = keyframes.length - 1; + for (let i = 1; i <= limit; i++) { + let kf = copyStyles(keyframes[i], false); + const oldOffset = kf['offset'] as number; + const timeAtKeyframe = delay + oldOffset * duration; + kf['offset'] = roundOffset(timeAtKeyframe / totalTime); + newKeyframes.push(kf); + } + + // the new starting keyframe should be added at the start + duration = totalTime; + delay = 0; + easing = ''; + + keyframes = newKeyframes; + } + + return createTimelineInstruction( + this.element, keyframes, this.preStyleProps, this.postStyleProps, duration, delay, easing, + true); + } +} + +function invokeQuery( + rootElement: any, selector: string, originalSelector: string, limit: number, + includeSelf: boolean, optional: boolean, errors: any[]): any[] { + const multi = limit != 1; + let results: any[] = []; + if (includeSelf) { + results.push(rootElement); + } + if (selector.length > 0) { // if :self is only used then the selector is empty + if (multi) { + results.push(...rootElement.querySelectorAll(selector)); + if (limit > 1) { + results = results.slice(0, limit); + } + } else { + const elm = rootElement.querySelector(selector); + if (elm) { + results.push(elm); + } + } + } + + if (!optional && results.length == 0) { + errors.push( + `\`query("${originalSelector}")\` returned zero elements. (Use \`query("${originalSelector}", { optional: true })\` if you wish to allow this.)`); + } + return results; +} + +function roundOffset(offset: number, decimalPoints = 3): number { + const mult = Math.pow(10, decimalPoints - 1); + return Math.round(offset * mult) / mult; +} + +function flattenStyles(input: (ɵStyleData | string)[], allStyles: ɵStyleData) { + const styles: ɵStyleData = {}; + let allProperties: string[]; + input.forEach(token => { + if (token === '*') { + allProperties = allProperties || Object.keys(allStyles); + allProperties.forEach(prop => { styles[prop] = AUTO_STYLE; }); + } else { + copyStyles(token as ɵStyleData, false, styles); + } + }); + return styles; +} diff --git a/packages/animations/browser/src/dsl/animation_timeline_instruction.ts b/packages/animations/browser/src/dsl/animation_timeline_instruction.ts index a7486ecf6c..4c10d69a3d 100644 --- a/packages/animations/browser/src/dsl/animation_timeline_instruction.ts +++ b/packages/animations/browser/src/dsl/animation_timeline_instruction.ts @@ -9,21 +9,30 @@ import {ɵStyleData} from '@angular/animations'; import {AnimationEngineInstruction, AnimationTransitionInstructionType} from '../render/animation_engine_instruction'; export interface AnimationTimelineInstruction extends AnimationEngineInstruction { + element: any; keyframes: ɵStyleData[]; + preStyleProps: string[]; + postStyleProps: string[]; duration: number; delay: number; totalTime: number; - easing: string|null|undefined; + easing: string|null; + stretchStartingKeyframe?: boolean; + subTimeline: boolean; } export function createTimelineInstruction( - keyframes: ɵStyleData[], duration: number, delay: number, - easing: string | null | undefined): AnimationTimelineInstruction { + element: any, keyframes: ɵStyleData[], preStyleProps: string[], postStyleProps: string[], + duration: number, delay: number, easing: string | null = null, + subTimeline: boolean = false): AnimationTimelineInstruction { return { type: AnimationTransitionInstructionType.TimelineAnimation, + element, keyframes, + preStyleProps, + postStyleProps, duration, delay, - totalTime: duration + delay, easing + totalTime: duration + delay, easing, subTimeline }; } diff --git a/packages/animations/browser/src/dsl/animation_timeline_visitor.ts b/packages/animations/browser/src/dsl/animation_timeline_visitor.ts deleted file mode 100644 index c5583c4f3b..0000000000 --- a/packages/animations/browser/src/dsl/animation_timeline_visitor.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * 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, AnimateTimings, AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, sequence, style, ɵStyleData} from '@angular/animations'; - -import {copyStyles, normalizeStyles, parseTimeExpression} from '../util'; - -import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; -import {AnimationTimelineInstruction, createTimelineInstruction} from './animation_timeline_instruction'; - - - -/* - * The code within this file aims to generate web-animations-compatible keyframes from Angular's - * animation DSL code. - * - * The code below will be converted from: - * - * ``` - * sequence([ - * style({ opacity: 0 }), - * animate(1000, style({ opacity: 0 })) - * ]) - * ``` - * - * To: - * ``` - * keyframes = [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }] - * duration = 1000 - * delay = 0 - * easing = '' - * ``` - * - * For this operation to cover the combination of animation verbs (style, animate, group, etc...) a - * combination of prototypical inheritance, AST traversal and merge-sort-like algorithms are used. - * - * [AST Traversal] - * Each of the animation verbs, when executed, will return an string-map object representing what - * type of action it is (style, animate, group, etc...) and the data associated with it. This means - * that when functional composition mix of these functions is evaluated (like in the example above) - * then it will end up producing a tree of objects representing the animation itself. - * - * When this animation object tree is processed by the visitor code below it will visit each of the - * verb statements within the visitor. And during each visit it will build the context of the - * animation keyframes by interacting with the `TimelineBuilder`. - * - * [TimelineBuilder] - * This class is responsible for tracking the styles and building a series of keyframe objects for a - * timeline between a start and end time. The builder starts off with an initial timeline and each - * time the AST comes across a `group()`, `keyframes()` or a combination of the two within a - * `sequence()` then it will generate a sub timeline for each step as well as a new one after - * they are complete. - * - * As the AST is traversed, the timing state on each of the timelines will be incremented. If a sub - * timeline was created (based on one of the cases above) then the parent timeline will attempt to - * merge the styles used within the sub timelines into itself (only with group() this will happen). - * This happens with a merge operation (much like how the merge works in mergesort) and it will only - * copy the most recently used styles from the sub timelines into the parent timeline. This ensures - * that if the styles are used later on in another phase of the animation then they will be the most - * up-to-date values. - * - * [How Missing Styles Are Updated] - * Each timeline has a `backFill` property which is responsible for filling in new styles into - * already processed keyframes if a new style shows up later within the animation sequence. - * - * ``` - * sequence([ - * style({ width: 0 }), - * animate(1000, style({ width: 100 })), - * animate(1000, style({ width: 200 })), - * animate(1000, style({ width: 300 })) - * animate(1000, style({ width: 400, height: 400 })) // notice how `height` doesn't exist anywhere - * else - * ]) - * ``` - * - * What is happening here is that the `height` value is added later in the sequence, but is missing - * from all previous animation steps. Therefore when a keyframe is created it would also be missing - * from all previous keyframes up until where it is first used. For the timeline keyframe generation - * to properly fill in the style it will place the previous value (the value from the parent - * timeline) or a default value of `*` into the backFill object. Given that each of the keyframe - * styles are objects that prototypically inherited from the backFill object, this means that if a - * value is added into the backFill then it will automatically propagate any missing values to all - * keyframes. Therefore the missing `height` value will be properly filled into the already - * processed keyframes. - * - * When a sub-timeline is created it will have its own backFill property. This is done so that - * styles present within the sub-timeline do not accidentally seep into the previous/future timeline - * keyframes - * - * (For prototypically inherited contents to be detected a `for(i in obj)` loop must be used.) - * - * [Validation] - * The code in this file is not responsible for validation. That functionality happens with within - * the `AnimationValidatorVisitor` code. - */ -export function buildAnimationKeyframes( - ast: AnimationMetadata | AnimationMetadata[], startingStyles: ɵStyleData = {}, - finalStyles: ɵStyleData = {}): AnimationTimelineInstruction[] { - const normalizedAst = - Array.isArray(ast) ? sequence(ast) : ast; - return new AnimationTimelineVisitor().buildKeyframes(normalizedAst, startingStyles, finalStyles); -} - -export declare type StyleAtTime = { - time: number; value: string | number; -}; - -export class AnimationTimelineContext { - currentTimeline: TimelineBuilder; - currentAnimateTimings: AnimateTimings|null; - previousNode: AnimationMetadata = {}; - subContextCount = 0; - - constructor( - public errors: any[], public timelines: TimelineBuilder[], - initialTimeline?: TimelineBuilder) { - this.currentTimeline = initialTimeline || new TimelineBuilder(0); - timelines.push(this.currentTimeline); - } - - createSubContext(): AnimationTimelineContext { - const context = - new AnimationTimelineContext(this.errors, this.timelines, this.currentTimeline.fork()); - context.previousNode = this.previousNode; - context.currentAnimateTimings = this.currentAnimateTimings; - this.subContextCount++; - return context; - } - - transformIntoNewTimeline(newTime = 0) { - this.currentTimeline = this.currentTimeline.fork(newTime); - this.timelines.push(this.currentTimeline); - return this.currentTimeline; - } - - incrementTime(time: number) { - this.currentTimeline.forwardTime(this.currentTimeline.duration + time); - } -} - -export class AnimationTimelineVisitor implements AnimationDslVisitor { - buildKeyframes(ast: AnimationMetadata, startingStyles: ɵStyleData, finalStyles: ɵStyleData): - AnimationTimelineInstruction[] { - const context = new AnimationTimelineContext([], []); - context.currentTimeline.setStyles(startingStyles); - - visitAnimationNode(this, ast, context); - - // this checks to see if an actual animation happened - const timelines = context.timelines.filter(timeline => timeline.hasStyling()); - if (timelines.length && Object.keys(finalStyles).length) { - const tl = timelines[timelines.length - 1]; - if (!tl.allowOnlyTimelineStyles()) { - tl.setStyles(finalStyles); - } - } - - return timelines.length ? timelines.map(timeline => timeline.buildKeyframes()) : - [createTimelineInstruction([], 0, 0, '')]; - } - - visitState(ast: AnimationStateMetadata, context: any): any { - // these values are not visited in this AST - } - - visitTransition(ast: AnimationTransitionMetadata, context: any): any { - // these values are not visited in this AST - } - - visitSequence(ast: AnimationSequenceMetadata, context: AnimationTimelineContext) { - const subContextCount = context.subContextCount; - if (context.previousNode.type == AnimationMetadataType.Style) { - context.currentTimeline.forwardFrame(); - context.currentTimeline.snapshotCurrentStyles(); - } - - ast.steps.forEach(s => visitAnimationNode(this, s, context)); - - // this means that some animation function within the sequence - // ended up creating a sub timeline (which means the current - // timeline cannot overlap with the contents of the sequence) - if (context.subContextCount > subContextCount) { - context.transformIntoNewTimeline(); - } - - context.previousNode = ast; - } - - visitGroup(ast: AnimationGroupMetadata, context: AnimationTimelineContext) { - const innerTimelines: TimelineBuilder[] = []; - let furthestTime = context.currentTimeline.currentTime; - ast.steps.forEach(s => { - const innerContext = context.createSubContext(); - visitAnimationNode(this, s, innerContext); - furthestTime = Math.max(furthestTime, innerContext.currentTimeline.currentTime); - innerTimelines.push(innerContext.currentTimeline); - }); - - // this operation is run after the AST loop because otherwise - // if the parent timeline's collected styles were updated then - // it would pass in invalid data into the new-to-be forked items - innerTimelines.forEach( - timeline => context.currentTimeline.mergeTimelineCollectedStyles(timeline)); - context.transformIntoNewTimeline(furthestTime); - context.previousNode = ast; - } - - visitAnimate(ast: AnimationAnimateMetadata, context: AnimationTimelineContext) { - const timings = ast.timings.hasOwnProperty('duration') ? - ast.timings : - parseTimeExpression(ast.timings, context.errors); - context.currentAnimateTimings = timings; - - if (timings.delay) { - context.incrementTime(timings.delay); - context.currentTimeline.snapshotCurrentStyles(); - } - - const astType = ast.styles ? ast.styles.type : -1; - if (astType == AnimationMetadataType.KeyframeSequence) { - this.visitKeyframeSequence(ast.styles, context); - } else { - let styleAst = ast.styles as AnimationStyleMetadata; - if (!styleAst) { - const newStyleData: {[prop: string]: string | number} = {}; - if (timings.easing) { - newStyleData['easing'] = timings.easing; - } - styleAst = style(newStyleData); - (styleAst as any)['treatAsEmptyStep'] = true; - } - context.incrementTime(timings.duration); - if (styleAst) { - this.visitStyle(styleAst, context); - } - } - - context.currentAnimateTimings = null; - context.previousNode = ast; - } - - visitStyle(ast: AnimationStyleMetadata, context: AnimationTimelineContext) { - // this is a special case when a style() call is issued directly after - // a call to animate(). If the clock is not forwarded by one frame then - // the style() calls will be merged into the previous animate() call - // which is incorrect. - if (!context.currentAnimateTimings && - context.previousNode.type == AnimationMetadataType.Animate) { - context.currentTimeline.forwardFrame(); - } - - const normalizedStyles = normalizeStyles(ast.styles); - const easing = context.currentAnimateTimings && context.currentAnimateTimings.easing; - this._applyStyles( - normalizedStyles, easing, (ast as any)['treatAsEmptyStep'] ? true : false, context); - context.previousNode = ast; - } - - private _applyStyles( - styles: ɵStyleData, easing: string|null, treatAsEmptyStep: boolean, - context: AnimationTimelineContext) { - if (styles.hasOwnProperty('easing')) { - easing = easing || styles['easing'] as string; - delete styles['easing']; - } - context.currentTimeline.setStyles(styles, easing, treatAsEmptyStep); - } - - visitKeyframeSequence( - ast: AnimationKeyframesSequenceMetadata, context: AnimationTimelineContext) { - const MAX_KEYFRAME_OFFSET = 1; - const limit = ast.steps.length - 1; - const firstKeyframe = ast.steps[0]; - - let offsetGap = 0; - const containsOffsets = getOffset(firstKeyframe) != null; - if (!containsOffsets) { - offsetGap = MAX_KEYFRAME_OFFSET / limit; - } - - const startTime = context.currentTimeline.duration; - const duration = context.currentAnimateTimings !.duration; - const innerContext = context.createSubContext(); - const innerTimeline = innerContext.currentTimeline; - innerTimeline.easing = context.currentAnimateTimings !.easing; - - ast.steps.forEach((step: AnimationStyleMetadata, i: number) => { - const normalizedStyles = normalizeStyles(step.styles); - const offset = containsOffsets ? - (step.offset != null ? step.offset : parseFloat(normalizedStyles['offset'] as string)) : - (i == limit ? MAX_KEYFRAME_OFFSET : i * offsetGap); - innerTimeline.forwardTime(offset * duration); - this._applyStyles(normalizedStyles, null, false, innerContext); - }); - - // this will ensure that the parent timeline gets all the styles from - // the child even if the new timeline below is not used - context.currentTimeline.mergeTimelineCollectedStyles(innerTimeline); - - // we do this because the window between this timeline and the sub timeline - // should ensure that the styles within are exactly the same as they were before - context.transformIntoNewTimeline(startTime + duration); - context.previousNode = ast; - } -} - -export class TimelineBuilder { - public duration: number = 0; - public easing: string|null = ''; - private _previousKeyframe: ɵStyleData = {}; - private _currentKeyframe: ɵStyleData; - private _keyframes = new Map(); - private _styleSummary: {[prop: string]: StyleAtTime} = {}; - private _localTimelineStyles: ɵStyleData; - private _backFill: ɵStyleData = {}; - private _currentEmptyStepKeyframe: ɵStyleData|null = null; - private _globalTimelineStyles: ɵStyleData; - - constructor(public startTime: number, globalTimelineStyles?: ɵStyleData) { - this._localTimelineStyles = Object.create(this._backFill, {}); - this._globalTimelineStyles = - globalTimelineStyles ? globalTimelineStyles : this._localTimelineStyles; - this._loadKeyframe(); - } - - hasStyling(): boolean { return this._keyframes.size > 1; } - - get currentTime() { return this.startTime + this.duration; } - - fork(currentTime = 0): TimelineBuilder { - return new TimelineBuilder(currentTime || this.currentTime, this._globalTimelineStyles); - } - - private _loadKeyframe() { - if (this._currentKeyframe) { - this._previousKeyframe = this._currentKeyframe; - } - this._currentKeyframe = this._keyframes.get(this.duration) !; - if (!this._currentKeyframe) { - this._currentKeyframe = Object.create(this._backFill, {}); - this._keyframes.set(this.duration, this._currentKeyframe); - } - } - - forwardFrame() { - this.duration++; - this._loadKeyframe(); - } - - forwardTime(time: number) { - this.duration = time; - this._loadKeyframe(); - } - - private _updateStyle(prop: string, value: string|number) { - this._localTimelineStyles[prop] = value; - this._globalTimelineStyles ![prop] = value; - this._styleSummary[prop] = {time: this.currentTime, value}; - } - - allowOnlyTimelineStyles() { return this._currentEmptyStepKeyframe !== this._currentKeyframe; } - - setStyles(styles: ɵStyleData, easing: string|null = null, treatAsEmptyStep: boolean = false) { - if (easing) { - this._previousKeyframe !['easing'] = easing; - } - - if (treatAsEmptyStep) { - // special case for animate(duration): - // all missing styles are filled with a `*` value then - // if any destination styles are filled in later on the same - // keyframe then they will override the overridden styles - // We use `_globalTimelineStyles` here because there may be - // styles in previous keyframes that are not present in this timeline - Object.keys(this._globalTimelineStyles).forEach(prop => { - this._backFill[prop] = this._globalTimelineStyles[prop] || AUTO_STYLE; - this._currentKeyframe[prop] = AUTO_STYLE; - }); - this._currentEmptyStepKeyframe = this._currentKeyframe; - } else { - Object.keys(styles).forEach(prop => { - if (prop !== 'offset') { - const val = styles[prop]; - this._currentKeyframe[prop] = val; - if (!this._localTimelineStyles[prop]) { - this._backFill[prop] = this._globalTimelineStyles[prop] || AUTO_STYLE; - } - this._updateStyle(prop, val); - } - }); - - Object.keys(this._localTimelineStyles).forEach(prop => { - if (!this._currentKeyframe.hasOwnProperty(prop)) { - this._currentKeyframe[prop] = this._localTimelineStyles[prop]; - } - }); - } - } - - snapshotCurrentStyles() { copyStyles(this._localTimelineStyles, false, this._currentKeyframe); } - - getFinalKeyframe(): ɵStyleData { return this._keyframes.get(this.duration) !; } - - get properties() { - const properties: string[] = []; - for (let prop in this._currentKeyframe) { - properties.push(prop); - } - return properties; - } - - mergeTimelineCollectedStyles(timeline: TimelineBuilder) { - Object.keys(timeline._styleSummary).forEach(prop => { - const details0 = this._styleSummary[prop]; - const details1 = timeline._styleSummary[prop]; - if (!details0 || details1.time > details0.time) { - this._updateStyle(prop, details1.value); - } - }); - } - - buildKeyframes(): AnimationTimelineInstruction { - const finalKeyframes: ɵStyleData[] = []; - // special case for when there are only start/destination - // styles but no actual animation animate steps... - if (this.duration == 0) { - const targetKeyframe = this.getFinalKeyframe(); - - const firstKeyframe = copyStyles(targetKeyframe, true); - firstKeyframe['offset'] = 0; - finalKeyframes.push(firstKeyframe); - - const lastKeyframe = copyStyles(targetKeyframe, true); - lastKeyframe['offset'] = 1; - finalKeyframes.push(lastKeyframe); - } else { - this._keyframes.forEach((keyframe, time) => { - const finalKeyframe = copyStyles(keyframe, true); - finalKeyframe['offset'] = time / this.duration; - finalKeyframes.push(finalKeyframe); - }); - } - - return createTimelineInstruction(finalKeyframes, this.duration, this.startTime, this.easing); - } -} - -function getOffset(ast: AnimationStyleMetadata): number { - let offset = ast.offset; - if (offset == null) { - const styles = ast.styles; - if (Array.isArray(styles)) { - for (let i = 0; i < styles.length; i++) { - const o = styles[i]['offset'] as number; - if (o != null) { - offset = o; - break; - } - } - } else { - offset = styles['offset'] as number; - } - } - return offset !; -} diff --git a/packages/animations/browser/src/dsl/animation_transition_expr.ts b/packages/animations/browser/src/dsl/animation_transition_expr.ts index de2fb7da11..0458e3fc71 100644 --- a/packages/animations/browser/src/dsl/animation_transition_expr.ts +++ b/packages/animations/browser/src/dsl/animation_transition_expr.ts @@ -57,8 +57,16 @@ function parseAnimationAlias(alias: string, errors: string[]): string { function makeLambdaFromStates(lhs: string, rhs: string): TransitionMatcherFn { return (fromState: any, toState: any): boolean => { - const lhsMatch = lhs == ANY_STATE || lhs == fromState; - const rhsMatch = rhs == ANY_STATE || rhs == toState; + let lhsMatch = lhs == ANY_STATE || lhs == fromState; + let rhsMatch = rhs == ANY_STATE || rhs == toState; + + if (!lhsMatch && typeof fromState === 'boolean') { + lhsMatch = fromState ? lhs === 'true' : lhs === 'false'; + } + if (!rhsMatch && typeof toState === 'boolean') { + rhsMatch = toState ? rhs === 'true' : rhs === 'false'; + } + return lhsMatch && rhsMatch; }; } diff --git a/packages/animations/browser/src/dsl/animation_transition_factory.ts b/packages/animations/browser/src/dsl/animation_transition_factory.ts index a5c1bc0298..6eedcc118c 100644 --- a/packages/animations/browser/src/dsl/animation_transition_factory.ts +++ b/packages/animations/browser/src/dsl/animation_transition_factory.ts @@ -5,38 +5,66 @@ * 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 {AnimationMetadata, AnimationTransitionMetadata, sequence, ɵStyleData} from '@angular/animations'; +import {AnimationOptions, ɵStyleData} from '@angular/animations'; -import {buildAnimationKeyframes} from './animation_timeline_visitor'; +import {getOrSetAsInMap} from '../render/shared'; +import {iteratorToArray, mergeAnimationOptions} from '../util'; + +import {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'; export class AnimationTransitionFactory { - private _animationAst: AnimationMetadata; - constructor( - private _triggerName: string, ast: AnimationTransitionMetadata, - private matchFns: TransitionMatcherFn[], - private _stateStyles: {[stateName: string]: ɵStyleData}) { - const normalizedAst = Array.isArray(ast.animation) ? - sequence(ast.animation) : - ast.animation; - this._animationAst = normalizedAst; + private _triggerName: string, public ast: TransitionAst, + private _stateStyles: {[stateName: string]: ɵStyleData}) {} + + match(currentState: any, nextState: any): boolean { + return oneOrMoreTransitionsMatch(this.ast.matchers, currentState, nextState); } - match(currentState: any, nextState: any): AnimationTransitionInstruction|undefined { - if (!oneOrMoreTransitionsMatch(this.matchFns, currentState, nextState)) return; + build( + element: any, currentState: any, nextState: any, options?: AnimationOptions, + subInstructions?: ElementInstructionMap): AnimationTransitionInstruction|undefined { + const animationOptions = mergeAnimationOptions(this.ast.options || {}, options || {}); const backupStateStyles = this._stateStyles['*'] || {}; const currentStateStyles = this._stateStyles[currentState] || backupStateStyles; const nextStateStyles = this._stateStyles[nextState] || backupStateStyles; - const timelines = - buildAnimationKeyframes(this._animationAst, currentStateStyles, nextStateStyles); + const errors: any[] = []; + const timelines = buildAnimationTimelines( + element, this.ast.animation, currentStateStyles, nextStateStyles, animationOptions, + subInstructions, errors); + if (errors.length) { + const errorMessage = `animation building failed:\n${errors.join("\n")}`; + throw new Error(errorMessage); + } + + const preStyleMap = new Map(); + const postStyleMap = new Map(); + const queriedElements = new Set(); + timelines.forEach(tl => { + const elm = tl.element; + const preProps = getOrSetAsInMap(preStyleMap, elm, {}); + tl.preStyleProps.forEach(prop => preProps[prop] = true); + + const postProps = getOrSetAsInMap(postStyleMap, elm, {}); + tl.postStyleProps.forEach(prop => postProps[prop] = true); + + if (elm !== element) { + queriedElements.add(elm); + } + }); + + const queriedElementsList = iteratorToArray(queriedElements.values()); return createTransitionInstruction( - this._triggerName, currentState, nextState, nextState === 'void', currentStateStyles, - nextStateStyles, timelines); + element, this._triggerName, currentState, nextState, nextState === 'void', + currentStateStyles, nextStateStyles, timelines, queriedElementsList, preStyleMap, + postStyleMap); } } diff --git a/packages/animations/browser/src/dsl/animation_transition_instruction.ts b/packages/animations/browser/src/dsl/animation_transition_instruction.ts index d2cc70fcaf..dbd963fadd 100644 --- a/packages/animations/browser/src/dsl/animation_transition_instruction.ts +++ b/packages/animations/browser/src/dsl/animation_transition_instruction.ts @@ -10,6 +10,7 @@ import {AnimationEngineInstruction, AnimationTransitionInstructionType} from '.. import {AnimationTimelineInstruction} from './animation_timeline_instruction'; export interface AnimationTransitionInstruction extends AnimationEngineInstruction { + element: any; triggerName: string; isRemovalTransition: boolean; fromState: string; @@ -17,20 +18,29 @@ export interface AnimationTransitionInstruction extends AnimationEngineInstructi toState: string; toStyles: ɵStyleData; timelines: AnimationTimelineInstruction[]; + queriedElements: any[]; + preStyleProps: Map; + postStyleProps: Map; } export function createTransitionInstruction( - triggerName: string, fromState: string, toState: string, isRemovalTransition: boolean, - fromStyles: ɵStyleData, toStyles: ɵStyleData, - timelines: AnimationTimelineInstruction[]): AnimationTransitionInstruction { + element: any, triggerName: string, fromState: string, toState: string, + isRemovalTransition: boolean, fromStyles: ɵStyleData, toStyles: ɵStyleData, + timelines: AnimationTimelineInstruction[], queriedElements: any[], + preStyleProps: Map, + postStyleProps: Map): AnimationTransitionInstruction { return { type: AnimationTransitionInstructionType.TransitionAnimation, + element, triggerName, isRemovalTransition, fromState, fromStyles, toState, toStyles, - timelines + timelines, + queriedElements, + preStyleProps, + postStyleProps }; } diff --git a/packages/animations/browser/src/dsl/animation_trigger.ts b/packages/animations/browser/src/dsl/animation_trigger.ts index a5492a5efc..19cfdeaad5 100644 --- a/packages/animations/browser/src/dsl/animation_trigger.ts +++ b/packages/animations/browser/src/dsl/animation_trigger.ts @@ -5,22 +5,18 @@ * 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 {AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, ɵStyleData} from '@angular/animations'; +import {ɵStyleData} from '@angular/animations'; -import {copyStyles, normalizeStyles} from '../util'; +import {copyStyles} from '../util'; -import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; -import {parseTransitionExpr} from './animation_transition_expr'; +import {SequenceAst, TransitionAst, TriggerAst} from './animation_ast'; import {AnimationTransitionFactory} from './animation_transition_factory'; -import {AnimationTransitionInstruction, createTransitionInstruction} from './animation_transition_instruction'; -import {validateAnimationSequence} from './animation_validator_visitor'; - /** * @experimental Animation support is experimental. */ -export function buildTrigger(name: string, definitions: AnimationMetadata[]): AnimationTrigger { - return new AnimationTriggerVisitor().buildTrigger(name, definitions); +export function buildTrigger(name: string, ast: TriggerAst): AnimationTrigger { + return new AnimationTrigger(name, ast); } /** @@ -28,90 +24,51 @@ export function buildTrigger(name: string, definitions: AnimationMetadata[]): An */ export class AnimationTrigger { public transitionFactories: AnimationTransitionFactory[] = []; + public fallbackTransition: AnimationTransitionFactory; public states: {[stateName: string]: ɵStyleData} = {}; - constructor( - public name: string, states: {[stateName: string]: ɵStyleData}, - private _transitionAsts: AnimationTransitionMetadata[]) { - Object.keys(states).forEach( - stateName => { this.states[stateName] = copyStyles(states[stateName], false); }); - - const errors: string[] = []; - _transitionAsts.forEach(ast => { - const exprs = parseTransitionExpr(ast.expr, errors); - const sequenceErrors = validateAnimationSequence(ast); - if (sequenceErrors.length) { - errors.push(...sequenceErrors); - } else { - this.transitionFactories.push( - new AnimationTransitionFactory(this.name, ast, exprs, states)); - } + 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); + } + }); }); - if (errors.length) { - const LINE_START = '\n - '; - throw new Error( - `Animation parsing for the ${name} trigger have failed:${LINE_START}${errors.join(LINE_START)}`); + balanceProperties(this.states, 'true', '1'); + balanceProperties(this.states, 'false', '0'); + + ast.transitions.forEach(ast => { + this.transitionFactories.push(new AnimationTransitionFactory(name, ast, this.states)); + }); + + this.fallbackTransition = createFallbackTransition(name, this.states); + } + + get containsQueries() { return this.ast.queryCount > 0; } + + matchTransition(currentState: any, nextState: any): AnimationTransitionFactory|null { + const entry = this.transitionFactories.find(f => f.match(currentState, nextState)); + return entry || null; + } +} + +function createFallbackTransition( + triggerName: string, states: {[stateName: string]: ɵStyleData}): AnimationTransitionFactory { + const matchers = [(fromState: any, toState: any) => true]; + const animation = new SequenceAst([]); + const transition = new TransitionAst(matchers, animation); + return new AnimationTransitionFactory(triggerName, transition, states); +} + +function balanceProperties(obj: {[key: string]: any}, key1: string, key2: string) { + if (obj.hasOwnProperty(key1)) { + if (!obj.hasOwnProperty(key2)) { + obj[key2] = obj[key1]; } - } - - createFallbackInstruction(currentState: any, nextState: any): AnimationTransitionInstruction { - const backupStateStyles = this.states['*'] || {}; - const currentStateStyles = this.states[currentState] || backupStateStyles; - const nextStateStyles = this.states[nextState] || backupStateStyles; - return createTransitionInstruction( - this.name, currentState, nextState, nextState == 'void', currentStateStyles, - nextStateStyles, []); - } - - matchTransition(currentState: any, nextState: any): AnimationTransitionInstruction|null { - for (let i = 0; i < this.transitionFactories.length; i++) { - let result = this.transitionFactories[i].match(currentState, nextState); - if (result) return result; - } - return null; - } -} - -class AnimationTriggerContext { - public errors: string[] = []; - public states: {[stateName: string]: ɵStyleData} = {}; - public transitions: AnimationTransitionMetadata[] = []; -} - -class AnimationTriggerVisitor implements AnimationDslVisitor { - buildTrigger(name: string, definitions: AnimationMetadata[]): AnimationTrigger { - const context = new AnimationTriggerContext(); - definitions.forEach(def => visitAnimationNode(this, def, context)); - return new AnimationTrigger(name, context.states, context.transitions); - } - - visitState(ast: AnimationStateMetadata, context: any): any { - const styles = normalizeStyles(ast.styles.styles); - ast.name.split(/\s*,\s*/).forEach(name => { context.states[name] = styles; }); - } - - visitTransition(ast: AnimationTransitionMetadata, context: any): any { - context.transitions.push(ast); - } - - visitSequence(ast: AnimationSequenceMetadata, context: any) { - // these values are not visited in this AST - } - - visitGroup(ast: AnimationGroupMetadata, context: any) { - // these values are not visited in this AST - } - - visitAnimate(ast: AnimationAnimateMetadata, context: any) { - // these values are not visited in this AST - } - - visitStyle(ast: AnimationStyleMetadata, context: any) { - // these values are not visited in this AST - } - - visitKeyframeSequence(ast: AnimationKeyframesSequenceMetadata, context: any) { - // these values are not visited in this AST + } else if (obj.hasOwnProperty(key2)) { + obj[key1] = obj[key2]; } } diff --git a/packages/animations/browser/src/dsl/animation_validator_visitor.ts b/packages/animations/browser/src/dsl/animation_validator_visitor.ts deleted file mode 100644 index baf859cf9d..0000000000 --- a/packages/animations/browser/src/dsl/animation_validator_visitor.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * 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 {AnimateTimings, AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, sequence} from '@angular/animations'; - -import {normalizeStyles, parseTimeExpression} from '../util'; - -import {AnimationDslVisitor, visitAnimationNode} from './animation_dsl_visitor'; - -export type StyleTimeTuple = { - startTime: number; endTime: number; -}; - -/* - * [Validation] - * The visitor code below will traverse the animation AST generated by the animation verb functions - * (the output is a tree of objects) and attempt to perform a series of validations on the data. The - * following corner-cases will be validated: - * - * 1. Overlap of animations - * Given that a CSS property cannot be animated in more than one place at the same time, it's - * important that this behaviour is detected and validated. The way in which this occurs is that - * each time a style property is examined, a string-map containing the property will be updated with - * the start and end times for when the property is used within an animation step. - * - * If there are two or more parallel animations that are currently running (these are invoked by the - * group()) on the same element then the validator will throw an error. Since the start/end timing - * values are collected for each property then if the current animation step is animating the same - * property and its timing values fall anywhere into the window of time that the property is - * currently being animated within then this is what causes an error. - * - * 2. Timing values - * The validator will validate to see if a timing value of `duration delay easing` or - * `durationNumber` is valid or not. - * - * (note that upon validation the code below will replace the timing data with an object containing - * {duration,delay,easing}. - * - * 3. Offset Validation - * Each of the style() calls are allowed to have an offset value when placed inside of keyframes(). - * Offsets within keyframes() are considered valid when: - * - * - No offsets are used at all - * - Each style() entry contains an offset value - * - Each offset is between 0 and 1 - * - Each offset is greater to or equal than the previous one - * - * Otherwise an error will be thrown. - */ -export function validateAnimationSequence(ast: AnimationMetadata) { - const normalizedAst = - Array.isArray(ast) ? sequence(ast) : ast; - return new AnimationValidatorVisitor().validate(normalizedAst); -} - -export class AnimationValidatorVisitor implements AnimationDslVisitor { - validate(ast: AnimationMetadata): string[] { - const context = new AnimationValidatorContext(); - visitAnimationNode(this, ast, context); - return context.errors; - } - - visitState(ast: AnimationStateMetadata, context: any): any { - // these values are not visited in this AST - } - - visitTransition(ast: AnimationTransitionMetadata, context: any): any { - // these values are not visited in this AST - } - - visitSequence(ast: AnimationSequenceMetadata, context: AnimationValidatorContext): any { - ast.steps.forEach(step => visitAnimationNode(this, step, context)); - } - - visitGroup(ast: AnimationGroupMetadata, context: AnimationValidatorContext): any { - const currentTime = context.currentTime; - let furthestTime = 0; - ast.steps.forEach(step => { - context.currentTime = currentTime; - visitAnimationNode(this, step, context); - furthestTime = Math.max(furthestTime, context.currentTime); - }); - context.currentTime = furthestTime; - } - - visitAnimate(ast: AnimationAnimateMetadata, context: AnimationValidatorContext): any { - // we reassign the timings here so that they are not reparsed each - // time an animation occurs - context.currentAnimateTimings = ast.timings = - parseTimeExpression(ast.timings, context.errors); - - const astType = ast.styles && ast.styles.type; - if (astType == AnimationMetadataType.KeyframeSequence) { - this.visitKeyframeSequence(ast.styles, context); - } else { - context.currentTime += - context.currentAnimateTimings.duration + context.currentAnimateTimings.delay; - if (astType == AnimationMetadataType.Style) { - this.visitStyle(ast.styles, context); - } - } - - context.currentAnimateTimings = null; - } - - visitStyle(ast: AnimationStyleMetadata, context: AnimationValidatorContext): any { - const styleData = normalizeStyles(ast.styles); - const timings = context.currentAnimateTimings; - let endTime = context.currentTime; - let startTime = context.currentTime; - if (timings && startTime > 0) { - startTime -= timings.duration + timings.delay; - } - Object.keys(styleData).forEach(prop => { - const collectedEntry = context.collectedStyles[prop]; - let updateCollectedStyle = true; - if (collectedEntry) { - if (startTime != endTime && startTime >= collectedEntry.startTime && - endTime <= collectedEntry.endTime) { - context.errors.push( - `The CSS property "${prop}" that exists between the times of "${collectedEntry.startTime}ms" and "${collectedEntry.endTime}ms" is also being animated in a parallel animation between the times of "${startTime}ms" and "${endTime}ms"`); - updateCollectedStyle = false; - } - - // we always choose the smaller start time value since we - // want to have a record of the entire animation window where - // the style property is being animated in between - startTime = collectedEntry.startTime; - } - if (updateCollectedStyle) { - context.collectedStyles[prop] = {startTime, endTime}; - } - }); - } - - visitKeyframeSequence( - ast: AnimationKeyframesSequenceMetadata, context: AnimationValidatorContext): any { - let totalKeyframesWithOffsets = 0; - const offsets: number[] = []; - let offsetsOutOfOrder = false; - let keyframesOutOfRange = false; - let previousOffset: number = 0; - ast.steps.forEach(step => { - const styleData = normalizeStyles(step.styles); - let offset = 0; - if (styleData.hasOwnProperty('offset')) { - totalKeyframesWithOffsets++; - offset = styleData['offset']; - } - keyframesOutOfRange = keyframesOutOfRange || offset < 0 || offset > 1; - offsetsOutOfOrder = offsetsOutOfOrder || offset < previousOffset; - previousOffset = offset; - offsets.push(offset); - }); - - if (keyframesOutOfRange) { - context.errors.push(`Please ensure that all keyframe offsets are between 0 and 1`); - } - - if (offsetsOutOfOrder) { - context.errors.push(`Please ensure that all keyframe offsets are in order`); - } - - const length = ast.steps.length; - let generatedOffset = 0; - if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) { - context.errors.push(`Not all style() steps within the declared keyframes() contain offsets`); - } else if (totalKeyframesWithOffsets == 0) { - generatedOffset = 1 / length; - } - - const limit = length - 1; - const currentTime = context.currentTime; - const animateDuration = context.currentAnimateTimings !.duration; - ast.steps.forEach((step, i) => { - const offset = generatedOffset > 0 ? (i == limit ? 1 : (generatedOffset * i)) : offsets[i]; - const durationUpToThisFrame = offset * animateDuration; - context.currentTime = - currentTime + context.currentAnimateTimings !.delay + durationUpToThisFrame; - context.currentAnimateTimings !.duration = durationUpToThisFrame; - this.visitStyle(step, context); - }); - } -} - -export class AnimationValidatorContext { - public errors: string[] = []; - public currentTime: number = 0; - public currentAnimateTimings: AnimateTimings|null; - public collectedStyles: {[propName: string]: StyleTimeTuple} = {}; -} diff --git a/packages/animations/browser/src/dsl/element_instruction_map.ts b/packages/animations/browser/src/dsl/element_instruction_map.ts new file mode 100644 index 0000000000..ad61a5f35e --- /dev/null +++ b/packages/animations/browser/src/dsl/element_instruction_map.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimationTimelineInstruction} from './animation_timeline_instruction'; + +export class ElementInstructionMap { + private _map = new Map(); + + consume(element: any): AnimationTimelineInstruction[] { + let instructions = this._map.get(element); + if (instructions) { + this._map.delete(element); + } else { + instructions = []; + } + return instructions; + } + + append(element: any, instructions: AnimationTimelineInstruction[]) { + let existingInstructions = this._map.get(element); + if (!existingInstructions) { + this._map.set(element, existingInstructions = []); + } + existingInstructions.push(...instructions); + } + + has(element: any): boolean { return this._map.has(element); } + + clear() { this._map.clear(); } +} diff --git a/packages/animations/browser/src/private_export.ts b/packages/animations/browser/src/private_export.ts index ff9e74e968..26caa740a3 100644 --- a/packages/animations/browser/src/private_export.ts +++ b/packages/animations/browser/src/private_export.ts @@ -10,7 +10,7 @@ export {Animation as ɵAnimation} from './dsl/animation'; export {AnimationStyleNormalizer as ɵAnimationStyleNormalizer, NoopAnimationStyleNormalizer as ɵNoopAnimationStyleNormalizer} from './dsl/style_normalization/animation_style_normalizer'; export {WebAnimationsStyleNormalizer as ɵWebAnimationsStyleNormalizer} from './dsl/style_normalization/web_animations_style_normalizer'; export {NoopAnimationDriver as ɵNoopAnimationDriver} from './render/animation_driver'; -export {DomAnimationEngine as ɵDomAnimationEngine} from './render/dom_animation_engine'; +export {DomAnimationEngine as ɵDomAnimationEngine} from './render/dom_animation_engine_next'; export {NoopAnimationEngine as ɵNoopAnimationEngine} from './render/noop_animation_engine'; export {WebAnimationsDriver as ɵWebAnimationsDriver, supportsWebAnimations as ɵsupportsWebAnimations} from './render/web_animations/web_animations_driver'; export {WebAnimationsPlayer as ɵWebAnimationsPlayer} from './render/web_animations/web_animations_player'; diff --git a/packages/animations/browser/src/render/animation_driver.ts b/packages/animations/browser/src/render/animation_driver.ts index 48d05dfa97..fdef299948 100644 --- a/packages/animations/browser/src/render/animation_driver.ts +++ b/packages/animations/browser/src/render/animation_driver.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {ɵStyleData} from '@angular/animations'; import {AnimationPlayer, NoopAnimationPlayer} from '@angular/animations'; @@ -14,6 +15,10 @@ import {AnimationPlayer, NoopAnimationPlayer} from '@angular/animations'; * @experimental */ export class NoopAnimationDriver implements AnimationDriver { + computeStyle(element: any, prop: string, defaultValue?: string): string { + return defaultValue || ''; + } + animate( element: any, keyframes: {[key: string]: string | number}[], duration: number, delay: number, easing: string, previousPlayers: any[] = []): AnimationPlayer { @@ -26,6 +31,9 @@ export class NoopAnimationDriver implements AnimationDriver { */ export abstract class AnimationDriver { static NOOP: AnimationDriver = new NoopAnimationDriver(); + + abstract computeStyle(element: any, prop: string, defaultValue?: string): string; + abstract animate( element: any, keyframes: {[key: string]: string | number}[], duration: number, delay: number, easing?: string|null, previousPlayers?: any[]): any; diff --git a/packages/animations/browser/src/render/dom_animation_engine.ts b/packages/animations/browser/src/render/dom_animation_engine.ts deleted file mode 100644 index f59611d9fc..0000000000 --- a/packages/animations/browser/src/render/dom_animation_engine.ts +++ /dev/null @@ -1,527 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * 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 {AnimationEvent, AnimationPlayer, AnimationTriggerMetadata, NoopAnimationPlayer, ɵAnimationGroupPlayer, ɵStyleData} from '@angular/animations'; - -import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instruction'; -import {AnimationTransitionInstruction} from '../dsl/animation_transition_instruction'; -import {AnimationTrigger, buildTrigger} from '../dsl/animation_trigger'; -import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; -import {eraseStyles, setStyles} from '../util'; - -import {AnimationDriver} from './animation_driver'; - -export interface QueuedAnimationTransitionTuple { - element: any; - player: AnimationPlayer; - triggerName: string; - event: AnimationEvent; -} - -export interface TriggerListenerTuple { - triggerName: string; - phase: string; - callback: (event: any) => any; -} - -const MARKED_FOR_ANIMATION_CLASSNAME = 'ng-animating'; -const MARKED_FOR_ANIMATION_SELECTOR = '.ng-animating'; -const MARKED_FOR_REMOVAL = '$$ngRemove'; -const VOID_STATE = 'void'; - -export class DomAnimationEngine { - private _flaggedInserts = new Set(); - private _queuedRemovals = new Map any>(); - private _queuedTransitionAnimations: QueuedAnimationTransitionTuple[] = []; - private _activeTransitionAnimations = new Map(); - private _activeElementAnimations = new Map(); - - private _elementTriggerStates = new Map(); - - private _triggers: {[triggerName: string]: AnimationTrigger} = Object.create(null); - private _triggerListeners = new Map(); - - private _pendingListenerRemovals = new Map(); - - constructor(private _driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) {} - - get queuedPlayers(): AnimationPlayer[] { - return this._queuedTransitionAnimations.map(q => q.player); - } - - get activePlayers(): AnimationPlayer[] { - const players: AnimationPlayer[] = []; - this._activeElementAnimations.forEach(activePlayers => players.push(...activePlayers)); - return players; - } - - registerTrigger(trigger: AnimationTriggerMetadata, name?: string): void { - name = name || trigger.name; - if (this._triggers[name]) { - return; - } - this._triggers[name] = buildTrigger(name, trigger.definitions); - } - - onInsert(element: any, domFn: () => any): void { - if (element['nodeType'] == 1) { - this._flaggedInserts.add(element); - } - domFn(); - } - - onRemove(element: any, domFn: () => any): void { - if (element['nodeType'] != 1) { - domFn(); - return; - } - - let lookupRef = this._elementTriggerStates.get(element); - if (lookupRef) { - const possibleTriggers = Object.keys(lookupRef); - const hasRemoval = possibleTriggers.some(triggerName => { - const oldValue = lookupRef ![triggerName]; - const instruction = this._triggers[triggerName].matchTransition(oldValue, VOID_STATE); - return !!instruction; - }); - if (hasRemoval) { - element[MARKED_FOR_REMOVAL] = true; - this._queuedRemovals.set(element, domFn); - return; - } - } - - // this means that there are no animations to take on this - // leave operation therefore we should fire the done|start callbacks - if (this._triggerListeners.has(element)) { - element[MARKED_FOR_REMOVAL] = true; - this._queuedRemovals.set(element, () => {}); - } - this._onRemovalTransition(element).forEach(player => player.destroy()); - domFn(); - } - - setProperty(element: any, property: string, value: any): void { - const trigger = this._triggers[property]; - if (!trigger) { - throw new Error(`The provided animation trigger "${property}" has not been registered!`); - } - - let lookupRef = this._elementTriggerStates.get(element); - if (!lookupRef) { - this._elementTriggerStates.set(element, lookupRef = {}); - } - - let oldValue = lookupRef.hasOwnProperty(property) ? lookupRef[property] : VOID_STATE; - if (oldValue !== value) { - value = normalizeTriggerValue(value); - let instruction = trigger.matchTransition(oldValue, value); - if (!instruction) { - // we do this to make sure we always have an animation player so - // that callback operations are properly called - instruction = trigger.createFallbackInstruction(oldValue, value); - } - this.animateTransition(element, instruction); - lookupRef[property] = value; - } - } - - listen(element: any, eventName: string, eventPhase: string, callback: (event: any) => any): - () => void { - if (!eventPhase) { - throw new Error( - `Unable to listen on the animation trigger "${eventName}" because the provided event is undefined!`); - } - if (!this._triggers[eventName]) { - throw new Error( - `Unable to listen on the animation trigger event "${eventPhase}" because the animation trigger "${eventName}" doesn't exist!`); - } - let elementListeners = this._triggerListeners.get(element); - if (!elementListeners) { - this._triggerListeners.set(element, elementListeners = []); - } - validatePlayerEvent(eventName, eventPhase); - const tuple = {triggerName: eventName, phase: eventPhase, callback}; - elementListeners.push(tuple); - return () => { - // this is queued up in the event that a removal animation is set - // to fire on the element (the listeners need to be set during flush) - getOrSetAsInMap(this._pendingListenerRemovals, element, []).push(tuple); - }; - } - - private _clearPendingListenerRemovals() { - this._pendingListenerRemovals.forEach((tuples: TriggerListenerTuple[], element: any) => { - const elementListeners = this._triggerListeners.get(element); - if (elementListeners) { - tuples.forEach(tuple => { - const index = elementListeners.indexOf(tuple); - if (index >= 0) { - elementListeners.splice(index, 1); - } - }); - } - }); - this._pendingListenerRemovals.clear(); - } - - private _onRemovalTransition(element: any): AnimationPlayer[] { - // when a parent animation is set to trigger a removal we want to - // find all of the children that are currently animating and clear - // them out by destroying each of them. - const elms = element.querySelectorAll(MARKED_FOR_ANIMATION_SELECTOR); - for (let i = 0; i < elms.length; i++) { - const elm = elms[i]; - const activePlayers = this._activeElementAnimations.get(elm); - if (activePlayers) { - activePlayers.forEach(player => player.destroy()); - } - - const activeTransitions = this._activeTransitionAnimations.get(elm); - if (activeTransitions) { - Object.keys(activeTransitions).forEach(triggerName => { - const player = activeTransitions[triggerName]; - if (player) { - player.destroy(); - } - }); - } - } - - // we make a copy of the array because the actual source array is modified - // each time a player is finished/destroyed (the forEach loop would fail otherwise) - return copyArray(this._activeElementAnimations.get(element) !); - } - - animateTransition(element: any, instruction: AnimationTransitionInstruction): AnimationPlayer { - const triggerName = instruction.triggerName; - - let previousPlayers: AnimationPlayer[]; - if (instruction.isRemovalTransition) { - previousPlayers = this._onRemovalTransition(element); - } else { - previousPlayers = []; - const existingTransitions = this._activeTransitionAnimations.get(element); - const existingPlayer = existingTransitions ? existingTransitions[triggerName] : null; - if (existingPlayer) { - previousPlayers.push(existingPlayer); - } - } - - // it's important to do this step before destroying the players - // so that the onDone callback below won't fire before this - eraseStyles(element, instruction.fromStyles); - - // we first run this so that the previous animation player - // data can be passed into the successive animation players - let totalTime = 0; - const players = instruction.timelines.map((timelineInstruction, i) => { - totalTime = Math.max(totalTime, timelineInstruction.totalTime); - return this._buildPlayer(element, timelineInstruction, previousPlayers, i); - }); - - previousPlayers.forEach(previousPlayer => previousPlayer.destroy()); - const player = optimizeGroupPlayer(players); - player.onDone(() => { - player.destroy(); - const elmTransitionMap = this._activeTransitionAnimations.get(element); - if (elmTransitionMap) { - delete elmTransitionMap[triggerName]; - if (Object.keys(elmTransitionMap).length == 0) { - this._activeTransitionAnimations.delete(element); - } - } - deleteFromArrayMap(this._activeElementAnimations, element, player); - setStyles(element, instruction.toStyles); - }); - - const elmTransitionMap = getOrSetAsInMap(this._activeTransitionAnimations, element, {}); - elmTransitionMap[triggerName] = player; - - this._queuePlayer( - element, triggerName, player, - makeAnimationEvent( - element, triggerName, instruction.fromState, instruction.toState, - null, // this will be filled in during event creation - totalTime)); - - return player; - } - - public animateTimeline( - element: any, instructions: AnimationTimelineInstruction[], - previousPlayers: AnimationPlayer[] = []): AnimationPlayer { - const players = instructions.map((instruction, i) => { - const player = this._buildPlayer(element, instruction, previousPlayers, i); - player.onDestroy( - () => { deleteFromArrayMap(this._activeElementAnimations, element, player); }); - this._markPlayerAsActive(element, player); - return player; - }); - return optimizeGroupPlayer(players); - } - - private _buildPlayer( - element: any, instruction: AnimationTimelineInstruction, previousPlayers: AnimationPlayer[], - index: number = 0): AnimationPlayer { - // only the very first animation can absorb the previous styles. This - // is here to prevent the an overlap situation where a group animation - // absorbs previous styles multiple times for the same element. - if (index && previousPlayers.length) { - previousPlayers = []; - } - return this._driver.animate( - element, this._normalizeKeyframes(instruction.keyframes), instruction.duration, - instruction.delay, instruction.easing, previousPlayers); - } - - private _normalizeKeyframes(keyframes: ɵStyleData[]): ɵStyleData[] { - const errors: string[] = []; - const normalizedKeyframes: ɵStyleData[] = []; - keyframes.forEach(kf => { - const normalizedKeyframe: ɵStyleData = {}; - Object.keys(kf).forEach(prop => { - let normalizedProp = prop; - let normalizedValue = kf[prop]; - if (prop != 'offset') { - normalizedProp = this._normalizer.normalizePropertyName(prop, errors); - normalizedValue = - this._normalizer.normalizeStyleValue(prop, normalizedProp, kf[prop], errors); - } - normalizedKeyframe[normalizedProp] = normalizedValue; - }); - normalizedKeyframes.push(normalizedKeyframe); - }); - if (errors.length) { - const LINE_START = '\n - '; - throw new Error( - `Unable to animate due to the following errors:${LINE_START}${errors.join(LINE_START)}`); - } - return normalizedKeyframes; - } - - private _markPlayerAsActive(element: any, player: AnimationPlayer) { - const elementAnimations = getOrSetAsInMap(this._activeElementAnimations, element, []); - elementAnimations.push(player); - } - - private _queuePlayer( - element: any, triggerName: string, player: AnimationPlayer, event: AnimationEvent) { - const tuple = {element, player, triggerName, event}; - this._queuedTransitionAnimations.push(tuple); - player.init(); - - element.classList.add(MARKED_FOR_ANIMATION_CLASSNAME); - player.onDone(() => { element.classList.remove(MARKED_FOR_ANIMATION_CLASSNAME); }); - } - - private _flushQueuedAnimations() { - parentLoop: while (this._queuedTransitionAnimations.length) { - const {player, element, triggerName, event} = this._queuedTransitionAnimations.shift() !; - - let parent = element; - while (parent = parent.parentNode) { - // this means that a parent element will or will not - // have its own animation operation which in this case - // there's no point in even trying to do an animation - if (parent[MARKED_FOR_REMOVAL]) continue parentLoop; - } - - const listeners = this._triggerListeners.get(element); - if (listeners) { - listeners.forEach(tuple => { - if (tuple.triggerName == triggerName) { - listenOnPlayer(player, tuple.phase, event, tuple.callback); - } - }); - } - - // if a removal exists for the given element then we need cancel - // all the queued players so that a proper removal animation can go - if (this._queuedRemovals.has(element)) { - player.destroy(); - continue; - } - - this._markPlayerAsActive(element, player); - - // in the event that an animation throws an error then we do - // not want to re-run animations on any previous animations - // if they have already been kicked off beforehand - player.init(); - if (!player.hasStarted()) { - player.play(); - } - } - } - - flush() { - const leaveListeners = new Map(); - this._queuedRemovals.forEach((callback, element) => { - const tuple = this._pendingListenerRemovals.get(element); - if (tuple) { - leaveListeners.set(element, tuple); - this._pendingListenerRemovals.delete(element); - } - }); - - this._clearPendingListenerRemovals(); - this._pendingListenerRemovals = leaveListeners; - - this._flushQueuedAnimations(); - - let flushAgain = false; - this._queuedRemovals.forEach((callback, element) => { - // an item that was inserted/removed in the same flush means - // that an animation should not happen anyway - if (this._flaggedInserts.has(element)) return; - - let parent = element; - let players: AnimationPlayer[] = []; - while (parent = parent.parentNode) { - // there is no reason to even try to - if (parent[MARKED_FOR_REMOVAL]) { - callback(); - return; - } - - const match = this._activeElementAnimations.get(parent); - if (match) { - players.push(...match); - break; - } - } - - // the loop was unable to find an parent that is animating even - // though this element has set to be removed, so the algorithm - // should check to see if there are any triggers on the element - // that are present to handle a leave animation and then setup - // those players to facilitate the callback after done - if (players.length == 0) { - // this means that the element has valid state triggers - const stateDetails = this._elementTriggerStates.get(element); - if (stateDetails) { - Object.keys(stateDetails).forEach(triggerName => { - flushAgain = true; - const oldValue = stateDetails[triggerName]; - const instruction = this._triggers[triggerName].matchTransition(oldValue, VOID_STATE); - if (instruction) { - players.push(this.animateTransition(element, instruction)); - } else { - const event = makeAnimationEvent(element, triggerName, oldValue, VOID_STATE, '', 0); - const player = new NoopAnimationPlayer(); - this._queuePlayer(element, triggerName, player, event); - } - }); - } - } - - if (players.length) { - optimizeGroupPlayer(players).onDone(callback); - } else { - callback(); - } - }); - - this._queuedRemovals.clear(); - this._flaggedInserts.clear(); - - // this means that one or more leave animations were detected - if (flushAgain) { - this._flushQueuedAnimations(); - this._clearPendingListenerRemovals(); - } - } -} - -function getOrSetAsInMap(map: Map, key: any, defaultValue: any) { - let value = map.get(key); - if (!value) { - map.set(key, value = defaultValue); - } - return value; -} - -function deleteFromArrayMap(map: Map, key: any, value: any) { - let arr = map.get(key); - if (arr) { - const index = arr.indexOf(value); - if (index >= 0) { - arr.splice(index, 1); - if (arr.length == 0) { - map.delete(key); - } - } - } -} - -function optimizeGroupPlayer(players: AnimationPlayer[]): AnimationPlayer { - switch (players.length) { - case 0: - return new NoopAnimationPlayer(); - case 1: - return players[0]; - default: - return new ɵAnimationGroupPlayer(players); - } -} - -function copyArray(source: any[]): any[] { - return source ? source.splice(0) : []; -} - -function validatePlayerEvent(triggerName: string, eventName: string) { - switch (eventName) { - case 'start': - case 'done': - return; - default: - throw new Error( - `The provided animation trigger event "${eventName}" for the animation trigger "${triggerName}" is not supported!`); - } -} - -function listenOnPlayer( - player: AnimationPlayer, eventName: string, baseEvent: AnimationEvent, - callback: (event: any) => any) { - switch (eventName) { - case 'start': - player.onStart(() => { - const event = copyAnimationEvent(baseEvent); - event.phaseName = 'start'; - callback(event); - }); - break; - case 'done': - player.onDone(() => { - const event = copyAnimationEvent(baseEvent); - event.phaseName = 'done'; - callback(event); - }); - break; - } -} - -function copyAnimationEvent(e: AnimationEvent): AnimationEvent { - return makeAnimationEvent( - e.element, e.triggerName, e.fromState, e.toState, e.phaseName, e.totalTime); -} - -function makeAnimationEvent( - element: any, triggerName: string, fromState: string, toState: string, phaseName: string | null, - totalTime: number): AnimationEvent { - return {element, triggerName, fromState, toState, phaseName, totalTime}; -} - -function normalizeTriggerValue(value: any): string { - switch (typeof value) { - case 'boolean': - return value ? '1' : '0'; - default: - return value ? value.toString() : null; - } -} diff --git a/packages/animations/browser/src/render/dom_animation_engine_next.ts b/packages/animations/browser/src/render/dom_animation_engine_next.ts new file mode 100644 index 0000000000..ba9d1c53ab --- /dev/null +++ b/packages/animations/browser/src/render/dom_animation_engine_next.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimationMetadata, AnimationPlayer, AnimationTriggerMetadata} from '@angular/animations'; + +import {AnimationEngine} from '../animation_engine'; +import {TriggerAst} from '../dsl/animation_ast'; +import {buildAnimationAst} from '../dsl/animation_ast_builder'; +import {AnimationTrigger, buildTrigger} from '../dsl/animation_trigger'; +import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; + +import {AnimationDriver} from './animation_driver'; +import {parseTimelineCommand} from './shared'; +import {TimelineAnimationEngine} from './timeline_animation_engine'; +import {TransitionAnimationEngine} from './transition_animation_engine'; + +export class DomAnimationEngine implements AnimationEngine { + private _transitionEngine: TransitionAnimationEngine; + private _timelineEngine: TimelineAnimationEngine; + + private _triggerCache: {[key: string]: AnimationTrigger} = {}; + + // this method is designed to be overridden by the code that uses this engine + public onRemovalComplete = (element: any, context: any) => {}; + + constructor(driver: AnimationDriver, normalizer: AnimationStyleNormalizer) { + this._transitionEngine = new TransitionAnimationEngine(driver, normalizer); + this._timelineEngine = new TimelineAnimationEngine(driver, normalizer); + + this._transitionEngine.onRemovalComplete = + (element: any, context: any) => { this.onRemovalComplete(element, context); } + } + + registerTrigger( + componentId: string, namespaceId: string, hostElement: any, name: string, + metadata: AnimationTriggerMetadata): void { + const cacheKey = componentId + '-' + name; + let trigger = this._triggerCache[cacheKey]; + if (!trigger) { + const errors: any[] = []; + const ast = buildAnimationAst(metadata as AnimationMetadata, errors) as TriggerAst; + if (errors.length) { + throw new Error( + `The animation trigger "${name}" has failed to build due to the following errors:\n - ${errors.join("\n - ")}`); + } + trigger = buildTrigger(name, ast); + this._triggerCache[cacheKey] = trigger; + } + + this._transitionEngine.register(namespaceId, hostElement, name, trigger); + } + + destroy(namespaceId: string, context: any) { + this._transitionEngine.destroy(namespaceId, context); + } + + onInsert(namespaceId: string, element: any, parent: any, insertBefore: boolean): void { + this._transitionEngine.insertNode(namespaceId, element, parent, insertBefore); + } + + onRemove(namespaceId: string, element: any, context: any): void { + 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; + } + return this._transitionEngine.trigger(namespaceId, element, property, value); + } + + listen( + namespaceId: string, element: any, eventName: string, eventPhase: string, + callback: (event: any) => any): () => any { + // @@listen + if (eventName.charAt(0) == '@') { + const [id, action] = parseTimelineCommand(eventName); + return this._timelineEngine.listen(id, element, action, callback); + } + return this._transitionEngine.listen(namespaceId, element, eventName, eventPhase, callback); + } + + flush(): void { this._transitionEngine.flush(); } + + get players(): AnimationPlayer[] { + return (this._transitionEngine.players as AnimationPlayer[]) + .concat(this._timelineEngine.players as AnimationPlayer[]); + } +} diff --git a/packages/animations/browser/src/render/noop_animation_engine.ts b/packages/animations/browser/src/render/noop_animation_engine.ts index 342e7c7efb..b5ec1a3c8d 100644 --- a/packages/animations/browser/src/render/noop_animation_engine.ts +++ b/packages/animations/browser/src/render/noop_animation_engine.ts @@ -5,20 +5,30 @@ * 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 {AnimationEvent, AnimationMetadataType, AnimationPlayer, AnimationStateMetadata, AnimationTriggerMetadata, ɵStyleData} from '@angular/animations'; +import {AnimationEvent, AnimationPlayer, AnimationTriggerMetadata, ɵStyleData} from '@angular/animations'; import {AnimationEngine} from '../animation_engine'; +import {TriggerAst} from '../dsl/animation_ast'; +import {buildAnimationAst} from '../dsl/animation_ast_builder'; +import {buildTrigger} from '../dsl/animation_trigger'; +import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; import {copyStyles, eraseStyles, normalizeStyles, setStyles} from '../util'; +import {AnimationDriver} from './animation_driver'; +import {parseTimelineCommand} from './shared'; +import {TimelineAnimationEngine} from './timeline_animation_engine'; + interface ListenerTuple { eventPhase: string; triggerName: string; + namespacedName: string; callback: (event: any) => any; doRemove?: boolean; } interface ChangeTuple { element: any; + namespacedName: string; triggerName: string; oldValue: string; newValue: string; @@ -35,36 +45,55 @@ export class NoopAnimationEngine extends AnimationEngine { private _triggerStyles: {[triggerName: string]: {[stateName: string]: ɵStyleData}} = Object.create(null); - registerTrigger(trigger: AnimationTriggerMetadata, name?: string): void { - name = name || trigger.name; + private _timelineEngine: TimelineAnimationEngine; + + // this method is designed to be overridden by the code that uses this engine + public onRemovalComplete = (element: any, context: any) => {}; + + constructor(driver: AnimationDriver, normalizer: AnimationStyleNormalizer) { + super(); + this._timelineEngine = new TimelineAnimationEngine(driver, normalizer); + } + + registerTrigger( + componentId: string, namespaceId: string, hostElement: any, name: string, + metadata: AnimationTriggerMetadata): void { + name = name || metadata.name; + name = namespaceId + '#' + name; if (this._triggerStyles[name]) { return; } - const stateMap: {[stateName: string]: ɵStyleData} = {}; - trigger.definitions.forEach(def => { - if (def.type === AnimationMetadataType.State) { - const stateDef = def as AnimationStateMetadata; - stateMap[stateDef.name] = normalizeStyles(stateDef.styles.styles); - } - }); - this._triggerStyles[name] = stateMap; + + const errors: any[] = []; + const ast = buildAnimationAst(metadata, errors) as TriggerAst; + const trigger = buildTrigger(name, ast); + this._triggerStyles[name] = trigger.states; } - onInsert(element: any, domFn: () => any): void { domFn(); } + onInsert(namespaceId: string, element: any, parent: any, insertBefore: boolean): void {} - onRemove(element: any, domFn: () => any): void { - domFn(); + onRemove(namespaceId: string, element: any, context: any): void { + this.onRemovalComplete(element, context); if (element['nodeType'] == 1) { this._flaggedRemovals.add(element); } } - setProperty(element: any, property: string, value: any): void { - const storageProp = makeStorageProp(property); - const oldValue = element[storageProp] || DEFAULT_STATE_VALUE; - this._changes.push({element, oldValue, newValue: value, triggerName: property}); + setProperty(namespaceId: string, element: any, property: string, value: any): boolean { + if (property.charAt(0) == '@') { + const [id, action] = parseTimelineCommand(property); + const args = value as any[]; + this._timelineEngine.command(id, element, action, args); + return false; + } - const triggerStateStyles = this._triggerStyles[property] || {}; + const namespacedName = namespaceId + '#' + property; + const storageProp = makeStorageProp(namespacedName); + const oldValue = element[storageProp] || DEFAULT_STATE_VALUE; + this._changes.push( + {element, oldValue, newValue: value, triggerName: property, namespacedName}); + + const triggerStateStyles = this._triggerStyles[namespacedName] || {}; const fromStateStyles = triggerStateStyles[oldValue] || triggerStateStyles[DEFAULT_STATE_STYLES]; if (fromStateStyles) { @@ -78,16 +107,27 @@ export class NoopAnimationEngine extends AnimationEngine { setStyles(element, toStateStyles); } }); + + return true; } - listen(element: any, eventName: string, eventPhase: string, callback: (event: any) => any): - () => any { + listen( + namespaceId: string, element: any, eventName: string, eventPhase: string, + callback: (event: any) => any): () => any { + if (eventName.charAt(0) == '@') { + const [id, action] = parseTimelineCommand(eventName); + return this._timelineEngine.listen(id, element, action, callback); + } + let listeners = this._listeners.get(element); if (!listeners) { this._listeners.set(element, listeners = []); } - const tuple = {triggerName: eventName, eventPhase, callback}; + const tuple = { + namespacedName: namespaceId + '#' + eventName, + triggerName: eventName, eventPhase, callback + }; listeners.push(tuple); return () => tuple.doRemove = true; @@ -113,7 +153,7 @@ export class NoopAnimationEngine extends AnimationEngine { const listeners = this._listeners.get(element); if (listeners) { listeners.forEach(listener => { - if (listener.triggerName == change.triggerName) { + if (listener.namespacedName == change.namespacedName) { handleListener(listener, change); } }); @@ -126,10 +166,12 @@ export class NoopAnimationEngine extends AnimationEngine { if (listeners) { listeners.forEach(listener => { const triggerName = listener.triggerName; - const storageProp = makeStorageProp(triggerName); + const namespacedName = listener.namespacedName; + const storageProp = makeStorageProp(namespacedName); handleListener(listener, { - element: element, - triggerName: triggerName, + element, + triggerName, + namespacedName: listener.namespacedName, oldValue: element[storageProp] || DEFAULT_STATE_VALUE, newValue: DEFAULT_STATE_VALUE }); @@ -156,8 +198,9 @@ export class NoopAnimationEngine extends AnimationEngine { this._onDoneFns = []; } - get activePlayers(): AnimationPlayer[] { return []; } - get queuedPlayers(): AnimationPlayer[] { return []; } + get players(): AnimationPlayer[] { return []; } + + destroy(namespaceId: string) {} } function makeAnimationEvent( diff --git a/packages/animations/browser/src/render/shared.ts b/packages/animations/browser/src/render/shared.ts new file mode 100644 index 0000000000..022c79c876 --- /dev/null +++ b/packages/animations/browser/src/render/shared.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, AnimationEvent, AnimationPlayer, NoopAnimationPlayer, ɵAnimationGroupPlayer, ɵPRE_STYLE as PRE_STYLE, ɵStyleData} from '@angular/animations'; + +import {AnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer'; +import {AnimationDriver} from '../../src/render/animation_driver'; + +export function optimizeGroupPlayer(players: AnimationPlayer[]): AnimationPlayer { + switch (players.length) { + case 0: + return new NoopAnimationPlayer(); + case 1: + return players[0]; + default: + return new ɵAnimationGroupPlayer(players); + } +} + +export function normalizeKeyframes( + driver: AnimationDriver, normalizer: AnimationStyleNormalizer, element: any, + keyframes: ɵStyleData[], preStyles: ɵStyleData = {}, + postStyles: ɵStyleData = {}): ɵStyleData[] { + const errors: string[] = []; + const normalizedKeyframes: ɵStyleData[] = []; + let previousOffset = -1; + let previousKeyframe: ɵStyleData|null = null; + keyframes.forEach(kf => { + const offset = kf['offset'] as number; + const isSameOffset = offset == previousOffset; + const normalizedKeyframe: ɵStyleData = (isSameOffset && previousKeyframe) || {}; + Object.keys(kf).forEach(prop => { + let normalizedProp = prop; + let normalizedValue = kf[prop]; + if (normalizedValue == PRE_STYLE) { + normalizedValue = preStyles[prop]; + } else if (normalizedValue == AUTO_STYLE) { + normalizedValue = postStyles[prop]; + } else if (prop != 'offset') { + normalizedProp = normalizer.normalizePropertyName(prop, errors); + normalizedValue = normalizer.normalizeStyleValue(prop, normalizedProp, kf[prop], errors); + } + normalizedKeyframe[normalizedProp] = normalizedValue; + }); + if (!isSameOffset) { + normalizedKeyframes.push(normalizedKeyframe); + } + previousKeyframe = normalizedKeyframe; + previousOffset = offset; + }); + if (errors.length) { + const LINE_START = '\n - '; + throw new Error( + `Unable to animate due to the following errors:${LINE_START}${errors.join(LINE_START)}`); + } + + return normalizedKeyframes; +} + +export function listenOnPlayer( + player: AnimationPlayer, eventName: string, event: AnimationEvent | undefined, + callback: (event: any) => any) { + switch (eventName) { + case 'start': + player.onStart(() => callback(event && copyAnimationEvent(event, 'start', player.totalTime))); + break; + case 'done': + player.onDone(() => callback(event && copyAnimationEvent(event, 'done', player.totalTime))); + break; + case 'destroy': + player.onDestroy( + () => callback(event && copyAnimationEvent(event, 'destroy', player.totalTime))); + break; + } +} + +export function copyAnimationEvent( + e: AnimationEvent, phaseName?: string, totalTime?: number): AnimationEvent { + return makeAnimationEvent( + e.element, e.triggerName, e.fromState, e.toState, phaseName || e.phaseName, + totalTime == undefined ? e.totalTime : totalTime); +} + +export function makeAnimationEvent( + element: any, triggerName: string, fromState: string, toState: string, phaseName: string = '', + totalTime: number = 0): AnimationEvent { + return {element, triggerName, fromState, toState, phaseName, totalTime}; +} + +export function getOrSetAsInMap( + map: Map| {[key: string]: any}, key: any, defaultValue: any) { + let value: any; + if (map instanceof Map) { + value = map.get(key); + if (!value) { + map.set(key, value = defaultValue); + } + } else { + value = map[key]; + if (!value) { + value = map[key] = defaultValue; + } + } + return value; +} + +export function parseTimelineCommand(command: string): [string, string] { + const separatorPos = command.indexOf(':'); + const id = command.substring(1, separatorPos); + const action = command.substr(separatorPos + 1); + return [id, action]; +} diff --git a/packages/animations/browser/src/render/timeline_animation_engine.ts b/packages/animations/browser/src/render/timeline_animation_engine.ts new file mode 100644 index 0000000000..5d356422a4 --- /dev/null +++ b/packages/animations/browser/src/render/timeline_animation_engine.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, AnimationOptions, AnimationPlayer, ɵStyleData} from '@angular/animations'; + +import {Ast} from '../dsl/animation_ast'; +import {buildAnimationAst} from '../dsl/animation_ast_builder'; +import {buildAnimationTimelines} from '../dsl/animation_timeline_builder'; +import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instruction'; +import {ElementInstructionMap} from '../dsl/element_instruction_map'; +import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; + +import {AnimationDriver} from './animation_driver'; +import {getOrSetAsInMap, listenOnPlayer, makeAnimationEvent, normalizeKeyframes, optimizeGroupPlayer} from './shared'; + +const EMPTY_INSTRUCTION_MAP = new ElementInstructionMap(); + +export class TimelineAnimationEngine { + private _animations: {[id: string]: Ast} = {}; + private _playersById: {[id: string]: AnimationPlayer} = {}; + public players: AnimationPlayer[] = []; + + constructor(private _driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) {} + + register(id: string, metadata: AnimationMetadata|AnimationMetadata[]) { + const errors: any[] = []; + const ast = buildAnimationAst(metadata, errors); + if (errors.length) { + throw new Error( + `Unable to build the animation due to the following errors: ${errors.join("\n")}`); + } else { + this._animations[id] = ast; + } + } + + private _buildPlayer( + i: AnimationTimelineInstruction, preStyles: ɵStyleData, + postStyles?: ɵStyleData): AnimationPlayer { + const element = i.element; + const keyframes = normalizeKeyframes( + this._driver, this._normalizer, element, i.keyframes, preStyles, postStyles); + return this._driver.animate(element, keyframes, i.duration, i.delay, i.easing, []); + } + + create(id: string, element: any, options: AnimationOptions = {}): AnimationPlayer { + const errors: any[] = []; + const ast = this._animations[id]; + let instructions: AnimationTimelineInstruction[]; + + const autoStylesMap = new Map(); + + if (ast) { + instructions = + buildAnimationTimelines(element, ast, {}, {}, options, EMPTY_INSTRUCTION_MAP, errors); + instructions.forEach(inst => { + const styles = getOrSetAsInMap(autoStylesMap, inst.element, {}); + inst.postStyleProps.forEach(prop => styles[prop] = null); + }); + } else { + errors.push('The requested animation doesn\'t exist or has already been destroyed'); + instructions = []; + } + + if (errors.length) { + throw new Error( + `Unable to create the animation due to the following errors: ${errors.join("\n")}`); + } + + autoStylesMap.forEach((styles, element) => { + Object.keys(styles).forEach( + prop => { styles[prop] = this._driver.computeStyle(element, prop, AUTO_STYLE); }); + }); + + const players = instructions.map(i => { + const styles = autoStylesMap.get(i.element); + return this._buildPlayer(i, {}, styles); + }); + const player = optimizeGroupPlayer(players); + this._playersById[id] = player; + player.onDestroy(() => this.destroy(id)); + + this.players.push(player); + return player; + } + + destroy(id: string) { + const player = this._getPlayer(id); + player.destroy(); + delete this._playersById[id]; + const index = this.players.indexOf(player); + if (index >= 0) { + this.players.splice(index, 1); + } + } + + private _getPlayer(id: string): AnimationPlayer { + const player = this._playersById[id]; + if (!player) { + throw new Error(`Unable to find the timeline player referenced by ${id}`); + } + return player; + } + + listen(id: string, element: string, eventName: string, callback: (event: any) => any): + () => void { + // triggerName, fromState, toState are all ignored for timeline animations + const baseEvent = makeAnimationEvent(element, '', '', ''); + listenOnPlayer(this._getPlayer(id), eventName, baseEvent, callback); + return () => {}; + } + + command(id: string, element: any, command: string, args: any[]): void { + if (command == 'register') { + this.register(id, args[0] as AnimationMetadata | AnimationMetadata[]); + return; + } + + if (command == 'create') { + const options = (args[0] || {}) as AnimationOptions; + this.create(id, element, options); + return; + } + + const player = this._getPlayer(id); + switch (command) { + case 'play': + player.play(); + break; + case 'pause': + player.pause(); + break; + case 'reset': + player.reset(); + break; + case 'restart': + player.restart(); + break; + case 'finish': + player.finish(); + break; + case 'init': + player.init(); + break; + case 'setPosition': + player.setPosition(parseFloat(args[0] as string)); + break; + case 'destroy': + this.destroy(id); + break; + } + } +} diff --git a/packages/animations/browser/src/render/transition_animation_engine.ts b/packages/animations/browser/src/render/transition_animation_engine.ts new file mode 100644 index 0000000000..e79fa64c31 --- /dev/null +++ b/packages/animations/browser/src/render/transition_animation_engine.ts @@ -0,0 +1,1230 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, AnimationOptions, AnimationPlayer, NoopAnimationPlayer, ɵPRE_STYLE as PRE_STYLE, ɵStyleData} from '@angular/animations'; + +import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instruction'; +import {AnimationTransitionFactory} from '../dsl/animation_transition_factory'; +import {AnimationTransitionInstruction} from '../dsl/animation_transition_instruction'; +import {AnimationTrigger} from '../dsl/animation_trigger'; +import {ElementInstructionMap} from '../dsl/element_instruction_map'; +import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer'; +import {ENTER_CLASSNAME, LEAVE_CLASSNAME, LEAVE_SELECTOR, NG_ANIMATING_CLASSNAME, NG_TRIGGER_CLASSNAME, NG_TRIGGER_SELECTOR, copyObj, eraseStyles, iteratorToArray, setStyles} from '../util'; + +import {AnimationDriver} from './animation_driver'; +import {getOrSetAsInMap, listenOnPlayer, makeAnimationEvent, normalizeKeyframes, optimizeGroupPlayer} from './shared'; + +const EMPTY_PLAYER_ARRAY: AnimationPlayer[] = []; + +interface TriggerListener { + name: string; + phase: string; + callback: (event: any) => any; +} + +export interface QueueInstruction { + element: any; + triggerName: string; + fromState: StateValue; + toState: StateValue; + transition: AnimationTransitionFactory; + player: TransitionAnimationPlayer; + isFallbackTransition: boolean; +} + +export class StateValue { + public value: string; + public options: AnimationOptions; + + constructor(input: any) { + const isObj = input && input.hasOwnProperty('value'); + const value = isObj ? input['value'] : input; + this.value = normalizeTriggerValue(value); + if (isObj) { + const options = copyObj(input as any); + delete options['value']; + this.options = options as AnimationOptions; + } else { + this.options = {}; + } + if (!this.options.params) { + this.options.params = {}; + } + } + + absorbOptions(options: AnimationOptions) { + const newParams = options.params; + if (newParams) { + const oldParams = this.options.params !; + Object.keys(newParams).forEach(prop => { + if (oldParams[prop] == null) { + oldParams[prop] = newParams[prop]; + } + }); + } + } +} + +export const VOID_VALUE = 'void'; +export const DEFAULT_STATE_VALUE = new StateValue(VOID_VALUE); +export const DELETED_STATE_VALUE = new StateValue('DELETED'); + +const POTENTIAL_ENTER_CLASSNAME = ENTER_CLASSNAME + '-temp'; +const POTENTIAL_ENTER_SELECTOR = '.' + POTENTIAL_ENTER_CLASSNAME; + +export class AnimationTransitionNamespace { + public players: TransitionAnimationPlayer[] = []; + + private _triggers: {[triggerName: string]: AnimationTrigger} = {}; + private _queue: QueueInstruction[] = []; + + private _elementListeners = new Map(); + + private _hostClassName: string; + + constructor( + public id: string, public hostElement: any, private _engine: TransitionAnimationEngine) { + this._hostClassName = 'ng-tns-' + id; + hostElement.classList.add(this._hostClassName); + } + + listen(element: any, name: string, phase: string, callback: (event: any) => boolean): () => any { + if (!this._triggers.hasOwnProperty(name)) { + throw new Error( + `Unable to listen on the animation trigger event "${phase}" because the animation trigger "${name}" doesn\'t exist!`); + } + + if (phase == null || phase.length == 0) { + throw new Error( + `Unable to listen on the animation trigger "${name}" because the provided event is undefined!`); + } + + if (!isTriggerEventValid(phase)) { + throw new Error( + `The provided animation trigger event "${phase}" for the animation trigger "${name}" is not supported!`); + } + + const listeners = getOrSetAsInMap(this._elementListeners, element, []); + const data = {name, phase, callback}; + listeners.push(data); + + const triggersWithStates = getOrSetAsInMap(this._engine.statesByElement, element, {}); + if (!triggersWithStates.hasOwnProperty(name)) { + element.classList.add(NG_TRIGGER_CLASSNAME); + element.classList.add(NG_TRIGGER_CLASSNAME + '-' + name); + triggersWithStates[name] = null; + } + + return () => { + // the event listener is removed AFTER the flush has occurred such + // that leave animations callbacks can fire (otherwise if the node + // is removed in between then the listeners would be deregistered) + this._engine.afterFlush(() => { + const index = listeners.indexOf(data); + if (index >= 0) { + listeners.splice(index, 1); + } + + if (!this._triggers[name]) { + delete triggersWithStates[name]; + } + }); + }; + } + + register(name: string, ast: AnimationTrigger): boolean { + if (this._triggers[name]) { + // throw + return false; + } else { + this._triggers[name] = ast; + return true; + } + } + + private _getTrigger(name: string) { + const trigger = this._triggers[name]; + if (!trigger) { + throw new Error(`The provided animation trigger "${name}" has not been registered!`); + } + return trigger; + } + + trigger(element: any, triggerName: string, value: any, defaultToFallback: boolean = true): + TransitionAnimationPlayer|undefined { + const trigger = this._getTrigger(triggerName); + const player = new TransitionAnimationPlayer(this.id, triggerName, element); + + let triggersWithStates = this._engine.statesByElement.get(element); + if (!triggersWithStates) { + element.classList.add(NG_TRIGGER_CLASSNAME); + element.classList.add(NG_TRIGGER_CLASSNAME + '-' + triggerName); + this._engine.statesByElement.set(element, triggersWithStates = {}); + } + + let fromState = triggersWithStates[triggerName]; + const toState = new StateValue(value); + + const isObj = value && value.hasOwnProperty('value'); + if (!isObj && fromState) { + toState.absorbOptions(fromState.options); + } + + triggersWithStates[triggerName] = toState; + + if (!fromState) { + fromState = DEFAULT_STATE_VALUE; + } else if (fromState === DELETED_STATE_VALUE) { + return player; + } + + const playersOnElement: TransitionAnimationPlayer[] = + getOrSetAsInMap(this._engine.playersByElement, element, []); + playersOnElement.forEach(player => { + // only remove the player if it is queued on the EXACT same trigger/namespace + // we only also deal with queued players here because if the animation has + // started then we want to keep the player alive until the flush happens + // (which is where the previousPlayers are passed into the new palyer) + if (player.namespaceId == this.id && player.triggerName == triggerName && player.queued) { + player.destroy(); + } + }); + + let transition = trigger.matchTransition(fromState.value, toState.value); + let isFallbackTransition = false; + if (!transition) { + if (!defaultToFallback) return; + transition = trigger.fallbackTransition; + isFallbackTransition = true; + } + + this._engine.totalQueuedPlayers++; + this._queue.push( + {element, triggerName, transition, fromState, toState, player, isFallbackTransition}); + + if (!isFallbackTransition) { + element.classList.add(NG_ANIMATING_CLASSNAME); + } + + player.onDone(() => { + element.classList.remove(NG_ANIMATING_CLASSNAME); + + let index = this.players.indexOf(player); + if (index >= 0) { + this.players.splice(index, 1); + } + + const players = this._engine.playersByElement.get(element); + if (players) { + let index = players.indexOf(player); + if (index >= 0) { + players.splice(index, 1); + } + } + }); + + this.players.push(player); + playersOnElement.push(player); + + return player; + } + + deregister(name: string) { + delete this._triggers[name]; + + this._engine.statesByElement.forEach((stateMap, element) => { delete stateMap[name]; }); + + this._elementListeners.forEach((listeners, element) => { + this._elementListeners.set( + element, listeners.filter(entry => { return entry.name != name; })); + }); + } + + private _onElementDestroy(element: any) { + this._engine.statesByElement.delete(element); + this._elementListeners.delete(element); + const elementPlayers = this._engine.playersByElement.get(element); + if (elementPlayers) { + elementPlayers.forEach(player => player.destroy()); + this._engine.playersByElement.delete(element); + } + } + + private _destroyInnerNodes(rootElement: any, context: any, animate: boolean = false) { + listToArray(rootElement.querySelectorAll(NG_TRIGGER_SELECTOR)).forEach(elm => { + if (animate && elm.classList.contains(this._hostClassName)) { + const innerNs = this._engine.namespacesByHostElement.get(elm); + + // special case for a host element with animations on the same element + if (innerNs) { + innerNs.removeNode(elm, context, true); + } + + this.removeNode(elm, context, true); + } else { + this._onElementDestroy(elm); + } + }); + } + + removeNode(element: any, context: any, doNotRecurse?: boolean): void { + const engine = this._engine; + + element.classList.add(LEAVE_CLASSNAME); + engine.afterFlush(() => element.classList.remove(LEAVE_CLASSNAME)); + + if (!doNotRecurse && element.childElementCount) { + this._destroyInnerNodes(element, context, true); + } + + const triggerStates = engine.statesByElement.get(element); + if (triggerStates) { + const players: TransitionAnimationPlayer[] = []; + Object.keys(triggerStates).forEach(triggerName => { + // this check is here in the event that an element is removed + // twice (both on the host level and the component level) + if (this._triggers[triggerName]) { + const player = this.trigger(element, triggerName, VOID_VALUE, false); + if (player) { + players.push(player); + } + } + }); + + if (players.length) { + optimizeGroupPlayer(players).onDone(() => { + engine.destroyInnerAnimations(element); + this._onElementDestroy(element); + engine._onRemovalComplete(element, context); + }); + + return; + } + } + + // find the player that is animating and make sure that the + // removal is delayed until that player has completed + let containsPotentialParentTransition = false; + if (engine.totalAnimations) { + const currentPlayers = + engine.players.length ? engine.playersByQueriedElement.get(element) : []; + + // when this `if statement` does not continue forward it means that + // a previous animation query has selected the current element and + // is animating it. In this situation want to continue fowards and + // allow the element to be queued up for animation later. + if (currentPlayers && currentPlayers.length) { + containsPotentialParentTransition = true; + } else { + let parent = element; + while (parent = parent.parentNode) { + const triggers = engine.statesByElement.get(parent); + if (triggers) { + containsPotentialParentTransition = true; + break; + } + } + } + } + + // at this stage we know that the element will either get removed + // during flush or will be picked up by a parent query. Either way + // we need to fire the listeners for this element when it DOES get + // removed (once the query parent animation is done or after flush) + const listeners = this._elementListeners.get(element); + if (listeners) { + const visitedTriggers = new Set(); + listeners.forEach(listener => { + const triggerName = listener.name; + if (visitedTriggers.has(triggerName)) return; + visitedTriggers.add(triggerName); + + const trigger = this._triggers[triggerName]; + const transition = trigger.fallbackTransition; + const elementStates = engine.statesByElement.get(element) !; + const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE; + const toState = new StateValue(VOID_VALUE); + const player = new TransitionAnimationPlayer(this.id, triggerName, element); + + this._engine.totalQueuedPlayers++; + this._queue.push({ + element, + triggerName, + transition, + fromState, + toState, + player, + isFallbackTransition: true + }); + }); + } + + // whether or not a parent has an animation we need to delay the deferral of the leave + // operation until we have more information (which we do after flush() has been called) + if (containsPotentialParentTransition) { + engine.queuedRemovals.set(element, () => { + engine.destroyInnerAnimations(element); + this._onElementDestroy(element); + engine._onRemovalComplete(element, context); + }); + } else { + // we do this after the flush has occurred such + // that the callbacks can be fired + engine.afterFlush(() => this._onElementDestroy(element)); + engine.destroyInnerAnimations(element); + engine._onRemovalComplete(element, context); + } + } + + insertNode(element: any, parent: any): void { element.classList.add(this._hostClassName); } + + drainQueuedTransitions(): QueueInstruction[] { + const instructions: QueueInstruction[] = []; + this._queue.forEach(entry => { + const player = entry.player; + if (player.destroyed) return; + + const element = entry.element; + const listeners = this._elementListeners.get(element); + if (listeners) { + listeners.forEach((listener: TriggerListener) => { + if (listener.name == entry.triggerName) { + const baseEvent = makeAnimationEvent( + element, entry.triggerName, entry.fromState.value, entry.toState.value); + listenOnPlayer(entry.player, listener.phase, baseEvent, listener.callback); + } + }); + } + + if (player.markedForDestroy) { + this._engine.afterFlush(() => { + // now we can destroy the element properly since the event listeners have + // been bound to the player + player.destroy(); + }); + } else { + instructions.push(entry); + } + }); + + this._queue = []; + + return instructions.sort((a, b) => { + // if depCount == 0 them move to front + // otherwise if a.contains(b) then move back + const d0 = a.transition.ast.depCount; + const d1 = b.transition.ast.depCount; + if (d0 == 0 || d1 == 0) { + return d0 - d1; + } + return a.element.contains(b.element) ? 1 : -1; + }); + } + + destroy(context: any) { + this.players.forEach(p => p.destroy()); + this._destroyInnerNodes(this.hostElement, context); + } + + elementContainsData(element: any): boolean { + let containsData = false; + if (this._elementListeners.has(element)) containsData = true; + containsData = + (this._queue.find(entry => entry.element === element) ? true : false) || containsData; + return containsData; + } +} + +export interface QueuedTransition { + element: any; + instruction: AnimationTransitionInstruction; + player: TransitionAnimationPlayer; +} + +export class TransitionAnimationEngine { + public players: TransitionAnimationPlayer[] = []; + public queuedRemovals = new Map any>(); + public newlyInserted = new Set(); + public newHostElements = new Map(); + public playersByElement = new Map(); + public playersByQueriedElement = new Map(); + public statesByElement = new Map(); + public totalAnimations = 0; + public totalQueuedPlayers = 0; + + private _namespaceLookup: {[id: string]: AnimationTransitionNamespace} = {}; + private _namespaceList: AnimationTransitionNamespace[] = []; + private _flushFns: (() => any)[] = []; + private _whenQuietFns: (() => any)[] = []; + + public namespacesByHostElement = new Map(); + + // this method is designed to be overridden by the code that uses this engine + public onRemovalComplete = (element: any, context: any) => {}; + + _onRemovalComplete(element: any, context: any) { this.onRemovalComplete(element, context); } + + constructor(private _driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) {} + + get queuedPlayers(): TransitionAnimationPlayer[] { + const players: TransitionAnimationPlayer[] = []; + this._namespaceList.forEach(ns => { + ns.players.forEach(player => { + if (player.queued) { + players.push(player); + } + }); + }); + return players; + } + + createNamespace(namespaceId: string, hostElement: any) { + const ns = new AnimationTransitionNamespace(namespaceId, hostElement, this); + if (hostElement.parentNode) { + this._balanceNamespaceList(ns, hostElement); + } else { + // defer this later until flush during when the host element has + // been inserted so that we know exactly where to place it in + // the namespace list + this.newHostElements.set(hostElement, ns); + + // given that this host element is apart of the animation code, it + // may or may not be inserted by a parent node that is an of an + // animation renderer type. If this happens then we can still have + // access to this item when we query for :enter nodes. If the parent + // is a renderer then the set data-structure will normalize the entry + this.newlyInserted.add(hostElement); + } + return this._namespaceLookup[namespaceId] = ns; + } + + private _balanceNamespaceList(ns: AnimationTransitionNamespace, hostElement: any) { + const limit = this._namespaceList.length - 1; + if (limit >= 0) { + let found = false; + for (let i = limit; i >= 0; i--) { + const nextNamespace = this._namespaceList[i]; + if (nextNamespace.hostElement.contains(hostElement)) { + this._namespaceList.splice(i + 1, 0, ns); + found = true; + break; + } + } + if (!found) { + this._namespaceList.splice(0, 0, ns); + } + } else { + this._namespaceList.push(ns); + } + + this.namespacesByHostElement.set(hostElement, ns); + return ns; + } + + register(namespaceId: string, hostElement: any, name: string, trigger: AnimationTrigger) { + let ns = this._namespaceLookup[namespaceId]; + if (!ns) { + ns = this.createNamespace(namespaceId, hostElement); + } + if (ns.register(name, trigger)) { + this.totalAnimations++; + } + } + + destroy(namespaceId: string, context: any) { + const ns = this._fetchNamespace(namespaceId); + + this.afterFlush(() => { + this.namespacesByHostElement.delete(ns.hostElement); + delete this._namespaceLookup[namespaceId]; + const index = this._namespaceList.indexOf(ns); + if (index >= 0) { + this._namespaceList.splice(index, 1); + } + }); + + this.afterFlushAnimationsDone(() => ns.destroy(context)); + } + + private _fetchNamespace(id: string) { return this._namespaceLookup[id]; } + + trigger(namespaceId: string, element: any, name: string, value: any): boolean { + if (isElementNode(element)) { + this._fetchNamespace(namespaceId).trigger(element, name, value); + return true; + } + return false; + } + + insertNode(namespaceId: string, element: any, parent: any, insertBefore: boolean): void { + if (!isElementNode(element)) return; + + this._fetchNamespace(namespaceId).insertNode(element, parent); + + // only *directives and host elements are inserted before + if (insertBefore) { + this.newlyInserted.add(element); + } + } + + removeNode(namespaceId: string, element: any, context: any, doNotRecurse?: boolean): void { + const ns = this._fetchNamespace(namespaceId); + if (!isElementNode(element) || !ns) { + this._onRemovalComplete(element, context); + } else { + ns.removeNode(element, context, doNotRecurse); + } + } + + listen( + namespaceId: string, element: any, name: string, phase: string, + callback: (event: any) => boolean): () => any { + if (isElementNode(element)) { + return this._fetchNamespace(namespaceId).listen(element, name, phase, callback); + } + return () => {}; + } + + private _buildInstruction(entry: QueueInstruction, subTimelines: ElementInstructionMap) { + return entry.transition.build( + entry.element, entry.fromState.value, entry.toState.value, entry.toState.options, + subTimelines); + } + + destroyInnerAnimations(containerElement: any) { + listToArray(containerElement.querySelectorAll(NG_TRIGGER_SELECTOR)).forEach(element => { + const players = this.playersByElement.get(element); + if (players) { + players.forEach(player => { + // special case for when an element is set for destruction, but hasn't started. + // in this situation we want to delay the destruction until the flush occurs + // so that any event listeners attached to the player are triggered. + if (player.queued) { + player.markedForDestroy = true; + } else { + player.destroy(); + } + }); + } + const stateMap = this.statesByElement.get(element); + if (stateMap) { + Object.keys(stateMap).forEach(triggerName => stateMap[triggerName] = DELETED_STATE_VALUE); + } + }); + } + + flush() { + let players: AnimationPlayer[] = []; + if (this.newHostElements.size) { + this.newHostElements.forEach((ns, element) => { this._balanceNamespaceList(ns, element); }); + this.newHostElements.clear(); + } + + if (this._namespaceList.length && (this.totalQueuedPlayers || this.queuedRemovals.size)) { + players = this._flushAnimations(); + } + + this.totalQueuedPlayers = 0; + this.queuedRemovals.clear(); + this.newlyInserted.clear(); + this._flushFns.forEach(fn => fn()); + this._flushFns = []; + + if (this._whenQuietFns.length) { + // we move these over to a variable so that + // if any new callbacks are registered in another + // flush they do not populate the existing set + const quietFns = this._whenQuietFns; + this._whenQuietFns = []; + + if (players.length) { + optimizeGroupPlayer(players).onDone(() => { quietFns.forEach(fn => fn()); }); + } else { + quietFns.forEach(fn => fn()); + } + } + } + + private _flushAnimations(): TransitionAnimationPlayer[] { + const subTimelines = new ElementInstructionMap(); + const skippedPlayers: TransitionAnimationPlayer[] = []; + const skippedPlayersMap = new Map(); + const queuedInstructions: QueuedTransition[] = []; + const queriedElements = new Map(); + const allPreStyleElements = new Map>(); + const allPostStyleElements = new Map>(); + + // this must occur before the instructions are built below such that + // the :enter queries match the elements (since the timeline queries + // are fired during instruction building). + const allEnterNodes = iteratorToArray(this.newlyInserted.values()); + const enterNodes: any[] = collectEnterElements(allEnterNodes); + + for (let i = this._namespaceList.length - 1; i >= 0; i--) { + const ns = this._namespaceList[i]; + ns.drainQueuedTransitions().forEach(entry => { + const player = entry.player; + const element = entry.element; + if (!document.body.contains(element)) { + player.destroy(); + return; + } + + const instruction = this._buildInstruction(entry, subTimelines); + if (!instruction) return; + + // if a unmatched transition is queued to go then it SHOULD NOT render + // an animation and cancel the previously running animations. + if (entry.isFallbackTransition && !instruction.isRemovalTransition) { + eraseStyles(element, instruction.fromStyles); + player.onDestroy(() => setStyles(element, instruction.toStyles)); + skippedPlayers.push(player); + return; + } + + // this means that if a parent animation uses this animation as a sub trigger + // then it will instruct the timeline builder to not add a player delay, but + // instead stretch the first keyframe gap up until the animation starts. The + // reason this is important is to prevent extra initialization styles from being + // required by the user in the animation. + instruction.timelines.forEach(tl => tl.stretchStartingKeyframe = true); + + subTimelines.append(element, instruction.timelines); + + const tuple = {instruction, player, element}; + + queuedInstructions.push(tuple); + + instruction.queriedElements.forEach( + element => getOrSetAsInMap(queriedElements, element, []).push(player)); + + instruction.preStyleProps.forEach((stringMap, element) => { + const props = Object.keys(stringMap); + if (props.length) { + let setVal: Set = allPreStyleElements.get(element) !; + if (!setVal) { + allPreStyleElements.set(element, setVal = new Set()); + } + props.forEach(prop => setVal.add(prop)); + } + }); + + instruction.postStyleProps.forEach((stringMap, element) => { + const props = Object.keys(stringMap); + let setVal: Set = allPostStyleElements.get(element) !; + if (!setVal) { + allPostStyleElements.set(element, setVal = new Set()); + } + props.forEach(prop => setVal.add(prop)); + }); + }); + } + + const allPreviousPlayersMap = new Map(); + let sortedParentElements: any[] = []; + queuedInstructions.forEach(entry => { + const element = entry.element; + if (subTimelines.has(element)) { + sortedParentElements.unshift(element); + this._beforeAnimationBuild( + entry.player.namespaceId, entry.instruction, allPreviousPlayersMap); + } + }); + + allPreviousPlayersMap.forEach(players => { players.forEach(player => player.destroy()); }); + + const leaveNodes: any[] = allPostStyleElements.size ? + listToArray(document.body.querySelectorAll(LEAVE_SELECTOR)) : + []; + + // PRE STAGE: fill the ! styles + const preStylesMap = allPreStyleElements.size ? + cloakAndComputeStyles(this._driver, enterNodes, allPreStyleElements, PRE_STYLE) : + new Map(); + + // POST STAGE: fill the * styles + const postStylesMap = + cloakAndComputeStyles(this._driver, leaveNodes, allPostStyleElements, AUTO_STYLE); + + const rootPlayers: TransitionAnimationPlayer[] = []; + const subPlayers: TransitionAnimationPlayer[] = []; + queuedInstructions.forEach(entry => { + const {element, player, instruction} = entry; + // 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)) { + const innerPlayer = this._buildAnimation( + player.namespaceId, instruction, allPreviousPlayersMap, skippedPlayersMap, preStylesMap, + postStylesMap); + player.setRealPlayer(innerPlayer); + + let parentHasPriority: any = null; + for (let i = 0; i < sortedParentElements.length; i++) { + const parent = sortedParentElements[i]; + if (parent === element) break; + if (parent.contains(element)) { + parentHasPriority = parent; + break; + } + } + + if (parentHasPriority) { + const parentPlayers = this.playersByElement.get(parentHasPriority); + if (parentPlayers && parentPlayers.length) { + player.parentPlayer = optimizeGroupPlayer(parentPlayers); + } + skippedPlayers.push(player); + } else { + rootPlayers.push(player); + } + } else { + eraseStyles(element, instruction.fromStyles); + player.onDestroy(() => setStyles(element, instruction.toStyles)); + subPlayers.push(player); + } + }); + + subPlayers.forEach(player => { + const playersForElement = skippedPlayersMap.get(player.element); + if (playersForElement && playersForElement.length) { + const innerPlayer = optimizeGroupPlayer(playersForElement); + player.setRealPlayer(innerPlayer); + } + }); + + // the reason why we don't actually play the animation is + // because all that a skipped player is designed to do is to + // fire the start/done transition callback events + skippedPlayers.forEach(player => { + if (player.parentPlayer) { + player.parentPlayer.onDestroy(() => player.destroy()); + } else { + player.destroy(); + } + }); + + // run through all of the queued removals and see if they + // were picked up by a query. If not then perform the removal + // operation right away unless a parent animation is ongoing. + this.queuedRemovals.forEach((fn, element) => { + const players = queriedElements.get(element); + if (players) { + optimizeGroupPlayer(players).onDone(fn); + } else { + let elementPlayers: AnimationPlayer[]|null = null; + + let parent = element; + while (parent = parent.parentNode) { + const playersForThisElement = this.playersByElement.get(parent); + if (playersForThisElement && playersForThisElement.length) { + elementPlayers = playersForThisElement; + break; + } + } + + if (elementPlayers) { + optimizeGroupPlayer(elementPlayers).onDone(fn); + } else { + fn(); + } + } + }); + + rootPlayers.forEach(player => { + this.players.push(player); + player.onDone(() => { + player.destroy(); + + const index = this.players.indexOf(player); + this.players.splice(index, 1); + }); + player.play(); + }); + + enterNodes.forEach(element => element.classList.remove(ENTER_CLASSNAME)); + + return rootPlayers; + } + + elementContainsData(namespaceId: string, element: any) { + let containsData = false; + if (this.queuedRemovals.has(element)) containsData = true; + if (this.newlyInserted.has(element)) containsData = true; + if (this.playersByElement.has(element)) containsData = true; + if (this.playersByQueriedElement.has(element)) containsData = true; + if (this.statesByElement.has(element)) containsData = true; + return this._fetchNamespace(namespaceId).elementContainsData(element) || containsData; + } + + afterFlush(callback: () => any) { this._flushFns.push(callback); } + + afterFlushAnimationsDone(callback: () => any) { this._whenQuietFns.push(callback); } + + private _getPreviousPlayers( + element: string, instruction: AnimationTransitionInstruction, isQueriedElement: boolean, + namespaceId?: string, triggerName?: string): TransitionAnimationPlayer[] { + let players: TransitionAnimationPlayer[] = []; + if (isQueriedElement) { + const queriedElementPlayers = this.playersByQueriedElement.get(element); + if (queriedElementPlayers) { + players = queriedElementPlayers; + } + } else { + const elementPlayers = this.playersByElement.get(element); + if (elementPlayers) { + const isRemovalAnimation = instruction.toState == VOID_VALUE; + elementPlayers.forEach(player => { + if (player.queued) return; + if (!isRemovalAnimation && player.triggerName != instruction.triggerName) return; + players.push(player); + }); + } + } + if (namespaceId || triggerName) { + players = players.filter(player => { + if (namespaceId && namespaceId != player.namespaceId) return false; + if (triggerName && triggerName != player.triggerName) return false; + return true; + }) + } + return players; + } + + private _beforeAnimationBuild( + namespaceId: string, instruction: AnimationTransitionInstruction, + allPreviousPlayersMap: Map) { + // it's important to do this step before destroying the players + // so that the onDone callback below won't fire before this + eraseStyles(instruction.element, instruction.fromStyles); + + const triggerName = instruction.triggerName; + const rootElement = instruction.element; + + // when a removal animation occurs, ALL previous players are collected + // and destroyed (even if they are outside of the current namespace) + const targetNameSpaceId: string|undefined = + instruction.isRemovalTransition ? undefined : namespaceId; + const targetTriggerName: string|undefined = + instruction.isRemovalTransition ? undefined : triggerName; + + instruction.timelines.map(timelineInstruction => { + const element = timelineInstruction.element; + const isQueriedElement = element !== rootElement; + const players = getOrSetAsInMap(allPreviousPlayersMap, element, []); + const previousPlayers = this._getPreviousPlayers( + element, instruction, isQueriedElement, targetNameSpaceId, targetTriggerName); + previousPlayers.forEach(player => { + const realPlayer = player.getRealPlayer() as any; + if (realPlayer.beforeDestroy) { + realPlayer.beforeDestroy(); + } + players.push(player); + }); + }); + } + + private _buildAnimation( + namespaceId: string, instruction: AnimationTransitionInstruction, + allPreviousPlayersMap: Map, + skippedPlayersMap: Map, preStylesMap: Map, + postStylesMap: Map): AnimationPlayer { + const triggerName = instruction.triggerName; + const rootElement = instruction.element; + + // we first run this so that the previous animation player + // data can be passed into the successive animation players + const allQueriedPlayers: TransitionAnimationPlayer[] = []; + const allConsumedElements = new Set(); + const allSubElements = new Set(); + const allNewPlayers = instruction.timelines.map(timelineInstruction => { + const element = timelineInstruction.element; + + // FIXME (matsko): make sure to-be-removed animations are removed properly + if (element['REMOVED']) return new NoopAnimationPlayer(); + + const isQueriedElement = element !== rootElement; + let previousPlayers: AnimationPlayer[] = EMPTY_PLAYER_ARRAY; + if (!allConsumedElements.has(element)) { + allConsumedElements.add(element); + const _previousPlayers = allPreviousPlayersMap.get(element); + if (_previousPlayers) { + previousPlayers = _previousPlayers.map(p => p.getRealPlayer()); + } + } + const preStyles = preStylesMap.get(element); + const postStyles = postStylesMap.get(element); + const keyframes = normalizeKeyframes( + this._driver, this._normalizer, element, timelineInstruction.keyframes, preStyles, + postStyles); + const player = this._buildPlayer(timelineInstruction, keyframes, previousPlayers); + + // this means that this particular player belongs to a sub trigger. It is + // important that we match this player up with the corresponding (@trigger.listener) + if (timelineInstruction.subTimeline && skippedPlayersMap) { + allSubElements.add(element); + } + + if (isQueriedElement) { + const wrappedPlayer = new TransitionAnimationPlayer(namespaceId, triggerName, element); + wrappedPlayer.setRealPlayer(player); + allQueriedPlayers.push(wrappedPlayer); + } + + return player; + }); + + allQueriedPlayers.forEach(player => { + getOrSetAsInMap(this.playersByQueriedElement, player.element, []).push(player); + player.onDone( + () => { deleteOrUnsetInMap(this.playersByQueriedElement, player.element, player); }); + }); + + allConsumedElements.forEach(element => { element.classList.add(NG_ANIMATING_CLASSNAME); }); + + const player = optimizeGroupPlayer(allNewPlayers); + player.onDone(() => { + allConsumedElements.forEach(element => { element.classList.remove(NG_ANIMATING_CLASSNAME); }); + setStyles(rootElement, instruction.toStyles); + }); + + // this basically makes all of the callbacks for sub element animations + // be dependent on the upper players for when they finish + allSubElements.forEach( + element => { getOrSetAsInMap(skippedPlayersMap, element, []).push(player); }); + + return player; + } + + private _buildPlayer( + instruction: AnimationTimelineInstruction, keyframes: ɵStyleData[], + previousPlayers: AnimationPlayer[]): AnimationPlayer { + if (keyframes.length > 0) { + return this._driver.animate( + instruction.element, keyframes, instruction.duration, instruction.delay, + instruction.easing, previousPlayers); + } + + // special case for when an empty transition|definition is provided + // ... there is no point in rendering an empty animation + return new NoopAnimationPlayer(); + } +} + +export class TransitionAnimationPlayer implements AnimationPlayer { + private _player: AnimationPlayer = new NoopAnimationPlayer(); + private _containsRealPlayer = false; + + private _queuedCallbacks: {[name: string]: (() => any)[]} = {}; + private _destroyed = false; + public parentPlayer: AnimationPlayer; + + public markedForDestroy: boolean = false; + + constructor(public namespaceId: string, public triggerName: string, public element: any) {} + + get queued() { return this._containsRealPlayer == false; } + + get destroyed() { return this._destroyed; } + + setRealPlayer(player: AnimationPlayer) { + if (this._containsRealPlayer) return; + + this._player = player; + Object.keys(this._queuedCallbacks).forEach(phase => { + this._queuedCallbacks[phase].forEach( + callback => listenOnPlayer(player, phase, undefined, callback)); + }); + this._queuedCallbacks = {}; + this._containsRealPlayer = true; + } + + getRealPlayer() { return this._player; } + + private _queueEvent(name: string, callback: (event: any) => any): void { + getOrSetAsInMap(this._queuedCallbacks, name, []).push(callback); + } + + onDone(fn: () => void): void { + if (this.queued) { + this._queueEvent('done', fn); + } + this._player.onDone(fn); + } + + onStart(fn: () => void): void { + if (this.queued) { + this._queueEvent('start', fn); + } + this._player.onStart(fn); + } + + onDestroy(fn: () => void): void { + if (this.queued) { + this._queueEvent('destroy', fn); + } + this._player.onDestroy(fn); + } + + init(): void { this._player.init(); } + + hasStarted(): boolean { return this.queued ? false : this._player.hasStarted(); } + + play(): void { !this.queued && this._player.play(); } + + pause(): void { !this.queued && this._player.pause(); } + + restart(): void { !this.queued && this._player.restart(); } + + finish(): void { this._player.finish(); } + + destroy(): void { + this._destroyed = true; + this._player.destroy(); + } + + reset(): void { !this.queued && this._player.reset(); } + + setPosition(p: any): void { + if (!this.queued) { + this._player.setPosition(p); + } + } + + getPosition(): number { return this.queued ? 0 : this._player.getPosition(); } + + get totalTime(): number { return this._player.totalTime; } +} + +function deleteOrUnsetInMap(map: Map| {[key: string]: any}, key: any, value: any) { + let currentValues: any[]|null|undefined; + if (map instanceof Map) { + currentValues = map.get(key); + if (currentValues) { + if (currentValues.length) { + const index = currentValues.indexOf(value); + currentValues.splice(index, 1); + } + if (currentValues.length == 0) { + map.delete(key); + } + } + } else { + currentValues = map[key]; + if (currentValues) { + if (currentValues.length) { + const index = currentValues.indexOf(value); + currentValues.splice(index, 1); + } + if (currentValues.length == 0) { + delete map[key]; + } + } + } + return currentValues; +} + +function normalizeTriggerValue(value: any): string { + switch (typeof value) { + case 'boolean': + return value ? '1' : '0'; + default: + return value ? value.toString() : null; + } +} + +function isElementNode(node: any) { + return node && node['nodeType'] === 1; +} + +function isTriggerEventValid(eventName: string): boolean { + return eventName == 'start' || eventName == 'done'; +} + +function cloakElement(element: any, value?: string) { + const oldValue = element.style.display; + element.style.display = value != null ? value : 'none'; + return oldValue; +} + +let elementMatches: (element: any, selector: string) => boolean = + (element: any, selector: string) => false; +if (typeof Element == 'function') { + if (Element.prototype.matches) { + elementMatches = (element: any, selector: string) => element.matches(selector); + } else { + const proto = Element.prototype as any; + const fn = proto.matchesSelector || proto.mozMatchesSelector || proto.msMatchesSelector || + proto.oMatchesSelector || proto.webkitMatchesSelector; + elementMatches = (element: any, selector: string) => fn.apply(element, [selector]); + } +} + +function filterNodeClasses(rootElement: any, selector: string): any[] { + const rootElements: any[] = []; + let cursor: any = rootElement; + let nextCursor: any = {}; + do { + nextCursor = cursor.querySelector(selector); + if (!nextCursor) { + cursor = cursor.parentElement; + if (!cursor) break; + nextCursor = cursor = cursor.nextElementSibling; + } else { + while (nextCursor && elementMatches(nextCursor, selector)) { + rootElements.push(nextCursor); + nextCursor = nextCursor.nextElementSibling; + if (nextCursor) { + cursor = nextCursor; + } else { + cursor = cursor.parentElement; + if (!cursor) break; + nextCursor = cursor = cursor.nextElementSibling; + } + } + } + } while (nextCursor && nextCursor !== rootElement); + + return rootElements; +} + +function cloakAndComputeStyles( + driver: AnimationDriver, elements: any[], elementPropsMap: Map>, + defaultStyle: string): Map { + const cloakVals = elements.map(element => cloakElement(element)); + const valuesMap = new Map(); + + elementPropsMap.forEach((props: Set, element: any) => { + const styles: ɵStyleData = {}; + props.forEach(prop => { + const value = styles[prop] = driver.computeStyle(element, prop, defaultStyle); + + // there is no easy way to detect this because a sub element could be removed + // by a parent animation element being detached. + if (!value || value.length == 0) { + element['REMOVED'] = true; + } + }); + valuesMap.set(element, styles); + }); + + elements.forEach((element, i) => cloakElement(element, cloakVals[i])); + return valuesMap; +} + +function listToArray(list: any): any[] { + const arr: any[] = []; + arr.push(...(list as any[])); + return arr; +} + +function collectEnterElements(allEnterNodes: any[]) { + allEnterNodes.forEach(element => element.classList.add(POTENTIAL_ENTER_CLASSNAME)); + const enterNodes = filterNodeClasses(document.body, POTENTIAL_ENTER_SELECTOR); + enterNodes.forEach(element => element.classList.add(ENTER_CLASSNAME)); + allEnterNodes.forEach(element => element.classList.remove(POTENTIAL_ENTER_CLASSNAME)); + return enterNodes; +} diff --git a/packages/animations/browser/src/render/web_animations/web_animations_driver.ts b/packages/animations/browser/src/render/web_animations/web_animations_driver.ts index c08401110f..cb3c6b58c6 100644 --- a/packages/animations/browser/src/render/web_animations/web_animations_driver.ts +++ b/packages/animations/browser/src/render/web_animations/web_animations_driver.ts @@ -12,11 +12,15 @@ import {AnimationDriver} from '../animation_driver'; import {WebAnimationsPlayer} from './web_animations_player'; export class WebAnimationsDriver implements AnimationDriver { + computeStyle(element: any, prop: string, defaultValue?: string): string { + return (window.getComputedStyle(element) as any)[prop] as string; + } + animate( element: any, keyframes: ɵStyleData[], duration: number, delay: number, easing: string, previousPlayers: AnimationPlayer[] = []): WebAnimationsPlayer { - const playerOptions: {[key: string]: string | - number} = {'duration': duration, 'delay': delay, 'fill': 'forwards'}; + const fill = delay == 0 ? 'both' : 'forwards'; + const playerOptions: {[key: string]: string | number} = {duration, delay, fill}; // we check for this to avoid having a null|undefined value be present // for the easing (which results in an error for certain browsers #9752) diff --git a/packages/animations/browser/src/render/web_animations/web_animations_player.ts b/packages/animations/browser/src/render/web_animations/web_animations_player.ts index 829c7c6edd..c55487dd27 100644 --- a/packages/animations/browser/src/render/web_animations/web_animations_player.ts +++ b/packages/animations/browser/src/render/web_animations/web_animations_player.ts @@ -5,7 +5,10 @@ * 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, AnimationPlayer} from '@angular/animations'; +import {AnimationPlayer} from '@angular/animations'; + +import {copyStyles, eraseStyles, setStyles} from '../../util'; + import {DOMAnimation} from './dom_animation'; export class WebAnimationsPlayer implements AnimationPlayer { @@ -24,18 +27,19 @@ export class WebAnimationsPlayer implements AnimationPlayer { public parentPlayer: AnimationPlayer|null = null; public previousStyles: {[styleName: string]: string | number}; + public currentSnapshot: {[styleName: string]: string | number} = {}; constructor( public element: any, public keyframes: {[key: string]: string | number}[], public options: {[key: string]: string | number}, - previousPlayers: WebAnimationsPlayer[] = []) { + private previousPlayers: WebAnimationsPlayer[] = []) { this._duration = options['duration']; this._delay = options['delay'] || 0; this.time = this._duration + this._delay; this.previousStyles = {}; previousPlayers.forEach(player => { - let styles = player._captureStyles(); + let styles = player.currentSnapshot; Object.keys(styles).forEach(prop => this.previousStyles[prop] = styles[prop]); }); } @@ -52,20 +56,7 @@ export class WebAnimationsPlayer implements AnimationPlayer { if (this._initialized) return; this._initialized = true; - const keyframes = this.keyframes.map(styles => { - const formattedKeyframe: {[key: string]: string | number} = {}; - Object.keys(styles).forEach((prop, index) => { - let value = styles[prop]; - if (value == AUTO_STYLE) { - value = _computeStyle(this.element, prop); - } - if (value != undefined) { - formattedKeyframe[prop] = value; - } - }); - return formattedKeyframe; - }); - + const keyframes = this.keyframes.map(styles => copyStyles(styles, false)); const previousStyleProps = Object.keys(this.previousStyles); if (previousStyleProps.length) { let startingKeyframe = keyframes[0]; @@ -90,11 +81,14 @@ export class WebAnimationsPlayer implements AnimationPlayer { } this._player = this._triggerWebAnimation(this.element, keyframes, this.options); - this._finalKeyframe = - keyframes.length ? _copyKeyframeStyles(keyframes[keyframes.length - 1]) : {}; + this._finalKeyframe = keyframes.length ? keyframes[keyframes.length - 1] : {}; // this is required so that the player doesn't start to animate right away - this._resetDomPlayerState(); + if (this._delay) { + this._resetDomPlayerState(); + } else { + this._player.pause(); + } this._player.addEventListener('finish', () => this._onFinish()); } @@ -168,7 +162,9 @@ export class WebAnimationsPlayer implements AnimationPlayer { getPosition(): number { return this._player.currentTime / this.time; } - private _captureStyles(): {[prop: string]: string | number} { + get totalTime(): number { return this._delay + this._duration; } + + beforeDestroy() { const styles: {[key: string]: string | number} = {}; if (this.hasStarted()) { Object.keys(this._finalKeyframe).forEach(prop => { @@ -178,22 +174,10 @@ export class WebAnimationsPlayer implements AnimationPlayer { } }); } - - return styles; + this.currentSnapshot = styles; } } function _computeStyle(element: any, prop: string): string { return (window.getComputedStyle(element))[prop]; } - -function _copyKeyframeStyles(styles: {[style: string]: string | number}): - {[style: string]: string | number} { - const newStyles: {[style: string]: string | number} = {}; - Object.keys(styles).forEach(prop => { - if (prop != 'offset') { - newStyles[prop] = styles[prop]; - } - }); - return newStyles; -} diff --git a/packages/animations/browser/src/util.ts b/packages/animations/browser/src/util.ts index bda44a0063..9963fc6de6 100644 --- a/packages/animations/browser/src/util.ts +++ b/packages/animations/browser/src/util.ts @@ -5,37 +5,62 @@ * 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 {AnimateTimings, ɵStyleData} from '@angular/animations'; +import {AnimateTimings, AnimationMetadata, AnimationOptions, sequence, ɵStyleData} from '@angular/animations'; export const ONE_SECOND = 1000; -export function parseTimeExpression(exp: string | number, errors: string[]): AnimateTimings { - const regex = /^([\.\d]+)(m?s)(?:\s+([\.\d]+)(m?s))?(?:\s+([-a-z]+(?:\(.+?\))?))?$/i; +export const ENTER_CLASSNAME = 'ng-enter'; +export const LEAVE_CLASSNAME = 'ng-leave'; +export const ENTER_SELECTOR = '.ng-enter'; +export const LEAVE_SELECTOR = '.ng-leave'; +export const NG_TRIGGER_CLASSNAME = 'ng-trigger'; +export const NG_TRIGGER_SELECTOR = '.ng-trigger'; +export const NG_ANIMATING_CLASSNAME = 'ng-animating'; +export const NG_ANIMATING_SELECTOR = '.ng-animating'; + +export function resolveTimingValue(value: string | number) { + if (typeof value == 'number') return value; + + const matches = (value as string).match(/^(-?[\.\d]+)(m?s)/); + if (!matches || matches.length < 2) return 0; + + return _convertTimeValueToMS(parseFloat(matches[1]), matches[2]); +} + +function _convertTimeValueToMS(value: number, unit: string): number { + switch (unit) { + case 's': + return value * ONE_SECOND; + default: // ms or something else + return value; + } +} + +export function resolveTiming( + timings: string | number | AnimateTimings, errors: any[], allowNegativeValues?: boolean) { + return timings.hasOwnProperty('duration') ? + timings : + parseTimeExpression(timings, errors, allowNegativeValues); +} + +function parseTimeExpression( + exp: string | number, errors: string[], allowNegativeValues?: boolean): AnimateTimings { + const regex = /^(-?[\.\d]+)(m?s)(?:\s+(-?[\.\d]+)(m?s))?(?:\s+([-a-z]+(?:\(.+?\))?))?$/i; let duration: number; let delay: number = 0; - let easing: string|null = null; + let easing: string = ''; if (typeof exp === 'string') { const matches = exp.match(regex); if (matches === null) { errors.push(`The provided timing value "${exp}" is invalid.`); - return {duration: 0, delay: 0, easing: null}; + return {duration: 0, delay: 0, easing: ''}; } - let durationMatch = parseFloat(matches[1]); - const durationUnit = matches[2]; - if (durationUnit == 's') { - durationMatch *= ONE_SECOND; - } - duration = Math.floor(durationMatch); + duration = _convertTimeValueToMS(parseFloat(matches[1]), matches[2]); const delayMatch = matches[3]; - const delayUnit = matches[4]; if (delayMatch != null) { - let delayVal: number = parseFloat(delayMatch); - if (delayUnit != null && delayUnit == 's') { - delayVal *= ONE_SECOND; - } - delay = Math.floor(delayVal); + delay = _convertTimeValueToMS(Math.floor(parseFloat(delayMatch)), matches[4]); } const easingVal = matches[5]; @@ -46,9 +71,31 @@ export function parseTimeExpression(exp: string | number, errors: string[]): Ani duration = exp; } + if (!allowNegativeValues) { + let containsErrors = false; + let startIndex = errors.length; + if (duration < 0) { + errors.push(`Duration values below 0 are not allowed for this animation step.`); + containsErrors = true; + } + if (delay < 0) { + errors.push(`Delay values below 0 are not allowed for this animation step.`); + containsErrors = true; + } + if (containsErrors) { + errors.splice(startIndex, 0, `The provided timing value "${exp}" is invalid.`); + } + } + return {duration, delay, easing}; } +export function copyObj( + obj: {[key: string]: any}, destination: {[key: string]: any} = {}): {[key: string]: any} { + Object.keys(obj).forEach(prop => { destination[prop] = obj[prop]; }); + return destination; +} + export function normalizeStyles(styles: ɵStyleData | ɵStyleData[]): ɵStyleData { const normalizedStyles: ɵStyleData = {}; if (Array.isArray(styles)) { @@ -69,7 +116,7 @@ export function copyStyles( destination[prop] = styles[prop]; } } else { - Object.keys(styles).forEach(prop => destination[prop] = styles[prop]); + copyObj(styles, destination); } return destination; } @@ -89,3 +136,73 @@ export function eraseStyles(element: any, styles: ɵStyleData) { }); } } + +export function normalizeAnimationEntry(steps: AnimationMetadata | AnimationMetadata[]): + AnimationMetadata { + if (Array.isArray(steps)) { + if (steps.length == 1) return steps[0]; + return sequence(steps); + } + return steps as AnimationMetadata; +} + +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) { + matches.forEach(varName => { + if (!params.hasOwnProperty(varName)) { + errors.push( + `Unable to resolve the local animation param ${varName} in the given list of values`); + } + }); + } +} + +const PARAM_REGEX = /\{\{\s*(.+?)\s*\}\}/g; +export function interpolateParams( + value: string | number, params: {[name: string]: any}, errors: any[]): string|number { + const original = value.toString(); + const str = original.replace(PARAM_REGEX, (_, varName) => { + let localVal = params[varName]; + // this means that the value was never overidden by the data passed in by the user + if (!params.hasOwnProperty(varName)) { + errors.push(`Please provide a value for the animation param ${varName}`); + localVal = ''; + } + return localVal.toString(); + }); + + // we do this to assert that numeric values stay as they are + return str == original ? value : str; +} + +export function iteratorToArray(iterator: any): any[] { + const arr: any[] = []; + let item = iterator.next(); + while (!item.done) { + arr.push(item.value); + item = iterator.next(); + } + return arr; +} + +export function mergeAnimationOptions( + source: AnimationOptions, destination: AnimationOptions): AnimationOptions { + if (source.params) { + const p0 = source.params; + if (!destination.params) { + destination.params = {}; + } + const p1 = destination.params; + Object.keys(p0).forEach(param => { + if (!p1.hasOwnProperty(param)) { + p1[param] = p0[param]; + } + }); + } + return destination; +} diff --git a/packages/animations/browser/test/dsl/animation_spec.ts b/packages/animations/browser/test/dsl/animation_spec.ts index ef455044fd..301de66195 100644 --- a/packages/animations/browser/test/dsl/animation_spec.ts +++ b/packages/animations/browser/test/dsl/animation_spec.ts @@ -5,14 +5,38 @@ * 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, group, keyframes, sequence, style, ɵStyleData} from '@angular/animations'; +import {AUTO_STYLE, AnimationMetadata, AnimationMetadataType, animate, group, keyframes, query, sequence, style, ɵStyleData} from '@angular/animations'; +import {AnimationOptions} from '@angular/core/src/animation/dsl'; import {Animation} from '../../src/dsl/animation'; +import {buildAnimationAst} from '../../src/dsl/animation_ast_builder'; import {AnimationTimelineInstruction} from '../../src/dsl/animation_timeline_instruction'; -import {validateAnimationSequence} from '../../src/dsl/animation_validator_visitor'; +import {ElementInstructionMap} from '../../src/dsl/element_instruction_map'; + +function createDiv() { + return document.createElement('div'); +} export function main() { describe('Animation', () => { + // these tests are only mean't to be run within the DOM (for now) + if (typeof Element == 'undefined') return; + + let rootElement: any; + let subElement1: any; + let subElement2: any; + + beforeEach(() => { + rootElement = createDiv(); + subElement1 = createDiv(); + subElement2 = createDiv(); + document.body.appendChild(rootElement); + rootElement.appendChild(subElement1); + rootElement.appendChild(subElement2); + }); + + afterEach(() => { document.body.removeChild(rootElement); }); + describe('validation', () => { it('should throw an error if one or more but not all keyframes() styles contain offsets', () => { @@ -90,6 +114,48 @@ export function main() { validateAndThrowAnimationSequence(steps2); }).toThrowError(/The provided timing value "500ms 500ms 500ms ease-out" is invalid/); }); + + it('should throw if negative durations are used', () => { + const steps = [animate(-1000, style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/Duration values below 0 are not allowed for this animation step/); + + const steps2 = [animate('-1s', style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps2); + }).toThrowError(/Duration values below 0 are not allowed for this animation step/); + }); + + it('should throw if negative delays are used', () => { + const steps = [animate('1s -500ms', style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/Delay values below 0 are not allowed for this animation step/); + + const steps2 = [animate('1s -0.5s', style({opacity: 1}))]; + + expect(() => { + validateAndThrowAnimationSequence(steps2); + }).toThrowError(/Delay values below 0 are not allowed for this animation step/); + }); + + it('should throw if keyframes() is not used inside of animate()', () => { + const steps = [keyframes([])]; + + expect(() => { + validateAndThrowAnimationSequence(steps); + }).toThrowError(/keyframes\(\) must be placed inside of a call to animate\(\)/); + + const steps2 = [group([keyframes([])])]; + + expect(() => { + validateAndThrowAnimationSequence(steps2); + }).toThrowError(/keyframes\(\) must be placed inside of a call to animate\(\)/); + }); }); describe('keyframe building', () => { @@ -102,7 +168,7 @@ export function main() { animate(1000, style({width: 200})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players[0].keyframes).toEqual([ {height: AUTO_STYLE, width: 0, offset: 0}, {height: 50, width: 0, offset: .25}, @@ -116,7 +182,7 @@ export function main() { () => { const steps = [animate(1000, style({width: 999}))]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players[0].keyframes).toEqual([ {width: AUTO_STYLE, offset: 0}, {width: 999, offset: 1} ]); @@ -128,7 +194,7 @@ export function main() { animate(1000, style({width: 100, height: 400, opacity: 1})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players[0].keyframes).toEqual([ {width: 200, height: 0, opacity: 0, offset: 0}, {width: 100, height: 400, opacity: 1, offset: 1} @@ -142,7 +208,7 @@ export function main() { animate(1000, style({opacity: 1})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); const keyframes = humanizeOffsets(players[0].keyframes, 4); expect(keyframes).toEqual([ @@ -159,7 +225,7 @@ export function main() { animate('1s cubic-bezier(.29, .55 ,.53 ,1.53)', style({opacity: 1})) ]; - const player = invokeAnimationSequence(steps)[0]; + const player = invokeAnimationSequence(rootElement, steps)[0]; const firstKeyframe = player.keyframes[0]; const firstKeyframeEasing = firstKeyframe['easing'] as string; expect(firstKeyframeEasing.replace(/\s+/g, '')).toEqual('cubic-bezier(.29,.55,.53,1.53)'); @@ -170,34 +236,30 @@ export function main() { it('should not produce extra timelines when multiple sequences are used within each other', () => { const steps = [ - style({width: 0}), animate(1000, style({width: 100})), sequence([ + style({width: 0}), + animate(1000, style({width: 100})), + sequence([ animate(1000, style({width: 200})), - sequence([animate(1000, style({width: 300}))]) + sequence([ + animate(1000, style({width: 300})), + ]), + ]), + animate(1000, style({width: 400})), + sequence([ + animate(1000, style({width: 500})), ]), - animate(1000, style({width: 400})), sequence([animate(1000, style({width: 500}))]) ]; - const players = invokeAnimationSequence(steps); - expect(players[0].keyframes).toEqual([ + const players = invokeAnimationSequence(rootElement, steps); + expect(players.length).toEqual(1); + + const player = players[0]; + expect(player.keyframes).toEqual([ {width: 0, offset: 0}, {width: 100, offset: .2}, {width: 200, offset: .4}, {width: 300, offset: .6}, {width: 400, offset: .8}, {width: 500, offset: 1} ]); }); - it('should produce a 1ms animation step if a style call exists before sequence within a call to animate()', - () => { - const steps = [ - style({width: 100}), sequence([ - animate(1000, style({width: 200})), - ]) - ]; - - const players = invokeAnimationSequence(steps); - expect(humanizeOffsets(players[0].keyframes, 4)).toEqual([ - {width: 100, offset: 0}, {width: 100, offset: .001}, {width: 200, offset: 1} - ]); - }); - it('should create a new timeline after a sequence if group() or keyframe() commands are used within', () => { const steps = [ @@ -211,7 +273,7 @@ export function main() { animate(1000, style({width: 500, height: 500})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(4); const finalPlayer = players[players.length - 1]; @@ -219,6 +281,76 @@ export function main() { {width: 200, height: 200, offset: 0}, {width: 500, height: 500, offset: 1} ]); }); + + it('should push the start of a sequence if a delay option is provided', () => { + const steps = [ + style({width: '0px'}), animate(1000, style({width: '100px'})), + sequence( + [ + animate(1000, style({width: '200px'})), + ], + {delay: 500}) + ]; + + const players = invokeAnimationSequence(rootElement, steps); + const finalPlayer = players[players.length - 1]; + expect(finalPlayer.keyframes).toEqual([ + {width: '100px', offset: 0}, + {width: '200px', offset: 1}, + ]); + expect(finalPlayer.delay).toEqual(1500); + }); + }); + + describe('subtitutions', () => { + it('should substitute in timing values', () => { + function makeAnimation(exp: string, values: {[key: string]: any}) { + const steps = [style({opacity: 0}), animate(exp, style({opacity: 1}))]; + return invokeAnimationSequence(rootElement, steps, values); + } + + let players = makeAnimation('{{ duration }}', buildParams({duration: '1234ms'})); + expect(players[0].duration).toEqual(1234); + + players = makeAnimation('{{ duration }}', buildParams({duration: '9s 2s'})); + expect(players[0].duration).toEqual(11000); + + players = makeAnimation('{{ duration }} 1s', buildParams({duration: '1.5s'})); + expect(players[0].duration).toEqual(2500); + + players = makeAnimation( + '{{ duration }} {{ delay }}', buildParams({duration: '1s', delay: '2s'})); + expect(players[0].duration).toEqual(3000); + }); + + it('should allow multiple substitutions to occur within the same style value', () => { + const steps = [ + style({transform: ''}), + animate(1000, style({transform: 'translateX({{ x }}) translateY({{ y }})'})) + ]; + const players = + invokeAnimationSequence(rootElement, steps, buildParams({x: '200px', y: '400px'})); + expect(players[0].keyframes).toEqual([ + {offset: 0, transform: ''}, + {offset: 1, transform: 'translateX(200px) translateY(400px)'} + ]); + }); + + it('should throw an error when an input variable is not provided when invoked and is not a default value', + () => { + expect(() => {invokeAnimationSequence(rootElement, [style({color: '{{ color }}'})])}) + .toThrowError(/Please provide a value for the animation param color/); + + expect( + () => {invokeAnimationSequence( + rootElement, + [ + style({color: '{{ start }}'}), + animate('{{ time }}', style({color: '{{ end }}'})), + ], + buildParams({start: 'blue', end: 'red'}))}) + .toThrowError(/Please provide a value for the animation param time/); + }); }); describe('keyframes()', () => { @@ -230,7 +362,7 @@ export function main() { animate(1000, style({height: 0, opacity: 0})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(3); const player0 = players[0]; @@ -267,7 +399,7 @@ export function main() { animate(1000, style({color: 'green', opacity: 0})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); const finalPlayer = players[players.length - 1]; expect(finalPlayer.keyframes).toEqual([ {opacity: 1, color: 'blue', offset: 0}, {opacity: 0, color: 'green', offset: 1} @@ -283,7 +415,7 @@ export function main() { ])) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(2); const topPlayer = players[0]; @@ -305,7 +437,7 @@ export function main() { keyframes([style({opacity: .8, offset: .5}), style({opacity: 1, offset: 1})])) ]; - const player = invokeAnimationSequence(steps)[1]; + const player = invokeAnimationSequence(rootElement, steps)[1]; expect(player.easing).toEqual('ease-out'); }); @@ -318,7 +450,7 @@ export function main() { keyframes([style({opacity: .8, offset: .5}), style({opacity: 1, offset: 1})])) ]; - const player = invokeAnimationSequence(steps)[1]; + const player = invokeAnimationSequence(rootElement, steps)[1]; expect(player.delay).toEqual(2500); }); @@ -343,7 +475,7 @@ export function main() { group([animate('2s', style({height: '500px', width: '500px'}))]) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(5); const firstPlayerKeyframes = players[0].keyframes; @@ -381,7 +513,7 @@ export function main() { style({opacity: 1, offset: 1}) ])); - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(1); const player = players[0]; @@ -398,7 +530,7 @@ export function main() { {type: AnimationMetadataType.Style, offset: 1, styles: {opacity: 1}}, ])); - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(1); const player = players[0]; @@ -417,7 +549,7 @@ export function main() { animate(1000, style({width: 1000, height: 1000})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(4); const player0 = players[0]; @@ -460,7 +592,7 @@ export function main() { ]) ])]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(2); const gPlayer1 = players[0]; @@ -484,7 +616,7 @@ export function main() { animate('1s 1s', style({height: 200, width: 200})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(4); const finalPlayer = players[players.length - 1]; @@ -505,7 +637,7 @@ export function main() { animate(2000, style({width: 0, opacity: 0})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); const middlePlayer = players[2]; expect(middlePlayer.delay).toEqual(2000); expect(middlePlayer.duration).toEqual(2000); @@ -514,6 +646,97 @@ export function main() { expect(finalPlayer.delay).toEqual(6000); expect(finalPlayer.duration).toEqual(2000); }); + + it('should push the start of a group if a delay option is provided', () => { + const steps = [ + style({width: '0px', height: '0px'}), + animate(1500, style({width: '100px', height: '100px'})), + group( + [ + animate(1000, style({width: '200px'})), + animate(2000, style({height: '200px'})), + ], + {delay: 300}) + ]; + + const players = invokeAnimationSequence(rootElement, steps); + const finalWidthPlayer = players[players.length - 2]; + const finalHeightPlayer = players[players.length - 1]; + + expect(finalWidthPlayer.delay).toEqual(1800); + expect(finalWidthPlayer.keyframes).toEqual([ + {width: '100px', offset: 0}, + {width: '200px', offset: 1}, + ]); + + expect(finalHeightPlayer.delay).toEqual(1800); + expect(finalHeightPlayer.keyframes).toEqual([ + {height: '100px', offset: 0}, + {height: '200px', offset: 1}, + ]); + }); + }); + + describe('query()', () => { + it('should delay the query operation if a delay option is provided', () => { + const steps = [ + style({opacity: 0}), animate(1000, style({opacity: 1})), + query( + 'div', + [ + style({width: 0}), + animate(500, style({width: 200})), + ], + {delay: 200}) + ]; + + const players = invokeAnimationSequence(rootElement, steps); + const finalPlayer = players[players.length - 1]; + expect(finalPlayer.delay).toEqual(1200); + }); + + it('should throw an error when an animation query returns zero elements', () => { + const steps = + [query('somethingFake', [style({opacity: 0}), animate(1000, style({opacity: 1}))])]; + + expect(() => { invokeAnimationSequence(rootElement, steps); }) + .toThrowError( + /`query\("somethingFake"\)` returned zero elements\. \(Use `query\("somethingFake", \{ optional: true \}\)` if you wish to allow this\.\)/); + }); + + it('should allow a query to be skipped if it is set as optional and returns zero elements', + () => { + const steps = [query( + 'somethingFake', [style({opacity: 0}), animate(1000, style({opacity: 1}))], + {optional: true})]; + + expect(() => { invokeAnimationSequence(rootElement, steps); }).not.toThrow(); + + const steps2 = [query( + 'fakeSomethings', [style({opacity: 0}), animate(1000, style({opacity: 1}))], + {optional: true})]; + + expect(() => { invokeAnimationSequence(rootElement, steps2); }).not.toThrow(); + }); + + it('should delay the query operation if a delay option is provided', () => { + const steps = [ + style({opacity: 0}), animate(1300, style({opacity: 1})), + query( + 'div', + [ + style({width: 0}), + animate(500, style({width: 200})), + ], + {delay: 300}) + ]; + + const players = invokeAnimationSequence(rootElement, steps); + const fp1 = players[players.length - 2]; + const fp2 = players[players.length - 1]; + expect(fp1.delay).toEqual(1600); + expect(fp2.delay).toEqual(1600); + }); }); describe('timing values', () => { @@ -522,7 +745,7 @@ export function main() { const steps: AnimationMetadata[] = [style({opacity: 0}), animate('3s 1s ease-out', style({opacity: 1}))]; - const player = invokeAnimationSequence(steps)[0]; + const player = invokeAnimationSequence(rootElement, steps)[0]; expect(player.keyframes).toEqual([ {opacity: 0, offset: 0}, {opacity: 0, offset: .25, easing: 'ease-out'}, {opacity: 1, offset: 1} @@ -535,7 +758,7 @@ export function main() { animate('2s ease-out', style({width: 20})), animate('1s ease-in', style({width: 30})) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players.length).toEqual(1); const player = players[0]; @@ -560,7 +783,7 @@ export function main() { ]) ]; - const player = invokeAnimationSequence(steps)[0]; + const player = invokeAnimationSequence(rootElement, steps)[0]; expect(player.duration).toEqual(1000); expect(player.delay).toEqual(0); }); @@ -579,7 +802,7 @@ export function main() { ])) ]; - const players = invokeAnimationSequence(steps); + const players = invokeAnimationSequence(rootElement, steps); expect(players[0].delay).toEqual(0); // top-level animation expect(players[1].delay).toEqual(1500); // first entry in group() expect(players[2].delay).toEqual(1500); // second entry in group() @@ -595,7 +818,7 @@ export function main() { const toStyles: ɵStyleData[] = [{background: 'red'}]; - const player = invokeAnimationSequence(steps, fromStyles, toStyles)[0]; + const player = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles)[0]; expect(player.duration).toEqual(0); expect(player.keyframes).toEqual([]); }); @@ -608,7 +831,7 @@ export function main() { const toStyles: ɵStyleData[] = [{background: 'red'}]; - const players = invokeAnimationSequence(steps, fromStyles, toStyles); + const players = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles); expect(players[0].keyframes).toEqual([ {background: 'blue', height: 100, offset: 0}, {background: 'red', height: AUTO_STYLE, offset: 1} @@ -623,7 +846,7 @@ export function main() { const toStyles: ɵStyleData[] = [{background: 'red'}]; - const players = invokeAnimationSequence(steps, fromStyles, toStyles); + const players = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles); expect(players[0].keyframes).toEqual([ {background: 'blue', offset: 0, easing: 'ease-out'}, {background: 'red', offset: 1} @@ -642,16 +865,21 @@ function humanizeOffsets(keyframes: ɵStyleData[], digits: number = 3): ɵStyleD } function invokeAnimationSequence( - steps: AnimationMetadata | AnimationMetadata[], startingStyles: ɵStyleData[] = [], - destinationStyles: ɵStyleData[] = []): AnimationTimelineInstruction[] { - return new Animation(steps).buildTimelines(startingStyles, destinationStyles); + element: any, steps: AnimationMetadata | AnimationMetadata[], locals: {[key: string]: any} = {}, + startingStyles: ɵStyleData[] = [], destinationStyles: ɵStyleData[] = [], + subInstructions?: ElementInstructionMap): AnimationTimelineInstruction[] { + return new Animation(steps).buildTimelines( + element, startingStyles, destinationStyles, locals, subInstructions); } function validateAndThrowAnimationSequence(steps: AnimationMetadata | AnimationMetadata[]) { - const ast = - Array.isArray(steps) ? sequence(steps) : steps; - const errors = validateAnimationSequence(ast); + const errors: any[] = []; + const ast = buildAnimationAst(steps, errors); if (errors.length) { throw new Error(errors.join('\n')); } } + +function buildParams(params: {[name: string]: any}): AnimationOptions { + return {params}; +} diff --git a/packages/animations/browser/test/dsl/animation_trigger_spec.ts b/packages/animations/browser/test/dsl/animation_trigger_spec.ts index 4cc916114f..6f1d3eb4bf 100644 --- a/packages/animations/browser/test/dsl/animation_trigger_spec.ts +++ b/packages/animations/browser/test/dsl/animation_trigger_spec.ts @@ -6,17 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import {animate, state, style, transition, trigger} from '@angular/animations'; -import {buildTrigger} from '../../src/dsl/animation_trigger'; +import {AnimationOptions, animate, state, style, transition} from '@angular/animations'; +import {AnimationTransitionInstruction} from '@angular/animations/browser/src/dsl/animation_transition_instruction'; +import {AnimationTrigger} from '@angular/animations/browser/src/dsl/animation_trigger'; -function makeTrigger(name: string, steps: any) { - const triggerData = trigger(name, steps); - const triggerInstance = buildTrigger(triggerData.name, triggerData.definitions); - return triggerInstance; -} +import {makeTrigger} from '../shared'; export function main() { describe('AnimationTrigger', () => { + // these tests are only mean't to be run within the DOM (for now) + if (typeof Element == 'undefined') return; + + let element: any; + beforeEach(() => { + element = document.createElement('div'); + document.body.appendChild(element); + }); + + afterEach(() => { document.body.removeChild(element); }); + describe('trigger validation', () => { it('should group errors together for an animation trigger', () => { expect(() => { @@ -64,7 +72,7 @@ export function main() { const result = makeTrigger( 'name', [transition('a => b', animate(1234)), transition('b => c', animate(5678))]); - const trans = result.matchTransition('b', 'c') !; + const trans = buildTransition(result, element, 'b', 'c') !; expect(trans.timelines.length).toEqual(1); const timeline = trans.timelines[0]; expect(timeline.duration).toEqual(5678); @@ -76,99 +84,148 @@ export function main() { transition('* => *', animate(9999)) ]); - let trans = result.matchTransition('b', 'c') !; + let trans = buildTransition(result, element, 'b', 'c') !; expect(trans.timelines[0].duration).toEqual(5678); - trans = result.matchTransition('a', 'b') !; + trans = buildTransition(result, element, 'a', 'b') !; expect(trans.timelines[0].duration).toEqual(1234); - trans = result.matchTransition('c', 'c') !; + trans = buildTransition(result, element, 'c', 'c') !; expect(trans.timelines[0].duration).toEqual(9999); }); it('should null when no results are found', () => { const result = makeTrigger('name', [transition('a => b', animate(1111))]); - const trans = result.matchTransition('b', 'a'); - expect(trans).toBeFalsy(); + const trigger = result.matchTransition('b', 'a'); + expect(trigger).toBeFalsy(); }); - it('should allow a function to be used as a predicate for the transition', () => { - let returnValue = false; - - const result = makeTrigger('name', [transition((from, to) => returnValue, animate(1111))]); - - expect(result.matchTransition('a', 'b')).toBeFalsy(); - expect(result.matchTransition('1', 2)).toBeFalsy(); - expect(result.matchTransition(false, true)).toBeFalsy(); - - returnValue = true; - - expect(result.matchTransition('a', 'b')).toBeTruthy(); - }); - - it('should call each transition predicate function until the first one that returns true', - () => { - let count = 0; - - function countAndReturn(value: boolean) { - return (fromState: any, toState: any) => { - count++; - return value; - }; - } - - const result = makeTrigger('name', [ - transition(countAndReturn(false), animate(1111)), - transition(countAndReturn(false), animate(2222)), - transition(countAndReturn(true), animate(3333)), - transition(countAndReturn(true), animate(3333)) - ]); - - const trans = result.matchTransition('a', 'b') !; - expect(trans.timelines[0].duration).toEqual(3333); - - expect(count).toEqual(3); - }); - it('should support bi-directional transition expressions', () => { const result = makeTrigger('name', [transition('a <=> b', animate(2222))]); - const t1 = result.matchTransition('a', 'b') !; + const t1 = buildTransition(result, element, 'a', 'b') !; expect(t1.timelines[0].duration).toEqual(2222); - const t2 = result.matchTransition('b', 'a') !; + const t2 = buildTransition(result, element, 'b', 'a') !; expect(t2.timelines[0].duration).toEqual(2222); }); it('should support multiple transition statements in one string', () => { const result = makeTrigger('name', [transition('a => b, b => a, c => *', animate(1234))]); - const t1 = result.matchTransition('a', 'b') !; + const t1 = buildTransition(result, element, 'a', 'b') !; expect(t1.timelines[0].duration).toEqual(1234); - const t2 = result.matchTransition('b', 'a') !; + const t2 = buildTransition(result, element, 'b', 'a') !; expect(t2.timelines[0].duration).toEqual(1234); - const t3 = result.matchTransition('c', 'a') !; + const t3 = buildTransition(result, element, 'c', 'a') !; expect(t3.timelines[0].duration).toEqual(1234); }); + describe('params', () => { + it('should support transition-level animation variable params', () => { + const result = makeTrigger( + 'name', + [transition( + 'a => b', [style({height: '{{ a }}'}), animate(1000, style({height: '{{ b }}'}))], + buildParams({a: '100px', b: '200px'}))]); + + const trans = buildTransition(result, element, 'a', 'b') !; + const keyframes = trans.timelines[0].keyframes; + expect(keyframes).toEqual([{height: '100px', offset: 0}, {height: '200px', offset: 1}]); + }); + + it('should subtitute variable params provided directly within the transition match', () => { + const result = makeTrigger( + 'name', + [transition( + '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 keyframes = trans.timelines[0].keyframes; + expect(keyframes).toEqual([{height: '300px', offset: 0}, {height: '200px', offset: 1}]); + }); + }); + + it('should match `true` and `false` given boolean values', () => { + const result = makeTrigger('name', [ + state('false', style({color: 'red'})), state('true', style({color: 'green'})), + transition('true <=> false', animate(1234)) + ]); + + const trans = buildTransition(result, element, false, true) !; + expect(trans.timelines[0].duration).toEqual(1234); + }); + + it('should match `1` and `0` given boolean values', () => { + const result = makeTrigger('name', [ + state('0', style({color: 'red'})), state('1', style({color: 'green'})), + transition('1 <=> 0', animate(4567)) + ]); + + const trans = buildTransition(result, element, false, true) !; + expect(trans.timelines[0].duration).toEqual(4567); + }); + + it('should match `true` and `false` state styles on a `1 <=> 0` boolean transition given boolean values', + () => { + const result = makeTrigger('name', [ + state('false', style({color: 'red'})), state('true', style({color: 'green'})), + transition('1 <=> 0', animate(4567)) + ]); + + const trans = buildTransition(result, element, false, true) !; + expect(trans.timelines[0].keyframes).toEqual([ + {offset: 0, color: 'red'}, {offset: 1, color: 'green'} + ]) + }); + + it('should match `1` and `0` state styles on a `true <=> false` boolean transition given boolean values', + () => { + const result = makeTrigger('name', [ + state('0', style({color: 'orange'})), state('1', style({color: 'blue'})), + transition('true <=> false', animate(4567)) + ]); + + const trans = buildTransition(result, element, false, true) !; + expect(trans.timelines[0].keyframes).toEqual([ + {offset: 0, color: 'orange'}, {offset: 1, color: 'blue'} + ]) + }); + describe('aliases', () => { it('should alias the :enter transition as void => *', () => { const result = makeTrigger('name', [transition(':enter', animate(3333))]); - const trans = result.matchTransition('void', 'something') !; + const trans = buildTransition(result, element, 'void', 'something') !; expect(trans.timelines[0].duration).toEqual(3333); }); it('should alias the :leave transition as * => void', () => { const result = makeTrigger('name', [transition(':leave', animate(3333))]); - const trans = result.matchTransition('something', 'void') !; + const trans = buildTransition(result, element, 'something', 'void') !; expect(trans.timelines[0].duration).toEqual(3333); }); }); }); }); } + +function buildTransition( + trigger: AnimationTrigger, element: any, fromState: any, toState: any, + params?: AnimationOptions): AnimationTransitionInstruction|null { + const trans = trigger.matchTransition(fromState, toState) !; + if (trans) { + return trans.build(element, fromState, toState, params) !; + } + return null; +} + +function buildParams(params: {[name: string]: any}): AnimationOptions { + return {params}; +} diff --git a/packages/animations/browser/test/engine/timeline_animation_engine_spec.ts b/packages/animations/browser/test/engine/timeline_animation_engine_spec.ts new file mode 100644 index 0000000000..025045b01f --- /dev/null +++ b/packages/animations/browser/test/engine/timeline_animation_engine_spec.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimationMetadata, animate, style} from '@angular/animations'; + +import {AnimationStyleNormalizer, NoopAnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer'; +import {AnimationDriver} from '../../src/render/animation_driver'; +import {TimelineAnimationEngine} from '../../src/render/timeline_animation_engine'; +import {MockAnimationDriver, MockAnimationPlayer} from '../../testing/src/mock_animation_driver'; + +export function main() { + const defaultDriver = new MockAnimationDriver(); + + function makeEngine(driver?: AnimationDriver, normalizer?: AnimationStyleNormalizer) { + return new TimelineAnimationEngine( + driver || defaultDriver, normalizer || new NoopAnimationStyleNormalizer()); + } + + // these tests are only mean't to be run within the DOM + if (typeof Element == 'undefined') return; + + describe('TimelineAnimationEngine', () => { + let element: any; + + beforeEach(() => { + MockAnimationDriver.log = []; + element = document.createElement('div'); + document.body.appendChild(element); + }); + + afterEach(() => document.body.removeChild(element)); + + it('should animate a timeline', () => { + const engine = makeEngine(); + const steps = [style({height: 100}), animate(1000, style({height: 0}))]; + expect(MockAnimationDriver.log.length).toEqual(0); + invokeAnimation(engine, element, steps); + expect(MockAnimationDriver.log.length).toEqual(1); + }); + + it('should not destroy timeline-based animations after they have finished', () => { + const engine = makeEngine(); + + const log: string[] = []; + function capture(value: string) { + return () => { log.push(value); }; + } + + const steps = [style({height: 0}), animate(1000, style({height: 500}))]; + + const player = invokeAnimation(engine, element, steps); + player.onDone(capture('done')); + player.onDestroy(capture('destroy')); + expect(log).toEqual([]); + + player.finish(); + expect(log).toEqual(['done']); + + player.destroy(); + expect(log).toEqual(['done', 'destroy']); + }); + + it('should normalize the style values that are animateTransitioned within an a timeline animation', + () => { + const engine = makeEngine(defaultDriver, new SuffixNormalizer('-normalized')); + + const steps = [ + style({width: '333px'}), + animate(1000, style({width: '999px'})), + ]; + + const player = invokeAnimation(engine, element, steps) as MockAnimationPlayer; + expect(player.keyframes).toEqual([ + {'width-normalized': '333px-normalized', offset: 0}, + {'width-normalized': '999px-normalized', offset: 1} + ]); + }); + + it('should normalize `*` values', () => { + const driver = new SuperMockDriver(); + const engine = makeEngine(driver); + + const steps = [ + style({width: '*'}), + animate(1000, style({width: '999px'})), + ]; + + const player = invokeAnimation(engine, element, steps) as MockAnimationPlayer; + expect(player.keyframes).toEqual([{width: '*star*', offset: 0}, {width: '999px', offset: 1}]); + }); + }); +} + +function invokeAnimation( + engine: TimelineAnimationEngine, element: any, steps: AnimationMetadata | AnimationMetadata[], + id: string = 'id') { + engine.register(id, steps); + return engine.create(id, element); +} + +class SuffixNormalizer extends AnimationStyleNormalizer { + constructor(private _suffix: string) { super(); } + + normalizePropertyName(propertyName: string, errors: string[]): string { + return propertyName + this._suffix; + } + + normalizeStyleValue( + userProvidedProperty: string, normalizedProperty: string, value: string|number, + errors: string[]): string { + return value + this._suffix; + } +} + +class SuperMockDriver extends MockAnimationDriver { + computeStyle(element: any, prop: string, defaultValue?: string): string { return '*star*'; } +} diff --git a/packages/animations/browser/test/engine/dom_animation_engine_spec.ts b/packages/animations/browser/test/engine/transition_animation_engine_spec.ts similarity index 54% rename from packages/animations/browser/test/engine/dom_animation_engine_spec.ts rename to packages/animations/browser/test/engine/transition_animation_engine_spec.ts index 9647d2cd30..f3d6ad7dc1 100644 --- a/packages/animations/browser/test/engine/dom_animation_engine_spec.ts +++ b/packages/animations/browser/test/engine/transition_animation_engine_spec.ts @@ -5,20 +5,16 @@ * 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 {AnimationEvent, NoopAnimationPlayer, animate, keyframes, state, style, transition, trigger} from '@angular/animations'; -import {el} from '@angular/platform-browser/testing/src/browser_util'; +import {AnimationEvent, AnimationMetadata, AnimationTriggerMetadata, NoopAnimationPlayer, animate, state, style, transition, trigger} from '@angular/animations'; -import {buildAnimationKeyframes} from '../../src/dsl/animation_timeline_visitor'; +import {TriggerAst} from '../../src/dsl/animation_ast'; +import {buildAnimationAst} from '../../src/dsl/animation_ast_builder'; import {buildTrigger} from '../../src/dsl/animation_trigger'; import {AnimationStyleNormalizer, NoopAnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer'; -import {DomAnimationEngine} from '../../src/render/dom_animation_engine'; +import {TransitionAnimationEngine} from '../../src/render/transition_animation_engine'; import {MockAnimationDriver, MockAnimationPlayer} from '../../testing/src/mock_animation_driver'; -function makeTrigger(name: string, steps: any) { - const triggerData = trigger(name, steps); - const triggerInstance = buildTrigger(triggerData.name, triggerData.definitions); - return triggerInstance; -} +const DEFAULT_NAMESPACE_ID = 'id'; export function main() { const driver = new MockAnimationDriver(); @@ -26,23 +22,30 @@ export function main() { // these tests are only mean't to be run within the DOM if (typeof Element == 'undefined') return; - describe('DomAnimationEngine', () => { + describe('TransitionAnimationEngine', () => { let element: any; beforeEach(() => { MockAnimationDriver.log = []; - element = el('
'); + element = document.createElement('div'); + document.body.appendChild(element); }); + afterEach(() => { document.body.removeChild(element); }); + function makeEngine(normalizer?: AnimationStyleNormalizer) { - return new DomAnimationEngine(driver, normalizer || new NoopAnimationStyleNormalizer()); + const engine = + new TransitionAnimationEngine(driver, normalizer || new NoopAnimationStyleNormalizer()); + engine.createNamespace(DEFAULT_NAMESPACE_ID, element); + return engine; } describe('trigger registration', () => { it('should ignore and not throw an error if the same trigger is registered twice', () => { + // TODO (matsko): ask why this is avoided const engine = makeEngine(); - engine.registerTrigger(trigger('trig', [])); - expect(() => { engine.registerTrigger(trigger('trig', [])); }).not.toThrow(); + registerTrigger(element, engine, trigger('trig', [])); + expect(() => { registerTrigger(element, engine, trigger('trig', [])); }).not.toThrow(); }); }); @@ -54,11 +57,10 @@ export function main() { transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))]) ]); - engine.registerTrigger(trig); - - expect(engine.queuedPlayers.length).toEqual(0); - engine.setProperty(element, 'myTrigger', 'value'); - expect(engine.queuedPlayers.length).toEqual(1); + registerTrigger(element, engine, trig); + setProperty(element, engine, 'myTrigger', 'value'); + engine.flush(); + expect(engine.players.length).toEqual(1); const player = MockAnimationDriver.log.pop() as MockAnimationPlayer; expect(player.keyframes).toEqual([ @@ -66,43 +68,6 @@ export function main() { ]); }); - it('should always invoke an animation even if the property change is not matched', () => { - const engine = makeEngine(); - - const trig = trigger( - 'myTrigger', - [transition( - 'yes => no', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]); - - engine.registerTrigger(trig); - expect(engine.queuedPlayers.length).toEqual(0); - - engine.setProperty(element, 'myTrigger', 'no'); - expect(engine.queuedPlayers.length).toEqual(1); - expect(engine.queuedPlayers.pop() instanceof NoopAnimationPlayer).toBe(true); - engine.flush(); - - engine.setProperty(element, 'myTrigger', 'yes'); - expect(engine.queuedPlayers.length).toEqual(1); - expect(engine.queuedPlayers.pop() instanceof NoopAnimationPlayer).toBe(true); - }); - - it('should not initialize the animation until the engine has been flushed', () => { - const engine = makeEngine(); - engine.registerTrigger(trigger( - 'trig', [transition('* => something', [animate(1000, style({color: 'gold'}))])])); - - engine.setProperty(element, 'trig', 'something'); - const player = engine.queuedPlayers.pop() as MockAnimationPlayer; - - let initialized = false; - player.onInit(() => initialized = true); - - expect(initialized).toBe(false); - engine.flush(); - expect(initialized).toBe(true); - }); - it('should not queue an animation if the property value has not changed at all', () => { const engine = makeEngine(); @@ -110,25 +75,71 @@ export function main() { transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))]) ]); - engine.registerTrigger(trig); - expect(engine.queuedPlayers.length).toEqual(0); + registerTrigger(element, engine, trig); + engine.flush(); + expect(engine.players.length).toEqual(0); - engine.setProperty(element, 'myTrigger', 'abc'); - expect(engine.queuedPlayers.length).toEqual(1); + setProperty(element, engine, 'myTrigger', 'abc'); + engine.flush(); + expect(engine.players.length).toEqual(1); - engine.setProperty(element, 'myTrigger', 'abc'); - expect(engine.queuedPlayers.length).toEqual(1); + setProperty(element, engine, 'myTrigger', 'abc'); + engine.flush(); + expect(engine.players.length).toEqual(1); }); it('should throw an error if an animation property without a matching trigger is changed', () => { const engine = makeEngine(); expect(() => { - engine.setProperty(element, 'myTrigger', 'no'); + setProperty(element, engine, 'myTrigger', 'no'); }).toThrowError(/The provided animation trigger "myTrigger" has not been registered!/); }); }); + describe('removal operations', () => { + it('should cleanup all inner state that\'s tied to an element once removed', () => { + const engine = makeEngine(); + + const trig = trigger('myTrigger', [ + transition(':leave', [style({height: '0px'}), animate(1000, style({height: '100px'}))]) + ]); + + registerTrigger(element, engine, trig); + setProperty(element, engine, 'myTrigger', 'value'); + engine.flush(); + + expect(engine.elementContainsData(DEFAULT_NAMESPACE_ID, element)).toBeTruthy(); + + engine.removeNode(DEFAULT_NAMESPACE_ID, element, true); + engine.flush(); + + expect(engine.elementContainsData(DEFAULT_NAMESPACE_ID, element)).toBeTruthy(); + }); + + it('should create and recreate a namespace for a host element with the same component source', + () => { + const engine = makeEngine(); + + const trig = + trigger('myTrigger', [transition('* => *', animate(1234, style({color: 'red'})))]); + + registerTrigger(element, engine, trig); + setProperty(element, engine, 'myTrigger', 'value'); + engine.flush(); + expect((engine.players[0].getRealPlayer() as MockAnimationPlayer).duration) + .toEqual(1234); + + engine.destroy(DEFAULT_NAMESPACE_ID, null); + + registerTrigger(element, engine, trig); + setProperty(element, engine, 'myTrigger', 'value'); + engine.flush(); + expect((engine.players[0].getRealPlayer() as MockAnimationPlayer).duration) + .toEqual(1234); + }); + }); + describe('event listeners', () => { it('should listen to the onStart operation for the animation', () => { const engine = makeEngine(); @@ -138,9 +149,9 @@ export function main() { ]); let count = 0; - engine.registerTrigger(trig); - engine.listen(element, 'myTrigger', 'start', () => count++); - engine.setProperty(element, 'myTrigger', 'value'); + registerTrigger(element, engine, trig); + listen(element, engine, 'myTrigger', 'start', () => count++); + setProperty(element, engine, 'myTrigger', 'value'); expect(count).toEqual(0); engine.flush(); @@ -155,33 +166,31 @@ export function main() { ]); let count = 0; - engine.registerTrigger(trig); - engine.listen(element, 'myTrigger', 'done', () => count++); - engine.setProperty(element, 'myTrigger', 'value'); + registerTrigger(element, engine, trig); + listen(element, engine, 'myTrigger', 'done', () => count++); + setProperty(element, engine, 'myTrigger', 'value'); expect(count).toEqual(0); engine.flush(); expect(count).toEqual(0); - const player = engine.activePlayers.pop() !; - player.finish(); - + engine.players[0].finish(); expect(count).toEqual(1); }); it('should throw an error when an event is listened to that isn\'t supported', () => { const engine = makeEngine(); const trig = trigger('myTrigger', []); - engine.registerTrigger(trig); + registerTrigger(element, engine, trig); - expect(() => { engine.listen(element, 'myTrigger', 'explode', () => {}); }) + expect(() => { listen(element, engine, 'myTrigger', 'explode', () => {}); }) .toThrowError( /The provided animation trigger event "explode" for the animation trigger "myTrigger" is not supported!/); }); it('should throw an error when an event is listened for a trigger that doesn\'t exist', () => { const engine = makeEngine(); - expect(() => { engine.listen(element, 'myTrigger', 'explode', () => {}); }) + expect(() => { listen(element, engine, 'myTrigger', 'explode', () => {}); }) .toThrowError( /Unable to listen on the animation trigger event "explode" because the animation trigger "myTrigger" doesn\'t exist!/); }); @@ -189,8 +198,8 @@ export function main() { it('should throw an error when an undefined event is listened for', () => { const engine = makeEngine(); const trig = trigger('myTrigger', []); - engine.registerTrigger(trig); - expect(() => { engine.listen(element, 'myTrigger', '', () => {}); }) + registerTrigger(element, engine, trig); + expect(() => { listen(element, engine, 'myTrigger', '', () => {}); }) .toThrowError( /Unable to listen on the animation trigger "myTrigger" because the provided event is undefined!/); }); @@ -203,16 +212,16 @@ export function main() { [transition( '* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]); - engine.registerTrigger(trig); + registerTrigger(element, engine, trig); let count = 0; - engine.listen(element, 'myTrigger', 'start', () => count++); + listen(element, engine, 'myTrigger', 'start', () => count++); - engine.setProperty(element, 'myTrigger', '123'); + setProperty(element, engine, 'myTrigger', '123'); engine.flush(); expect(count).toEqual(1); - engine.setProperty(element, 'myTrigger', '456'); + setProperty(element, engine, 'myTrigger', '456'); engine.flush(); expect(count).toEqual(2); }); @@ -224,61 +233,64 @@ export function main() { 'myTrigger1', [transition( '* => 123', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]); - engine.registerTrigger(trig1); + registerTrigger(element, engine, trig1); const trig2 = trigger( 'myTrigger2', [transition( '* => 123', [style({width: '0px'}), animate(1000, style({width: '100px'}))])]); - engine.registerTrigger(trig2); + registerTrigger(element, engine, trig2); let count = 0; - engine.listen(element, 'myTrigger1', 'start', () => count++); + listen(element, engine, 'myTrigger1', 'start', () => count++); - engine.setProperty(element, 'myTrigger1', '123'); + setProperty(element, engine, 'myTrigger1', '123'); engine.flush(); expect(count).toEqual(1); - engine.setProperty(element, 'myTrigger2', '123'); + setProperty(element, engine, 'myTrigger2', '123'); engine.flush(); expect(count).toEqual(1); }); - it('should allow a listener to be deregistered', () => { + it('should allow a listener to be deregistered, but only after a flush occurs', () => { const engine = makeEngine(); const trig = trigger( 'myTrigger', [transition( '* => 123', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]); - engine.registerTrigger(trig); + registerTrigger(element, engine, trig); let count = 0; - const deregisterFn = engine.listen(element, 'myTrigger', 'start', () => count++); - engine.setProperty(element, 'myTrigger', '123'); + const deregisterFn = listen(element, engine, 'myTrigger', 'start', () => count++); + setProperty(element, engine, 'myTrigger', '123'); engine.flush(); expect(count).toEqual(1); deregisterFn(); - engine.setProperty(element, 'myTrigger', '456'); + engine.flush(); + + setProperty(element, engine, 'myTrigger', '456'); engine.flush(); expect(count).toEqual(1); }); it('should trigger a listener callback with an AnimationEvent argument', () => { const engine = makeEngine(); - engine.registerTrigger(trigger( - 'myTrigger', - [transition( - '* => *', [style({height: '0px'}), animate(1234, style({height: '100px'}))])])); + registerTrigger( + element, engine, trigger('myTrigger', [ + transition( + '* => *', [style({height: '0px'}), animate(1234, style({height: '100px'}))]) + ])); // we do this so that the next transition has a starting value that isnt null - engine.setProperty(element, 'myTrigger', '123'); + setProperty(element, engine, 'myTrigger', '123'); engine.flush(); let capture: AnimationEvent = null !; - engine.listen(element, 'myTrigger', 'start', (e) => capture = e); - engine.listen(element, 'myTrigger', 'done', (e) => capture = e); - engine.setProperty(element, 'myTrigger', '456'); + listen(element, engine, 'myTrigger', 'start', e => capture = e); + listen(element, engine, 'myTrigger', 'done', e => capture = e); + setProperty(element, engine, 'myTrigger', '456'); engine.flush(); expect(capture).toEqual({ @@ -291,7 +303,7 @@ export function main() { }); capture = null !; - const player = engine.activePlayers.pop() !; + const player = engine.players.pop() !; player.finish(); expect(capture).toEqual({ @@ -305,102 +317,46 @@ export function main() { }); }); - describe('instructions', () => { - it('should animate a transition instruction', () => { - const engine = makeEngine(); - - const trig = makeTrigger('something', [ - state('on', style({height: 100})), state('off', style({height: 0})), - transition('on => off', animate(9876)) - ]); - - const instruction = trig.matchTransition('on', 'off') !; - - expect(MockAnimationDriver.log.length).toEqual(0); - engine.animateTransition(element, instruction); - expect(MockAnimationDriver.log.length).toEqual(1); - }); - - it('should animate a timeline instruction', () => { - const engine = makeEngine(); - const timelines = - buildAnimationKeyframes([style({height: 100}), animate(1000, style({height: 0}))]); - expect(MockAnimationDriver.log.length).toEqual(0); - engine.animateTimeline(element, timelines); - expect(MockAnimationDriver.log.length).toEqual(1); - }); - - it('should animate an array of animation instructions', () => { - const engine = makeEngine(); - - const instructions = buildAnimationKeyframes([ - style({height: 100}), animate(1000, style({height: 0})), - animate(1000, keyframes([style({width: 0}), style({width: 1000})])) - ]); - - expect(MockAnimationDriver.log.length).toEqual(0); - engine.animateTimeline(element, instructions); - expect(MockAnimationDriver.log.length).toBeGreaterThan(0); - }); - }); - - describe('removals / insertions', () => { - it('should allow text nodes to be removed through the engine', () => { - const engine = makeEngine(); - const node = document.createTextNode('hello'); - element.appendChild(node); - - let called = false; - engine.onRemove(node, () => called = true); - - expect(called).toBeTruthy(); - }); - - it('should allow text nodes to be inserted through the engine', () => { - const engine = makeEngine(); - const node = document.createTextNode('hello'); - - let called = false; - engine.onInsert(node, () => called = true); - - expect(called).toBeTruthy(); - }); - }); - describe('transition operations', () => { it('should persist the styles on the element as actual styles once the animation is complete', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('on', style({height: '100px'})), state('off', style({height: '0px'})), transition('on => off', animate(9876)) ]); - const instruction = trig.matchTransition('on', 'off') !; - const player = engine.animateTransition(element, instruction); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'on'); + setProperty(element, engine, trig.name, 'off'); + engine.flush(); expect(element.style.height).not.toEqual('0px'); - player.finish(); + engine.players[0].finish(); expect(element.style.height).toEqual('0px'); }); it('should remove all existing state styling from an element when a follow-up transition occurs on the same trigger', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('a', style({height: '100px'})), state('b', style({height: '500px'})), state('c', style({width: '200px'})), transition('* => *', animate(9876)) ]); - const instruction1 = trig.matchTransition('a', 'b') !; - const player1 = engine.animateTransition(element, instruction1); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'a'); + setProperty(element, engine, trig.name, 'b'); + engine.flush(); + const player1 = engine.players[0]; player1.finish(); expect(element.style.height).toEqual('500px'); - const instruction2 = trig.matchTransition('b', 'c') !; - const player2 = engine.animateTransition(element, instruction2); + setProperty(element, engine, trig.name, 'c'); + engine.flush(); + const player2 = engine.players[0]; expect(element.style.height).not.toEqual('500px'); player2.finish(); expect(element.style.width).toEqual('200px'); @@ -410,26 +366,33 @@ export function main() { it('should allow two animation transitions with different triggers to animate in parallel', () => { const engine = makeEngine(); - const trig1 = makeTrigger('something1', [ + const trig1 = trigger('something1', [ state('a', style({width: '100px'})), state('b', style({width: '200px'})), transition('* => *', animate(1000)) ]); - const trig2 = makeTrigger('something2', [ + const trig2 = trigger('something2', [ state('x', style({height: '500px'})), state('y', style({height: '1000px'})), transition('* => *', animate(2000)) ]); + registerTrigger(element, engine, trig1); + registerTrigger(element, engine, trig2); + let doneCount = 0; function doneCallback() { doneCount++; } - const instruction1 = trig1.matchTransition('a', 'b') !; - const instruction2 = trig2.matchTransition('x', 'y') !; - const player1 = engine.animateTransition(element, instruction1); + setProperty(element, engine, trig1.name, 'a'); + setProperty(element, engine, trig1.name, 'b'); + setProperty(element, engine, trig2.name, 'x'); + setProperty(element, engine, trig2.name, 'y'); + engine.flush(); + + const player1 = engine.players[0] !; player1.onDone(doneCallback); expect(doneCount).toEqual(0); - const player2 = engine.animateTransition(element, instruction2); + const player2 = engine.players[1] !; player2.onDone(doneCallback); expect(doneCount).toEqual(0); @@ -446,18 +409,23 @@ export function main() { it('should cancel a previously running animation when a follow-up transition kicks off on the same trigger', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('x', style({opacity: 0})), state('y', style({opacity: .5})), state('z', style({opacity: 1})), transition('* => *', animate(1000)) ]); - const instruction1 = trig.matchTransition('x', 'y') !; - const instruction2 = trig.matchTransition('y', 'z') !; + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'x'); + setProperty(element, engine, trig.name, 'y'); + engine.flush(); expect(parseFloat(element.style.opacity)).not.toEqual(.5); - const player1 = engine.animateTransition(element, instruction1); - const player2 = engine.animateTransition(element, instruction2); + const player1 = engine.players[0]; + setProperty(element, engine, trig.name, 'z'); + engine.flush(); + + const player2 = engine.players[0]; expect(parseFloat(element.style.opacity)).toEqual(.5); @@ -471,65 +439,73 @@ export function main() { it('should pass in the previously running players into the follow-up transition player when cancelled', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('x', style({opacity: 0})), state('y', style({opacity: .5})), state('z', style({opacity: 1})), transition('* => *', animate(1000)) ]); - const instruction1 = trig.matchTransition('x', 'y') !; - const instruction2 = trig.matchTransition('y', 'z') !; - const instruction3 = trig.matchTransition('z', 'x') !; - - const player1 = engine.animateTransition(element, instruction1); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'x'); + setProperty(element, engine, trig.name, 'y'); engine.flush(); + + const player1 = MockAnimationDriver.log.pop() !as MockAnimationPlayer; player1.setPosition(0.5); - const player2 = engine.animateTransition(element, instruction2); + setProperty(element, engine, trig.name, 'z'); + engine.flush(); + + const player2 = MockAnimationDriver.log.pop() !as MockAnimationPlayer; expect(player2.previousPlayers).toEqual([player1]); player2.finish(); - const player3 = engine.animateTransition(element, instruction3); + setProperty(element, engine, trig.name, 'x'); + engine.flush(); + + const player3 = MockAnimationDriver.log.pop() !as MockAnimationPlayer; expect(player3.previousPlayers).toEqual([]); }); it('should cancel all existing players if a removal animation is set to occur', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('m', style({opacity: 0})), state('n', style({opacity: 1})), transition('* => *', animate(1000)) ]); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'm'); + setProperty(element, engine, trig.name, 'n'); + engine.flush(); + let doneCount = 0; function doneCallback() { doneCount++; } - const instruction1 = trig.matchTransition('m', 'n') !; - const instructions2 = - buildAnimationKeyframes([style({height: 0}), animate(1000, style({height: 100}))]) !; - const instruction3 = trig.matchTransition('n', 'void') !; - - const player1 = engine.animateTransition(element, instruction1); + const player1 = engine.players[0]; player1.onDone(doneCallback); - const player2 = engine.animateTimeline(element, instructions2); - player2.onDone(doneCallback); - - engine.flush(); expect(doneCount).toEqual(0); - const player3 = engine.animateTransition(element, instruction3); - expect(doneCount).toEqual(2); + setProperty(element, engine, trig.name, 'void'); + engine.flush(); + + expect(doneCount).toEqual(1); }); it('should only persist styles that exist in the final state styles and not the last keyframe', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('0', style({width: '0px'})), state('1', style({width: '100px'})), transition('* => *', [animate(1000, style({height: '200px'}))]) ]); - const instruction = trig.matchTransition('0', '1') !; - const player = engine.animateTransition(element, instruction); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, '0'); + setProperty(element, engine, trig.name, '1'); + engine.flush(); + + const player = engine.players[0] !; expect(element.style.width).not.toEqual('100px'); player.finish(); @@ -540,104 +516,74 @@ export function main() { it('should default to using styling from the `*` state if a matching state is not found', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('a', style({opacity: 0})), state('*', style({opacity: .5})), transition('* => *', animate(1000)) ]); - const instruction = trig.matchTransition('a', 'z') !; - engine.animateTransition(element, instruction).finish(); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'a'); + setProperty(element, engine, trig.name, 'z'); + engine.flush(); + engine.players[0].finish(); expect(parseFloat(element.style.opacity)).toEqual(.5); }); it('should treat `void` as `void`', () => { const engine = makeEngine(); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('a', style({opacity: 0})), state('void', style({opacity: .8})), transition('* => *', animate(1000)) ]); - const instruction = trig.matchTransition('a', 'void') !; - engine.animateTransition(element, instruction).finish(); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'a'); + setProperty(element, engine, trig.name, 'void'); + engine.flush(); + engine.players[0].finish(); expect(parseFloat(element.style.opacity)).toEqual(.8); }); }); - describe('timeline operations', () => { - it('should not destroy timeline-based animations after they have finished', () => { - const engine = makeEngine(); - - const log: string[] = []; - function capture(value: string) { - return () => { log.push(value); }; - } - - const instructions = - buildAnimationKeyframes([style({height: 0}), animate(1000, style({height: 500}))]); - - const player = engine.animateTimeline(element, instructions); - player.onDone(capture('done')); - player.onDestroy(capture('destroy')); - expect(log).toEqual([]); - - player.finish(); - expect(log).toEqual(['done']); - - player.destroy(); - expect(log).toEqual(['done', 'destroy']); - }); - }); - describe('style normalizer', () => { it('should normalize the style values that are animateTransitioned within an a transition animation', () => { const engine = makeEngine(new SuffixNormalizer('-normalized')); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('on', style({height: 100})), state('off', style({height: 0})), transition('on => off', animate(9876)) ]); - const instruction = trig.matchTransition('on', 'off') !; - const player = engine.animateTransition(element, instruction); + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'on'); + setProperty(element, engine, trig.name, 'off'); + engine.flush(); + const player = MockAnimationDriver.log.pop() as MockAnimationPlayer; expect(player.keyframes).toEqual([ {'height-normalized': '100-normalized', offset: 0}, {'height-normalized': '0-normalized', offset: 1} ]); }); - it('should normalize the style values that are animateTransitioned within an a timeline animation', - () => { - const engine = makeEngine(new SuffixNormalizer('-normalized')); - - const instructions = buildAnimationKeyframes([ - style({width: '333px'}), - animate(1000, style({width: '999px'})), - ]); - - const player = engine.animateTimeline(element, instructions); - expect(player.keyframes).toEqual([ - {'width-normalized': '333px-normalized', offset: 0}, - {'width-normalized': '999px-normalized', offset: 1} - ]); - }); - it('should throw an error when normalization fails within a transition animation', () => { const engine = makeEngine(new ExactCssValueNormalizer({left: '100px'})); - const trig = makeTrigger('something', [ + const trig = trigger('something', [ state('a', style({left: '0px', width: '200px'})), state('b', style({left: '100px', width: '100px'})), transition('a => b', animate(9876)) ]); - const instruction = trig.matchTransition('a', 'b') !; + registerTrigger(element, engine, trig); + setProperty(element, engine, trig.name, 'a'); + setProperty(element, engine, trig.name, 'b'); let errorMessage = ''; try { - engine.animateTransition(element, instruction); + engine.flush(); } catch (e) { errorMessage = e.toString(); } @@ -652,15 +598,18 @@ export function main() { it('should perform insert operations immediately ', () => { const engine = makeEngine(); - let container = el('
'); - let child1 = el('
'); - let child2 = el('
'); + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + element.appendChild(child1); + element.appendChild(child2); - engine.onInsert(container, () => container.appendChild(child1)); - engine.onInsert(container, () => container.appendChild(child2)); + element.appendChild(child1); + engine.insertNode(DEFAULT_NAMESPACE_ID, child1, element, true); + element.appendChild(child2); + engine.insertNode(DEFAULT_NAMESPACE_ID, child2, element, true); - expect(container.contains(child1)).toBe(true); - expect(container.contains(child2)).toBe(true); + expect(element.contains(child1)).toBe(true); + expect(element.contains(child2)).toBe(true); }); }); }); @@ -700,3 +649,27 @@ class ExactCssValueNormalizer extends AnimationStyleNormalizer { return expectedValue; } } + +function registerTrigger( + element: any, engine: TransitionAnimationEngine, metadata: AnimationTriggerMetadata, + id: string = DEFAULT_NAMESPACE_ID) { + const errors: any[] = []; + const name = metadata.name; + const ast = buildAnimationAst(metadata as AnimationMetadata, errors) as TriggerAst; + if (errors.length) { + } + const trigger = buildTrigger(name, ast); + engine.register(id, element, name, trigger) +} + +function setProperty( + element: any, engine: TransitionAnimationEngine, property: string, value: any, + id: string = DEFAULT_NAMESPACE_ID) { + engine.trigger(id, element, property, value); +} + +function listen( + element: any, engine: TransitionAnimationEngine, eventName: string, phaseName: string, + callback: (event: any) => any, id: string = DEFAULT_NAMESPACE_ID) { + return engine.listen(id, element, eventName, phaseName, callback); +} diff --git a/packages/animations/browser/test/render/web_animations_player_spec.ts b/packages/animations/browser/test/render/web_animations_player_spec.ts deleted file mode 100644 index 93c56f6c15..0000000000 --- a/packages/animations/browser/test/render/web_animations_player_spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * 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 {DOMAnimation} from '../../src/render/web_animations/dom_animation'; -import {WebAnimationsPlayer} from '../../src/render/web_animations/web_animations_player'; - -export function main() { - describe('WebAnimationsPlayer', function() { - // these tests are only mean't to be run within the DOM - if (typeof Element == 'undefined') return; - - let element: any; - beforeEach(() => { - element = document.createElement('div'); - document.body.appendChild(element); - }); - - afterEach(() => { document.body.removeChild(element); }); - - it('should properly balance any previous player styles into the animation keyframes', () => { - element.style.height = '666px'; - element.style.width = '333px'; - - const prevPlayer1 = new MockWebAnimationsPlayer( - element, [{width: '0px', offset: 0}, {width: '200px', offset: 1}], {}); - prevPlayer1.play(); - prevPlayer1.finish(); - - const prevPlayer2 = new MockWebAnimationsPlayer( - element, [{height: '0px', offset: 0}, {height: '200px', offset: 1}], {}); - prevPlayer2.play(); - prevPlayer2.finish(); - - // what needs to happen here is the player below should - // examine which styles are present in the provided previous - // players and use them as input data for the keyframes of - // the new player. Given that the players are in their finished - // state, the styles are copied over as the starting keyframe - // for the animation and if the styles are missing in later keyframes - // then the styling is resolved by computing the styles - const player = new MockWebAnimationsPlayer( - element, [{width: '100px', offset: 0}, {width: '500px', offset: 1}], {}, - [prevPlayer1, prevPlayer2]); - - player.init(); - expect(player.capturedKeyframes).toEqual([ - {height: '200px', width: '200px', offset: 0}, - {height: '666px', width: '500px', offset: 1} - ]); - }); - }); -} - -class MockWebAnimationsPlayer extends WebAnimationsPlayer { - capturedKeyframes: any[]; - - _triggerWebAnimation(element: any, keyframes: any[], options: any): any { - this.capturedKeyframes = keyframes; - return new MockDOMAnimation(); - } -} - -class MockDOMAnimation implements DOMAnimation { - onfinish = (callback: (e: any) => any) => {}; - position = 0; - currentTime = 0; - - cancel(): void {} - play(): void {} - pause(): void {} - finish(): void {} - addEventListener(eventName: string, handler: (event: any) => any): any { return null; } - dispatchEvent(eventName: string): any { return null; } -} diff --git a/packages/animations/browser/test/shared.ts b/packages/animations/browser/test/shared.ts new file mode 100644 index 0000000000..bff558cf74 --- /dev/null +++ b/packages/animations/browser/test/shared.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {trigger} from '@angular/animations'; + +import {TriggerAst} from '../src/dsl/animation_ast'; +import {buildAnimationAst} from '../src/dsl/animation_ast_builder'; +import {AnimationTrigger, buildTrigger} from '../src/dsl/animation_trigger'; + +export function makeTrigger( + name: string, steps: any, skipErrors: boolean = false): AnimationTrigger { + const errors: any[] = []; + const triggerData = trigger(name, steps); + const triggerAst = buildAnimationAst(triggerData, errors) as TriggerAst; + if (!skipErrors && errors.length) { + const LINE_START = '\n - '; + throw new Error( + `Animation parsing for the ${name} trigger have failed:${LINE_START}${errors.join(LINE_START)}`); + } + return buildTrigger(name, triggerAst); +} diff --git a/packages/animations/browser/testing/src/mock_animation_driver.ts b/packages/animations/browser/testing/src/mock_animation_driver.ts index 943cc749a7..be0f43291f 100644 --- a/packages/animations/browser/testing/src/mock_animation_driver.ts +++ b/packages/animations/browser/testing/src/mock_animation_driver.ts @@ -15,6 +15,10 @@ import {AnimationDriver} from '../../src/render/animation_driver'; export class MockAnimationDriver implements AnimationDriver { static log: AnimationPlayer[] = []; + computeStyle(element: any, prop: string, defaultValue?: string): string { + return defaultValue || ''; + } + animate( element: any, keyframes: {[key: string]: string | number}[], duration: number, delay: number, easing: string, previousPlayers: any[] = []): MockAnimationPlayer { @@ -30,8 +34,10 @@ export class MockAnimationDriver implements AnimationDriver { */ export class MockAnimationPlayer extends NoopAnimationPlayer { private __finished = false; + private __started = false; public previousStyles: {[key: string]: string | number} = {}; private _onInitFns: (() => any)[] = []; + public currentSnapshot: ɵStyleData = {}; constructor( public element: any, public keyframes: {[key: string]: string | number}[], @@ -40,10 +46,12 @@ export class MockAnimationPlayer extends NoopAnimationPlayer { super(); previousPlayers.forEach(player => { if (player instanceof MockAnimationPlayer) { - const styles = player._captureStyles(); - Object.keys(styles).forEach(prop => { this.previousStyles[prop] = styles[prop]; }); + const styles = player.currentSnapshot; + Object.keys(styles).forEach(prop => this.previousStyles[prop] = styles[prop]); } }); + + this.totalTime = delay + duration; } /* @internal */ @@ -66,7 +74,17 @@ export class MockAnimationPlayer extends NoopAnimationPlayer { this.__finished = true; } - private _captureStyles(): {[styleName: string]: string | number} { + /* @internal */ + triggerMicrotask() {} + + play(): void { + super.play(); + this.__started = true; + } + + hasStarted() { return this.__started; } + + beforeDestroy() { const captures: ɵStyleData = {}; Object.keys(this.previousStyles).forEach(prop => { @@ -86,6 +104,6 @@ export class MockAnimationPlayer extends NoopAnimationPlayer { }); } - return captures; + this.currentSnapshot = captures; } } diff --git a/packages/animations/src/animation_builder.ts b/packages/animations/src/animation_builder.ts new file mode 100644 index 0000000000..71224df0e4 --- /dev/null +++ b/packages/animations/src/animation_builder.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimationMetadata, AnimationOptions} from './animation_metadata'; +import {AnimationPlayer} from './players/animation_player'; + +/** + * @experimental Animation support is experimental. + */ +export abstract class AnimationBuilder { + abstract build(animation: AnimationMetadata|AnimationMetadata[]): Animation; +} + +/** + * @experimental Animation support is experimental. + */ +export abstract class Animation { + abstract create(element: any, options?: AnimationOptions): AnimationPlayer; +} diff --git a/packages/animations/src/animation_metadata.ts b/packages/animations/src/animation_metadata.ts index 2d8b85bc63..f9ee31643f 100755 --- a/packages/animations/src/animation_metadata.ts +++ b/packages/animations/src/animation_metadata.ts @@ -16,17 +16,32 @@ export declare type AnimateTimings = { easing: string | null }; +/** + * @experimental Animation support is experimental. + */ +export declare interface AnimationOptions { + delay?: number|string; + duration?: number|string; + params?: {[name: string]: any}; +} + /** * @experimental Animation support is experimental. */ export const enum AnimationMetadataType { - State, - Transition, - Sequence, - Group, - Animate, - KeyframeSequence, - Style + State = 0, + Transition = 1, + Sequence = 2, + Group = 3, + Animate = 4, + Keyframes = 5, + Style = 6, + Trigger = 7, + Reference = 8, + AnimateChild = 9, + AnimateRef = 10, + Query = 11, + Stagger = 12 } /** @@ -42,9 +57,10 @@ export interface AnimationMetadata { type: AnimationMetadataType; } /** * @experimental Animation support is experimental. */ -export interface AnimationTriggerMetadata { +export interface AnimationTriggerMetadata extends AnimationMetadata { name: string; definitions: AnimationMetadata[]; + options: {params?: {[name: string]: any}}|null; } /** @@ -65,8 +81,26 @@ export interface AnimationStateMetadata extends AnimationMetadata { * @experimental Animation support is experimental. */ export interface AnimationTransitionMetadata extends AnimationMetadata { - expr: string|((fromState: string, toState: string) => boolean); + expr: string; animation: AnimationMetadata|AnimationMetadata[]; + options: AnimationOptions|null; +} + +/** + * @experimental Animation support is experimental. + */ +export interface AnimationReferenceMetadata extends AnimationMetadata { + animation: AnimationMetadata|AnimationMetadata[]; + options: AnimationOptions|null; +} + +/** + * @experimental Animation support is experimental. + */ +export interface AnimationQueryMetadata extends AnimationMetadata { + selector: string; + animation: AnimationMetadata|AnimationMetadata[]; + options: AnimationQueryOptions|null; } /** @@ -86,8 +120,8 @@ export interface AnimationKeyframesSequenceMetadata extends AnimationMetadata { * @experimental Animation support is experimental. */ export interface AnimationStyleMetadata extends AnimationMetadata { - styles: {[key: string]: string | number}|{[key: string]: string | number}[]; - offset?: number; + styles: '*'|{[key: string]: string | number}|Array<{[key: string]: string | number}|'*'>; + offset: number|null; } /** @@ -101,13 +135,31 @@ export interface AnimationAnimateMetadata extends AnimationMetadata { styles: AnimationStyleMetadata|AnimationKeyframesSequenceMetadata|null; } +/** + * @experimental Animation support is experimental. + */ +export interface AnimationAnimateChildMetadata extends AnimationMetadata { + options: AnimationOptions|null; +} + +/** + * @experimental Animation support is experimental. + */ +export interface AnimationAnimateRefMetadata extends AnimationMetadata { + animation: AnimationReferenceMetadata; + options: AnimationOptions|null; +} + /** * Metadata representing the entry of animations. Instances of this class are provided via the * animation DSL when the {@link sequence sequence animation function} is called. * * @experimental Animation support is experimental. */ -export interface AnimationSequenceMetadata extends AnimationMetadata { steps: AnimationMetadata[]; } +export interface AnimationSequenceMetadata extends AnimationMetadata { + steps: AnimationMetadata[]; + options: AnimationOptions|null; +} /** * Metadata representing the entry of animations. Instances of this class are provided via the @@ -115,7 +167,18 @@ export interface AnimationSequenceMetadata extends AnimationMetadata { steps: An * * @experimental Animation support is experimental. */ -export interface AnimationGroupMetadata extends AnimationMetadata { steps: AnimationMetadata[]; } +export interface AnimationGroupMetadata extends AnimationMetadata { + steps: AnimationMetadata[]; + options: AnimationOptions|null; +} + +/** +* @experimental Animation support is experimental. +*/ +export interface AnimationStaggerMetadata extends AnimationMetadata { + timings: string|number; + animation: AnimationMetadata|AnimationMetadata[]; +} /** * `trigger` is an animation-specific function that is designed to be used inside of Angular's @@ -169,7 +232,7 @@ export interface AnimationGroupMetadata extends AnimationMetadata { steps: Anima * @experimental Animation support is experimental. */ export function trigger(name: string, definitions: AnimationMetadata[]): AnimationTriggerMetadata { - return {name, definitions}; + return {type: AnimationMetadataType.Trigger, name, definitions, options: {}}; } /** @@ -220,7 +283,7 @@ export function trigger(name: string, definitions: AnimationMetadata[]): Animati export function animate( timings: string | number, styles: AnimationStyleMetadata | AnimationKeyframesSequenceMetadata | null = null): AnimationAnimateMetadata { - return {type: AnimationMetadataType.Animate, styles: styles, timings: timings}; + return {type: AnimationMetadataType.Animate, styles, timings}; } /** @@ -254,8 +317,9 @@ export function animate( * * @experimental Animation support is experimental. */ -export function group(steps: AnimationMetadata[]): AnimationGroupMetadata { - return {type: AnimationMetadataType.Group, steps: steps}; +export function group( + steps: AnimationMetadata[], options: AnimationOptions | null = null): AnimationGroupMetadata { + return {type: AnimationMetadataType.Group, steps, options}; } /** @@ -292,8 +356,9 @@ export function group(steps: AnimationMetadata[]): AnimationGroupMetadata { * * @experimental Animation support is experimental. */ -export function sequence(steps: AnimationMetadata[]): AnimationSequenceMetadata { - return {type: AnimationMetadataType.Sequence, steps: steps}; +export function sequence(steps: AnimationMetadata[], options: AnimationOptions | null = null): + AnimationSequenceMetadata { + return {type: AnimationMetadataType.Sequence, steps, options}; } /** @@ -339,9 +404,9 @@ export function sequence(steps: AnimationMetadata[]): AnimationSequenceMetadata * @experimental Animation support is experimental. */ export function style( - tokens: {[key: string]: string | number} | - Array<{[key: string]: string | number}>): AnimationStyleMetadata { - return {type: AnimationMetadataType.Style, styles: tokens}; + tokens: '*' | {[key: string]: string | number} | + Array<'*'|{[key: string]: string | number}>): AnimationStyleMetadata { + return {type: AnimationMetadataType.Style, styles: tokens, offset: null}; } /** @@ -393,7 +458,7 @@ export function style( * @experimental Animation support is experimental. */ export function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata { - return {type: AnimationMetadataType.State, name: name, styles: styles}; + return {type: AnimationMetadataType.State, name, styles}; } /** @@ -442,7 +507,7 @@ export function state(name: string, styles: AnimationStyleMetadata): AnimationSt * @experimental Animation support is experimental. */ export function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSequenceMetadata { - return {type: AnimationMetadataType.KeyframeSequence, steps: steps}; + return {type: AnimationMetadataType.Keyframes, steps}; } /** @@ -553,7 +618,59 @@ export function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSe * @experimental Animation support is experimental. */ export function transition( - stateChangeExpr: string | ((fromState: string, toState: string) => boolean), - steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata { - return {type: AnimationMetadataType.Transition, expr: stateChangeExpr, animation: steps}; + stateChangeExpr: string, steps: AnimationMetadata | AnimationMetadata[], + options: AnimationOptions | null = null): AnimationTransitionMetadata { + return {type: AnimationMetadataType.Transition, expr: stateChangeExpr, animation: steps, options}; +} + +/** + * @experimental Animation support is experimental. + */ +export function animation( + steps: AnimationMetadata | AnimationMetadata[], + options: AnimationOptions | null = null): AnimationReferenceMetadata { + return {type: AnimationMetadataType.Reference, animation: steps, options}; +} + +/** + * @experimental Animation support is experimental. + */ +export function animateChild(options: AnimationOptions | null = null): + AnimationAnimateChildMetadata { + return {type: AnimationMetadataType.AnimateChild, options}; +} + +/** + * @experimental Animation support is experimental. + */ +export function useAnimation( + animation: AnimationReferenceMetadata, + options: AnimationOptions | null = null): AnimationAnimateRefMetadata { + return {type: AnimationMetadataType.AnimateRef, animation, options}; +} + +/** + * @experimental Animation support is experimental. + */ +export declare interface AnimationQueryOptions extends AnimationOptions { + optional?: boolean; + limit?: number; +} + +/** + * @experimental Animation support is experimental. + */ +export function query( + selector: string, animation: AnimationMetadata | AnimationMetadata[], + options: AnimationQueryOptions | null = null): AnimationQueryMetadata { + return {type: AnimationMetadataType.Query, selector, animation, options}; +} + +/** + * @experimental Animation support is experimental. + */ +export function stagger( + timings: string | number, + animation: AnimationMetadata | AnimationMetadata[]): AnimationStaggerMetadata { + return {type: AnimationMetadataType.Stagger, timings, animation}; } diff --git a/packages/animations/src/animations.ts b/packages/animations/src/animations.ts index 9ccc23a5d0..71d819278f 100644 --- a/packages/animations/src/animations.ts +++ b/packages/animations/src/animations.ts @@ -11,8 +11,9 @@ * @description * Entry point for all animation APIs of the animation package. */ +export {Animation, AnimationBuilder} from './animation_builder'; export {AnimationEvent} from './animation_event'; -export {AUTO_STYLE, AnimateTimings, AnimationAnimateMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationSequenceMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata, animate, group, keyframes, sequence, state, style, transition, trigger, ɵStyleData} from './animation_metadata'; +export {AUTO_STYLE, AnimateTimings, AnimationAnimateChildMetadata, AnimationAnimateMetadata, AnimationAnimateRefMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationOptions, AnimationQueryMetadata, AnimationQueryOptions, AnimationReferenceMetadata, AnimationSequenceMetadata, AnimationStaggerMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata, animate, animateChild, animation, group, keyframes, query, sequence, stagger, state, style, transition, trigger, useAnimation, ɵStyleData} from './animation_metadata'; export {AnimationPlayer, NoopAnimationPlayer} from './players/animation_player'; export * from './private_export'; diff --git a/packages/animations/src/players/animation_group_player.ts b/packages/animations/src/players/animation_group_player.ts index aa790524a4..a1ecff2463 100644 --- a/packages/animations/src/players/animation_group_player.ts +++ b/packages/animations/src/players/animation_group_player.ts @@ -18,22 +18,38 @@ export class AnimationGroupPlayer implements AnimationPlayer { private _onDestroyFns: Function[] = []; public parentPlayer: AnimationPlayer|null = null; + public totalTime: number = 0; constructor(private _players: AnimationPlayer[]) { - let count = 0; + let doneCount = 0; + let destroyCount = 0; + let startCount = 0; const total = this._players.length; + if (total == 0) { scheduleMicroTask(() => this._onFinish()); } else { this._players.forEach(player => { player.parentPlayer = this; player.onDone(() => { - if (++count >= total) { + if (++doneCount >= total) { this._onFinish(); } }); + player.onDestroy(() => { + if (++destroyCount >= total) { + this._onDestroy(); + } + }); + player.onStart(() => { + if (++startCount >= total) { + this._onStart(); + } + }); }); } + + this.totalTime = this._players.reduce((time, player) => Math.max(time, player.totalTime), 0); } private _onFinish() { @@ -48,6 +64,14 @@ export class AnimationGroupPlayer implements AnimationPlayer { onStart(fn: () => void): void { this._onStartFns.push(fn); } + private _onStart() { + if (!this.hasStarted()) { + this._onStartFns.forEach(fn => fn()); + this._onStartFns = []; + this._started = true; + } + } + onDone(fn: () => void): void { this._onDoneFns.push(fn); } onDestroy(fn: () => void): void { this._onDestroyFns.push(fn); } @@ -58,11 +82,7 @@ export class AnimationGroupPlayer implements AnimationPlayer { if (!this.parentPlayer) { this.init(); } - if (!this.hasStarted()) { - this._onStartFns.forEach(fn => fn()); - this._onStartFns = []; - this._started = true; - } + this._onStart(); this._players.forEach(player => player.play()); } @@ -75,11 +95,13 @@ export class AnimationGroupPlayer implements AnimationPlayer { this._players.forEach(player => player.finish()); } - destroy(): void { + destroy(): void { this._onDestroy(); } + + private _onDestroy() { if (!this._destroyed) { + this._destroyed = true; this._onFinish(); this._players.forEach(player => player.destroy()); - this._destroyed = true; this._onDestroyFns.forEach(fn => fn()); this._onDestroyFns = []; } @@ -93,7 +115,11 @@ export class AnimationGroupPlayer implements AnimationPlayer { } setPosition(p: number): void { - this._players.forEach(player => { player.setPosition(p); }); + const timeAtPosition = p * this.totalTime; + this._players.forEach(player => { + const position = player.totalTime ? Math.min(1, timeAtPosition / player.totalTime) : 1; + player.setPosition(position); + }); } getPosition(): number { diff --git a/packages/animations/src/players/animation_player.ts b/packages/animations/src/players/animation_player.ts index 234c4a596a..0906f7543d 100644 --- a/packages/animations/src/players/animation_player.ts +++ b/packages/animations/src/players/animation_player.ts @@ -26,6 +26,8 @@ export abstract class AnimationPlayer { abstract getPosition(): number; get parentPlayer(): AnimationPlayer|null { throw new Error('NOT IMPLEMENTED: Base Class'); } set parentPlayer(player: AnimationPlayer|null) { throw new Error('NOT IMPLEMENTED: Base Class'); } + get totalTime(): number { throw new Error('NOT IMPLEMENTED: Base Class'); } + beforeDestroy?: () => any; } /** @@ -39,6 +41,7 @@ export class NoopAnimationPlayer implements AnimationPlayer { private _destroyed = false; private _finished = false; public parentPlayer: AnimationPlayer|null = null; + public totalTime = 0; constructor() {} private _onFinish() { if (!this._finished) { @@ -54,15 +57,20 @@ export class NoopAnimationPlayer implements AnimationPlayer { init(): void {} play(): void { if (!this.hasStarted()) { - scheduleMicroTask(() => this._onFinish()); + this.triggerMicrotask(); this._onStart(); } this._started = true; } + + /* @internal */ + triggerMicrotask() { scheduleMicroTask(() => this._onFinish()); } + private _onStart() { this._onStartFns.forEach(fn => fn()); this._onStartFns = []; } + pause(): void {} restart(): void {} finish(): void { this._onFinish(); } diff --git a/packages/animations/src/private_export.ts b/packages/animations/src/private_export.ts index 635a56c87d..69b3314026 100644 --- a/packages/animations/src/private_export.ts +++ b/packages/animations/src/private_export.ts @@ -6,3 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ export {AnimationGroupPlayer as ɵAnimationGroupPlayer} from './players/animation_group_player'; +export const ɵPRE_STYLE = '!'; diff --git a/packages/core/src/animation/animation_metadata_wrapped.ts b/packages/core/src/animation/animation_metadata_wrapped.ts index b2fb05475d..d8b6da3a37 100644 --- a/packages/core/src/animation/animation_metadata_wrapped.ts +++ b/packages/core/src/animation/animation_metadata_wrapped.ts @@ -53,8 +53,8 @@ export interface AnimationKeyframesSequenceMetadata extends AnimationMetadata { * @deprecated This symbol has moved. Please Import from @angular/animations instead! */ export interface AnimationStyleMetadata extends AnimationMetadata { - styles: {[key: string]: string | number}|{[key: string]: string | number}[]; - offset?: number; + styles: '*'|{[key: string]: string | number}|Array<{[key: string]: string | number}|'*'>; + offset: number|null; } /** @@ -131,9 +131,8 @@ export function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSe /** * @deprecated This symbol has moved. Please Import from @angular/animations instead! */ -export function transition( - stateChangeExpr: string | ((fromState: string, toState: string) => boolean), - steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata { +export function transition(stateChangeExpr: string, steps: AnimationMetadata | AnimationMetadata[]): + AnimationTransitionMetadata { return _transition(stateChangeExpr, steps); } diff --git a/packages/core/test/animation/animation_integration_spec.ts b/packages/core/test/animation/animation_integration_spec.ts index 706d9e821e..1fa6d59166 100644 --- a/packages/core/test/animation/animation_integration_spec.ts +++ b/packages/core/test/animation/animation_integration_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, AnimationEvent, animate, group, keyframes, state, style, transition, trigger} from '@angular/animations'; +import {AUTO_STYLE, AnimationEvent, AnimationOptions, animate, animateChild, group, keyframes, query, state, style, transition, trigger} from '@angular/animations'; import {AnimationDriver, ɵAnimationEngine, ɵNoopAnimationDriver} from '@angular/animations/browser'; import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing'; import {Component, HostBinding, HostListener, RendererFactory2, ViewChild} from '@angular/core'; @@ -15,6 +15,9 @@ import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {TestBed, fakeAsync, flushMicrotasks} from '../../testing'; +const DEFAULT_NAMESPACE_ID = 'id'; +const DEFAULT_COMPONENT_ID = '1'; + export function main() { // these tests are only mean't to be run within the DOM (for now) if (typeof Element == 'undefined') return; @@ -65,6 +68,85 @@ export function main() { ]); }); + it('should not cancel the previous transition if a follow-up transition is not matched', + fakeAsync(() => { + @Component({ + selector: 'if-cmp', + template: ` +
+ `, + animations: [trigger( + 'myAnimation', + [transition( + 'a => b', [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])], + }) + class Cmp { + exp: any; + startEvent: any; + doneEvent: any; + + callback(event: any) { + if (event.phaseName == 'done') { + this.doneEvent = event; + } else { + this.startEvent = event; + } + } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'a'; + fixture.detectChanges(); + engine.flush(); + expect(getLog().length).toEqual(0); + expect(engine.players.length).toEqual(0); + + flushMicrotasks(); + expect(cmp.startEvent.toState).toEqual('a'); + expect(cmp.startEvent.totalTime).toEqual(0); + expect(cmp.startEvent.toState).toEqual('a'); + expect(cmp.startEvent.totalTime).toEqual(0); + resetLog(); + + cmp.exp = 'b'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(1); + expect(engine.players.length).toEqual(1); + + flushMicrotasks(); + expect(cmp.startEvent.toState).toEqual('b'); + expect(cmp.startEvent.totalTime).toEqual(500); + expect(cmp.startEvent.toState).toEqual('b'); + expect(cmp.startEvent.totalTime).toEqual(500); + resetLog(); + + let completed = false; + players[0].onDone(() => completed = true); + + cmp.exp = 'c'; + fixture.detectChanges(); + engine.flush(); + + expect(engine.players.length).toEqual(1); + expect(getLog().length).toEqual(0); + + flushMicrotasks(); + expect(cmp.startEvent.toState).toEqual('c'); + expect(cmp.startEvent.totalTime).toEqual(0); + expect(cmp.startEvent.toState).toEqual('c'); + expect(cmp.startEvent.totalTime).toEqual(0); + + expect(completed).toBe(false); + })); + it('should only turn a view removal as into `void` state transition', () => { @Component({ selector: 'if-cmp', @@ -285,7 +367,7 @@ export function main() { {opacity: '0', offset: 1}, ]); - flushMicrotasks(); + player.finish(); expect(fixture.debugElement.nativeElement.children.length).toBe(0); })); @@ -335,6 +417,7 @@ export function main() { {opacity: '0', offset: 1}, ]); + player.finish(); flushMicrotasks(); expect(fixture.debugElement.nativeElement.children.length).toBe(0); })); @@ -387,15 +470,17 @@ export function main() { const [p1, p2] = getLog(); expect(p1.keyframes).toEqual([ - {height: '100px', offset: 0}, - {height: '0px', offset: 1}, - ]); - - expect(p2.keyframes).toEqual([ {width: '100px', offset: 0}, {width: '0px', offset: 1}, ]); + expect(p2.keyframes).toEqual([ + {height: '100px', offset: 0}, + {height: '0px', offset: 1}, + ]); + + p1.finish(); + p2.finish(); flushMicrotasks(); expect(fixture.debugElement.nativeElement.children.length).toBe(0); })); @@ -621,7 +706,11 @@ export function main() { template: `
`, - animations: [trigger('green', [state('*', style({backgroundColor: 'green'}))])] + animations: [trigger( + 'green', + [ + state('*', style({backgroundColor: 'green'})), transition('* => *', animate(500)) + ])] }) class Cmp { @ViewChild('green') public element: any; @@ -635,7 +724,7 @@ export function main() { fixture.detectChanges(); engine.flush(); - const player = engine.activePlayers.pop(); + const player = engine.players.pop(); player.finish(); expect(getDOM().hasStyle(cmp.element.nativeElement, 'background-color', 'green')) @@ -774,13 +863,219 @@ export function main() { fixture.detectChanges(); engine.flush(); expect(getLog().length).toEqual(0); - resetLog(); }); + + it('should cancel all active inner child animations when a parent removal animation is set to go', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [ + trigger('parent', [transition( + ':leave', + [style({opacity: 0}), animate(1000, style({opacity: 1}))])]), + trigger('child', [transition( + 'a => b', + [style({opacity: 0}), animate(1000, style({opacity: 1}))])]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = true; + cmp.exp2 = 'a'; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp2 = 'b'; + fixture.detectChanges(); + engine.flush(); + + let players = getLog(); + expect(players.length).toEqual(2); + const [p1, p2] = players; + + let count = 0; + p1.onDone(() => count++); + p2.onDone(() => count++); + + cmp.exp1 = false; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(2); + }); + + it('should destroy inner animations when a parent node is set for removal', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [trigger( + 'child', + [transition('a => b', [style({opacity: 0}), animate(1000, style({opacity: 1}))])])] + }) + class Cmp { + public exp: any; + + @ViewChild('parent') public parentElement: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine) as ɵAnimationEngine; + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + const someTrigger = trigger('someTrigger', []); + engine.registerTrigger( + DEFAULT_COMPONENT_ID, DEFAULT_NAMESPACE_ID, fixture.nativeElement, someTrigger.name, + someTrigger); + + cmp.exp = 'a'; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp = 'b'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(2); + const [p1, p2] = players; + + let count = 0; + p1.onDone(() => count++); + p2.onDone(() => count++); + + engine.onRemove(DEFAULT_NAMESPACE_ID, cmp.parentElement.nativeElement, null); + expect(count).toEqual(2); + }); + + it('should always make children wait for the parent animation to finish before any removals occur', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [trigger( + 'parent', + [transition( + 'a => b', [style({opacity: 0}), animate(1000, style({opacity: 1}))])])] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public parent: any; + + @ViewChild('child1') public child1Elm: any; + + @ViewChild('child2') public child2Elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'a'; + cmp.exp2 = true; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp1 = 'b'; + fixture.detectChanges(); + engine.flush(); + + const player = getLog()[0]; + const p = cmp.parent.nativeElement; + const c1 = cmp.child1Elm.nativeElement; + const c2 = cmp.child2Elm.nativeElement; + + expect(p.contains(c1)).toBeTruthy(); + expect(p.contains(c2)).toBeTruthy(); + + cmp.exp2 = false; + fixture.detectChanges(); + engine.flush(); + + expect(p.contains(c1)).toBeTruthy(); + expect(p.contains(c2)).toBeTruthy(); + + player.finish(); + + expect(p.contains(c1)).toBeFalsy(); + expect(p.contains(c2)).toBeFalsy(); + }); + + it('should substitute in values if the provided state match is an object with values', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ `, + animations: [trigger( + 'myAnimation', + [transition( + 'a => b', + [style({opacity: '{{ start }}'}), animate(1000, style({opacity: '{{ end }}'}))], + buildParams({start: '0', end: '1'}))])] + }) + 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 = {value: 'a'}; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp = {value: 'b', params: {start: .3, end: .6}}; + fixture.detectChanges(); + engine.flush(); + const player = getLog().pop() !; + expect(player.keyframes).toEqual([ + {opacity: '0.3', offset: 0}, {opacity: '0.6', offset: 1} + ]); + }); + }); describe('animation listeners', () => { it('should trigger a `start` state change listener for when the animation changes state from void => state', - () => { + fakeAsync(() => { @Component({ selector: 'if-cmp', template: ` @@ -806,17 +1101,17 @@ export function main() { const cmp = fixture.componentInstance; cmp.exp = 'true'; fixture.detectChanges(); - engine.flush(); + flushMicrotasks(); expect(cmp.event.triggerName).toEqual('myAnimation'); expect(cmp.event.phaseName).toEqual('start'); expect(cmp.event.totalTime).toEqual(500); expect(cmp.event.fromState).toEqual('void'); expect(cmp.event.toState).toEqual('true'); - }); + })); it('should trigger a `done` state change listener for when the animation changes state from a => b', - () => { + fakeAsync(() => { @Component({ selector: 'if-cmp', template: ` @@ -846,75 +1141,79 @@ export function main() { expect(cmp.event).toBeFalsy(); - const player = engine.activePlayers.pop(); + const player = engine.players.pop(); player.finish(); + flushMicrotasks(); expect(cmp.event.triggerName).toEqual('myAnimation123'); expect(cmp.event.phaseName).toEqual('done'); expect(cmp.event.totalTime).toEqual(999); expect(cmp.event.fromState).toEqual('void'); expect(cmp.event.toState).toEqual('b'); - }); + })); - it('should handle callbacks for multiple triggers running simultaneously', () => { - @Component({ - selector: 'if-cmp', - template: ` + it('should handle callbacks for multiple triggers running simultaneously', fakeAsync(() => { + @Component({ + selector: 'if-cmp', + template: `
`, - animations: [ - trigger( - 'ani1', - [ - transition( - '* => a', [style({'opacity': '0'}), animate(999, style({'opacity': '1'}))]), - ]), - trigger( - 'ani2', - [ - transition( - '* => b', [style({'width': '0px'}), animate(999, style({'width': '100px'}))]), - ]) - ], - }) - class Cmp { - exp1: any = false; - exp2: any = false; - event1: AnimationEvent; - event2: AnimationEvent; - callback1 = (event: any) => { this.event1 = event; }; - callback2 = (event: any) => { this.event2 = event; }; - } + animations: [ + trigger( + 'ani1', + [ + transition( + '* => a', + [style({'opacity': '0'}), animate(999, style({'opacity': '1'}))]), + ]), + trigger( + 'ani2', + [ + transition( + '* => b', + [style({'width': '0px'}), animate(999, style({'width': '100px'}))]), + ]) + ], + }) + class Cmp { + exp1: any = false; + exp2: any = false; + event1: AnimationEvent; + event2: AnimationEvent; + callback1 = (event: any) => { this.event1 = event; }; + callback2 = (event: any) => { this.event2 = event; }; + } - TestBed.configureTestingModule({declarations: [Cmp]}); + TestBed.configureTestingModule({declarations: [Cmp]}); - const engine = TestBed.get(ɵAnimationEngine); - const fixture = TestBed.createComponent(Cmp); - const cmp = fixture.componentInstance; + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; - cmp.exp1 = 'a'; - cmp.exp2 = 'b'; - fixture.detectChanges(); - engine.flush(); + cmp.exp1 = 'a'; + cmp.exp2 = 'b'; + fixture.detectChanges(); + engine.flush(); - expect(cmp.event1).toBeFalsy(); - expect(cmp.event2).toBeFalsy(); + expect(cmp.event1).toBeFalsy(); + expect(cmp.event2).toBeFalsy(); - const player1 = engine.activePlayers[0]; - const player2 = engine.activePlayers[1]; + const player1 = engine.players[0]; + const player2 = engine.players[1]; - player1.finish(); - expect(cmp.event1.triggerName).toBeTruthy('ani1'); - expect(cmp.event2).toBeFalsy(); + player1.finish(); + player2.finish(); + expect(cmp.event1).toBeFalsy(); + expect(cmp.event2).toBeFalsy(); - player2.finish(); - expect(cmp.event1.triggerName).toBeTruthy('ani1'); - expect(cmp.event2.triggerName).toBeTruthy('ani2'); - }); + flushMicrotasks(); + expect(cmp.event1.triggerName).toBeTruthy('ani1'); + expect(cmp.event2.triggerName).toBeTruthy('ani2'); + })); it('should handle callbacks for multiple triggers running simultaneously on the same element', - () => { + fakeAsync(() => { @Component({ selector: 'if-cmp', template: ` @@ -960,20 +1259,21 @@ export function main() { expect(cmp.event1).toBeFalsy(); expect(cmp.event2).toBeFalsy(); - const player1 = engine.activePlayers[0]; - const player2 = engine.activePlayers[1]; + const player1 = engine.players[0]; + const player2 = engine.players[1]; player1.finish(); - expect(cmp.event1.triggerName).toBeTruthy('ani1'); + player2.finish(); + expect(cmp.event1).toBeFalsy(); expect(cmp.event2).toBeFalsy(); - player2.finish(); + flushMicrotasks(); expect(cmp.event1.triggerName).toBeTruthy('ani1'); expect(cmp.event2.triggerName).toBeTruthy('ani2'); - }); + })); it('should trigger a state change listener for when the animation changes state from void => state on the host element', - () => { + fakeAsync(() => { @Component({ selector: 'my-cmp', template: `...`, @@ -1000,14 +1300,14 @@ export function main() { const cmp = fixture.componentInstance; cmp.exp = 'TRUE'; fixture.detectChanges(); - engine.flush(); + flushMicrotasks(); expect(cmp.event.triggerName).toEqual('myAnimation2'); expect(cmp.event.phaseName).toEqual('start'); expect(cmp.event.totalTime).toEqual(1000); expect(cmp.event.fromState).toEqual('void'); expect(cmp.event.toState).toEqual('TRUE'); - }); + })); it('should always fire callbacks even when a transition is not detected', fakeAsync(() => { @Component({ @@ -1044,7 +1344,8 @@ export function main() { expect(cmp.log).toEqual(['start => b', 'done => b']); })); - it('should fire callback events for leave animations', fakeAsync(() => { + it('should fire callback events for leave animations even if there is no leave transition', + fakeAsync(() => { @Component({ selector: 'my-cmp', template: ` @@ -1082,6 +1383,233 @@ export function main() { expect(cmp.log).toEqual(['start => void', 'done => void']); })); + + it('should fire callbacks on a sub animation once it starts and finishes', fakeAsync(() => { + @Component({ + selector: 'my-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger( + 'parent', + [ + transition( + '* => go', + [ + style({width: '0px'}), + animate(1000, style({width: '100px'})), + query( + '.child', + [ + animateChild({duration: '1s'}), + ]), + animate(1000, style({width: '0px'})), + ]), + ]), + trigger( + 'child', + [ + transition( + '* => go', + [ + style({height: '0px'}), + animate(1000, style({height: '100px'})), + ]), + ]) + ] + }) + class Cmp { + log: string[] = []; + exp1: string; + exp2: string; + + cb(name: string, event: AnimationEvent) { this.log.push(name); } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + cmp.exp1 = 'go'; + cmp.exp2 = 'go'; + fixture.detectChanges(); + engine.flush(); + flushMicrotasks(); + + expect(cmp.log).toEqual(['parent-start', 'child-start']); + cmp.log = []; + + const players = getLog(); + expect(players.length).toEqual(3); + const [p1, p2, p3] = players; + + p1.finish(); + flushMicrotasks(); + expect(cmp.log).toEqual([]); + + p2.finish(); + flushMicrotasks(); + expect(cmp.log).toEqual([]); + + p3.finish(); + flushMicrotasks(); + expect(cmp.log).toEqual(['parent-done', 'child-done']); + })); + + it('should fire callbacks and collect the correct the totalTime and element details for any queried sub animations', + fakeAsync( + () => { + @Component({ + selector: 'my-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [ + trigger('parent', [ + transition('* => go', [ + style({ opacity: 0 }), + animate('1s', style({ opacity: 1 })), + query('.item', [ + style({ opacity: 0 }), + animate(1000, style({ opacity: 1 })) + ]), + query('.item', [ + animateChild({ duration: '1.8s', delay: '300ms' }) + ]) + ]) + ]), + trigger('child', [ + transition(':enter', [ + style({ opacity: 0 }), + animate(1500, style({ opactiy: 1 })) + ]) + ]) + ] + }) + class Cmp { + log: string[] = []; + events: {[name: string]: any} = {}; + exp: string; + items: any = [0, 1, 2, 3]; + + cb(name: string, phase: string, event: AnimationEvent) { + this.log.push(name + '-' + phase); + this.events[name] = event; + } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + flushMicrotasks(); + + expect(cmp.log).toEqual(['c-0-start', 'c-1-start', 'c-2-start', 'c-3-start']); + cmp.log = []; + + const players = getLog(); + // 1 + 4 + 4 = 9 players + expect(players.length).toEqual(9); + + const [pA, pq1a, pq1b, pq1c, pq1d, pq2a, pq2b, pq2c, pq2d] = getLog(); + pA.finish(); + pq1a.finish(); + pq1b.finish(); + pq1c.finish(); + pq1d.finish(); + flushMicrotasks(); + + expect(cmp.log).toEqual([]); + pq2a.finish(); + pq2b.finish(); + pq2c.finish(); + pq2d.finish(); + flushMicrotasks(); + + expect(cmp.log).toEqual( + ['all-done', 'c-0-done', 'c-1-done', 'c-2-done', 'c-3-done']); + + expect(cmp.events['c-0'].totalTime).toEqual(4100); // 1000 + 1000 + 1800 + 300 + expect(cmp.events['c-0'].element.innerText.trim()).toEqual('0'); + expect(cmp.events['c-1'].totalTime).toEqual(4100); + expect(cmp.events['c-1'].element.innerText.trim()).toEqual('1'); + expect(cmp.events['c-2'].totalTime).toEqual(4100); + expect(cmp.events['c-2'].element.innerText.trim()).toEqual('2'); + expect(cmp.events['c-3'].totalTime).toEqual(4100); + expect(cmp.events['c-3'].element.innerText.trim()).toEqual('3'); + })); + }); + + it('should throw neither state() or transition() are used inside of trigger()', () => { + @Component({ + selector: 'if-cmp', + template: ` +
+ `, + animations: [trigger('myAnimation', [animate(1000, style({width: '100px'}))])] + }) + class Cmp { + exp: any = false; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + expect(() => { TestBed.createComponent(Cmp); }) + .toThrowError( + /only state\(\) and transition\(\) definitions can sit inside of a trigger\(\)/); + }); + + it('should not throw an error if styles overlap in separate transitions', () => { + @Component({ + selector: 'if-cmp', + template: ` +
+ `, + animations: [ + trigger( + 'myAnimation', + [ + transition( + 'void => *', + [ + style({opacity: 0}), + animate('0.5s 1s', style({opacity: 1})), + ]), + transition( + '* => void', + [animate(1000, style({height: 0})), animate(1000, style({opacity: 0}))]), + ]), + ] + }) + class Cmp { + exp: any = false; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + expect(() => { TestBed.createComponent(Cmp); }).not.toThrowError(); }); describe('errors for not using the animation module', () => { @@ -1128,3 +1656,7 @@ function assertHasParent(element: any, yes: boolean) { expect(parent).toBeFalsy(); } } + +function buildParams(params: {[name: string]: any}): AnimationOptions { + return {params}; +} diff --git a/packages/core/test/animation/animation_query_integration_spec.ts b/packages/core/test/animation/animation_query_integration_spec.ts new file mode 100644 index 0000000000..869f57a940 --- /dev/null +++ b/packages/core/test/animation/animation_query_integration_spec.ts @@ -0,0 +1,1907 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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, AnimationPlayer, animate, animateChild, query, stagger, state, style, transition, trigger, ɵAnimationGroupPlayer as AnimationGroupPlayer} from '@angular/animations'; +import {AnimationDriver, ɵAnimationEngine} from '@angular/animations/browser'; +import {matchesElement} from '@angular/animations/browser/src/render/shared'; +import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing'; +import {CommonModule} from '@angular/common'; +import {Component, HostBinding, ViewChild} from '@angular/core'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; + +import {HostListener} from '../../src/metadata/directives'; +import {TestBed} from '../../testing'; +import {fakeAsync, flushMicrotasks} from '../../testing/src/fake_async'; + + +export function main() { + // these tests are only mean't to be run within the DOM (for now) + if (typeof Element == 'undefined') return; + + describe('animation query tests', function() { + function getLog(): MockAnimationPlayer[] { + return MockAnimationDriver.log as MockAnimationPlayer[]; + } + + function resetLog() { MockAnimationDriver.log = []; } + + beforeEach(() => { + resetLog(); + TestBed.configureTestingModule({ + providers: [{provide: AnimationDriver, useClass: MockAnimationDriver}], + imports: [BrowserAnimationsModule, CommonModule] + }); + }); + + describe('query()', () => { + it('should be able to query all animation triggers via `@*`', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+
+
+
+ `, + animations: [ + trigger( + 'parent', + [ + transition( + '* => *', + [ + query( + '@*:animating', + [ + animate(1000, style({background: 'red'})), + ]), + ]), + ]), + trigger( + 'a', + [ + transition('* => *', []), + ]), + trigger( + 'b', + [ + transition('* => *', []), + ]), + trigger( + 'c', + [ + transition('* => *', []), + ]) + ] + }) + class Cmp { + public exp0: any; + public exp1: any; + public exp2: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp0 = 1; + cmp.exp1 = 1; + cmp.exp2 = 1; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + const [p1, p2, p3] = players; + + expect(p1.element.classList.contains('a')).toBeTruthy(); + expect(p2.element.classList.contains('b')).toBeTruthy(); + expect(p3.element.classList.contains('c')).toBeTruthy(); + }); + + it('should be able to query triggers directly by name', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+
+
+
+
+ `, + animations: [ + trigger('foo', []), + trigger('bar', []), + trigger( + 'myAnimation', + [ + transition( + '* => foo', + [ + query( + '@foo', + [ + animate(1000, style({color: 'red'})), + ]), + ]), + transition( + '* => bar', + [ + query( + '@bar', + [ + animate(1000, style({color: 'blue'})), + ]), + ]) + ]), + ] + }) + class Cmp { + public exp0: any; + public exp1: any; + public exp2: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp0 = 'foo'; + fixture.detectChanges(); + engine.flush(); + + let players = getLog(); + expect(players.length).toEqual(3); + const [p1, p2, p3] = players; + resetLog(); + + expect(p1.element.classList.contains('f1')).toBeTruthy(); + expect(p2.element.classList.contains('f2')).toBeTruthy(); + expect(p3.element.classList.contains('f3')).toBeTruthy(); + + cmp.exp0 = 'bar'; + fixture.detectChanges(); + engine.flush(); + + players = getLog(); + expect(players.length).toEqual(3); + const [p4, p5, p6] = players; + resetLog(); + + expect(p4.element.classList.contains('b1')).toBeTruthy(); + expect(p5.element.classList.contains('b2')).toBeTruthy(); + expect(p6.element.classList.contains('b3')).toBeTruthy(); + }); + + it('should be able to query all active animations using :animating in a query', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [ + trigger( + 'myAnimation', + [ + transition( + '* => a', + [ + query( + '.item:nth-child(odd)', + [ + style({opacity: 0}), + animate(1000, style({opacity: 1})), + ]), + ]), + transition( + '* => b', + [ + query( + '.item:animating', + [ + style({opacity: 1}), + animate(1000, style({opacity: 0})), + ]), + ]), + ]), + ] + }) + class Cmp { + public exp: any; + public items: number[] = [0, 1, 2, 3, 4]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'a'; + fixture.detectChanges(); + engine.flush(); + + let players = getLog(); + expect(players.length).toEqual(3); + resetLog(); + + cmp.exp = 'b'; + fixture.detectChanges(); + engine.flush(); + + players = getLog(); + expect(players.length).toEqual(3); + expect(players[0].element.classList.contains('e-0')).toBeTruthy(); + expect(players[1].element.classList.contains('e-2')).toBeTruthy(); + expect(players[2].element.classList.contains('e-4')).toBeTruthy(); + }); + + it('should be able to query all actively queued animation triggers via `@*:animating`', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+
+
+
+ `, + animations: [ + trigger( + 'parent', + [ + transition( + '* => *', + [ + query( + '@*:animating', [animate(1000, style({background: 'red'}))], + {optional: true}), + ]), + ]), + trigger( + 'child', + [ + transition('* => *', []), + ]) + ] + }) + class Cmp { + public exp0: any; + public exp1: any; + public exp2: any; + public exp3: any; + public exp4: any; + public exp5: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp0 = 0; + + cmp.exp1 = 0; + cmp.exp2 = 0; + cmp.exp3 = 0; + cmp.exp4 = 0; + cmp.exp5 = 0; + fixture.detectChanges(); + engine.flush(); + + let players = engine.players; + cancelAllPlayers(players); + + cmp.exp0 = 1; + + cmp.exp2 = 1; + cmp.exp4 = 1; + fixture.detectChanges(); + engine.flush(); + + players = engine.players; + cancelAllPlayers(players); + expect(players.length).toEqual(3); + + cmp.exp0 = 2; + + cmp.exp1 = 2; + cmp.exp2 = 2; + cmp.exp3 = 2; + cmp.exp4 = 2; + cmp.exp5 = 2; + fixture.detectChanges(); + engine.flush(); + + players = engine.players; + cancelAllPlayers(players); + expect(players.length).toEqual(6); + + cmp.exp0 = 3; + fixture.detectChanges(); + engine.flush(); + + players = engine.players; + cancelAllPlayers(players); + expect(players.length).toEqual(1); + }); + + it('should collect styles for the same elements between queries', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [ + trigger('myAnimation', [ + transition('* => go', [ + query(':self, header, footer', style({opacity: '0.01'})), + animate(1000, style({opacity: '1'})), + query('header, footer', [ + stagger(500, [ + animate(1000, style({opacity: '1'})) + ]) + ]) + ]) + ]) + ] + }) + class Cmp { + public exp: any; + public items: any[] = [0, 1, 2]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(6); + + const [p1, p2, p3, p4, p5, p6] = players; + + expect(p1.delay).toEqual(0); + expect(p1.duration).toEqual(0); + expect(p1.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '0.01', offset: 1}, + ]); + + expect(p2.delay).toEqual(0); + expect(p2.duration).toEqual(0); + expect(p2.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '0.01', offset: 1}, + ]); + + expect(p3.delay).toEqual(0); + expect(p3.duration).toEqual(0); + expect(p3.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '0.01', offset: 1}, + ]); + + expect(p4.delay).toEqual(0); + expect(p4.duration).toEqual(1000); + expect(p4.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '1', offset: 1}, + ]); + + expect(p5.delay).toEqual(1000); + expect(p5.duration).toEqual(1000); + expect(p5.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '1', offset: 1}, + ]); + + expect(p6.delay).toEqual(1500); + expect(p6.duration).toEqual(1000); + expect(p6.keyframes).toEqual([ + {opacity: '0.01', offset: 0}, + {opacity: '1', offset: 1}, + ]); + }); + + it('should retain style values when :self is used inside of a query', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ `, + animations: [trigger('myAnimation', [transition( + '* => go', + [ + query(':self', style({opacity: '0.5'})), + animate(1000, style({opacity: '1'})) + ])])] + }) + 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 = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(2); + + const [p1, p2] = players; + expect(p1.delay).toEqual(0); + expect(p1.duration).toEqual(0); + expect(p1.keyframes).toEqual([{opacity: '0.5', offset: 0}, {opacity: '0.5', offset: 1}]); + + expect(p2.delay).toEqual(0); + expect(p2.duration).toEqual(1000); + expect(p2.keyframes).toEqual([{opacity: '0.5', offset: 0}, {opacity: '1', offset: 1}]); + }); + + it('should properly apply stagger after various other steps within a query', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+ `, + animations: [ + trigger('myAnimation', [ + transition('* => go', [ + query(':self, header, footer', [ + style({opacity: '0'}), + animate(1000, style({opacity: '0.3'})), + animate(1000, style({opacity: '0.6'})), + stagger(500, [ + animate(1000, style({opacity: '1'})) + ]) + ]) + ]) + ]) + ] + }) + class Cmp { + public exp: any; + public items: any[] = [0, 1, 2]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + + const [p1, p2, p3] = players; + + expect(p1.delay).toEqual(0); + expect(p1.duration).toEqual(3000); + expect(p2.delay).toEqual(0); + expect(p2.duration).toEqual(3500); + expect(p3.delay).toEqual(0); + expect(p3.duration).toEqual(4000); + }); + + it('should apply a full stagger step delay if the timing data is left undefined', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [transition( + '* => go', [query('.item', [stagger('full',[ + style({opacity: 0}), animate(1000, style({opacity: .5})), + animate(500, style({opacity: 1})) + ])])])])] + }) + class Cmp { + public exp: any; + public items: any[] = [0, 1, 2, 3, 4]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(5); + + const [p1, p2, p3, p4, p5] = players; + expect(p1.delay).toEqual(0); + expect(p2.delay).toEqual(1500); + expect(p3.delay).toEqual(3000); + expect(p4.delay).toEqual(4500); + expect(p5.delay).toEqual(6000); + }); + + it('should persist inner sub trigger styles once their animation is complete', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger( + 'parent', + [ + transition( + ':enter', + [ + query( + '.child', + [ + animateChild(), + ]), + ]), + ]), + trigger( + 'child', + [ + state('*, void', style({height: '0px'})), + state('b', style({height: '444px'})), + transition('* => *', animate(500)), + ]), + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = true; + cmp.exp2 = 'b'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(1); + const player = players[0]; + + expect(player.keyframes).toEqual([ + {height: '0px', offset: 0}, {height: '444px', offset: 1} + ]); + player.finish(); + + expect(player.element.style.height).toEqual('444px'); + }); + + it('should find newly inserted items in the component via :enter', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [ + transition( + ':enter', + [ + query( + ':enter', + [ + style({opacity: 0}), + animate(1000, style({opacity: .5})), + ]), + ]), + ])] + }) + class Cmp { + public items: any[] = [0, 1, 2]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + + const [p1, p2, p3] = players; + expect(p1.element.innerText.trim()).toEqual('0'); + expect(p2.element.innerText.trim()).toEqual('1'); + expect(p3.element.innerText.trim()).toEqual('2'); + + players.forEach(p => { + expect(p.keyframes).toEqual([{opacity: '0', offset: 0}, {opacity: '0.5', offset: 1}]); + }); + }); + + it('should find elements that have been removed via :leave', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [ + transition( + 'a => b', + [query(':leave', [style({opacity: 1}), animate(1000, style({opacity: .5}))])]), + ])] + }) + class Cmp { + public exp: any; + public items: any[] = [4, 2, 0]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'a'; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp = 'b'; + cmp.items = []; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + + const [p1, p2, p3] = players; + expect(p1.element.innerText.trim()).toEqual('4'); + expect(p2.element.innerText.trim()).toEqual('2'); + expect(p3.element.innerText.trim()).toEqual('0'); + + players.forEach(p => { + expect(p.keyframes).toEqual([{opacity: '1', offset: 0}, {opacity: '0.5', offset: 1}]); + }); + }); + + it('should properly cancel items that were queried into a former animation', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [ + transition('* => on', [ + query(':enter', [style({opacity: 0}), animate(1000, style({opacity: 1}))]), + query(':enter', [style({width: 0}), animate(1000, style({height: 200}))]) + ]), + transition('* => off', [ + query(':leave', [animate(1000, style({width: 0}))]), + query(':leave', [animate(1000, style({opacity: 0}))]) + ]), + ])] + }) + class Cmp { + public exp: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'on'; + cmp.items = [0, 1, 2, 3, 4]; + fixture.detectChanges(); + engine.flush(); + + const previousPlayers = getLog(); + expect(previousPlayers.length).toEqual(10); + resetLog(); + + cmp.exp = 'off'; + cmp.items = [0, 1, 2]; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(4); + + const [p1, p2, p3, p4] = players; + const [p1p1, p1p2] = p1.previousPlayers; + const [p2p1, p2p2] = p2.previousPlayers; + + expect(p1p1).toBe(previousPlayers[3]); + expect(p1p2).toBe(previousPlayers[8]); + expect(p2p1).toBe(previousPlayers[4]); + expect(p2p2).toBe(previousPlayers[9]); + + expect(p3.previousPlayers).toEqual([]); + expect(p4.previousPlayers).toEqual([]); + }); + + it('should finish queried players in an animation when the next animation takes over', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [ + transition( + '* => on', + [ + query(':enter', [style({opacity: 0}), animate(1000, style({opacity: 1}))]), + ]), + transition('* => off', []) + ])] + }) + class Cmp { + public exp: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'on'; + cmp.items = [0, 1, 2, 3, 4]; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(5); + + let count = 0; + players.forEach(p => { p.onDone(() => count++); }); + + expect(count).toEqual(0); + + cmp.exp = 'off'; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(5); + }); + + it('should finish queried players when the previous player is finished', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [ + transition( + '* => on', + [ + query(':enter', [style({opacity: 0}), animate(1000, style({opacity: 1}))]), + ]), + transition('* => off', []) + ])] + }) + class Cmp { + public exp: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'on'; + cmp.items = [0, 1, 2, 3, 4]; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(5); + + let count = 0; + players.forEach(p => { p.onDone(() => count++); }); + + expect(count).toEqual(0); + + expect(engine.players.length).toEqual(1); + engine.players[0].finish(); + + expect(count).toEqual(5); + }); + + it('should allow multiple triggers to animate on queried elements at the same time', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [ + trigger('one', [ + transition('* => on', [ + query('.child', [ + style({width: '0px'}), + animate(1000, style({width: '100px'})) + ]) + ]), + transition('* => off', []) + ]), + trigger('two', [ + transition('* => on', [ + query('.child:nth-child(odd)', [ + style({height: '0px'}), + animate(1000, style({height: '100px'})) + ]) + ]), + transition('* => off', []) + ]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'on'; + cmp.items = [0, 1, 2, 3, 4]; + fixture.detectChanges(); + engine.flush(); + + let players = getLog(); + expect(players.length).toEqual(5); + + let count = 0; + players.forEach(p => { p.onDone(() => count++); }); + + resetLog(); + + expect(count).toEqual(0); + + cmp.exp2 = 'on'; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(0); + + players = getLog(); + expect(players.length).toEqual(3); + + players.forEach(p => { p.onDone(() => count++); }); + + cmp.exp1 = 'off'; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(5); + + cmp.exp2 = 'off'; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(8); + }); + + it('should not cancel inner queried animations if a trigger state value changes, but isn\'t detected as a valid transition', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [trigger( + 'myAnimation', + [transition( + '* => on', + [ + query(':enter', [style({opacity: 0}), animate(1000, style({opacity: 1}))]), + ])])] + }) + class Cmp { + public exp: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'on'; + cmp.items = [0, 1, 2, 3, 4]; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(5); + + let count = 0; + players.forEach(p => { p.onDone(() => count++); }); + + expect(count).toEqual(0); + + cmp.exp = 'off'; + fixture.detectChanges(); + engine.flush(); + + expect(count).toEqual(0); + }); + + it('should allow for queried items to restore their styling back to the original state via animate(time, "*")', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+ {{ item }} +
+
+ `, + animations: [ + trigger('myAnimation', [ + transition('* => on', [ + query(':enter', [ + style({opacity: '0', width: '0px', height: '0px'}), + animate(1000, style({opacity: '1'})), + animate(1000, style(['*', {height: '200px'}])) + ]) + ]) + ]) + ] + }) + class Cmp { + public exp: any; + public items: any[]; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'on'; + cmp.items = [0, 1, 2]; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + + players.forEach(p => { + expect(p.keyframes).toEqual([ + {opacity: '0', width: '0px', height: '0px', offset: 0}, + {opacity: '1', width: '0px', height: '0px', offset: .5}, + {opacity: AUTO_STYLE, width: AUTO_STYLE, height: '200px', offset: 1} + ]); + }); + }); + }); + + describe('sub triggers', () => { + it('should animate a sub trigger that exists in an inner element in the template', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger('parent', [transition( + '* => go1', + [ + style({width: '0px'}), animate(1000, style({width: '100px'})), + query('.child', [animateChild()]) + ])]), + trigger('child', [transition( + '* => go2', + [ + style({height: '0px'}), + animate(1000, style({height: '100px'})), + ])]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public elm1: any; + + @ViewChild('child') public elm2: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'go1'; + cmp.exp2 = 'go2'; + fixture.detectChanges(); + engine.flush(); + + const elm1 = cmp.elm1; + const elm2 = cmp.elm2; + + const [p1, p2] = getLog(); + expect(p1.delay).toEqual(0); + expect(p1.element).toEqual(elm1.nativeElement); + expect(p1.duration).toEqual(1000); + expect(p1.keyframes).toEqual([{width: '0px', offset: 0}, {width: '100px', offset: 1}]); + + expect(p2.delay).toEqual(0); + expect(p2.element).toEqual(elm2.nativeElement); + expect(p2.duration).toEqual(2000); + expect(p2.keyframes).toEqual([ + {height: '0px', offset: 0}, {height: '0px', offset: .5}, {height: '100px', offset: 1} + ]); + }); + + it('should run and operate a series of triggers on a list of elements with overridden timing data', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger('parent', [transition( + '* => go', + [ + style({opacity: '0'}), animate(1000, style({opacity: '1'})), + query('.item', [animateChild({ duration: '2.5s', delay: '500ms' })]), + animate(1000, style({opacity: '0'})) + ])]), + trigger('child', [transition( + ':enter', + [ + style({height: '0px'}), + animate(1000, style({height: '100px'})), + ])]) + ] + }) + class Cmp { + public exp: any; + public items: any[] = [0, 1, 2, 3, 4]; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + + const parent = cmp.elm.nativeElement; + const elements = parent.querySelectorAll('.item'); + + const players = getLog(); + expect(players.length).toEqual(7); + const [pA, pc1, pc2, pc3, pc4, pc5, pZ] = players; + + expect(pA.element).toEqual(parent); + expect(pA.delay).toEqual(0); + expect(pA.duration).toEqual(1000); + + expect(pc1.element).toEqual(elements[0]); + expect(pc1.delay).toEqual(0); + expect(pc1.duration).toEqual(4000); + + expect(pc2.element).toEqual(elements[1]); + expect(pc2.delay).toEqual(0); + expect(pc2.duration).toEqual(4000); + + expect(pc3.element).toEqual(elements[2]); + expect(pc3.delay).toEqual(0); + expect(pc3.duration).toEqual(4000); + + expect(pc4.element).toEqual(elements[3]); + expect(pc4.delay).toEqual(0); + expect(pc4.duration).toEqual(4000); + + expect(pc5.element).toEqual(elements[4]); + expect(pc5.delay).toEqual(0); + expect(pc5.duration).toEqual(4000); + + expect(pZ.element).toEqual(parent); + expect(pZ.delay).toEqual(4000); + expect(pZ.duration).toEqual(1000); + }); + + it('should silently continue if a sub trigger is animated that doesn\'t exist', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: + [trigger('parent', [transition( + '* => go', + [ + style({opacity: 0}), animate(1000, style({opacity: 1})), + query('.child', [animateChild({duration: '1s'})]), + animate(1000, style({opacity: 0})) + ])])] + }) + class Cmp { + public exp: any; + public items: any[] = [0, 1, 2, 3, 4]; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = 'go'; + fixture.detectChanges(); + engine.flush(); + + const parent = cmp.elm.nativeElement; + const players = getLog(); + expect(players.length).toEqual(2); + + const [pA, pZ] = players; + expect(pA.element).toEqual(parent); + expect(pA.delay).toEqual(0); + expect(pA.duration).toEqual(1000); + + expect(pZ.element).toEqual(parent); + expect(pZ.delay).toEqual(1000); + expect(pZ.duration).toEqual(1000); + }); + + it('should silently continue if a sub trigger is animated that doesn\'t contain a trigger that is setup for animation', + () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger('child', [transition( + 'a => z', + [style({opacity: 0}), animate(1000, style({opacity: 1}))])]), + trigger('parent', [transition( + 'a => z', + [ + style({opacity: 0}), animate(1000, style({opacity: 1})), + query('.child', [animateChild({duration: '1s'})]), + animate(1000, style({opacity: 0})) + ])]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'a'; + cmp.exp2 = 'a'; + fixture.detectChanges(); + engine.flush(); + resetLog(); + + cmp.exp1 = 'z'; + fixture.detectChanges(); + engine.flush(); + + const parent = cmp.elm.nativeElement; + const players = getLog(); + expect(players.length).toEqual(2); + + const [pA, pZ] = players; + expect(pA.element).toEqual(parent); + expect(pA.delay).toEqual(0); + expect(pA.duration).toEqual(1000); + + expect(pZ.element).toEqual(parent); + expect(pZ.delay).toEqual(1000); + expect(pZ.duration).toEqual(1000); + }); + + it('should animate all sub triggers on the element at the same time', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger('w', [ + transition('* => go', [ + style({ width: 0 }), + animate(1800, style({ width: '100px' })) + ]) + ]), + trigger('h', [ + transition('* => go', [ + style({ height: 0 }), + animate(1500, style({ height: '100px' })) + ]) + ]), + trigger('parent', [ + transition('* => go', [ + style({ opacity: 0 }), + animate(1000, style({ opacity: 1 })), + query('.child', [ + animateChild() + ]), + animate(1000, style({ opacity: 0 })) + ]) + ]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'go'; + cmp.exp2 = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(4); + const [pA, pc1, pc2, pZ] = players; + + expect(pc1.delay).toEqual(0); + expect(pc1.duration).toEqual(2800); + + expect(pc2.delay).toEqual(0); + expect(pc2.duration).toEqual(2500); + + expect(pZ.delay).toEqual(2800); + expect(pZ.duration).toEqual(1000); + }); + + it('should skip a sub animation when a zero duration value is passed in', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+ `, + animations: [ + trigger('child', [transition( + '* => go', + [style({width: 0}), animate(1800, style({width: '100px'}))])]), + trigger('parent', [transition( + '* => go', + [ + style({opacity: 0}), animate(1000, style({opacity: 1})), + query('.child', [animateChild({duration: '0'})]), + animate(1000, style({opacity: 0})) + ])]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'go'; + cmp.exp2 = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(2); + const [pA, pZ] = players; + + expect(pA.delay).toEqual(0); + expect(pA.duration).toEqual(1000); + + expect(pZ.delay).toEqual(1000); + expect(pZ.duration).toEqual(1000); + }); + + it('should only allow a sub animation to be used up by a parent trigger once', () => { + @Component({ + selector: 'ani-cmp', + template: ` +
+
+
+
+
+
+ `, + animations: [ + trigger('parent', [transition( + '* => go', + [ + style({opacity: 0}), animate(1000, style({opacity: 1})), + query('.child', animateChild()) + ])]), + trigger('child', [transition( + '* => go', + [style({opacity: 0}), animate(1800, style({opacity: 1}))])]) + ] + }) + class Cmp { + public exp1: any; + public exp2: any; + + @ViewChild('parent') public elm: any; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp1 = 'go'; + cmp.exp2 = 'go'; + fixture.detectChanges(); + engine.flush(); + + const players = getLog(); + expect(players.length).toEqual(3); + + const [p1, p2, p3] = players; + + // parent2 is evaluated first because it is inside of parent1 + expect(p1.element.classList.contains('parent2')).toBeTruthy(); + expect(p2.element.classList.contains('child')).toBeTruthy(); + expect(p3.element.classList.contains('parent1')).toBeTruthy(); + }); + + it('should emulate a leave animation on the nearest sub host elements when a parent is removed', + fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + template: ` +
+ +
+ `, + animations: [ + trigger( + 'leave', + [ + transition(':leave', [animate(1000, style({color: 'gold'}))]), + ]), + trigger( + 'parent', + [ + transition(':leave', [query(':leave', animateChild())]), + ]), + ] + }) + class ParentCmp { + public exp: boolean = true; + @ViewChild('child') public childElm: any; + + public childEvent: any; + + animateStart(event: any) { + if (event.toState == 'void') { + this.childEvent = event; + } + } + } + + @Component({ + selector: 'child-cmp', + template: '...', + animations: [ + trigger( + 'child', + [ + transition(':leave', [animate(1000, style({color: 'gold'}))]), + ]), + ] + }) + class ChildCmp { + public childEvent: any; + + @HostBinding('@child') public animate = true; + + @HostListener('@child.start', ['$event']) + animateStart(event: any) { + if (event.toState == 'void') { + this.childEvent = event; + } + } + } + + TestBed.configureTestingModule({declarations: [ParentCmp, ChildCmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(ParentCmp); + const cmp = fixture.componentInstance; + + fixture.detectChanges(); + engine.flush(); + + const childCmp = cmp.childElm; + + cmp.exp = false; + fixture.detectChanges(); + engine.flush(); + flushMicrotasks(); + + expect(cmp.childEvent.toState).toEqual('void'); + expect(cmp.childEvent.totalTime).toEqual(1000); + expect(childCmp.childEvent.toState).toEqual('void'); + expect(childCmp.childEvent.totalTime).toEqual(1000); + })); + + it('should only mark outermost *directive nodes :enter and :leave when inserts and removals occur', + () => { + @Component({ + selector: 'ani-cmp', + animations: [ + trigger( + 'anim', + [ + transition( + '* => enter', + [ + query(':enter', [animate(1000, style({color: 'red'}))]), + ]), + transition( + '* => leave', + [ + query(':leave', [animate(1000, style({color: 'blue'}))]), + ]), + ]), + ], + template: ` +
+
+
+
+ text +
+
+
+
+
+ text2 +
+
+
+ ` + }) + class Cmp { + public exp: boolean; + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + const container = fixture.elementRef.nativeElement; + + cmp.exp = true; + fixture.detectChanges(); + engine.flush(); + + let players = getLog(); + resetLog(); + expect(players.length).toEqual(2); + const [p1, p2] = players; + + expect(p1.element.classList.contains('a')); + expect(p2.element.classList.contains('d')); + + cmp.exp = false; + fixture.detectChanges(); + engine.flush(); + + players = getLog(); + resetLog(); + expect(players.length).toEqual(2); + const [p3, p4] = players; + + expect(p3.element.classList.contains('a')); + expect(p4.element.classList.contains('d')); + }); + + it('should emulate leave animation callbacks for all sub elements that have leave triggers within the component', + fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + animations: [ + trigger('parent', []), trigger('child', []), + trigger( + 'childWithAnimation', + [ + transition( + ':leave', + [ + animate(1000, style({background: 'red'})), + ]), + ]) + ], + template: ` +
+
+
+
+
+ ` + }) + class Cmp { + public exp: boolean; + public log: string[] = []; + callback(event: any) { + this.log.push(event.element.getAttribute('data-name') + '-' + event.phaseName); + } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + cmp.exp = true; + fixture.detectChanges(); + flushMicrotasks(); + cmp.log = []; + + cmp.exp = false; + fixture.detectChanges(); + flushMicrotasks(); + expect(cmp.log).toEqual([ + 'c1-start', 'c1-done', 'c2-start', 'c2-done', 'p-start', 'p-done', 'c3-start', + 'c3-done' + ]); + })); + + it('should build, but not run sub triggers when a parent animation is scheduled', () => { + @Component({ + selector: 'parent-cmp', + animations: + [trigger('parent', [transition('* => *', [animate(1000, style({opacity: 0}))])])], + template: '
' + }) + class ParentCmp { + public exp: any; + + @ViewChild('child') public childCmp: any; + } + + @Component({ + selector: 'child-cmp', + animations: + [trigger('child', [transition('* => *', [animate(1000, style({color: 'red'}))])])], + template: '
' + }) + class ChildCmp { + public exp: any; + } + + TestBed.configureTestingModule({declarations: [ParentCmp, ChildCmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(ParentCmp); + fixture.detectChanges(); + engine.flush(); + resetLog(); + + const cmp = fixture.componentInstance; + const childCmp = cmp.childCmp; + + cmp.exp = 1; + childCmp.exp = 1; + fixture.detectChanges(); + engine.flush(); + + // we have 2 players, but the child is not used even though + // it is created. + const players = getLog(); + expect(players.length).toEqual(2); + expect(engine.players.length).toEqual(1); + + expect(engine.players[0].getRealPlayer()).toBe(players[1]); + }); + + it('should stretch the starting keyframe of a child animation queries are issued by the parent', + () => { + @Component({ + selector: 'parent-cmp', + animations: [trigger( + 'parent', + [transition( + '* => *', + [animate(1000, style({color: 'red'})), query('@child', animateChild())])])], + template: '
' + }) + class ParentCmp { + public exp: any; + + @ViewChild('child') public childCmp: any; + } + + @Component({ + selector: 'child-cmp', + animations: [trigger( + 'child', + [transition( + '* => *', [style({color: 'blue'}), animate(1000, style({color: 'red'}))])])], + template: '
' + }) + class ChildCmp { + public exp: any; + } + + TestBed.configureTestingModule({declarations: [ParentCmp, ChildCmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(ParentCmp); + fixture.detectChanges(); + engine.flush(); + resetLog(); + + const cmp = fixture.componentInstance; + const childCmp = cmp.childCmp; + + cmp.exp = 1; + childCmp.exp = 1; + fixture.detectChanges(); + engine.flush(); + + expect(engine.players.length).toEqual(1); // child player, parent cover, parent player + const groupPlayer = engine.players[0].getRealPlayer() as AnimationGroupPlayer; + const childPlayer = groupPlayer.players.find(player => { + if (player instanceof MockAnimationPlayer) { + return matchesElement(player.element, '.child'); + } + return false; + }) as MockAnimationPlayer; + + const keyframes = childPlayer.keyframes.map(kf => { + delete kf['offset']; + return kf; + }); + + expect(keyframes.length).toEqual(3); + + const [k1, k2, k3] = keyframes; + expect(k1).toEqual(k2); + }); + + it('should allow a parent trigger to control child triggers across multiple template boundaries even if there are no animations in between', + () => { + @Component({ + selector: 'parent-cmp', + animations: [ + trigger( + 'parentAnimation', + [ + transition( + '* => go', + [ + query(':self, @grandChildAnimation', style({opacity: 0})), + animate(1000, style({opacity: 1})), + query( + '@grandChildAnimation', + [ + animate(1000, style({opacity: 1})), + animateChild(), + ]), + ]), + ]), + ], + template: '
' + }) + class ParentCmp { + public exp: any; + + @ViewChild('child') public innerCmp: any; + } + + @Component( + {selector: 'child-cmp', template: ''}) + class ChildCmp { + @ViewChild('grandchild') public innerCmp: any; + } + + @Component({ + selector: 'grandchild-cmp', + animations: [ + trigger( + 'grandChildAnimation', + [ + transition( + '* => go', + [ + style({width: '0px'}), + animate(1000, style({width: '200px'})), + ]), + ]), + ], + template: '
' + }) + class GrandChildCmp { + public exp: any; + } + + TestBed.configureTestingModule({declarations: [ParentCmp, ChildCmp, GrandChildCmp]}); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(ParentCmp); + fixture.detectChanges(); + engine.flush(); + resetLog(); + + const cmp = fixture.componentInstance; + const grandChildCmp = cmp.innerCmp.innerCmp; + + cmp.exp = 'go'; + grandChildCmp.exp = 'go'; + + fixture.detectChanges(); + engine.flush(); + const players = getLog(); + expect(players.length).toEqual(5); + const [p1, p2, p3, p4, p5] = players; + + expect(p5.keyframes).toEqual([ + {offset: 0, width: '0px'}, {offset: .67, width: '0px'}, {offset: 1, width: '200px'} + ]); + }); + }); + }); +} + +function cancelAllPlayers(players: AnimationPlayer[]) { + players.forEach(p => p.destroy()); +} diff --git a/packages/core/test/animation/animations_with_web_animations_integration_spec.ts b/packages/core/test/animation/animations_with_web_animations_integration_spec.ts index 4b7fe3261a..9a56adb74b 100644 --- a/packages/core/test/animation/animations_with_web_animations_integration_spec.ts +++ b/packages/core/test/animation/animations_with_web_animations_integration_spec.ts @@ -26,25 +26,23 @@ export function main() { }); }); - it('should animate a component that captures height during an animation', () => { + it('should compute pre (!) and post (*) animation styles with different dom states', () => { @Component({ - selector: 'if-cmp', + selector: 'ani-cmp', template: ` -
- hello {{ text }} -
- `, +
+
+ - {{ item }} +
+
+ `, animations: [trigger( 'myAnimation', - [ - transition('* => *', [style({height: '0px'}), animate(1000, style({height: '*'}))]), - ])] + [transition('* => *', [style({height: '!'}), animate(1000, style({height: '*'}))])])] }) class Cmp { - exp: any = false; - text: string; - - @ViewChild('element') public element: any; + public exp: number; + public items = [0, 1, 2, 3, 4]; } TestBed.configureTestingModule({declarations: [Cmp]}); @@ -52,33 +50,36 @@ export function main() { const engine = TestBed.get(ɵAnimationEngine); const fixture = TestBed.createComponent(Cmp); const cmp = fixture.componentInstance; + cmp.exp = 1; - cmp.text = ''; fixture.detectChanges(); engine.flush(); - const element = cmp.element.nativeElement; - element.style.lineHeight = '20px'; - element.style.width = '50px'; + expect(engine.players.length).toEqual(1); + let player = engine.players[0]; + let webPlayer = player.getRealPlayer() as ɵWebAnimationsPlayer; + + expect(webPlayer.keyframes).toEqual([ + {height: '0px', offset: 0}, {height: '100px', offset: 1} + ]); + + // we destroy the player because since it has started and is + // at 0ms duration a height value of `0px` will be extracted + // from the element and passed into the follow-up animation. + player.destroy(); cmp.exp = 2; - cmp.text = '12345'; + cmp.items = [0, 1, 2, 6]; fixture.detectChanges(); engine.flush(); - let player = engine.activePlayers.pop() as ɵWebAnimationsPlayer; - player.setPosition(1); + expect(engine.players.length).toEqual(1); + player = engine.players[0]; + webPlayer = player.getRealPlayer() as ɵWebAnimationsPlayer; - assertStyleBetween(element, 'height', 15, 25); - - cmp.exp = 3; - cmp.text = '12345-12345-12345-12345'; - fixture.detectChanges(); - engine.flush(); - - player = engine.activePlayers.pop() as ɵWebAnimationsPlayer; - player.setPosition(1); - assertStyleBetween(element, 'height', 35, 45); + expect(webPlayer.keyframes).toEqual([ + {height: '100px', offset: 0}, {height: '80px', offset: 1} + ]); }); }); } diff --git a/packages/platform-browser/animations/src/animation_builder.ts b/packages/platform-browser/animations/src/animation_builder.ts new file mode 100644 index 0000000000..97fbe11a3d --- /dev/null +++ b/packages/platform-browser/animations/src/animation_builder.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {Animation, AnimationBuilder, AnimationMetadata, AnimationOptions, AnimationPlayer, NoopAnimationPlayer, sequence} from '@angular/animations'; +import {Injectable, RendererFactory2, RendererType2, ViewEncapsulation} from '@angular/core'; + +import {AnimationRenderer} from './animation_renderer'; + +@Injectable() +export class BrowserAnimationBuilder extends AnimationBuilder { + private _nextAnimationId = 0; + private _renderer: AnimationRenderer; + + constructor(rootRenderer: RendererFactory2) { + super(); + const typeData = { + id: '0', + encapsulation: ViewEncapsulation.None, + styles: [], + data: {animation: []} + } as RendererType2; + this._renderer = rootRenderer.createRenderer(document.body, typeData) as AnimationRenderer; + } + + build(animation: AnimationMetadata|AnimationMetadata[]): Animation { + const id = this._nextAnimationId.toString(); + this._nextAnimationId++; + const entry = Array.isArray(animation) ? sequence(animation) : animation; + issueAnimationCommand(this._renderer, null, id, 'register', [entry]); + return new BrowserAnimation(id, this._renderer); + } +} + +@Injectable() +export class NoopAnimationBuilder extends BrowserAnimationBuilder { + build(animation: AnimationMetadata|AnimationMetadata[]): Animation { return new NoopAnimation(); } +} + +export class BrowserAnimation extends Animation { + constructor(private _id: string, private _renderer: AnimationRenderer) { super(); } + + create(element: any, options?: AnimationOptions): AnimationPlayer { + return new RendererAnimationPlayer(this._id, element, options || {}, this._renderer); + } +} + +export class NoopAnimation extends Animation { + constructor() { super(); } + + create(element: any, options?: AnimationOptions): AnimationPlayer { + return new NoopAnimationPlayer(); + } +} + +export class RendererAnimationPlayer implements AnimationPlayer { + public parentPlayer: AnimationPlayer|null = null; + private _started = false; + + constructor( + public id: string, public element: any, options: AnimationOptions, + private _renderer: AnimationRenderer) { + this._command('create', options); + } + + private _listen(eventName: string, callback: (event: any) => any): () => void { + return this._renderer.listen(this.element, `@@${this.id}:${eventName}`, callback); + } + + private _command(command: string, ...args: any[]) { + return issueAnimationCommand(this._renderer, this.element, this.id, command, args); + } + + onDone(fn: () => void): void { this._listen('done', fn); } + + onStart(fn: () => void): void { this._listen('start', fn); } + + onDestroy(fn: () => void): void { this._listen('destroy', fn); } + + init(): void { this._command('init'); } + + hasStarted(): boolean { return this._started; } + + play(): void { + this._command('play'); + this._started = true; + } + + pause(): void { this._command('pause'); } + + restart(): void { this._command('restart'); } + + finish(): void { this._command('finish'); } + + destroy(): void { this._command('destroy'); } + + reset(): void { this._command('reset'); } + + setPosition(p: number): void { this._command('setPosition', p); } + + getPosition(): number { return 0; } + + public totalTime = 0; +} + +function issueAnimationCommand( + renderer: AnimationRenderer, element: any, id: string, command: string, args: any[]): any { + return renderer.setProperty(element, `@@${id}:${command}`, args); +} diff --git a/packages/platform-browser/animations/src/animation_renderer.ts b/packages/platform-browser/animations/src/animation_renderer.ts index 36d762e1a9..bb75f69028 100644 --- a/packages/platform-browser/animations/src/animation_renderer.ts +++ b/packages/platform-browser/animations/src/animation_renderer.ts @@ -11,18 +11,33 @@ import {Injectable, NgZone, Renderer2, RendererFactory2, RendererStyleFlags2, Re @Injectable() export class AnimationRendererFactory implements RendererFactory2 { + private _currentId: number = 0; + constructor( private delegate: RendererFactory2, private _engine: AnimationEngine, private _zone: NgZone) { + _engine.onRemovalComplete = (element: any, delegate: any) => { + // Note: if an component element has a leave animation, and the component + // a host leave animation, the view engine will call `removeChild` for the parent + // component renderer as well as for the child component renderer. + // Therefore, we need to check if we already removed the element. + if (delegate && delegate.parentNode(element)) { + delegate.removeChild(element.parentNode, element); + } + }; } createRenderer(hostElement: any, type: RendererType2): Renderer2 { let delegate = this.delegate.createRenderer(hostElement, type); if (!hostElement || !type || !type.data || !type.data['animation']) return delegate; - const namespaceId = type.id; + const componentId = type.id; + const namespaceId = type.id + '-' + this._currentId; + this._currentId++; + const animationTriggers = type.data['animation'] as AnimationTriggerMetadata[]; animationTriggers.forEach( - trigger => this._engine.registerTrigger(trigger, namespaceify(namespaceId, trigger.name))); + trigger => this._engine.registerTrigger( + componentId, namespaceId, hostElement, trigger.name, trigger)); return new AnimationRenderer(delegate, this._engine, this._zone, namespaceId); } @@ -31,7 +46,9 @@ export class AnimationRendererFactory implements RendererFactory2 { this.delegate.begin(); } } + end() { + this._zone.runOutsideAngular(() => this._engine.flush()); if (this.delegate.end) { this.delegate.end(); } @@ -40,7 +57,7 @@ export class AnimationRendererFactory implements RendererFactory2 { export class AnimationRenderer implements Renderer2 { public destroyNode: ((node: any) => any)|null = null; - private _flushPromise: Promise|null = null; + private _animationCallbacksBuffer: [(e: any) => any, any][] = []; constructor( public delegate: Renderer2, private _engine: AnimationEngine, private _zone: NgZone, @@ -50,7 +67,10 @@ export class AnimationRenderer implements Renderer2 { get data() { return this.delegate.data; } - destroy(): void { this.delegate.destroy(); } + destroy(): void { + this._engine.destroy(this._namespaceId, this.delegate); + this.delegate.destroy(); + } createElement(name: string, namespace?: string): any { return this.delegate.createElement(name, namespace); @@ -91,32 +111,23 @@ export class AnimationRenderer implements Renderer2 { setValue(node: any, value: string): void { this.delegate.setValue(node, value); } appendChild(parent: any, newChild: any): void { - this._engine.onInsert(newChild, () => this.delegate.appendChild(parent, newChild)); - this._queueFlush(); + this.delegate.appendChild(parent, newChild); + this._engine.onInsert(this._namespaceId, newChild, parent, false); } insertBefore(parent: any, newChild: any, refChild: any): void { - this._engine.onInsert(newChild, () => this.delegate.insertBefore(parent, newChild, refChild)); - this._queueFlush(); + this.delegate.insertBefore(parent, newChild, refChild); + this._engine.onInsert(this._namespaceId, newChild, parent, true); } removeChild(parent: any, oldChild: any): void { - this._engine.onRemove(oldChild, () => { - // Note: if an component element has a leave animation, and the component - // a host leave animation, the view engine will call `removeChild` for the parent - // component renderer as well as for the child component renderer. - // Therefore, we need to check if we already removed the element. - if (this.delegate.parentNode(oldChild)) { - this.delegate.removeChild(parent, oldChild); - } - }); - this._queueFlush(); + this._engine.onRemove(this._namespaceId, oldChild, this.delegate); } setProperty(el: any, name: string, value: any): void { if (name.charAt(0) == '@') { - this._engine.setProperty(el, namespaceify(this._namespaceId, name.substr(1)), value); - this._queueFlush(); + name = name.substr(1); + this._engine.setProperty(this._namespaceId, el, name, value); } else { this.delegate.setProperty(el, name, value); } @@ -126,28 +137,32 @@ export class AnimationRenderer implements Renderer2 { () => void { if (eventName.charAt(0) == '@') { const element = resolveElementFromTarget(target); - const [name, phase] = parseTriggerCallbackName(eventName.substr(1)); - return this._engine.listen( - element, namespaceify(this._namespaceId, name), phase, (event: any) => { - const e = event as any; - if (e.triggerName) { - e.triggerName = deNamespaceify(this._namespaceId, e.triggerName); - } - this._zone.run(() => callback(event)); - }); + let name = eventName.substr(1); + let phase = ''; + if (name.charAt(0) != '@') { // transition-specific + [name, phase] = parseTriggerCallbackName(name); + } + return this._engine.listen(this._namespaceId, element, name, phase, event => { + this._bufferMicrotaskIntoZone(callback, event); + }); } return this.delegate.listen(target, eventName, callback); } - private _queueFlush() { - if (!this._flushPromise) { - this._zone.runOutsideAngular(() => { - this._flushPromise = Promise.resolve(null).then(() => { - this._flushPromise = null !; - this._engine.flush(); + private _bufferMicrotaskIntoZone(fn: (e: any) => any, data: any) { + if (this._animationCallbacksBuffer.length == 0) { + Promise.resolve(null).then(() => { + this._zone.run(() => { + this._animationCallbacksBuffer.forEach(tuple => { + const [fn, data] = tuple; + fn(data); + }); + this._animationCallbacksBuffer = []; }); - }); + }) } + + this._animationCallbacksBuffer.push([fn, data]); } } @@ -170,11 +185,3 @@ function parseTriggerCallbackName(triggerName: string) { const phase = triggerName.substr(dotIndex + 1); return [trigger, phase]; } - -function namespaceify(namespaceId: string, value: string): string { - return `${namespaceId}#${value}`; -} - -function deNamespaceify(namespaceId: string, value: string): string { - return value.replace(namespaceId + '#', ''); -} diff --git a/packages/platform-browser/animations/src/animations.ts b/packages/platform-browser/animations/src/animations.ts index 919817e66e..182f7bca81 100644 --- a/packages/platform-browser/animations/src/animations.ts +++ b/packages/platform-browser/animations/src/animations.ts @@ -12,4 +12,5 @@ * Entry point for all animation APIs of the animation browser package. */ export {BrowserAnimationsModule, NoopAnimationsModule} from './module'; + export * from './private_export'; diff --git a/packages/platform-browser/animations/src/private_export.ts b/packages/platform-browser/animations/src/private_export.ts index f1411716df..c543c50c5f 100644 --- a/packages/platform-browser/animations/src/private_export.ts +++ b/packages/platform-browser/animations/src/private_export.ts @@ -5,4 +5,6 @@ * 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 */ +export {NoopAnimation as ɵNoopAnimation, NoopAnimationBuilder as ɵNoopAnimationBuilder} from './animation_builder'; +export {BrowserAnimation as ɵBrowserAnimation, BrowserAnimationBuilder as ɵBrowserAnimationBuilder} from './animation_builder'; export {AnimationRenderer as ɵAnimationRenderer, AnimationRendererFactory as ɵAnimationRendererFactory} from './animation_renderer'; diff --git a/packages/platform-browser/animations/src/providers.ts b/packages/platform-browser/animations/src/providers.ts index 7c700aa65c..d0bed48f77 100644 --- a/packages/platform-browser/animations/src/providers.ts +++ b/packages/platform-browser/animations/src/providers.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {AnimationDriver, ɵAnimationEngine as AnimationEngine, ɵAnimationStyleNormalizer as AnimationStyleNormalizer, ɵDomAnimationEngine as DomAnimationEngine, ɵNoopAnimationDriver as NoopAnimationDriver, ɵNoopAnimationEngine as NoopAnimationEngine, ɵWebAnimationsDriver as WebAnimationsDriver, ɵWebAnimationsStyleNormalizer as WebAnimationsStyleNormalizer, ɵsupportsWebAnimations as supportsWebAnimations} from '@angular/animations/browser'; +import {AnimationBuilder} from '@angular/animations'; +import {AnimationDriver, ɵAnimationEngine as AnimationEngine, ɵAnimationStyleNormalizer as AnimationStyleNormalizer, ɵDomAnimationEngine as DomAnimationEngine, ɵNoopAnimationDriver as NoopAnimationDriver, ɵNoopAnimationEngine as NoopAnimationEngine, ɵNoopAnimationStyleNormalizer as NoopAnimationStyleNormalizer, ɵWebAnimationsDriver as WebAnimationsDriver, ɵWebAnimationsStyleNormalizer as WebAnimationsStyleNormalizer, ɵsupportsWebAnimations as supportsWebAnimations} from '@angular/animations/browser'; import {Injectable, NgZone, Provider, RendererFactory2} from '@angular/core'; import {ɵDomRendererFactory2 as DomRendererFactory2} from '@angular/platform-browser'; +import {BrowserAnimationBuilder, NoopAnimationBuilder} from './animation_builder'; import {AnimationRendererFactory} from './animation_renderer'; @Injectable() @@ -19,6 +21,13 @@ export class InjectableAnimationEngine extends DomAnimationEngine { } } +@Injectable() +export class InjectableNoopAnimationEngine extends NoopAnimationEngine { + constructor(driver: AnimationDriver, normalizer: AnimationStyleNormalizer) { + super(driver, normalizer); + } +} + export function instantiateSupportedAnimationDriver() { if (supportsWebAnimations()) { return new WebAnimationsDriver(); @@ -40,6 +49,7 @@ export function instantiateRendererFactory( * include them in the BrowserModule. */ export const BROWSER_ANIMATIONS_PROVIDERS: Provider[] = [ + {provide: AnimationBuilder, useClass: NoopAnimationBuilder}, {provide: AnimationDriver, useFactory: instantiateSupportedAnimationDriver}, {provide: AnimationStyleNormalizer, useFactory: instantiateDefaultStyleNormalizer}, {provide: AnimationEngine, useClass: InjectableAnimationEngine}, { @@ -54,7 +64,14 @@ export const BROWSER_ANIMATIONS_PROVIDERS: Provider[] = [ * include them in the BrowserTestingModule. */ export const BROWSER_NOOP_ANIMATIONS_PROVIDERS: Provider[] = [ - {provide: AnimationEngine, useClass: NoopAnimationEngine}, { + {provide: AnimationBuilder, useClass: BrowserAnimationBuilder}, + {provide: AnimationDriver, useClass: NoopAnimationDriver}, + {provide: AnimationStyleNormalizer, useFactory: instantiateDefaultStyleNormalizer}, { + provide: AnimationEngine, + useClass: NoopAnimationEngine, + deps: [AnimationDriver, AnimationStyleNormalizer] + }, + { provide: RendererFactory2, useFactory: instantiateRendererFactory, deps: [DomRendererFactory2, AnimationEngine, NgZone] diff --git a/packages/platform-browser/animations/test/noop_animation_engine_spec.ts b/packages/platform-browser/animations/test/noop_animation_engine_spec.ts index a20fe3c310..c418182801 100644 --- a/packages/platform-browser/animations/test/noop_animation_engine_spec.ts +++ b/packages/platform-browser/animations/test/noop_animation_engine_spec.ts @@ -5,10 +5,19 @@ * 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 {state, style, trigger} from '@angular/animations'; +import {AnimationMetadata, AnimationTriggerMetadata, state, style, trigger} from '@angular/animations'; import {ɵNoopAnimationEngine as NoopAnimationEngine} from '@angular/animations/browser'; +import {NoopAnimationStyleNormalizer} from '@angular/animations/browser/src/dsl/style_normalization/animation_style_normalizer'; +import {MockAnimationDriver} from '@angular/animations/browser/testing'; import {el} from '@angular/platform-browser/testing/src/browser_util'; +import {TriggerAst} from '../../../animations/browser/src/dsl/animation_ast'; +import {buildAnimationAst} from '../../../animations/browser/src/dsl/animation_ast_builder'; +import {buildTrigger} from '../../../animations/browser/src/dsl/animation_trigger'; + +const DEFAULT_NAMESPACE_ID = 'id'; +const DEFAULT_COMPONENT_ID = '1'; + export function main() { describe('NoopAnimationEngine', () => { let captures: string[] = []; @@ -16,19 +25,37 @@ export function main() { beforeEach(() => { captures = []; }); + function makeEngine() { + const driver = new MockAnimationDriver(); + const normalizer = new NoopAnimationStyleNormalizer(); + return new NoopAnimationEngine(driver, normalizer); + } + it('should immediately issue DOM removals during remove animations and then fire the animation callbacks after flush', () => { - const engine = new NoopAnimationEngine(); + const engine = makeEngine(); + const capture1 = capture('1'); + const capture2 = capture('2'); + engine.onRemovalComplete = (element: any, context: any) => { + switch (context as string) { + case '1': + capture1(); + break; + case '2': + capture2(); + break; + } + }; const elm1 = {nodeType: 1}; const elm2 = {nodeType: 1}; - engine.onRemove(elm1, capture('1')); - engine.onRemove(elm2, capture('2')); + engine.onRemove(DEFAULT_NAMESPACE_ID, elm1, '1'); + engine.onRemove(DEFAULT_NAMESPACE_ID, elm2, '2'); - engine.listen(elm1, 'trig', 'start', capture('1-start')); - engine.listen(elm2, 'trig', 'start', capture('2-start')); - engine.listen(elm1, 'trig', 'done', capture('1-done')); - engine.listen(elm2, 'trig', 'done', capture('2-done')); + listen(elm1, engine, 'trig', 'start', capture('1-start')); + listen(elm2, engine, 'trig', 'start', capture('2-start')); + listen(elm1, engine, 'trig', 'done', capture('1-done')); + listen(elm2, engine, 'trig', 'done', capture('2-done')); expect(captures).toEqual(['1', '2']); engine.flush(); @@ -37,17 +64,17 @@ export function main() { }); it('should only fire the `start` listener for a trigger that has had a property change', () => { - const engine = new NoopAnimationEngine(); + const engine = makeEngine(); const elm1 = {}; const elm2 = {}; const elm3 = {}; - engine.listen(elm1, 'trig1', 'start', capture()); - engine.setProperty(elm1, 'trig1', 'cool'); - engine.setProperty(elm2, 'trig2', 'sweet'); - engine.listen(elm2, 'trig2', 'start', capture()); - engine.listen(elm3, 'trig3', 'start', capture()); + listen(elm1, engine, 'trig1', 'start', capture()); + setProperty(elm1, engine, 'trig1', 'cool'); + setProperty(elm2, engine, 'trig2', 'sweet'); + listen(elm2, engine, 'trig2', 'start', capture()); + listen(elm3, engine, 'trig3', 'start', capture()); expect(captures).toEqual([]); engine.flush(); @@ -79,17 +106,17 @@ export function main() { }); it('should only fire the `done` listener for a trigger that has had a property change', () => { - const engine = new NoopAnimationEngine(); + const engine = makeEngine(); const elm1 = {}; const elm2 = {}; const elm3 = {}; - engine.listen(elm1, 'trig1', 'done', capture()); - engine.setProperty(elm1, 'trig1', 'awesome'); - engine.setProperty(elm2, 'trig2', 'amazing'); - engine.listen(elm2, 'trig2', 'done', capture()); - engine.listen(elm3, 'trig3', 'done', capture()); + listen(elm1, engine, 'trig1', 'done', capture()); + setProperty(elm1, engine, 'trig1', 'awesome'); + setProperty(elm2, engine, 'trig2', 'amazing'); + listen(elm2, engine, 'trig2', 'done', capture()); + listen(elm3, engine, 'trig3', 'done', capture()); expect(captures).toEqual([]); engine.flush(); @@ -122,48 +149,49 @@ export function main() { it('should deregister a listener when the return function is called, but only after flush', () => { - const engine = new NoopAnimationEngine(); + const engine = makeEngine(); const elm = {}; - const fn1 = engine.listen(elm, 'trig1', 'start', capture('trig1-start')); - const fn2 = engine.listen(elm, 'trig2', 'done', capture('trig2-done')); + const fn1 = listen(elm, engine, 'trig1', 'start', capture('trig1-start')); + const fn2 = listen(elm, engine, 'trig2', 'done', capture('trig2-done')); - engine.setProperty(elm, 'trig1', 'value1'); - engine.setProperty(elm, 'trig2', 'value2'); + setProperty(elm, engine, 'trig1', 'value1'); + setProperty(elm, engine, 'trig2', 'value2'); engine.flush(); expect(captures).toEqual(['trig1-start', 'trig2-done']); captures = []; - engine.setProperty(elm, 'trig1', 'value3'); - engine.setProperty(elm, 'trig2', 'value4'); + setProperty(elm, engine, 'trig1', 'value3'); + setProperty(elm, engine, 'trig2', 'value4'); fn1(); engine.flush(); expect(captures).toEqual(['trig1-start', 'trig2-done']); captures = []; - engine.setProperty(elm, 'trig1', 'value5'); - engine.setProperty(elm, 'trig2', 'value6'); + setProperty(elm, engine, 'trig1', 'value5'); + setProperty(elm, engine, 'trig2', 'value6'); fn2(); engine.flush(); expect(captures).toEqual(['trig2-done']); captures = []; - engine.setProperty(elm, 'trig1', 'value7'); - engine.setProperty(elm, 'trig2', 'value8'); + setProperty(elm, engine, 'trig1', 'value7'); + setProperty(elm, engine, 'trig2', 'value8'); engine.flush(); expect(captures).toEqual([]); }); it('should fire a removal listener even if the listener is deregistered prior to flush', () => { - const engine = new NoopAnimationEngine(); + const engine = makeEngine(); const elm = {nodeType: 1}; + engine.onRemovalComplete = (element: any, context: string) => { capture(context)(); }; - const fn = engine.listen(elm, 'trig', 'start', capture('removal listener')); + const fn = listen(elm, engine, 'trig', 'start', capture('removal listener')); fn(); - engine.onRemove(elm, capture('dom removal')); + engine.onRemove(DEFAULT_NAMESPACE_ID, elm, 'dom removal'); engine.flush(); expect(captures).toEqual(['dom removal', 'removal listener']); @@ -174,15 +202,15 @@ export function main() { if (typeof Element == 'undefined') return; it('should persist the styles on the element when the animation is complete', () => { - const engine = new NoopAnimationEngine(); - engine.registerTrigger(trigger('matias', [ - state('a', style({width: '100px'})), - ])); - + const engine = makeEngine(); const element = el('
'); + registerTrigger(element, engine, trigger('matias', [ + state('a', style({width: '100px'})), + ])); + expect(element.style.width).not.toEqual('100px'); - engine.setProperty(element, 'matias', 'a'); + setProperty(element, engine, 'matias', 'a'); expect(element.style.width).not.toEqual('100px'); engine.flush(); @@ -191,19 +219,19 @@ export function main() { it('should remove previously persist styles off of the element when a follow-up animation starts', () => { - const engine = new NoopAnimationEngine(); - engine.registerTrigger(trigger('matias', [ - state('a', style({width: '100px'})), - state('b', style({height: '100px'})), - ])); - + const engine = makeEngine(); const element = el('
'); - engine.setProperty(element, 'matias', 'a'); + registerTrigger(element, engine, trigger('matias', [ + state('a', style({width: '100px'})), + state('b', style({height: '100px'})), + ])); + + setProperty(element, engine, 'matias', 'a'); engine.flush(); expect(element.style.width).toEqual('100px'); - engine.setProperty(element, 'matias', 'b'); + setProperty(element, engine, 'matias', 'b'); expect(element.style.width).not.toEqual('100px'); expect(element.style.height).not.toEqual('100px'); @@ -212,17 +240,35 @@ export function main() { }); it('should fall back to `*` styles incase the target state styles are not found', () => { - const engine = new NoopAnimationEngine(); - engine.registerTrigger(trigger('matias', [ - state('*', style({opacity: '0.5'})), - ])); - + const engine = makeEngine(); const element = el('
'); - engine.setProperty(element, 'matias', 'xyz'); + registerTrigger(element, engine, trigger('matias', [ + state('*', style({opacity: '0.5'})), + ])); + + setProperty(element, engine, 'matias', 'xyz'); engine.flush(); expect(element.style.opacity).toEqual('0.5'); }); }); }); } + +function registerTrigger( + element: any, engine: NoopAnimationEngine, metadata: AnimationTriggerMetadata, + namespaceId: string = DEFAULT_NAMESPACE_ID, componentId: string = DEFAULT_COMPONENT_ID) { + engine.registerTrigger(componentId, namespaceId, element, name, metadata) +} + +function setProperty( + element: any, engine: NoopAnimationEngine, property: string, value: any, + id: string = DEFAULT_NAMESPACE_ID) { + engine.setProperty(id, element, property, value); +} + +function listen( + element: any, engine: NoopAnimationEngine, eventName: string, phaseName: string, + callback: (event: any) => any, id: string = DEFAULT_NAMESPACE_ID) { + return engine.listen(id, element, eventName, phaseName, callback); +} diff --git a/packages/platform-browser/test/animation/animation_renderer_spec.ts b/packages/platform-browser/test/animation/animation_renderer_spec.ts index caeb2305d8..977b15aa63 100644 --- a/packages/platform-browser/test/animation/animation_renderer_spec.ts +++ b/packages/platform-browser/test/animation/animation_renderer_spec.ts @@ -6,22 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ import {AnimationPlayer, AnimationTriggerMetadata, animate, state, style, transition, trigger} from '@angular/animations'; -import {ɵAnimationEngine} from '@angular/animations/browser'; -import {Component, Injectable, RendererFactory2, RendererType2, ViewChild} from '@angular/core'; +import {ɵAnimationEngine as AnimationEngine} from '@angular/animations/browser'; +import {Component, Injectable, NgZone, RendererFactory2, RendererType2, ViewChild} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {BrowserAnimationsModule, ɵAnimationRendererFactory} from '@angular/platform-browser/animations'; +import {BrowserAnimationsModule, ɵAnimationRendererFactory as AnimationRendererFactory} from '@angular/platform-browser/animations'; +import {DomRendererFactory2} from '@angular/platform-browser/src/dom/dom_renderer'; import {InjectableAnimationEngine} from '../../animations/src/providers'; import {el} from '../../testing/src/browser_util'; export function main() { - describe('ɵAnimationRenderer', () => { + describe('AnimationRenderer', () => { let element: any; beforeEach(() => { element = el('
'); TestBed.configureTestingModule({ - providers: [{provide: ɵAnimationEngine, useClass: MockAnimationEngine}], + providers: [{provide: AnimationEngine, useClass: MockAnimationEngine}], imports: [BrowserAnimationsModule] }); }); @@ -33,20 +34,13 @@ export function main() { styles: [], data: {'animation': animationTriggers} }; - return (TestBed.get(RendererFactory2) as ɵAnimationRendererFactory) + return (TestBed.get(RendererFactory2) as AnimationRendererFactory) .createRenderer(element, type); } - it('should register the provided triggers with the view engine when created', () => { - const renderer = makeRenderer([trigger('trig1', []), trigger('trig2', [])]); - - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; - expect(engine.triggers.map(t => t.name)).toEqual(['trig1', 'trig2']); - }); - it('should hook into the engine\'s insert operations when appending children', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; const container = el('
'); renderer.appendChild(container, element); @@ -56,7 +50,7 @@ export function main() { it('should hook into the engine\'s insert operations when inserting a child before another', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; const container = el('
'); const element2 = el('
'); container.appendChild(element2); @@ -67,7 +61,7 @@ export function main() { it('should hook into the engine\'s insert operations when removing children', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; const container = el('
'); renderer.removeChild(container, element); @@ -76,19 +70,19 @@ export function main() { it('should hook into the engine\'s setProperty call if the property begins with `@`', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; renderer.setProperty(element, 'prop', 'value'); expect(engine.captures['setProperty']).toBeFalsy(); renderer.setProperty(element, '@prop', 'value'); - expect(engine.captures['setProperty'].pop()).toEqual([element, 'id#prop', 'value']); + expect(engine.captures['setProperty'].pop()).toEqual([element, 'prop', 'value']); }); describe('listen', () => { it('should hook into the engine\'s listen call if the property begins with `@`', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; const cb = (event: any): boolean => { return true; }; @@ -96,13 +90,13 @@ export function main() { expect(engine.captures['listen']).toBeFalsy(); renderer.listen(element, '@event.phase', cb); - expect(engine.captures['listen'].pop()).toEqual([element, 'id#event', 'phase']); + expect(engine.captures['listen'].pop()).toEqual([element, 'event', 'phase']); }); it('should resolve the body|document|window nodes given their values as strings as input', () => { const renderer = makeRenderer(); - const engine = TestBed.get(ɵAnimationEngine) as MockAnimationEngine; + const engine = TestBed.get(AnimationEngine) as MockAnimationEngine; const cb = (event: any): boolean => { return true; }; @@ -117,6 +111,10 @@ export function main() { }); }); + describe('registering animations', () => { + it('should only create a trigger definition once even if the registered multiple times'); + }); + describe('flushing animations', () => { // these tests are only mean't to be run within the DOM if (typeof Element == 'undefined') return; @@ -138,11 +136,11 @@ export function main() { } TestBed.configureTestingModule({ - providers: [{provide: ɵAnimationEngine, useClass: InjectableAnimationEngine}], + providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}], declarations: [Cmp] }); - const engine = TestBed.get(ɵAnimationEngine); + const engine = TestBed.get(AnimationEngine); const fixture = TestBed.createComponent(Cmp); const cmp = fixture.componentInstance; cmp.exp = 'state'; @@ -174,7 +172,7 @@ export function main() { } TestBed.configureTestingModule({ - providers: [{provide: ɵAnimationEngine, useClass: InjectableAnimationEngine}], + providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}], declarations: [Cmp] }); @@ -223,11 +221,11 @@ export function main() { } TestBed.configureTestingModule({ - providers: [{provide: ɵAnimationEngine, useClass: InjectableAnimationEngine}], + providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}], declarations: [Cmp] }); - const engine = TestBed.get(ɵAnimationEngine); + const engine = TestBed.get(AnimationEngine); const fixture = TestBed.createComponent(Cmp); const cmp = fixture.componentInstance; @@ -239,7 +237,7 @@ export function main() { assertHasParent(elm2); assertHasParent(elm3); engine.flush(); - finishPlayers(engine.activePlayers); + finishPlayers(engine.players); cmp.exp1 = false; fixture.detectChanges(); @@ -247,7 +245,7 @@ export function main() { assertHasParent(elm2); assertHasParent(elm3); engine.flush(); - expect(engine.activePlayers.length).toEqual(0); + expect(engine.players.length).toEqual(0); cmp.exp2 = false; fixture.detectChanges(); @@ -255,7 +253,7 @@ export function main() { assertHasParent(elm2, false); assertHasParent(elm3); engine.flush(); - expect(engine.activePlayers.length).toEqual(0); + expect(engine.players.length).toEqual(0); cmp.exp3 = false; fixture.detectChanges(); @@ -263,14 +261,57 @@ export function main() { assertHasParent(elm2, false); assertHasParent(elm3); engine.flush(); - expect(engine.activePlayers.length).toEqual(1); + expect(engine.players.length).toEqual(1); }); }); }); + + describe('AnimationRendererFactory', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: RendererFactory2, + useClass: ExtendedAnimationRendererFactory, + deps: [DomRendererFactory2, AnimationEngine, NgZone] + }], + imports: [BrowserAnimationsModule] + }); + }); + + it('should provide hooks at the start and end of change detection', () => { + @Component({ + selector: 'my-cmp', + template: ` +
+ `, + animations: [trigger('myAnimation', [])] + }) + class Cmp { + public exp: any; + } + + TestBed.configureTestingModule({ + providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}], + declarations: [Cmp] + }); + + const renderer = TestBed.get(RendererFactory2) as ExtendedAnimationRendererFactory; + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + renderer.log = []; + fixture.detectChanges(); + expect(renderer.log).toEqual(['begin', 'end']); + + renderer.log = []; + fixture.detectChanges(); + expect(renderer.log).toEqual(['begin', 'end']); + }); + }); } @Injectable() -class MockAnimationEngine extends ɵAnimationEngine { +class MockAnimationEngine extends AnimationEngine { captures: {[method: string]: any[]} = {}; triggers: AnimationTriggerMetadata[] = []; @@ -279,24 +320,46 @@ class MockAnimationEngine extends ɵAnimationEngine { data.push(args); } - registerTrigger(trigger: AnimationTriggerMetadata) { this.triggers.push(trigger); } + registerTrigger(componentId: string, namespaceId: string, trigger: AnimationTriggerMetadata) { + this.triggers.push(trigger); + } - onInsert(element: any, domFn: () => any): void { this._capture('onInsert', [element]); } + onInsert(namespaceId: string, element: any): void { this._capture('onInsert', [element]); } - onRemove(element: any, domFn: () => any): void { this._capture('onRemove', [element]); } + onRemove(namespaceId: string, element: any, domFn: () => any): void { + this._capture('onRemove', [element]); + } - setProperty(element: any, property: string, value: any): void { + setProperty(namespaceId: string, element: any, property: string, value: any): void { this._capture('setProperty', [element, property, value]); } - listen(element: any, eventName: string, eventPhase: string, callback: (event: any) => any): - () => void { + listen( + namespaceId: string, element: any, eventName: string, eventPhase: string, + callback: (event: any) => any): () => void { // we don't capture the callback here since the renderer wraps it in a zone this._capture('listen', [element, eventName, eventPhase]); return () => {}; } flush() {} + + destroy(namespaceId: string) {} +} + +@Injectable() +class ExtendedAnimationRendererFactory extends AnimationRendererFactory { + public log: string[] = []; + + begin() { + super.begin(); + this.log.push('begin'); + } + + end() { + super.end(); + this.log.push('end'); + } } diff --git a/packages/platform-browser/test/animation/browser_animation_builder_spec.ts b/packages/platform-browser/test/animation/browser_animation_builder_spec.ts new file mode 100644 index 0000000000..d427a2d629 --- /dev/null +++ b/packages/platform-browser/test/animation/browser_animation_builder_spec.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {AnimationBuilder, animate, style} from '@angular/animations'; +import {AnimationDriver} from '@angular/animations/browser'; +import {MockAnimationDriver} from '@angular/animations/browser/testing'; +import {Component, ViewChild} from '@angular/core'; +import {TestBed, fakeAsync, flushMicrotasks} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +import {BrowserAnimationBuilder} from '../../animations/src/animation_builder'; +import {el} from '../../testing/src/browser_util'; + +export function main() { + describe('BrowserAnimationBuilder', () => { + let element: any; + beforeEach(() => { + element = el('
'); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [{provide: AnimationDriver, useClass: MockAnimationDriver}] + }); + }); + + it('should inject AnimationBuilder into a component', () => { + @Component({ + selector: 'ani-cmp', + template: '...', + }) + class Cmp { + constructor(public builder: AnimationBuilder) {} + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + + fixture.detectChanges(); + expect(cmp.builder instanceof BrowserAnimationBuilder).toBeTruthy(); + }); + + it('should listen on start and done on the animation builder\'s player', fakeAsync(() => { + @Component({ + selector: 'ani-cmp', + template: '...', + }) + class Cmp { + @ViewChild('target') public target: any; + + constructor(public builder: AnimationBuilder) {} + + build() { + const definition = + this.builder.build([style({opacity: 0}), animate(1000, style({opacity: 1}))]); + + return definition.create(this.target); + } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + fixture.detectChanges(); + + const player = cmp.build(); + + let started = false; + player.onStart(() => started = true); + + let finished = false; + player.onDone(() => finished = true); + + let destroyed = false; + player.onDestroy(() => destroyed = true); + + player.init(); + flushMicrotasks(); + expect(started).toBeFalsy(); + expect(finished).toBeFalsy(); + expect(destroyed).toBeFalsy(); + + player.play(); + flushMicrotasks(); + expect(started).toBeTruthy(); + expect(finished).toBeFalsy(); + expect(destroyed).toBeFalsy(); + + player.finish(); + flushMicrotasks(); + expect(started).toBeTruthy(); + expect(finished).toBeTruthy(); + expect(destroyed).toBeFalsy(); + + player.destroy(); + flushMicrotasks(); + expect(started).toBeTruthy(); + expect(finished).toBeTruthy(); + expect(destroyed).toBeTruthy(); + })); + }); +} diff --git a/tools/public_api_guard/animations/animations.d.ts b/tools/public_api_guard/animations/animations.d.ts index cb91d7c925..f5da8a4545 100644 --- a/tools/public_api_guard/animations/animations.d.ts +++ b/tools/public_api_guard/animations/animations.d.ts @@ -1,6 +1,9 @@ /** @experimental */ export declare function animate(timings: string | number, styles?: AnimationStyleMetadata | AnimationKeyframesSequenceMetadata | null): AnimationAnimateMetadata; +/** @experimental */ +export declare function animateChild(options?: AnimationOptions | null): AnimationAnimateChildMetadata; + /** @experimental */ export declare type AnimateTimings = { duration: number; @@ -8,12 +11,36 @@ export declare type AnimateTimings = { easing: string | null; }; +/** @experimental */ +export declare function animation(steps: AnimationMetadata | AnimationMetadata[], options?: AnimationOptions | null): AnimationReferenceMetadata; + +/** @experimental */ +export declare abstract class Animation { + abstract create(element: any, options?: AnimationOptions): AnimationPlayer; +} + +/** @experimental */ +export interface AnimationAnimateChildMetadata extends AnimationMetadata { + options: AnimationOptions | null; +} + /** @experimental */ export interface AnimationAnimateMetadata extends AnimationMetadata { styles: AnimationStyleMetadata | AnimationKeyframesSequenceMetadata | null; timings: string | number | AnimateTimings; } +/** @experimental */ +export interface AnimationAnimateRefMetadata extends AnimationMetadata { + animation: AnimationReferenceMetadata; + options: AnimationOptions | null; +} + +/** @experimental */ +export declare abstract class AnimationBuilder { + abstract build(animation: AnimationMetadata | AnimationMetadata[]): Animation; +} + /** @experimental */ export interface AnimationEvent { element: any; @@ -26,6 +53,7 @@ export interface AnimationEvent { /** @experimental */ export interface AnimationGroupMetadata extends AnimationMetadata { + options: AnimationOptions | null; steps: AnimationMetadata[]; } @@ -46,13 +74,30 @@ export declare const enum AnimationMetadataType { Sequence = 2, Group = 3, Animate = 4, - KeyframeSequence = 5, + Keyframes = 5, Style = 6, + Trigger = 7, + Reference = 8, + AnimateChild = 9, + AnimateRef = 10, + Query = 11, + Stagger = 12, +} + +/** @experimental */ +export interface AnimationOptions { + delay?: number | string; + duration?: number | string; + params?: { + [name: string]: any; + }; } /** @experimental */ export declare abstract class AnimationPlayer { + beforeDestroy?: () => any; parentPlayer: AnimationPlayer | null; + readonly totalTime: number; abstract destroy(): void; abstract finish(): void; abstract getPosition(): number; @@ -68,11 +113,37 @@ export declare abstract class AnimationPlayer { abstract setPosition(p: any): void; } +/** @experimental */ +export interface AnimationQueryMetadata extends AnimationMetadata { + animation: AnimationMetadata | AnimationMetadata[]; + options: AnimationQueryOptions | null; + selector: string; +} + +/** @experimental */ +export interface AnimationQueryOptions extends AnimationOptions { + limit?: number; + optional?: boolean; +} + +/** @experimental */ +export interface AnimationReferenceMetadata extends AnimationMetadata { + animation: AnimationMetadata | AnimationMetadata[]; + options: AnimationOptions | null; +} + /** @experimental */ export interface AnimationSequenceMetadata extends AnimationMetadata { + options: AnimationOptions | null; steps: AnimationMetadata[]; } +/** @experimental */ +export interface AnimationStaggerMetadata extends AnimationMetadata { + animation: AnimationMetadata | AnimationMetadata[]; + timings: string | number; +} + /** @experimental */ export interface AnimationStateMetadata extends AnimationMetadata { name: string; @@ -81,31 +152,37 @@ export interface AnimationStateMetadata extends AnimationMetadata { /** @experimental */ export interface AnimationStyleMetadata extends AnimationMetadata { - offset?: number; - styles: { + offset: number | null; + styles: '*' | { [key: string]: string | number; - } | { + } | Array<{ [key: string]: string | number; - }[]; + } | '*'>; } /** @experimental */ export interface AnimationTransitionMetadata extends AnimationMetadata { animation: AnimationMetadata | AnimationMetadata[]; - expr: string | ((fromState: string, toState: string) => boolean); + expr: string; + options: AnimationOptions | null; } /** @experimental */ -export interface AnimationTriggerMetadata { +export interface AnimationTriggerMetadata extends AnimationMetadata { definitions: AnimationMetadata[]; name: string; + options: { + params?: { + [name: string]: any; + }; + } | null; } /** @experimental */ export declare const AUTO_STYLE = "*"; /** @experimental */ -export declare function group(steps: AnimationMetadata[]): AnimationGroupMetadata; +export declare function group(steps: AnimationMetadata[], options?: AnimationOptions | null): AnimationGroupMetadata; /** @experimental */ export declare function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSequenceMetadata; @@ -113,6 +190,7 @@ export declare function keyframes(steps: AnimationStyleMetadata[]): AnimationKey /** @experimental */ export declare class NoopAnimationPlayer implements AnimationPlayer { parentPlayer: AnimationPlayer | null; + totalTime: number; constructor(); destroy(): void; finish(): void; @@ -130,20 +208,29 @@ export declare class NoopAnimationPlayer implements AnimationPlayer { } /** @experimental */ -export declare function sequence(steps: AnimationMetadata[]): AnimationSequenceMetadata; +export declare function query(selector: string, animation: AnimationMetadata | AnimationMetadata[], options?: AnimationQueryOptions | null): AnimationQueryMetadata; + +/** @experimental */ +export declare function sequence(steps: AnimationMetadata[], options?: AnimationOptions | null): AnimationSequenceMetadata; + +/** @experimental */ +export declare function stagger(timings: string | number, animation: AnimationMetadata | AnimationMetadata[]): AnimationStaggerMetadata; /** @experimental */ export declare function state(name: string, styles: AnimationStyleMetadata): AnimationStateMetadata; /** @experimental */ -export declare function style(tokens: { +export declare function style(tokens: '*' | { [key: string]: string | number; -} | Array<{ +} | Array<'*' | { [key: string]: string | number; }>): AnimationStyleMetadata; /** @experimental */ -export declare function transition(stateChangeExpr: string | ((fromState: string, toState: string) => boolean), steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata; +export declare function transition(stateChangeExpr: string, steps: AnimationMetadata | AnimationMetadata[], options?: AnimationOptions | null): AnimationTransitionMetadata; /** @experimental */ export declare function trigger(name: string, definitions: AnimationMetadata[]): AnimationTriggerMetadata; + +/** @experimental */ +export declare function useAnimation(animation: AnimationReferenceMetadata, options?: AnimationOptions | null): AnimationAnimateRefMetadata; diff --git a/tools/public_api_guard/animations/browser.d.ts b/tools/public_api_guard/animations/browser.d.ts index d589c56638..1c12076bdd 100644 --- a/tools/public_api_guard/animations/browser.d.ts +++ b/tools/public_api_guard/animations/browser.d.ts @@ -3,5 +3,6 @@ export declare abstract class AnimationDriver { abstract animate(element: any, keyframes: { [key: string]: string | number; }[], duration: number, delay: number, easing?: string | null, previousPlayers?: any[]): any; + abstract computeStyle(element: any, prop: string, defaultValue?: string): string; static NOOP: AnimationDriver; } diff --git a/tools/public_api_guard/animations/browser/testing.d.ts b/tools/public_api_guard/animations/browser/testing.d.ts index 8452f075a0..7789ee8170 100644 --- a/tools/public_api_guard/animations/browser/testing.d.ts +++ b/tools/public_api_guard/animations/browser/testing.d.ts @@ -3,11 +3,13 @@ export declare class MockAnimationDriver implements AnimationDriver { animate(element: any, keyframes: { [key: string]: string | number; }[], duration: number, delay: number, easing: string, previousPlayers?: any[]): MockAnimationPlayer; + computeStyle(element: any, prop: string, defaultValue?: string): string; static log: AnimationPlayer[]; } /** @experimental */ export declare class MockAnimationPlayer extends NoopAnimationPlayer { + currentSnapshot: ɵStyleData; delay: number; duration: number; easing: string; @@ -22,6 +24,9 @@ export declare class MockAnimationPlayer extends NoopAnimationPlayer { constructor(element: any, keyframes: { [key: string]: string | number; }[], duration: number, delay: number, easing: string, previousPlayers: any[]); + beforeDestroy(): void; destroy(): void; finish(): void; + hasStarted(): boolean; + play(): void; } diff --git a/tools/public_api_guard/core/core.d.ts b/tools/public_api_guard/core/core.d.ts index d25cd55ca6..62fd4d37cb 100644 --- a/tools/public_api_guard/core/core.d.ts +++ b/tools/public_api_guard/core/core.d.ts @@ -70,12 +70,12 @@ export declare type AnimationStateTransitionMetadata = any; /** @deprecated */ export interface AnimationStyleMetadata extends AnimationMetadata { - offset?: number; - styles: { + offset: number | null; + styles: '*' | { [key: string]: string | number; - } | { + } | Array<{ [key: string]: string | number; - }[]; + } | '*'>; } /** @deprecated */ @@ -1007,7 +1007,7 @@ export interface TrackByFunction { } /** @deprecated */ -export declare function transition(stateChangeExpr: string | ((fromState: string, toState: string) => boolean), steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata; +export declare function transition(stateChangeExpr: string, steps: AnimationMetadata | AnimationMetadata[]): AnimationTransitionMetadata; /** @experimental */ export declare const TRANSLATIONS: InjectionToken; diff --git a/tools/public_api_guard/platform-browser/animations/testing.d.ts b/tools/public_api_guard/platform-browser/animations/testing.d.ts index 8452f075a0..ac0800ff6e 100644 --- a/tools/public_api_guard/platform-browser/animations/testing.d.ts +++ b/tools/public_api_guard/platform-browser/animations/testing.d.ts @@ -1,27 +1,7 @@ /** @experimental */ -export declare class MockAnimationDriver implements AnimationDriver { - animate(element: any, keyframes: { - [key: string]: string | number; - }[], duration: number, delay: number, easing: string, previousPlayers?: any[]): MockAnimationPlayer; - static log: AnimationPlayer[]; +export declare class BrowserAnimationsTestingModule { } /** @experimental */ -export declare class MockAnimationPlayer extends NoopAnimationPlayer { - delay: number; - duration: number; - easing: string; - element: any; - keyframes: { - [key: string]: string | number; - }[]; - previousPlayers: any[]; - previousStyles: { - [key: string]: string | number; - }; - constructor(element: any, keyframes: { - [key: string]: string | number; - }[], duration: number, delay: number, easing: string, previousPlayers: any[]); - destroy(): void; - finish(): void; +export declare class NoopAnimationsTestingModule { }