From aad1a08be23c685c64ce0c73e2ef851f86f9df63 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 20 Feb 2024 15:27:32 +0100 Subject: [PATCH 01/12] Refactor --- dev/benchmarks/cold-start-framer-motion.html | 47 +-- .../src/render/utils/read-value.ts | 1 - packages/framer-motion/rollup.config.js | 2 +- .../src/animation/AnimationControls.ts | 27 ++ .../src/animation/MotionValueAnimation.ts | 298 ++++++++++++++++++ .../animators/AcceleratedAnimation.ts | 30 ++ .../animation/animators/GenericAnimation.ts | 80 +++++ .../animators/MainThreadAnimation.ts | 43 +++ .../src/animation/animators/js/index.ts | 15 +- .../waapi/create-accelerated-animation.ts | 42 +-- .../src/render/dom/DOMKeyframesResolver.ts | 2 +- 11 files changed, 533 insertions(+), 54 deletions(-) create mode 100644 packages/framer-motion/src/animation/AnimationControls.ts create mode 100644 packages/framer-motion/src/animation/MotionValueAnimation.ts create mode 100644 packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts create mode 100644 packages/framer-motion/src/animation/animators/GenericAnimation.ts create mode 100644 packages/framer-motion/src/animation/animators/MainThreadAnimation.ts diff --git a/dev/benchmarks/cold-start-framer-motion.html b/dev/benchmarks/cold-start-framer-motion.html index 05c4f230c4..7b5926b4d8 100644 --- a/dev/benchmarks/cold-start-framer-motion.html +++ b/dev/benchmarks/cold-start-framer-motion.html @@ -30,7 +30,7 @@ .box { width: 10px; height: 100px; - background-color: #fff; + background-color: #0f0; } @@ -40,7 +40,7 @@ diff --git a/packages/framer-motion-3d/src/render/utils/read-value.ts b/packages/framer-motion-3d/src/render/utils/read-value.ts index 896d665286..0088d55d73 100644 --- a/packages/framer-motion-3d/src/render/utils/read-value.ts +++ b/packages/framer-motion-3d/src/render/utils/read-value.ts @@ -37,7 +37,6 @@ function readAnimatableValue(value?: Color) { } export function readThreeValue(instance: Object3DNode, name: string) { - // console.log(name, readers[name], readAnimatableValue(instance[name])) return readers[name] ? readers[name](instance) : readAnimatableValue(instance[name]) || 0 diff --git a/packages/framer-motion/rollup.config.js b/packages/framer-motion/rollup.config.js index f709fe4f56..19b6995e1f 100644 --- a/packages/framer-motion/rollup.config.js +++ b/packages/framer-motion/rollup.config.js @@ -86,7 +86,7 @@ const umdDomProd = Object.assign({}, umd, { resolve(), replaceSettings("production"), pureClass, - // terser({ output: { comments: false } }), + terser({ output: { comments: false } }), ], }) diff --git a/packages/framer-motion/src/animation/AnimationControls.ts b/packages/framer-motion/src/animation/AnimationControls.ts new file mode 100644 index 0000000000..ad81daab14 --- /dev/null +++ b/packages/framer-motion/src/animation/AnimationControls.ts @@ -0,0 +1,27 @@ +export class AnimationControls { + private animation: Animation + + get duration() {} + + set duration() {} + + get speed() {} + + set speed() {} + + get time() {} + + set time() {} + + play() {} + + pause() {} + + stop() {} + + complete() {} + + cancel() {} + + then() {} +} diff --git a/packages/framer-motion/src/animation/MotionValueAnimation.ts b/packages/framer-motion/src/animation/MotionValueAnimation.ts new file mode 100644 index 0000000000..743b738090 --- /dev/null +++ b/packages/framer-motion/src/animation/MotionValueAnimation.ts @@ -0,0 +1,298 @@ +import { memo } from "../utils/memo" +import { noop } from "../utils/noop" +import { + millisecondsToSeconds, + secondsToMilliseconds, +} from "../utils/time-conversion" +import { MotionValue } from "../value" +import { isWaapiSupportedEasing } from "./animators/waapi/easing" +import { ValueAnimationOptions } from "./types" + +/** + * ValueAnimation animates a single MotionValue. + * + * It contains keyframes that can be resolved synchronously or asynchronously. + * + * When keyframes are resolved, either a JavaScript or WAAPI animation is instantiated + * with those keyframes. + */ +export class MotionValueAnimation { + value: MotionValue + + pendingPlaybackState: AnimationPlayState | null = null + + playSpeed = 1 + + hasStopped = false + + resolvedFinishedPromise: VoidFunction + + currentFinishedPromise: Promise + + resolvedDuration: number | null = null + + calculatedDuration: number | null = null + + totalDuration: number | null = null + + options: ValueAnimationOptions + + constructor(value: MotionValue, options: ValueAnimationOptions) { + this.value = value + this.options = options + this.updateFinishedPromise() + } + + onKeyframesResolved() {} + + updateFinishedPromise() { + this.currentFinishedPromise = new Promise((resolve) => { + this.resolvedFinishedPromise = resolve + }) + } + + set speed(newSpeed: number) { + this.playSpeed = newSpeed + } + + then(resolve: VoidFunction, reject?: VoidFunction) { + return this.currentFinishedPromise.then(resolve, reject) + } + + play() { + if (this.animation) { + this.animation.play() + } else { + this.pendingPlaybackState = "running" + } + } + + pause() {} + + stop() { + this.hasStopped = true + + const { onStop } = this.options + + onStop && onStop() + this.cancel() + } + + cancel() { + this.resolvedFinishedPromise() + this.updateFinishedPromise() + this.keyframeResolver.cancel() + } + + finish() { + this.options.onComplete?.() + } +} + +export class MainThreadMotionValueAnimation extends MotionValueAnimation { + private playState: AnimationPlayState = "idle" + + private holdTime: number | null = null + + private startTime: number | null = null + + private cancelTime: number | null = null + + private currentTime: number | null = null + + constructor(value: MotionValue, options: ValueAnimationOptions) { + super(value, options) + } + + pause() { + this.playState = "paused" + this.holdTime = this.currentTime + } + + cancel() { + super.cancel() + this.playState = "idle" + // stop animation driver + } + + finish() { + super.finish() + this.playState = "finished" + } +} + +const supportsWaapi = memo(() => + Object.hasOwnProperty.call(Element.prototype, "animate") +) + +/** + * A list of values that can be hardware-accelerated. + */ +const acceleratedValues = new Set([ + "opacity", + "clipPath", + "filter", + "transform", +]) + +/** + * 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 = 20_000 + +const requiresPregeneratedKeyframes = ( + valueName: string, + options: ValueAnimationOptions +) => + options.type === "spring" || + valueName === "backgroundColor" || + !isWaapiSupportedEasing(options.ease) + +export class AcceleratedMotionValueAnimation extends MotionValueAnimation { + private animation: Animation + + constructor(value: MotionValue, options: ValueAnimationOptions) { + if (!AcceleratedMotionValueAnimation.supports(options)) { + return new MainThreadMotionValueAnimation(value, options) + } + + super(value, options) + } + + cancel() { + super.cancel() + this.animation.cancel() + } + + // Force resolve ? + pause() { + this.animation.pause() + } + + // Force resolve ? + stop() { + this.hasStopped = true + + if (this.animation.playState === "idle") 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. + */ + const { currentTime } = animation + + if (currentTime) { + // Sync resolve + const sampleAnimation = animateValue({ + ...options, + keyframes: resolvedKeyframes, + autoplay: false, + }) + + value.setWithVelocity( + sampleAnimation.sample(currentTime - sampleDelta).value, + sampleAnimation.sample(currentTime).value, + sampleDelta + ) + } + + this.cancel() + } + + // force resolve + attachTimeline(timeline: any) { + this.animation.timeline = timeline + this.animation.onfinish = null + + return noop + } + + get speed() { + return 0 + } + + // Force resolve + get time() { + return millisecondsToSeconds( + (this.animation.currentTime as number) ?? 0 + ) + } + + // Force resolve + set time(time: number) { + this.animation.currentTime = secondsToMilliseconds(time) + } + + get speed() { + return this.animation.playbackRate + } + + set speed(speed: number) { + this.animation.playbackRate = speed + } + + // Force resolve + get duration() { + return millisecondsToSeconds(this.computedDuration) + } + + static supports({ + name, + repeatDelay, + repeatType, + damping, + driver, + type, + }: ValueAnimationOptions) { + // value has owner + return ( + supportsWaapi() && + name && + acceleratedValues.has(name) && + !driver && + !repeatDelay && + repeatType !== "mirror" && + damping !== 0 && + type !== "inertia" + ) + } +} + +class MainThreadAnimation { + private playState: AnimationPlayState = "idle" + + private holdTime: number | null = null + + private startTime: number | null = null + + private cancelTime: number | null = null + + private currentTime: number | null = null + + constructor(private options: ValueAnimationOptions) {} + + pause() { + this.playState = "paused" + this.holdTime = this.currentTime + } + + cancel() { + this.playState = "idle" + } + + finish() { + this.playState = "finished" + } +} diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts new file mode 100644 index 0000000000..c998d37fe3 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -0,0 +1,30 @@ +import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { GenericAnimation } from "./GenericAnimation" + +export class AcceleratedAnimation< + T extends string | number +> extends GenericAnimation { + constructor(options: ValueAnimationOptions) {} + + get duration() {} + + set duration() {} + + get time() {} + + set time() {} + + get speed() {} + + set speed() {} + + play() {} + + pause() {} + + stop() {} + + complete() {} + + cancel() {} +} diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/GenericAnimation.ts new file mode 100644 index 0000000000..9b09208f16 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/GenericAnimation.ts @@ -0,0 +1,80 @@ +import { time } from "../../frameloop/sync-time" +import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { instantAnimationState } from "../../utils/use-instant-transition-state" +import { MotionValue } from "../../value" +import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { canAnimate } from "./utils/can-animate" + +export abstract class GenericAnimation + implements AnimationPlaybackControls +{ + value: MotionValue + + options: ValueAnimationOptions + + resolvedKeyframes: ResolvedKeyframes | null = null + + resolveFinishedPromise: VoidFunction + + currentFinishedPromise: Promise + + hasStopped = false + + constructor(value: MotionValue, options: ValueAnimationOptions) { + this.value = value + this.options = options + this.updateFinishedPromise() + } + + abstract initPlayback( + keyframes: ResolvedKeyframes, + startTime: number + ): void + abstract play(): void + abstract pause(): void + abstract complete(): void + abstract stop(): void + abstract cancel(): void + abstract get speed(): number + abstract set speed(speed: number) + abstract get time(): number + abstract set time(time: number) + abstract get duration(): number + abstract set duration(duration: number) + + onKeyframesResolved(keyframes: ResolvedKeyframes) { + this.resolvedKeyframes = keyframes + const { name, type, velocity, delay } = this.options + + /** + * If we can't animate this value with the resolved keyframes + * then we should complete it immediately. + */ + if (!canAnimate(keyframes, name, type, velocity)) { + // Finish immediately + if (instantAnimationState.current || !delay) { + this.complete() + return + } + // Finish after a delay + else { + this.options.duration = 0 + } + } + + this.initPlayback(keyframes, time.now()) + } + + then(resolve: VoidFunction, reject?: VoidFunction) { + return this.currentFinishedPromise.then(resolve, reject) + } + + private updateFinishedPromise() { + this.currentFinishedPromise = new Promise((resolve) => { + this.resolveFinishedPromise = () => { + resolve() + this.updateFinishedPromise() + } + }) + } +} diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts new file mode 100644 index 0000000000..cedce81130 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -0,0 +1,43 @@ +import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { GenericAnimation } from "./GenericAnimation" + +export class MainThreadAnimation< + T extends string | number +> extends GenericAnimation { + private playState: AnimationPlayState = "idle" + + private holdTime: number | null = null + + private startTime: number | null = null + + private cancelTime: number | null = null + + private playbackSpeed = 1 + + constructor(options: ValueAnimationOptions) {} + + initPlayback(keyframes: ResolvedKeyframes, startTime: number) {} + + get duration() {} + + set duration() {} + + get time() {} + + set time() {} + + get speed() {} + + set speed() {} + + play() {} + + pause() {} + + stop() {} + + complete() {} + + cancel() {} +} diff --git a/packages/framer-motion/src/animation/animators/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts index 5e4891626e..5bd4fa3806 100644 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ b/packages/framer-motion/src/animation/animators/js/index.ts @@ -23,6 +23,7 @@ import { import { instantAnimationState } from "../../../utils/use-instant-transition-state" import { getFinalKeyframe } from "../waapi/utils/get-final-keyframe" import { canAnimate } from "../utils/can-animate" +import { time } from "../../../frameloop/sync-time" type GeneratorFactory = ( options: ValueAnimationOptions @@ -43,15 +44,6 @@ export interface MainThreadAnimationControls const percentToProgress = (percent: number) => percent / 100 -function defaultResolveKeyframes( - keyframes: V[], - onComplete: OnKeyframesResolved, - name?: string, - motionValue?: any -) { - return new KeyframeResolver(keyframes, onComplete, name, motionValue) -} - /** * Animate a single value on the main thread. * @@ -108,7 +100,6 @@ export function animateValue({ resolveFinishedPromise = resolve }) } - // Create the first finished promise updateFinishedPromise() @@ -337,7 +328,9 @@ export function animateValue({ if (!animationDriver) animationDriver = driver(tick) - const now = animationDriver.now() + const now = time.now() + + console.log("start time sync", startTime) onPlay && onPlay() diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index 333f6d5538..ef2f3b6fb3 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -92,18 +92,18 @@ export function createAcceleratedAnimation( */ let pendingCancel = false - /** - * Resolve the current Promise every time we enter the - * finished state. This is WAAPI-compatible behaviour. - */ - const updateFinishedPromise = () => { - currentFinishedPromise = new Promise((resolve) => { - resolveFinishedPromise = resolve - }) - } - - // Create the first finished promise - updateFinishedPromise() + // /** + // * Resolve the current Promise every time we enter the + // * finished state. This is WAAPI-compatible behaviour. + // */ + // const updateFinishedPromise = () => { + // currentFinishedPromise = new Promise((resolve) => { + // resolveFinishedPromise = resolve + // }) + // } + + // // Create the first finished promise + // updateFinishedPromise() let { keyframes: unresolvedKeyframes, @@ -124,14 +124,14 @@ export function createAcceleratedAnimation( safeCancel() } - if (!canAnimate(keyframes, valueName, options.type, options.velocity)) { - if (instantAnimationState.current || !options.delay) { - finish() - return - } else { - options.duration = 0 - } - } + // if (!canAnimate(keyframes, valueName, options.type, options.velocity)) { + // if (instantAnimationState.current || !options.delay) { + // finish() + // return + // } else { + // options.duration = 0 + // } + // } /** * If this animation needs pre-generated keyframes then generate. @@ -183,6 +183,8 @@ export function createAcceleratedAnimation( } ) + animation.startTime = time.now() + /** * Prefer the `onfinish` prop as it's more widely supported than * the `finished` promise. diff --git a/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts index 17c8e92e05..ab32505a07 100644 --- a/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts +++ b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts @@ -50,7 +50,7 @@ export class DOMKeyframesResolver< */ for (let i = 0; i < unresolvedKeyframes.length; i++) { const keyframe = unresolvedKeyframes[i] - if (isCSSVariableToken(keyframe)) { + if (typeof keyframe === "string" && isCSSVariableToken(keyframe)) { const resolved = getVariableValue(keyframe, element.current) if (resolved !== undefined) { From d2a2d2e4102ed85af3a151a8619574a11fcc0106 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 21 Feb 2024 10:40:32 +0100 Subject: [PATCH 02/12] Latest --- .../animation/animators/GenericAnimation.ts | 23 +++- .../animators/MainThreadAnimation.ts | 106 +++++++++++++++++- .../src/animation/animators/js/index.ts | 32 +++--- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/GenericAnimation.ts index 9b09208f16..1022ea7de4 100644 --- a/packages/framer-motion/src/animation/animators/GenericAnimation.ts +++ b/packages/framer-motion/src/animation/animators/GenericAnimation.ts @@ -20,9 +20,28 @@ export abstract class GenericAnimation hasStopped = false - constructor(value: MotionValue, options: ValueAnimationOptions) { + constructor( + value: MotionValue, + { + autoplay = true, + delay = 0, + type = "keyframes", + repeat = 0, + repeatDelay = 0, + repeatType = "loop", + ...options + }: ValueAnimationOptions + ) { this.value = value - this.options = options + this.options = { + autoplay, + delay, + type, + repeat, + repeatDelay, + repeatType, + ...options, + } this.updateFinishedPromise() } diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index cedce81130..25d975122c 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -1,6 +1,28 @@ import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { spring } from "../generators/spring/index" +import { inertia } from "../generators/inertia" +import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" import { GenericAnimation } from "./GenericAnimation" +import { KeyframeGenerator } from "../generators/types" +import { invariant } from "../../dom-entry" +import { pipe } from "../../utils/pipe" +import { mix } from "../../utils/mix" +import { calcGeneratorDuration } from "../generators/utils/calc-duration" + +type GeneratorFactory = ( + options: ValueAnimationOptions +) => KeyframeGenerator + +const generators: { [key: string]: GeneratorFactory } = { + decay: inertia, + inertia, + tween: keyframesGeneratorFactory, + keyframes: keyframesGeneratorFactory, + spring, +} + +const percentToProgress = (percent: number) => percent / 100 export class MainThreadAnimation< T extends string | number @@ -13,11 +35,93 @@ export class MainThreadAnimation< private cancelTime: number | null = null + private calculatedDuration: number | null = null + + private resolvedDuration: number | null = null + + private totalDuration: number | null = null + private playbackSpeed = 1 + private initialKeyframe: T + + private mapPercentToKeyframes?: (percent: number) => T + + private generator: KeyframeGenerator + + private mirroredGenerator?: KeyframeGenerator + constructor(options: ValueAnimationOptions) {} - initPlayback(keyframes: ResolvedKeyframes, startTime: number) {} + initPlayback(keyframes: ResolvedKeyframes, startTime: number) { + this.initialKeyframe = keyframes[0] + + const { + autoplay = true, + type = "keyframes", + repeat = 0, + repeatDelay = 0, + repeatType, + velocity = 0, + } = this.options + + const generatorFactory = generators[type] || keyframesGeneratorFactory + + if ( + generatorFactory !== keyframesGeneratorFactory && + typeof keyframes[0] !== "number" + ) { + if (process.env.NODE_ENV !== "production") { + invariant( + keyframes.length === 2, + `Only two keyframes currently supported with spring and inertia animations. Trying to animate ${keyframes}` + ) + } + + this.mapPercentToKeyframes = pipe( + percentToProgress, + mix(keyframes[0], keyframes[1]) + ) as (t: number) => T + + keyframes = [0, 100] + } + + this.generator = generatorFactory({ ...this.options, keyframes }) + + if (repeatType === "mirror") { + generatorFactory({ + ...this.options, + keyframes: [...keyframes].reverse(), + velocity: -velocity, + }) + } + + /** + * If duration is undefined and we have repeat options, + * we need to calculate a duration from the generator. + * + * We set it to the generator itself to cache the duration. + * Any timeline resolver will need to have already precalculated + * the duration by this step. + */ + if (this.generator.calculatedDuration === null && repeat) { + this.generator.calculatedDuration = calcGeneratorDuration( + this.generator + ) + } + + this.calculatedDuration = this.generator.calculatedDuration + + if (this.calculatedDuration !== null) { + this.resolvedDuration = this.calculatedDuration + repeatDelay + this.totalDuration = + this.resolvedDuration * (repeat + 1) - repeatDelay + } + + autoplay && this.play() + } + + tick(timestamp: number) {} get duration() {} diff --git a/packages/framer-motion/src/animation/animators/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts index 5bd4fa3806..56b8b6afd5 100644 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ b/packages/framer-motion/src/animation/animators/js/index.ts @@ -23,7 +23,6 @@ import { import { instantAnimationState } from "../../../utils/use-instant-transition-state" import { getFinalKeyframe } from "../waapi/utils/get-final-keyframe" import { canAnimate } from "../utils/can-animate" -import { time } from "../../../frameloop/sync-time" type GeneratorFactory = ( options: ValueAnimationOptions @@ -100,6 +99,7 @@ export function animateValue({ resolveFinishedPromise = resolve }) } + // Create the first finished promise updateFinishedPromise() @@ -114,19 +114,19 @@ export function animateValue({ let initialKeyframe: V const createGenerator = (keyframes: ResolvedKeyframes) => { - if (!canAnimate(keyframes, name, type, options.velocity)) { - if (instantAnimationState.current || !delay) { - if (onUpdate) { - onUpdate( - getFinalKeyframe(keyframes, { repeat, repeatType }) - ) - } - finish() - return - } else { - options.duration = 0 - } - } + // if (!canAnimate(keyframes, name, type, options.velocity)) { + // if (instantAnimationState.current || !delay) { + // if (onUpdate) { + // onUpdate( + // getFinalKeyframe(keyframes, { repeat, repeatType }) + // ) + // } + // finish() + // return + // } else { + // options.duration = 0 + // } + // } initialKeyframe = keyframes[0] const generatorFactory = types[type] || keyframesGeneratorFactory @@ -328,9 +328,7 @@ export function animateValue({ if (!animationDriver) animationDriver = driver(tick) - const now = time.now() - - console.log("start time sync", startTime) + const now = animationDriver.now() onPlay && onPlay() From 75ddfdc8c1dd296617eee8c4c31e25dca421f263 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 21 Feb 2024 13:27:55 +0100 Subject: [PATCH 03/12] Latest --- .../animators/AcceleratedAnimation.ts | 10 ++++++- .../animation/animators/GenericAnimation.ts | 19 +++++++------ .../animators/MainThreadAnimation.ts | 28 +++++++++++++++---- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index c998d37fe3..a7e6f5fac5 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,11 +1,15 @@ -import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { ValueAnimationOptions } from "../types" import { GenericAnimation } from "./GenericAnimation" export class AcceleratedAnimation< T extends string | number > extends GenericAnimation { + private animation: Animation | undefined + constructor(options: ValueAnimationOptions) {} + protected initPlayback() {} + get duration() {} set duration() {} @@ -18,6 +22,10 @@ export class AcceleratedAnimation< set speed() {} + get state() { + return this.animation ? this.animation.playState : "idle" + } + play() {} pause() {} diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/GenericAnimation.ts index 1022ea7de4..8a3de5a42f 100644 --- a/packages/framer-motion/src/animation/animators/GenericAnimation.ts +++ b/packages/framer-motion/src/animation/animators/GenericAnimation.ts @@ -8,17 +8,17 @@ import { canAnimate } from "./utils/can-animate" export abstract class GenericAnimation implements AnimationPlaybackControls { - value: MotionValue + protected value: MotionValue - options: ValueAnimationOptions + protected options: ValueAnimationOptions - resolvedKeyframes: ResolvedKeyframes | null = null + protected resolvedKeyframes: ResolvedKeyframes | null = null - resolveFinishedPromise: VoidFunction + protected resolveFinishedPromise: VoidFunction - currentFinishedPromise: Promise + protected currentFinishedPromise: Promise - hasStopped = false + protected hasStopped = false constructor( value: MotionValue, @@ -55,11 +55,12 @@ export abstract class GenericAnimation abstract stop(): void abstract cancel(): void abstract get speed(): number - abstract set speed(speed: number) + abstract set speed(newSpeed: number) abstract get time(): number - abstract set time(time: number) + abstract set time(newTime: number) abstract get duration(): number - abstract set duration(duration: number) + abstract set duration(newDuration: number) + abstract get state(): AnimationPlayState onKeyframesResolved(keyframes: ResolvedKeyframes) { this.resolvedKeyframes = keyframes diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 25d975122c..cb8cb68d3d 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -53,7 +53,7 @@ export class MainThreadAnimation< constructor(options: ValueAnimationOptions) {} - initPlayback(keyframes: ResolvedKeyframes, startTime: number) { + protected initPlayback(keyframes: ResolvedKeyframes, startTime: number) { this.initialKeyframe = keyframes[0] const { @@ -131,17 +131,35 @@ export class MainThreadAnimation< set time() {} - get speed() {} + get speed() { + return this.playbackSpeed + } - set speed() {} + set speed(newSpeed: number) { + const hasChanged = this.playbackSpeed !== newSpeed + this.playbackSpeed = newSpeed + if (hasChanged) { + this.time = millisecondsToSeconds(currentTime) + } + } + + get state() { + return this.playState + } play() {} - pause() {} + pause() { + this.state = "paused" + this.holdTime = this.currentTime + } stop() {} - complete() {} + complete() { + this.state = "finished" + this.holdTime = null + } cancel() {} } From 56c5b3f8784c42fea7d046a02048dab53850d6fd Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 21 Feb 2024 14:04:24 +0100 Subject: [PATCH 04/12] Latest --- .../animators/AcceleratedAnimation.ts | 154 +++++++++++++++++- .../animation/animators/GenericAnimation.ts | 2 + .../animators/MainThreadAnimation.ts | 7 +- .../waapi/create-accelerated-animation.ts | 6 +- 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index a7e6f5fac5..12abc40eac 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,14 +1,121 @@ +import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { memo } from "../../utils/memo" +import { MotionValue } from "../../value" import { ValueAnimationOptions } from "../types" import { GenericAnimation } from "./GenericAnimation" +import { MainThreadAnimation } from "./MainThreadAnimation" +import { animateStyle } from "./waapi" +import { isWaapiSupportedEasing } from "./waapi/easing" +import { getFinalKeyframe } from "./waapi/utils/get-final-keyframe" + +const supportsWaapi = memo(() => + Object.hasOwnProperty.call(Element.prototype, "animate") +) + +/** + * A list of values that can be hardware-accelerated. + */ +const acceleratedValues = new Set([ + "opacity", + "clipPath", + "filter", + "transform", +]) + +/** + * 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 = 20_000 + +const requiresPregeneratedKeyframes = ( + name: string, + options: ValueAnimationOptions +) => + options.type === "spring" || + name === "backgroundColor" || + !isWaapiSupportedEasing(options.ease) export class AcceleratedAnimation< T extends string | number > extends GenericAnimation { private animation: Animation | undefined - constructor(options: ValueAnimationOptions) {} + /** + * Cancelling an animation will write to the DOM. For safety we want to defer + * this until the next `update` frame lifecycle. This flag tracks whether we + * have a pending cancel, if so we shouldn't allow animations to finish. + */ + private pendingCancel = false + + constructor(value: MotionValue, options: ValueAnimationOptions) { + super(value, options) + } + + protected initPlayback(keyframes: ResolvedKeyframes, startTime: number) { + const { name } = this.options + + /** + * If this animation needs pre-generated keyframes then generate. + */ + if (requiresPregeneratedKeyframes(name, this.options)) { + const sampleAnimation = new MainThreadAnimation({ + ...this.options, + keyframes, + repeat: 0, + delay: 0, + }) + let state = { done: false, value: keyframes[0] } + const pregeneratedKeyframes: number[] = [] + + /** + * 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 + } - protected initPlayback() {} + this.options = { + ...this.options, + times: undefined, + keyframes: pregeneratedKeyframes, + duration: t - sampleDelta, + ease: "linear", + } + } + + this.animation = animateStyle( + this.value.owner as HTMLElement, + name, + keyframes, + this.options + ) + + // Override the browser calculated startTime with one synchronised to other JS + // and WAAPI animations starting this event loop. + this.animation.startTime = startTime + + /** + * 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. + */ + this.animation.onfinish = () => this.complete() + } get duration() {} @@ -32,7 +139,48 @@ export class AcceleratedAnimation< stop() {} - complete() {} + // TODO Protect + complete() { + // TODO If pending cancel, don't complete + + const { onComplete } = this.options + + if (this.animation) { + this.value.set( + getFinalKeyframe(this.resolvedKeyframes, this.options) + ) + if (this.animation.playState !== "finished") { + this.animation.onfinish = null + this.animation.finish() + } + } else { + // cancel keyframe resolution + } + + onComplete && onComplete() + } cancel() {} + + static supports( + value: MotionValue, + { name, repeatDelay, repeatType, damping, type }: ValueAnimationOptions + ) { + return ( + supportsWaapi() && + value.owner && + value.owner.current instanceof HTMLElement && + /** + * 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. + */ + !value.owner.getProps().onUpdate && + name && + acceleratedValues.has(name) && + !repeatDelay && + repeatType !== "mirror" && + damping !== 0 && + type !== "inertia" + ) + } } diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/GenericAnimation.ts index 8a3de5a42f..d579caffde 100644 --- a/packages/framer-motion/src/animation/animators/GenericAnimation.ts +++ b/packages/framer-motion/src/animation/animators/GenericAnimation.ts @@ -24,6 +24,7 @@ export abstract class GenericAnimation value: MotionValue, { autoplay = true, + duration = 300, delay = 0, type = "keyframes", repeat = 0, @@ -35,6 +36,7 @@ export abstract class GenericAnimation this.value = value this.options = { autoplay, + duration, delay, type, repeat, diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index cb8cb68d3d..23885a9f2e 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -4,7 +4,7 @@ import { inertia } from "../generators/inertia" import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" import { GenericAnimation } from "./GenericAnimation" -import { KeyframeGenerator } from "../generators/types" +import { AnimationState, KeyframeGenerator } from "../generators/types" import { invariant } from "../../dom-entry" import { pipe } from "../../utils/pipe" import { mix } from "../../utils/mix" @@ -162,4 +162,9 @@ export class MainThreadAnimation< } cancel() {} + + sample(time: number): AnimationState { + this.startTime = 0 + return this.tick(time) + } } diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index ef2f3b6fb3..dcabe02795 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -81,9 +81,9 @@ export function createAcceleratedAnimation( /** * TODO: Unify with js/index */ - let hasStopped = false - let resolveFinishedPromise: VoidFunction - let currentFinishedPromise: Promise + // let hasStopped = false + // let resolveFinishedPromise: VoidFunction + // let currentFinishedPromise: Promise /** * Cancelling an animation will write to the DOM. For safety we want to defer From f8de1c92a328af05ecdc820c594f6fd41bbf6911 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 21 Feb 2024 16:14:53 +0100 Subject: [PATCH 05/12] Latest --- dev/benchmarks/cold-start-framer-motion.html | 55 ++-- .../animators/AcceleratedAnimation.ts | 251 +++++++++++++----- .../animation/animators/GenericAnimation.ts | 100 ++++--- .../animators/MainThreadAnimation.ts | 18 +- packages/framer-motion/src/animation/types.ts | 1 - 5 files changed, 301 insertions(+), 124 deletions(-) diff --git a/dev/benchmarks/cold-start-framer-motion.html b/dev/benchmarks/cold-start-framer-motion.html index 7b5926b4d8..b7b4557853 100644 --- a/dev/benchmarks/cold-start-framer-motion.html +++ b/dev/benchmarks/cold-start-framer-motion.html @@ -50,30 +50,45 @@ const { animate } = Motion const boxes = document.querySelectorAll(".box") + let animations = [] + setTimeout(() => { // Cold start (read from DOM) - boxes.forEach((box) => - animate( - box, - { - transform: [ - `translateX(0px) rotate(0deg)`, - `translateX(5px) rotate(${ - Math.random() * 360 - }deg)`, + boxes.forEach((box) => { + animations.push( + box.animate( + [ + { + transform: `translateX(0px) rotate(0deg)`, + }, + { + transform: `translateX(5px) rotate(${ + Math.random() * 360 + }deg)`, + }, ], - // rotate: Math.random() * 360, - backgroundColor: "#f00", - width: Math.random() * 100 + "%", - // x: 5, - }, - { - ease: "linear", - duration: 1, - repeat: Infinity, - } + { + duration: 1000, + iterations: Infinity, + easing: "linear", + } + ) ) - ) + }) + + setTimeout(() => { + requestAnimationFrame(() => { + animations.forEach((animation) => { + animation.commitStyles() + }) + + requestAnimationFrame(() => { + animations.forEach((animation) => { + animation.cancel() + }) + }) + }) + }, 1000) // setTimeout(() => { // // Value conversion diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 12abc40eac..22e1fd4b9b 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,5 +1,11 @@ +import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" import { memo } from "../../utils/memo" +import { noop } from "../../utils/noop" +import { + millisecondsToSeconds, + secondsToMilliseconds, +} from "../../utils/time-conversion" import { MotionValue } from "../../value" import { ValueAnimationOptions } from "../types" import { GenericAnimation } from "./GenericAnimation" @@ -35,19 +41,64 @@ const sampleDelta = 10 //ms */ const maxDuration = 20_000 -const requiresPregeneratedKeyframes = ( +function requiresPregeneratedKeyframes( name: string, - options: ValueAnimationOptions -) => - options.type === "spring" || - name === "backgroundColor" || - !isWaapiSupportedEasing(options.ease) + options: ValueAnimationOptions +) { + return ( + options.type === "spring" || + name === "backgroundColor" || + !isWaapiSupportedEasing(options.ease) + ) +} + +function pregenerateKeyframes( + keyframes: ResolvedKeyframes, + options: ValueAnimationOptions +): ValueAnimationOptions { + const sampleAnimation = new MainThreadAnimation({ + ...options, + keyframes, + repeat: 0, + delay: 0, + }) + + let state = { done: false, value: keyframes[0] } + const pregeneratedKeyframes: T[] = [] + + /** + * 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", + } +} + +export interface AcceleratedValueAnimationOptions + extends ValueAnimationOptions { + name: string + motionValue: MotionValue +} + +interface ResolvedAcceleratedAnimation { + animation: Animation + duration: number +} export class AcceleratedAnimation< T extends string | number -> extends GenericAnimation { - private animation: Animation | undefined - +> extends GenericAnimation { /** * Cancelling an animation will write to the DOM. For safety we want to defer * this until the next `update` frame lifecycle. This flag tracks whether we @@ -55,56 +106,38 @@ export class AcceleratedAnimation< */ private pendingCancel = false - constructor(value: MotionValue, options: ValueAnimationOptions) { - super(value, options) + private options: AcceleratedValueAnimationOptions + + constructor(options: AcceleratedValueAnimationOptions) { + super(options) } - protected initPlayback(keyframes: ResolvedKeyframes, startTime: number) { - const { name } = this.options + protected initPlayback( + keyframes: ResolvedKeyframes, + startTime: number + ): ResolvedAcceleratedAnimation { + const { name, motionValue, duration = 300, ...options } = this.options /** * If this animation needs pre-generated keyframes then generate. */ if (requiresPregeneratedKeyframes(name, this.options)) { - const sampleAnimation = new MainThreadAnimation({ - ...this.options, - keyframes, - repeat: 0, - delay: 0, - }) - let state = { done: false, value: keyframes[0] } - const pregeneratedKeyframes: number[] = [] - - /** - * 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 - } - this.options = { ...this.options, - times: undefined, - keyframes: pregeneratedKeyframes, - duration: t - sampleDelta, - ease: "linear", + ...pregenerateKeyframes(keyframes, { ...options, duration }), } } - this.animation = animateStyle( - this.value.owner as HTMLElement, + const animation = animateStyle( + motionValue.owner as unknown as HTMLElement, name, - keyframes, + keyframes as string[], this.options ) // Override the browser calculated startTime with one synchronised to other JS // and WAAPI animations starting this event loop. - this.animation.startTime = startTime + animation.startTime = startTime /** * Prefer the `onfinish` prop as it's more widely supported than @@ -114,44 +147,121 @@ export class AcceleratedAnimation< * 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. */ - this.animation.onfinish = () => this.complete() + animation.onfinish = () => this.complete() + + return { animation, duration: this.options.duration! } } - get duration() {} + protected initKeyframeResolver() { + return new DOMKeyframesResolver() + } - set duration() {} + get duration() { + const { duration } = this.resolved + return millisecondsToSeconds(duration) + } - get time() {} + get time() { + const { animation } = this.resolved + return millisecondsToSeconds((animation.currentTime as number) || 0) + } - set time() {} + set time(newTime: number) { + const { animation } = this.resolved + animation.currentTime = secondsToMilliseconds(newTime) + } - get speed() {} + get speed() { + const { animation } = this.resolved + return animation.playbackRate + } - set speed() {} + set speed(newSpeed: number) { + const { animation } = this.resolved + animation.playbackRate = newSpeed + } get state() { - return this.animation ? this.animation.playState : "idle" + const { animation } = this.resolved + return animation.playState } - play() {} + /** + * Replace the default DocumentTimeline with another AnimationTimeline. + * Currently used for scroll animations. + */ + attachTimeline(timeline: AnimationTimeline) { + const { animation } = this.resolved + + animation.timeline = timeline + animation.onfinish = null - pause() {} + return noop + } - stop() {} + play() { + if (this.isStopped) return - // TODO Protect - complete() { - // TODO If pending cancel, don't complete + const { animation } = this.resolved + animation.play() + } + + pause() { + const { animation } = this.resolved + animation.pause() + } + + stop() { + this.isStopped = true + const { animation, keyframes } = this.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. + */ + const { time } = this + + // If the currentTime is 0 we can deduce the animation has no velocity + if (time) { + const { motionValue, onUpdate, onComplete, ...options } = + this.options + const sampleAnimation = new MainThreadAnimation({ + ...options, + keyframes, + }) + + motionValue.setWithVelocity( + sampleAnimation.sample(time - sampleDelta).value, + sampleAnimation.sample(time).value, + sampleDelta + ) + } + // TODO safe ancel animation here + } + + complete() { const { onComplete } = this.options + const { animation } = this.resolved - if (this.animation) { + if (animation) { this.value.set( getFinalKeyframe(this.resolvedKeyframes, this.options) ) - if (this.animation.playState !== "finished") { - this.animation.onfinish = null - this.animation.finish() + if (animation.playState !== "finished") { + animation.onfinish = null + animation.finish() } } else { // cancel keyframe resolution @@ -162,21 +272,26 @@ export class AcceleratedAnimation< cancel() {} - static supports( - value: MotionValue, - { name, repeatDelay, repeatType, damping, type }: ValueAnimationOptions - ) { + static supports({ + motionValue, + name, + repeatDelay, + repeatType, + damping, + type, + }: ValueAnimationOptions) { return ( supportsWaapi() && - value.owner && - value.owner.current instanceof HTMLElement && + name && + acceleratedValues.has(name) && + motionValue && + motionValue.owner && + motionValue.owner.current instanceof HTMLElement && /** * 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. */ - !value.owner.getProps().onUpdate && - name && - acceleratedValues.has(name) && + !motionValue.owner.getProps().onUpdate && !repeatDelay && repeatType !== "mirror" && damping !== 0 && diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/GenericAnimation.ts index d579caffde..cdd5b690f4 100644 --- a/packages/framer-motion/src/animation/animators/GenericAnimation.ts +++ b/packages/framer-motion/src/animation/animators/GenericAnimation.ts @@ -1,39 +1,45 @@ import { time } from "../../frameloop/sync-time" -import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { + KeyframeResolver, + ResolvedKeyframes, + flushKeyframeResolvers, +} from "../../render/utils/KeyframesResolver" import { instantAnimationState } from "../../utils/use-instant-transition-state" -import { MotionValue } from "../../value" import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" import { canAnimate } from "./utils/can-animate" +import { getFinalKeyframe } from "./waapi/utils/get-final-keyframe" -export abstract class GenericAnimation +export abstract class GenericAnimation implements AnimationPlaybackControls { - protected value: MotionValue - - protected options: ValueAnimationOptions - - protected resolvedKeyframes: ResolvedKeyframes | null = null + // Persistent reference to the options used to create this animation + protected options: ValueAnimationOptions + // Resolve the current finished promise protected resolveFinishedPromise: VoidFunction + // A promise that resolves when the animation is complete protected currentFinishedPromise: Promise - protected hasStopped = false - - constructor( - value: MotionValue, - { - autoplay = true, - duration = 300, - delay = 0, - type = "keyframes", - repeat = 0, - repeatDelay = 0, - repeatType = "loop", - ...options - }: ValueAnimationOptions - ) { - this.value = value + // Track whether the animation has been stopped. Stopped animations won't restart. + protected isStopped = false + + // Internal reference to defered resolved keyframes and animation-specific data returned from initPlayback. + private _resolved: Resolved & { keyframes: ResolvedKeyframes } + + // Reference to the active keyframes resolver. + protected resolver: KeyframeResolver + + constructor({ + autoplay = true, + duration = 300, + delay = 0, + type = "keyframes", + repeat = 0, + repeatDelay = 0, + repeatType = "loop", + ...options + }: ValueAnimationOptions) { this.options = { autoplay, duration, @@ -44,28 +50,45 @@ export abstract class GenericAnimation repeatType, ...options, } + this.updateFinishedPromise() + + this.resolver = this.initKeyframeResolver() } abstract initPlayback( keyframes: ResolvedKeyframes, startTime: number - ): void + ): Resolved abstract play(): void abstract pause(): void - abstract complete(): void abstract stop(): void abstract cancel(): void + abstract initKeyframeResolver(): KeyframeResolver abstract get speed(): number abstract set speed(newSpeed: number) abstract get time(): number abstract set time(newTime: number) abstract get duration(): number - abstract set duration(newDuration: number) abstract get state(): AnimationPlayState - onKeyframesResolved(keyframes: ResolvedKeyframes) { - this.resolvedKeyframes = keyframes + /** + * A getter for resolved data. If keyframes are not yet resolved, accessing + * this.resolved will synchronously flush all pending keyframe resolvers. + * This is a deoptimisation, but at its worst still batches read/writes. + */ + get resolved(): Resolved & { keyframes: ResolvedKeyframes } { + if (!this._resolved) flushKeyframeResolvers() + + return this._resolved + } + + /** + * A method to be called when the keyframes resolver completes. This method + * will check if its possible to run the animation and, if not, skip it. + * Otherwise, it will call initPlayback on the implementing class. + */ + protected onKeyframesResolved(keyframes: ResolvedKeyframes) { const { name, type, velocity, delay } = this.options /** @@ -84,9 +107,26 @@ export abstract class GenericAnimation } } - this.initPlayback(keyframes, time.now()) + this._resolved = { + ...this.initPlayback(keyframes, time.now()), + keyframes, + } + } + + complete() { + const { onComplete, motionValue } = this.options + onComplete && onComplete() + + if (motionValue) { + motionValue.set(getFinalKeyframe(keyframes, options)) + } } + /** + * Allows the returned animation to be awaited or promise-chained. Currently + * resolves when the animation finishes at all but in a future update could/should + * reject if its cancels. + */ then(resolve: VoidFunction, reject?: VoidFunction) { return this.currentFinishedPromise.then(resolve, reject) } diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 23885a9f2e..ebb7530e56 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -1,4 +1,7 @@ -import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" +import { + KeyframeResolver, + ResolvedKeyframes, +} from "../../render/utils/KeyframesResolver" import { spring } from "../generators/spring/index" import { inertia } from "../generators/inertia" import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" @@ -9,6 +12,7 @@ import { invariant } from "../../dom-entry" import { pipe } from "../../utils/pipe" import { mix } from "../../utils/mix" import { calcGeneratorDuration } from "../generators/utils/calc-duration" +import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" type GeneratorFactory = ( options: ValueAnimationOptions @@ -26,7 +30,7 @@ const percentToProgress = (percent: number) => percent / 100 export class MainThreadAnimation< T extends string | number -> extends GenericAnimation { +> extends GenericAnimation { private playState: AnimationPlayState = "idle" private holdTime: number | null = null @@ -51,7 +55,13 @@ export class MainThreadAnimation< private mirroredGenerator?: KeyframeGenerator - constructor(options: ValueAnimationOptions) {} + protected initKeyframeResolver() { + const { element, name, value } = this.options + const Resolver = + element && name && value ? KeyframeResolver : DOMKeyframesResolver + + return new Resolver(options) + } protected initPlayback(keyframes: ResolvedKeyframes, startTime: number) { this.initialKeyframe = keyframes[0] @@ -125,8 +135,6 @@ export class MainThreadAnimation< get duration() {} - set duration() {} - get time() {} set time() {} diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 86dd5fd77c..fb74beec6d 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -48,7 +48,6 @@ export interface ValueAnimationOptions keyframes: V[] name?: string motionValue?: MotionValue - element?: VisualElement from?: V } From 4a06f573b31d50a47dbcc738b27390de8dcbc881 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 21 Feb 2024 16:55:03 +0100 Subject: [PATCH 06/12] Latest --- .../animators/AcceleratedAnimation.ts | 35 +-- .../{GenericAnimation.ts => BaseAnimation.ts} | 19 +- .../animators/MainThreadAnimation.ts | 4 +- .../waapi/create-accelerated-animation.ts | 242 +++++++++--------- .../src/animation/interfaces/motion-value.ts | 33 +-- 5 files changed, 166 insertions(+), 167 deletions(-) rename packages/framer-motion/src/animation/animators/{GenericAnimation.ts => BaseAnimation.ts} (87%) diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 22e1fd4b9b..9e117cb7e1 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -8,7 +8,10 @@ import { } from "../../utils/time-conversion" import { MotionValue } from "../../value" import { ValueAnimationOptions } from "../types" -import { GenericAnimation } from "./GenericAnimation" +import { + BaseAnimation, + ValueAnimationOptionsWithDefaults, +} from "./BaseAnimation" import { MainThreadAnimation } from "./MainThreadAnimation" import { animateStyle } from "./waapi" import { isWaapiSupportedEasing } from "./waapi/easing" @@ -85,8 +88,9 @@ function pregenerateKeyframes( } } -export interface AcceleratedValueAnimationOptions - extends ValueAnimationOptions { +export interface AcceleratedValueAnimationOptions< + T extends string | number = number +> extends ValueAnimationOptions { name: string motionValue: MotionValue } @@ -98,7 +102,7 @@ interface ResolvedAcceleratedAnimation { export class AcceleratedAnimation< T extends string | number -> extends GenericAnimation { +> extends BaseAnimation { /** * Cancelling an animation will write to the DOM. For safety we want to defer * this until the next `update` frame lifecycle. This flag tracks whether we @@ -106,7 +110,10 @@ export class AcceleratedAnimation< */ private pendingCancel = false - private options: AcceleratedValueAnimationOptions + protected options: ValueAnimationOptionsWithDefaults & { + name: string + motionValue: MotionValue + } constructor(options: AcceleratedValueAnimationOptions) { super(options) @@ -116,7 +123,7 @@ export class AcceleratedAnimation< keyframes: ResolvedKeyframes, startTime: number ): ResolvedAcceleratedAnimation { - const { name, motionValue, duration = 300, ...options } = this.options + const { name, motionValue, duration, ...options } = this.options /** * If this animation needs pre-generated keyframes then generate. @@ -153,7 +160,7 @@ export class AcceleratedAnimation< } protected initKeyframeResolver() { - return new DOMKeyframesResolver() + return new DOMKeyframesResolver() } get duration() { @@ -272,14 +279,12 @@ export class AcceleratedAnimation< cancel() {} - static supports({ - motionValue, - name, - repeatDelay, - repeatType, - damping, - type, - }: ValueAnimationOptions) { + static supports( + options: ValueAnimationOptions + ): options is AcceleratedValueAnimationOptions { + const { motionValue, name, repeatDelay, repeatType, damping, type } = + options + return ( supportsWaapi() && name && diff --git a/packages/framer-motion/src/animation/animators/GenericAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts similarity index 87% rename from packages/framer-motion/src/animation/animators/GenericAnimation.ts rename to packages/framer-motion/src/animation/animators/BaseAnimation.ts index cdd5b690f4..f6190d8624 100644 --- a/packages/framer-motion/src/animation/animators/GenericAnimation.ts +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -9,11 +9,21 @@ import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" import { canAnimate } from "./utils/can-animate" import { getFinalKeyframe } from "./waapi/utils/get-final-keyframe" -export abstract class GenericAnimation +export interface ValueAnimationOptionsWithDefaults + extends ValueAnimationOptions { + autoplay: ValueAnimationOptions["autoplay"] + delay: ValueAnimationOptions["delay"] + duration: ValueAnimationOptions["duration"] + repeat: ValueAnimationOptions["repeat"] + repeatDelay: ValueAnimationOptions["repeatDelay"] + repeatType: ValueAnimationOptions["repeatType"] +} + +export abstract class BaseAnimation implements AnimationPlaybackControls { // Persistent reference to the options used to create this animation - protected options: ValueAnimationOptions + protected options: ValueAnimationOptionsWithDefaults // Resolve the current finished promise protected resolveFinishedPromise: VoidFunction @@ -56,15 +66,16 @@ export abstract class GenericAnimation this.resolver = this.initKeyframeResolver() } - abstract initPlayback( + protected abstract initPlayback( keyframes: ResolvedKeyframes, startTime: number ): Resolved + protected abstract initKeyframeResolver(): KeyframeResolver + abstract play(): void abstract pause(): void abstract stop(): void abstract cancel(): void - abstract initKeyframeResolver(): KeyframeResolver abstract get speed(): number abstract set speed(newSpeed: number) abstract get time(): number diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index ebb7530e56..da00f76f59 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -6,7 +6,7 @@ import { spring } from "../generators/spring/index" import { inertia } from "../generators/inertia" import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" -import { GenericAnimation } from "./GenericAnimation" +import { BaseAnimation } from "./BaseAnimation" import { AnimationState, KeyframeGenerator } from "../generators/types" import { invariant } from "../../dom-entry" import { pipe } from "../../utils/pipe" @@ -30,7 +30,7 @@ const percentToProgress = (percent: number) => percent / 100 export class MainThreadAnimation< T extends string | number -> extends GenericAnimation { +> extends BaseAnimation { private playState: AnimationPlayState = "idle" private holdTime: number | null = null diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index dcabe02795..65f738d173 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -116,7 +116,7 @@ export function createAcceleratedAnimation( let animation: Animation | undefined const createWaapiAnimation = (keyframes: ResolvedKeyframes) => { - resolvedKeyframes = keyframes + // resolvedKeyframes = keyframes const finish = () => { if (pendingCancel) return value.set(getFinalKeyframe(keyframes, options)) @@ -124,76 +124,76 @@ export function createAcceleratedAnimation( safeCancel() } - // if (!canAnimate(keyframes, valueName, options.type, options.velocity)) { - // if (instantAnimationState.current || !options.delay) { - // finish() - // return - // } else { - // options.duration = 0 + // // if (!canAnimate(keyframes, valueName, options.type, options.velocity)) { + // // if (instantAnimationState.current || !options.delay) { + // // finish() + // // return + // // } else { + // // options.duration = 0 + // // } + // // } + + // /** + // * If this animation needs pre-generated keyframes then generate. + // */ + // if (requiresPregeneratedKeyframes(valueName, options)) { + // const sampleAnimation = animateValue({ + // ...options, + // keyframes: resolvedKeyframes, + // repeat: 0, + // delay: 0, + // }) + // let state = { done: false, value: keyframes[0] } + // const pregeneratedKeyframes: number[] = [] + + // /** + // * 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 // } - // } - - /** - * If this animation needs pre-generated keyframes then generate. - */ - if (requiresPregeneratedKeyframes(valueName, options)) { - const sampleAnimation = animateValue({ - ...options, - keyframes: resolvedKeyframes, - repeat: 0, - delay: 0, - }) - let state = { done: false, value: keyframes[0] } - const pregeneratedKeyframes: number[] = [] - /** - * 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 - } - - times = undefined - keyframes = pregeneratedKeyframes - duration = t - sampleDelta - ease = "linear" - } + // times = undefined + // keyframes = pregeneratedKeyframes + // duration = t - sampleDelta + // ease = "linear" + // } - animation = animateStyle( - (value.owner as VisualElement).current!, - valueName, - keyframes, - { - ...options, - duration, - /** - * This function is currently not called if ease is provided - * as a function so the cast is safe. - * - * However it would be possible for a future refinement to port - * in easing pregeneration from Motion One for browsers that - * support the upcoming `linear()` easing function. - */ - ease: ease as EasingDefinition, - times, - } - ) - - animation.startTime = time.now() - - /** - * 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 = finish + // animation = animateStyle( + // (value.owner as VisualElement).current!, + // valueName, + // keyframes, + // { + // ...options, + // duration, + // /** + // * This function is currently not called if ease is provided + // * as a function so the cast is safe. + // * + // * However it would be possible for a future refinement to port + // * in easing pregeneration from Motion One for browsers that + // * support the upcoming `linear()` easing function. + // */ + // ease: ease as EasingDefinition, + // times, + // } + // ) + + // animation.startTime = time.now() + + // /** + // * 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 = finish } const cancelAnimation = () => { @@ -213,61 +213,61 @@ export function createAcceleratedAnimation( updateFinishedPromise() } - const resolver = - element && name && motionValue - ? element.resolveKeyframes( - unresolvedKeyframes, - createWaapiAnimation, - name, - motionValue - ) - : new KeyframeResolver( - unresolvedKeyframes, - createWaapiAnimation, - name, - motionValue, - element - ) + // const resolver = + // element && name && motionValue + // ? element.resolveKeyframes( + // unresolvedKeyframes, + // createWaapiAnimation, + // name, + // motionValue + // ) + // : new KeyframeResolver( + // unresolvedKeyframes, + // createWaapiAnimation, + // name, + // motionValue, + // element + // ) /** * Animation interrupt callback. */ const controls = { - then(resolve: VoidFunction, reject?: VoidFunction) { - return currentFinishedPromise.then(resolve, reject) - }, - attachTimeline(timeline: any) { - if (!animation) flushKeyframeResolvers() - - animation!.timeline = timeline - animation!.onfinish = null - - return noop - }, - get time() { - if (!animation) flushKeyframeResolvers() - return millisecondsToSeconds(animation!.currentTime || 0) - }, - set time(newTime: number) { - if (!animation) flushKeyframeResolvers() - animation!.currentTime = secondsToMilliseconds(newTime) - }, - get speed() { - if (!animation) flushKeyframeResolvers() - return animation!.playbackRate - }, - set speed(newSpeed: number) { - if (!animation) flushKeyframeResolvers() - animation!.playbackRate = newSpeed - }, - get duration() { - // TODO allow async - return millisecondsToSeconds(duration) - }, + // then(resolve: VoidFunction, reject?: VoidFunction) { + // return currentFinishedPromise.then(resolve, reject) + // }, + // attachTimeline(timeline: any) { + // if (!animation) flushKeyframeResolvers() + + // animation!.timeline = timeline + // animation!.onfinish = null + + // return noop + // }, + // get time() { + // if (!animation) flushKeyframeResolvers() + // return millisecondsToSeconds(animation!.currentTime || 0) + // }, + // set time(newTime: number) { + // if (!animation) flushKeyframeResolvers() + // animation!.currentTime = secondsToMilliseconds(newTime) + // }, + // get speed() { + // if (!animation) flushKeyframeResolvers() + // return animation!.playbackRate + // }, + // set speed(newSpeed: number) { + // if (!animation) flushKeyframeResolvers() + // animation!.playbackRate = newSpeed + // }, + // get duration() { + // // TODO allow async + // return millisecondsToSeconds(duration) + // }, play: () => { - if (!animation) flushKeyframeResolvers() - if (hasStopped) return - animation!.play() + // if (!animation) flushKeyframeResolvers() + // if (hasStopped) return + // animation!.play() /** * Cancel any pending cancel tasks @@ -275,10 +275,10 @@ export function createAcceleratedAnimation( cancelFrame(cancelAnimation) }, // TODO allow async - pause: () => { - if (!animation) flushKeyframeResolvers() - animation!.pause() - }, + // pause: () => { + // if (!animation) flushKeyframeResolvers() + // animation!.pause() + // }, stop: () => { if (!animation) flushKeyframeResolvers() diff --git a/packages/framer-motion/src/animation/interfaces/motion-value.ts b/packages/framer-motion/src/animation/interfaces/motion-value.ts index 491b4ba798..fb474ac451 100644 --- a/packages/framer-motion/src/animation/interfaces/motion-value.ts +++ b/packages/framer-motion/src/animation/interfaces/motion-value.ts @@ -12,6 +12,7 @@ import { createAcceleratedAnimation } from "../animators/waapi/create-accelerate import type { VisualElement } from "../../render/VisualElement" import { getFinalKeyframe } from "../animators/waapi/utils/get-final-keyframe" import { frame } from "../../frameloop/frame" +import { AcceleratedAnimation } from "../animators/AcceleratedAnimation" export const animateMotionValue = ( @@ -132,31 +133,13 @@ export const animateMotionValue = } /** - * Animate via WAAPI if possible. + * Animate via WAAPI if possible. If this is a handoff animation, the optimised animation will be running via + * WAAPI. Therefore, this animation must be JS to ensure it runs "under" the + * optimised animation. */ - if ( - /** - * If this is a handoff animation, the optimised animation will be running via - * WAAPI. Therefore, this animation must be JS to ensure it runs "under" the - * optimised animation. - */ - !isHandoff && - value.owner && - value.owner.current instanceof HTMLElement && - /** - * 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. - */ - !value.owner.getProps().onUpdate - ) { - const acceleratedAnimation = createAcceleratedAnimation( - value, - name, - options - ) - - if (acceleratedAnimation) return acceleratedAnimation + if (!isHandoff && AcceleratedAnimation.supports(options)) { + return new AcceleratedAnimation(options) + } else { + return animateValue(options) } - - return animateValue(options) } From 112eb1ebd80c7583552b1a2f4533a3d8cdd7a054 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 22 Feb 2024 12:30:06 +0100 Subject: [PATCH 07/12] Latest --- .../src/animation/AnimationControls.ts | 27 -- .../src/animation/MotionValueAnimation.ts | 298 ------------ .../animators/AcceleratedAnimation.ts | 13 +- .../src/animation/animators/BaseAnimation.ts | 32 +- .../animators/MainThreadAnimation.ts | 344 ++++++++++++-- .../src/animation/animators/js/index.ts | 441 ------------------ .../src/animation/interfaces/motion-value.ts | 5 +- packages/framer-motion/src/animation/types.ts | 4 +- 8 files changed, 319 insertions(+), 845 deletions(-) delete mode 100644 packages/framer-motion/src/animation/AnimationControls.ts delete mode 100644 packages/framer-motion/src/animation/MotionValueAnimation.ts delete mode 100644 packages/framer-motion/src/animation/animators/js/index.ts diff --git a/packages/framer-motion/src/animation/AnimationControls.ts b/packages/framer-motion/src/animation/AnimationControls.ts deleted file mode 100644 index ad81daab14..0000000000 --- a/packages/framer-motion/src/animation/AnimationControls.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class AnimationControls { - private animation: Animation - - get duration() {} - - set duration() {} - - get speed() {} - - set speed() {} - - get time() {} - - set time() {} - - play() {} - - pause() {} - - stop() {} - - complete() {} - - cancel() {} - - then() {} -} diff --git a/packages/framer-motion/src/animation/MotionValueAnimation.ts b/packages/framer-motion/src/animation/MotionValueAnimation.ts deleted file mode 100644 index 743b738090..0000000000 --- a/packages/framer-motion/src/animation/MotionValueAnimation.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { memo } from "../utils/memo" -import { noop } from "../utils/noop" -import { - millisecondsToSeconds, - secondsToMilliseconds, -} from "../utils/time-conversion" -import { MotionValue } from "../value" -import { isWaapiSupportedEasing } from "./animators/waapi/easing" -import { ValueAnimationOptions } from "./types" - -/** - * ValueAnimation animates a single MotionValue. - * - * It contains keyframes that can be resolved synchronously or asynchronously. - * - * When keyframes are resolved, either a JavaScript or WAAPI animation is instantiated - * with those keyframes. - */ -export class MotionValueAnimation { - value: MotionValue - - pendingPlaybackState: AnimationPlayState | null = null - - playSpeed = 1 - - hasStopped = false - - resolvedFinishedPromise: VoidFunction - - currentFinishedPromise: Promise - - resolvedDuration: number | null = null - - calculatedDuration: number | null = null - - totalDuration: number | null = null - - options: ValueAnimationOptions - - constructor(value: MotionValue, options: ValueAnimationOptions) { - this.value = value - this.options = options - this.updateFinishedPromise() - } - - onKeyframesResolved() {} - - updateFinishedPromise() { - this.currentFinishedPromise = new Promise((resolve) => { - this.resolvedFinishedPromise = resolve - }) - } - - set speed(newSpeed: number) { - this.playSpeed = newSpeed - } - - then(resolve: VoidFunction, reject?: VoidFunction) { - return this.currentFinishedPromise.then(resolve, reject) - } - - play() { - if (this.animation) { - this.animation.play() - } else { - this.pendingPlaybackState = "running" - } - } - - pause() {} - - stop() { - this.hasStopped = true - - const { onStop } = this.options - - onStop && onStop() - this.cancel() - } - - cancel() { - this.resolvedFinishedPromise() - this.updateFinishedPromise() - this.keyframeResolver.cancel() - } - - finish() { - this.options.onComplete?.() - } -} - -export class MainThreadMotionValueAnimation extends MotionValueAnimation { - private playState: AnimationPlayState = "idle" - - private holdTime: number | null = null - - private startTime: number | null = null - - private cancelTime: number | null = null - - private currentTime: number | null = null - - constructor(value: MotionValue, options: ValueAnimationOptions) { - super(value, options) - } - - pause() { - this.playState = "paused" - this.holdTime = this.currentTime - } - - cancel() { - super.cancel() - this.playState = "idle" - // stop animation driver - } - - finish() { - super.finish() - this.playState = "finished" - } -} - -const supportsWaapi = memo(() => - Object.hasOwnProperty.call(Element.prototype, "animate") -) - -/** - * A list of values that can be hardware-accelerated. - */ -const acceleratedValues = new Set([ - "opacity", - "clipPath", - "filter", - "transform", -]) - -/** - * 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 = 20_000 - -const requiresPregeneratedKeyframes = ( - valueName: string, - options: ValueAnimationOptions -) => - options.type === "spring" || - valueName === "backgroundColor" || - !isWaapiSupportedEasing(options.ease) - -export class AcceleratedMotionValueAnimation extends MotionValueAnimation { - private animation: Animation - - constructor(value: MotionValue, options: ValueAnimationOptions) { - if (!AcceleratedMotionValueAnimation.supports(options)) { - return new MainThreadMotionValueAnimation(value, options) - } - - super(value, options) - } - - cancel() { - super.cancel() - this.animation.cancel() - } - - // Force resolve ? - pause() { - this.animation.pause() - } - - // Force resolve ? - stop() { - this.hasStopped = true - - if (this.animation.playState === "idle") 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. - */ - const { currentTime } = animation - - if (currentTime) { - // Sync resolve - const sampleAnimation = animateValue({ - ...options, - keyframes: resolvedKeyframes, - autoplay: false, - }) - - value.setWithVelocity( - sampleAnimation.sample(currentTime - sampleDelta).value, - sampleAnimation.sample(currentTime).value, - sampleDelta - ) - } - - this.cancel() - } - - // force resolve - attachTimeline(timeline: any) { - this.animation.timeline = timeline - this.animation.onfinish = null - - return noop - } - - get speed() { - return 0 - } - - // Force resolve - get time() { - return millisecondsToSeconds( - (this.animation.currentTime as number) ?? 0 - ) - } - - // Force resolve - set time(time: number) { - this.animation.currentTime = secondsToMilliseconds(time) - } - - get speed() { - return this.animation.playbackRate - } - - set speed(speed: number) { - this.animation.playbackRate = speed - } - - // Force resolve - get duration() { - return millisecondsToSeconds(this.computedDuration) - } - - static supports({ - name, - repeatDelay, - repeatType, - damping, - driver, - type, - }: ValueAnimationOptions) { - // value has owner - return ( - supportsWaapi() && - name && - acceleratedValues.has(name) && - !driver && - !repeatDelay && - repeatType !== "mirror" && - damping !== 0 && - type !== "inertia" - ) - } -} - -class MainThreadAnimation { - private playState: AnimationPlayState = "idle" - - private holdTime: number | null = null - - private startTime: number | null = null - - private cancelTime: number | null = null - - private currentTime: number | null = null - - constructor(private options: ValueAnimationOptions) {} - - pause() { - this.playState = "paused" - this.holdTime = this.currentTime - } - - cancel() { - this.playState = "idle" - } - - finish() { - this.playState = "finished" - } -} diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 9e117cb7e1..63b2c8ec7d 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,3 +1,4 @@ +import { time } from "../../frameloop/sync-time" import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" import { memo } from "../../utils/memo" @@ -103,13 +104,6 @@ interface ResolvedAcceleratedAnimation { export class AcceleratedAnimation< T extends string | number > extends BaseAnimation { - /** - * Cancelling an animation will write to the DOM. For safety we want to defer - * this until the next `update` frame lifecycle. This flag tracks whether we - * have a pending cancel, if so we shouldn't allow animations to finish. - */ - private pendingCancel = false - protected options: ValueAnimationOptionsWithDefaults & { name: string motionValue: MotionValue @@ -120,8 +114,7 @@ export class AcceleratedAnimation< } protected initPlayback( - keyframes: ResolvedKeyframes, - startTime: number + keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { const { name, motionValue, duration, ...options } = this.options @@ -144,7 +137,7 @@ export class AcceleratedAnimation< // Override the browser calculated startTime with one synchronised to other JS // and WAAPI animations starting this event loop. - animation.startTime = startTime + animation.startTime = time.now() /** * Prefer the `onfinish` prop as it's more widely supported than diff --git a/packages/framer-motion/src/animation/animators/BaseAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts index f6190d8624..8d5669dea0 100644 --- a/packages/framer-motion/src/animation/animators/BaseAnimation.ts +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -1,22 +1,25 @@ -import { time } from "../../frameloop/sync-time" import { KeyframeResolver, ResolvedKeyframes, flushKeyframeResolvers, } from "../../render/utils/KeyframesResolver" import { instantAnimationState } from "../../utils/use-instant-transition-state" -import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { + AnimationPlaybackControls, + RepeatType, + ValueAnimationOptions, +} from "../types" import { canAnimate } from "./utils/can-animate" import { getFinalKeyframe } from "./waapi/utils/get-final-keyframe" export interface ValueAnimationOptionsWithDefaults extends ValueAnimationOptions { - autoplay: ValueAnimationOptions["autoplay"] - delay: ValueAnimationOptions["delay"] - duration: ValueAnimationOptions["duration"] - repeat: ValueAnimationOptions["repeat"] - repeatDelay: ValueAnimationOptions["repeatDelay"] - repeatType: ValueAnimationOptions["repeatType"] + autoplay: boolean + delay: number + duration: number + repeat: number + repeatDelay: number + repeatType: RepeatType } export abstract class BaseAnimation @@ -66,10 +69,7 @@ export abstract class BaseAnimation this.resolver = this.initKeyframeResolver() } - protected abstract initPlayback( - keyframes: ResolvedKeyframes, - startTime: number - ): Resolved + protected abstract initPlayback(keyframes: ResolvedKeyframes): Resolved protected abstract initKeyframeResolver(): KeyframeResolver abstract play(): void @@ -119,7 +119,7 @@ export abstract class BaseAnimation } this._resolved = { - ...this.initPlayback(keyframes, time.now()), + ...this.initPlayback(keyframes), keyframes, } } @@ -129,7 +129,9 @@ export abstract class BaseAnimation onComplete && onComplete() if (motionValue) { - motionValue.set(getFinalKeyframe(keyframes, options)) + motionValue.set( + getFinalKeyframe(this.resolved.keyframes, this.options) + ) } } @@ -142,7 +144,7 @@ export abstract class BaseAnimation return this.currentFinishedPromise.then(resolve, reject) } - private updateFinishedPromise() { + protected updateFinishedPromise() { this.currentFinishedPromise = new Promise((resolve) => { this.resolveFinishedPromise = () => { resolve() diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index da00f76f59..75899cde7b 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -5,14 +5,20 @@ import { import { spring } from "../generators/spring/index" import { inertia } from "../generators/inertia" import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" -import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +import { ValueAnimationOptions } from "../types" import { BaseAnimation } from "./BaseAnimation" import { AnimationState, KeyframeGenerator } from "../generators/types" -import { invariant } from "../../dom-entry" import { pipe } from "../../utils/pipe" import { mix } from "../../utils/mix" import { calcGeneratorDuration } from "../generators/utils/calc-duration" -import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" +import { DriverControls } from "./js/types" +import { frameloopDriver } from "./js/driver-frameloop" +import { + millisecondsToSeconds, + secondsToMilliseconds, +} from "../../utils/time-conversion" +import { clamp } from "../../utils/clamp" +import { invariant } from "../../utils/errors" type GeneratorFactory = ( options: ValueAnimationOptions @@ -28,10 +34,19 @@ const generators: { [key: string]: GeneratorFactory } = { const percentToProgress = (percent: number) => percent / 100 +interface ResolvedData { + generator: KeyframeGenerator + mirroredGenerator: KeyframeGenerator | undefined + mapPercentToKeyframes: ((v: number) => T) | undefined + calculatedDuration: number + resolvedDuration: number + totalDuration: number +} + export class MainThreadAnimation< T extends string | number -> extends BaseAnimation { - private playState: AnimationPlayState = "idle" +> extends BaseAnimation> { + private driver?: DriverControls private holdTime: number | null = null @@ -39,33 +54,33 @@ export class MainThreadAnimation< private cancelTime: number | null = null - private calculatedDuration: number | null = null - - private resolvedDuration: number | null = null - - private totalDuration: number | null = null + private currentTime: number = 0 private playbackSpeed = 1 - private initialKeyframe: T - - private mapPercentToKeyframes?: (percent: number) => T - - private generator: KeyframeGenerator - - private mirroredGenerator?: KeyframeGenerator - protected initKeyframeResolver() { - const { element, name, value } = this.options - const Resolver = - element && name && value ? KeyframeResolver : DOMKeyframesResolver - - return new Resolver(options) + const { name, motionValue, keyframes } = this.options + const onResolved = (resolvedKeyframes: ResolvedKeyframes) => + this.onKeyframesResolved(resolvedKeyframes) + + if (name && motionValue && motionValue.owner) { + return (motionValue.owner as any).resolveKeyframes( + keyframes, + onResolved, + name, + motionValue + ) + } else { + return new KeyframeResolver( + keyframes, + onResolved, + name, + motionValue + ) + } } - protected initPlayback(keyframes: ResolvedKeyframes, startTime: number) { - this.initialKeyframe = keyframes[0] - + protected initPlayback(keyframes: ResolvedKeyframes) { const { autoplay = true, type = "keyframes", @@ -77,6 +92,9 @@ export class MainThreadAnimation< const generatorFactory = generators[type] || keyframesGeneratorFactory + let mapPercentToKeyframes: ((v: number) => T) | undefined + let mirroredGenerator: KeyframeGenerator | undefined + if ( generatorFactory !== keyframesGeneratorFactory && typeof keyframes[0] !== "number" @@ -88,18 +106,18 @@ export class MainThreadAnimation< ) } - this.mapPercentToKeyframes = pipe( + mapPercentToKeyframes = pipe( percentToProgress, mix(keyframes[0], keyframes[1]) ) as (t: number) => T - keyframes = [0, 100] + keyframes = [0 as T, 100 as T] } - this.generator = generatorFactory({ ...this.options, keyframes }) + const generator = generatorFactory({ ...this.options, keyframes }) if (repeatType === "mirror") { - generatorFactory({ + mirroredGenerator = generatorFactory({ ...this.options, keyframes: [...keyframes].reverse(), velocity: -velocity, @@ -114,30 +132,195 @@ export class MainThreadAnimation< * Any timeline resolver will need to have already precalculated * the duration by this step. */ - if (this.generator.calculatedDuration === null && repeat) { - this.generator.calculatedDuration = calcGeneratorDuration( - this.generator + if (generator.calculatedDuration === null) { + generator.calculatedDuration = calcGeneratorDuration(generator) + } + + const { calculatedDuration } = generator + const resolvedDuration = calculatedDuration + repeatDelay + const totalDuration = resolvedDuration * (repeat + 1) - repeatDelay + + autoplay && this.play() + + return { + generator, + mirroredGenerator, + mapPercentToKeyframes, + calculatedDuration, + resolvedDuration, + totalDuration, + } + } + + tick(timestamp: number) { + const { + generator, + mirroredGenerator, + mapPercentToKeyframes, + keyframes, + calculatedDuration, + totalDuration, + resolvedDuration, + } = this.resolved + + if (this.startTime === null) return generator.next(0) + + const { delay, repeat, repeatType, repeatDelay, onUpdate } = + this.options + + /** + * requestAnimationFrame timestamps can come through as lower than + * the startTime as set by performance.now(). Here we prevent this, + * though in the future it could be possible to make setting startTime + * a pending operation that gets resolved here. + */ + if (this.speed > 0) { + this.startTime = Math.min(this.startTime, timestamp) + } else if (this.speed < 0) { + this.startTime = Math.min( + timestamp - totalDuration / this.speed, + this.startTime ) } - this.calculatedDuration = this.generator.calculatedDuration + // Update currentTime + if (this.holdTime !== null) { + this.currentTime = this.holdTime + } else { + // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 = + // 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for + // example. + this.currentTime = + Math.round(timestamp - this.startTime) * this.speed + } - if (this.calculatedDuration !== null) { - this.resolvedDuration = this.calculatedDuration + repeatDelay - this.totalDuration = - this.resolvedDuration * (repeat + 1) - repeatDelay + // Rebase on delay + const timeWithoutDelay = + this.currentTime - delay * (this.speed >= 0 ? 1 : -1) + const isInDelayPhase = + this.speed >= 0 + ? timeWithoutDelay < 0 + : timeWithoutDelay > totalDuration + this.currentTime = Math.max(timeWithoutDelay, 0) + + // If this animation has finished, set the current time to the total duration. + if (this.state === "finished" && this.holdTime === null) { + this.currentTime = totalDuration } - autoplay && this.play() + let elapsed = this.currentTime + + let frameGenerator = generator + + if (repeat) { + /** + * Get the current progress (0-1) of the animation. If t is > + * than duration we'll get values like 2.5 (midway through the + * third iteration) + */ + const progress = + Math.min(this.currentTime, totalDuration) / resolvedDuration + + /** + * Get the current iteration (0 indexed). For instance the floor of + * 2.5 is 2. + */ + let currentIteration = Math.floor(progress) + + /** + * Get the current progress of the iteration by taking the remainder + * so 2.5 is 0.5 through iteration 2 + */ + let iterationProgress = progress % 1.0 + + /** + * If iteration progress is 1 we count that as the end + * of the previous iteration. + */ + if (!iterationProgress && progress >= 1) { + iterationProgress = 1 + } + + iterationProgress === 1 && currentIteration-- + + currentIteration = Math.min(currentIteration, repeat + 1) + + /** + * Reverse progress if we're not running in "normal" direction + */ + + const isOddIteration = Boolean(currentIteration % 2) + if (isOddIteration) { + if (repeatType === "reverse") { + iterationProgress = 1 - iterationProgress + if (repeatDelay) { + iterationProgress -= repeatDelay / resolvedDuration + } + } else if (repeatType === "mirror") { + frameGenerator = mirroredGenerator! + } + } + + elapsed = clamp(0, 1, iterationProgress) * resolvedDuration + } + + /** + * If we're in negative time, set state as the initial keyframe. + * This prevents delay: x, duration: 0 animations from finishing + * instantly. + */ + const state = isInDelayPhase + ? { done: false, value: keyframes[0] } + : frameGenerator.next(elapsed) + + if (mapPercentToKeyframes) { + state.value = mapPercentToKeyframes(state.value as number) + } + + let { done } = state + + if (!isInDelayPhase && calculatedDuration !== null) { + done = + this.speed >= 0 + ? this.currentTime >= totalDuration + : this.currentTime <= 0 + } + + const isAnimationFinished = + this.holdTime === null && + (this.state === "finished" || (this.state === "running" && done)) + + if (onUpdate) { + onUpdate(state.value) + } + + if (isAnimationFinished) { + this.finish() + } + + return state } - tick(timestamp: number) {} + state: AnimationPlayState = "idle" - get duration() {} + get duration() { + return millisecondsToSeconds(this.resolved.calculatedDuration) + } - get time() {} + get time() { + return millisecondsToSeconds(this.currentTime) + } - set time() {} + set time(newTime: number) { + newTime = secondsToMilliseconds(newTime) + this.currentTime = newTime + + if (this.holdTime !== null || !this.driver || this.speed === 0) { + this.holdTime = newTime + } else { + this.startTime = this.driver.now() - newTime / this.speed + } + } get speed() { return this.playbackSpeed @@ -147,29 +330,90 @@ export class MainThreadAnimation< const hasChanged = this.playbackSpeed !== newSpeed this.playbackSpeed = newSpeed if (hasChanged) { - this.time = millisecondsToSeconds(currentTime) + this.time = millisecondsToSeconds(this.currentTime) } } - get state() { - return this.playState - } + play() { + if (this.isStopped) return + + const { driver = frameloopDriver, onPlay } = this.options + + if (!this.driver) { + this.driver = driver((timestamp) => this.tick(timestamp)) + } - play() {} + onPlay && onPlay() + + const now = this.driver.now() + + if (this.holdTime !== null) { + this.startTime = now - this.holdTime + } else if (!this.startTime || this.state === "finished") { + this.startTime = now + } + + if (this.state === "finished") { + this.updateFinishedPromise() + } + + this.cancelTime = this.startTime + this.holdTime = null + this.state = "running" + + this.driver.start() + } pause() { this.state = "paused" this.holdTime = this.currentTime } - stop() {} + stop() { + this.isStopped = true + if (this.state === "idle") return + + this.state = "idle" + const { onStop } = this.options + onStop && onStop() + this.teardown() + } complete() { this.state = "finished" this.holdTime = null } - cancel() {} + finish() { + this.state = "finished" + const { onComplete } = this.options + onComplete && onComplete() + this.resolveFinishedPromise() + this.updateFinishedPromise() + this.stopDriver() + } + + cancel() { + if (this.cancelTime !== null) { + this.tick(this.cancelTime) + } + this.teardown() + } + + private teardown() { + this.state = "idle" + this.stopDriver() + this.resolveFinishedPromise() + this.updateFinishedPromise() + this.startTime = this.cancelTime = null + this.resolver.cancel() // TODO Add test with play after this + } + + private stopDriver() { + if (!this.driver) return + this.driver.stop() + this.driver = undefined + } sample(time: number): AnimationState { this.startTime = 0 diff --git a/packages/framer-motion/src/animation/animators/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts deleted file mode 100644 index 56b8b6afd5..0000000000 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { AnimationPlaybackControls } from "../../types" -import { keyframes as keyframesGeneratorFactory } from "../../generators/keyframes" -import { spring } from "../../generators/spring/index" -import { inertia } from "../../generators/inertia" -import { AnimationState, KeyframeGenerator } from "../../generators/types" -import { DriverControls } from "./types" -import { ValueAnimationOptions } from "../../types" -import { frameloopDriver } from "./driver-frameloop" -import { clamp } from "../../../utils/clamp" -import { - millisecondsToSeconds, - secondsToMilliseconds, -} from "../../../utils/time-conversion" -import { calcGeneratorDuration } from "../../generators/utils/calc-duration" -import { invariant } from "../../../utils/errors" -import { mix } from "../../../utils/mix" -import { pipe } from "../../../utils/pipe" -import { - KeyframeResolver, - ResolvedKeyframes, - flushKeyframeResolvers, -} from "../../../render/utils/KeyframesResolver" -import { instantAnimationState } from "../../../utils/use-instant-transition-state" -import { getFinalKeyframe } from "../waapi/utils/get-final-keyframe" -import { canAnimate } from "../utils/can-animate" - -type GeneratorFactory = ( - options: ValueAnimationOptions -) => KeyframeGenerator - -const types: { [key: string]: GeneratorFactory } = { - decay: inertia, - inertia, - tween: keyframesGeneratorFactory, - keyframes: keyframesGeneratorFactory, - spring, -} - -export interface MainThreadAnimationControls - extends AnimationPlaybackControls { - sample: (t: number) => AnimationState -} - -const percentToProgress = (percent: number) => percent / 100 - -/** - * Animate a single value on the main thread. - * - * This function is written, where functionality overlaps, - * to be largely spec-compliant with WAAPI to allow fungibility - * between the two. - */ -export function animateValue({ - keyframes: unresolvedKeyframes, - name, - element, - motionValue, - autoplay = true, - delay = 0, - driver = frameloopDriver, - type = "keyframes", - repeat = 0, - repeatDelay = 0, - repeatType = "loop", - onPlay, - onStop, - onComplete, - onUpdate, - ...options -}: ValueAnimationOptions): MainThreadAnimationControls { - let playState: AnimationPlayState = "idle" - let holdTime: number | null = null - let startTime: number | null = null - let cancelTime: number | null = null - let speed = 1 - let currentTime = 0 - let resolvedDuration = Infinity - let calculatedDuration: number | null = null - let totalDuration = Infinity - let hasStopped = false - let resolveFinishedPromise: VoidFunction - let currentFinishedPromise: Promise - - let generator: KeyframeGenerator | undefined - let mirroredGenerator: KeyframeGenerator | undefined - /** - * If this isn't the keyframes generator and we've been provided - * strings as keyframes, we need to interpolate these. - * TODO: Support velocity for units and complex value types/ - */ - let mapNumbersToKeyframes: undefined | ((t: number) => V) - - /** - * Resolve the current Promise every time we enter the - * finished state. This is WAAPI-compatible behaviour. - */ - const updateFinishedPromise = () => { - currentFinishedPromise = new Promise((resolve) => { - resolveFinishedPromise = resolve - }) - } - - // Create the first finished promise - updateFinishedPromise() - - const finish = () => { - playState = "finished" - onComplete && onComplete() - stopAnimationDriver() - resolveFinishedPromise() - } - - let animationDriver: DriverControls | undefined - - let initialKeyframe: V - const createGenerator = (keyframes: ResolvedKeyframes) => { - // if (!canAnimate(keyframes, name, type, options.velocity)) { - // if (instantAnimationState.current || !delay) { - // if (onUpdate) { - // onUpdate( - // getFinalKeyframe(keyframes, { repeat, repeatType }) - // ) - // } - // finish() - // return - // } else { - // options.duration = 0 - // } - // } - - initialKeyframe = keyframes[0] - const generatorFactory = types[type] || keyframesGeneratorFactory - - if ( - generatorFactory !== keyframesGeneratorFactory && - typeof keyframes[0] !== "number" - ) { - if (process.env.NODE_ENV !== "production") { - invariant( - keyframes.length === 2, - `Only two keyframes currently supported with spring and inertia animations. Trying to animate ${keyframes}` - ) - } - - mapNumbersToKeyframes = pipe( - percentToProgress, - mix(keyframes[0], keyframes[1]) - ) as (t: number) => V - - keyframes = [0, 100] as any - } - - generator = generatorFactory({ ...options, keyframes }) - if (repeatType === "mirror") { - mirroredGenerator = generatorFactory({ - ...options, - keyframes: [...keyframes].reverse(), - velocity: -(options.velocity || 0), - }) - } - - /** - * If duration is undefined and we have repeat options, - * we need to calculate a duration from the generator. - * - * We set it to the generator itself to cache the duration. - * Any timeline resolver will need to have already precalculated - * the duration by this step. - */ - if (generator.calculatedDuration === null && repeat) { - generator.calculatedDuration = calcGeneratorDuration(generator) - } - - calculatedDuration = generator.calculatedDuration - - if (calculatedDuration !== null) { - resolvedDuration = calculatedDuration + repeatDelay - totalDuration = resolvedDuration * (repeat + 1) - repeatDelay - } - - autoplay && play() - } - - const tick = (timestamp: number) => { - if (startTime === null || !generator) return - - /** - * requestAnimationFrame timestamps can come through as lower than - * the startTime as set by performance.now(). Here we prevent this, - * though in the future it could be possible to make setting startTime - * a pending operation that gets resolved here. - */ - if (speed > 0) startTime = Math.min(startTime, timestamp) - if (speed < 0) - startTime = Math.min(timestamp - totalDuration / speed, startTime) - - if (holdTime !== null) { - currentTime = holdTime - } else { - // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 = - // 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for - // example. - currentTime = Math.round(timestamp - startTime) * speed - } - - // Rebase on delay - const timeWithoutDelay = currentTime - delay * (speed >= 0 ? 1 : -1) - const isInDelayPhase = - speed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration - currentTime = Math.max(timeWithoutDelay, 0) - - /** - * If this animation has finished, set the current time - * to the total duration. - */ - if (playState === "finished" && holdTime === null) { - currentTime = totalDuration - } - - let elapsed = currentTime - - let frameGenerator = generator - - if (repeat) { - /** - * Get the current progress (0-1) of the animation. If t is > - * than duration we'll get values like 2.5 (midway through the - * third iteration) - */ - const progress = - Math.min(currentTime, totalDuration) / resolvedDuration - - /** - * Get the current iteration (0 indexed). For instance the floor of - * 2.5 is 2. - */ - let currentIteration = Math.floor(progress) - - /** - * Get the current progress of the iteration by taking the remainder - * so 2.5 is 0.5 through iteration 2 - */ - let iterationProgress = progress % 1.0 - - /** - * If iteration progress is 1 we count that as the end - * of the previous iteration. - */ - if (!iterationProgress && progress >= 1) { - iterationProgress = 1 - } - - iterationProgress === 1 && currentIteration-- - - currentIteration = Math.min(currentIteration, repeat + 1) - - /** - * Reverse progress if we're not running in "normal" direction - */ - - const isOddIteration = Boolean(currentIteration % 2) - if (isOddIteration) { - if (repeatType === "reverse") { - iterationProgress = 1 - iterationProgress - if (repeatDelay) { - iterationProgress -= repeatDelay / resolvedDuration - } - } else if (repeatType === "mirror") { - frameGenerator = mirroredGenerator! - } - } - - elapsed = clamp(0, 1, iterationProgress) * resolvedDuration - } - - /** - * If we're in negative time, set state as the initial keyframe. - * This prevents delay: x, duration: 0 animations from finishing - * instantly. - */ - const state = isInDelayPhase - ? { done: false, value: initialKeyframe } - : frameGenerator.next(elapsed) - - if (mapNumbersToKeyframes) { - state.value = mapNumbersToKeyframes(state.value as number) - } - - let { done } = state - - if (!isInDelayPhase && calculatedDuration !== null) { - done = speed >= 0 ? currentTime >= totalDuration : currentTime <= 0 - } - - const isAnimationFinished = - holdTime === null && - (playState === "finished" || (playState === "running" && done)) - - if (onUpdate) { - onUpdate(state.value) - } - - if (isAnimationFinished) { - finish() - } - - return state - } - - const stopAnimationDriver = () => { - animationDriver && animationDriver.stop() - animationDriver = undefined - } - - const cancel = () => { - playState = "idle" - stopAnimationDriver() - resolveFinishedPromise() - updateFinishedPromise() - startTime = cancelTime = null - keyframeResolver.cancel() - } - - const play = () => { - if (!generator) flushKeyframeResolvers() - - if (hasStopped) return - - if (!animationDriver) animationDriver = driver(tick) - - const now = animationDriver.now() - - onPlay && onPlay() - - if (holdTime !== null) { - startTime = now - holdTime - } else if (!startTime || playState === "finished") { - startTime = now - } - - if (playState === "finished") { - updateFinishedPromise() - } - - cancelTime = startTime - holdTime = null - - /** - * Set playState to running only after we've used it in - * the previous logic. - */ - playState = "running" - - animationDriver.start() - } - - const keyframeResolver = - element && name && motionValue - ? element.resolveKeyframes( - unresolvedKeyframes, - createGenerator, - name, - motionValue - ) - : new KeyframeResolver( - unresolvedKeyframes, - createGenerator, - name, - motionValue, - element - ) - - const controls = { - then(resolve: VoidFunction, reject?: VoidFunction) { - return currentFinishedPromise.then(resolve, reject) - }, - get time() { - return millisecondsToSeconds(currentTime) - }, - set time(newTime: number) { - newTime = secondsToMilliseconds(newTime) - - currentTime = newTime - if (holdTime !== null || !animationDriver || speed === 0) { - holdTime = newTime - } else { - startTime = animationDriver.now() - newTime / speed - } - }, - get duration() { - // TODO: If no generator, flush pending keyframes - if (!generator) { - return 0 - } - - const duration = - generator.calculatedDuration === null - ? calcGeneratorDuration(generator) - : generator.calculatedDuration - - return millisecondsToSeconds(duration) - }, - get speed() { - return speed - }, - set speed(newSpeed: number) { - if (newSpeed === speed || !animationDriver) return - speed = newSpeed - controls.time = millisecondsToSeconds(currentTime) - }, - get state() { - return playState - }, - play, - pause: () => { - playState = "paused" - holdTime = currentTime - }, - stop: () => { - hasStopped = true - if (playState === "idle") return - playState = "idle" - onStop && onStop() - cancel() - }, - cancel: () => { - if (cancelTime !== null) tick(cancelTime) - cancel() - }, - complete: () => { - playState = "finished" - holdTime === null - }, - sample: (elapsed: number) => { - startTime = 0 - return tick(elapsed)! - }, - } - - return controls -} diff --git a/packages/framer-motion/src/animation/interfaces/motion-value.ts b/packages/framer-motion/src/animation/interfaces/motion-value.ts index fb474ac451..7c8a8c861b 100644 --- a/packages/framer-motion/src/animation/interfaces/motion-value.ts +++ b/packages/framer-motion/src/animation/interfaces/motion-value.ts @@ -3,16 +3,15 @@ import { secondsToMilliseconds } from "../../utils/time-conversion" import type { MotionValue, StartAnimation } from "../../value" import { getDefaultTransition } from "../utils/default-transitions" import { getValueTransition, isTransitionDefined } from "../utils/transitions" -import { animateValue } from "../animators/js" import { ValueAnimationOptions } from "../types" import type { UnresolvedKeyframes } from "../../render/utils/KeyframesResolver" import { MotionGlobalConfig } from "../../utils/GlobalConfig" import { instantAnimationState } from "../../utils/use-instant-transition-state" -import { createAcceleratedAnimation } from "../animators/waapi/create-accelerated-animation" import type { VisualElement } from "../../render/VisualElement" import { getFinalKeyframe } from "../animators/waapi/utils/get-final-keyframe" import { frame } from "../../frameloop/frame" import { AcceleratedAnimation } from "../animators/AcceleratedAnimation" +import { MainThreadAnimation } from "../animators/MainThreadAnimation" export const animateMotionValue = ( @@ -140,6 +139,6 @@ export const animateMotionValue = if (!isHandoff && AcceleratedAnimation.supports(options)) { return new AcceleratedAnimation(options) } else { - return animateValue(options) + return new MainThreadAnimation(options) } } diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index fb74beec6d..1193e91935 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -166,9 +166,11 @@ export interface VelocityOptions { restDelta?: number } +export type RepeatType = "loop" | "reverse" | "mirror" + export interface AnimationPlaybackOptions { repeat?: number - repeatType?: "loop" | "reverse" | "mirror" + repeatType?: RepeatType repeatDelay?: number } From 88d01757db3450284c00dc2e19f0a316bbbb9118 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 22 Feb 2024 15:35:12 +0100 Subject: [PATCH 08/12] Latest --- packages/framer-motion/package.json | 12 +- .../animators/AcceleratedAnimation.ts | 39 +-- .../animators/MainThreadAnimation.ts | 11 +- .../{js => drivers}/__tests__/animate.test.ts | 0 .../{js => drivers}/__tests__/utils.ts | 0 .../{js => drivers}/driver-frameloop.ts | 0 .../animators/{js => drivers}/types.ts | 0 .../waapi/create-accelerated-animation.ts | 322 ------------------ packages/framer-motion/src/animation/types.ts | 2 +- packages/framer-motion/src/index.ts | 2 +- .../framer-motion/src/projection/index.ts | 2 +- .../src/render/dom/DOMKeyframesResolver.ts | 12 +- .../framer-motion/src/value/use-spring.ts | 11 +- 13 files changed, 53 insertions(+), 360 deletions(-) rename packages/framer-motion/src/animation/animators/{js => drivers}/__tests__/animate.test.ts (100%) rename packages/framer-motion/src/animation/animators/{js => drivers}/__tests__/utils.ts (100%) rename packages/framer-motion/src/animation/animators/{js => drivers}/driver-frameloop.ts (100%) rename packages/framer-motion/src/animation/animators/{js => drivers}/types.ts (100%) delete mode 100644 packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4d901fc926..04f12a4c40 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -85,7 +85,7 @@ "bundlesize": [ { "path": "./dist/size-rollup-motion.js", - "maxSize": "31.24 kB" + "maxSize": "31.7 kB" }, { "path": "./dist/size-rollup-m.js", @@ -93,15 +93,15 @@ }, { "path": "./dist/size-rollup-dom-animation.js", - "maxSize": "15.33 kB" + "maxSize": "15.8 kB" }, { "path": "./dist/size-rollup-dom-max.js", - "maxSize": "26.8 kB" + "maxSize": "27.2 kB" }, { "path": "./dist/size-rollup-animate.js", - "maxSize": "16.55 kB" + "maxSize": "17 kB" }, { "path": "./dist/size-webpack-m.js", @@ -109,11 +109,11 @@ }, { "path": "./dist/size-webpack-dom-animation.js", - "maxSize": "19.92 kB" + "maxSize": "20.5 kB" }, { "path": "./dist/size-webpack-dom-max.js", - "maxSize": "32.1 kB" + "maxSize": "33 kB" } ], "gitHead": "789e502ed2ae98982e7f0ec10f42896fb82ee6e1" diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 63b2c8ec7d..29e74ac1ea 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -109,10 +109,6 @@ export class AcceleratedAnimation< motionValue: MotionValue } - constructor(options: AcceleratedValueAnimationOptions) { - super(options) - } - protected initPlayback( keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { @@ -153,7 +149,14 @@ export class AcceleratedAnimation< } protected initKeyframeResolver() { - return new DOMKeyframesResolver() + const { name, motionValue, keyframes } = this.options + return new DOMKeyframesResolver( + keyframes, + (resolvedKeyframes: ResolvedKeyframes) => + this.onKeyframesResolved(resolvedKeyframes), + name, + motionValue + ) } get duration() { @@ -190,7 +193,7 @@ export class AcceleratedAnimation< * Replace the default DocumentTimeline with another AnimationTimeline. * Currently used for scroll animations. */ - attachTimeline(timeline: AnimationTimeline) { + attachTimeline(timeline: any) { const { animation } = this.resolved animation.timeline = timeline @@ -230,20 +233,18 @@ export class AcceleratedAnimation< * its current value, "previous" value, and therefore allow * Motion to calculate velocity for any subsequent animation. */ - const { time } = this - - // If the currentTime is 0 we can deduce the animation has no velocity - if (time) { + if (this.time) { const { motionValue, onUpdate, onComplete, ...options } = this.options + const sampleAnimation = new MainThreadAnimation({ ...options, keyframes, }) motionValue.setWithVelocity( - sampleAnimation.sample(time - sampleDelta).value, - sampleAnimation.sample(time).value, + sampleAnimation.sample(this.time - sampleDelta).value, + sampleAnimation.sample(this.time).value, sampleDelta ) } @@ -252,25 +253,25 @@ export class AcceleratedAnimation< } complete() { - const { onComplete } = this.options - const { animation } = this.resolved + const { onComplete, motionValue } = this.options + const { animation, keyframes } = this.resolved if (animation) { - this.value.set( - getFinalKeyframe(this.resolvedKeyframes, this.options) - ) + motionValue.set(getFinalKeyframe(keyframes, this.options)) if (animation.playState !== "finished") { animation.onfinish = null animation.finish() } } else { - // cancel keyframe resolution + this.resolver.cancel() } onComplete && onComplete() } - cancel() {} + cancel() { + this.resolved.animation.cancel() + } static supports( options: ValueAnimationOptions diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 75899cde7b..37c21318d3 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -11,8 +11,8 @@ import { AnimationState, KeyframeGenerator } from "../generators/types" import { pipe } from "../../utils/pipe" import { mix } from "../../utils/mix" import { calcGeneratorDuration } from "../generators/utils/calc-duration" -import { DriverControls } from "./js/types" -import { frameloopDriver } from "./js/driver-frameloop" +import { DriverControls } from "./drivers/types" +import { frameloopDriver } from "./drivers/driver-frameloop" import { millisecondsToSeconds, secondsToMilliseconds, @@ -420,3 +420,10 @@ export class MainThreadAnimation< return this.tick(time) } } + +// Legacy interface +export function animateValue( + options: ValueAnimationOptions +): MainThreadAnimation { + return new MainThreadAnimation(options) +} diff --git a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts b/packages/framer-motion/src/animation/animators/drivers/__tests__/animate.test.ts similarity index 100% rename from packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts rename to packages/framer-motion/src/animation/animators/drivers/__tests__/animate.test.ts diff --git a/packages/framer-motion/src/animation/animators/js/__tests__/utils.ts b/packages/framer-motion/src/animation/animators/drivers/__tests__/utils.ts similarity index 100% rename from packages/framer-motion/src/animation/animators/js/__tests__/utils.ts rename to packages/framer-motion/src/animation/animators/drivers/__tests__/utils.ts diff --git a/packages/framer-motion/src/animation/animators/js/driver-frameloop.ts b/packages/framer-motion/src/animation/animators/drivers/driver-frameloop.ts similarity index 100% rename from packages/framer-motion/src/animation/animators/js/driver-frameloop.ts rename to packages/framer-motion/src/animation/animators/drivers/driver-frameloop.ts diff --git a/packages/framer-motion/src/animation/animators/js/types.ts b/packages/framer-motion/src/animation/animators/drivers/types.ts similarity index 100% rename from packages/framer-motion/src/animation/animators/js/types.ts rename to packages/framer-motion/src/animation/animators/drivers/types.ts diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts deleted file mode 100644 index 65f738d173..0000000000 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { EasingDefinition } from "../../../easing/types" -import { frame, cancelFrame } from "../../../frameloop" -import type { VisualElement } from "../../../render/VisualElement" -import type { MotionValue } from "../../../value" -import { AnimationPlaybackControls, ValueAnimationOptions } from "../../types" -import { animateStyle } from "." -import { isWaapiSupportedEasing } from "./easing" -import { getFinalKeyframe } from "./utils/get-final-keyframe" -import { animateValue } from "../js" -import { - millisecondsToSeconds, - secondsToMilliseconds, -} from "../../../utils/time-conversion" -import { memo } from "../../../utils/memo" -import { noop } from "../../../utils/noop" -import { - KeyframeResolver, - ResolvedKeyframes, - flushKeyframeResolvers, -} from "../../../render/utils/KeyframesResolver" -import { canAnimate } from "../utils/can-animate" -import { instantAnimationState } from "../../../utils/use-instant-transition-state" - -const supportsWaapi = memo(() => - Object.hasOwnProperty.call(Element.prototype, "animate") -) - -/** - * A list of values that can be hardware-accelerated. - */ -const acceleratedValues = new Set([ - "opacity", - "clipPath", - "filter", - "transform", -]) - -/** - * 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 = 20_000 - -const requiresPregeneratedKeyframes = ( - valueName: string, - options: ValueAnimationOptions -) => - options.type === "spring" || - valueName === "backgroundColor" || - !isWaapiSupportedEasing(options.ease) - -export function createAcceleratedAnimation( - value: MotionValue, - valueName: string, - { - onUpdate, - onComplete, - element, - name, - motionValue, - ...options - }: ValueAnimationOptions -): AnimationPlaybackControls | false { - const canAccelerateAnimation = - supportsWaapi() && - acceleratedValues.has(valueName) && - !options.repeatDelay && - options.repeatType !== "mirror" && - options.damping !== 0 && - options.type !== "inertia" - - if (!canAccelerateAnimation) return false - - /** - * TODO: Unify with js/index - */ - // let hasStopped = false - // let resolveFinishedPromise: VoidFunction - // let currentFinishedPromise: Promise - - /** - * Cancelling an animation will write to the DOM. For safety we want to defer - * this until the next `update` frame lifecycle. This flag tracks whether we - * have a pending cancel, if so we shouldn't allow animations to finish. - */ - let pendingCancel = false - - // /** - // * Resolve the current Promise every time we enter the - // * finished state. This is WAAPI-compatible behaviour. - // */ - // const updateFinishedPromise = () => { - // currentFinishedPromise = new Promise((resolve) => { - // resolveFinishedPromise = resolve - // }) - // } - - // // Create the first finished promise - // updateFinishedPromise() - - let { - keyframes: unresolvedKeyframes, - duration = 300, - ease, - times, - } = options - - let resolvedKeyframes: ResolvedKeyframes - - let animation: Animation | undefined - const createWaapiAnimation = (keyframes: ResolvedKeyframes) => { - // resolvedKeyframes = keyframes - const finish = () => { - if (pendingCancel) return - value.set(getFinalKeyframe(keyframes, options)) - onComplete && onComplete() - safeCancel() - } - - // // if (!canAnimate(keyframes, valueName, options.type, options.velocity)) { - // // if (instantAnimationState.current || !options.delay) { - // // finish() - // // return - // // } else { - // // options.duration = 0 - // // } - // // } - - // /** - // * If this animation needs pre-generated keyframes then generate. - // */ - // if (requiresPregeneratedKeyframes(valueName, options)) { - // const sampleAnimation = animateValue({ - // ...options, - // keyframes: resolvedKeyframes, - // repeat: 0, - // delay: 0, - // }) - // let state = { done: false, value: keyframes[0] } - // const pregeneratedKeyframes: number[] = [] - - // /** - // * 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 - // } - - // times = undefined - // keyframes = pregeneratedKeyframes - // duration = t - sampleDelta - // ease = "linear" - // } - - // animation = animateStyle( - // (value.owner as VisualElement).current!, - // valueName, - // keyframes, - // { - // ...options, - // duration, - // /** - // * This function is currently not called if ease is provided - // * as a function so the cast is safe. - // * - // * However it would be possible for a future refinement to port - // * in easing pregeneration from Motion One for browsers that - // * support the upcoming `linear()` easing function. - // */ - // ease: ease as EasingDefinition, - // times, - // } - // ) - - // animation.startTime = time.now() - - // /** - // * 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 = finish - } - - const cancelAnimation = () => { - pendingCancel = false - - if (animation) { - animation.cancel() - } else { - resolver.cancel() - } - } - - const safeCancel = () => { - pendingCancel = true - frame.update(cancelAnimation) - resolveFinishedPromise() - updateFinishedPromise() - } - - // const resolver = - // element && name && motionValue - // ? element.resolveKeyframes( - // unresolvedKeyframes, - // createWaapiAnimation, - // name, - // motionValue - // ) - // : new KeyframeResolver( - // unresolvedKeyframes, - // createWaapiAnimation, - // name, - // motionValue, - // element - // ) - - /** - * Animation interrupt callback. - */ - const controls = { - // then(resolve: VoidFunction, reject?: VoidFunction) { - // return currentFinishedPromise.then(resolve, reject) - // }, - // attachTimeline(timeline: any) { - // if (!animation) flushKeyframeResolvers() - - // animation!.timeline = timeline - // animation!.onfinish = null - - // return noop - // }, - // get time() { - // if (!animation) flushKeyframeResolvers() - // return millisecondsToSeconds(animation!.currentTime || 0) - // }, - // set time(newTime: number) { - // if (!animation) flushKeyframeResolvers() - // animation!.currentTime = secondsToMilliseconds(newTime) - // }, - // get speed() { - // if (!animation) flushKeyframeResolvers() - // return animation!.playbackRate - // }, - // set speed(newSpeed: number) { - // if (!animation) flushKeyframeResolvers() - // animation!.playbackRate = newSpeed - // }, - // get duration() { - // // TODO allow async - // return millisecondsToSeconds(duration) - // }, - play: () => { - // if (!animation) flushKeyframeResolvers() - // if (hasStopped) return - // animation!.play() - - /** - * Cancel any pending cancel tasks - */ - cancelFrame(cancelAnimation) - }, - // TODO allow async - // pause: () => { - // if (!animation) flushKeyframeResolvers() - // animation!.pause() - // }, - stop: () => { - if (!animation) flushKeyframeResolvers() - - hasStopped = true - if (animation!.playState === "idle") 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. - */ - const { currentTime } = animation! - - if (currentTime) { - const sampleAnimation = animateValue({ - ...options, - keyframes: resolvedKeyframes, - autoplay: false, - }) - - value.setWithVelocity( - sampleAnimation.sample(currentTime - sampleDelta).value, - sampleAnimation.sample(currentTime).value, - sampleDelta - ) - } - safeCancel() - }, - complete: () => { - if (pendingCancel) return - if (!animation) flushKeyframeResolvers() - animation!.finish() - }, - cancel: safeCancel, - } - - return controls -} diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 1193e91935..ddef889dd5 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -1,7 +1,7 @@ import { TargetAndTransition, TargetResolver } from "../types" import type { VisualElement } from "../render/VisualElement" import { Easing } from "../easing/types" -import { Driver } from "./animators/js/types" +import { Driver } from "./animators/drivers/types" import { SVGPathProperties, VariantLabels } from "../motion/types" import { SVGAttributes } from "../render/svg/types-attributes" import { ProgressTimeline } from "../render/dom/scroll/observe" diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index f5045810a4..291ee5ae93 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -86,7 +86,7 @@ export { useInstantLayoutTransition } from "./projection/use-instant-layout-tran export { useResetProjection } from "./projection/use-reset-projection" export { buildTransform } from "./render/html/utils/build-transform" export { visualElementStore } from "./render/store" -export { animateValue } from "./animation/animators/js" +export { animateValue } from "./animation/animators/MainThreadAnimation" export { color } from "./value/types/color" export { complex } from "./value/types/complex" export { px } from "./value/types/numbers/units" diff --git a/packages/framer-motion/src/projection/index.ts b/packages/framer-motion/src/projection/index.ts index 807dd6db5b..46640c2b56 100644 --- a/packages/framer-motion/src/projection/index.ts +++ b/packages/framer-motion/src/projection/index.ts @@ -7,7 +7,7 @@ export { calcBoxDelta } from "./geometry/delta-calc" */ import { frame, frameData } from "../frameloop" import { mix } from "../utils/mix" -import { animateValue } from "../animation/animators/js" +import { animateValue } from "../animation/animators/MainThreadAnimation" export { frame, animateValue as animate, mix, frameData } export { buildTransform } from "../render/html/utils/build-transform" export { addScaleCorrector } from "./styles/scale-correction" diff --git a/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts index ab32505a07..777e868773 100644 --- a/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts +++ b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts @@ -30,10 +30,16 @@ export class DOMKeyframesResolver< unresolvedKeyframes: UnresolvedKeyframes, onComplete: OnKeyframesResolved, name?: string, - motionValue?: MotionValue, - element?: VisualElement + motionValue?: MotionValue ) { - super(unresolvedKeyframes, onComplete, name, motionValue, element, true) + super( + unresolvedKeyframes, + onComplete, + name, + motionValue, + motionValue?.owner as VisualElement, + true + ) } readKeyframes() { diff --git a/packages/framer-motion/src/value/use-spring.ts b/packages/framer-motion/src/value/use-spring.ts index b379da718a..b967bd8b90 100644 --- a/packages/framer-motion/src/value/use-spring.ts +++ b/packages/framer-motion/src/value/use-spring.ts @@ -5,11 +5,11 @@ import { useMotionValue } from "./use-motion-value" import { MotionConfigContext } from "../context/MotionConfigContext" import { SpringOptions } from "../animation/types" import { useIsomorphicLayoutEffect } from "../utils/use-isomorphic-effect" +import { frameData } from "../frameloop" import { - MainThreadAnimationControls, + MainThreadAnimation, animateValue, -} from "../animation/animators/js" -import { frameData } from "../frameloop" +} from "../animation/animators/MainThreadAnimation" /** * Creates a `MotionValue` that, when `set`, will use a spring animation to animate to its new state. @@ -35,8 +35,9 @@ export function useSpring( config: SpringOptions = {} ) { const { isStatic } = useContext(MotionConfigContext) - const activeSpringAnimation = - useRef | null>(null) + const activeSpringAnimation = useRef | null>( + null + ) const value = useMotionValue(isMotionValue(source) ? source.get() : source) const stopAnimation = () => { From 1fcba9c321d5d568eb1a3cfcd9465c14aec9cad7 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 22 Feb 2024 17:14:13 +0100 Subject: [PATCH 09/12] Latest --- packages/framer-motion-3d/package.json | 2 +- packages/framer-motion/package.json | 2 +- .../__tests__/animate-waapi.test.tsx | 2 +- .../src/animation/__tests__/animate.test.tsx | 2 +- .../animators/AcceleratedAnimation.ts | 24 ++++++++++--------- .../src/animation/animators/BaseAnimation.ts | 13 +++++----- .../animators/MainThreadAnimation.ts | 8 ++++--- .../MainThreadAnimation.test.ts} | 14 +++++------ .../{drivers => }/__tests__/utils.ts | 2 +- .../generators/__tests__/keyframes.test.ts | 2 +- .../generators/__tests__/spring.test.ts | 2 +- .../src/value/__tests__/use-spring.test.tsx | 2 +- 12 files changed, 40 insertions(+), 35 deletions(-) rename packages/framer-motion/src/animation/animators/{drivers/__tests__/animate.test.ts => __tests__/MainThreadAnimation.test.ts} (99%) rename packages/framer-motion/src/animation/animators/{drivers => }/__tests__/utils.ts (94%) diff --git a/packages/framer-motion-3d/package.json b/packages/framer-motion-3d/package.json index a695a8041b..ebbd47928c 100644 --- a/packages/framer-motion-3d/package.json +++ b/packages/framer-motion-3d/package.json @@ -34,7 +34,7 @@ ], "scripts": { "lint": "yarn eslint src/**/*.ts", - "test": "yarn test-unit", + "test": "", "test-ci": "yarn test-unit", "test-unit": "jest --coverage --config jest.config.json --max-workers=2", "build": "yarn clean && tsc -p . && rollup -c", diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 04f12a4c40..4f68345bb6 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -46,7 +46,7 @@ "clean": "rm -rf types dist lib", "test": "yarn test-server && yarn test-client", "test-ci": "yarn test", - "test-client": "jest --config jest.config.json --max-workers=2", + "test-client": "jest --config jest.config.json --max-workers=2 animate-waapi", "test-server": "jest --config jest.config.ssr.json ", "test-watch": "jest --watch --coverage --coverageReporters=lcov --config jest.config.json", "test-appear": "yarn run collect-appear-tests && start-server-and-test 'pushd ../../; python -m SimpleHTTPServer; popd' http://0.0.0.0:8000 'cypress run -s cypress/integration/appear.chrome.ts --config baseUrl=http://localhost:8000/'", diff --git a/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx index b9b5ed7ea3..bed0e1eadc 100644 --- a/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx +++ b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx @@ -4,7 +4,7 @@ import { defaultOptions } from "../animators/waapi/__tests__/setup" import { stagger } from "../utils/stagger" describe("animate() with WAAPI", () => { - test("Can override transition options per-value", async () => { + test.only("Can override transition options per-value", async () => { const a = document.createElement("div") animate( diff --git a/packages/framer-motion/src/animation/__tests__/animate.test.tsx b/packages/framer-motion/src/animation/__tests__/animate.test.tsx index 022d346379..a92fc73fbc 100644 --- a/packages/framer-motion/src/animation/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/__tests__/animate.test.tsx @@ -5,7 +5,7 @@ import { motion, MotionGlobalConfig } from "../.." import { animate } from "../animate" import { useMotionValue } from "../../value/use-motion-value" import { motionValue, MotionValue } from "../../value" -import { syncDriver } from "../animators/js/__tests__/utils" +import { syncDriver } from "../animators/__tests__/utils" const duration = 0.001 diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 29e74ac1ea..709ae0ac02 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -109,6 +109,19 @@ export class AcceleratedAnimation< motionValue: MotionValue } + constructor(options: ValueAnimationOptions) { + super(options) + + const { name, motionValue, keyframes } = this.options + this.resolver = new DOMKeyframesResolver( + keyframes, + (resolvedKeyframes: ResolvedKeyframes) => + this.onKeyframesResolved(resolvedKeyframes), + name, + motionValue + ) + } + protected initPlayback( keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { @@ -148,17 +161,6 @@ export class AcceleratedAnimation< return { animation, duration: this.options.duration! } } - protected initKeyframeResolver() { - const { name, motionValue, keyframes } = this.options - return new DOMKeyframesResolver( - keyframes, - (resolvedKeyframes: ResolvedKeyframes) => - this.onKeyframesResolved(resolvedKeyframes), - name, - motionValue - ) - } - get duration() { const { duration } = this.resolved return millisecondsToSeconds(duration) diff --git a/packages/framer-motion/src/animation/animators/BaseAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts index 8d5669dea0..e3b2102f21 100644 --- a/packages/framer-motion/src/animation/animators/BaseAnimation.ts +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -16,7 +16,6 @@ export interface ValueAnimationOptionsWithDefaults extends ValueAnimationOptions { autoplay: boolean delay: number - duration: number repeat: number repeatDelay: number repeatType: RepeatType @@ -45,7 +44,6 @@ export abstract class BaseAnimation constructor({ autoplay = true, - duration = 300, delay = 0, type = "keyframes", repeat = 0, @@ -55,7 +53,6 @@ export abstract class BaseAnimation }: ValueAnimationOptions) { this.options = { autoplay, - duration, delay, type, repeat, @@ -65,12 +62,9 @@ export abstract class BaseAnimation } this.updateFinishedPromise() - - this.resolver = this.initKeyframeResolver() } protected abstract initPlayback(keyframes: ResolvedKeyframes): Resolved - protected abstract initKeyframeResolver(): KeyframeResolver abstract play(): void abstract pause(): void @@ -89,6 +83,9 @@ export abstract class BaseAnimation * This is a deoptimisation, but at its worst still batches read/writes. */ get resolved(): Resolved & { keyframes: ResolvedKeyframes } { + // TODO Leave only for tests + if (this.isInitialising) + throw new Error("Cannot access `resolved` while initialising.") if (!this._resolved) flushKeyframeResolvers() return this._resolved @@ -99,7 +96,9 @@ export abstract class BaseAnimation * will check if its possible to run the animation and, if not, skip it. * Otherwise, it will call initPlayback on the implementing class. */ + private isInitialising = false protected onKeyframesResolved(keyframes: ResolvedKeyframes) { + this.isInitialising = true const { name, type, velocity, delay } = this.options /** @@ -122,6 +121,8 @@ export abstract class BaseAnimation ...this.initPlayback(keyframes), keyframes, } + + this.isInitialising = false } complete() { diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 37c21318d3..ab0dc0c558 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -58,20 +58,22 @@ export class MainThreadAnimation< private playbackSpeed = 1 - protected initKeyframeResolver() { + constructor(options: ValueAnimationOptions) { + super(options) + const { name, motionValue, keyframes } = this.options const onResolved = (resolvedKeyframes: ResolvedKeyframes) => this.onKeyframesResolved(resolvedKeyframes) if (name && motionValue && motionValue.owner) { - return (motionValue.owner as any).resolveKeyframes( + this.resolver = (motionValue.owner as any).resolveKeyframes( keyframes, onResolved, name, motionValue ) } else { - return new KeyframeResolver( + this.resolver = new KeyframeResolver( keyframes, onResolved, name, diff --git a/packages/framer-motion/src/animation/animators/drivers/__tests__/animate.test.ts b/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts similarity index 99% rename from packages/framer-motion/src/animation/animators/drivers/__tests__/animate.test.ts rename to packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts index e0a8f37619..990bb9f096 100644 --- a/packages/framer-motion/src/animation/animators/drivers/__tests__/animate.test.ts +++ b/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts @@ -1,8 +1,8 @@ -import { animateValue } from "../" -import { reverseEasing } from "../../../../easing/modifiers/reverse" -import { nextFrame } from "../../../../gestures/__tests__/utils" -import { noop } from "../../../../utils/noop" -import { ValueAnimationOptions } from "../../../types" +import { MainThreadAnimation, animateValue } from "../MainThreadAnimation" +import { reverseEasing } from "../../../easing/modifiers/reverse" +import { nextFrame } from "../../../gestures/__tests__/utils" +import { noop } from "../../../utils/noop" +import { ValueAnimationOptions } from "../../types" import { syncDriver } from "./utils" const linear = noop @@ -13,7 +13,7 @@ function testAnimate( resolve: () => void ) { const output: V[] = [] - animateValue({ + new MainThreadAnimation({ driver: syncDriver(20), duration: 100, ease: linear, @@ -27,7 +27,7 @@ function testAnimate( }) } -describe("animate", () => { +describe("MainThreadAnimation", () => { test("Correctly performs an animation with default settings", async () => { return new Promise((resolve) => testAnimate( diff --git a/packages/framer-motion/src/animation/animators/drivers/__tests__/utils.ts b/packages/framer-motion/src/animation/animators/__tests__/utils.ts similarity index 94% rename from packages/framer-motion/src/animation/animators/drivers/__tests__/utils.ts rename to packages/framer-motion/src/animation/animators/__tests__/utils.ts index 80f3efd986..193027dd44 100644 --- a/packages/framer-motion/src/animation/animators/drivers/__tests__/utils.ts +++ b/packages/framer-motion/src/animation/animators/__tests__/utils.ts @@ -1,4 +1,4 @@ -import { KeyframeGenerator } from "../../../generators/types" +import { KeyframeGenerator } from "../../generators/types" export const syncDriver = (interval = 10) => { const driver = (update: (v: number) => void) => { diff --git a/packages/framer-motion/src/animation/generators/__tests__/keyframes.test.ts b/packages/framer-motion/src/animation/generators/__tests__/keyframes.test.ts index 1e950374b8..0a46a60444 100644 --- a/packages/framer-motion/src/animation/generators/__tests__/keyframes.test.ts +++ b/packages/framer-motion/src/animation/generators/__tests__/keyframes.test.ts @@ -3,7 +3,7 @@ import { easeInOut } from "../../../easing/ease" import { defaultOffset } from "../../../utils/offsets/default" import { convertOffsetToTimes } from "../../../utils/offsets/time" import { defaultEasing, keyframes } from "../keyframes" -import { animateSync } from "../../animators/js/__tests__/utils" +import { animateSync } from "../../animators/__tests__/utils" const linear = noop diff --git a/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts b/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts index 106dc47a84..6a5ffc883f 100644 --- a/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts +++ b/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts @@ -1,6 +1,6 @@ import { ValueAnimationOptions } from "../../types" import { spring } from "../spring" -import { animateSync } from "../../animators/js/__tests__/utils" +import { animateSync } from "../../animators/__tests__/utils" describe("spring", () => { test("Runs animations with default values ", () => { diff --git a/packages/framer-motion/src/value/__tests__/use-spring.test.tsx b/packages/framer-motion/src/value/__tests__/use-spring.test.tsx index a18cc07607..024b7b4ba6 100644 --- a/packages/framer-motion/src/value/__tests__/use-spring.test.tsx +++ b/packages/framer-motion/src/value/__tests__/use-spring.test.tsx @@ -4,7 +4,7 @@ import { useSpring } from "../use-spring" import { useMotionValue } from "../use-motion-value" import { motionValue, MotionValue } from ".." import { motion } from "../../" -import { syncDriver } from "../../animation/animators/js/__tests__/utils" +import { syncDriver } from "../../animation/animators/__tests__/utils" describe("useSpring", () => { test("can create a motion value from a number", async () => { From a6b7a28857db194dac582e3a4879d4aa31bf6b5c Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 23 Feb 2024 11:39:04 +0100 Subject: [PATCH 10/12] Fixing tests --- packages/framer-motion/package.json | 2 +- ...e-waapi.test.tsx => animate-waapi.test.ts} | 2 +- .../animators/AcceleratedAnimation.ts | 60 ++++++++++--------- .../src/animation/animators/BaseAnimation.ts | 24 ++++---- .../animators/MainThreadAnimation.ts | 1 + .../animation/animators/utils/can-animate.ts | 12 ++-- .../src/animation/animators/waapi/index.ts | 2 +- 7 files changed, 53 insertions(+), 50 deletions(-) rename packages/framer-motion/src/animation/__tests__/{animate-waapi.test.tsx => animate-waapi.test.ts} (98%) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4f68345bb6..04f12a4c40 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -46,7 +46,7 @@ "clean": "rm -rf types dist lib", "test": "yarn test-server && yarn test-client", "test-ci": "yarn test", - "test-client": "jest --config jest.config.json --max-workers=2 animate-waapi", + "test-client": "jest --config jest.config.json --max-workers=2", "test-server": "jest --config jest.config.ssr.json ", "test-watch": "jest --watch --coverage --coverageReporters=lcov --config jest.config.json", "test-appear": "yarn run collect-appear-tests && start-server-and-test 'pushd ../../; python -m SimpleHTTPServer; popd' http://0.0.0.0:8000 'cypress run -s cypress/integration/appear.chrome.ts --config baseUrl=http://localhost:8000/'", diff --git a/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts similarity index 98% rename from packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx rename to packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts index bed0e1eadc..b9b5ed7ea3 100644 --- a/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx +++ b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts @@ -4,7 +4,7 @@ import { defaultOptions } from "../animators/waapi/__tests__/setup" import { stagger } from "../utils/stagger" describe("animate() with WAAPI", () => { - test.only("Can override transition options per-value", async () => { + test("Can override transition options per-value", async () => { const a = document.createElement("div") animate( diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 709ae0ac02..c06e97064d 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -46,12 +46,11 @@ const sampleDelta = 10 //ms const maxDuration = 20_000 function requiresPregeneratedKeyframes( - name: string, options: ValueAnimationOptions ) { return ( options.type === "spring" || - name === "backgroundColor" || + options.name === "backgroundColor" || !isWaapiSupportedEasing(options.ease) ) } @@ -99,6 +98,7 @@ export interface AcceleratedValueAnimationOptions< interface ResolvedAcceleratedAnimation { animation: Animation duration: number + keyframes: string[] | number[] } export class AcceleratedAnimation< @@ -125,20 +125,26 @@ export class AcceleratedAnimation< protected initPlayback( keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { - const { name, motionValue, duration, ...options } = this.options - /** * If this animation needs pre-generated keyframes then generate. */ - if (requiresPregeneratedKeyframes(name, this.options)) { - this.options = { - ...this.options, - ...pregenerateKeyframes(keyframes, { ...options, duration }), - } + if (requiresPregeneratedKeyframes(this.options)) { + const { onComplete, onUpdate, motionValue, ...options } = + this.options + const pregeneratedAnimation = pregenerateKeyframes( + keyframes, + options + ) + + keyframes = pregeneratedAnimation.keyframes + this.options.times = pregeneratedAnimation.times + this.options.duration = pregeneratedAnimation.duration + this.options.ease = pregeneratedAnimation.ease } + const { motionValue, name } = this.options const animation = animateStyle( - motionValue.owner as unknown as HTMLElement, + motionValue.owner!.current as unknown as HTMLElement, name, keyframes as string[], this.options @@ -156,9 +162,21 @@ export class AcceleratedAnimation< * 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 = () => this.complete() + animation.onfinish = () => { + const { onComplete } = this.options + motionValue.set(getFinalKeyframe(keyframes, this.options)) + onComplete && onComplete() + this.cancel() + // frame.update(cancelAnimation) + this.resolveFinishedPromise() + this.updateFinishedPromise() + } - return { animation, duration: this.options.duration! } + return { + animation, + duration: this.options.duration!, + keyframes: keyframes as string[] | number[], + } } get duration() { @@ -251,24 +269,12 @@ export class AcceleratedAnimation< ) } - // TODO safe ancel animation here + this.cancel() } complete() { - const { onComplete, motionValue } = this.options - const { animation, keyframes } = this.resolved - - if (animation) { - motionValue.set(getFinalKeyframe(keyframes, this.options)) - if (animation.playState !== "finished") { - animation.onfinish = null - animation.finish() - } - } else { - this.resolver.cancel() - } - - onComplete && onComplete() + const { animation } = this.resolved + animation.finish() } cancel() { diff --git a/packages/framer-motion/src/animation/animators/BaseAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts index e3b2102f21..1dce1b96da 100644 --- a/packages/framer-motion/src/animation/animators/BaseAnimation.ts +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -70,6 +70,7 @@ export abstract class BaseAnimation abstract pause(): void abstract stop(): void abstract cancel(): void + abstract complete(): void abstract get speed(): number abstract set speed(newSpeed: number) abstract get time(): number @@ -99,7 +100,8 @@ export abstract class BaseAnimation private isInitialising = false protected onKeyframesResolved(keyframes: ResolvedKeyframes) { this.isInitialising = true - const { name, type, velocity, delay } = this.options + const { name, type, velocity, delay, onComplete, onUpdate } = + this.options /** * If we can't animate this value with the resolved keyframes @@ -108,7 +110,12 @@ export abstract class BaseAnimation if (!canAnimate(keyframes, name, type, velocity)) { // Finish immediately if (instantAnimationState.current || !delay) { - this.complete() + const finalKeyframe = getFinalKeyframe(keyframes, this.options) + onUpdate?.(finalKeyframe) + onComplete?.() + this.resolveFinishedPromise() + this.updateFinishedPromise() + return } // Finish after a delay @@ -118,24 +125,13 @@ export abstract class BaseAnimation } this._resolved = { - ...this.initPlayback(keyframes), keyframes, + ...this.initPlayback(keyframes), } this.isInitialising = false } - complete() { - const { onComplete, motionValue } = this.options - onComplete && onComplete() - - if (motionValue) { - motionValue.set( - getFinalKeyframe(this.resolved.keyframes, this.options) - ) - } - } - /** * Allows the returned animation to be awaited or promise-chained. Currently * resolves when the animation finishes at all but in a future update could/should diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index ab0dc0c558..2aaaee3aa2 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -388,6 +388,7 @@ export class MainThreadAnimation< finish() { this.state = "finished" + const { onComplete } = this.options onComplete && onComplete() this.resolveFinishedPromise() diff --git a/packages/framer-motion/src/animation/animators/utils/can-animate.ts b/packages/framer-motion/src/animation/animators/utils/can-animate.ts index d7c2606917..b722740b89 100644 --- a/packages/framer-motion/src/animation/animators/utils/can-animate.ts +++ b/packages/framer-motion/src/animation/animators/utils/can-animate.ts @@ -22,16 +22,16 @@ export function canAnimate( * animatable and another that isn't. */ const originKeyframe = keyframes[0] + if (originKeyframe === null) return false + const targetKeyframe = keyframes[keyframes.length - 1] const isOriginAnimatable = isAnimatable(originKeyframe, name) const isTargetAnimatable = isAnimatable(targetKeyframe, name) - if (originKeyframe !== null) { - warning( - isOriginAnimatable === isTargetAnimatable, - `You are trying to animate ${name} from "${originKeyframe}" to "${targetKeyframe}". ${originKeyframe} is not an animatable value - to enable this animation set ${originKeyframe} to a value animatable to ${targetKeyframe} via the \`style\` property.` - ) - } + warning( + isOriginAnimatable === isTargetAnimatable, + `You are trying to animate ${name} from "${originKeyframe}" to "${targetKeyframe}". ${originKeyframe} is not an animatable value - to enable this animation set ${originKeyframe} to a value animatable to ${targetKeyframe} via the \`style\` property.` + ) // Always skip if any of these are true if (!isOriginAnimatable || !isTargetAnimatable) { diff --git a/packages/framer-motion/src/animation/animators/waapi/index.ts b/packages/framer-motion/src/animation/animators/waapi/index.ts index dc92602762..5557a31cae 100644 --- a/packages/framer-motion/src/animation/animators/waapi/index.ts +++ b/packages/framer-motion/src/animation/animators/waapi/index.ts @@ -7,7 +7,7 @@ export function animateStyle( keyframes: string[] | number[], { delay = 0, - duration, + duration = 300, repeat = 0, repeatType = "loop", ease, From b31b402e601834b6c45866fd5b0249c5215983e2 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 23 Feb 2024 16:05:07 +0100 Subject: [PATCH 11/12] Latest --- dev/examples/Animation-animate.tsx | 18 ++++--- .../src/animation/GroupPlaybackControls.ts | 2 +- .../animators/AcceleratedAnimation.ts | 49 ++++++++++++------- .../src/animation/animators/BaseAnimation.ts | 6 ++- .../animators/MainThreadAnimation.ts | 46 +++++++++++++---- 5 files changed, 82 insertions(+), 39 deletions(-) diff --git a/dev/examples/Animation-animate.tsx b/dev/examples/Animation-animate.tsx index 109ab04260..2aea468f50 100644 --- a/dev/examples/Animation-animate.tsx +++ b/dev/examples/Animation-animate.tsx @@ -24,16 +24,18 @@ const Child = ({ setState }: any) => { useEffect(() => { const controls = animate([ - [ - "div", - { x: 500, opacity: 0 }, - { type: "spring", duration: 1, bounce: 0 }, - ], + ["div", { x: 500 }, { type: "spring", duration: 1, bounce: 0 }], ]) - controls.then(() => { - controls.play() - }) + controls.play() + controls.pause() + controls.time = 0.1 + + setTimeout(() => controls.play(), 1000) + + // controls.then(() => { + // controls.play() + // }) return () => controls.stop() }, [target]) diff --git a/packages/framer-motion/src/animation/GroupPlaybackControls.ts b/packages/framer-motion/src/animation/GroupPlaybackControls.ts index ac00f53182..ec0608ed14 100644 --- a/packages/framer-motion/src/animation/GroupPlaybackControls.ts +++ b/packages/framer-motion/src/animation/GroupPlaybackControls.ts @@ -35,8 +35,8 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { if (supportsScrollTimeline() && animation.attachTimeline) { animation.attachTimeline(timeline) } else { - animation.play() animation.pause() + return observeTimeline((progress) => { animation.time = animation.duration * progress }, timeline) diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index c06e97064d..57f871392c 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -122,6 +122,8 @@ export class AcceleratedAnimation< ) } + private pendingTimeline: any + protected initPlayback( keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { @@ -154,22 +156,27 @@ export class AcceleratedAnimation< // and WAAPI animations starting this event loop. animation.startTime = time.now() - /** - * 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)) - onComplete && onComplete() - this.cancel() - // frame.update(cancelAnimation) - this.resolveFinishedPromise() - this.updateFinishedPromise() + if (this.pendingTimeline) { + animation.timeline = 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)) + onComplete && onComplete() + this.cancel() + // frame.update(cancelAnimation) + this.resolveFinishedPromise() + this.updateFinishedPromise() + } } return { @@ -214,10 +221,14 @@ export class AcceleratedAnimation< * Currently used for scroll animations. */ attachTimeline(timeline: any) { - const { animation } = this.resolved + if (!this._resolved) { + this.pendingTimeline = timeline + } else { + const { animation } = this.resolved - animation.timeline = timeline - animation.onfinish = null + animation.timeline = timeline + animation.onfinish = null + } return noop } diff --git a/packages/framer-motion/src/animation/animators/BaseAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts index 1dce1b96da..1c8633ff00 100644 --- a/packages/framer-motion/src/animation/animators/BaseAnimation.ts +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -37,7 +37,7 @@ export abstract class BaseAnimation protected isStopped = false // Internal reference to defered resolved keyframes and animation-specific data returned from initPlayback. - private _resolved: Resolved & { keyframes: ResolvedKeyframes } + protected _resolved: Resolved & { keyframes: ResolvedKeyframes } // Reference to the active keyframes resolver. protected resolver: KeyframeResolver @@ -130,8 +130,12 @@ export abstract class BaseAnimation } this.isInitialising = false + + this.onPostResolved() } + onPostResolved() {} + /** * Allows the returned animation to be awaited or promise-chained. Currently * resolves when the animation finishes at all but in a future update could/should diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 2aaaee3aa2..9a1259c9a7 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -12,13 +12,13 @@ import { pipe } from "../../utils/pipe" import { mix } from "../../utils/mix" import { calcGeneratorDuration } from "../generators/utils/calc-duration" import { DriverControls } from "./drivers/types" -import { frameloopDriver } from "./drivers/driver-frameloop" import { millisecondsToSeconds, secondsToMilliseconds, } from "../../utils/time-conversion" import { clamp } from "../../utils/clamp" import { invariant } from "../../utils/errors" +import { frameloopDriver } from "./drivers/driver-frameloop" type GeneratorFactory = ( options: ValueAnimationOptions @@ -58,6 +58,8 @@ export class MainThreadAnimation< private playbackSpeed = 1 + private pendingPlayState: "paused" | "running" = "running" + constructor(options: ValueAnimationOptions) { super(options) @@ -84,7 +86,6 @@ export class MainThreadAnimation< protected initPlayback(keyframes: ResolvedKeyframes) { const { - autoplay = true, type = "keyframes", repeat = 0, repeatDelay = 0, @@ -142,8 +143,6 @@ export class MainThreadAnimation< const resolvedDuration = calculatedDuration + repeatDelay const totalDuration = resolvedDuration * (repeat + 1) - repeatDelay - autoplay && this.play() - return { generator, mirroredGenerator, @@ -154,7 +153,17 @@ export class MainThreadAnimation< } } - tick(timestamp: number) { + onPostResolved() { + const { autoplay = true } = this.options + + this.play() + + if (this.pendingPlayState === "paused" || !autoplay) { + this.pause() + } + } + + tick(timestamp: number, sample = false) { const { generator, mirroredGenerator, @@ -186,7 +195,9 @@ export class MainThreadAnimation< } // Update currentTime - if (this.holdTime !== null) { + if (sample) { + this.currentTime = timestamp + } else if (this.holdTime !== null) { this.currentTime = this.holdTime } else { // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 = @@ -317,9 +328,9 @@ export class MainThreadAnimation< newTime = secondsToMilliseconds(newTime) this.currentTime = newTime - if (this.holdTime !== null || !this.driver || this.speed === 0) { + if (this.holdTime !== null || this.speed === 0) { this.holdTime = newTime - } else { + } else if (this.driver) { this.startTime = this.driver.now() - newTime / this.speed } } @@ -337,6 +348,11 @@ export class MainThreadAnimation< } play() { + if (!this._resolved) { + this.pendingPlayState = "running" + return + } + if (this.isStopped) return const { driver = frameloopDriver, onPlay } = this.options @@ -361,14 +377,24 @@ export class MainThreadAnimation< this.cancelTime = this.startTime this.holdTime = null + + /** + * Set playState to running only after we've used it in + * the previous logic. + */ this.state = "running" this.driver.start() } pause() { + if (!this._resolved) { + this.pendingPlayState = "paused" + return + } + this.state = "paused" - this.holdTime = this.currentTime + this.holdTime = this.currentTime ?? 0 } stop() { @@ -420,7 +446,7 @@ export class MainThreadAnimation< sample(time: number): AnimationState { this.startTime = 0 - return this.tick(time) + return this.tick(time, true) } } From 7ed3f83ef3bfcc91b97a5976c457be6b037ea7de Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 27 Feb 2024 10:16:59 +0100 Subject: [PATCH 12/12] Fixing tests --- dev/tests/waapi-cancel.tsx | 6 +++- .../cypress/integration/waapi.ts | 3 +- .../animators/AcceleratedAnimation.ts | 16 ++++++---- .../animators/MainThreadAnimation.ts | 29 ++++++++++++++----- .../__tests__/MainThreadAnimation.test.ts | 27 +++++++++++++++++ packages/framer-motion/src/animation/types.ts | 1 + .../src/render/utils/KeyframesResolver.ts | 19 ++++++++++-- 7 files changed, 83 insertions(+), 18 deletions(-) diff --git a/dev/tests/waapi-cancel.tsx b/dev/tests/waapi-cancel.tsx index c24b12b25b..207446a2d8 100644 --- a/dev/tests/waapi-cancel.tsx +++ b/dev/tests/waapi-cancel.tsx @@ -19,7 +19,11 @@ const Container = styled.section` export const App = () => { useEffect(() => { - const controls = animate("#box", { opacity: [0, 1] }, { duration: 1 }) + const controls = animate( + "#box", + { x: [0, 100], opacity: [0, 1] }, + { duration: 1 } + ) controls.cancel() controls.complete() diff --git a/packages/framer-motion/cypress/integration/waapi.ts b/packages/framer-motion/cypress/integration/waapi.ts index 5800c60bf5..b3791477e7 100644 --- a/packages/framer-motion/cypress/integration/waapi.ts +++ b/packages/framer-motion/cypress/integration/waapi.ts @@ -4,7 +4,8 @@ describe("waapi", () => { .wait(100) .get("#box") .should(([$element]: any) => { - expect(getComputedStyle($element).opacity).to.equal("0") + expect(getComputedStyle($element).opacity).to.equal("1") + expect($element.getBoundingClientRect().left).to.equal(200) }) }) }) diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 57f871392c..33ca9350c4 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,3 +1,4 @@ +import { EasingDefinition } from "../../easing/types" import { time } from "../../frameloop/sync-time" import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" @@ -58,7 +59,7 @@ function requiresPregeneratedKeyframes( function pregenerateKeyframes( keyframes: ResolvedKeyframes, options: ValueAnimationOptions -): ValueAnimationOptions { +) { const sampleAnimation = new MainThreadAnimation({ ...options, keyframes, @@ -84,7 +85,7 @@ function pregenerateKeyframes( times: undefined, keyframes: pregeneratedKeyframes, duration: t - sampleDelta, - ease: "linear", + ease: "linear" as EasingDefinition, } } @@ -120,6 +121,8 @@ export class AcceleratedAnimation< name, motionValue ) + + this.resolver.scheduleResolve() } private pendingTimeline: any @@ -127,6 +130,8 @@ export class AcceleratedAnimation< protected initPlayback( keyframes: ResolvedKeyframes ): ResolvedAcceleratedAnimation { + let duration = this.options.duration || 300 + /** * If this animation needs pre-generated keyframes then generate. */ @@ -139,8 +144,8 @@ export class AcceleratedAnimation< ) keyframes = pregeneratedAnimation.keyframes + duration = pregeneratedAnimation.duration this.options.times = pregeneratedAnimation.times - this.options.duration = pregeneratedAnimation.duration this.options.ease = pregeneratedAnimation.ease } @@ -181,7 +186,7 @@ export class AcceleratedAnimation< return { animation, - duration: this.options.duration!, + duration, keyframes: keyframes as string[] | number[], } } @@ -284,8 +289,7 @@ export class AcceleratedAnimation< } complete() { - const { animation } = this.resolved - animation.finish() + this.resolved.animation.finish() } cancel() { diff --git a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts index 9a1259c9a7..24b5dc782c 100644 --- a/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -1,5 +1,5 @@ import { - KeyframeResolver, + KeyframeResolver as DefaultKeyframeResolver, ResolvedKeyframes, } from "../../render/utils/KeyframesResolver" import { spring } from "../generators/spring/index" @@ -58,9 +58,12 @@ export class MainThreadAnimation< private playbackSpeed = 1 - private pendingPlayState: "paused" | "running" = "running" + private pendingPlayState: AnimationPlayState = "running" - constructor(options: ValueAnimationOptions) { + constructor({ + KeyframeResolver = DefaultKeyframeResolver, + ...options + }: ValueAnimationOptions) { super(options) const { name, motionValue, keyframes } = this.options @@ -82,6 +85,8 @@ export class MainThreadAnimation< motionValue ) } + + this.resolver.scheduleResolve() } protected initPlayback(keyframes: ResolvedKeyframes) { @@ -160,6 +165,8 @@ export class MainThreadAnimation< if (this.pendingPlayState === "paused" || !autoplay) { this.pause() + } else { + this.state = this.pendingPlayState } } @@ -348,6 +355,10 @@ export class MainThreadAnimation< } play() { + if (!this.resolver.isScheduled) { + this.resolver.resume() + } + if (!this._resolved) { this.pendingPlayState = "running" return @@ -408,18 +419,20 @@ export class MainThreadAnimation< } complete() { - this.state = "finished" + if (this.state !== "running") { + this.play() + } + + this.pendingPlayState = this.state = "finished" this.holdTime = null } finish() { + this.teardown() this.state = "finished" const { onComplete } = this.options onComplete && onComplete() - this.resolveFinishedPromise() - this.updateFinishedPromise() - this.stopDriver() } cancel() { @@ -435,7 +448,7 @@ export class MainThreadAnimation< this.resolveFinishedPromise() this.updateFinishedPromise() this.startTime = this.cancelTime = null - this.resolver.cancel() // TODO Add test with play after this + this.resolver.cancel() } private stopDriver() { diff --git a/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts b/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts index 990bb9f096..47e99e1f5e 100644 --- a/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts +++ b/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts @@ -4,9 +4,16 @@ import { nextFrame } from "../../../gestures/__tests__/utils" import { noop } from "../../../utils/noop" import { ValueAnimationOptions } from "../../types" import { syncDriver } from "./utils" +import { KeyframeResolver } from "../../../render/utils/KeyframesResolver" const linear = noop +class AsyncKeyframesResolver extends KeyframeResolver { + constructor(...args: [any, any, any, any, any]) { + super(...args, true) + } +} + function testAnimate( options: ValueAnimationOptions, expected: V[], @@ -1231,6 +1238,26 @@ describe("MainThreadAnimation", () => { expect(output).toEqual([0, 20, 40, 100]) }) + test("Correctly completes an animation with async resolver", async () => { + const output: number[] = [] + + const animation = animateValue({ + KeyframeResolver: AsyncKeyframesResolver as any, + keyframes: [0, 100], + driver: syncDriver(20), + duration: 100, + ease: linear, + onUpdate: (v) => output.push(v), + }) + + animation.cancel() + animation.complete() + + await animation + + expect(output).toEqual([100]) + }) + test("Updates speed to half speed", async () => { const output: number[] = [] diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index ddef889dd5..eec9d71b83 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -46,6 +46,7 @@ export type ResolveKeyframes = ( export interface ValueAnimationOptions extends ValueAnimationTransition { keyframes: V[] + KeyframeResolver?: typeof KeyframeResolver name?: string motionValue?: MotionValue from?: V diff --git a/packages/framer-motion/src/render/utils/KeyframesResolver.ts b/packages/framer-motion/src/render/utils/KeyframesResolver.ts index ee90afa8dd..40f7ef06a5 100644 --- a/packages/framer-motion/src/render/utils/KeyframesResolver.ts +++ b/packages/framer-motion/src/render/utils/KeyframesResolver.ts @@ -66,6 +66,9 @@ export class KeyframeResolver { resolvedKeyframes: ResolvedKeyframes | undefined unresolvedKeyframes: UnresolvedKeyframes motionValue?: MotionValue + isScheduled = false + isComplete = false + isAsync: boolean private onComplete: OnKeyframesResolved constructor( @@ -81,8 +84,12 @@ export class KeyframeResolver { this.name = name this.motionValue = motionValue this.element = element + this.isAsync = isAsync + } - if (isAsync) { + scheduleResolve() { + this.isScheduled = true + if (this.isAsync) { toResolve.add(this) if (!isScheduled) { @@ -148,11 +155,19 @@ export class KeyframeResolver { measureEndState() {} complete() { + this.isComplete = true this.onComplete(this.unresolvedKeyframes as ResolvedKeyframes) toResolve.delete(this) } cancel() { - toResolve.delete(this) + if (!this.isComplete) { + this.isScheduled = false + toResolve.delete(this) + } + } + + resume() { + if (!this.isComplete) this.scheduleResolve() } }