diff --git a/modules/@angular/core/test/animation/animation_integration_next_spec.ts b/modules/@angular/core/test/animation/animation_integration_next_spec.ts index 99241bbb42..a743b85521 100644 --- a/modules/@angular/core/test/animation/animation_integration_next_spec.ts +++ b/modules/@angular/core/test/animation/animation_integration_next_spec.ts @@ -8,11 +8,9 @@ import {AUTO_STYLE, AnimationEvent, animate, keyframes, state, style, transition, trigger} from '@angular/animations'; import {USE_VIEW_ENGINE} from '@angular/compiler/src/config'; import {Component, HostBinding, HostListener, ViewChild} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; import {AnimationDriver, BrowserAnimationModule, ɵAnimationEngine} from '@angular/platform-browser/animations'; import {MockAnimationDriver, MockAnimationPlayer} from '@angular/platform-browser/animations/testing'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; - import {TestBed} from '../../testing'; export function main() { @@ -46,7 +44,7 @@ function declareTests({useJit}: {useJit: boolean}) { resetLog(); TestBed.configureTestingModule({ providers: [{provide: AnimationDriver, useClass: MockAnimationDriver}], - imports: [BrowserModule, BrowserAnimationModule] + imports: [BrowserAnimationModule] }); }); diff --git a/modules/@angular/platform-browser/animations/src/animation_engine.ts b/modules/@angular/platform-browser/animations/src/animation_engine.ts new file mode 100644 index 0000000000..2315b6e00d --- /dev/null +++ b/modules/@angular/platform-browser/animations/src/animation_engine.ts @@ -0,0 +1,22 @@ +/** + * @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 {AnimationPlayer, AnimationTriggerMetadata} from '@angular/animations'; + +export abstract class AnimationEngine { + abstract registerTrigger(trigger: AnimationTriggerMetadata): 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 listen( + element: any, eventName: string, eventPhase: string, + callback: (event: any) => any): () => any; + abstract flush(): void; + + get activePlayers(): AnimationPlayer[] { throw new Error('...'); } + get queuedPlayers(): AnimationPlayer[] { throw new Error('...'); } +} diff --git a/modules/@angular/platform-browser/animations/src/animations.ts b/modules/@angular/platform-browser/animations/src/animations.ts index cc38dab476..b34f9af435 100644 --- a/modules/@angular/platform-browser/animations/src/animations.ts +++ b/modules/@angular/platform-browser/animations/src/animations.ts @@ -12,5 +12,6 @@ * Entry point for all animation APIs of the animation browser package. */ export {BrowserAnimationModule} from './browser_animation_module'; +export {NoopBrowserAnimationModule} from './noop_browser_animation_module'; export {AnimationDriver} from './render/animation_driver'; export * from './private_export'; diff --git a/modules/@angular/platform-browser/animations/src/browser_animation_module.ts b/modules/@angular/platform-browser/animations/src/browser_animation_module.ts index 4c36d36fc6..4df8be7457 100644 --- a/modules/@angular/platform-browser/animations/src/browser_animation_module.ts +++ b/modules/@angular/platform-browser/animations/src/browser_animation_module.ts @@ -5,18 +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 {Injectable, NgModule, RendererFactoryV2} from '@angular/core'; +import {Injectable, NgModule, NgZone, RendererFactoryV2} from '@angular/core'; import {BrowserModule, ɵDomRendererFactoryV2} from '@angular/platform-browser'; +import {AnimationEngine} from './animation_engine'; import {AnimationStyleNormalizer} from './dsl/style_normalization/animation_style_normalizer'; import {WebAnimationsStyleNormalizer} from './dsl/style_normalization/web_animations_style_normalizer'; import {AnimationDriver, NoOpAnimationDriver} from './render/animation_driver'; -import {AnimationEngine} from './render/animation_engine'; import {AnimationRendererFactory} from './render/animation_renderer'; +import {DomAnimationEngine} from './render/dom_animation_engine'; import {WebAnimationsDriver, supportsWebAnimations} from './render/web_animations/web_animations_driver'; @Injectable() -export class InjectableAnimationEngine extends AnimationEngine { +export class InjectableAnimationEngine extends DomAnimationEngine { constructor(driver: AnimationDriver, normalizer: AnimationStyleNormalizer) { super(driver, normalizer); } @@ -34,8 +35,8 @@ export function instantiateDefaultStyleNormalizer() { } export function instantiateRendererFactory( - renderer: ɵDomRendererFactoryV2, engine: AnimationEngine) { - return new AnimationRendererFactory(renderer, engine); + renderer: ɵDomRendererFactoryV2, engine: AnimationEngine, zone: NgZone) { + return new AnimationRendererFactory(renderer, engine, zone); } /** @@ -49,7 +50,7 @@ export function instantiateRendererFactory( {provide: AnimationEngine, useClass: InjectableAnimationEngine}, { provide: RendererFactoryV2, useFactory: instantiateRendererFactory, - deps: [ɵDomRendererFactoryV2, AnimationEngine] + deps: [ɵDomRendererFactoryV2, AnimationEngine, NgZone] } ] }) diff --git a/modules/@angular/platform-browser/animations/src/dsl/animation.ts b/modules/@angular/platform-browser/animations/src/dsl/animation.ts index d236bff966..bde7df3b8e 100644 --- a/modules/@angular/platform-browser/animations/src/dsl/animation.ts +++ b/modules/@angular/platform-browser/animations/src/dsl/animation.ts @@ -8,7 +8,7 @@ import {AnimationMetadata, AnimationPlayer, AnimationStyleMetadata, sequence, ɵStyleData} from '@angular/animations'; import {AnimationDriver} from '../render/animation_driver'; -import {AnimationEngine} from '../render/animation_engine'; +import {DomAnimationEngine} from '../render/dom_animation_engine'; import {normalizeStyles} from '../util'; import {AnimationTimelineInstruction} from './animation_timeline_instruction'; @@ -49,7 +49,7 @@ export class Animation { // 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 AnimationEngine(driver, normalizer); + const engine = new DomAnimationEngine(driver, normalizer); return engine.animateTimeline(element, instructions); } } diff --git a/modules/@angular/platform-browser/animations/src/noop_browser_animation_module.ts b/modules/@angular/platform-browser/animations/src/noop_browser_animation_module.ts new file mode 100644 index 0000000000..cb6c648b7d --- /dev/null +++ b/modules/@angular/platform-browser/animations/src/noop_browser_animation_module.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 {NgModule, NgZone, RendererFactoryV2} from '@angular/core'; +import {BrowserModule, ɵDomRendererFactoryV2} from '@angular/platform-browser'; + +import {AnimationEngine} from './animation_engine'; +import {AnimationRendererFactory} from './render/animation_renderer'; +import {NoopAnimationEngine} from './render/noop_animation_engine'; + +export function instantiateRendererFactory( + renderer: ɵDomRendererFactoryV2, engine: AnimationEngine, zone: NgZone) { + return new AnimationRendererFactory(renderer, engine, zone); +} + +/** + * @experimental Animation support is experimental. + */ +@NgModule({ + imports: [BrowserModule], + providers: [ + {provide: AnimationEngine, useClass: NoopAnimationEngine}, { + provide: RendererFactoryV2, + useFactory: instantiateRendererFactory, + deps: [ɵDomRendererFactoryV2, AnimationEngine, NgZone] + } + ] +}) +export class NoopBrowserAnimationModule { +} diff --git a/modules/@angular/platform-browser/animations/src/private_export.ts b/modules/@angular/platform-browser/animations/src/private_export.ts index 0366f0906b..e26160660e 100644 --- a/modules/@angular/platform-browser/animations/src/private_export.ts +++ b/modules/@angular/platform-browser/animations/src/private_export.ts @@ -5,7 +5,9 @@ * 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 {AnimationEngine as ɵAnimationEngine} from './animation_engine'; export {Animation as ɵAnimation} from './dsl/animation'; -export {AnimationStyleNormalizer as ɵAnimationStyleNormalizer} from './dsl/style_normalization/animation_style_normalizer'; -export {AnimationEngine as ɵAnimationEngine} from './render/animation_engine'; +export {AnimationStyleNormalizer as ɵAnimationStyleNormalizer, NoOpAnimationStyleNormalizer as ɵNoOpAnimationStyleNormalizer} from './dsl/style_normalization/animation_style_normalizer'; +export {NoOpAnimationDriver as ɵNoOpAnimationDriver} from './render/animation_driver'; export {AnimationRenderer as ɵAnimationRenderer, AnimationRendererFactory as ɵAnimationRendererFactory} from './render/animation_renderer'; +export {DomAnimationEngine as ɵDomAnimationEngine} from './render/dom_animation_engine'; diff --git a/modules/@angular/platform-browser/animations/src/render/animation_renderer.ts b/modules/@angular/platform-browser/animations/src/render/animation_renderer.ts index e05f7afee5..706ccb797a 100644 --- a/modules/@angular/platform-browser/animations/src/render/animation_renderer.ts +++ b/modules/@angular/platform-browser/animations/src/render/animation_renderer.ts @@ -6,13 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ import {AnimationTriggerMetadata} from '@angular/animations'; -import {Injectable, RendererFactoryV2, RendererTypeV2, RendererV2} from '@angular/core'; +import {Injectable, NgZone, RendererFactoryV2, RendererTypeV2, RendererV2} from '@angular/core'; -import {AnimationEngine} from './animation_engine'; +import {AnimationEngine} from '../animation_engine'; @Injectable() export class AnimationRendererFactory implements RendererFactoryV2 { - constructor(private delegate: RendererFactoryV2, private _engine: AnimationEngine) {} + constructor( + private delegate: RendererFactoryV2, private _engine: AnimationEngine, + private _zone: NgZone) {} createRenderer(hostElement: any, type: RendererTypeV2): RendererV2 { let delegate = this.delegate.createRenderer(hostElement, type); @@ -24,16 +26,17 @@ export class AnimationRendererFactory implements RendererFactoryV2 { } const animationTriggers = type.data['animation'] as AnimationTriggerMetadata[]; animationRenderer = (type.data as any)['__animationRenderer__'] = - new AnimationRenderer(delegate, this._engine, animationTriggers); + new AnimationRenderer(delegate, this._engine, this._zone, animationTriggers); return animationRenderer; } } export class AnimationRenderer implements RendererV2 { public destroyNode: (node: any) => (void|any) = null; + private _flushPromise: Promise = null; constructor( - public delegate: RendererV2, private _engine: AnimationEngine, + public delegate: RendererV2, private _engine: AnimationEngine, private _zone: NgZone, _triggers: AnimationTriggerMetadata[] = null) { this.destroyNode = this.delegate.destroyNode ? (n) => delegate.destroyNode(n) : null; if (_triggers) { @@ -92,11 +95,13 @@ export class AnimationRenderer implements RendererV2 { removeChild(parent: any, oldChild: any): void { this._engine.onRemove(oldChild, () => this.delegate.removeChild(parent, oldChild)); + this._queueFlush(); } setProperty(el: any, name: string, value: any): void { if (name.charAt(0) == '@') { this._engine.setProperty(el, name.substr(1), value); + this._queueFlush(); } else { this.delegate.setProperty(el, name, value); } @@ -107,10 +112,22 @@ export class AnimationRenderer implements RendererV2 { if (eventName.charAt(0) == '@') { const element = resolveElementFromTarget(target); const [name, phase] = parseTriggerCallbackName(eventName.substr(1)); - return this._engine.listen(element, name, phase, callback); + return this._engine.listen( + element, name, phase, (event: any) => this._zone.run(() => 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(); + }); + }); + } + } } function resolveElementFromTarget(target: 'window' | 'document' | 'body' | any): any { diff --git a/modules/@angular/platform-browser/animations/src/render/animation_engine.ts b/modules/@angular/platform-browser/animations/src/render/dom_animation_engine.ts similarity index 94% rename from modules/@angular/platform-browser/animations/src/render/animation_engine.ts rename to modules/@angular/platform-browser/animations/src/render/dom_animation_engine.ts index cc148105b1..ced0ccc77f 100644 --- a/modules/@angular/platform-browser/animations/src/render/animation_engine.ts +++ b/modules/@angular/platform-browser/animations/src/render/dom_animation_engine.ts @@ -11,6 +11,7 @@ import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instructio 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'; @@ -20,7 +21,6 @@ export interface QueuedAnimationTransitionTuple { triggerName: string; event: AnimationEvent; } -; export interface TriggerListenerTuple { triggerName: string; @@ -31,7 +31,7 @@ export interface TriggerListenerTuple { const MARKED_FOR_ANIMATION = 'ng-animate'; const MARKED_FOR_REMOVAL = '$$ngRemove'; -export class AnimationEngine { +export class DomAnimationEngine { private _flaggedInserts = new Set(); private _queuedRemovals = new Map any>(); private _queuedTransitionAnimations: QueuedAnimationTransitionTuple[] = []; @@ -43,11 +43,6 @@ export class AnimationEngine { private _triggers: {[triggerName: string]: AnimationTrigger} = {}; private _triggerListeners = new Map(); - private _flushId = 0; - private _awaitingFlush = false; - - static raf = (fn: () => any): any => { return requestAnimationFrame(fn); }; - constructor(private _driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) {} get queuedPlayers(): AnimationPlayer[] { @@ -60,7 +55,7 @@ export class AnimationEngine { return players; } - registerTrigger(trigger: AnimationTriggerMetadata) { + registerTrigger(trigger: AnimationTriggerMetadata): void { const name = trigger.name; if (this._triggers[name]) { throw new Error(`The provided animation trigger "${name}" has already been registered!`); @@ -271,16 +266,6 @@ export class AnimationEngine { element.classList.add(MARKED_FOR_ANIMATION); player.onDone(() => { element.classList.remove(MARKED_FOR_ANIMATION); }); - - if (!this._awaitingFlush) { - const flushId = this._flushId; - AnimationEngine.raf(() => { - if (flushId == this._flushId) { - this._awaitingFlush = false; - this.flush(); - } - }); - } } private _flushQueuedAnimations() { @@ -323,7 +308,6 @@ export class AnimationEngine { } flush() { - this._flushId++; this._flushQueuedAnimations(); let flushAgain = false; @@ -406,18 +390,6 @@ function deleteFromArrayMap(map: Map, key: any, value: any) { } } -function setStyles(element: any, styles: ɵStyleData) { - Object.keys(styles).forEach(prop => { element.style[prop] = styles[prop]; }); -} - -function eraseStyles(element: any, styles: ɵStyleData) { - Object.keys(styles).forEach(prop => { - // IE requires '' instead of null - // see https://github.com/angular/angular/issues/7916 - element.style[prop] = ''; - }); -} - function optimizeGroupPlayer(players: AnimationPlayer[]): AnimationPlayer { switch (players.length) { case 0: diff --git a/modules/@angular/platform-browser/animations/src/render/noop_animation_engine.ts b/modules/@angular/platform-browser/animations/src/render/noop_animation_engine.ts new file mode 100644 index 0000000000..b2107512e9 --- /dev/null +++ b/modules/@angular/platform-browser/animations/src/render/noop_animation_engine.ts @@ -0,0 +1,158 @@ +/** + * @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, AnimationMetadataType, AnimationPlayer, AnimationStateMetadata, AnimationTriggerMetadata, ɵStyleData} from '@angular/animations'; + +import {AnimationEngine} from '../animation_engine'; +import {copyStyles, eraseStyles, normalizeStyles, setStyles} from '../util'; + +interface ListenerTuple { + eventPhase: string; + triggerName: string; + callback: (event: any) => any; +} + +interface ChangeTuple { + element: any; + triggerName: string; + oldValue: string; + newValue: string; +} + +const DEFAULT_STATE_VALUE = 'void'; +const DEFAULT_STATE_STYLES = '*'; + +export class NoopAnimationEngine extends AnimationEngine { + private _listeners = new Map(); + private _changes: ChangeTuple[] = []; + private _flaggedRemovals = new Set(); + private _onDoneFns: (() => any)[] = []; + private _triggerStyles: {[triggerName: string]: {[stateName: string]: ɵStyleData}} = {}; + + registerTrigger(trigger: AnimationTriggerMetadata): void { + 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[trigger.name] = stateMap; + } + + onInsert(element: any, domFn: () => any): void { domFn(); } + + onRemove(element: any, domFn: () => any): void { + domFn(); + 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}); + + const triggerStateStyles = this._triggerStyles[property] || {}; + const fromStateStyles = + triggerStateStyles[oldValue] || triggerStateStyles[DEFAULT_STATE_STYLES]; + if (fromStateStyles) { + eraseStyles(element, fromStateStyles); + } + + element[storageProp] = value; + this._onDoneFns.push(() => { + const toStateStyles = triggerStateStyles[value] || triggerStateStyles[DEFAULT_STATE_STYLES]; + if (toStateStyles) { + setStyles(element, toStateStyles); + } + }); + } + + listen(element: any, eventName: string, eventPhase: string, callback: (event: any) => any): + () => any { + let listeners = this._listeners.get(element); + if (!listeners) { + this._listeners.set(element, listeners = []); + } + + const tuple = {triggerName: eventName, eventPhase, callback}; + listeners.push(tuple); + + return () => { + const index = listeners.indexOf(tuple); + if (index >= 0) { + listeners.splice(index, 1); + } + }; + } + + flush(): void { + const onStartCallbacks: (() => any)[] = []; + const onDoneCallbacks: (() => any)[] = []; + + function handleListener(listener: ListenerTuple, data: ChangeTuple) { + const phase = listener.eventPhase; + const event = makeAnimationEvent( + data.element, data.triggerName, data.oldValue, data.newValue, phase, 0); + if (phase == 'start') { + onStartCallbacks.push(() => listener.callback(event)); + } else if (phase == 'done') { + onDoneCallbacks.push(() => listener.callback(event)); + } + } + + this._changes.forEach(change => { + const element = change.element; + const listeners = this._listeners.get(element); + if (listeners) { + listeners.forEach(listener => { + if (listener.triggerName == change.triggerName) { + handleListener(listener, change); + } + }); + } + }); + + // upon removal ALL the animation triggers need to get fired + this._flaggedRemovals.forEach(element => { + const listeners = this._listeners.get(element); + if (listeners) { + listeners.forEach(listener => { + const triggerName = listener.triggerName; + const storageProp = makeStorageProp(triggerName); + handleListener(listener, { + element: element, + triggerName: triggerName, + oldValue: element[storageProp] || DEFAULT_STATE_VALUE, + newValue: DEFAULT_STATE_VALUE + }); + }); + } + }); + + onStartCallbacks.forEach(fn => fn()); + onDoneCallbacks.forEach(fn => fn()); + this._flaggedRemovals.clear(); + this._changes = []; + + this._onDoneFns.forEach(doneFn => doneFn()); + this._onDoneFns = []; + } + + get activePlayers(): AnimationPlayer[] { return []; } + get queuedPlayers(): AnimationPlayer[] { return []; } +} + +function makeAnimationEvent( + element: any, triggerName: string, fromState: string, toState: string, phaseName: string, + totalTime: number): AnimationEvent { + return {element, triggerName, fromState, toState, phaseName, totalTime}; +} + +function makeStorageProp(property: string): string { + return '_@_' + property; +} diff --git a/modules/@angular/platform-browser/animations/src/util.ts b/modules/@angular/platform-browser/animations/src/util.ts index 38e3562a3f..f6c85f0b5a 100644 --- a/modules/@angular/platform-browser/animations/src/util.ts +++ b/modules/@angular/platform-browser/animations/src/util.ts @@ -69,3 +69,19 @@ export function copyStyles( } return destination; } + +export function setStyles(element: any, styles: ɵStyleData) { + if (element['style']) { + Object.keys(styles).forEach(prop => element.style[prop] = styles[prop]); + } +} + +export function eraseStyles(element: any, styles: ɵStyleData) { + if (element['style']) { + Object.keys(styles).forEach(prop => { + // IE requires '' instead of null + // see https://github.com/angular/angular/issues/7916 + element.style[prop] = ''; + }); + } +} diff --git a/modules/@angular/platform-browser/animations/test/engine/animation_engine_spec.ts b/modules/@angular/platform-browser/animations/test/engine/animation_engine_spec.ts index 4334be82a5..d73efceeee 100644 --- a/modules/@angular/platform-browser/animations/test/engine/animation_engine_spec.ts +++ b/modules/@angular/platform-browser/animations/test/engine/animation_engine_spec.ts @@ -12,7 +12,7 @@ import {el} from '@angular/platform-browser/testing/browser_util'; import {buildAnimationKeyframes} from '../../src/dsl/animation_timeline_visitor'; import {buildTrigger} from '../../src/dsl/animation_trigger'; import {AnimationStyleNormalizer, NoOpAnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer'; -import {AnimationEngine} from '../../src/render/animation_engine'; +import {DomAnimationEngine} from '../../src/render/dom_animation_engine'; import {MockAnimationDriver, MockAnimationPlayer} from '../../testing/mock_animation_driver'; function makeTrigger(name: string, steps: any) { @@ -27,7 +27,7 @@ export function main() { // these tests are only mean't to be run within the DOM if (typeof Element == 'undefined') return; - describe('AnimationEngine', () => { + describe('DomAnimationEngine', () => { let element: any; beforeEach(() => { @@ -36,7 +36,7 @@ export function main() { }); function makeEngine(normalizer: AnimationStyleNormalizer = null) { - return new AnimationEngine(driver, normalizer || new NoOpAnimationStyleNormalizer()); + return new DomAnimationEngine(driver, normalizer || new NoOpAnimationStyleNormalizer()); } describe('trigger registration', () => { @@ -292,51 +292,6 @@ export function main() { }); }); - describe('flushing animations', () => { - let ticks: (() => any)[]; - let _raf: () => any; - beforeEach(() => { - ticks = []; - _raf = <() => any>AnimationEngine.raf; - AnimationEngine.raf = (cb: () => any) => { ticks.push(cb); }; - }); - - afterEach(() => AnimationEngine.raf = _raf); - - function flushTicks() { - ticks.forEach(tick => tick()); - ticks = []; - } - - it('should invoke queued transition animations after a requestAnimationFrame flushes', () => { - const engine = makeEngine(); - engine.registerTrigger(trigger('myTrigger', [transition('* => *', animate(1234))])); - - engine.setProperty(element, 'myTrigger', 'on'); - expect(engine.queuedPlayers.length).toEqual(1); - expect(engine.activePlayers.length).toEqual(0); - - flushTicks(); - expect(engine.queuedPlayers.length).toEqual(0); - expect(engine.activePlayers.length).toEqual(1); - }); - - it('should not flush the animations twice when flushed right away before a frame changes', - () => { - const engine = makeEngine(); - engine.registerTrigger(trigger('myTrigger', [transition('* => *', animate(1234))])); - - engine.setProperty(element, 'myTrigger', 'on'); - expect(engine.activePlayers.length).toEqual(0); - - engine.flush(); - expect(engine.activePlayers.length).toEqual(1); - - flushTicks(); - expect(engine.activePlayers.length).toEqual(1); - }); - }); - describe('instructions', () => { it('should animate a transition instruction', () => { const engine = makeEngine(); diff --git a/modules/@angular/platform-browser/animations/test/noop_animation_engine_spec.ts b/modules/@angular/platform-browser/animations/test/noop_animation_engine_spec.ts new file mode 100644 index 0000000000..fbe09d8df9 --- /dev/null +++ b/modules/@angular/platform-browser/animations/test/noop_animation_engine_spec.ts @@ -0,0 +1,209 @@ +/** + * @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 {state, style, trigger} from '@angular/animations'; +import {el} from '@angular/platform-browser/testing/browser_util'; + +import {NoopAnimationEngine} from '../src/render/noop_animation_engine'; + +export function main() { + describe('NoopAnimationEngine', () => { + let captures: string[] = []; + function capture(value: string = null) { return (v: any = null) => captures.push(value || v); } + + beforeEach(() => { captures = []; }); + + it('should immediately issue DOM removals during remove animations and then fire the animation callbacks after flush', + () => { + const engine = new NoopAnimationEngine(); + + const elm1 = {}; + const elm2 = {}; + engine.onRemove(elm1, capture('1')); + engine.onRemove(elm2, capture('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')); + + expect(captures).toEqual(['1', '2']); + engine.flush(); + + expect(captures).toEqual(['1', '2', '1-start', '2-start', '1-done', '2-done']); + }); + + it('should only fire the `start` listener for a trigger that has had a property change', () => { + const engine = new NoopAnimationEngine(); + + 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()); + + expect(captures).toEqual([]); + engine.flush(); + + expect(captures.length).toEqual(2); + const trig1Data = captures.shift(); + const trig2Data = captures.shift(); + expect(trig1Data).toEqual({ + element: elm1, + triggerName: 'trig1', + fromState: 'void', + toState: 'cool', + phaseName: 'start', + totalTime: 0 + }); + + expect(trig2Data).toEqual({ + element: elm2, + triggerName: 'trig2', + fromState: 'void', + toState: 'sweet', + phaseName: 'start', + totalTime: 0 + }); + + captures = []; + engine.flush(); + expect(captures).toEqual([]); + }); + + it('should only fire the `done` listener for a trigger that has had a property change', () => { + const engine = new NoopAnimationEngine(); + + 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()); + + expect(captures).toEqual([]); + engine.flush(); + + expect(captures.length).toEqual(2); + const trig1Data = captures.shift(); + const trig2Data = captures.shift(); + expect(trig1Data).toEqual({ + element: elm1, + triggerName: 'trig1', + fromState: 'void', + toState: 'awesome', + phaseName: 'done', + totalTime: 0 + }); + + expect(trig2Data).toEqual({ + element: elm2, + triggerName: 'trig2', + fromState: 'void', + toState: 'amazing', + phaseName: 'done', + totalTime: 0 + }); + + captures = []; + engine.flush(); + expect(captures).toEqual([]); + }); + + it('should deregister a listener when the return function is called', () => { + const engine = new NoopAnimationEngine(); + const elm = {}; + + const fn1 = engine.listen(elm, 'trig1', 'start', capture('trig1-start')); + const fn2 = engine.listen(elm, 'trig2', 'done', capture('trig2-done')); + + engine.setProperty(elm, 'trig1', 'value1'); + engine.setProperty(elm, 'trig2', 'value2'); + engine.flush(); + expect(captures).toEqual(['trig1-start', 'trig2-done']); + + captures = []; + engine.setProperty(elm, 'trig1', 'value3'); + engine.setProperty(elm, 'trig2', 'value4'); + + fn1(); + engine.flush(); + expect(captures).toEqual(['trig2-done']); + + captures = []; + engine.setProperty(elm, 'trig1', 'value5'); + engine.setProperty(elm, 'trig2', 'value6'); + + fn2(); + engine.flush(); + expect(captures).toEqual([]); + }); + + describe('styling', () => { + // these tests are only mean't to be run within the DOM + 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 element = el('
'); + expect(element.style.width).not.toEqual('100px'); + + engine.setProperty(element, 'matias', 'a'); + expect(element.style.width).not.toEqual('100px'); + + engine.flush(); + expect(element.style.width).toEqual('100px'); + }); + + 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 element = el('
'); + + engine.setProperty(element, 'matias', 'a'); + engine.flush(); + expect(element.style.width).toEqual('100px'); + + engine.setProperty(element, 'matias', 'b'); + expect(element.style.width).not.toEqual('100px'); + expect(element.style.height).not.toEqual('100px'); + + engine.flush(); + expect(element.style.height).toEqual('100px'); + }); + + 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 element = el('
'); + + engine.setProperty(element, 'matias', 'xyz'); + engine.flush(); + expect(element.style.opacity).toEqual('0.5'); + }); + }); + }); +} diff --git a/modules/@angular/platform-browser/animations/test/noop_browser_animation_module_spec.ts b/modules/@angular/platform-browser/animations/test/noop_browser_animation_module_spec.ts new file mode 100644 index 0000000000..6588178a28 --- /dev/null +++ b/modules/@angular/platform-browser/animations/test/noop_browser_animation_module_spec.ts @@ -0,0 +1,67 @@ +/** + * @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 {animate, state, style, transition, trigger} from '@angular/animations'; +import {USE_VIEW_ENGINE} from '@angular/compiler/src/config'; +import {Component} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ɵAnimationEngine} from '@angular/platform-browser/animations'; +import {NoopBrowserAnimationModule} from '../src/noop_browser_animation_module'; +import {NoopAnimationEngine} from '../src/render/noop_animation_engine'; + +export function main() { + describe('NoopBrowserAnimationModule', () => { + beforeEach(() => { + TestBed.configureTestingModule({imports: [NoopBrowserAnimationModule]}); + TestBed.configureCompiler({ + useJit: true, + providers: [{ + provide: USE_VIEW_ENGINE, + useValue: true, + }] + }); + }); + + it('the engine should be a Noop engine', () => { + const engine = TestBed.get(ɵAnimationEngine); + expect(engine instanceof NoopAnimationEngine).toBeTruthy(); + }); + + it('should flush and fire callbacks when the zone becomes stable', (async) => { + @Component({ + selector: 'my-cmp', + template: + '
', + animations: [trigger( + 'myAnimation', + [transition( + '* => state', [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])], + }) + class Cmp { + exp: any; + startEvent: any; + doneEvent: any; + onStart(event: any) { this.startEvent = event; } + onDone(event: any) { this.doneEvent = event; } + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + cmp.exp = 'state'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(cmp.startEvent.triggerName).toEqual('myAnimation'); + expect(cmp.startEvent.phaseName).toEqual('start'); + expect(cmp.doneEvent.triggerName).toEqual('myAnimation'); + expect(cmp.doneEvent.phaseName).toEqual('done'); + async(); + }); + }); + }); +} diff --git a/modules/@angular/platform-browser/test/animation/animation_renderer_spec.ts b/modules/@angular/platform-browser/test/animation/animation_renderer_spec.ts index 59363a75e9..4e6f580e7e 100644 --- a/modules/@angular/platform-browser/test/animation/animation_renderer_spec.ts +++ b/modules/@angular/platform-browser/test/animation/animation_renderer_spec.ts @@ -5,12 +5,13 @@ * 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 {AnimationTriggerMetadata, trigger} from '@angular/animations'; -import {Injectable, RendererFactoryV2, RendererTypeV2} from '@angular/core'; +import {AnimationTriggerMetadata, animate, state, style, transition, trigger} from '@angular/animations'; +import {USE_VIEW_ENGINE} from '@angular/compiler/src/config'; +import {Component, Injectable, RendererFactoryV2, RendererTypeV2} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {BrowserAnimationModule, ɵAnimationEngine, ɵAnimationRendererFactory} from '@angular/platform-browser/animations'; -import {BrowserModule} from '../../src/browser'; +import {InjectableAnimationEngine} from '../../animations/src/browser_animation_module'; import {el} from '../../testing/browser_util'; export function main() { @@ -21,7 +22,7 @@ export function main() { TestBed.configureTestingModule({ providers: [{provide: ɵAnimationEngine, useClass: MockAnimationEngine}], - imports: [BrowserModule, BrowserAnimationModule] + imports: [BrowserAnimationModule] }); }); @@ -95,7 +96,7 @@ export function main() { expect(engine.captures['listen']).toBeFalsy(); renderer.listen(element, '@event.phase', cb); - expect(engine.captures['listen'].pop()).toEqual([element, 'event', 'phase', cb]); + expect(engine.captures['listen'].pop()).toEqual([element, 'event', 'phase']); }); it('should resolve the body|document|window nodes given their values as strings as input', @@ -115,6 +116,50 @@ export function main() { expect(engine.captures['listen'].pop()[0]).toBe(window); }); }); + + describe('flushing animations', () => { + beforeEach(() => { + TestBed.configureCompiler( + {useJit: true, providers: [{provide: USE_VIEW_ENGINE, useValue: true}]}); + }); + + it('should flush and fire callbacks when the zone becomes stable', (async) => { + @Component({ + selector: 'my-cmp', + template: '
', + animations: [trigger( + 'myAnimation', + [transition( + '* => state', + [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])], + }) + class Cmp { + exp: any; + event: any; + onStart(event: any) { this.event = event; } + } + + TestBed.configureTestingModule({ + providers: [{provide: ɵAnimationEngine, useClass: InjectableAnimationEngine}], + declarations: [Cmp] + }); + + const engine = TestBed.get(ɵAnimationEngine); + const fixture = TestBed.createComponent(Cmp); + const cmp = fixture.componentInstance; + cmp.exp = 'state'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(cmp.event.triggerName).toEqual('myAnimation'); + expect(cmp.event.phaseName).toEqual('start'); + cmp.event = null; + + engine.flush(); + expect(cmp.event).toBeFalsy(); + async(); + }); + }); + }); }); } @@ -140,7 +185,10 @@ class MockAnimationEngine extends ɵAnimationEngine { listen(element: any, eventName: string, eventPhase: string, callback: (event: any) => any): () => void { - this._capture('listen', [element, eventName, eventPhase, callback]); + // we don't capture the callback here since the renderer wraps it in a zone + this._capture('listen', [element, eventName, eventPhase]); return () => {}; } + + flush() {} } diff --git a/tools/public_api_guard/platform-browser/typings/animations/animations.d.ts b/tools/public_api_guard/platform-browser/typings/animations/animations.d.ts index ef3ac1f4dd..fab14eefa3 100644 --- a/tools/public_api_guard/platform-browser/typings/animations/animations.d.ts +++ b/tools/public_api_guard/platform-browser/typings/animations/animations.d.ts @@ -9,3 +9,7 @@ export declare abstract class AnimationDriver { /** @experimental */ export declare class BrowserAnimationModule { } + +/** @experimental */ +export declare class NoopBrowserAnimationModule { +}