463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
/**
|
|
* @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 = 'ng-animate';
|
|
const MARKED_FOR_REMOVAL = '$$ngRemove';
|
|
|
|
export class DomAnimationEngine {
|
|
private _flaggedInserts = new Set<any>();
|
|
private _queuedRemovals = new Map<any, () => any>();
|
|
private _queuedTransitionAnimations: QueuedAnimationTransitionTuple[] = [];
|
|
private _activeTransitionAnimations = new Map<any, {[triggerName: string]: AnimationPlayer}>();
|
|
private _activeElementAnimations = new Map<any, AnimationPlayer[]>();
|
|
|
|
private _elementTriggerStates = new Map<any, {[triggerName: string]: string}>();
|
|
|
|
private _triggers: {[triggerName: string]: AnimationTrigger} = Object.create(null);
|
|
private _triggerListeners = new Map<any, TriggerListenerTuple[]>();
|
|
|
|
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 = null): void {
|
|
name = name || trigger.name;
|
|
if (this._triggers[name]) {
|
|
return;
|
|
}
|
|
this._triggers[name] = buildTrigger(name, trigger.definitions);
|
|
}
|
|
|
|
onInsert(element: any, domFn: () => any): void {
|
|
this._flaggedInserts.add(element);
|
|
domFn();
|
|
}
|
|
|
|
onRemove(element: any, domFn: () => any): void {
|
|
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');
|
|
return !!instruction;
|
|
});
|
|
if (hasRemoval) {
|
|
element[MARKED_FOR_REMOVAL] = true;
|
|
this._queuedRemovals.set(element, domFn);
|
|
return;
|
|
}
|
|
}
|
|
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[property] || 'void';
|
|
if (oldValue != 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 = <TriggerListenerTuple>{triggerName: eventName, phase: eventPhase, callback};
|
|
elementListeners.push(tuple);
|
|
return () => {
|
|
const index = elementListeners.indexOf(tuple);
|
|
if (index >= 0) {
|
|
elementListeners.splice(index, 1);
|
|
}
|
|
};
|
|
}
|
|
|
|
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);
|
|
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 => {
|
|
totalTime = Math.max(totalTime, timelineInstruction.totalTime);
|
|
return this._buildPlayer(element, timelineInstruction, previousPlayers);
|
|
});
|
|
|
|
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 => {
|
|
const player = this._buildPlayer(element, instruction, previousPlayers);
|
|
player.onDestroy(
|
|
() => { deleteFromArrayMap(this._activeElementAnimations, element, player); });
|
|
player.init();
|
|
|
|
this._markPlayerAsActive(element, player);
|
|
return player;
|
|
});
|
|
return optimizeGroupPlayer(players);
|
|
}
|
|
|
|
private _buildPlayer(
|
|
element: any, instruction: AnimationTimelineInstruction,
|
|
previousPlayers: AnimationPlayer[]): AnimationPlayer {
|
|
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 = <QueuedAnimationTransitionTuple>{element, player, triggerName, event};
|
|
this._queuedTransitionAnimations.push(tuple);
|
|
player.init();
|
|
|
|
element.classList.add(MARKED_FOR_ANIMATION);
|
|
player.onDone(() => { element.classList.remove(MARKED_FOR_ANIMATION); });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
const listeners = this._triggerListeners.get(element);
|
|
if (listeners) {
|
|
listeners.forEach(tuple => {
|
|
if (tuple.triggerName == triggerName) {
|
|
listenOnPlayer(player, tuple.phase, event, tuple.callback);
|
|
}
|
|
});
|
|
}
|
|
|
|
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
|
|
if (!player.hasStarted()) {
|
|
player.play();
|
|
}
|
|
}
|
|
}
|
|
|
|
flush() {
|
|
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 => {
|
|
const oldValue = stateDetails[triggerName];
|
|
const instruction = this._triggers[triggerName].matchTransition(oldValue, 'void');
|
|
if (instruction) {
|
|
players.push(this.animateTransition(element, instruction));
|
|
flushAgain = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
function getOrSetAsInMap(map: Map<any, any>, key: any, defaultValue: any) {
|
|
let value = map.get(key);
|
|
if (!value) {
|
|
map.set(key, value = defaultValue);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function deleteFromArrayMap(map: Map<any, any[]>, 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,
|
|
totalTime: number): AnimationEvent {
|
|
return <AnimationEvent>{element, triggerName, fromState, toState, phaseName, totalTime};
|
|
}
|