diff --git a/dev/benchmarks/cold-start-framer-motion.html b/dev/benchmarks/cold-start-framer-motion.html index 05c4f230c4..b7b4557853 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/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/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-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-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/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/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/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/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/__tests__/animate-waapi.test.tsx b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts similarity index 100% rename from packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx rename to packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts 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 new file mode 100644 index 0000000000..33ca9350c4 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -0,0 +1,323 @@ +import { EasingDefinition } from "../../easing/types" +import { time } from "../../frameloop/sync-time" +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 { + BaseAnimation, + ValueAnimationOptionsWithDefaults, +} from "./BaseAnimation" +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 + +function requiresPregeneratedKeyframes( + options: ValueAnimationOptions +) { + return ( + options.type === "spring" || + options.name === "backgroundColor" || + !isWaapiSupportedEasing(options.ease) + ) +} + +function pregenerateKeyframes( + keyframes: ResolvedKeyframes, + options: 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" as EasingDefinition, + } +} + +export interface AcceleratedValueAnimationOptions< + T extends string | number = number +> extends ValueAnimationOptions { + name: string + motionValue: MotionValue +} + +interface ResolvedAcceleratedAnimation { + animation: Animation + duration: number + keyframes: string[] | number[] +} + +export class AcceleratedAnimation< + T extends string | number +> extends BaseAnimation { + protected options: ValueAnimationOptionsWithDefaults & { + name: string + 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 + ) + + this.resolver.scheduleResolve() + } + + private pendingTimeline: any + + protected initPlayback( + keyframes: ResolvedKeyframes + ): ResolvedAcceleratedAnimation { + let duration = this.options.duration || 300 + + /** + * If this animation needs pre-generated keyframes then generate. + */ + if (requiresPregeneratedKeyframes(this.options)) { + const { onComplete, onUpdate, motionValue, ...options } = + this.options + const pregeneratedAnimation = pregenerateKeyframes( + keyframes, + options + ) + + keyframes = pregeneratedAnimation.keyframes + duration = pregeneratedAnimation.duration + this.options.times = pregeneratedAnimation.times + this.options.ease = pregeneratedAnimation.ease + } + + const { motionValue, name } = this.options + const animation = animateStyle( + motionValue.owner!.current as unknown as HTMLElement, + name, + keyframes as string[], + this.options + ) + + // Override the browser calculated startTime with one synchronised to other JS + // and WAAPI animations starting this event loop. + animation.startTime = time.now() + + 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 { + animation, + duration, + keyframes: keyframes as string[] | number[], + } + } + + get duration() { + const { duration } = this.resolved + return millisecondsToSeconds(duration) + } + + get time() { + const { animation } = this.resolved + return millisecondsToSeconds((animation.currentTime as number) || 0) + } + + set time(newTime: number) { + const { animation } = this.resolved + animation.currentTime = secondsToMilliseconds(newTime) + } + + get speed() { + const { animation } = this.resolved + return animation.playbackRate + } + + set speed(newSpeed: number) { + const { animation } = this.resolved + animation.playbackRate = newSpeed + } + + get state() { + const { animation } = this.resolved + return animation.playState + } + + /** + * Replace the default DocumentTimeline with another AnimationTimeline. + * Currently used for scroll animations. + */ + attachTimeline(timeline: any) { + if (!this._resolved) { + this.pendingTimeline = timeline + } else { + const { animation } = this.resolved + + animation.timeline = timeline + animation.onfinish = null + } + + return noop + } + + play() { + if (this.isStopped) return + + 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. + */ + if (this.time) { + const { motionValue, onUpdate, onComplete, ...options } = + this.options + + const sampleAnimation = new MainThreadAnimation({ + ...options, + keyframes, + }) + + motionValue.setWithVelocity( + sampleAnimation.sample(this.time - sampleDelta).value, + sampleAnimation.sample(this.time).value, + sampleDelta + ) + } + + this.cancel() + } + + complete() { + this.resolved.animation.finish() + } + + cancel() { + this.resolved.animation.cancel() + } + + static supports( + options: ValueAnimationOptions + ): options is AcceleratedValueAnimationOptions { + const { motionValue, name, repeatDelay, repeatType, damping, type } = + options + + return ( + supportsWaapi() && + 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. + */ + !motionValue.owner.getProps().onUpdate && + !repeatDelay && + repeatType !== "mirror" && + damping !== 0 && + type !== "inertia" + ) + } +} diff --git a/packages/framer-motion/src/animation/animators/BaseAnimation.ts b/packages/framer-motion/src/animation/animators/BaseAnimation.ts new file mode 100644 index 0000000000..1c8633ff00 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -0,0 +1,156 @@ +import { + KeyframeResolver, + ResolvedKeyframes, + flushKeyframeResolvers, +} from "../../render/utils/KeyframesResolver" +import { instantAnimationState } from "../../utils/use-instant-transition-state" +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: boolean + delay: number + repeat: number + repeatDelay: number + repeatType: RepeatType +} + +export abstract class BaseAnimation + implements AnimationPlaybackControls +{ + // Persistent reference to the options used to create this animation + protected options: ValueAnimationOptionsWithDefaults + + // Resolve the current finished promise + protected resolveFinishedPromise: VoidFunction + + // A promise that resolves when the animation is complete + protected currentFinishedPromise: Promise + + // 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. + protected _resolved: Resolved & { keyframes: ResolvedKeyframes } + + // Reference to the active keyframes resolver. + protected resolver: KeyframeResolver + + constructor({ + autoplay = true, + delay = 0, + type = "keyframes", + repeat = 0, + repeatDelay = 0, + repeatType = "loop", + ...options + }: ValueAnimationOptions) { + this.options = { + autoplay, + delay, + type, + repeat, + repeatDelay, + repeatType, + ...options, + } + + this.updateFinishedPromise() + } + + protected abstract initPlayback(keyframes: ResolvedKeyframes): Resolved + + abstract play(): void + 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 + abstract set time(newTime: number) + abstract get duration(): number + abstract get state(): AnimationPlayState + + /** + * 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 } { + // TODO Leave only for tests + if (this.isInitialising) + throw new Error("Cannot access `resolved` while initialising.") + 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. + */ + private isInitialising = false + protected onKeyframesResolved(keyframes: ResolvedKeyframes) { + this.isInitialising = true + const { name, type, velocity, delay, onComplete, onUpdate } = + 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) { + const finalKeyframe = getFinalKeyframe(keyframes, this.options) + onUpdate?.(finalKeyframe) + onComplete?.() + this.resolveFinishedPromise() + this.updateFinishedPromise() + + return + } + // Finish after a delay + else { + this.options.duration = 0 + } + } + + this._resolved = { + keyframes, + ...this.initPlayback(keyframes), + } + + 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 + * reject if its cancels. + */ + then(resolve: VoidFunction, reject?: VoidFunction) { + return this.currentFinishedPromise.then(resolve, reject) + } + + protected 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..24b5dc782c --- /dev/null +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -0,0 +1,471 @@ +import { + KeyframeResolver as DefaultKeyframeResolver, + ResolvedKeyframes, +} from "../../render/utils/KeyframesResolver" +import { spring } from "../generators/spring/index" +import { inertia } from "../generators/inertia" +import { keyframes as keyframesGeneratorFactory } from "../generators/keyframes" +import { ValueAnimationOptions } from "../types" +import { BaseAnimation } from "./BaseAnimation" +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 "./drivers/types" +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 +) => KeyframeGenerator + +const generators: { [key: string]: GeneratorFactory } = { + decay: inertia, + inertia, + tween: keyframesGeneratorFactory, + keyframes: keyframesGeneratorFactory, + spring, +} + +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 driver?: DriverControls + + private holdTime: number | null = null + + private startTime: number | null = null + + private cancelTime: number | null = null + + private currentTime: number = 0 + + private playbackSpeed = 1 + + private pendingPlayState: AnimationPlayState = "running" + + constructor({ + KeyframeResolver = DefaultKeyframeResolver, + ...options + }: ValueAnimationOptions) { + super(options) + + const { name, motionValue, keyframes } = this.options + const onResolved = (resolvedKeyframes: ResolvedKeyframes) => + this.onKeyframesResolved(resolvedKeyframes) + + if (name && motionValue && motionValue.owner) { + this.resolver = (motionValue.owner as any).resolveKeyframes( + keyframes, + onResolved, + name, + motionValue + ) + } else { + this.resolver = new KeyframeResolver( + keyframes, + onResolved, + name, + motionValue + ) + } + + this.resolver.scheduleResolve() + } + + protected initPlayback(keyframes: ResolvedKeyframes) { + const { + type = "keyframes", + repeat = 0, + repeatDelay = 0, + repeatType, + velocity = 0, + } = this.options + + const generatorFactory = generators[type] || keyframesGeneratorFactory + + let mapPercentToKeyframes: ((v: number) => T) | undefined + let mirroredGenerator: KeyframeGenerator | undefined + + 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}` + ) + } + + mapPercentToKeyframes = pipe( + percentToProgress, + mix(keyframes[0], keyframes[1]) + ) as (t: number) => T + + keyframes = [0 as T, 100 as T] + } + + const generator = generatorFactory({ ...this.options, keyframes }) + + if (repeatType === "mirror") { + mirroredGenerator = 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 (generator.calculatedDuration === null) { + generator.calculatedDuration = calcGeneratorDuration(generator) + } + + const { calculatedDuration } = generator + const resolvedDuration = calculatedDuration + repeatDelay + const totalDuration = resolvedDuration * (repeat + 1) - repeatDelay + + return { + generator, + mirroredGenerator, + mapPercentToKeyframes, + calculatedDuration, + resolvedDuration, + totalDuration, + } + } + + onPostResolved() { + const { autoplay = true } = this.options + + this.play() + + if (this.pendingPlayState === "paused" || !autoplay) { + this.pause() + } else { + this.state = this.pendingPlayState + } + } + + tick(timestamp: number, sample = false) { + 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 + ) + } + + // Update currentTime + 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 = + // 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 + } + + // 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 + } + + 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 + } + + state: AnimationPlayState = "idle" + + get duration() { + return millisecondsToSeconds(this.resolved.calculatedDuration) + } + + get time() { + return millisecondsToSeconds(this.currentTime) + } + + set time(newTime: number) { + newTime = secondsToMilliseconds(newTime) + this.currentTime = newTime + + if (this.holdTime !== null || this.speed === 0) { + this.holdTime = newTime + } else if (this.driver) { + this.startTime = this.driver.now() - newTime / this.speed + } + } + + get speed() { + return this.playbackSpeed + } + + set speed(newSpeed: number) { + const hasChanged = this.playbackSpeed !== newSpeed + this.playbackSpeed = newSpeed + if (hasChanged) { + this.time = millisecondsToSeconds(this.currentTime) + } + } + + play() { + if (!this.resolver.isScheduled) { + this.resolver.resume() + } + + if (!this._resolved) { + this.pendingPlayState = "running" + return + } + + if (this.isStopped) return + + const { driver = frameloopDriver, onPlay } = this.options + + if (!this.driver) { + this.driver = driver((timestamp) => this.tick(timestamp)) + } + + 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 + + /** + * 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 ?? 0 + } + + stop() { + this.isStopped = true + if (this.state === "idle") return + + this.state = "idle" + const { onStop } = this.options + onStop && onStop() + this.teardown() + } + + complete() { + 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() + } + + 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() + } + + private stopDriver() { + if (!this.driver) return + this.driver.stop() + this.driver = undefined + } + + sample(time: number): AnimationState { + this.startTime = 0 + return this.tick(time, true) + } +} + +// 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/__tests__/MainThreadAnimation.test.ts similarity index 97% rename from packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts rename to packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts index e0a8f37619..47e99e1f5e 100644 --- a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts +++ b/packages/framer-motion/src/animation/animators/__tests__/MainThreadAnimation.test.ts @@ -1,19 +1,26 @@ -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" +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[], resolve: () => void ) { const output: V[] = [] - animateValue({ + new MainThreadAnimation({ driver: syncDriver(20), duration: 100, ease: linear, @@ -27,7 +34,7 @@ function testAnimate( }) } -describe("animate", () => { +describe("MainThreadAnimation", () => { test("Correctly performs an animation with default settings", async () => { return new Promise((resolve) => testAnimate( @@ -1231,6 +1238,26 @@ describe("animate", () => { 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/animators/js/__tests__/utils.ts b/packages/framer-motion/src/animation/animators/__tests__/utils.ts similarity index 94% rename from packages/framer-motion/src/animation/animators/js/__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/js/__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/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/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts deleted file mode 100644 index 5e4891626e..0000000000 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ /dev/null @@ -1,450 +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 - -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. - * - * 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/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/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts deleted file mode 100644 index 333f6d5538..0000000000 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ /dev/null @@ -1,320 +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, - } - ) - - /** - * 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/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, 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/animation/interfaces/motion-value.ts b/packages/framer-motion/src/animation/interfaces/motion-value.ts index 491b4ba798..7c8a8c861b 100644 --- a/packages/framer-motion/src/animation/interfaces/motion-value.ts +++ b/packages/framer-motion/src/animation/interfaces/motion-value.ts @@ -3,15 +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 = ( @@ -132,31 +132,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 new MainThreadAnimation(options) } - - return animateValue(options) } diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 86dd5fd77c..eec9d71b83 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" @@ -46,9 +46,9 @@ export type ResolveKeyframes = ( export interface ValueAnimationOptions extends ValueAnimationTransition { keyframes: V[] + KeyframeResolver?: typeof KeyframeResolver name?: string motionValue?: MotionValue - element?: VisualElement from?: V } @@ -167,9 +167,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 } 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 17c8e92e05..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() { @@ -50,7 +56,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) { 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() } } 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 () => { 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 = () => {