diff --git a/core/src/utils/animation-old/animation-interface.ts b/core/src/utils/animation-old/animation-interface.ts new file mode 100644 index 0000000000..eb658cf80b --- /dev/null +++ b/core/src/utils/animation-old/animation-interface.ts @@ -0,0 +1,64 @@ + +export interface AnimationController { + create(animationBuilder?: AnimationBuilder, baseEl?: any, opts?: any): Promise; +} + +export interface Animation { + new (): any; + parent: Animation | undefined; + hasChildren: boolean; + addElement(el: Node | Node[] | NodeList): Animation; + add(childAnimation: Animation): Animation; + duration(milliseconds: number): Animation; + easing(name: string): Animation; + easingReverse(name: string): Animation; + getDuration(opts?: PlayOptions): number; + getEasing(): string; + from(prop: string, val: any): Animation; + to(prop: string, val: any, clearProperyAfterTransition?: boolean): Animation; + fromTo(prop: string, fromVal: any, toVal: any, clearProperyAfterTransition?: boolean): Animation; + beforeAddClass(className: string): Animation; + beforeRemoveClass(className: string): Animation; + beforeStyles(styles: { [property: string]: any; }): Animation; + beforeClearStyles(propertyNames: string[]): Animation; + beforeAddRead(domReadFn: () => void): Animation; + beforeAddWrite(domWriteFn: () => void): Animation; + afterAddClass(className: string): Animation; + afterRemoveClass(className: string): Animation; + afterStyles(styles: { [property: string]: any; }): Animation; + afterClearStyles(propertyNames: string[]): Animation; + play(opts?: PlayOptions): void; + playSync(): void; + playAsync(opts?: PlayOptions): Promise; + reverse(shouldReverse?: boolean): Animation; + stop(stepValue?: number): void; + progressStart(): void; + progressStep(stepValue: number): void; + progressEnd(shouldComplete: boolean, currentStepValue: number, dur: number): void; + onFinish(callback: (animation?: Animation) => void, opts?: {oneTimeCallback?: boolean, clearExistingCallbacks?: boolean}): Animation; + destroy(): void; + isRoot(): boolean; + hasCompleted: boolean; +} + +export type AnimationBuilder = (Animation: Animation, baseEl: any, opts?: any) => Promise; + +export interface PlayOptions { + duration?: number; + promise?: boolean; +} + +export interface EffectProperty { + effectName: string; + trans: boolean; + wc?: string; + to?: EffectState; + from?: EffectState; + [state: string]: any; +} + +export interface EffectState { + val: any; + num: number; + effectUnit: string; +} diff --git a/core/src/utils/animation-old/animator.ts b/core/src/utils/animation-old/animator.ts new file mode 100644 index 0000000000..1fc64f436a --- /dev/null +++ b/core/src/utils/animation-old/animator.ts @@ -0,0 +1,1253 @@ +import { EffectProperty, EffectState, PlayOptions } from './animation-interface'; +import { transitionEnd } from './transition-end'; + +export const CSS_VALUE_REGEX = /(^-?\d*\.?\d*)(.*)/; +export const DURATION_MIN = 32; +export const TRANSITION_END_FALLBACK_PADDING_MS = 400; + +export const TRANSFORM_PROPS: {[key: string]: number} = { + 'translateX': 1, + 'translateY': 1, + 'translateZ': 1, + + 'scale': 1, + 'scaleX': 1, + 'scaleY': 1, + 'scaleZ': 1, + + 'rotate': 1, + 'rotateX': 1, + 'rotateY': 1, + 'rotateZ': 1, + + 'skewX': 1, + 'skewY': 1, + 'perspective': 1 +}; + +const win = typeof (window as any) !== 'undefined' ? window : {}; +const raf = (win as any).requestAnimationFrame + ? (win as Window).requestAnimationFrame.bind(win) + : (f: FrameRequestCallback) => f(Date.now()); + +export class Animator { + + private _afterAddClasses?: string[]; + private _afterRemoveClasses?: string[]; + private _afterStyles?: { [property: string]: any; }; + private _beforeAddClasses?: string[]; + private _beforeRemoveClasses?: string[]; + private _beforeStyles?: { [property: string]: any; }; + private _childAnimations?: Animator[]; + private _duration?: number; + private _easingName?: string; + private _elements?: HTMLElement[]; + private _fxProperties?: EffectProperty[]; + private _hasDur = false; + private _hasTweenEffect = false; + private _isAsync = false; + private _isReverse = false; + private _onFinishCallbacks?: ((a: Animator) => void)[]; + private _onFinishOneTimeCallbacks?: ((a: Animator) => void)[]; + private _readCallbacks?: (() => void)[]; + private _reversedEasingName?: string; + private _timerId?: any; + private _unregisterTrnsEnd?: (() => void); + private _writeCallbacks?: (() => void)[]; + private _destroyed = false; + + parent: Animator | undefined; + hasChildren = false; + isPlaying = false; + hasCompleted = false; + + addElement(el: Node | Node[] | NodeList | undefined | null): Animator { + if (el != null) { + if ((el as NodeList).length > 0) { + for (let i = 0; i < (el as NodeList).length; i++) { + this._addEl((el as any)[i]); + } + + } else { + this._addEl(el); + } + } + return this; + } + + /** + * NO DOM + */ + private _addEl(el: any) { + if (el.nodeType === 1) { + (this._elements = this._elements || []).push(el); + } + } + + /** + * Add a child animation to this animation. + */ + add(childAnimation: Animator): Animator { + childAnimation.parent = this; + this.hasChildren = true; + (this._childAnimations = this._childAnimations || []).push(childAnimation); + return this; + } + + /** + * Get the duration of this animation. If this animation does + * not have a duration, then it'll get the duration from its parent. + */ + getDuration(opts?: PlayOptions): number { + if (opts && opts.duration !== undefined) { + return opts.duration; + } else if (this._duration !== undefined) { + return this._duration; + } else if (this.parent) { + return this.parent.getDuration(); + } + return 0; + } + + /** + * Returns if the animation is a root one. + */ + isRoot(): boolean { + return !this.parent; + } + + /** + * Set the duration for this animation. + */ + duration(milliseconds: number): Animator { + this._duration = milliseconds; + return this; + } + + /** + * Get the easing of this animation. If this animation does + * not have an easing, then it'll get the easing from its parent. + */ + getEasing(): string | null { + if (this._isReverse && this._reversedEasingName !== undefined) { + return this._reversedEasingName; + } + return this._easingName !== undefined ? this._easingName : (this.parent && this.parent.getEasing()) || null; + } + + /** + * Set the easing for this animation. + */ + easing(name: string): Animator { + this._easingName = name; + return this; + } + + /** + * Set the easing for this reversed animation. + */ + easingReverse(name: string): Animator { + this._reversedEasingName = name; + return this; + } + + /** + * Add the "from" value for a specific property. + */ + from(prop: string, val: any): Animator { + this._addProp('from', prop, val); + return this; + } + + /** + * Add the "to" value for a specific property. + */ + to(prop: string, val: any, clearProperyAfterTransition = false): Animator { + const fx = this._addProp('to', prop, val); + + if (clearProperyAfterTransition) { + // if this effect is a transform then clear the transform effect + // otherwise just clear the actual property + this.afterClearStyles(fx.trans ? ['transform', '-webkit-transform'] : [prop]); + } + + return this; + } + + /** + * Shortcut to add both the "from" and "to" for the same property. + */ + fromTo(prop: string, fromVal: any, toVal: any, clearProperyAfterTransition?: boolean): Animator { + return this.from(prop, fromVal).to(prop, toVal, clearProperyAfterTransition); + } + + /** + * NO DOM + */ + + private _getProp(name: string): EffectProperty | undefined { + if (this._fxProperties) { + return this._fxProperties.find(prop => prop.effectName === name); + } + return undefined; + } + + private _addProp(state: string, prop: string, val: any): EffectProperty { + let fxProp = this._getProp(prop); + + if (!fxProp) { + // first time we've see this EffectProperty + const shouldTrans = (TRANSFORM_PROPS[prop] === 1); + fxProp = { + effectName: prop, + trans: shouldTrans, + + // add the will-change property for transforms or opacity + wc: (shouldTrans ? 'transform' : prop) + } as EffectProperty; + (this._fxProperties = this._fxProperties || []).push(fxProp); + } + + // add from/to EffectState to the EffectProperty + const fxState: EffectState = { + val, + num: 0, + effectUnit: '', + }; + fxProp[state] = fxState; + + if (typeof val === 'string' && val.indexOf(' ') < 0) { + const r = val.match(CSS_VALUE_REGEX); + if (r) { + const num = parseFloat(r[1]); + + if (!isNaN(num)) { + fxState.num = num; + } + fxState.effectUnit = (r[0] !== r[2] ? r[2] : ''); + } + } else if (typeof val === 'number') { + fxState.num = val; + } + + return fxProp; + } + + /** + * Add CSS class to this animation's elements + * before the animation begins. + */ + beforeAddClass(className: string): Animator { + (this._beforeAddClasses = this._beforeAddClasses || []).push(className); + return this; + } + + /** + * Remove CSS class from this animation's elements + * before the animation begins. + */ + beforeRemoveClass(className: string): Animator { + (this._beforeRemoveClasses = this._beforeRemoveClasses || []).push(className); + return this; + } + + /** + * Set CSS inline styles to this animation's elements + * before the animation begins. + */ + beforeStyles(styles: { [property: string]: any; }): Animator { + this._beforeStyles = styles; + return this; + } + + /** + * Clear CSS inline styles from this animation's elements + * before the animation begins. + */ + beforeClearStyles(propertyNames: string[]): Animator { + this._beforeStyles = this._beforeStyles || {}; + for (const prop of propertyNames) { + this._beforeStyles[prop] = ''; + } + return this; + } + + /** + * Add a function which contains DOM reads, which will run + * before the animation begins. + */ + beforeAddRead(domReadFn: () => void): Animator { + (this._readCallbacks = this._readCallbacks || []).push(domReadFn); + return this; + } + + /** + * Add a function which contains DOM writes, which will run + * before the animation begins. + */ + beforeAddWrite(domWriteFn: () => void): Animator { + (this._writeCallbacks = this._writeCallbacks || []).push(domWriteFn); + return this; + } + + /** + * Add CSS class to this animation's elements + * after the animation finishes. + */ + afterAddClass(className: string): Animator { + (this._afterAddClasses = this._afterAddClasses || []).push(className); + return this; + } + + /** + * Remove CSS class from this animation's elements + * after the animation finishes. + */ + afterRemoveClass(className: string): Animator { + (this._afterRemoveClasses = this._afterRemoveClasses || []).push(className); + return this; + } + + /** + * Set CSS inline styles to this animation's elements + * after the animation finishes. + */ + afterStyles(styles: { [property: string]: any; }): Animator { + this._afterStyles = styles; + return this; + } + + /** + * Clear CSS inline styles from this animation's elements + * after the animation finishes. + */ + afterClearStyles(propertyNames: string[]): Animator { + this._afterStyles = this._afterStyles || {}; + for (const prop of propertyNames) { + this._afterStyles[prop] = ''; + } + return this; + } + + /** + * Play the animation. + */ + play(opts?: PlayOptions) { + // If the animation was already invalidated (it did finish), do nothing + if (this._destroyed) { + return; + } + + // this is the top level animation and is in full control + // of when the async play() should actually kick off + // if there is no duration then it'll set the TO property immediately + // if there is a duration, then it'll stage all animations at the + // FROM property and transition duration, wait a few frames, then + // kick off the animation by setting the TO property for each animation + this._isAsync = this._hasDuration(opts); + + // ensure all past transition end events have been cleared + this._clearAsync(); + + // recursively kicks off the correct progress step for each child animation + // ******** DOM WRITE **************** + this._playInit(opts); + + // doubling up RAFs since this animation was probably triggered + // from an input event, and just having one RAF would have this code + // run within the same frame as the triggering input event, and the + // input event probably already did way too much work for one frame + raf(() => { + raf(() => { + this._playDomInspect(opts); + }); + }); + } + + playAsync(opts?: PlayOptions): Promise { + return new Promise(resolve => { + this.onFinish(resolve, { oneTimeCallback: true, clearExistingCallbacks: true }); + this.play(opts); + return this; + }); + } + + playSync() { + // If the animation was already invalidated (it did finish), do nothing + if (!this._destroyed) { + const opts = { duration: 0 }; + this._isAsync = false; + this._clearAsync(); + this._playInit(opts); + this._playDomInspect(opts); + } + } + + /** + * DOM WRITE + * RECURSION + */ + private _playInit(opts: PlayOptions | undefined) { + // always default that an animation does not tween + // a tween requires that an Animation class has an element + // and that it has at least one FROM/TO effect + // and that the FROM/TO effect can tween numeric values + this._hasTweenEffect = false; + this.isPlaying = true; + this.hasCompleted = false; + this._hasDur = (this.getDuration(opts) > DURATION_MIN); + + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._playInit(opts); + } + } + + if (this._hasDur) { + // if there is a duration then we want to start at step 0 + // ******** DOM WRITE **************** + this._progress(0); + + // add the will-change properties + // ******** DOM WRITE **************** + this._willChange(true); + } + } + + /** + * DOM WRITE + * NO RECURSION + * ROOT ANIMATION + */ + _playDomInspect(opts: PlayOptions | undefined) { + // fire off all the "before" function that have DOM READS in them + // elements will be in the DOM, however visibily hidden + // so we can read their dimensions if need be + // ******** DOM READ **************** + // ******** DOM WRITE **************** + this._beforeAnimation(); + + // for the root animation only + // set the async TRANSITION END event + // and run onFinishes when the transition ends + const dur = this.getDuration(opts); + if (this._isAsync) { + this._asyncEnd(dur, true); + } + + // ******** DOM WRITE **************** + this._playProgress(opts); + + if (this._isAsync && !this._destroyed) { + // this animation has a duration so we need another RAF + // for the CSS TRANSITION properties to kick in + raf(() => { + this._playToStep(1); + }); + } + } + + /** + * DOM WRITE + * RECURSION + */ + _playProgress(opts: PlayOptions | undefined) { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._playProgress(opts); + } + } + + if (this._hasDur) { + // set the CSS TRANSITION duration/easing + // ******** DOM WRITE **************** + this._setTrans(this.getDuration(opts), false); + + } else { + // this animation does not have a duration, so it should not animate + // just go straight to the TO properties and call it done + // ******** DOM WRITE **************** + this._progress(1); + + // since there was no animation, immediately run the after + // ******** DOM WRITE **************** + this._setAfterStyles(); + + // this animation has no duration, so it has finished + // other animations could still be running + this._didFinish(true); + } + } + + /** + * DOM WRITE + * RECURSION + */ + _playToStep(stepValue: number) { + if (!this._destroyed) { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._playToStep(stepValue); + } + } + + if (this._hasDur) { + // browser had some time to render everything in place + // and the transition duration/easing is set + // now set the TO properties which will trigger the transition to begin + // ******** DOM WRITE **************** + this._progress(stepValue); + } + } + } + + /** + * DOM WRITE + * NO RECURSION + * ROOT ANIMATION + */ + _asyncEnd(dur: number, shouldComplete: boolean) { + const self = this; + + function onTransitionEnd() { + // congrats! a successful transition completed! + // ensure transition end events and timeouts have been cleared + self._clearAsync(); + + // ******** DOM WRITE **************** + self._playEnd(); + + // transition finished + self._didFinishAll(shouldComplete, true, false); + } + + function onTransitionFallback() { + // oh noz! the transition end event didn't fire in time! + // instead the fallback timer when first + // if all goes well this fallback should never fire + + // clear the other async end events from firing + self._timerId = undefined; + self._clearAsync(); + + // set the after styles + // ******** DOM WRITE **************** + self._playEnd(shouldComplete ? 1 : 0); + + // transition finished + self._didFinishAll(shouldComplete, true, false); + } + + // set the TRANSITION END event on one of the transition elements + self._unregisterTrnsEnd = transitionEnd(self._transEl(), onTransitionEnd); + + // set a fallback timeout if the transition end event never fires, or is too slow + // transition end fallback: (animation duration + XXms) + self._timerId = setTimeout(onTransitionFallback, (dur + TRANSITION_END_FALLBACK_PADDING_MS)); + } + + /** + * DOM WRITE + * RECURSION + */ + _playEnd(stepValue?: number) { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._playEnd(stepValue); + } + } + + if (this._hasDur) { + if (stepValue !== undefined) { + // too late to have a smooth animation, just finish it + // ******** DOM WRITE **************** + this._setTrans(0, true); + + // ensure the ending progress step gets rendered + // ******** DOM WRITE **************** + this._progress(stepValue); + } + + // set the after styles + // ******** DOM WRITE **************** + this._setAfterStyles(); + + // remove the will-change properties + // ******** DOM WRITE **************** + this._willChange(false); + } + } + + /** + * NO DOM + * RECURSION + */ + _hasDuration(opts: PlayOptions | undefined) { + if (this.getDuration(opts) > DURATION_MIN) { + return true; + } + + const children = this._childAnimations; + if (children) { + for (const child of children) { + if (child._hasDuration(opts)) { + return true; + } + } + } + return false; + } + + /** + * NO DOM + * RECURSION + */ + _hasDomReads() { + if (this._readCallbacks && this._readCallbacks.length > 0) { + return true; + } + + const children = this._childAnimations; + if (children) { + for (const child of children) { + if (child._hasDomReads()) { + return true; + } + } + } + return false; + } + + /** + * Immediately stop at the end of the animation. + */ + stop(stepValue = 1) { + // ensure all past transition end events have been cleared + this._clearAsync(); + this._hasDur = true; + this._playEnd(stepValue); + } + + /** + * NO DOM + * NO RECURSION + */ + _clearAsync() { + if (this._unregisterTrnsEnd) { + this._unregisterTrnsEnd(); + } + if (this._timerId) { + clearTimeout(this._timerId); + } + this._timerId = this._unregisterTrnsEnd = undefined; + } + + /** + * DOM WRITE + * NO RECURSION + */ + _progress(stepValue: number) { + // bread 'n butter + let val: any; + const elements = this._elements; + const effects = this._fxProperties; + + if (!elements || elements.length === 0 || !effects || this._destroyed) { + return; + } + + // flip the number if we're going in reverse + if (this._isReverse) { + stepValue = 1 - stepValue; + } + let i = 0; + let j = 0; + let finalTransform = ''; + let fx: EffectProperty; + + for (i = 0; i < effects.length; i++) { + fx = effects[i]; + + if (fx.from && fx.to) { + const fromNum = fx.from.num; + const toNum = fx.to.num; + const tweenEffect = (fromNum !== toNum); + + if (tweenEffect) { + this._hasTweenEffect = true; + } + + if (stepValue === 0) { + // FROM + val = fx.from.val; + + } else if (stepValue === 1) { + // TO + val = fx.to.val; + + } else if (tweenEffect) { + // EVERYTHING IN BETWEEN + const valNum = (((toNum - fromNum) * stepValue) + fromNum); + const unit = fx.to.effectUnit; + val = valNum + unit; + } + + if (val !== null) { + const prop = fx.effectName; + if (fx.trans) { + finalTransform += prop + '(' + val + ') '; + + } else { + for (j = 0; j < elements.length; j++) { + // ******** DOM WRITE **************** + elements[j].style.setProperty(prop, val); + } + } + } + } + } + + // place all transforms on the same property + if (finalTransform.length > 0) { + if (!this._isReverse && stepValue !== 1 || this._isReverse && stepValue !== 0) { + finalTransform += 'translateZ(0px)'; + } + + for (i = 0; i < elements.length; i++) { + // ******** DOM WRITE **************** + elements[i].style.setProperty('transform', finalTransform); + elements[i].style.setProperty('-webkit-transform', finalTransform); + } + } + } + + /** + * DOM WRITE + * NO RECURSION + */ + _setTrans(dur: number, forcedLinearEasing: boolean) { + // Transition is not enabled if there are not effects + const elements = this._elements; + if (!elements || elements.length === 0 || !this._fxProperties) { + return; + } + + // set the TRANSITION properties inline on the element + const easing = (forcedLinearEasing ? 'linear' : this.getEasing()); + const durString = dur + 'ms'; + + for (const { style } of elements) { + if (dur > 0) { + // ******** DOM WRITE **************** + style.transitionDuration = durString; + + // each animation can have a different easing + if (easing !== null) { + // ******** DOM WRITE **************** + style.transitionTimingFunction = easing; + } + } else { + style.transitionDuration = '0'; + } + } + } + + /** + * DOM READ + * DOM WRITE + * RECURSION + */ + _beforeAnimation() { + // fire off all the "before" function that have DOM READS in them + // elements will be in the DOM, however visibily hidden + // so we can read their dimensions if need be + // ******** DOM READ **************** + this._fireBeforeReadFunc(); + + // ******** DOM READS ABOVE / DOM WRITES BELOW **************** + + // fire off all the "before" function that have DOM WRITES in them + // ******** DOM WRITE **************** + this._fireBeforeWriteFunc(); + + // stage all of the before css classes and inline styles + // ******** DOM WRITE **************** + this._setBeforeStyles(); + } + + /** + * DOM WRITE + * RECURSION + */ + _setBeforeStyles() { + const children = this._childAnimations; + if (children) { + for (const child of children) { + child._setBeforeStyles(); + } + } + + const elements = this._elements; + // before the animations have started + // only set before styles if animation is not reversed + if (!elements || elements.length === 0 || this._isReverse) { + return; + } + const addClasses = this._beforeAddClasses; + const removeClasses = this._beforeRemoveClasses; + + for (const el of elements) { + const elementClassList = el.classList; + + // css classes to add before the animation + + if (addClasses) { + for (const c of addClasses) { + // ******** DOM WRITE **************** + elementClassList.add(c); + } + } + + // css classes to remove before the animation + if (removeClasses) { + for (const c of removeClasses) { + // ******** DOM WRITE **************** + elementClassList.remove(c); + } + } + + // inline styles to add before the animation + if (this._beforeStyles) { + for (const [key, value] of Object.entries(this._beforeStyles)) { + // ******** DOM WRITE **************** + el.style.setProperty(key, value); + } + } + } + } + + /** + * DOM READ + * RECURSION + */ + _fireBeforeReadFunc() { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM READ **************** + child._fireBeforeReadFunc(); + } + } + + const readFunctions = this._readCallbacks; + if (readFunctions) { + for (const callback of readFunctions) { + // ******** DOM READ **************** + callback(); + } + } + } + + /** + * DOM WRITE + * RECURSION + */ + _fireBeforeWriteFunc() { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._fireBeforeWriteFunc(); + } + } + + const writeFunctions = this._writeCallbacks; + if (writeFunctions) { + for (const callback of writeFunctions) { + // ******** DOM WRITE **************** + callback(); + } + } + } + + /** + * DOM WRITE + */ + _setAfterStyles() { + const elements = this._elements; + if (!elements) { + return; + } + for (const el of elements) { + const elementClassList = el.classList; + + // remove the transition duration/easing + // ******** DOM WRITE **************** + el.style.transitionDuration = el.style.transitionTimingFunction = ''; + + if (this._isReverse) { + // finished in reverse direction + + // css classes that were added before the animation should be removed + const beforeAddClasses = this._beforeAddClasses; + if (beforeAddClasses) { + for (const c of beforeAddClasses) { + elementClassList.remove(c); + } + } + + // css classes that were removed before the animation should be added + const beforeRemoveClasses = this._beforeRemoveClasses; + if (beforeRemoveClasses) { + for (const c of beforeRemoveClasses) { + elementClassList.add(c); + } + } + + // inline styles that were added before the animation should be removed + const beforeStyles = this._beforeStyles; + if (beforeStyles) { + for (const propName of Object.keys(beforeStyles)) { + // ******** DOM WRITE **************** + el.style.removeProperty(propName); + } + } + + } else { + // finished in forward direction + + // css classes to add after the animation + const afterAddClasses = this._afterAddClasses; + if (afterAddClasses) { + for (const c of afterAddClasses) { + // ******** DOM WRITE **************** + elementClassList.add(c); + } + } + + // css classes to remove after the animation + const afterRemoveClasses = this._afterRemoveClasses; + if (afterRemoveClasses) { + for (const c of afterRemoveClasses) { + // ******** DOM WRITE **************** + elementClassList.remove(c); + } + } + + // inline styles to add after the animation + const afterStyles = this._afterStyles; + if (afterStyles) { + for (const [key, value] of Object.entries(afterStyles)) { + el.style.setProperty(key, value); + } + } + } + } + } + + /** + * DOM WRITE + * NO RECURSION + */ + _willChange(addWillChange: boolean) { + let wc: string[]; + const effects = this._fxProperties; + let willChange: string; + + if (addWillChange && effects) { + wc = []; + for (const effect of effects) { + const propWC = effect.wc; + if (propWC === 'webkitTransform') { + wc.push('transform', '-webkit-transform'); + + } else if (propWC !== undefined) { + wc.push(propWC); + } + } + willChange = wc.join(','); + + } else { + willChange = ''; + } + + const elements = this._elements; + if (elements) { + for (const el of elements) { + // ******** DOM WRITE **************** + el.style.setProperty('will-change', willChange); + } + } + } + + /** + * Start the animation with a user controlled progress. + */ + progressStart() { + // ensure all past transition end events have been cleared + this._clearAsync(); + + // ******** DOM READ/WRITE **************** + this._beforeAnimation(); + + // ******** DOM WRITE **************** + this._progressStart(); + } + + /** + * DOM WRITE + * RECURSION + */ + _progressStart() { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._progressStart(); + } + } + + // force no duration, linear easing + // ******** DOM WRITE **************** + this._setTrans(0, true); + // ******** DOM WRITE **************** + this._willChange(true); + } + + /** + * Set the progress step for this animation. + * progressStep() is not debounced, so it should not be called faster than 60FPS. + */ + progressStep(stepValue: number) { + // only update if the last update was more than 16ms ago + stepValue = Math.min(1, Math.max(0, stepValue)); + + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child.progressStep(stepValue); + } + } + + // ******** DOM WRITE **************** + this._progress(stepValue); + } + + /** + * End the progress animation. + */ + progressEnd(shouldComplete: boolean, currentStepValue: number, dur = -1) { + if (this._isReverse) { + // if the animation is going in reverse then + // flip the step value: 0 becomes 1, 1 becomes 0 + currentStepValue = 1 - currentStepValue; + } + const stepValue = shouldComplete ? 1 : 0; + const diff = Math.abs(currentStepValue - stepValue); + if (dur < 0) { + dur = this._duration || 0; + } else if (diff < 0.05) { + dur = 0; + } + + this._isAsync = (dur > 30); + + this._progressEnd(shouldComplete, stepValue, dur, this._isAsync); + + if (this._isAsync) { + // for the root animation only + // set the async TRANSITION END event + // and run onFinishes when the transition ends + // ******** DOM WRITE **************** + this._asyncEnd(dur, shouldComplete); + + // this animation has a duration so we need another RAF + // for the CSS TRANSITION properties to kick in + if (!this._destroyed) { + raf(() => { + this._playToStep(stepValue); + }); + } + } + } + + /** + * DOM WRITE + * RECURSION + */ + _progressEnd(shouldComplete: boolean, stepValue: number, dur: number, isAsync: boolean) { + const children = this._childAnimations; + if (children) { + for (const child of children) { + // ******** DOM WRITE **************** + child._progressEnd(shouldComplete, stepValue, dur, isAsync); + } + } + + if (!isAsync) { + // stop immediately + // set all the animations to their final position + // ******** DOM WRITE **************** + this._progress(stepValue); + this._willChange(false); + this._setAfterStyles(); + this._didFinish(shouldComplete); + + } else { + // animate it back to it's ending position + this.isPlaying = true; + this.hasCompleted = false; + this._hasDur = true; + + // ******** DOM WRITE **************** + this._willChange(true); + this._setTrans(dur, false); + } + } + + /** + * Add a callback to fire when the animation has finished. + */ + onFinish(callback: (animation?: any) => void, opts?: {oneTimeCallback?: boolean, clearExistingCallbacks?: boolean}): Animator { + if (opts && opts.clearExistingCallbacks) { + this._onFinishCallbacks = this._onFinishOneTimeCallbacks = undefined; + } + if (opts && opts.oneTimeCallback) { + this._onFinishOneTimeCallbacks = this._onFinishOneTimeCallbacks || []; + this._onFinishOneTimeCallbacks.push(callback); + + } else { + this._onFinishCallbacks = this._onFinishCallbacks || []; + this._onFinishCallbacks.push(callback); + } + return this; + } + + /** + * NO DOM + * RECURSION + */ + _didFinishAll(hasCompleted: boolean, finishAsyncAnimations: boolean, finishNoDurationAnimations: boolean) { + const children = this._childAnimations; + if (children) { + for (const child of children) { + child._didFinishAll(hasCompleted, finishAsyncAnimations, finishNoDurationAnimations); + } + } + + if (finishAsyncAnimations && this._isAsync || finishNoDurationAnimations && !this._isAsync) { + this._didFinish(hasCompleted); + } + } + + /** + * NO RECURSION + */ + _didFinish(hasCompleted: boolean) { + this.isPlaying = false; + this.hasCompleted = hasCompleted; + + if (this._onFinishCallbacks) { + // run all finish callbacks + for (const callback of this._onFinishCallbacks) { + callback(this); + } + } + + if (this._onFinishOneTimeCallbacks) { + // run all "onetime" finish callbacks + for (const callback of this._onFinishOneTimeCallbacks) { + callback(this); + } + this._onFinishOneTimeCallbacks.length = 0; + } + } + + /** + * Reverse the animation. + */ + reverse(shouldReverse = true): Animator { + const children = this._childAnimations; + if (children) { + for (const child of children) { + child.reverse(shouldReverse); + } + } + this._isReverse = !!shouldReverse; + return this; + } + + /** + * Recursively destroy this animation and all child animations. + */ + destroy() { + this._didFinish(false); + this._destroyed = true; + + const children = this._childAnimations; + if (children) { + for (const child of children) { + child.destroy(); + } + } + + this._clearAsync(); + + if (this._elements) { + this._elements.length = 0; + } + + if (this._readCallbacks) { + this._readCallbacks.length = 0; + } + + if (this._writeCallbacks) { + this._writeCallbacks.length = 0; + } + + this.parent = undefined; + + if (this._childAnimations) { + this._childAnimations.length = 0; + } + if (this._onFinishCallbacks) { + this._onFinishCallbacks.length = 0; + } + if (this._onFinishOneTimeCallbacks) { + this._onFinishOneTimeCallbacks.length = 0; + } + } + + /** + * NO DOM + */ + _transEl(): HTMLElement | null { + // get the lowest level element that has an Animator + const children = this._childAnimations; + if (children) { + for (const child of children) { + const targetEl = child._transEl(); + if (targetEl) { + return targetEl; + } + } + } + + return ( + this._hasTweenEffect && + this._hasDur && + this._elements !== undefined && + this._elements.length > 0 ? + this._elements[0] : null + ); + } +} diff --git a/core/src/utils/animation-old/index.ts b/core/src/utils/animation-old/index.ts new file mode 100644 index 0000000000..aca054e577 --- /dev/null +++ b/core/src/utils/animation-old/index.ts @@ -0,0 +1,10 @@ +import { Animation, AnimationBuilder } from '../../interface'; + +import { Animator } from './animator'; + +export function create(animationBuilder?: AnimationBuilder, baseEl?: any, opts?: any): Promise { + if (animationBuilder) { + return animationBuilder(Animator as any, baseEl, opts); + } + return Promise.resolve(new Animator() as any); +} diff --git a/core/src/utils/animation-old/transition-end.ts b/core/src/utils/animation-old/transition-end.ts new file mode 100644 index 0000000000..9568cb103d --- /dev/null +++ b/core/src/utils/animation-old/transition-end.ts @@ -0,0 +1,30 @@ + +export function transitionEnd(el: HTMLElement | null, callback: (ev?: TransitionEvent) => void) { + let unRegTrans: (() => void) | undefined; + const opts: any = { passive: true }; + + function unregister() { + if (unRegTrans) { + unRegTrans(); + } + } + + function onTransitionEnd(ev: Event) { + if (el === ev.target) { + unregister(); + callback(ev as TransitionEvent); + } + } + + if (el) { + el.addEventListener('webkitTransitionEnd', onTransitionEnd, opts); + el.addEventListener('transitionend', onTransitionEnd, opts); + + unRegTrans = () => { + el.removeEventListener('webkitTransitionEnd', onTransitionEnd, opts); + el.removeEventListener('transitionend', onTransitionEnd, opts); + }; + } + + return unregister; +} diff --git a/core/src/utils/animation/animation.ts b/core/src/utils/animation/animation.ts new file mode 100644 index 0000000000..95b1275ef8 --- /dev/null +++ b/core/src/utils/animation/animation.ts @@ -0,0 +1,260 @@ +// TODO: Add validation +// TODO: More tests + +const addElement = (animationElements: any[], el: Node | Node[] | NodeList | undefined | null) => { + if (el != null) { + const nodeList = el as NodeList; + if (nodeList.length >= 0) { + for (let i = 0; i < nodeList.length; i++) { + animationElements.push((el as any)[i]); + } + } else { + animationElements.push(el); + } + } +}; + +const addAnimation = (parentAnimation: Animation, childAnimations: Animation[], animationToAdd: Animation | Animation[] | undefined | null) => { + if (animationToAdd != null) { + const animationsToAdd = animationToAdd as Animation[]; + if (animationsToAdd.length >= 0) { + for (const animation of animationsToAdd) { + animation.parentAnimation = parentAnimation; + childAnimations.push(animation); + } + } else { + (animationToAdd as Animation).parentAnimation = parentAnimation; + childAnimations.push(animationToAdd as Animation); + } + } +}; + +const addTarget = (animationElements: any[], target: string) => { + const els = document.querySelectorAll(target); + addElement(animationElements, els); +}; + +const generateKeyframeString = (name: string | undefined, keyframes: any[] = []): string => { + if (name === undefined) { console.warn('A name is required to generate keyframes'); } + + const keyframeString = [`@keyframes ${name} {`]; + + keyframes.forEach(keyframe => { + const offset = keyframe.offset; + delete keyframe.offset; + + const frameString = []; + for (const property in keyframe) { + if (keyframe.hasOwnProperty(property)) { + frameString.push(`${property}: ${keyframe[property]};`); + } + } + + keyframeString.push(`${offset * 100}% { ${frameString.join(' ')} }`); + }); + + return keyframeString.join(' '); +}; + +export class Animation { + private elements: Element[] = []; + private childAnimations: Animation[] = []; + + private _delay: number | undefined; + private _duration: number | undefined; + private _easing: string | undefined; + private _iterations: number | undefined; + + private _keyframes: any[] = []; + private _keyframeString = ''; + + private initialized = false; + + private stylesheet?: HTMLElement; + + parentAnimation: Animation | undefined; + + constructor(public _name: string | undefined) {} + + getEasing(): string | undefined { + if (this._easing !== undefined) { return this._easing; } + if (this.parentAnimation && this.parentAnimation.getEasing() !== undefined) { return this.parentAnimation.getEasing(); } + + return undefined; + } + + getDuration(): number | undefined { + if (this._duration !== undefined) { return this._duration; } + if (this.parentAnimation && this.parentAnimation.getDuration() !== undefined) { return this.parentAnimation.getDuration(); } + + return undefined; + } + + getIterations(): number | undefined { + if (this._iterations !== undefined) { return this._iterations; } + if (this.parentAnimation && this.parentAnimation.getIterations() !== undefined) { return this.parentAnimation.getIterations(); } + + return undefined; + } + + getDelay(): number | undefined { + if (this._delay !== undefined) { return this._delay; } + if (this.parentAnimation && this.parentAnimation.getDelay() !== undefined) { return this.parentAnimation.getDelay(); } + + return undefined; + } + + getKeyframes(): any[] { + return this._keyframes; + } + + name(name: string): Animation { + this._name = name; + + return this; + } + + delay(delay: number): Animation { + this._delay = delay; + + return this; + } + + easing(easing: string): Animation { + this._easing = easing; + + return this; + } + + duration(duration: number): Animation { + this._duration = duration; + + return this; + } + + iterations(iterations: number): Animation { + this._iterations = iterations; + + return this; + } + + addElement(el: Node | Node[] | NodeList | undefined | null): Animation { + addElement(this.elements, el); + + return this; + } + + addTarget(target: string): Animation { + addTarget(this.elements, target); + + return this; + } + + addAnimation(childAnimation: Animation | undefined | null): Animation { + addAnimation(this, this.childAnimations, childAnimation); + + return this; + } + + keyframes(keyframes: any[]): Animation { + this._keyframes = keyframes; + this._keyframeString = generateKeyframeString(this._name, keyframes); + + return this; + } + + private initializeAnimation(): void { + if (!this.stylesheet) { + const stylesheet = document.createElement('style'); + stylesheet.appendChild(document.createTextNode(this._keyframeString)); + + document.querySelector('head')!.appendChild(stylesheet); + + this.stylesheet = stylesheet; + } + + const animationName = this._name; + const animationDuration = this.getDuration(); + const animationEasing = this.getEasing(); + const animationIterationCount = this.getIterations(); + const animationDelay = this.getDelay(); + + this.elements.forEach(element => { + if (animationName !== undefined) { + (element as HTMLElement).style.animationName = animationName; + } + + if (animationDuration !== undefined) { + (element as HTMLElement).style.animationDuration = `${animationDuration}ms`; + } + + if (animationEasing !== undefined) { + (element as HTMLElement).style.animationTimingFunction = animationEasing; + } + + if (animationIterationCount !== undefined) { + (element as HTMLElement).style.animationIterationCount = (animationIterationCount === Infinity) ? 'infinite' : animationIterationCount.toString(); + } + + if (animationDelay !== undefined) { + (element as HTMLElement).style.animationDelay = `${animationDelay}ms`; + } + }); + + this.initialized = true; + } + + playStep(step: number): Animation { + if (!this.initialized) { + this.initializeAnimation(); + } + + this.childAnimations.forEach(animation => { + animation.playStep(step); + }); + + this.pause(); + + if (this.getDuration() !== undefined) { + const animationDuration = `-${this.getDuration()! * step}ms`; + + this.elements.forEach(element => { + (element as HTMLElement).style.animationDelay = animationDuration; + }); + } + + return this; + } + + pause(): Animation { + if (!this.initialized) { + this.initializeAnimation(); + } + + this.childAnimations.forEach(animation => { + animation.pause(); + }); + + this.elements.forEach(element => { + (element as HTMLElement).style.animationPlayState = 'paused'; + }); + + return this; + } + + play(): Animation { + if (!this.initialized) { + this.initializeAnimation(); + } + + this.childAnimations.forEach(animation => { + animation.play(); + }); + + this.elements.forEach(element => { + (element as HTMLElement).style.animationPlayState = 'running'; + }); + + return this; + } +} diff --git a/core/src/utils/animation/test/animation.spec.ts b/core/src/utils/animation/test/animation.spec.ts new file mode 100644 index 0000000000..57e9c740d7 --- /dev/null +++ b/core/src/utils/animation/test/animation.spec.ts @@ -0,0 +1,221 @@ +import { Animation } from '../animation'; + +describe('Animation Class', () => { + + describe('addElement()', () => { + let animation; + beforeEach(() => { + animation = new Animation(); + }); + + it('should add 1 element', () => { + const el = document.createElement('p'); + animation.addElement(el); + + expect(animation.elements.length).toEqual(1); + }); + + it('should add multiple elements', () => { + const els = [ + document.createElement('p'), + document.createElement('p'), + document.createElement('p') + ]; + + animation.addElement(els); + + expect(animation.elements.length).toEqual(els.length); + }); + + it('should not error when trying to add null or undefined', () => { + const el = document.createElement('p'); + animation.addElement(el); + + animation.addElement(null); + animation.addElement(undefined); + + expect(animation.elements.length).toEqual(1); + }); + }); + + describe('addTarget()', () => { + let animation; + beforeEach(() => { + animation = new Animation(); + document.body.innerHTML = ''; + }); + + it('should add a target', () => { + document.body.appendChild(document.createElement('p')); + + animation.addTarget('p'); + expect(animation.elements.length).toEqual(1); + }); + + it('should add multiple targets of the same type', () => { + document.body.appendChild(document.createElement('p')); + document.body.appendChild(document.createElement('p')); + + animation.addTarget('p'); + expect(animation.elements.length).toEqual(2); + }); + + it('should add multiple targets of different types', () => { + document.body.appendChild(document.createElement('p')); + document.body.appendChild(document.createElement('p')); + document.body.appendChild(document.createElement('span')); + + animation.addTarget('p, span'); + expect(animation.elements.length).toEqual(3); + }); + + it('should not error when trying to add null or undefined', () => { + animation.addTarget('p'); + expect(animation.elements.length).toEqual(0); + }); + }); + + describe('addAnimation()', () => { + let animation; + beforeEach(() => { + animation = new Animation(); + }); + + it('should add 1 animation', () => { + const newAnimation = new Animation(); + animation.addAnimation(newAnimation); + + expect(animation.childAnimations.length).toEqual(1); + expect(animation.childAnimations[0].parentAnimation).toEqual(animation); + }); + + it('should add multiple animations', () => { + animation.addAnimation([new Animation(), new Animation(), new Animation()]); + + expect(animation.childAnimations.length).toEqual(3); + expect(animation.childAnimations[0].parentAnimation).toEqual(animation); + expect(animation.childAnimations[1].parentAnimation).toEqual(animation); + expect(animation.childAnimations[2].parentAnimation).toEqual(animation); + }); + + it('should not error when trying to add null or undefined', () => { + animation.addAnimation(null); + animation.addAnimation(undefined); + + expect(animation.childAnimations.length).toEqual(0); + }) + }); + + describe('keyframes()', () => { + let animation; + beforeEach(() => { + animation = new Animation('my-animation'); + }); + + it('should generate a keyframe', () => { + animation.keyframes([ + { transform: 'scale(1)', opacity: 1, offset: 0 }, + { transform: 'scale(0.5)', opacity: 0.5, offset: 0.5 }, + { transform: 'scale(0)', opacity: 0, offset: 1 } + ]); + + expect(animation._keyframes.length).toEqual(3); + }); + }); + + describe('Animation Config Methods', () => { + let animation; + beforeEach(() => { + animation = new Animation(); + }); + + it('should get undefined when easing not set', () => { + expect(animation.getEasing()).toEqual(undefined); + }); + + it('should get parent easing when child easing is not set', () => { + const childAnimation = new Animation(); + animation.addAnimation(childAnimation); + childAnimation.easing('linear'); + + expect(childAnimation.getEasing()).toEqual('linear'); + }); + + it('should get prefer child easing over parent easing', () => { + const childAnimation = new Animation(); + childAnimation.easing('linear'); + + animation.addAnimation(childAnimation); + animation.easing('ease-in-out'); + + expect(childAnimation.getEasing()).toEqual('linear'); + }); + + it('should get undefined when duration not set', () => { + expect(animation.getDuration()).toEqual(undefined); + }); + + it('should get parent duration when child duration is not set', () => { + const childAnimation = new Animation(); + animation.addAnimation(childAnimation); + childAnimation.duration(500); + + expect(childAnimation.getDuration()).toEqual(500); + }); + + it('should get prefer child duration over parent duration', () => { + const childAnimation = new Animation(); + childAnimation.duration(500); + + animation.addAnimation(childAnimation); + animation.duration(1000); + + expect(childAnimation.getDuration()).toEqual(500); + }); + + it('should get undefined when delay not set', () => { + expect(animation.getDelay()).toEqual(undefined); + }); + + it('should get parent delay when child delay is not set', () => { + const childAnimation = new Animation(); + animation.addAnimation(childAnimation); + childAnimation.delay(500); + + expect(childAnimation.getDelay()).toEqual(500); + }); + + it('should get prefer child delay over parent delay', () => { + const childAnimation = new Animation(); + childAnimation.delay(500); + + animation.addAnimation(childAnimation); + animation.delay(1000); + + expect(childAnimation.getDelay()).toEqual(500); + }); + + it('should get undefined when iterations not set', () => { + expect(animation.getIterations()).toEqual(undefined); + }); + + it('should get parent iterations when child iterations is not set', () => { + const childAnimation = new Animation(); + animation.addAnimation(childAnimation); + childAnimation.iterations(2); + + expect(childAnimation.getIterations()).toEqual(2); + }); + + it('should get prefer child iterations over parent iterations', () => { + const childAnimation = new Animation(); + childAnimation.iterations(2); + + animation.addAnimation(childAnimation); + animation.iterations(1); + + expect(childAnimation.getIterations()).toEqual(2); + }); + + }) +}); \ No newline at end of file diff --git a/core/src/utils/animation/test/basic/index.html b/core/src/utils/animation/test/basic/index.html new file mode 100644 index 0000000000..032d5ae9b2 --- /dev/null +++ b/core/src/utils/animation/test/basic/index.html @@ -0,0 +1,173 @@ + + + + + + Animation - Basic + + + + + + + + + + + + + + + Animations + + + + +
+
+ Drag along the track to animate the elements +
+
+
+ Hello +
+ +
+ Hello +
+ +
+ Hello +
+
+
+ + + + + diff --git a/core/src/utils/animation/test/grid/index.html b/core/src/utils/animation/test/grid/index.html new file mode 100644 index 0000000000..f18cabafe4 --- /dev/null +++ b/core/src/utils/animation/test/grid/index.html @@ -0,0 +1,112 @@ + + + + + + Animation - Grid + + + + + + + + + + + + + + + Animations + + Pause + Play + + + + + +
+
+
+
+
+
+
+
+
+ + + + +