feat(animate): adds basic support for CSS animations on enter and leave
Closes #3876
This commit is contained in:
188
modules/angular2/src/animate/animation.ts
Normal file
188
modules/angular2/src/animate/animation.ts
Normal file
@ -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<string, any>): 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]+$', ''), '');
|
||||
}
|
||||
}
|
19
modules/angular2/src/animate/animation_builder.ts
Normal file
19
modules/angular2/src/animate/animation_builder.ts
Normal file
@ -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); }
|
||||
}
|
54
modules/angular2/src/animate/browser_details.ts
Normal file
54
modules/angular2/src/animate/browser_details.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
93
modules/angular2/src/animate/css_animation_builder.ts
Normal file
93
modules/angular2/src/animate/css_animation_builder.ts
Normal file
@ -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<string, any>, to: StringMap<string, any>): CssAnimationBuilder {
|
||||
return this.setFromStyles(from).setToStyles(to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the initial styles for the animation
|
||||
* @param from
|
||||
*/
|
||||
setFromStyles(from: StringMap<string, any>): CssAnimationBuilder {
|
||||
this.data.fromStyles = from;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the destination styles for the animation
|
||||
* @param to
|
||||
*/
|
||||
setToStyles(to: StringMap<string, any>): 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);
|
||||
}
|
||||
}
|
22
modules/angular2/src/animate/css_animation_options.ts
Normal file
22
modules/angular2/src/animate/css_animation_options.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export class CssAnimationOptions {
|
||||
/** initial styles for the element */
|
||||
fromStyles: StringMap<string, any>;
|
||||
|
||||
/** destination styles for the element */
|
||||
toStyles: StringMap<string, any>;
|
||||
|
||||
/** 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;
|
||||
}
|
Reference in New Issue
Block a user