UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

320 lines (317 loc) • 11.4 kB
import { supportsLinearEasing, startWaapiAnimation, attachTimeline, isGenerator, isWaapiSupportedEasing } from 'motion-dom'; import { millisecondsToSeconds, secondsToMilliseconds, noop } from 'motion-utils'; import { anticipate } from '../../easing/anticipate.mjs'; import { backInOut } from '../../easing/back.mjs'; import { circInOut } from '../../easing/circ.mjs'; import { DOMKeyframesResolver } from '../../render/dom/DOMKeyframesResolver.mjs'; import { BaseAnimation } from './BaseAnimation.mjs'; import { MainThreadAnimation } from './MainThreadAnimation.mjs'; import { acceleratedValues } from './utils/accelerated-values.mjs'; import { getFinalKeyframe } from './waapi/utils/get-final-keyframe.mjs'; import { supportsWaapi } from './waapi/utils/supports-waapi.mjs'; /** * 10ms is chosen here as it strikes a balance between smooth * results (more than one keyframe per frame at 60fps) and * keyframe quantity. */ const sampleDelta = 10; //ms /** * Implement a practical max duration for keyframe generation * to prevent infinite loops */ const maxDuration = 20000; /** * Check if an animation can run natively via WAAPI or requires pregenerated keyframes. * WAAPI doesn't support spring or function easings so we run these as JS animation before * handing off. */ function requiresPregeneratedKeyframes(options) { return (isGenerator(options.type) || options.type === "spring" || !isWaapiSupportedEasing(options.ease)); } function pregenerateKeyframes(keyframes, options) { /** * Create a main-thread animation to pregenerate keyframes. * We sample this at regular intervals to generate keyframes that we then * linearly interpolate between. */ const sampleAnimation = new MainThreadAnimation({ ...options, keyframes, repeat: 0, delay: 0, isGenerator: true, }); let state = { done: false, value: keyframes[0] }; const pregeneratedKeyframes = []; /** * Bail after 20 seconds of pre-generated keyframes as it's likely * we're heading for an infinite loop. */ let t = 0; while (!state.done && t < maxDuration) { state = sampleAnimation.sample(t); pregeneratedKeyframes.push(state.value); t += sampleDelta; } return { times: undefined, keyframes: pregeneratedKeyframes, duration: t - sampleDelta, ease: "linear", }; } const unsupportedEasingFunctions = { anticipate, backInOut, circInOut, }; function isUnsupportedEase(key) { return key in unsupportedEasingFunctions; } class AcceleratedAnimation extends BaseAnimation { constructor(options) { super(options); const { name, motionValue, element, keyframes } = this.options; this.resolver = new DOMKeyframesResolver(keyframes, (resolvedKeyframes, finalKeyframe) => this.onKeyframesResolved(resolvedKeyframes, finalKeyframe), name, motionValue, element); this.resolver.scheduleResolve(); } initPlayback(keyframes, finalKeyframe) { let { duration = 300, times, ease, type, motionValue, name, startTime, } = this.options; /** * If element has since been unmounted, return false to indicate * the animation failed to initialised. */ if (!motionValue.owner || !motionValue.owner.current) { return false; } /** * If the user has provided an easing function name that isn't supported * by WAAPI (like "anticipate"), we need to provide the corressponding * function. This will later get converted to a linear() easing function. */ if (typeof ease === "string" && supportsLinearEasing() && isUnsupportedEase(ease)) { ease = unsupportedEasingFunctions[ease]; } /** * If this animation needs pre-generated keyframes then generate. */ if (requiresPregeneratedKeyframes(this.options)) { const { onComplete, onUpdate, motionValue, element, ...options } = this.options; const pregeneratedAnimation = pregenerateKeyframes(keyframes, options); keyframes = pregeneratedAnimation.keyframes; // If this is a very short animation, ensure we have // at least two keyframes to animate between as older browsers // can't animate between a single keyframe. if (keyframes.length === 1) { keyframes[1] = keyframes[0]; } duration = pregeneratedAnimation.duration; times = pregeneratedAnimation.times; ease = pregeneratedAnimation.ease; type = "keyframes"; } const animation = startWaapiAnimation(motionValue.owner.current, name, keyframes, { ...this.options, duration, times, ease }); // Override the browser calculated startTime with one synchronised to other JS // and WAAPI animations starting this event loop. animation.startTime = startTime ?? this.calcStartTime(); if (this.pendingTimeline) { attachTimeline(animation, this.pendingTimeline); this.pendingTimeline = undefined; } else { /** * Prefer the `onfinish` prop as it's more widely supported than * the `finished` promise. * * Here, we synchronously set the provided MotionValue to the end * keyframe. If we didn't, when the WAAPI animation is finished it would * be removed from the element which would then revert to its old styles. */ animation.onfinish = () => { const { onComplete } = this.options; motionValue.set(getFinalKeyframe(keyframes, this.options, finalKeyframe)); onComplete && onComplete(); this.cancel(); this.resolveFinishedPromise(); }; } return { animation, duration, times, type, ease, keyframes: keyframes, }; } get duration() { const { resolved } = this; if (!resolved) return 0; const { duration } = resolved; return millisecondsToSeconds(duration); } get time() { const { resolved } = this; if (!resolved) return 0; const { animation } = resolved; return millisecondsToSeconds(animation.currentTime || 0); } set time(newTime) { const { resolved } = this; if (!resolved) return; const { animation } = resolved; animation.currentTime = secondsToMilliseconds(newTime); } get speed() { const { resolved } = this; if (!resolved) return 1; const { animation } = resolved; return animation.playbackRate; } get finished() { return this.resolved.animation.finished; } set speed(newSpeed) { const { resolved } = this; if (!resolved) return; const { animation } = resolved; animation.playbackRate = newSpeed; } get state() { const { resolved } = this; if (!resolved) return "idle"; const { animation } = resolved; return animation.playState; } get startTime() { const { resolved } = this; if (!resolved) return null; const { animation } = resolved; // Coerce to number as TypeScript incorrectly types this // as CSSNumberish return animation.startTime; } /** * Replace the default DocumentTimeline with another AnimationTimeline. * Currently used for scroll animations. */ attachTimeline(timeline) { if (!this._resolved) { this.pendingTimeline = timeline; } else { const { resolved } = this; if (!resolved) return noop; const { animation } = resolved; attachTimeline(animation, timeline); } return noop; } play() { if (this.isStopped) return; const { resolved } = this; if (!resolved) return; const { animation } = resolved; if (animation.playState === "finished") { this.updateFinishedPromise(); } animation.play(); } pause() { const { resolved } = this; if (!resolved) return; const { animation } = resolved; animation.pause(); } stop() { this.resolver.cancel(); this.isStopped = true; if (this.state === "idle") return; this.resolveFinishedPromise(); this.updateFinishedPromise(); const { resolved } = this; if (!resolved) return; const { animation, keyframes, duration, type, ease, times } = resolved; if (animation.playState === "idle" || animation.playState === "finished") { return; } /** * WAAPI doesn't natively have any interruption capabilities. * * Rather than read commited styles back out of the DOM, we can * create a renderless JS animation and sample it twice to calculate * its current value, "previous" value, and therefore allow * Motion to calculate velocity for any subsequent animation. */ if (this.time) { const { motionValue, onUpdate, onComplete, element, ...options } = this.options; const sampleAnimation = new MainThreadAnimation({ ...options, keyframes, duration, type, ease, times, isGenerator: true, }); const sampleTime = secondsToMilliseconds(this.time); motionValue.setWithVelocity(sampleAnimation.sample(sampleTime - sampleDelta).value, sampleAnimation.sample(sampleTime).value, sampleDelta); } const { onStop } = this.options; onStop && onStop(); this.cancel(); } complete() { const { resolved } = this; if (!resolved) return; resolved.animation.finish(); } cancel() { const { resolved } = this; if (!resolved) return; resolved.animation.cancel(); } static supports(options) { const { motionValue, name, repeatDelay, repeatType, damping, type } = options; if (!motionValue || !motionValue.owner || !(motionValue.owner.current instanceof HTMLElement)) { return false; } const { onUpdate, transformTemplate } = motionValue.owner.getProps(); return (supportsWaapi() && name && acceleratedValues.has(name) && (name !== "transform" || !transformTemplate) && /** * If we're outputting values to onUpdate then we can't use WAAPI as there's * no way to read the value from WAAPI every frame. */ !onUpdate && !repeatDelay && repeatType !== "mirror" && damping !== 0 && type !== "inertia"); } } export { AcceleratedAnimation };