From 39ce9d33974bbeea30e9376608c6704ed71c48cb Mon Sep 17 00:00:00 2001 From: Robert Messerle Date: Fri, 28 Aug 2015 14:39:34 -0700 Subject: [PATCH] feat(animate): adds basic support for CSS animations on enter and leave Closes #3876 --- modules/angular2/angular2.ts | 3 +- modules/angular2/animate.ts | 5 + modules/angular2/src/animate/animation.ts | 188 ++++++++++++++++++ .../angular2/src/animate/animation_builder.ts | 19 ++ .../angular2/src/animate/browser_details.ts | 54 +++++ .../src/animate/css_animation_builder.ts | 93 +++++++++ .../src/animate/css_animation_options.ts | 22 ++ .../angular2/src/core/application_common.ts | 4 + .../src/core/dom/browser_adapter.dart | 10 + .../angular2/src/core/dom/browser_adapter.ts | 3 + modules/angular2/src/core/dom/dom_adapter.ts | 3 + .../angular2/src/core/dom/html_adapter.dart | 11 + .../angular2/src/core/dom/parse5_adapter.ts | 3 + modules/angular2/src/core/facade/math.dart | 2 + .../src/core/render/dom/dom_renderer.ts | 55 ++++- .../src/mock/animation_builder_mock.ts | 33 +++ .../angular2/src/test_lib/test_injector.ts | 3 + .../src/web_workers/ui/di_bindings.ts | 6 +- .../test/animate/animation_builder_spec.ts | 119 +++++++++++ modules/examples/pubspec.yaml | 3 +- modules/examples/src/animate/animate-app.ts | 14 ++ modules/examples/src/animate/css/app.css | 25 +++ modules/examples/src/animate/index.html | 10 + modules/examples/src/animate/index.ts | 6 + tools/broccoli/trees/browser_tree.ts | 1 + tools/broccoli/trees/node_tree.ts | 1 + 26 files changed, 688 insertions(+), 8 deletions(-) create mode 100644 modules/angular2/animate.ts create mode 100644 modules/angular2/src/animate/animation.ts create mode 100644 modules/angular2/src/animate/animation_builder.ts create mode 100644 modules/angular2/src/animate/browser_details.ts create mode 100644 modules/angular2/src/animate/css_animation_builder.ts create mode 100644 modules/angular2/src/animate/css_animation_options.ts create mode 100644 modules/angular2/src/mock/animation_builder_mock.ts create mode 100644 modules/angular2/test/animate/animation_builder_spec.ts create mode 100644 modules/examples/src/animate/animate-app.ts create mode 100644 modules/examples/src/animate/css/app.css create mode 100644 modules/examples/src/animate/index.html create mode 100644 modules/examples/src/animate/index.ts diff --git a/modules/angular2/angular2.ts b/modules/angular2/angular2.ts index 6cf3a5f6de..fcca221dde 100644 --- a/modules/angular2/angular2.ts +++ b/modules/angular2/angular2.ts @@ -1,4 +1,5 @@ export * from './core'; export * from './profile'; export * from './lifecycle_hooks'; -export * from './bootstrap'; \ No newline at end of file +export * from './bootstrap'; +export * from './animate'; diff --git a/modules/angular2/animate.ts b/modules/angular2/animate.ts new file mode 100644 index 0000000000..0fd63ccd03 --- /dev/null +++ b/modules/angular2/animate.ts @@ -0,0 +1,5 @@ +export {Animation} from './src/animate/animation'; +export {AnimationBuilder} from './src/animate/animation_builder'; +export {BrowserDetails} from './src/animate/browser_details'; +export {CssAnimationBuilder} from './src/animate/css_animation_builder'; +export {CssAnimationOptions} from './src/animate/css_animation_options'; diff --git a/modules/angular2/src/animate/animation.ts b/modules/angular2/src/animate/animation.ts new file mode 100644 index 0000000000..621d3311c7 --- /dev/null +++ b/modules/angular2/src/animate/animation.ts @@ -0,0 +1,188 @@ +import { + DateWrapper, + StringWrapper, + RegExpWrapper, + NumberWrapper +} from 'angular2/src/core/facade/lang'; +import {Math} from 'angular2/src/core/facade/math'; +import {camelCaseToDashCase} from 'angular2/src/core/render/dom/util'; +import {StringMapWrapper} from 'angular2/src/core/facade/collection'; +import {DOM} from 'angular2/src/core/dom/dom_adapter'; + +import {BrowserDetails} from './browser_details'; +import {CssAnimationOptions} from './css_animation_options'; + +export class Animation { + /** functions to be called upon completion */ + callbacks: Function[] = []; + + /** the duration (ms) of the animation (whether from CSS or manually set) */ + computedDuration: number; + + /** the animation delay (ms) (whether from CSS or manually set) */ + computedDelay: number; + + /** timestamp of when the animation started */ + startTime: number; + + /** functions for removing event listeners */ + eventClearFunctions: Function[] = []; + + /** flag used to track whether or not the animation has finished */ + completed: boolean = false; + + /** total amount of time that the animation should take including delay */ + get totalTime(): number { + let delay = this.computedDelay != null ? this.computedDelay : 0; + let duration = this.computedDuration != null ? this.computedDuration : 0; + return delay + duration; + } + + /** + * Stores the start time and starts the animation + * @param element + * @param data + * @param browserDetails + */ + constructor(public element: HTMLElement, public data: CssAnimationOptions, + public browserDetails: BrowserDetails) { + this.startTime = DateWrapper.toMillis(DateWrapper.now()); + this.setup(); + this.wait(timestamp => this.start()); + } + + wait(callback: Function) { + // Firefox requires 2 frames for some reason + this.browserDetails.raf(callback, 2); + } + + /** + * Sets up the initial styles before the animation is started + */ + setup(): void { + if (this.data.fromStyles != null) this.applyStyles(this.data.fromStyles); + if (this.data.duration != null) + this.applyStyles({'transitionDuration': this.data.duration.toString() + 'ms'}); + if (this.data.delay != null) + this.applyStyles({'transitionDelay': this.data.delay.toString() + 'ms'}); + } + + /** + * After the initial setup has occurred, this method adds the animation styles + */ + start(): void { + this.addClasses(this.data.classesToAdd); + this.addClasses(this.data.animationClasses); + this.removeClasses(this.data.classesToRemove); + if (this.data.toStyles != null) this.applyStyles(this.data.toStyles); + var computedStyles = DOM.getComputedStyle(this.element); + this.computedDelay = + Math.max(this.parseDurationString(computedStyles.getPropertyValue('transition-delay')), + this.parseDurationString(this.element.style.getPropertyValue('transition-delay'))); + this.computedDuration = Math.max( + this.parseDurationString(computedStyles.getPropertyValue('transition-duration')), + this.parseDurationString(this.element.style.getPropertyValue('transition-duration'))); + this.addEvents(); + } + + /** + * Applies the provided styles to the element + * @param styles + */ + applyStyles(styles: StringMap): void { + StringMapWrapper.forEach(styles, (value, key) => { + DOM.setStyle(this.element, camelCaseToDashCase(key), value.toString()); + }); + } + + /** + * Adds the provided classes to the element + * @param classes + */ + addClasses(classes: string[]): void { + for (let i = 0, len = classes.length; i < len; i++) DOM.addClass(this.element, classes[i]); + } + + /** + * Removes the provided classes from the element + * @param classes + */ + removeClasses(classes: string[]): void { + for (let i = 0, len = classes.length; i < len; i++) DOM.removeClass(this.element, classes[i]); + } + + /** + * Adds events to track when animations have finished + */ + addEvents(): void { + if (this.totalTime > 0) { + this.eventClearFunctions.push(DOM.onAndCancel( + this.element, 'transitionend', (event: any) => this.handleAnimationEvent(event))); + } else { + this.handleAnimationCompleted(); + } + } + + handleAnimationEvent(event: any): void { + let elapsedTime = Math.round(event.elapsedTime * 1000); + if (!this.browserDetails.elapsedTimeIncludesDelay) elapsedTime += this.computedDelay; + event.stopPropagation(); + if (elapsedTime >= this.totalTime) this.handleAnimationCompleted(); + } + + /** + * Runs all animation callbacks and removes temporary classes + */ + handleAnimationCompleted(): void { + this.removeClasses(this.data.animationClasses); + this.callbacks.forEach(callback => callback()); + this.callbacks = []; + this.eventClearFunctions.forEach(fn => fn()); + this.eventClearFunctions = []; + this.completed = true; + } + + /** + * Adds animation callbacks to be called upon completion + * @param callback + * @returns {Animation} + */ + onComplete(callback: Function): Animation { + if (this.completed) { + callback(); + } else { + this.callbacks.push(callback); + } + return this; + } + + /** + * Converts the duration string to the number of milliseconds + * @param duration + * @returns {number} + */ + parseDurationString(duration: string): number { + var maxValue = 0; + // duration must have at least 2 characters to be valid. (number + type) + if (duration == null || duration.length < 2) { + return maxValue; + } else if (duration.substring(duration.length - 2) == 'ms') { + let value = NumberWrapper.parseInt(this.stripLetters(duration), 10); + if (value > maxValue) maxValue = value; + } else if (duration.substring(duration.length - 1) == 's') { + let ms = NumberWrapper.parseFloat(this.stripLetters(duration)) * 1000; + let value = Math.floor(ms); + if (value > maxValue) maxValue = value; + } + return maxValue; + } + + /** + * Strips the letters from the duration string + * @param str + * @returns {string} + */ + stripLetters(str: string): string { + return StringWrapper.replaceAll(str, RegExpWrapper.create('[^0-9]+$', ''), ''); + } +} diff --git a/modules/angular2/src/animate/animation_builder.ts b/modules/angular2/src/animate/animation_builder.ts new file mode 100644 index 0000000000..3b82e629ff --- /dev/null +++ b/modules/angular2/src/animate/animation_builder.ts @@ -0,0 +1,19 @@ +import {Injectable} from 'angular2/src/core/di'; + +import {CssAnimationBuilder} from './css_animation_builder'; +import {BrowserDetails} from './browser_details'; + +@Injectable() +export class AnimationBuilder { + /** + * Used for DI + * @param browserDetails + */ + constructor(public browserDetails: BrowserDetails) {} + + /** + * Creates a new CSS Animation + * @returns {CssAnimationBuilder} + */ + css(): CssAnimationBuilder { return new CssAnimationBuilder(this.browserDetails); } +} diff --git a/modules/angular2/src/animate/browser_details.ts b/modules/angular2/src/animate/browser_details.ts new file mode 100644 index 0000000000..5ba634213c --- /dev/null +++ b/modules/angular2/src/animate/browser_details.ts @@ -0,0 +1,54 @@ +import {Injectable} from 'angular2/src/core/di'; +import {Math} from 'angular2/src/core/facade/math'; +import {DOM} from 'angular2/src/core/dom/dom_adapter'; + +@Injectable() +export class BrowserDetails { + elapsedTimeIncludesDelay = false; + + constructor() { this.doesElapsedTimeIncludesDelay(); } + + /** + * Determines if `event.elapsedTime` includes transition delay in the current browser. At this + * time, Chrome and Opera seem to be the only browsers that include this. + */ + doesElapsedTimeIncludesDelay(): void { + var div = DOM.createElement('div'); + DOM.setAttribute(div, 'style', `position: absolute; top: -9999px; left: -9999px; width: 1px; + height: 1px; transition: all 1ms linear 1ms;`); + // Firefox requires that we wait for 2 frames for some reason + this.raf(timestamp => { + DOM.on(div, 'transitionend', (event: any) => { + var elapsed = Math.round(event.elapsedTime * 1000); + this.elapsedTimeIncludesDelay = elapsed == 2; + DOM.remove(div); + }); + DOM.setStyle(div, 'width', '2px'); + }, 2); + } + + raf(callback: Function, frames: number = 1): Function { + var queue: RafQueue = new RafQueue(callback, frames); + return () => queue.cancel(); + } +} + +class RafQueue { + currentFrameId: number; + constructor(public callback: Function, public frames: number) { this._raf(); } + private _raf() { + this.currentFrameId = DOM.requestAnimationFrame(timestamp => this._nextFrame(timestamp)); + } + private _nextFrame(timestamp: number) { + this.frames--; + if (this.frames > 0) { + this._raf(); + } else { + this.callback(timestamp); + } + } + cancel() { + DOM.cancelAnimationFrame(this.currentFrameId); + this.currentFrameId = null; + } +} diff --git a/modules/angular2/src/animate/css_animation_builder.ts b/modules/angular2/src/animate/css_animation_builder.ts new file mode 100644 index 0000000000..1b5a4e73fc --- /dev/null +++ b/modules/angular2/src/animate/css_animation_builder.ts @@ -0,0 +1,93 @@ +import {CssAnimationOptions} from './css_animation_options'; +import {Animation} from './animation'; +import {BrowserDetails} from './browser_details'; + +export class CssAnimationBuilder { + /** @type {CssAnimationOptions} */ + data: CssAnimationOptions = new CssAnimationOptions(); + + /** + * Accepts public properties for CssAnimationBuilder + */ + constructor(public browserDetails: BrowserDetails) {} + + /** + * Adds a temporary class that will be removed at the end of the animation + * @param className + */ + addAnimationClass(className: string): CssAnimationBuilder { + this.data.animationClasses.push(className); + return this; + } + + /** + * Adds a class that will remain on the element after the animation has finished + * @param className + */ + addClass(className: string): CssAnimationBuilder { + this.data.classesToAdd.push(className); + return this; + } + + /** + * Removes a class from the element + * @param className + */ + removeClass(className: string): CssAnimationBuilder { + this.data.classesToRemove.push(className); + return this; + } + + /** + * Sets the animation duration (and overrides any defined through CSS) + * @param duration + */ + setDuration(duration: number): CssAnimationBuilder { + this.data.duration = duration; + return this; + } + + /** + * Sets the animation delay (and overrides any defined through CSS) + * @param delay + */ + setDelay(delay: number): CssAnimationBuilder { + this.data.delay = delay; + return this; + } + + /** + * Sets styles for both the initial state and the destination state + * @param from + * @param to + */ + setStyles(from: StringMap, to: StringMap): CssAnimationBuilder { + return this.setFromStyles(from).setToStyles(to); + } + + /** + * Sets the initial styles for the animation + * @param from + */ + setFromStyles(from: StringMap): CssAnimationBuilder { + this.data.fromStyles = from; + return this; + } + + /** + * Sets the destination styles for the animation + * @param to + */ + setToStyles(to: StringMap): CssAnimationBuilder { + this.data.toStyles = to; + return this; + } + + /** + * Starts the animation and returns a promise + * @param element + */ + start(element: HTMLElement): Animation { + return new Animation(element, this.data, this.browserDetails); + } +} diff --git a/modules/angular2/src/animate/css_animation_options.ts b/modules/angular2/src/animate/css_animation_options.ts new file mode 100644 index 0000000000..6fcb9e8f4f --- /dev/null +++ b/modules/angular2/src/animate/css_animation_options.ts @@ -0,0 +1,22 @@ +export class CssAnimationOptions { + /** initial styles for the element */ + fromStyles: StringMap; + + /** destination styles for the element */ + toStyles: StringMap; + + /** classes to be added to the element */ + classesToAdd: string[] = []; + + /** classes to be removed from the element */ + classesToRemove: string[] = []; + + /** classes to be added for the duration of the animation */ + animationClasses: string[] = []; + + /** override the duration of the animation (in milliseconds) */ + duration: number; + + /** override the transition delay (in milliseconds) */ + delay: number; +} diff --git a/modules/angular2/src/core/application_common.ts b/modules/angular2/src/core/application_common.ts index 463b517271..27c4ad97b2 100644 --- a/modules/angular2/src/core/application_common.ts +++ b/modules/angular2/src/core/application_common.ts @@ -86,6 +86,8 @@ import {APP_COMPONENT_REF_PROMISE, APP_COMPONENT} from './application_tokens'; import {wtfInit} from './profile/wtf_init'; import {EXCEPTION_BINDING} from './platform_bindings'; import {ApplicationRef} from './application_ref'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; +import {BrowserDetails} from 'angular2/src/animate/browser_details'; var _rootInjector: Injector; @@ -161,6 +163,8 @@ function _injectorBindings(appComponentType): Array { Testability, AnchorBasedAppRootUrl, bind(AppRootUrl).toAlias(AnchorBasedAppRootUrl), + BrowserDetails, + AnimationBuilder, FORM_BINDINGS ]; } diff --git a/modules/angular2/src/core/dom/browser_adapter.dart b/modules/angular2/src/core/dom/browser_adapter.dart index d3518a40dc..6a2cbc24d1 100644 --- a/modules/angular2/src/core/dom/browser_adapter.dart +++ b/modules/angular2/src/core/dom/browser_adapter.dart @@ -451,6 +451,8 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { return element.dataset[name]; } + getComputedStyle(elem) => elem.getComputedStyle(); + // TODO(tbosch): move this into a separate environment class once we have it setGlobalVar(String path, value) { var parts = path.split('.'); @@ -465,6 +467,14 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { } obj[parts.removeAt(0)] = value; } + + requestAnimationFrame(callback) { + return window.requestAnimationFrame(callback); + } + + cancelAnimationFrame(id) { + window.cancelAnimationFrame(id); + } } var baseElement = null; diff --git a/modules/angular2/src/core/dom/browser_adapter.ts b/modules/angular2/src/core/dom/browser_adapter.ts index 9854291e69..c362ded848 100644 --- a/modules/angular2/src/core/dom/browser_adapter.ts +++ b/modules/angular2/src/core/dom/browser_adapter.ts @@ -317,8 +317,11 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { this.setAttribute(element, 'data-' + name, value); } getData(element, name: string): string { return this.getAttribute(element, 'data-' + name); } + getComputedStyle(element): any { return getComputedStyle(element); } // TODO(tbosch): move this into a separate environment class once we have it setGlobalVar(path: string, value: any) { setValueOnPath(global, path, value); } + requestAnimationFrame(callback): number { return window.requestAnimationFrame(callback); } + cancelAnimationFrame(id: number) { window.cancelAnimationFrame(id); } } diff --git a/modules/angular2/src/core/dom/dom_adapter.ts b/modules/angular2/src/core/dom/dom_adapter.ts index f667d6452c..26fa9040e2 100644 --- a/modules/angular2/src/core/dom/dom_adapter.ts +++ b/modules/angular2/src/core/dom/dom_adapter.ts @@ -133,6 +133,9 @@ export class DomAdapter { resetBaseElement(): void { throw _abstract(); } getUserAgent(): string { throw _abstract(); } setData(element, name: string, value: string) { throw _abstract(); } + getComputedStyle(element): any { throw _abstract(); } getData(element, name: string): string { throw _abstract(); } setGlobalVar(name: string, value: any) { throw _abstract(); } + requestAnimationFrame(callback): number { throw _abstract(); } + cancelAnimationFrame(id) { throw _abstract(); } } diff --git a/modules/angular2/src/core/dom/html_adapter.dart b/modules/angular2/src/core/dom/html_adapter.dart index 41bdd9b951..e48c8df3c0 100644 --- a/modules/angular2/src/core/dom/html_adapter.dart +++ b/modules/angular2/src/core/dom/html_adapter.dart @@ -412,6 +412,10 @@ class Html5LibDomAdapter implements DomAdapter { this.setAttribute(element, 'data-${name}', value); } + getComputedStyle(element) { + throw 'not implemented'; + } + String getData(Element element, String name) { return this.getAttribute(element, 'data-${name}'); } @@ -420,4 +424,11 @@ class Html5LibDomAdapter implements DomAdapter { setGlobalVar(String name, value) { // noop on the server } + + requestAnimationFrame(callback) { + throw 'not implemented'; + } + cancelAnimationFrame(id) { + throw 'not implemented'; + } } diff --git a/modules/angular2/src/core/dom/parse5_adapter.ts b/modules/angular2/src/core/dom/parse5_adapter.ts index c91a6ddfc4..26faa53f18 100644 --- a/modules/angular2/src/core/dom/parse5_adapter.ts +++ b/modules/angular2/src/core/dom/parse5_adapter.ts @@ -539,9 +539,12 @@ export class Parse5DomAdapter extends DomAdapter { getLocation(): Location { throw 'not implemented'; } getUserAgent(): string { return "Fake user agent"; } getData(el, name: string): string { return this.getAttribute(el, 'data-' + name); } + getComputedStyle(el): any { throw 'not implemented'; } setData(el, name: string, value: string) { this.setAttribute(el, 'data-' + name, value); } // TODO(tbosch): move this into a separate environment class once we have it setGlobalVar(path: string, value: any) { setValueOnPath(global, path, value); } + requestAnimationFrame(callback): number { return setTimeout(callback, 0); } + cancelAnimationFrame(id: number) { clearTimeout(id); } } // TODO: build a proper list, this one is all the keys of a HTMLInputElement diff --git a/modules/angular2/src/core/facade/math.dart b/modules/angular2/src/core/facade/math.dart index 8e5cfa9d43..08adb680d3 100644 --- a/modules/angular2/src/core/facade/math.dart +++ b/modules/angular2/src/core/facade/math.dart @@ -19,4 +19,6 @@ class Math { static num ceil(num a) => a.ceil(); static num sqrt(num x) => math.sqrt(x); + + static num round(num x) => x.round(); } diff --git a/modules/angular2/src/core/render/dom/dom_renderer.ts b/modules/angular2/src/core/render/dom/dom_renderer.ts index 276ab5edea..e61601d21d 100644 --- a/modules/angular2/src/core/render/dom/dom_renderer.ts +++ b/modules/angular2/src/core/render/dom/dom_renderer.ts @@ -1,4 +1,5 @@ import {Inject, Injectable, OpaqueToken} from 'angular2/src/core/di'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; import {isPresent, isBlank, RegExpWrapper, CONST_EXPR} from 'angular2/src/core/facade/lang'; import {BaseException, WrappedException} from 'angular2/src/core/facade/exceptions'; @@ -42,7 +43,7 @@ export class DomRenderer extends Renderer { * @private */ constructor(private _eventManager: EventManager, - private _domSharedStylesHost: DomSharedStylesHost, + private _domSharedStylesHost: DomSharedStylesHost, private _animate: AnimationBuilder, private _templateCloner: TemplateCloner, @Inject(DOCUMENT) document) { super(); this._document = document; @@ -94,7 +95,51 @@ export class DomRenderer extends Renderer { var previousFragmentNodes = resolveInternalDomFragment(previousFragmentRef); if (previousFragmentNodes.length > 0) { var sibling = previousFragmentNodes[previousFragmentNodes.length - 1]; - moveNodesAfterSibling(sibling, resolveInternalDomFragment(fragmentRef)); + let nodes = resolveInternalDomFragment(fragmentRef); + moveNodesAfterSibling(sibling, nodes); + this.animateNodesEnter(nodes); + } + } + + /** + * Iterates through all nodes being added to the DOM and animates them if necessary + * @param nodes + */ + animateNodesEnter(nodes: Node[]) { + for (let i = 0; i < nodes.length; i++) this.animateNodeEnter(nodes[i]); + } + + /** + * Performs animations if necessary + * @param node + */ + animateNodeEnter(node: Node) { + if (DOM.isElementNode(node) && DOM.hasClass(node, 'ng-animate')) { + DOM.addClass(node, 'ng-enter'); + this._animate.css() + .addAnimationClass('ng-enter-active') + .start(node) + .onComplete(() => { DOM.removeClass(node, 'ng-enter'); }); + } + } + + /** + * If animations are necessary, performs animations then removes the element; otherwise, it just + * removes the element. + * @param node + */ + animateNodeLeave(node: Node) { + if (DOM.isElementNode(node) && DOM.hasClass(node, 'ng-animate')) { + DOM.addClass(node, 'ng-leave'); + this._animate.css() + .addAnimationClass('ng-leave-active') + .start(node) + .onComplete(() => { + DOM.removeClass(node, 'ng-leave'); + DOM.remove(node); + }); + } else { + DOM.remove(node); } } @@ -104,7 +149,9 @@ export class DomRenderer extends Renderer { } var parentView = resolveInternalDomView(elementRef.renderView); var element = parentView.boundElements[elementRef.renderBoundElementIndex]; - moveNodesAfterSibling(element, resolveInternalDomFragment(fragmentRef)); + var nodes = resolveInternalDomFragment(fragmentRef); + moveNodesAfterSibling(element, nodes); + this.animateNodesEnter(nodes); } _detachFragmentScope = wtfCreateScope('DomRenderer#detachFragment()'); @@ -112,7 +159,7 @@ export class DomRenderer extends Renderer { var s = this._detachFragmentScope(); var fragmentNodes = resolveInternalDomFragment(fragmentRef); for (var i = 0; i < fragmentNodes.length; i++) { - DOM.remove(fragmentNodes[i]); + this.animateNodeLeave(fragmentNodes[i]); } wtfLeave(s); } diff --git a/modules/angular2/src/mock/animation_builder_mock.ts b/modules/angular2/src/mock/animation_builder_mock.ts new file mode 100644 index 0000000000..8aa7758cd8 --- /dev/null +++ b/modules/angular2/src/mock/animation_builder_mock.ts @@ -0,0 +1,33 @@ +import {Injectable} from 'angular2/src/core/di'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; +import {CssAnimationBuilder} from 'angular2/src/animate/css_animation_builder'; +import {CssAnimationOptions} from 'angular2/src/animate/css_animation_options'; +import {Animation} from 'angular2/src/animate/animation'; +import {BrowserDetails} from 'angular2/src/animate/browser_details'; + +@Injectable() +export class MockAnimationBuilder extends AnimationBuilder { + constructor() { super(null); } + css(): MockCssAnimationBuilder { return new MockCssAnimationBuilder(); } +} + +class MockCssAnimationBuilder extends CssAnimationBuilder { + constructor() { super(null); } + start(element: HTMLElement): Animation { return new MockAnimation(element, this.data); } +} + +class MockBrowserAbstraction extends BrowserDetails { + doesElapsedTimeIncludesDelay(): void { this.elapsedTimeIncludesDelay = false; } +} + +class MockAnimation extends Animation { + private _callback: Function; + constructor(element: HTMLElement, data: CssAnimationOptions) { + super(element, data, new MockBrowserAbstraction()); + } + wait(callback: Function) { this._callback = callback; } + flush() { + this._callback(0); + this._callback = null; + } +} diff --git a/modules/angular2/src/test_lib/test_injector.ts b/modules/angular2/src/test_lib/test_injector.ts index ecb13460f5..0ac2eccf15 100644 --- a/modules/angular2/src/test_lib/test_injector.ts +++ b/modules/angular2/src/test_lib/test_injector.ts @@ -1,5 +1,7 @@ import {bind, Binding} from 'angular2/src/core/di'; import {DEFAULT_PIPES} from 'angular2/src/core/pipes'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; +import {MockAnimationBuilder} from 'angular2/src/mock/animation_builder_mock'; import {Compiler, CompilerCache} from 'angular2/src/core/compiler/compiler'; import {Reflector, reflector} from 'angular2/src/core/reflection/reflection'; @@ -149,6 +151,7 @@ function _getAppBindings() { StyleInliner, TestComponentBuilder, bind(NgZone).toClass(MockNgZone), + bind(AnimationBuilder).toClass(MockAnimationBuilder), EventManager, new Binding(EVENT_MANAGER_PLUGINS, {toClass: DomEventsPlugin, multi: true}) ]; diff --git a/modules/angular2/src/web_workers/ui/di_bindings.ts b/modules/angular2/src/web_workers/ui/di_bindings.ts index e3ff123919..d9a65ca9eb 100644 --- a/modules/angular2/src/web_workers/ui/di_bindings.ts +++ b/modules/angular2/src/web_workers/ui/di_bindings.ts @@ -2,6 +2,8 @@ // There should be a way to refactor application so that this file is unnecessary. See #3277 import {Injector, bind, Binding} from "angular2/src/core/di"; import {DEFAULT_PIPES} from 'angular2/src/core/pipes'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; +import {BrowserDetails} from 'angular2/src/animate/browser_details'; import {Reflector, reflector} from 'angular2/src/core/reflection/reflection'; import { Parser, @@ -140,7 +142,9 @@ function _injectorBindings(): any[] { MessageBasedXHRImpl, MessageBasedRenderer, ServiceMessageBrokerFactory, - ClientMessageBrokerFactory + ClientMessageBrokerFactory, + BrowserDetails, + AnimationBuilder, ]; } diff --git a/modules/angular2/test/animate/animation_builder_spec.ts b/modules/angular2/test/animate/animation_builder_spec.ts new file mode 100644 index 0000000000..14f360c736 --- /dev/null +++ b/modules/angular2/test/animate/animation_builder_spec.ts @@ -0,0 +1,119 @@ +import {el, describe, it, expect, inject, SpyObject} from 'angular2/test_lib'; +import {AnimationBuilder} from 'angular2/src/animate/animation_builder'; + +export function main() { + describe("AnimationBuilder", () => { + + it('should have data object', inject([AnimationBuilder], animate => { + var animateCss = animate.css(); + expect(animateCss.data).toBeDefined(); + })); + + it('should allow you to add classes', inject([AnimationBuilder], animate => { + var animateCss = animate.css(); + animateCss.addClass('some-class'); + expect(animateCss.data.classesToAdd).toEqual(['some-class']); + animateCss.addClass('another-class'); + expect(animateCss.data.classesToAdd).toEqual(['some-class', 'another-class']); + })); + + it('should allow you to add temporary classes', inject([AnimationBuilder], animate => { + var animateCss = animate.css(); + animateCss.addAnimationClass('some-class'); + expect(animateCss.data.animationClasses).toEqual(['some-class']); + animateCss.addAnimationClass('another-class'); + expect(animateCss.data.animationClasses).toEqual(['some-class', 'another-class']); + })); + + it('should allow you to remove classes', inject([AnimationBuilder], animate => { + var animateCss = animate.css(); + animateCss.removeClass('some-class'); + expect(animateCss.data.classesToRemove).toEqual(['some-class']); + animateCss.removeClass('another-class'); + expect(animateCss.data.classesToRemove).toEqual(['some-class', 'another-class']); + })); + + it('should support chaining', inject([AnimationBuilder], animate => { + var animateCss = animate.css() + .addClass('added-class') + .removeClass('removed-class') + .addAnimationClass('temp-class') + .addClass('another-added-class'); + expect(animateCss.data.classesToAdd).toEqual(['added-class', 'another-added-class']); + expect(animateCss.data.classesToRemove).toEqual(['removed-class']); + expect(animateCss.data.animationClasses).toEqual(['temp-class']); + })); + + it('should support duration and delay', inject([AnimationBuilder], (animate) => { + var animateCss = animate.css(); + animateCss.setDelay(100).setDuration(200); + expect(animateCss.data.duration).toBe(200); + expect(animateCss.data.delay).toBe(100); + + var element = el('
'); + var runner = animateCss.start(element); + runner.flush(); + + expect(runner.computedDelay).toBe(100); + expect(runner.computedDuration).toBe(200); + })); + + it('should support from styles', inject([AnimationBuilder], animate => { + var animateCss = animate.css(); + animateCss.setFromStyles({'backgroundColor': 'blue'}); + expect(animateCss.data.fromStyles).toBeDefined(); + + var element = el('
'); + animateCss.start(element); + + expect(element.style.getPropertyValue('background-color')).toEqual('blue'); + })); + + it('should support duration and delay defined in CSS', inject([AnimationBuilder], (animate) => { + var animateCss = animate.css(); + var element = el('
'); + var runner = animateCss.start(element); + runner.flush(); + + expect(runner.computedDuration).toEqual(500); + expect(runner.computedDelay).toEqual(250); + })); + + it('should add classes', inject([AnimationBuilder], (animate) => { + var animateCss = animate.css().addClass('one').addClass('two'); + var element = el('
'); + var runner = animateCss.start(element); + + expect(element).not.toHaveCssClass('one'); + expect(element).not.toHaveCssClass('two'); + + runner.flush(); + + expect(element).toHaveCssClass('one'); + expect(element).toHaveCssClass('two'); + })); + + it('should call `onComplete` method after animations have finished', + inject([AnimationBuilder], (animate) => { + var spyObject = new SpyObject(); + var callback = spyObject.spy('animationFinished'); + var runner = animate.css() + .addClass('one') + .addClass('two') + .setDuration(100) + .start(el('
')) + .onComplete(callback); + + expect(callback).not.toHaveBeenCalled(); + + runner.flush(); + + expect(callback).not.toHaveBeenCalled(); + + runner.handleAnimationCompleted(); + + expect(callback).toHaveBeenCalled(); + })); + + }); +} diff --git a/modules/examples/pubspec.yaml b/modules/examples/pubspec.yaml index 3153975d4c..f9f6fc70f0 100644 --- a/modules/examples/pubspec.yaml +++ b/modules/examples/pubspec.yaml @@ -21,7 +21,6 @@ transformers: # The build currently fails on material files because there is not yet # support for transforming cross-package urls. (see issue #2982) - 'web/src/material/**' - - 'web/src/zippy_component/**' # No need to transform the dart:mirrors specific entrypoints - '**/index_dynamic.dart' entry_points: @@ -39,6 +38,7 @@ transformers: - web/src/web_workers/todo/background_index.dart - web/src/web_workers/message_broker/background_index.dart - web/src/web_workers/kitchen_sink/background_index.dart + - web/src/zippy_component/index.dart # These entrypoints are disabled untl the transformer supports UI bootstrap (issue #3971) # - web/src/web_workers/message_broker/index.dart @@ -53,7 +53,6 @@ transformers: # - web/src/material/progress-linear/index.dart # - web/src/material/radio/index.dart # - web/src/material/switcher/index.dart - # - web/src/zippy_component/index.dart # # This entrypoint is not needed: # - web/src/material/demo_common.dart diff --git a/modules/examples/src/animate/animate-app.ts b/modules/examples/src/animate/animate-app.ts new file mode 100644 index 0000000000..b67659326f --- /dev/null +++ b/modules/examples/src/animate/animate-app.ts @@ -0,0 +1,14 @@ +import {Component, View, NgIf} from 'angular2/angular2'; + +@Component({selector: 'animate-app'}) +@View({ + directives: [NgIf], + template: ` +

The box is {{visible ? 'visible' : 'hidden'}}

+
+ + ` +}) +export class AnimateApp { + visible: boolean = false; +} diff --git a/modules/examples/src/animate/css/app.css b/modules/examples/src/animate/css/app.css new file mode 100644 index 0000000000..7020d0a6be --- /dev/null +++ b/modules/examples/src/animate/css/app.css @@ -0,0 +1,25 @@ +body { + padding: 20px; +} + +.box { + width: 100px; + height: 100px; + background-color: red; + transition: all 0.35s ease-in-out; +} +.box.blue { + background-color: blue; +} +.box.ng-enter { + opacity: 0; + height: 0; +} +.box.ng-enter-active { + opacity: 1; + height: 100px; +} +.box.ng-leave-active { + opacity: 0; + height: 0; +} diff --git a/modules/examples/src/animate/index.html b/modules/examples/src/animate/index.html new file mode 100644 index 0000000000..cdd04ab719 --- /dev/null +++ b/modules/examples/src/animate/index.html @@ -0,0 +1,10 @@ + + + Animation Example + + + + Loading... + $SCRIPTS$ + + diff --git a/modules/examples/src/animate/index.ts b/modules/examples/src/animate/index.ts new file mode 100644 index 0000000000..0ab7020b9a --- /dev/null +++ b/modules/examples/src/animate/index.ts @@ -0,0 +1,6 @@ +import {AnimateApp} from './animate-app'; +import {bootstrap} from 'angular2/bootstrap'; + +export function main() { + bootstrap(AnimateApp); +} diff --git a/tools/broccoli/trees/browser_tree.ts b/tools/broccoli/trees/browser_tree.ts index 931e4b5af2..469fe4d8ea 100644 --- a/tools/broccoli/trees/browser_tree.ts +++ b/tools/broccoli/trees/browser_tree.ts @@ -40,6 +40,7 @@ const kServedPaths = [ 'benchmarks_external/src/static_tree', // Relative (to /modules) paths to example directories + 'examples/src/animate', 'examples/src/benchpress', 'examples/src/model_driven_forms', 'examples/src/template_driven_forms', diff --git a/tools/broccoli/trees/node_tree.ts b/tools/broccoli/trees/node_tree.ts index 26a53b290c..de222aaa00 100644 --- a/tools/broccoli/trees/node_tree.ts +++ b/tools/broccoli/trees/node_tree.ts @@ -20,6 +20,7 @@ module.exports = function makeNodeTree(destinationPath) { include: ['angular2/**', 'benchpress/**', '**/e2e_test/**'], exclude: [ // the following code and tests are not compatible with CJS/node environment + 'angular2/test/animate/**', 'angular2/test/core/zone/**', 'angular2/test/test_lib/fake_async_spec.ts', 'angular2/test/core/render/xhr_impl_spec.ts',