/** * @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(); private _queuedRemovals = new Map any>(); private _queuedTransitionAnimations: QueuedAnimationTransitionTuple[] = []; private _activeTransitionAnimations = new Map(); private _activeElementAnimations = new Map(); private _elementTriggerStates = new Map(); private _triggers: {[triggerName: string]: AnimationTrigger} = Object.create(null); private _triggerListeners = new Map(); 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 = {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 = {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, key: any, defaultValue: any) { let value = map.get(key); if (!value) { map.set(key, value = defaultValue); } return value; } function deleteFromArrayMap(map: Map, key: any, value: any) { let arr = map.get(key); if (arr) { const index = arr.indexOf(value); if (index >= 0) { arr.splice(index, 1); if (arr.length == 0) { map.delete(key); } } } } function optimizeGroupPlayer(players: AnimationPlayer[]): AnimationPlayer { switch (players.length) { case 0: return new NoopAnimationPlayer(); case 1: return players[0]; default: return new ɵAnimationGroupPlayer(players); } } function copyArray(source: any[]): any[] { return source ? source.splice(0) : []; } function validatePlayerEvent(triggerName: string, eventName: string) { switch (eventName) { case 'start': case 'done': return; default: throw new Error( `The provided animation trigger event "${eventName}" for the animation trigger "${triggerName}" is not supported!`); } } function listenOnPlayer( player: AnimationPlayer, eventName: string, baseEvent: AnimationEvent, callback: (event: any) => any) { switch (eventName) { case 'start': player.onStart(() => { const event = copyAnimationEvent(baseEvent); event.phaseName = 'start'; callback(event); }); break; case 'done': player.onDone(() => { const event = copyAnimationEvent(baseEvent); event.phaseName = 'done'; callback(event); }); break; } } function copyAnimationEvent(e: AnimationEvent): AnimationEvent { return makeAnimationEvent( e.element, e.triggerName, e.fromState, e.toState, e.phaseName, e.totalTime); } function makeAnimationEvent( element: any, triggerName: string, fromState: string, toState: string, phaseName: string, totalTime: number): AnimationEvent { return {element, triggerName, fromState, toState, phaseName, totalTime}; }