diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe7ea4add..a9eb6e91a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Framer Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [11.0.11] 2024-03-12 + +### Changed + +- Keyframes now resolved asynchronously. +- External event handlers now fired synchronously. +- CSS variables and unit conversion now supported with >2 keyframe animations. +- Removed WAAPI animation of `background-color`. + ## [11.0.10] 2024-03-12 ### Fixed diff --git a/dev/benchmarks/cold-start-anime.html b/dev/benchmarks/cold-start-anime.html index 7bed6160b9..c8ca123ebf 100644 --- a/dev/benchmarks/cold-start-anime.html +++ b/dev/benchmarks/cold-start-anime.html @@ -28,7 +28,7 @@ } .box { - width: 10px; + width: 10%; height: 100px; background-color: #fff; } @@ -36,7 +36,7 @@
- + diff --git a/dev/benchmarks/cold-start-framer-motion.html b/dev/benchmarks/cold-start-framer-motion.html index 16f9923ca2..05c4f230c4 100644 --- a/dev/benchmarks/cold-start-framer-motion.html +++ b/dev/benchmarks/cold-start-framer-motion.html @@ -62,7 +62,7 @@ x: 5, }, { - easing: "linear", + ease: "linear", duration: 1, } ) @@ -78,7 +78,7 @@ x: "10%", }, { - easing: "linear", + ease: "linear", duration: 1, } ) diff --git a/dev/benchmarks/cold-start-waapi.html b/dev/benchmarks/cold-start-waapi.html index b0788d8427..865ba6d278 100644 --- a/dev/benchmarks/cold-start-waapi.html +++ b/dev/benchmarks/cold-start-waapi.html @@ -44,20 +44,51 @@ html += `
` } document.querySelector(".container").innerHTML = html + const boxes = document.querySelectorAll(".box") - boxes.forEach((box) => - box.animate( - { - rotate: "360deg", - backgroundColor: "#f00", - width: "100%", - }, - { - duration: 1000, - fill: "forwards", + setTimeout(() => { + boxes.forEach((box) => { + const animation = box.animate( + { + rotate: Math.random() * 360 + "deg", + backgroundColor: "#f00", + width: Math.random() * 100 + "%", + translate: "5px 0", + }, + { + duration: 1000, + fill: "both", + } + ) + animation.onfinish = () => { + requestAnimationFrame(() => { + animation.commitStyles() + animation.cancel() + }) } - ) - ) + }) + + setTimeout(() => { + boxes.forEach((box) => { + const animation = box.animate( + { + width: Math.random() * 100 + "px", + translate: "50% 0", + }, + { + duration: 1000, + fill: "both", + } + ) + animation.onfinish = () => { + requestAnimationFrame(() => { + animation.commitStyles() + animation.cancel() + }) + } + }) + }, 1500) + }, 1000) diff --git a/dev/benchmarks/warm-start-framer-motion.html b/dev/benchmarks/warm-start-framer-motion.html new file mode 100644 index 0000000000..adbabda428 --- /dev/null +++ b/dev/benchmarks/warm-start-framer-motion.html @@ -0,0 +1,73 @@ + + + + + + +
+ + + + + diff --git a/dev/benchmarks/warm-start-gsap.html b/dev/benchmarks/warm-start-gsap.html new file mode 100644 index 0000000000..114507e7dd --- /dev/null +++ b/dev/benchmarks/warm-start-gsap.html @@ -0,0 +1,84 @@ + + + + + + +
+ + + + diff --git a/dev/tests/animate-presence-pop.tsx b/dev/tests/animate-presence-pop.tsx index 22ea2d83c8..165d252f2d 100644 --- a/dev/tests/animate-presence-pop.tsx +++ b/dev/tests/animate-presence-pop.tsx @@ -1,6 +1,6 @@ -import { AnimatePresence, motion } from "framer-motion" +import { AnimatePresence, motion, animate } from "framer-motion" import * as React from "react" -import { useState } from "react" +import { useState, useRef, useEffect } from "react" import styled from "styled-components" const Container = styled.section` @@ -23,6 +23,15 @@ export const App = () => { const itemStyle = position === "relative" ? { position, top: 100, left: 100 } : {} + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + + animate(ref.current, { opacity: [0, 1] }, { duration: 1 }) + animate(ref.current, { opacity: [1, 0.5] }, { duration: 1 }) + }, []) + return ( setState(!state)}> @@ -54,6 +63,10 @@ export const App = () => { style={{ ...itemStyle, backgroundColor: "blue" }} /> +
) } diff --git a/dev/tests/drag-ref-constraints.tsx b/dev/tests/drag-ref-constraints.tsx index 9a62f7c0b6..74da2bb739 100644 --- a/dev/tests/drag-ref-constraints.tsx +++ b/dev/tests/drag-ref-constraints.tsx @@ -14,6 +14,7 @@ export const App = () => { window.scrollTo(0, 100) }, []) const x = useMotionValue("100%") + return (
{ useEffect(() => { /** - * Animate both background-color (WAAPI-driven) and color (sync) + * Animate both transform (WAAPI) and colors (JS) */ return scroll( animate( diff --git a/dev/tests/scroll-svg.tsx b/dev/tests/scroll-svg.tsx index ac20dd2756..87b6f8f822 100644 --- a/dev/tests/scroll-svg.tsx +++ b/dev/tests/scroll-svg.tsx @@ -18,7 +18,12 @@ export const App = () => { return ( <> -
+
{ />
- + {rectValues.scrollYProgress} - + {svgValues.scrollYProgress} 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/lerna.json b/lerna.json index 479a2414e9..a697246690 100644 --- a/lerna.json +++ b/lerna.json @@ -1,8 +1,6 @@ { "version": "11.0.10", - "packages": [ - "packages/*" - ], + "packages": ["packages/*"], "npmClient": "yarn", "useWorkspaces": true } diff --git a/packages/framer-motion-3d/package.json b/packages/framer-motion-3d/package.json index ab23634e72..790879abe2 100644 --- a/packages/framer-motion-3d/package.json +++ b/packages/framer-motion-3d/package.json @@ -61,5 +61,5 @@ "@react-three/test-renderer": "^9.0.0", "@rollup/plugin-commonjs": "^22.0.1" }, - "gitHead": "f99b162917a2f171dbabe32a5baf63990b83318b" + "gitHead": "2b49f76000d8006f08f7e76b63bd86eee0d25ab8" } diff --git a/packages/framer-motion-3d/src/render/__tests__/index.test.tsx b/packages/framer-motion-3d/src/render/__tests__/index.test.tsx index f3810f3b9e..917f5ef1e3 100644 --- a/packages/framer-motion-3d/src/render/__tests__/index.test.tsx +++ b/packages/framer-motion-3d/src/render/__tests__/index.test.tsx @@ -58,6 +58,7 @@ describe("motion for three", () => { ReactThreeTestRenderer.create() }) + expect(result.length).toBeGreaterThan(1) const lastFrame = result[result.length - 1] expect(lastFrame).toEqual({ scale: 5, @@ -230,8 +231,8 @@ describe("motion for three", () => { transition: { x: { type: false } }, }, off: { - x: 0, - y: 0, + x: 1, + y: 1, transition: { x: { type: false } }, }, }} @@ -240,10 +241,10 @@ describe("motion for three", () => { } ReactThreeTestRenderer.create() - frame.update(() => resolve([x.get(), y.get()])) + frame.postRender(() => resolve([x.get(), y.get()])) }) expect(result[0]).toEqual(100) - expect(result[1]).toEqual(0) + expect(result[1]).not.toEqual(100) }) }) diff --git a/packages/framer-motion-3d/src/render/create-visual-element.ts b/packages/framer-motion-3d/src/render/create-visual-element.ts index eada251e21..9076e1bc0f 100644 --- a/packages/framer-motion-3d/src/render/create-visual-element.ts +++ b/packages/framer-motion-3d/src/render/create-visual-element.ts @@ -1,15 +1,10 @@ import type { CreateVisualElement, - TargetAndTransition, ResolvedValues, MotionProps, } from "framer-motion" -import { - createBox, - checkTargetForNewValues, - VisualElement, -} from "framer-motion" +import { createBox, VisualElement } from "framer-motion" import { Object3DNode } from "@react-three/fiber" import { setThreeValue } from "./utils/set-value" @@ -41,15 +36,6 @@ export class ThreeVisualElement extends VisualElement< return a.id - b.id } - makeTargetAnimatableFromInstance({ - transition, - transitionEnd, - ...target - }: TargetAndTransition) { - checkTargetForNewValues(this, target, {}) - return { ...target, transition, transitionEnd } - } - removeValueFromRenderState() {} measureInstanceViewportBox() { diff --git a/packages/framer-motion/cypress/integration/drag-to-reorder.ts b/packages/framer-motion/cypress/integration/drag-to-reorder.ts index d85f206e4f..8e74738860 100644 --- a/packages/framer-motion/cypress/integration/drag-to-reorder.ts +++ b/packages/framer-motion/cypress/integration/drag-to-reorder.ts @@ -201,7 +201,7 @@ describe("Drag to reorder", () => { const y = step > 0 ? delta : -delta chain = chain .trigger("pointermove", 360, baseY + y, { force: true }) - .wait(50) + .wait(100) }) }) return chain diff --git a/packages/framer-motion/cypress/integration/drag.ts b/packages/framer-motion/cypress/integration/drag.ts index 11ea5af826..2d68d7627b 100644 --- a/packages/framer-motion/cypress/integration/drag.ts +++ b/packages/framer-motion/cypress/integration/drag.ts @@ -218,7 +218,7 @@ describe("Drag", () => { expect(top).to.equal(-10) }) .trigger("pointerup", { force: true }) - .wait(50) + .wait(100) .should(($draggable: any) => { const draggable = $draggable[0] as HTMLDivElement const { left, top } = draggable.getBoundingClientRect() 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 3dce0311c3..d8273f93c1 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -86,7 +86,7 @@ "bundlesize": [ { "path": "./dist/size-rollup-motion.js", - "maxSize": "31.4 kB" + "maxSize": "32 kB" }, { "path": "./dist/size-rollup-m.js", @@ -94,15 +94,15 @@ }, { "path": "./dist/size-rollup-dom-animation.js", - "maxSize": "15.35 kB" + "maxSize": "16.1kB" }, { "path": "./dist/size-rollup-dom-max.js", - "maxSize": "26.8 kB" + "maxSize": "27.5 kB" }, { "path": "./dist/size-rollup-animate.js", - "maxSize": "16.6 kB" + "maxSize": "17.3 kB" }, { "path": "./dist/size-webpack-m.js", @@ -110,12 +110,12 @@ }, { "path": "./dist/size-webpack-dom-animation.js", - "maxSize": "20 kB" + "maxSize": "21 kB" }, { "path": "./dist/size-webpack-dom-max.js", - "maxSize": "32.2 kB" + "maxSize": "33 kB" } ], - "gitHead": "f99b162917a2f171dbabe32a5baf63990b83318b" + "gitHead": "2b49f76000d8006f08f7e76b63bd86eee0d25ab8" } diff --git a/packages/framer-motion/src/animation/GroupPlaybackControls.ts b/packages/framer-motion/src/animation/GroupPlaybackControls.ts index 06141d884f..ec0608ed14 100644 --- a/packages/framer-motion/src/animation/GroupPlaybackControls.ts +++ b/packages/framer-motion/src/animation/GroupPlaybackControls.ts @@ -36,6 +36,7 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { animation.attachTimeline(timeline) } else { animation.pause() + return observeTimeline((progress) => { animation.time = animation.duration * progress }, timeline) @@ -76,7 +77,10 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { } private runAll( - methodName: keyof Omit + methodName: keyof Omit< + AnimationPlaybackControls, + PropNames | "then" | "state" + > ) { this.animations.forEach((controls) => controls[methodName]()) } 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 89% 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 8beaf851bf..b9b5ed7ea3 100644 --- a/packages/framer-motion/src/animation/__tests__/animate-waapi.test.tsx +++ b/packages/framer-motion/src/animation/__tests__/animate-waapi.test.ts @@ -1,3 +1,4 @@ +import { nextFrame } from "../../gestures/__tests__/utils" import { animate } from "../animate" import { defaultOptions } from "../animators/waapi/__tests__/setup" import { stagger } from "../utils/stagger" @@ -12,6 +13,8 @@ describe("animate() with WAAPI", () => { { duration: 1, transform: { duration: 2 } } ) + await nextFrame() + expect(a.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, { ...defaultOptions, duration: 1000 } @@ -65,6 +68,8 @@ describe("animate() with WAAPI", () => { animate(a, { opacity: [0.2, 0.5] }, { ease: "easeIn" }) + await nextFrame() + expect(a.animate).toBeCalledWith( { opacity: [0.2, 0.5], offset: undefined }, { @@ -81,6 +86,8 @@ describe("animate() with WAAPI", () => { animate(b, { opacity: [0.2, 0.5] }, { ease: ["easeIn"] }) + await nextFrame() + expect(b.animate).toBeCalledWith( { opacity: [0.2, 0.5], offset: undefined, easing: ["ease-in"] }, { @@ -101,6 +108,8 @@ describe("animate() with WAAPI", () => { { times: [0.2, 0.3, 1], ease: [[0, 1, 2, 3], "linear"] } ) + await nextFrame() + expect(c.animate).toBeCalledWith( { opacity: [0.2, 0.5, 1], @@ -119,11 +128,30 @@ describe("animate() with WAAPI", () => { }) test("Returns duration correctly", async () => { + const a = document.createElement("div") + const animation = animate( - document.createElement("div"), + a, { opacity: 1 }, { duration: 2, opacity: { duration: 3 } } ) + + await nextFrame() + + expect(a.animate).toBeCalledWith( + { + opacity: [0, 1], + }, + { + delay: -0, + duration: 3000, + easing: "ease-out", + iterations: 1, + direction: "normal", + fill: "both", + } + ) + expect(animation.duration).toEqual(3) }) @@ -139,6 +167,8 @@ describe("animate() with WAAPI", () => { ], ]) + await nextFrame() + expect(a.animate).toBeCalledWith( { opacity: [0, 1, 1], 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/__tests__/css-variables.test.tsx b/packages/framer-motion/src/animation/__tests__/css-variables.test.tsx index 8a05c01c50..8e97587cd3 100644 --- a/packages/framer-motion/src/animation/__tests__/css-variables.test.tsx +++ b/packages/framer-motion/src/animation/__tests__/css-variables.test.tsx @@ -21,7 +21,10 @@ const originalGetComputedStyle = window.getComputedStyle function getComputedStyleStub() { return { - getPropertyValue(variableName: "--from" | "--to" | "--a" | "--color") { + background: fromValue, + getPropertyValue( + variableName: "background" | "--from" | "--to" | "--a" | "--color" + ) { switch (variableName) { case fromName: return fromValue @@ -53,6 +56,7 @@ describe("css variables", () => { test("should animate css color variables", async () => { const promise = new Promise((resolve) => { let frameCount = 0 + const Component = () => ( { const results = await promise expect(results).toEqual([ { "--a": "20px", "--color": "rgba(0, 0, 0, 1)" }, + { "--a": "20px", "--color": "rgba(0, 0, 0, 1)" }, ]) }) diff --git a/packages/framer-motion/src/animation/animate.ts b/packages/framer-motion/src/animation/animate.ts index 28d6dbe5d2..05c2563d0b 100644 --- a/packages/framer-motion/src/animation/animate.ts +++ b/packages/framer-motion/src/animation/animate.ts @@ -141,7 +141,7 @@ export const createScopedAnimate = (scope?: AnimationScope) => { /** * Implementation */ - function scopedAnimate( + function scopedAnimate( valueOrElementOrSequence: | AnimationSequence | ElementOrSelector @@ -171,7 +171,7 @@ export const createScopedAnimate = (scope?: AnimationScope) => { ) } else { animation = animateSingleValue( - valueOrElementOrSequence, + valueOrElementOrSequence as MotionValue | V, keyframes as V | GenericKeyframesTarget, options as ValueAnimationTransition | undefined ) 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..a80946168c --- /dev/null +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -0,0 +1,338 @@ +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", + // TODO: Can be accelerated but currently disabled until https://issues.chromium.org/issues/41491098 is resolved + // or until we implement support for linear() easing. + // "background-color" +]) + +/** + * 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 + +/** + * Check if an animation can run natively via WAAPI or requires pregenerated keyframes. + * WAAPI doesn't support spring or function easings so we run these as JS animation before + * handing off. + */ +function requiresPregeneratedKeyframes( + options: ValueAnimationOptions +) { + return ( + options.type === "spring" || + options.name === "backgroundColor" || + !isWaapiSupportedEasing(options.ease) + ) +} + +function pregenerateKeyframes( + keyframes: ResolvedKeyframes, + options: ValueAnimationOptions +) { + /** + * Create a main-thread animation to pregenerate keyframes. + * We sample this at regular intervals to generate keyframes that we then + * linearly interpolate between. + */ + const sampleAnimation = new MainThreadAnimation({ + ...options, + keyframes, + repeat: 0, + delay: 0, + }) + + 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() + } + + /** + * An AnimationTimline to attach to the WAAPI animation once it's created. + */ + private pendingTimeline: AnimationTimeline | undefined + + 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, duration } + ) + + // 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() + 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..cabfdb8192 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/BaseAnimation.ts @@ -0,0 +1,149 @@ +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 } { + 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, 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.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..a516b46213 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/MainThreadAnimation.ts @@ -0,0 +1,522 @@ +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 + + /** + * Duration of the animation as calculated by the generator. + */ + calculatedDuration: number + + /** + * Duration of the animation plus repeatDelay. + */ + resolvedDuration: number + + /** + * Total duration of the animation including repeats. + */ + totalDuration: number +} + +/** + * Animation that runs on the main thread. Designed to be WAAPI-spec in the subset of + * features we expose publically. Mostly the compatibility is to ensure visual identity + * between both WAAPI and main thread animations. + */ +export class MainThreadAnimation< + T extends string | number +> extends BaseAnimation> { + /** + * The driver that's controlling the animation loop. Normally this is a requestAnimationFrame loop + * but in tests we can pass in a synchronous loop. + */ + private driver?: DriverControls + + /** + * The time at which the animation was paused. + */ + private holdTime: number | null = null + + /** + * The time at which the animation was started. + */ + private startTime: number | null = null + + /** + * The time at which the animation was cancelled. + */ + private cancelTime: number | null = null + + /** + * The current time of the animation. + */ + private currentTime: number = 0 + + /** + * Playback speed as a factor. 0 would be stopped, -1 reverse and 2 double speed. + */ + private playbackSpeed = 1 + + /** + * The state of the animation to apply when the animation is resolved. This + * allows calls to the public API to control the animation before it is resolved, + * without us having to resolve it first. + */ + 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 + + /** + * If our generator doesn't support mixing numbers, we need to replace keyframes with + * [0, 100] and then make a function that maps that to the actual keyframes. + * + * 100 is chosen instead of 1 as it works nicer with spring animations. + */ + 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 we have a mirror repeat type we need to create a second generator that outputs the + * mirrored (not reversed) animation and later ping pong between the two generators. + */ + 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 b8f6ad48b6..2d0cafee13 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 -function testAnimate( +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,25 @@ 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.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/__tests__/instant.test.ts b/packages/framer-motion/src/animation/animators/__tests__/instant.test.ts deleted file mode 100644 index 3757511bf8..0000000000 --- a/packages/framer-motion/src/animation/animators/__tests__/instant.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { createInstantAnimation } from "../instant" - -describe("instantAnimation", () => { - test("Is instant, await", async () => { - const onUpdate = jest.fn() - const onComplete = jest.fn() - - await createInstantAnimation({ - keyframes: [0, 1], - onUpdate, - onComplete, - }) - - expect(onUpdate).toBeCalledWith(1) - expect(onComplete).toBeCalled() - }) - - test("Is instant, .then()", async () => { - const onUpdate = jest.fn() - const onComplete = jest.fn() - - const animation = createInstantAnimation({ - keyframes: [0, 1], - onUpdate, - onComplete, - }) - - await new Promise((resolve) => { - animation.then(() => {}).then(() => resolve()) - }) - - expect(onUpdate).toBeCalledWith(1) - expect(onComplete).toBeCalled() - }) - - test("Can delay, await", async () => { - const onUpdate = jest.fn() - const onComplete = jest.fn() - - const animation = createInstantAnimation({ - delay: 0.1, - keyframes: [0, 1], - onUpdate, - onComplete, - }) - - expect(onUpdate).not.toBeCalledWith(1) - expect(onComplete).not.toBeCalled() - - await animation - - expect(onUpdate).toBeCalledWith(1) - expect(onComplete).toBeCalled() - }) - - test("Can delay, .then()", async () => { - const onUpdate = jest.fn() - const onComplete = jest.fn() - - const animation = createInstantAnimation({ - delay: 0.1, - keyframes: [0, 1], - onUpdate, - onComplete, - }) - - await new Promise((resolve) => { - animation.then(() => {}).then(() => resolve()) - - expect(onUpdate).not.toBeCalledWith(1) - expect(onComplete).not.toBeCalled() - }) - - expect(onUpdate).toBeCalledWith(1) - expect(onComplete).toBeCalled() - }) - - test("Returns duration: 0", async () => { - const animation = createInstantAnimation({ - delay: 0, - keyframes: [0, 1], - }) - expect(animation.duration).toEqual(0) - - const animationWithDelay = createInstantAnimation({ - delay: 0.2, - keyframes: [0, 1], - }) - expect(animationWithDelay.duration).toEqual(0) - }) -}) 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/instant.ts b/packages/framer-motion/src/animation/animators/instant.ts deleted file mode 100644 index 80a61258b7..0000000000 --- a/packages/framer-motion/src/animation/animators/instant.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" -import { animateValue } from "./js" -import { noop } from "../../utils/noop" - -export function createInstantAnimation({ - keyframes, - delay, - onUpdate, - onComplete, -}: ValueAnimationOptions): AnimationPlaybackControls { - const setValue = (): AnimationPlaybackControls => { - onUpdate && onUpdate(keyframes[keyframes.length - 1]) - onComplete && onComplete() - - /** - * TODO: As this API grows it could make sense to always return - * animateValue. This will be a bigger project as animateValue - * is frame-locked whereas this function resolves instantly. - * This is a behavioural change and also has ramifications regarding - * assumptions within tests. - */ - return { - time: 0, - speed: 1, - duration: 0, - play: noop, - pause: noop, - stop: noop, - then: (resolve: VoidFunction) => { - resolve() - return Promise.resolve() - }, - cancel: noop, - complete: noop, - } - } - - return delay - ? animateValue({ - keyframes: [0, 1], - duration: 0, - delay, - onComplete: setValue, - }) - : setValue() -} 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 fd3d02ac60..0000000000 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ /dev/null @@ -1,390 +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" - -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({ - autoplay = true, - delay = 0, - driver = frameloopDriver, - keyframes, - type = "keyframes", - repeat = 0, - repeatDelay = 0, - repeatType = "loop", - onPlay, - onStop, - onComplete, - onUpdate, - ...options -}: ValueAnimationOptions): MainThreadAnimationControls { - let speed = 1 - - let hasStopped = false - let resolveFinishedPromise: VoidFunction - let currentFinishedPromise: Promise - - /** - * 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 animationDriver: DriverControls | undefined - - const generatorFactory = types[type] || keyframesGeneratorFactory - - /** - * If this isn't the keyframes generator and we've been provided - * strings as keyframes, we need to interpolate these. - */ - let mapNumbersToKeyframes: undefined | ((t: number) => V) - 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 - } - - const generator = generatorFactory({ ...options, keyframes }) - - let mirroredGenerator: KeyframeGenerator | undefined - if (repeatType === "mirror") { - mirroredGenerator = generatorFactory({ - ...options, - keyframes: [...keyframes].reverse(), - velocity: -(options.velocity || 0), - }) - } - - let playState: AnimationPlayState = "idle" - let holdTime: number | null = null - let startTime: number | null = null - let cancelTime: number | null = null - - /** - * 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) - } - - const { calculatedDuration } = generator - - let resolvedDuration = Infinity - let totalDuration = Infinity - - if (calculatedDuration !== null) { - resolvedDuration = calculatedDuration + repeatDelay - totalDuration = resolvedDuration * (repeat + 1) - repeatDelay - } - - let currentTime = 0 - const tick = (timestamp: number) => { - if (startTime === null) 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: keyframes[0] } - : frameGenerator.next(elapsed) - - if (mapNumbersToKeyframes) { - state.value = mapNumbersToKeyframes(state.value) - } - - 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 - } - - const finish = () => { - playState = "finished" - onComplete && onComplete() - stopAnimationDriver() - resolveFinishedPromise() - } - - const play = () => { - 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() - } - - if (autoplay) { - play() - } - - 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() { - 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 new file mode 100644 index 0000000000..b722740b89 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/utils/can-animate.ts @@ -0,0 +1,42 @@ +import { ResolvedKeyframes } from "../../../render/utils/KeyframesResolver" +import { warning } from "../../../utils/errors" +import { isAnimatable } from "../../utils/is-animatable" + +function hasKeyframesChanged(keyframes: ResolvedKeyframes) { + const current = keyframes[0] + if (keyframes.length === 1) return true + for (let i = 0; i < keyframes.length; i++) { + if (keyframes[i] !== current) return true + } +} + +export function canAnimate( + keyframes: ResolvedKeyframes, + name?: string, + type?: string, + velocity?: number +) { + /** + * Check if we're able to animate between the start and end keyframes, + * and throw a warning if we're attempting to animate between one that's + * 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) + + 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) { + return false + } + + return hasKeyframesChanged(keyframes) || (type === "spring" && velocity) +} 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 9fe664932e..0000000000 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ /dev/null @@ -1,245 +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" - -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, ...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, duration = 300, ease, times } = options - - /** - * If this animation needs pre-generated keyframes then generate. - */ - if (requiresPregeneratedKeyframes(valueName, options)) { - const sampleAnimation = animateValue({ - ...options, - 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" - } - - const 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, - } - ) - - const cancelAnimation = () => { - pendingCancel = false - animation.cancel() - } - - const safeCancel = () => { - pendingCancel = true - frame.update(cancelAnimation) - resolveFinishedPromise() - updateFinishedPromise() - } - - /** - * 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 = () => { - if (pendingCancel) return - value.set(getFinalKeyframe(keyframes, options)) - onComplete && onComplete() - safeCancel() - } - - /** - * Animation interrupt callback. - */ - const controls = { - then(resolve: VoidFunction, reject?: VoidFunction) { - return currentFinishedPromise.then(resolve, reject) - }, - attachTimeline(timeline: any) { - animation.timeline = timeline - animation.onfinish = null - return noop - }, - get time() { - return millisecondsToSeconds(animation.currentTime || 0) - }, - set time(newTime: number) { - animation.currentTime = secondsToMilliseconds(newTime) - }, - get speed() { - return animation.playbackRate - }, - set speed(newSpeed: number) { - animation.playbackRate = newSpeed - }, - get duration() { - return millisecondsToSeconds(duration) - }, - play: () => { - if (hasStopped) return - animation.play() - - /** - * Cancel any pending cancel tasks - */ - cancelFrame(cancelAnimation) - }, - pause: () => animation.pause(), - stop: () => { - 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, - autoplay: false, - }) - - value.setWithVelocity( - sampleAnimation.sample(currentTime - sampleDelta).value, - sampleAnimation.sample(currentTime).value, - sampleDelta - ) - } - safeCancel() - }, - complete: () => { - if (pendingCancel) return - 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/animators/waapi/utils/get-final-keyframe.ts b/packages/framer-motion/src/animation/animators/waapi/utils/get-final-keyframe.ts index 1aa5a140e1..9909939832 100644 --- a/packages/framer-motion/src/animation/animators/waapi/utils/get-final-keyframe.ts +++ b/packages/framer-motion/src/animation/animators/waapi/utils/get-final-keyframe.ts @@ -1,12 +1,16 @@ import { Repeat } from "../../../../types" +const isNotNull = (value: unknown) => value !== null + export function getFinalKeyframe( keyframes: T[], { repeat, repeatType = "loop" }: Repeat ): T { + const resolvedKeyframes = keyframes.filter(isNotNull) const index = repeat && repeatType !== "loop" && repeat % 2 === 1 ? 0 - : keyframes.length - 1 - return keyframes[index] + : resolvedKeyframes.length - 1 + + return resolvedKeyframes[index] } 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/generators/inertia.ts b/packages/framer-motion/src/animation/generators/inertia.ts index ea36ad493d..e3c8cc9765 100644 --- a/packages/framer-motion/src/animation/generators/inertia.ts +++ b/packages/framer-motion/src/animation/generators/inertia.ts @@ -100,7 +100,7 @@ export function inertia({ * If we have a spring and the provided t is beyond the moment the friction * animation crossed the min/max boundary, use the spring. */ - if (timeReachedBoundary !== undefined && t > timeReachedBoundary) { + if (timeReachedBoundary !== undefined && t >= timeReachedBoundary) { return spring!.next(t - timeReachedBoundary) } else { !hasUpdatedFrame && applyFriction(t) diff --git a/packages/framer-motion/src/animation/generators/keyframes.ts b/packages/framer-motion/src/animation/generators/keyframes.ts index 0670896f57..c7ec15a5b1 100644 --- a/packages/framer-motion/src/animation/generators/keyframes.ts +++ b/packages/framer-motion/src/animation/generators/keyframes.ts @@ -15,7 +15,7 @@ export function defaultEasing( return values.map(() => easing || easeInOut).splice(0, values.length - 1) } -export function keyframes({ +export function keyframes({ duration = 300, keyframes: keyframeValues, times, diff --git a/packages/framer-motion/src/animation/hooks/animation-controls.ts b/packages/framer-motion/src/animation/hooks/animation-controls.ts index 93745b8061..4526d38df6 100644 --- a/packages/framer-motion/src/animation/hooks/animation-controls.ts +++ b/packages/framer-motion/src/animation/hooks/animation-controls.ts @@ -1,13 +1,41 @@ import { invariant } from "../../utils/errors" -import { setValues } from "../../render/utils/setters" +import { setTarget } from "../../render/utils/setters" import type { VisualElement } from "../../render/VisualElement" -import { AnimationControls } from "../types" +import { AnimationControls, AnimationDefinition } from "../types" import { animateVisualElement } from "../interfaces/visual-element" function stopAnimation(visualElement: VisualElement) { visualElement.values.forEach((value) => value.stop()) } +function setVariants(visualElement: VisualElement, variantLabels: string[]) { + const reversedLabels = [...variantLabels].reverse() + + reversedLabels.forEach((key) => { + const variant = visualElement.getVariant(key) + variant && setTarget(visualElement, variant) + + if (visualElement.variantChildren) { + visualElement.variantChildren.forEach((child) => { + setVariants(child, variantLabels) + }) + } + }) +} + +export function setValues( + visualElement: VisualElement, + definition: AnimationDefinition +) { + if (Array.isArray(definition)) { + return setVariants(visualElement, definition) + } else if (typeof definition === "string") { + return setVariants(visualElement, [definition]) + } else { + setTarget(visualElement, definition as any) + } +} + /** * @public */ diff --git a/packages/framer-motion/src/animation/hooks/use-animated-state.ts b/packages/framer-motion/src/animation/hooks/use-animated-state.ts index 60ca87b4ec..f0f8ba8a7f 100644 --- a/packages/framer-motion/src/animation/hooks/use-animated-state.ts +++ b/packages/framer-motion/src/animation/hooks/use-animated-state.ts @@ -1,6 +1,5 @@ import { useEffect, useState } from "react" import { useConstant } from "../../utils/use-constant" -import { checkTargetForNewValues, getOrigin } from "../../render/utils/setters" import { TargetAndTransition } from "../../types" import { ResolvedValues } from "../../render/types" import { makeUseVisualState } from "../../motion/utils/use-visual-state" @@ -46,16 +45,6 @@ class StateVisualElement extends VisualElement< sortInstanceNodePosition() { return 0 } - - makeTargetAnimatableFromInstance({ - transition, - transitionEnd, - ...target - }: TargetAndTransition) { - const origin = getOrigin(target as any, transition || {}, this) - checkTargetForNewValues(this, target, origin as any) - return { transition, transitionEnd, ...target } - } } const useVisualState = makeUseVisualState({ diff --git a/packages/framer-motion/src/animation/interfaces/motion-value.ts b/packages/framer-motion/src/animation/interfaces/motion-value.ts index c2afc603f5..569fd2f22e 100644 --- a/packages/framer-motion/src/animation/interfaces/motion-value.ts +++ b/packages/framer-motion/src/animation/interfaces/motion-value.ts @@ -1,26 +1,29 @@ -import { warning } from "../../utils/errors" -import { ResolvedValueTarget, Transition } from "../../types" +import { Transition } from "../../types" import { secondsToMilliseconds } from "../../utils/time-conversion" -import { instantAnimationState } from "../../utils/use-instant-transition-state" import type { MotionValue, StartAnimation } from "../../value" -import { createAcceleratedAnimation } from "../animators/waapi/create-accelerated-animation" -import { createInstantAnimation } from "../animators/instant" import { getDefaultTransition } from "../utils/default-transitions" -import { isAnimatable } from "../utils/is-animatable" -import { getKeyframes } from "../utils/keyframes" import { getValueTransition, isTransitionDefined } from "../utils/transitions" -import { animateValue } from "../animators/js" -import { AnimationPlaybackControls, ValueAnimationOptions } from "../types" +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 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 = ( - valueName: string, - value: MotionValue, - target: ResolvedValueTarget, - transition: Transition & { elapsed?: number; isHandoff?: boolean } = {} -): StartAnimation => { - return (onComplete: VoidFunction): AnimationPlaybackControls => { - const valueTransition = getValueTransition(transition, valueName) || {} +export const animateMotionValue = + ( + name: string, + value: MotionValue, + target: V | UnresolvedKeyframes, + transition: Transition & { elapsed?: number } = {}, + element?: VisualElement, + isHandoff?: boolean + ): StartAnimation => + (onComplete) => { + const valueTransition = getValueTransition(transition, name) || {} /** * Most transition values are currently completely overwritten by value-specific @@ -36,32 +39,10 @@ export const animateMotionValue = ( let { elapsed = 0 } = transition elapsed = elapsed - secondsToMilliseconds(delay) - const keyframes = getKeyframes( - value, - valueName, - target, - valueTransition - ) - - /** - * Check if we're able to animate between the start and end keyframes, - * and throw a warning if we're attempting to animate between one that's - * animatable and another that isn't. - */ - const originKeyframe = keyframes[0] - const targetKeyframe = keyframes[keyframes.length - 1] - const isOriginAnimatable = isAnimatable(valueName, originKeyframe) - const isTargetAnimatable = isAnimatable(valueName, targetKeyframe) - - warning( - isOriginAnimatable === isTargetAnimatable, - `You are trying to animate ${valueName} 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.` - ) - let options: ValueAnimationOptions = { - keyframes, - velocity: value.getVelocity(), + keyframes: Array.isArray(target) ? target : [null, target], ease: "easeOut", + velocity: value.getVelocity(), ...valueTransition, delay: -elapsed, onUpdate: (v) => { @@ -72,6 +53,9 @@ export const animateMotionValue = ( onComplete() valueTransition.onComplete && valueTransition.onComplete() }, + name, + motionValue: value, + element: isHandoff ? undefined : element, } /** @@ -81,7 +65,7 @@ export const animateMotionValue = ( if (!isTransitionDefined(valueTransition)) { options = { ...options, - ...getDefaultTransition(valueName, options), + ...getDefaultTransition(name, options), } } @@ -93,59 +77,61 @@ export const animateMotionValue = ( if (options.duration) { options.duration = secondsToMilliseconds(options.duration) } - if (options.repeatDelay) { options.repeatDelay = secondsToMilliseconds(options.repeatDelay) } + if (options.from !== undefined) { + options.keyframes[0] = options.from + } + + let shouldSkip = false + + if ((options as any).type === false) { + options.duration = 0 + if (options.delay === 0) { + shouldSkip = true + } + } + if ( - !isOriginAnimatable || - !isTargetAnimatable || instantAnimationState.current || - valueTransition.type === false || MotionGlobalConfig.skipAnimations ) { - /** - * If we can't animate this value, or the global instant animation flag is set, - * or this is simply defined as an instant transition, return an instant transition. - */ - return createInstantAnimation( - instantAnimationState.current - ? { ...options, delay: 0 } - : options - ) + shouldSkip = true + options.duration = 0 + options.delay = 0 } /** - * Animate via WAAPI if possible. + * If we can or must skip creating the animation, and apply only + * the final keyframe, do so. We also check once keyframes are resolved but + * this early check prevents the need to create an animation at all. */ - 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. - */ - !transition.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, - valueName, - options + if (shouldSkip && !isHandoff && value.get() !== undefined) { + const finalKeyframe = getFinalKeyframe( + options.keyframes as V[], + valueTransition ) - if (acceleratedAnimation) return acceleratedAnimation + if (finalKeyframe !== undefined) { + frame.update(() => { + options.onUpdate!(finalKeyframe) + options.onComplete!() + }) + + return + } } /** - * If we didn't create an accelerated animation, create a JS animation + * 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. */ - return animateValue(options) + if (!isHandoff && AcceleratedAnimation.supports(options)) { + return new AcceleratedAnimation(options) + } else { + return new MainThreadAnimation(options) + } } -} diff --git a/packages/framer-motion/src/animation/interfaces/single-value.ts b/packages/framer-motion/src/animation/interfaces/single-value.ts index 43533a6f8c..ccd18a64b5 100644 --- a/packages/framer-motion/src/animation/interfaces/single-value.ts +++ b/packages/framer-motion/src/animation/interfaces/single-value.ts @@ -4,16 +4,14 @@ import { isMotionValue } from "../../value/utils/is-motion-value" import { GenericKeyframesTarget } from "../../types" import { AnimationPlaybackControls, ValueAnimationTransition } from "../types" -export function animateSingleValue( +export function animateSingleValue( value: MotionValue | V, keyframes: V | GenericKeyframesTarget, options?: ValueAnimationTransition ): AnimationPlaybackControls { const motionValue = isMotionValue(value) ? value : createMotionValue(value) - motionValue.start( - animateMotionValue("", motionValue, keyframes as any, options) - ) + motionValue.start(animateMotionValue("", motionValue, keyframes, options)) return motionValue.animation! } diff --git a/packages/framer-motion/src/animation/interfaces/visual-element-target.ts b/packages/framer-motion/src/animation/interfaces/visual-element-target.ts index 3a1d803c41..f8b4b19d58 100644 --- a/packages/framer-motion/src/animation/interfaces/visual-element-target.ts +++ b/packages/framer-motion/src/animation/interfaces/visual-element-target.ts @@ -1,7 +1,7 @@ import { transformProps } from "../../render/html/utils/transform" import type { AnimationTypeState } from "../../render/utils/animation-state" import type { VisualElement } from "../../render/VisualElement" -import type { Target, TargetAndTransition } from "../../types" +import type { TargetAndTransition } from "../../types" import { optimizedAppearDataAttribute } from "../optimized-appear/data-id" import type { VisualElementAnimationOptions } from "./types" import { animateMotionValue } from "./motion-value" @@ -9,7 +9,6 @@ import { isWillChangeMotionValue } from "../../value/use-will-change/is" import { setTarget } from "../../render/utils/setters" import { AnimationPlaybackControls } from "../types" import { getValueTransition } from "../utils/transitions" -import { MotionValue } from "../../value" import { frame } from "../../frameloop" /** @@ -29,28 +28,16 @@ function shouldBlockAnimation( return shouldBlock } -function hasKeyframesChanged(value: MotionValue, target: Target) { - const current = value.get() - - if (Array.isArray(target)) { - for (let i = 0; i < target.length; i++) { - if (target[i] !== current) return true - } - } else { - return current !== target - } -} - export function animateTarget( visualElement: VisualElement, - definition: TargetAndTransition, + targetAndTransition: TargetAndTransition, { delay = 0, transitionOverride, type }: VisualElementAnimationOptions = {} ): AnimationPlaybackControls[] { let { transition = visualElement.getDefaultTransition(), transitionEnd, ...target - } = visualElement.makeTargetAnimatable(definition) + } = targetAndTransition const willChange = visualElement.getValue("willChange") @@ -64,11 +51,13 @@ export function animateTarget( visualElement.animationState.getState()[type] for (const key in target) { - const value = visualElement.getValue(key) + const value = visualElement.getValue( + key, + visualElement.latestValues[key] ?? null + ) const valueTarget = target[key] if ( - !value || valueTarget === undefined || (animationTypeState && shouldBlockAnimation(animationTypeState, key)) @@ -86,47 +75,21 @@ export function animateTarget( * If this is the first time a value is being animated, check * to see if we're handling off from an existing animation. */ + let isHandoff = false if (window.HandoffAppearAnimations) { const appearId = visualElement.getProps()[optimizedAppearDataAttribute] if (appearId) { - const elapsed = window.HandoffAppearAnimations( - appearId, - key, - value, - frame - ) + const elapsed = window.HandoffAppearAnimations(appearId, key) if (elapsed !== null) { valueTransition.elapsed = elapsed - valueTransition.isHandoff = true + isHandoff = true } } } - let canSkip = - !valueTransition.isHandoff && - !hasKeyframesChanged(value, valueTarget) - - if ( - valueTransition.type === "spring" && - (value.getVelocity() || valueTransition.velocity) - ) { - canSkip = false - } - - /** - * Temporarily disable skipping animations if there's an animation in - * progress. Better would be to track the current target of a value - * and compare that against valueTarget. - */ - if (value.animation) { - canSkip = false - } - - if (canSkip) continue - value.start( animateMotionValue( key, @@ -134,23 +97,29 @@ export function animateTarget( valueTarget, visualElement.shouldReduceMotion && transformProps.has(key) ? { type: false } - : valueTransition + : valueTransition, + visualElement, + isHandoff ) ) - const animation = value.animation! + const animation = value.animation - if (isWillChangeMotionValue(willChange)) { - willChange.add(key) - animation.then(() => willChange.remove(key)) - } + if (animation) { + if (isWillChangeMotionValue(willChange)) { + willChange.add(key) + animation.then(() => willChange.remove(key)) + } - animations.push(animation) + animations.push(animation) + } } if (transitionEnd) { Promise.all(animations).then(() => { - transitionEnd && setTarget(visualElement, transitionEnd) + frame.update(() => { + transitionEnd && setTarget(visualElement, transitionEnd) + }) }) } diff --git a/packages/framer-motion/src/animation/interfaces/visual-element-variant.ts b/packages/framer-motion/src/animation/interfaces/visual-element-variant.ts index 153732d19d..06dec1bb09 100644 --- a/packages/framer-motion/src/animation/interfaces/visual-element-variant.ts +++ b/packages/framer-motion/src/animation/interfaces/visual-element-variant.ts @@ -8,7 +8,14 @@ export function animateVariant( variant: string, options: VisualElementAnimationOptions = {} ) { - const resolved = resolveVariant(visualElement, variant, options.custom) + const resolved = resolveVariant( + visualElement, + variant, + options.type === "exit" + ? visualElement.presenceContext?.custom + : undefined + ) + let { transition = visualElement.getDefaultTransition() || {} } = resolved || {} diff --git a/packages/framer-motion/src/animation/interfaces/visual-element.ts b/packages/framer-motion/src/animation/interfaces/visual-element.ts index 2e3bc89f48..591a8ad351 100644 --- a/packages/framer-motion/src/animation/interfaces/visual-element.ts +++ b/packages/framer-motion/src/animation/interfaces/visual-element.ts @@ -1,3 +1,4 @@ +import { frame } from "../../frameloop" import { resolveVariant } from "../../render/utils/resolve-dynamic-variants" import { VisualElement } from "../../render/VisualElement" import { AnimationDefinition } from "../types" @@ -31,7 +32,9 @@ export function animateVisualElement( ) } - return animation.then(() => - visualElement.notify("AnimationComplete", definition) - ) + return animation.then(() => { + frame.postRender(() => { + visualElement.notify("AnimationComplete", definition) + }) + }) } diff --git a/packages/framer-motion/src/animation/optimized-appear/types.ts b/packages/framer-motion/src/animation/optimized-appear/types.ts index 17c07b791a..aa1686aaac 100644 --- a/packages/framer-motion/src/animation/optimized-appear/types.ts +++ b/packages/framer-motion/src/animation/optimized-appear/types.ts @@ -4,14 +4,8 @@ import type { MotionValue } from "../../value" export type HandoffFunction = ( storeId: string, valueName: string, - /** - * Legacy arguments. This function is inlined as part of SSG so it can be there's - * a version mismatch between the main included Motion and the inlined script. - * - * Remove in early 2024. - */ - _value: MotionValue, - _frame: Batcher + _value?: MotionValue, + _frame?: Batcher ) => null | number /** diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 659dbd26bc..eec9d71b83 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -1,10 +1,15 @@ 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" +import { MotionValue } from "../value" +import { + KeyframeResolver, + OnKeyframesResolved, +} from "../render/utils/KeyframesResolver" export interface AnimationPlaybackLifecycles { onUpdate?: (latest: V) => void @@ -29,13 +34,22 @@ export interface Transition export interface ValueAnimationTransition extends Transition, - AnimationPlaybackLifecycles { - isHandoff?: boolean -} + AnimationPlaybackLifecycles {} + +export type ResolveKeyframes = ( + keyframes: V[], + onComplete: OnKeyframesResolved, + name?: string, + motionValue?: any +) => KeyframeResolver -export interface ValueAnimationOptions +export interface ValueAnimationOptions extends ValueAnimationTransition { keyframes: V[] + KeyframeResolver?: typeof KeyframeResolver + name?: string + motionValue?: MotionValue + from?: V } export interface AnimationScope { @@ -153,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/animation/utils/__tests__/keyframes.test.ts b/packages/framer-motion/src/animation/utils/__tests__/keyframes.test.ts deleted file mode 100644 index 66b2380c71..0000000000 --- a/packages/framer-motion/src/animation/utils/__tests__/keyframes.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { motionValue } from "../../../value" -import { getKeyframes } from "../keyframes" - -describe("getKeyframes", () => { - test("Makes animatable 'none' from string target", () => { - const keyframes = getKeyframes( - motionValue("none"), - "transform", - "translateX(100px)", - {} - ) - expect(keyframes).toEqual(["translateX(0px)", "translateX(100px)"]) - }) - - test("Makes animatable 'none' from keyframes target", () => { - const a = getKeyframes( - motionValue("none"), - "transform", - [null, "translateX(100px)"], - {} - ) - expect(a).toEqual(["translateX(0px)", "translateX(100px)"]) - - const b = getKeyframes( - motionValue("none"), - "transform", - [null, "translateX(100px)", null], - {} - ) - expect(b).toEqual([ - "translateX(0px)", - "translateX(100px)", - "translateX(100px)", - ]) - }) - - test("Replaces 'none' within keyframes", () => { - const keyframes = getKeyframes( - motionValue("translateX(200px)"), - "transform", - ["none", "translateX(100px)"], - {} - ) - expect(keyframes).toEqual(["translateX(0px)", "translateX(100px)"]) - }) - - test("Fills wildcard keyframes", () => { - const keyframes = getKeyframes( - motionValue("none"), - "transform", - [null, null, "translateX(100px)", null], - {} - ) - expect(keyframes).toEqual([ - "translateX(0px)", - "translateX(0px)", - "translateX(100px)", - "translateX(100px)", - ]) - }) - - test("from overrides current motion value", () => { - const keyframes = getKeyframes( - motionValue("translateX(1px)"), - "transform", - [null, null, "translateX(100px)", null], - { from: "translateX(2px)" } - ) - expect(keyframes).toEqual([ - "translateX(2px)", - "translateX(2px)", - "translateX(100px)", - "translateX(100px)", - ]) - }) - - test("initial keyframe overrides from, if not null", () => { - const keyframes = getKeyframes( - motionValue("translateX(1px)"), - "transform", - ["translateX(3px)", null, "translateX(100px)", null], - { from: "translateX(2px)" } - ) - expect(keyframes).toEqual([ - "translateX(3px)", - "translateX(3px)", - "translateX(100px)", - "translateX(100px)", - ]) - }) - - test("Matches value type of origin keyframe if zero/none", () => { - const a = getKeyframes(motionValue(0), "transform", "50px", {}) - expect(a).toEqual(["0px", "50px"]) - const b = getKeyframes(motionValue("0"), "transform", "50px", {}) - expect(b).toEqual(["0px", "50px"]) - const c = getKeyframes(motionValue(2), "transform", [0, "50px"], {}) - expect(c).toEqual(["0px", "50px"]) - const d = getKeyframes(motionValue(2), "transform", ["0", "50px"], {}) - expect(d).toEqual(["0px", "50px"]) - const e = getKeyframes( - motionValue(2), - "transform", - ["none", "50px"], - {} - ) - expect(e).toEqual(["0px", "50px"]) - - const f = getKeyframes(motionValue("0px"), "transform", "50%", {}) - expect(f).toEqual(["0%", "50%"]) - }) - - test("Matches value type of subsequent keyframes if zero/none", () => { - const a = getKeyframes(motionValue("50px"), "transform", 0, {}) - expect(a).toEqual(["50px", "0px"]) - const b = getKeyframes(motionValue("50px"), "transform", "0", {}) - expect(b).toEqual(["50px", "0px"]) - const c = getKeyframes( - motionValue(""), - "transform", - [0, "50px", null], - {} - ) - expect(c).toEqual(["0px", "50px", "50px"]) - const d = getKeyframes( - motionValue(2), - "transform", - ["0", "50px", "none"], - {} - ) - expect(d).toEqual(["0px", "50px", "0px"]) - const e = getKeyframes( - motionValue(2), - "transform", - ["none", "50px"], - {} - ) - expect(e).toEqual(["0px", "50px"]) - }) - - test("Makes 0 motion value animatable to string", () => { - const keyframes = getKeyframes(motionValue(0), "transform", "0%", {}) - expect(keyframes).toEqual(["0%", "0%"]) - }) - - test("Makes 0 keyframe animatable to string", () => { - const keyframes = getKeyframes( - motionValue(0), - "transform", - [0, "0%"], - {} - ) - expect(keyframes).toEqual(["0%", "0%"]) - }) -}) diff --git a/packages/framer-motion/src/animation/utils/is-animatable.ts b/packages/framer-motion/src/animation/utils/is-animatable.ts index 27febc13f2..72ffe8a4c3 100644 --- a/packages/framer-motion/src/animation/utils/is-animatable.ts +++ b/packages/framer-motion/src/animation/utils/is-animatable.ts @@ -10,9 +10,12 @@ import { ValueKeyframesDefinition } from "../types" * * @internal */ -export const isAnimatable = (key: string, value: ValueKeyframesDefinition) => { +export const isAnimatable = ( + value: ValueKeyframesDefinition, + name?: string +) => { // If the list of keys tat might be non-animatable grows, replace with Set - if (key === "zIndex") return false + if (name === "zIndex") return false // If it's a number or a keyframes array, we can animate it. We might at some point // need to do a deep isAnimatable check of keyframes, or let Popmotion handle this, diff --git a/packages/framer-motion/src/animation/utils/is-none.ts b/packages/framer-motion/src/animation/utils/is-none.ts index 9eb0c3c0bd..39de76960a 100644 --- a/packages/framer-motion/src/animation/utils/is-none.ts +++ b/packages/framer-motion/src/animation/utils/is-none.ts @@ -5,5 +5,7 @@ export function isNone(value: string | number | null) { return value === 0 } else if (value !== null) { return value === "none" || value === "0" || isZeroValueString(value) + } else { + return true } } diff --git a/packages/framer-motion/src/animation/utils/keyframes.ts b/packages/framer-motion/src/animation/utils/keyframes.ts deleted file mode 100644 index c1dfe3e1ec..0000000000 --- a/packages/framer-motion/src/animation/utils/keyframes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getAnimatableNone } from "../../render/dom/value-types/animatable-none" -import { Transition } from "../../types" -import { MotionValue } from "../../value" -import { ValueKeyframesDefinition } from "../types" -import { isAnimatable } from "./is-animatable" -import { isNone } from "./is-none" - -export function getKeyframes( - value: MotionValue, - valueName: string, - target: ValueKeyframesDefinition, - transition: Transition -): string[] | number[] { - const isTargetAnimatable = isAnimatable(valueName, target) - let keyframes: ValueKeyframesDefinition - - if (Array.isArray(target)) { - keyframes = [...target] - } else { - keyframes = [null, target] - } - - const defaultOrigin = - transition.from !== undefined ? transition.from : value.get() - - let animatableTemplateValue: string | undefined = undefined - const noneKeyframeIndexes: number[] = [] - - for (let i = 0; i < keyframes.length; i++) { - /** - * Fill null/wildcard keyframes - */ - if (keyframes[i] === null) { - keyframes[i] = i === 0 ? defaultOrigin : keyframes[i - 1] - } - - if (isNone(keyframes[i])) { - noneKeyframeIndexes.push(i) - } - - // TODO: Clean this conditional, it works for now - if ( - typeof keyframes[i] === "string" && - keyframes[i] !== "none" && - keyframes[i] !== "0" - ) { - animatableTemplateValue = keyframes[i] as string - } - } - - if ( - isTargetAnimatable && - noneKeyframeIndexes.length && - animatableTemplateValue - ) { - for (let i = 0; i < noneKeyframeIndexes.length; i++) { - const index = noneKeyframeIndexes[i] - keyframes[index] = getAnimatableNone( - valueName, - animatableTemplateValue - ) - } - } - - return keyframes as string[] | number[] -} diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index eb39373019..d8e699e48f 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -11,6 +11,7 @@ import { } from "../../.." import { motionValue } from "../../../value" import { ResolvedValues } from "../../../render/types" +import { nextFrame } from "../../../gestures/__tests__/utils" describe("AnimatePresence", () => { test("Allows initial animation if no `initial` prop defined", async () => { @@ -23,9 +24,13 @@ describe("AnimatePresence", () => { animate={{ x: 100 }} style={{ x }} exit={{ x: 0 }} - onAnimationStart={() => - frame.postRender(() => resolve(x.get())) - } + onAnimationStart={() => { + frame.postRender(() => { + frame.postRender(() => { + resolve(x.get()) + }) + }) + }} /> ) @@ -112,7 +117,7 @@ describe("AnimatePresence", () => { }) test("Allows nested exit animations", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacity = motionValue(0) const Component = ({ isOpen }: any) => { return ( @@ -133,10 +138,16 @@ describe("AnimatePresence", () => { const { rerender } = render() rerender() + + await nextFrame() + expect(opacity.get()).toBe(0.9) rerender() rerender() - setTimeout(() => resolve(opacity.get()), 50) + + await nextFrame() + + resolve(opacity.get()) }) const opacity = await promise @@ -494,6 +505,8 @@ describe("AnimatePresence", () => { rerender() }) + await nextFrame() + expect(x.get()).toBe(200) }) @@ -503,7 +516,7 @@ describe("AnimatePresence", () => { exit: { opacity: 0, transition: { type: false } }, } - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacity = motionValue(1) const Component = ({ isVisible }: { isVisible: boolean }) => { return ( @@ -531,6 +544,8 @@ describe("AnimatePresence", () => { rerender() + await nextFrame() + resolve(opacity.get()) }) @@ -776,6 +791,8 @@ describe("AnimatePresence with custom components", () => { rerender() }) + await nextFrame() + expect(x.get()).toBe(200) }) @@ -845,10 +862,14 @@ describe("AnimatePresence with custom components", () => { rerender() }) + await nextFrame() + await act(async () => { rerender() }) + await nextFrame() + expect([xParent.get(), xChild.get()]).toEqual([200, 200]) }) @@ -887,11 +908,14 @@ describe("AnimatePresence with custom components", () => { rerender() }) + await nextFrame() + expect(opacity.get()).toBe(0) }) test("Sibling AnimatePresence wrapped in LayoutGroup remove exiting elements", async () => { - const opacity = motionValue(1) + const opacityA = motionValue(1) + const opacityB = motionValue(1) const Component = ({ isVisible }: { isVisible: boolean }) => { return ( @@ -902,7 +926,7 @@ describe("AnimatePresence with custom components", () => { data-testid="a" exit={{ opacity: 0 }} transition={{ type: false }} - style={{ opacity }} + style={{ opacity: opacityA }} /> )} @@ -912,7 +936,7 @@ describe("AnimatePresence with custom components", () => { data-testid="b" exit={{ opacity: 0 }} transition={{ type: false }} - style={{ opacity }} + style={{ opacity: opacityB }} /> )} @@ -934,8 +958,12 @@ describe("AnimatePresence with custom components", () => { rerender() }) + await nextFrame() + await new Promise((resolve) => { setTimeout(() => { + expect(opacityA.get()).toBe(0) + expect(opacityB.get()).toBe(0) expect(queryByTestId("a")).toBe(null) expect(queryByTestId("b")).toBe(null) resolve() diff --git a/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx b/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx index c199eb35e2..fd670a6429 100644 --- a/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx +++ b/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx @@ -3,6 +3,7 @@ import { motion } from "../../../render/dom/motion" import { MotionConfig } from "../" import * as React from "react" import { motionValue } from "../../../value" +import { nextFrame } from "../../../gestures/__tests__/utils" describe("custom properties", () => { test("renders", () => { @@ -48,7 +49,7 @@ describe("reducedMotion", () => { }) test("reducedMotion makes transforms animate instantly", async () => { - const result = await new Promise<[number, number]>((resolve) => { + const result = await new Promise<[number, number]>(async (resolve) => { const x = motionValue(0) const opacity = motionValue(0) const Component = () => { @@ -65,6 +66,9 @@ describe("reducedMotion", () => { const { rerender } = render() rerender() + + await nextFrame() + resolve([x.get(), opacity.get()]) }) diff --git a/packages/framer-motion/src/frameloop/batcher.ts b/packages/framer-motion/src/frameloop/batcher.ts index 75e99aa5ef..07bdcc347f 100644 --- a/packages/framer-motion/src/frameloop/batcher.ts +++ b/packages/framer-motion/src/frameloop/batcher.ts @@ -3,12 +3,12 @@ import { createRenderStep } from "./render-step" import { Batcher, Process, StepId, Steps, FrameData } from "./types" export const stepsOrder: StepId[] = [ - "prepare", - "read", - "update", - "preRender", - "render", - "postRender", + "read", // Read + "resolveKeyframes", // Write/Read/Write/Read + "update", // Compute + "preRender", // Compute + "render", // Write + "postRender", // Compute ] const maxElapsed = 40 @@ -69,6 +69,7 @@ export function createRenderBatcher( const step = steps[key] acc[key] = (process: Process, keepAlive = false, immediate = false) => { if (!runNextFrame) wake() + return step.schedule(process, keepAlive, immediate) } return acc diff --git a/packages/framer-motion/src/frameloop/types.ts b/packages/framer-motion/src/frameloop/types.ts index 4781b4aa4c..cb3b075b14 100644 --- a/packages/framer-motion/src/frameloop/types.ts +++ b/packages/framer-motion/src/frameloop/types.ts @@ -13,8 +13,8 @@ export interface Step { } export type StepId = - | "prepare" | "read" + | "resolveKeyframes" | "update" | "preRender" | "render" diff --git a/packages/framer-motion/src/gestures/__tests__/focus.test.tsx b/packages/framer-motion/src/gestures/__tests__/focus.test.tsx index 13069f22bc..2ad026aae3 100644 --- a/packages/framer-motion/src/gestures/__tests__/focus.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/focus.test.tsx @@ -1,10 +1,11 @@ import { focus, blur, render } from "../../../jest.setup" import * as React from "react" import { motion, motionValue } from "../../" +import { nextFrame } from "./utils" describe("focus", () => { test("whileFocus applied", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacity = motionValue(1) const ref = React.createRef() const Component = () => ( @@ -25,6 +26,8 @@ describe("focus", () => { focus(container, "myAnchorElement") + await nextFrame() + resolve(opacity.get()) }) @@ -60,7 +63,7 @@ describe("focus", () => { }) test("whileFocus applied if focus-visible selector throws unsupported", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacity = motionValue(1) const ref = React.createRef() const Component = () => ( @@ -87,6 +90,8 @@ describe("focus", () => { focus(container, "myAnchorElement") + await nextFrame() + resolve(opacity.get()) }) @@ -95,7 +100,7 @@ describe("focus", () => { test("whileFocus applied as variant", async () => { const target = 0.5 - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const variant = { hidden: { opacity: target }, } @@ -120,6 +125,8 @@ describe("focus", () => { focus(container, "myAnchorElement") + await nextFrame() + resolve(opacity.get()) }) @@ -127,7 +134,7 @@ describe("focus", () => { }) test("whileFocus is unapplied when blur", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const ref = React.createRef() const variant = { hidden: { opacity: 0.5, transitionEnd: { opacity: 0.75 } }, @@ -157,6 +164,7 @@ describe("focus", () => { ref.current!.matches = () => true focus(container, "myAnchorElement") + await nextFrame() setTimeout(() => { blurred = true blur(container, "myAnchorElement") diff --git a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx index 7d5a398428..ec777408d6 100644 --- a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx @@ -8,6 +8,7 @@ import * as React from "react" import { motion } from "../../" import { motionValue } from "../../value" import { frame } from "../../frameloop" +import { nextFrame } from "./utils" describe("hover", () => { test("hover event listeners fire", async () => { @@ -51,7 +52,7 @@ describe("hover", () => { }) test("whileHover applied", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacity = motionValue(1) const Component = () => ( { pointerEnter(container.firstChild as Element) + await nextFrame() + resolve(opacity.get()) }) @@ -74,7 +77,7 @@ describe("hover", () => { test("whileHover applied as variant", async () => { const target = 0.5 - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const variant = { hidden: { opacity: target }, } @@ -93,6 +96,8 @@ describe("hover", () => { pointerEnter(container.firstChild as Element) + await nextFrame() + resolve(opacity.get()) }) @@ -101,7 +106,7 @@ describe("hover", () => { test("whileHover propagates to children", async () => { const target = 0.2 - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const parent = { hidden: { opacity: 0.8 }, } @@ -128,6 +133,8 @@ describe("hover", () => { const { container } = render() pointerEnter(container.firstChild as Element) + + await nextFrame() resolve(opacity.get()) }) @@ -135,7 +142,7 @@ describe("hover", () => { }) test("whileHover is unapplied when hover ends", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const variant = { hidden: { opacity: 0.5, transitionEnd: { opacity: 0.75 } }, } @@ -159,6 +166,8 @@ describe("hover", () => { ) pointerEnter(container.firstChild as Element) + + await nextFrame() setTimeout(() => { hasMousedOut = true pointerLeave(container.firstChild as Element) @@ -169,7 +178,7 @@ describe("hover", () => { }) test("whileHover only animates values that arent being controlled by a higher-priority gesture ", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const variant = { hovering: { opacity: 0.5, scale: 0.5 }, tapping: { scale: 2 }, @@ -189,9 +198,14 @@ describe("hover", () => { const { container, rerender } = render() rerender() + await nextFrame() pointerDown(container.firstChild as Element) + + await nextFrame() pointerEnter(container.firstChild as Element) + await nextFrame() + resolve([opacity.get(), scale.get()]) }) diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 77ebe730b2..81adba85e9 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -341,7 +341,7 @@ describe("press", () => { }) test("press gesture variant applies and unapplies", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -355,16 +355,18 @@ describe("press", () => { ) const { container } = render() - + await nextFrame() logOpacity() // 0.5 // Trigger mouse down pointerDown(container.firstChild as Element) + await nextFrame() logOpacity() // 1 // Trigger mouse up pointerUp(container.firstChild as Element) + await nextFrame() logOpacity() // 0.5 resolve(opacityHistory) @@ -374,7 +376,7 @@ describe("press", () => { }) test("press gesture variant applies and unapplies via keyboard", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -389,14 +391,17 @@ describe("press", () => { const { container } = render() + await nextFrame() logOpacity() // 0.5 fireEvent.focus(container.firstChild as Element) fireEvent.keyDown(container.firstChild as Element, enterKey) + await nextFrame() logOpacity() // 1 fireEvent.keyUp(container.firstChild as Element, enterKey) + await nextFrame() logOpacity() // 0.5 resolve(opacityHistory) @@ -406,7 +411,7 @@ describe("press", () => { }) test("press gesture variant applies and unapplies via blur cancel", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -421,14 +426,17 @@ describe("press", () => { const { container } = render() + await nextFrame() logOpacity() // 0.5 fireEvent.focus(container.firstChild as Element) fireEvent.keyDown(container.firstChild as Element, enterKey) + await nextFrame() logOpacity() // 1 fireEvent.blur(container.firstChild as Element) + await nextFrame() logOpacity() // 0.5 resolve(opacityHistory) @@ -438,7 +446,7 @@ describe("press", () => { }) test("press gesture variant unapplies children", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -455,15 +463,17 @@ describe("press", () => { const { getByTestId } = render() + await nextFrame() logOpacity() // 0.5 // Trigger mouse down pointerDown(getByTestId("child") as Element) - + await nextFrame() logOpacity() // 1 // Trigger mouse up pointerUp(getByTestId("child") as Element) + await nextFrame() logOpacity() // 0.5 resolve(opacityHistory) }) @@ -472,7 +482,7 @@ describe("press", () => { }) test("press gesture on children returns to parent-defined variant", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -494,15 +504,18 @@ describe("press", () => { const { rerender, getByTestId } = render() rerender() + await nextFrame() logOpacity() // 1 // Trigger mouse down pointerDown(getByTestId("child") as Element) + await nextFrame() logOpacity() // 0.5 // Trigger mouse up pointerUp(getByTestId("child") as Element) + await nextFrame() logOpacity() // 1 resolve(opacityHistory) }) @@ -556,7 +569,7 @@ describe("press", () => { }) test("press gesture variant applies and unapplies with whileHover", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -573,38 +586,47 @@ describe("press", () => { const { container, rerender } = render() rerender() + await nextFrame() logOpacity() // 0.5 // Trigger hover pointerEnter(container.firstChild as Element) + await nextFrame() logOpacity() // 0.75 // Trigger mouse down pointerDown(container.firstChild as Element) + await nextFrame() logOpacity() // 1 // Trigger mouse up pointerUp(container.firstChild as Element) + await nextFrame() logOpacity() // 0.75 // Trigger hover end pointerLeave(container.firstChild as Element) + await nextFrame() logOpacity() // Trigger hover pointerEnter(container.firstChild as Element) + await nextFrame() logOpacity() // Trigger mouse down pointerDown(container.firstChild as Element) + await nextFrame() logOpacity() // Trigger hover end pointerLeave(container.firstChild as Element) + await nextFrame() logOpacity() // Trigger mouse up pointerUp(container.firstChild as Element) + await nextFrame() logOpacity() resolve(opacityHistory) @@ -616,7 +638,7 @@ describe("press", () => { }) test("press gesture variant applies and unapplies as state changes", () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const opacityHistory: number[] = [] const opacity = motionValue(0.5) const logOpacity = () => opacityHistory.push(opacity.get()) @@ -638,40 +660,58 @@ describe("press", () => { ) rerender() + await nextFrame() + logOpacity() // 0.5 // Trigger hover pointerEnter(container.firstChild as Element) + + await nextFrame() logOpacity() // 0.75 // Trigger mouse down pointerDown(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 rerender() rerender() // Trigger mouse up pointerUp(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 // Trigger hover end pointerLeave(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 // Trigger hover pointerEnter(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 // Trigger mouse down pointerDown(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 // Trigger hover end pointerLeave(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 // Trigger mouse up pointerUp(container.firstChild as Element) + + await nextFrame() logOpacity() // 1 resolve(opacityHistory) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index b0f69416d0..66d8c9e8ca 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -31,7 +31,6 @@ import { calcLength } from "../../projection/geometry/delta-calc" import { mixNumber } from "../../utils/mix/number" import { percent } from "../../value/types/numbers/units" import { animateMotionValue } from "../../animation/interfaces/motion-value" -import { frame } from "../../frameloop" import { getContextWindow } from "../../utils/get-context-window" export const elementDragControls = new WeakMap< @@ -153,9 +152,7 @@ export class VisualElementDragControls { }) // Fire onDragStart event - if (onDragStart) { - frame.update(() => onDragStart(event, info), false, true) - } + if (onDragStart) onDragStart(event, info) const { animationState } = this.visualElement animationState && animationState.setActive("whileDrag", true) @@ -243,9 +240,7 @@ export class VisualElementDragControls { this.startAnimation(velocity) const { onDragEnd } = this.getProps() - if (onDragEnd) { - frame.update(() => onDragEnd(event, info)) - } + if (onDragEnd) onDragEnd(event, info) } private cancel() { @@ -441,7 +436,13 @@ export class VisualElementDragControls { ) { const axisValue = this.getAxisMotionValue(axis) return axisValue.start( - animateMotionValue(axis, axisValue, 0, transition) + animateMotionValue( + axis, + axisValue, + 0, + transition, + this.visualElement + ) ) } diff --git a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx index f5826f416c..751a1d8192 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -496,6 +496,8 @@ describe("dragging", () => { expect(opacity.get()).toBe(0.5) await pointer.to(10, 200) pointer.end() + + await nextFrame() expect(opacity.get()).toBe(0) }) diff --git a/packages/framer-motion/src/gestures/hover.ts b/packages/framer-motion/src/gestures/hover.ts index d2a5f5aa17..4d153f9193 100644 --- a/packages/framer-motion/src/gestures/hover.ts +++ b/packages/framer-motion/src/gestures/hover.ts @@ -4,7 +4,6 @@ import { isDragActive } from "./drag/utils/lock" import { EventInfo } from "../events/types" import type { VisualElement } from "../render/VisualElement" import { Feature } from "../motion/features/Feature" -import { frame } from "../frameloop" function addHoverEvent(node: VisualElement, isActive: boolean) { const eventName = "pointer" + (isActive ? "enter" : "leave") @@ -20,7 +19,7 @@ function addHoverEvent(node: VisualElement, isActive: boolean) { } if (props[callbackName]) { - frame.update(() => props[callbackName](event, info)) + props[callbackName](event, info) } } diff --git a/packages/framer-motion/src/gestures/pan/index.ts b/packages/framer-motion/src/gestures/pan/index.ts index 68e79dd904..8a75c563bf 100644 --- a/packages/framer-motion/src/gestures/pan/index.ts +++ b/packages/framer-motion/src/gestures/pan/index.ts @@ -2,14 +2,13 @@ import { PanInfo, PanSession } from "./PanSession" import { addPointerEvent } from "../../events/add-pointer-event" import { Feature } from "../../motion/features/Feature" import { noop } from "../../utils/noop" -import { frame } from "../../frameloop" import { getContextWindow } from "../../utils/get-context-window" type PanEventHandler = (event: PointerEvent, info: PanInfo) => void const asyncHandler = (handler?: PanEventHandler) => (event: PointerEvent, info: PanInfo) => { if (handler) { - frame.update(() => handler(event, info)) + handler(event, info) } } @@ -39,9 +38,7 @@ export class PanGesture extends Feature { onMove: onPan, onEnd: (event: PointerEvent, info: PanInfo) => { delete this.session - if (onPanEnd) { - frame.update(() => onPanEnd(event, info)) - } + if (onPanEnd) onPanEnd(event, info) }, } } diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index 5561fb0743..f0e86b9276 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -10,7 +10,6 @@ import { pipe } from "../utils/pipe" import { isDragActive } from "./drag/utils/lock" import { isNodeOrChild } from "./utils/is-node-or-child" import { noop } from "../utils/noop" -import { frame } from "../frameloop" function fireSyntheticPointerEvent( name: string, @@ -41,7 +40,7 @@ export class PressGesture extends Feature { } if (onTapStart) { - frame.update(() => onTapStart(event, info)) + onTapStart(event, info) } } @@ -76,16 +75,14 @@ export class PressGesture extends Feature { const { onTap, onTapCancel, globalTapTarget } = this.node.getProps() - frame.update(() => { - /** - * We only count this as a tap gesture if the event.target is the same - * as, or a child of, this component's element - */ - !globalTapTarget && - !isNodeOrChild(this.node.current, endEvent.target as Element) - ? onTapCancel && onTapCancel(endEvent, endInfo) - : onTap && onTap(endEvent, endInfo) - }) + /** + * We only count this as a tap gesture if the event.target is the same + * as, or a child of, this component's element + */ + !globalTapTarget && + !isNodeOrChild(this.node.current, endEvent.target as Element) + ? onTapCancel && onTapCancel(endEvent, endInfo) + : onTap && onTap(endEvent, endInfo) } const removePointerUpListener = addPointerEvent( @@ -115,9 +112,7 @@ export class PressGesture extends Feature { if (!this.checkPressEnd()) return const { onTapCancel } = this.node.getProps() - if (onTapCancel) { - frame.update(() => onTapCancel(event, info)) - } + if (onTapCancel) onTapCancel(event, info) } private startAccessiblePress = () => { @@ -129,9 +124,7 @@ export class PressGesture extends Feature { fireSyntheticPointerEvent("up", (event, info) => { const { onTap } = this.node.getProps() - if (onTap) { - frame.update(() => onTap(event, info)) - } + if (onTap) onTap(event, info) }) } 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/motion/__tests__/animate-prop.test.tsx b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx index 47db2497bd..1f00b1d8b2 100644 --- a/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx @@ -8,7 +8,7 @@ import { } from "../../" import * as React from "react" import { createRef } from "react" -import { nextFrame, nextMicrotask } from "../../gestures/__tests__/utils" +import { nextFrame } from "../../gestures/__tests__/utils" describe("animate prop as object", () => { test("animates to set prop", async () => { @@ -101,7 +101,7 @@ describe("animate prop as object", () => { return expect(promise).resolves.toBe(true) }) test("uses transitionEnd on subsequent renders", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const x = motionValue(0) const Component = ({ animate }: any) => ( @@ -133,7 +133,11 @@ describe("animate prop as object", () => { }} /> ) - requestAnimationFrame(() => resolve(x.get())) + + await nextFrame() + await nextFrame() + + resolve(x.get()) }) return expect(promise).resolves.toBe(300) }) @@ -262,7 +266,7 @@ describe("animate prop as object", () => { return expect(promise).resolves.toHaveStyle("font-weight: 100") }) test("doesn't animate no-op values", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { let isAnimating = false const Component = () => ( { const { rerender } = render() rerender() - frame.postRender(() => { - frame.postRender(() => resolve(isAnimating)) - }) + await nextFrame() + await nextFrame() + + resolve(isAnimating) }) return expect(promise).resolves.toBe(false) }) test("doesn't animate no-op keyframes", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { let isAnimating = false const Component = () => ( { const { rerender } = render() rerender() - frame.postRender(() => { - frame.postRender(() => resolve(isAnimating)) - }) + await nextFrame() + await nextFrame() + + resolve(isAnimating) }) return expect(promise).resolves.toBe(false) }) - test("doe animate different keyframes", async () => { - const promise = new Promise((resolve) => { + test("does animate different keyframes", async () => { + const promise = new Promise(async (resolve) => { let isAnimating = false const Component = () => ( { const { rerender } = render() rerender() - frame.postRender(() => { - frame.postRender(() => resolve(isAnimating)) - }) + await nextFrame() + await nextFrame() + + resolve(isAnimating) }) return expect(promise).resolves.toBe(true) @@ -377,13 +384,13 @@ describe("animate prop as object", () => { return expect(promise).resolves.toBe(true) }) test("doesn't animate zIndex", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const Component = () => const { container, rerender } = render() rerender() - requestAnimationFrame(() => - resolve(container.firstChild as Element) - ) + + await nextFrame() + resolve(container.firstChild as Element) }) return expect(promise).resolves.toHaveStyle("z-index: 100") }) @@ -410,7 +417,7 @@ describe("animate prop as object", () => { rerender() rerender() - await nextMicrotask() + await nextFrame() expect(ref.current).toHaveStyle("opacity: 0") @@ -452,7 +459,7 @@ describe("animate prop as object", () => { /> ) - await nextMicrotask() + await nextFrame() expect(ref.current).toHaveStyle("opacity: 0.5") @@ -762,7 +769,7 @@ describe("animate prop as object", () => { return expect(promise).resolves.toBe(20) }) - test("animates previously unseen properties", async () => { + test("animates previously unseen properties, instant animation", async () => { const Component = ({ animate }: any) => ( ) @@ -770,10 +777,31 @@ describe("animate prop as object", () => { ) rerender() + + rerender() + rerender() + + await nextFrame() + + return expect(container.firstChild as Element).toHaveStyle( + "transform: translateX(0px) translateY(100px) translateZ(0)" + ) + }) + + test("animates previously unseen properties", async () => { + const Component = ({ animate }: any) => ( + + ) + const { container, rerender } = render( + + ) + rerender() + rerender() rerender() - await nextMicrotask() + await nextFrame() + await nextFrame() return expect(container.firstChild as Element).toHaveStyle( "transform: translateX(0px) translateY(100px) translateZ(0)" @@ -801,11 +829,17 @@ describe("animate prop as object", () => { test("animates previously unseen CSS variables", async () => { const promise = new Promise((resolve) => { + let latestColor = "" const Component = () => ( resolve(latest["--foo"] as string)} + onUpdate={(latest) => { + latestColor = latest["--foo"] as string + }} + onAnimationComplete={() => { + resolve(latestColor) + }} transition={{ type: false }} /> ) @@ -817,7 +851,7 @@ describe("animate prop as object", () => { }) test("forces an animation to fallback if has been set to `null`", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const complete = () => resolve(true) const Component = ({ animate, onAnimationComplete }: any) => ( { const { container, rerender } = render( ) + await nextFrame() rerender() rerender() + await nextFrame() expect(container.firstChild as Element).toHaveStyle( "transform: none" ) @@ -941,9 +977,9 @@ describe("animate prop as object", () => { ref={ref} initial={{ backgroundColor: "#0088ff" }} animate={{ backgroundColor: "hsl(345, 100%, 60%)" }} - onAnimationComplete={() => + onAnimationComplete={() => { ref.current && resolve(ref.current) - } + }} transition={{ duration: 0.01 }} /> ) diff --git a/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx b/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx index 7e6b975fde..24fbff1734 100644 --- a/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx @@ -1,7 +1,7 @@ import { render } from "../../../jest.setup" import { motion, motionValue, useMotionValue, useTransform } from "../../" import * as React from "react" -import { nextMicrotask } from "../../gestures/__tests__/utils" +import { nextFrame } from "../../gestures/__tests__/utils" describe("SVG", () => { test("doesn't add translateZ", () => { @@ -24,7 +24,7 @@ describe("SVG", () => { render() }) - test("recognises MotionValues in attributes", () => { + test("recognises MotionValues in attributes", async () => { let r = motionValue(0) let fill = motionValue("#000") @@ -49,6 +49,8 @@ describe("SVG", () => { const { rerender } = render() rerender() + await nextFrame() + expect(r.get()).toBe(100) expect(fill.get()).toBe("rgba(255, 0, 0, 1)") }) @@ -65,11 +67,14 @@ describe("SVG", () => { render() }) - test("doesn't calculate transformOrigin for elements", () => { + test("doesn't calculate transformOrigin for elements", async () => { const Component = () => { return } const { container } = render() + + await nextFrame() + expect(container.firstChild as Element).not.toHaveStyle( "transform-origin: 0px 0px" ) @@ -93,7 +98,7 @@ describe("SVG", () => { render() }) - test("doesn't read viewBox as '0 0 0 0'", () => { + test("doesn't read viewBox as '0 0 0 0'", async () => { const Component = () => { return ( { ) } const { container } = render() + + await nextFrame() + expect(container.firstChild as Element).toHaveAttribute( "viewBox", "0 0 100 100" @@ -121,7 +129,9 @@ describe("SVG", () => { ) } const { container } = render() - await nextMicrotask() + + await nextFrame() + expect(container.firstChild as Element).toHaveAttribute( "viewBox", "100 100 200 200" diff --git a/packages/framer-motion/src/motion/__tests__/variant.test.tsx b/packages/framer-motion/src/motion/__tests__/variant.test.tsx index 78fcc1b925..58d2b379bf 100644 --- a/packages/framer-motion/src/motion/__tests__/variant.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/variant.test.tsx @@ -508,7 +508,7 @@ describe("animate prop as variant", () => { }) test("nested controlled variants switch correctly", async () => { - const promise = new Promise((resolve) => { + const promise = new Promise(async (resolve) => { const parentOpacity = motionValue(0.2) const childOpacity = motionValue(0.1) @@ -539,16 +539,17 @@ describe("animate prop as variant", () => { } const { rerender } = render() - setTimeout(() => { - expect(parentOpacity.get()).toBe(0.4) - expect(childOpacity.get()).toBe(0.6) - rerender() + await nextFrame() + + expect(parentOpacity.get()).toBe(0.4) + expect(childOpacity.get()).toBe(0.6) + + rerender() - setTimeout(() => { - resolve([parentOpacity.get(), childOpacity.get()]) - }, 0) - }, 0) + await nextFrame() + + resolve([parentOpacity.get(), childOpacity.get()]) }) return expect(promise).resolves.toEqual([0.3, 0.5]) @@ -847,7 +848,7 @@ describe("animate prop as variant", () => { }).not.toThrowError() }) - test("new child items animate from initial to animate", () => { + test("new child items animate from initial to animate", async () => { const x = motionValue(0) const Component = ({ length }: { length: number }) => { const variants: Variants = { @@ -878,6 +879,8 @@ describe("animate prop as variant", () => { rerender() rerender() + await nextFrame() + expect(x.get()).toBe(100) }) @@ -1026,6 +1029,7 @@ describe("animate prop as variant", () => { rerender() await nextFrame() + expect(element).toHaveStyle("transform: none") }) diff --git a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx index 53b099ff8c..c00f3fb7a9 100644 --- a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx @@ -10,9 +10,10 @@ import * as React from "react" import { createRef } from "react" import { nextFrame } from "../../gestures/__tests__/utils" import "../../animation/animators/waapi/__tests__/setup" +import { act } from "react-dom/test-utils" describe("WAAPI animations", () => { - test("opacity animates with WAAPI at default settings", () => { + test("opacity animates with WAAPI at default settings", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -38,7 +41,7 @@ describe("WAAPI animations", () => { ) }) - test("filter animates with WAAPI at default settings", () => { + test("filter animates with WAAPI at default settings", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -67,7 +72,7 @@ describe("WAAPI animations", () => { ) }) - test("clipPath animates with WAAPI at default settings", () => { + test("clipPath animates with WAAPI at default settings", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -96,7 +103,7 @@ describe("WAAPI animations", () => { ) }) - test("Complex string type animates with WAAPI spring", () => { + test("Complex string type animates with WAAPI spring", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -126,7 +135,7 @@ describe("WAAPI animations", () => { ) }) - test("transform animates with WAAPI at default settings", () => { + test("transform animates with WAAPI at default settings", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -155,7 +166,8 @@ describe("WAAPI animations", () => { ) }) - test.skip("backgroundColor animates with WAAPI at default settings", () => { + // backgroundColor currently disabled for performance reasons + test.skip("backgroundColor animates with WAAPI at default settings", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -216,7 +230,7 @@ describe("WAAPI animations", () => { ) }) - test("opacity animates with WAAPI when no value is originally provided via initial", () => { + test("opacity animates with WAAPI when no value is originally provided via initial", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() }) - test("opacity animates with WAAPI at default settings with no initial value set", () => { + test("opacity animates with WAAPI at default settings with no initial value set", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() }) - test("opacity animates with WAAPI at default settings when layout is enabled", () => { + test("opacity animates with WAAPI at default settings when layout is enabled", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() }) @@ -272,13 +292,14 @@ describe("WAAPI animations", () => { setIsHovered(true)} - onHoverEnd={() => setIsHovered(false)} + onHoverStart={() => act(() => setIsHovered(true))} + onHoverEnd={() => act(() => setIsHovered(false))} > ) @@ -286,10 +307,12 @@ describe("WAAPI animations", () => { const { container, rerender } = render() pointerEnter(container.firstChild as Element) + await nextFrame() await nextFrame() pointerLeave(container.firstChild as Element) await nextFrame() rerender() + await nextFrame() expect(ref.current!.animate).toBeCalledTimes(2) }) @@ -317,6 +340,7 @@ describe("WAAPI animations", () => { const { container, rerender } = render() pointerDown(container.firstChild as Element) + await nextFrame() await nextFrame() pointerUp(container.firstChild as Element) @@ -324,10 +348,12 @@ describe("WAAPI animations", () => { rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalledTimes(2) }) - test("WAAPI is called with expected arguments", () => { + test("WAAPI is called with expected arguments", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: [0.2, 0.9] }, @@ -361,7 +389,7 @@ describe("WAAPI animations", () => { ) }) - test("WAAPI is called with expected arguments with pre-generated keyframes", () => { + test("WAAPI is called with expected arguments with pre-generated keyframes", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], offset: undefined }, @@ -393,7 +423,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'easeIn' to 'ease-in'", () => { + test("Maps 'easeIn' to 'ease-in'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -422,7 +454,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'easeOut' to 'ease-out'", () => { + test("Maps 'easeOut' to 'ease-out'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -451,7 +485,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'easeInOut' to 'ease-in-out'", () => { + test("Maps 'easeInOut' to 'ease-in-out'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -480,7 +516,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'circIn' to 'cubic-bezier(0, 0.65, 0.55, 1)'", () => { + test("Maps 'circIn' to 'cubic-bezier(0, 0.65, 0.55, 1)'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -509,7 +547,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'circOut' to 'cubic-bezier(0.55, 0, 1, 0.45)'", () => { + test("Maps 'circOut' to 'cubic-bezier(0.55, 0, 1, 0.45)'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -538,7 +578,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'backIn' to 'cubic-bezier(0.31, 0.01, 0.66, -0.59)'", () => { + test("Maps 'backIn' to 'cubic-bezier(0.31, 0.01, 0.66, -0.59)'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -567,7 +608,7 @@ describe("WAAPI animations", () => { ) }) - test("Maps 'backOut' to 'cubic-bezier(0.33, 1.53, 0.69, 0.99)'", () => { + test("Maps 'backOut' to 'cubic-bezier(0.33, 1.53, 0.69, 0.99)'", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { opacity: [0, 1], offset: undefined }, @@ -596,7 +639,7 @@ describe("WAAPI animations", () => { ) }) - test("WAAPI is called with pre-generated spring keyframes", () => { + test("WAAPI is called with pre-generated spring keyframes", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -638,7 +683,7 @@ describe("WAAPI animations", () => { /** * TODO: We could not accelerate but scrub WAAPI animation if repeatDelay is defined */ - test("Doesn't animate with WAAPI if repeatDelay is defined", () => { + test("Doesn't animate with WAAPI if repeatDelay is defined", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).not.toBeCalled() }) - test("Pregenerates keyframes if ease is function", () => { + test("Pregenerates keyframes if ease is function", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -684,7 +733,7 @@ describe("WAAPI animations", () => { ) }) - test("Pregenerates keyframes if ease is anticipate", () => { + test("Pregenerates keyframes if ease is anticipate", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -717,7 +768,7 @@ describe("WAAPI animations", () => { ) }) - test("Pregenerates keyframes if ease is backInOut", () => { + test("Pregenerates keyframes if ease is backInOut", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -750,7 +803,7 @@ describe("WAAPI animations", () => { ) }) - test("Pregenerates keyframes if ease is circInOut", () => { + test("Pregenerates keyframes if ease is circInOut", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -783,7 +838,7 @@ describe("WAAPI animations", () => { ) }) - test("Doesn't animate with WAAPI if repeatType is defined as mirror", () => { + test("Doesn't animate with WAAPI if repeatType is defined as mirror", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() expect(ref.current!.animate).not.toBeCalled() }) - test("Doesn't animate with WAAPI if onUpdate is defined", () => { + test("Doesn't animate with WAAPI if onUpdate is defined", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).not.toBeCalled() }) - test("Doesn't animate with WAAPI if external motion value is defined", () => { + test("Doesn't animate with WAAPI if external motion value is defined", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).not.toBeCalled() }) - test("Animates with WAAPI if repeat is defined and we need to generate keyframes", () => { + test("Animates with WAAPI if repeat is defined and we need to generate keyframes", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { @@ -868,7 +930,7 @@ describe("WAAPI animations", () => { ) }) - test("Animates with WAAPI if repeat is Infinity and we need to generate keyframes", () => { + test("Animates with WAAPI if repeat is Infinity and we need to generate keyframes", async () => { const ref = createRef() const Component = () => ( { const { rerender } = render() rerender() + await nextFrame() + expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { diff --git a/packages/framer-motion/src/motion/features/animation/exit.ts b/packages/framer-motion/src/motion/features/animation/exit.ts index 12c99e340a..b7970ce5c8 100644 --- a/packages/framer-motion/src/motion/features/animation/exit.ts +++ b/packages/framer-motion/src/motion/features/animation/exit.ts @@ -8,7 +8,7 @@ export class ExitAnimationFeature extends Feature { update() { if (!this.node.presenceContext) return - const { isPresent, onExitComplete, custom } = this.node.presenceContext + const { isPresent, onExitComplete } = this.node.presenceContext const { isPresent: prevIsPresent } = this.node.prevPresenceContext || {} if (!this.node.animationState || isPresent === prevIsPresent) { @@ -17,8 +17,7 @@ export class ExitAnimationFeature extends Feature { const exitAnimation = this.node.animationState.setActive( "exit", - !isPresent, - { custom: custom ?? this.node.getProps().custom } + !isPresent ) if (onExitComplete && !isPresent) { 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/VisualElement.ts b/packages/framer-motion/src/render/VisualElement.ts index 34051371dd..838d82186c 100644 --- a/packages/framer-motion/src/render/VisualElement.ts +++ b/packages/framer-motion/src/render/VisualElement.ts @@ -10,7 +10,6 @@ import { MotionProps, MotionStyle } from "../motion/types" import { createBox } from "../projection/geometry/models" import { Box } from "../projection/geometry/types" import { IProjectionNode } from "../projection/node/types" -import { TargetAndTransition } from "../types" import { isRefObject } from "../utils/is-ref-object" import { initPrefersReducedMotion } from "../utils/reduced-motion" import { @@ -42,6 +41,16 @@ import { Feature } from "../motion/features/Feature" import type { PresenceContextProps } from "../context/PresenceContext" import { variantProps } from "./utils/variant-props" import { visualElementStore } from "./store" +import { + KeyframeResolver, + ResolvedKeyframes, + UnresolvedKeyframes, +} from "./utils/KeyframesResolver" +import { isNumericalString } from "../utils/is-numerical-string" +import { isZeroValueString } from "../utils/is-zero-value-string" +import { findValueType } from "./dom/value-types/find" +import { complex } from "../value/types/complex" +import { getAnimatableNone } from "./dom/value-types/animatable-none" const featureNames = Object.keys(featureDefinitions) const numFeatures = featureNames.length @@ -80,15 +89,6 @@ export abstract class VisualElement< */ abstract sortInstanceNodePosition(a: Instance, b: Instance): number - /** - * Take a target and make it animatable. For instance if provided - * height: "auto" we need to measure height in pixels and animate that instead. - */ - abstract makeTargetAnimatableFromInstance( - target: TargetAndTransition, - isLive: boolean - ): TargetAndTransition - /** * Measure the viewport-relative bounding box of the Instance. */ @@ -151,6 +151,24 @@ export abstract class VisualElement< projection?: IProjectionNode ): void + resolveKeyframes = ( + keyframes: UnresolvedKeyframes, + // We use an onComplete callback here rather than a Promise as a Promise + // resolution is a microtask and we want to retain the ability to force + // the resolution of keyframes synchronously. + onComplete: (resolvedKeyframes: ResolvedKeyframes) => void, + name: string, + value: MotionValue + ): KeyframeResolver => { + return new this.KeyframeResolver( + keyframes, + onComplete, + name, + value, + this + ) + } + /** * If the component child is provided as a motion value, handle subscriptions * with the renderer-specific VisualElement. @@ -260,6 +278,8 @@ export abstract class VisualElement< */ animationState?: AnimationState + KeyframeResolver = KeyframeResolver + /** * The options used to create this VisualElement. The Options type is defined * by the inheriting VisualElement and is passed straight through to the render functions. @@ -339,6 +359,7 @@ export abstract class VisualElement< props, presenceContext, reducedMotionConfig, + blockInitialAnimation, visualState, }: VisualElementOptions, options: Options = {} as any @@ -354,6 +375,7 @@ export abstract class VisualElement< this.depth = parent ? parent.depth + 1 : 0 this.reducedMotionConfig = reducedMotionConfig this.options = options + this.blockInitialAnimation = Boolean(blockInitialAnimation) this.isControllingVariants = checkIsControllingVariants(props) this.isVariantNode = checkIsVariantNode(props) @@ -450,8 +472,8 @@ export abstract class VisualElement< "change", (latestValue: string | number) => { this.latestValues[key] = latestValue - this.props.onUpdate && - frame.update(this.notifyUpdate, false, true) + + this.props.onUpdate && frame.preRender(this.notifyUpdate) if (valueIsTransform && this.projection) { this.projection.isTransformDirty = true @@ -633,20 +655,6 @@ export abstract class VisualElement< this.latestValues[key] = value } - /** - * Make a target animatable by Popmotion. For instance, if we're - * trying to animate width from 100px to 100vw we need to measure 100vw - * in pixels to determine what we really need to animate to. This is also - * pluggable to support Framer's custom value types like Color, - * and CSS variables. - */ - makeTargetAnimatable( - target: TargetAndTransition, - canMutate = true - ): TargetAndTransition { - return this.makeTargetAnimatableFromInstance(target, canMutate) - } - /** * Update the provided props. Ensure any newly-added motion values are * added to our map, old ones removed, and listeners updated. @@ -799,10 +807,10 @@ export abstract class VisualElement< * value, we'll create one if none exists. */ getValue(key: string): MotionValue | undefined - getValue(key: string, defaultValue: string | number): MotionValue + getValue(key: string, defaultValue: string | number | null): MotionValue getValue( key: string, - defaultValue?: string | number + defaultValue?: string | number | null ): MotionValue | undefined { if (this.props.values && this.props.values[key]) { return this.props.values[key] @@ -811,7 +819,10 @@ export abstract class VisualElement< let value = this.values.get(key) if (value === undefined && defaultValue !== undefined) { - value = motionValue(defaultValue, { owner: this }) + value = motionValue( + defaultValue === null ? undefined : defaultValue, + { owner: this } + ) this.addValue(key, value) } @@ -823,11 +834,28 @@ export abstract class VisualElement< * we need to check for it in our state and as a last resort read it * directly from the instance (which might have performance implications). */ - readValue(key: string) { - return this.latestValues[key] !== undefined || !this.current - ? this.latestValues[key] - : this.getBaseTargetFromProps(this.props, key) ?? + readValue(key: string, target?: string | number | null) { + let value = + this.latestValues[key] !== undefined || !this.current + ? this.latestValues[key] + : this.getBaseTargetFromProps(this.props, key) ?? this.readValueFromInstance(this.current, key, this.options) + + if (value !== undefined && value !== null) { + if ( + typeof value === "string" && + (isNumericalString(value) || isZeroValueString(value)) + ) { + // If this is a number read as a string, ie "0" or "200", convert it to a number + value = parseFloat(value) + } else if (!findValueType(value) && complex.test(target)) { + value = getAnimatableNone(key, target as string) + } + + this.setBaseTarget(key, isMotionValue(value) ? value.get() : value) + } + + return isMotionValue(value) ? value.get() : value } /** @@ -846,7 +874,11 @@ export abstract class VisualElement< const { initial } = this.props const valueFromInitial = typeof initial === "string" || typeof initial === "object" - ? resolveVariantFromProps(this.props, initial as any)?.[key] + ? resolveVariantFromProps( + this.props, + initial as any, + this.presenceContext?.custom + )?.[key] : undefined /** diff --git a/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts new file mode 100644 index 0000000000..0f37cfd9f6 --- /dev/null +++ b/packages/framer-motion/src/render/dom/DOMKeyframesResolver.ts @@ -0,0 +1,183 @@ +import { isNone } from "../../animation/utils/is-none" +import { getVariableValue } from "./utils/css-variables-conversion" +import { isCSSVariableToken } from "./utils/is-css-variable" +import { + isNumOrPxType, + positionalKeys, + positionalValues, + removeNonTranslationalTransform, +} from "./utils/unit-conversion" +import { findDimensionValueType } from "./value-types/dimensions" +import { + KeyframeResolver, + OnKeyframesResolved, + UnresolvedKeyframes, +} from "../utils/KeyframesResolver" +import { makeNoneKeyframesAnimatable } from "../html/utils/make-none-animatable" +import { VisualElement } from "../VisualElement" +import { MotionValue } from "../../value" + +export class DOMKeyframesResolver< + T extends string | number +> extends KeyframeResolver { + name: string + protected element: VisualElement + + private removedTransforms?: [string, string | number][] + private measuredOrigin?: string | number + private suspendedScrollY?: number + + constructor( + unresolvedKeyframes: UnresolvedKeyframes, + onComplete: OnKeyframesResolved, + name?: string, + motionValue?: MotionValue + ) { + super( + unresolvedKeyframes, + onComplete, + name, + motionValue, + motionValue?.owner as VisualElement, + true + ) + } + + readKeyframes() { + const { unresolvedKeyframes, element, name } = this + + if (!element.current) return + + const noneKeyframeIndexes: number[] = [] + + super.readKeyframes() + + /** + * If any keyframe is a CSS variable, we need to find its value by sampling the element + */ + for (let i = 0; i < unresolvedKeyframes.length; i++) { + const keyframe = unresolvedKeyframes[i] + if (typeof keyframe === "string" && isCSSVariableToken(keyframe)) { + const resolved = getVariableValue(keyframe, element.current) + + if (resolved !== undefined) { + unresolvedKeyframes[i] = resolved as T + } + } + + if (isNone(unresolvedKeyframes[i])) { + noneKeyframeIndexes.push(i) + } + } + + if (noneKeyframeIndexes.length) { + makeNoneKeyframesAnimatable( + unresolvedKeyframes, + noneKeyframeIndexes, + name + ) + } + + /** + * Check to see if unit type has changed. If so schedule jobs that will + * temporarily set styles to the destination keyframes. + * Skip if we have more than two keyframes or this isn't a positional value. + * TODO: We can throw if there are multiple keyframes and the value type changes. + */ + if (!positionalKeys.has(name) || unresolvedKeyframes.length !== 2) { + return + } + + const [origin, target] = unresolvedKeyframes + const originType = findDimensionValueType(origin) + const targetType = findDimensionValueType(target) + + /** + * Either we don't recognise these value types or we can animate between them. + */ + if (!originType || !targetType || originType === targetType) return + + /** + * If both values are numbers or pixels, we can animate between them by + * converting them to numbers. + */ + if (isNumOrPxType(originType) && isNumOrPxType(targetType)) { + for (let i = 0; i < unresolvedKeyframes.length; i++) { + const value = unresolvedKeyframes[i] + if (typeof value === "string") { + unresolvedKeyframes[i] = parseFloat(value as string) + } + } + } else { + /** + * Else, the only way to resolve this is by measuring the element. + */ + this.needsMeasurement = true + } + } + + unsetTransforms() { + const { element, name, unresolvedKeyframes } = this + + if (!element.current) return + + this.removedTransforms = removeNonTranslationalTransform(element) + + const finalKeyframe = + unresolvedKeyframes[unresolvedKeyframes.length - 1] + + element.getValue(name, finalKeyframe).jump(finalKeyframe, false) + } + + measureInitialState() { + const { element, unresolvedKeyframes, name } = this + + if (!element.current) return + + if (name === "height") { + this.suspendedScrollY = window.pageYOffset + } + + this.measuredOrigin = positionalValues[name]( + element.measureViewportBox(), + window.getComputedStyle(element.current) + ) + + unresolvedKeyframes[0] = this.measuredOrigin + } + + renderEndStyles() { + this.element.render() + } + + measureEndState() { + const { element, name, unresolvedKeyframes } = this + + if (!element.current) return + + const value = element.getValue(name) + value && value.jump(this.measuredOrigin, false) + + unresolvedKeyframes[unresolvedKeyframes.length - 1] = positionalValues[ + name + ]( + element.measureViewportBox(), + window.getComputedStyle(element.current) + ) as any + + if (name === "height" && this.suspendedScrollY !== undefined) { + window.scrollTo(0, this.suspendedScrollY) + } + + // If we removed transform values, reapply them before the next render + if (this.removedTransforms?.length) { + this.removedTransforms.forEach( + ([unsetTransformName, unsetTransformValue]) => { + element + .getValue(unsetTransformName)! + .set(unsetTransformValue) + } + ) + } + } +} diff --git a/packages/framer-motion/src/render/dom/DOMVisualElement.ts b/packages/framer-motion/src/render/dom/DOMVisualElement.ts index 4b772373ac..e7c486bd99 100644 --- a/packages/framer-motion/src/render/dom/DOMVisualElement.ts +++ b/packages/framer-motion/src/render/dom/DOMVisualElement.ts @@ -1,11 +1,9 @@ -import { checkTargetForNewValues, getOrigin } from "../utils/setters" import { DOMVisualElementOptions } from "../dom/types" -import { parseDomVariant } from "../dom/utils/parse-dom-variant" import { VisualElement } from "../VisualElement" import { MotionProps } from "../../motion/types" import { MotionValue } from "../../value" -import { TargetAndTransition } from "../.." import { HTMLRenderState } from "../html/types" +import { DOMKeyframesResolver } from "./DOMKeyframesResolver" export abstract class DOMVisualElement< Instance extends HTMLElement | SVGElement = HTMLElement, @@ -36,24 +34,5 @@ export abstract class DOMVisualElement< delete style[key] } - makeTargetAnimatableFromInstance( - { transition, transitionEnd, ...target }: TargetAndTransition, - isMounted: boolean - ): TargetAndTransition { - const origin = getOrigin(target as any, transition || {}, this) - - if (isMounted) { - checkTargetForNewValues(this, target, origin as any) - - const parsed = parseDomVariant(this, target, origin, transitionEnd) - transitionEnd = parsed.transitionEnd - target = parsed.target - } - - return { - transition, - transitionEnd, - ...target, - } - } + KeyframeResolver = DOMKeyframesResolver } diff --git a/packages/framer-motion/src/render/dom/utils/css-variables-conversion.ts b/packages/framer-motion/src/render/dom/utils/css-variables-conversion.ts index c47c6972c8..37e2cf0c08 100644 --- a/packages/framer-motion/src/render/dom/utils/css-variables-conversion.ts +++ b/packages/framer-motion/src/render/dom/utils/css-variables-conversion.ts @@ -1,7 +1,5 @@ -import { Target, TargetWithKeyframes } from "../../../types" import { invariant } from "../../../utils/errors" import { isNumericalString } from "../../../utils/is-numerical-string" -import type { VisualElement } from "../../VisualElement" import { isCSSVariableToken, CSSVariableToken } from "./is-css-variable" /** @@ -26,7 +24,7 @@ export function parseCSSVariable(current: string) { } const maxDepth = 4 -function getVariableValue( +export function getVariableValue( current: CSSVariableToken, element: Element, depth = 1 @@ -53,57 +51,3 @@ function getVariableValue( ? getVariableValue(fallback, element, depth + 1) : fallback } - -/** - * Resolve CSS variables from - * - * @internal - */ -export function resolveCSSVariables( - visualElement: VisualElement, - { ...target }: TargetWithKeyframes, - transitionEnd: Target | undefined -): { target: TargetWithKeyframes; transitionEnd?: Target } { - const element = visualElement.current - if (!(element instanceof Element)) return { target, transitionEnd } - - // If `transitionEnd` isn't `undefined`, clone it. We could clone `target` and `transitionEnd` - // only if they change but I think this reads clearer and this isn't a performance-critical path. - if (transitionEnd) { - transitionEnd = { ...transitionEnd } - } - - // Go through existing `MotionValue`s and ensure any existing CSS variables are resolved - visualElement.values.forEach((value) => { - const current = value.get() - if (!isCSSVariableToken(current)) return - - const resolved = getVariableValue(current, element) - if (resolved) value.set(resolved) - }) - - // Cycle through every target property and resolve CSS variables. Currently - // we only read single-var properties like `var(--foo)`, not `calc(var(--foo) + 20px)` - for (const key in target) { - const current = target[key] - if (!isCSSVariableToken(current)) continue - - const resolved = getVariableValue(current, element) - - if (!resolved) continue - - // Clone target if it hasn't already been - target[key] = resolved - - if (!transitionEnd) transitionEnd = {} - - // If the user hasn't already set this key on `transitionEnd`, set it to the unresolved - // CSS variable. This will ensure that after the animation the component will reflect - // changes in the value of the CSS variable. - if (transitionEnd[key] === undefined) { - transitionEnd[key] = current - } - } - - return { target, transitionEnd } -} diff --git a/packages/framer-motion/src/render/dom/utils/is-css-variable.ts b/packages/framer-motion/src/render/dom/utils/is-css-variable.ts index e01bf8d858..1995bd92ed 100644 --- a/packages/framer-motion/src/render/dom/utils/is-css-variable.ts +++ b/packages/framer-motion/src/render/dom/utils/is-css-variable.ts @@ -4,7 +4,7 @@ export type CSSVariableToken = `var(${CSSVariableName})` const checkStringStartsWith = (token: string) => - (key?: string): key is T => + (key?: string | number | null): key is T => typeof key === "string" && key.startsWith(token) export const isCSSVariableName = checkStringStartsWith("--") diff --git a/packages/framer-motion/src/render/dom/utils/parse-dom-variant.ts b/packages/framer-motion/src/render/dom/utils/parse-dom-variant.ts deleted file mode 100644 index 2668cfe970..0000000000 --- a/packages/framer-motion/src/render/dom/utils/parse-dom-variant.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Target, TargetWithKeyframes } from "../../../types" -import type { VisualElement } from "../../VisualElement" -import { resolveCSSVariables } from "./css-variables-conversion" -import { unitConversion } from "./unit-conversion" - -export type MakeTargetAnimatable = ( - visualElement: VisualElement, - target: TargetWithKeyframes, - origin?: Target, - transitionEnd?: Target -) => { - target: TargetWithKeyframes - transitionEnd?: Target -} - -/** - * Parse a DOM variant to make it animatable. This involves resolving CSS variables - * and ensuring animations like "20%" => "calc(50vw)" are performed in pixels. - */ -export const parseDomVariant: MakeTargetAnimatable = ( - visualElement, - target, - origin, - transitionEnd -) => { - const resolved = resolveCSSVariables(visualElement, target, transitionEnd) - target = resolved.target - transitionEnd = resolved.transitionEnd - return unitConversion(visualElement, target, origin, transitionEnd) -} diff --git a/packages/framer-motion/src/render/dom/utils/unit-conversion.ts b/packages/framer-motion/src/render/dom/utils/unit-conversion.ts index 08de7c9080..4a77e5b233 100644 --- a/packages/framer-motion/src/render/dom/utils/unit-conversion.ts +++ b/packages/framer-motion/src/render/dom/utils/unit-conversion.ts @@ -1,18 +1,12 @@ -import { Target, TargetWithKeyframes } from "../../../types" -import { isKeyframesTarget } from "../../../animation/utils/is-keyframes-target" -import { invariant } from "../../../utils/errors" import { MotionValue } from "../../../value" import { transformPropOrder } from "../../html/utils/transform" -import { ResolvedValues } from "../../types" -import { findDimensionValueType } from "../value-types/dimensions" import { Box } from "../../../projection/geometry/types" -import { isBrowser } from "../../../utils/is-browser" import type { VisualElement } from "../../VisualElement" import { ValueType } from "../../../value/types/types" import { number } from "../../../value/types/numbers" import { px } from "../../../value/types/numbers/units" -const positionalKeys = new Set([ +export const positionalKeys = new Set([ "width", "height", "top", @@ -24,12 +18,8 @@ const positionalKeys = new Set([ "translateX", "translateY", ]) -const isPositionalKey = (key: string) => positionalKeys.has(key) -const hasPositionalKey = (target: TargetWithKeyframes) => { - return Object.keys(target).some(isPositionalKey) -} -const isNumOrPxType = (v?: ValueType): v is ValueType => +export const isNumOrPxType = (v?: ValueType): v is ValueType => v === number || v === px type GetActualMeasurementInPixels = ( @@ -65,7 +55,7 @@ const nonTranslationalTransformKeys = transformPropOrder.filter( ) type RemovedTransforms = [string, string | number][] -function removeNonTranslationalTransform(visualElement: VisualElement) { +export function removeNonTranslationalTransform(visualElement: VisualElement) { const removedTransforms: RemovedTransforms = [] nonTranslationalTransformKeys.forEach((key) => { @@ -105,213 +95,3 @@ export const positionalValues: { [key: string]: GetActualMeasurementInPixels } = // Alias translate longform names positionalValues.translateX = positionalValues.x positionalValues.translateY = positionalValues.y - -const convertChangedValueTypes = ( - target: TargetWithKeyframes, - visualElement: VisualElement, - changedKeys: string[] -) => { - const originBbox = visualElement.measureViewportBox() - const element = visualElement.current - const elementComputedStyle = getComputedStyle(element!) - const { display } = elementComputedStyle - const origin: ResolvedValues = {} - - // If the element is currently set to display: "none", make it visible before - // measuring the target bounding box - if (display === "none") { - visualElement.setStaticValue( - "display", - (target.display as string) || "block" - ) - } - - /** - * Record origins before we render and update styles - */ - changedKeys.forEach((key) => { - origin[key] = positionalValues[key](originBbox, elementComputedStyle) - }) - - // Apply the latest values (as set in checkAndConvertChangedValueTypes) - visualElement.render() - - const targetBbox = visualElement.measureViewportBox() - - changedKeys.forEach((key) => { - // Restore styles to their **calculated computed style**, not their actual - // originally set style. This allows us to animate between equivalent pixel units. - const value = visualElement.getValue(key) - - value && value.jump(origin[key]) - target[key] = positionalValues[key](targetBbox, elementComputedStyle) - }) - - return target -} - -const checkAndConvertChangedValueTypes = ( - visualElement: VisualElement, - target: TargetWithKeyframes, - origin: Target = {}, - transitionEnd: Target = {} -): { target: TargetWithKeyframes; transitionEnd: Target } => { - target = { ...target } - transitionEnd = { ...transitionEnd } - - const targetPositionalKeys = Object.keys(target).filter(isPositionalKey) - - // We want to remove any transform values that could affect the element's bounding box before - // it's measured. We'll reapply these later. - let removedTransformValues: RemovedTransforms = [] - let hasAttemptedToRemoveTransformValues = false - - const changedValueTypeKeys: string[] = [] - - targetPositionalKeys.forEach((key) => { - const value = visualElement.getValue(key) as MotionValue< - number | string - > - if (!visualElement.hasValue(key)) return - - let from = origin[key] - let fromType = findDimensionValueType(from) - const to = target[key] - let toType - - // TODO: The current implementation of this basically throws an error - // if you try and do value conversion via keyframes. There's probably - // a way of doing this but the performance implications would need greater scrutiny, - // as it'd be doing multiple resize-remeasure operations. - if (isKeyframesTarget(to)) { - const numKeyframes = to.length - const fromIndex = to[0] === null ? 1 : 0 - from = to[fromIndex] - fromType = findDimensionValueType(from) - - for (let i = fromIndex; i < numKeyframes; i++) { - /** - * Don't allow wildcard keyframes to be used to detect - * a difference in value types. - */ - if (to[i] === null) break - - if (!toType) { - toType = findDimensionValueType(to[i]) - - invariant( - toType === fromType || - (isNumOrPxType(fromType) && isNumOrPxType(toType)), - "Keyframes must be of the same dimension as the current value" - ) - } else { - invariant( - findDimensionValueType(to[i]) === toType, - "All keyframes must be of the same type" - ) - } - } - } else { - toType = findDimensionValueType(to) - } - - if (fromType !== toType) { - // If they're both just number or px, convert them both to numbers rather than - // relying on resize/remeasure to convert (which is wasteful in this situation) - if (isNumOrPxType(fromType) && isNumOrPxType(toType)) { - const current = value.get() - if (typeof current === "string") { - value.set(parseFloat(current)) - } - if (typeof to === "string") { - target[key] = parseFloat(to) - } else if (Array.isArray(to) && toType === px) { - target[key] = to.map(parseFloat) - } - } else if ( - fromType?.transform && - toType?.transform && - (from === 0 || to === 0) - ) { - // If one or the other value is 0, it's safe to coerce it to the - // type of the other without measurement - if (from === 0) { - value.set((toType as any).transform(from)) - } else { - target[key] = (fromType as any).transform(to) - } - } else { - // If we're going to do value conversion via DOM measurements, we first - // need to remove non-positional transform values that could affect the bbox measurements. - if (!hasAttemptedToRemoveTransformValues) { - removedTransformValues = - removeNonTranslationalTransform(visualElement) - hasAttemptedToRemoveTransformValues = true - } - - changedValueTypeKeys.push(key) - transitionEnd[key] = - transitionEnd[key] !== undefined - ? transitionEnd[key] - : target[key] - - value.jump(to) - } - } - }) - - if (changedValueTypeKeys.length) { - const scrollY = - changedValueTypeKeys.indexOf("height") >= 0 - ? window.pageYOffset - : null - - const convertedTarget = convertChangedValueTypes( - target, - visualElement, - changedValueTypeKeys - ) - - // If we removed transform values, reapply them before the next render - if (removedTransformValues.length) { - removedTransformValues.forEach(([key, value]) => { - visualElement.getValue(key)!.set(value) - }) - } - - // Reapply original values - visualElement.render() - - // Restore scroll position - if (isBrowser && scrollY !== null) { - window.scrollTo({ top: scrollY }) - } - - return { target: convertedTarget, transitionEnd } - } else { - return { target, transitionEnd } - } -} - -/** - * Convert value types for x/y/width/height/top/left/bottom/right - * - * Allows animation between `'auto'` -> `'100%'` or `0` -> `'calc(50% - 10vw)'` - * - * @internal - */ -export function unitConversion( - visualElement: VisualElement, - target: TargetWithKeyframes, - origin?: Target, - transitionEnd?: Target -): { target: TargetWithKeyframes; transitionEnd?: Target } { - return hasPositionalKey(target) - ? checkAndConvertChangedValueTypes( - visualElement, - target, - origin, - transitionEnd - ) - : { target, transitionEnd } -} diff --git a/packages/framer-motion/src/render/html/utils/make-none-animatable.ts b/packages/framer-motion/src/render/html/utils/make-none-animatable.ts new file mode 100644 index 0000000000..d730c066d7 --- /dev/null +++ b/packages/framer-motion/src/render/html/utils/make-none-animatable.ts @@ -0,0 +1,36 @@ +import { getAnimatableNone } from "../../dom/value-types/animatable-none" +import { UnresolvedKeyframes } from "../../utils/KeyframesResolver" + +/** + * If we encounter keyframes like "none" or "0" and we also have keyframes like + * "#fff" or "200px 200px" we want to find a keyframe to serve as a template for + * the "none" keyframes. In this case "#fff" or "200px 200px" - then these get turned into + * zero equivalents, i.e. "#fff0" or "0px 0px". + */ +export function makeNoneKeyframesAnimatable( + unresolvedKeyframes: UnresolvedKeyframes, + noneKeyframeIndexes: number[], + name?: string +) { + let i = 0 + let animatableTemplate: string | undefined = undefined + while (i < unresolvedKeyframes.length && !animatableTemplate) { + if ( + typeof unresolvedKeyframes[i] === "string" && + unresolvedKeyframes[i] !== "none" && + unresolvedKeyframes[i] !== "0" + ) { + animatableTemplate = unresolvedKeyframes[i] as string + } + i++ + } + + if (animatableTemplate && name) { + for (const noneIndex of noneKeyframeIndexes) { + unresolvedKeyframes[noneIndex] = getAnimatableNone( + name, + animatableTemplate + ) + } + } +} diff --git a/packages/framer-motion/src/render/utils/KeyframesResolver.ts b/packages/framer-motion/src/render/utils/KeyframesResolver.ts new file mode 100644 index 0000000000..20d928e8c7 --- /dev/null +++ b/packages/framer-motion/src/render/utils/KeyframesResolver.ts @@ -0,0 +1,198 @@ +import { cancelFrame, frame } from "../../frameloop" +import { MotionValue } from "../../value" +import type { VisualElement } from "../VisualElement" + +export type UnresolvedKeyframes = Array + +export type ResolvedKeyframes = Array + +const toResolve = new Set() +let isScheduled = false +let anyNeedsMeasurement = false + +function measureAllKeyframes() { + if (anyNeedsMeasurement) { + // Write + toResolve.forEach((resolver) => { + resolver.needsMeasurement && resolver.unsetTransforms() + }) + + // Read + toResolve.forEach((resolver) => { + resolver.needsMeasurement && resolver.measureInitialState() + }) + + // Write + toResolve.forEach((resolver) => { + resolver.needsMeasurement && resolver.renderEndStyles() + }) + + // Read + toResolve.forEach((resolver) => { + resolver.needsMeasurement && resolver.measureEndState() + }) + } + + anyNeedsMeasurement = false + isScheduled = false + + toResolve.forEach((resolver) => resolver.complete()) + + toResolve.clear() +} + +function readAllKeyframes() { + toResolve.forEach((resolver) => { + resolver.readKeyframes() + + if (resolver.needsMeasurement) { + anyNeedsMeasurement = true + } + }) + + frame.resolveKeyframes(measureAllKeyframes) +} + +export function flushKeyframeResolvers() { + readAllKeyframes() + measureAllKeyframes() + + cancelFrame(readAllKeyframes) + cancelFrame(measureAllKeyframes) +} + +export type OnKeyframesResolved = ( + resolvedKeyframes: ResolvedKeyframes +) => void + +export class KeyframeResolver { + protected element?: VisualElement + protected unresolvedKeyframes: UnresolvedKeyframes + name?: string + + private motionValue?: MotionValue + private onComplete: OnKeyframesResolved + + /** + * Track whether this resolver has completed. Once complete, it never + * needs to attempt keyframe resolution again. + */ + private isComplete = false + + /** + * Track whether this resolver is async. If it is, it'll be added to the + * resolver queue and flushed in the next frame. Resolvers that aren't going + * to trigger read/write thrashing don't need to be async. + */ + private isAsync = false + + /** + * Track whether this resolver needs to perform a measurement + * to resolve its keyframes. + */ + needsMeasurement = false + + /** + * Track whether this resolver is currently scheduled to resolve + * to allow it to be cancelled and resumed externally. + */ + isScheduled = false + + constructor( + unresolvedKeyframes: UnresolvedKeyframes, + onComplete: OnKeyframesResolved, + name?: string, + motionValue?: MotionValue, + element?: VisualElement, + isAsync = false + ) { + this.unresolvedKeyframes = [...unresolvedKeyframes] + this.onComplete = onComplete + this.name = name + this.motionValue = motionValue + this.element = element + this.isAsync = isAsync + } + + scheduleResolve() { + this.isScheduled = true + if (this.isAsync) { + toResolve.add(this) + + if (!isScheduled) { + isScheduled = true + frame.read(readAllKeyframes) + } + } else { + this.readKeyframes() + this.complete() + } + } + + readKeyframes() { + const { unresolvedKeyframes, name, element, motionValue } = this + + /** + * If a keyframe is null, we hydrate it either by reading it from + * the instance, or propagating from previous keyframes. + */ + for (let i = 0; i < unresolvedKeyframes.length; i++) { + if (unresolvedKeyframes[i] === null) { + /** + * If the first keyframe is null, we need to find its value by sampling the element + */ + if (i === 0) { + const currentValue = motionValue?.get() + + const finalKeyframe = + unresolvedKeyframes[unresolvedKeyframes.length - 1] + + if (currentValue !== undefined) { + unresolvedKeyframes[0] = currentValue + } else if (element && name) { + const valueAsRead = element.readValue( + name, + finalKeyframe + ) + + if (valueAsRead !== undefined && valueAsRead !== null) { + unresolvedKeyframes[0] = valueAsRead + } + } + + if (unresolvedKeyframes[0] === undefined) { + unresolvedKeyframes[0] = finalKeyframe + } + + if (motionValue && currentValue === undefined) { + motionValue.set(unresolvedKeyframes[0] as T) + } + } else { + unresolvedKeyframes[i] = unresolvedKeyframes[i - 1] + } + } + } + } + + unsetTransforms() {} + measureInitialState() {} + renderEndStyles() {} + measureEndState() {} + + complete() { + this.isComplete = true + this.onComplete(this.unresolvedKeyframes as ResolvedKeyframes) + toResolve.delete(this) + } + + cancel() { + if (!this.isComplete) { + this.isScheduled = false + toResolve.delete(this) + } + } + + resume() { + if (!this.isComplete) this.scheduleResolve() + } +} diff --git a/packages/framer-motion/src/render/utils/__tests__/StateVisualElement.ts b/packages/framer-motion/src/render/utils/__tests__/StateVisualElement.ts index 4d8850d84c..a9013348f4 100644 --- a/packages/framer-motion/src/render/utils/__tests__/StateVisualElement.ts +++ b/packages/framer-motion/src/render/utils/__tests__/StateVisualElement.ts @@ -1,9 +1,7 @@ import { ResolvedValues } from "../../types" -import { checkTargetForNewValues, getOrigin } from "../setters" import { MotionProps } from "../../../motion/types" import { createBox } from "../../../projection/geometry/models" import { VisualElement } from "../../VisualElement" -import { TargetAndTransition } from "../../.." export class StateVisualElement extends VisualElement< ResolvedValues, @@ -34,14 +32,4 @@ export class StateVisualElement extends VisualElement< ) { return options.initialState[key] || 0 } - - makeTargetAnimatableFromInstance({ - transition, - transitionEnd, - ...target - }: TargetAndTransition) { - const origin = getOrigin(target as any, transition || {}, this) - checkTargetForNewValues(this, target, origin as any) - return { transition, transitionEnd, ...target } - } } diff --git a/packages/framer-motion/src/render/utils/__tests__/animation-state.test.ts b/packages/framer-motion/src/render/utils/__tests__/animation-state.test.ts index ab2d6b5784..9848ac69c3 100644 --- a/packages/framer-motion/src/render/utils/__tests__/animation-state.test.ts +++ b/packages/framer-motion/src/render/utils/__tests__/animation-state.test.ts @@ -30,15 +30,10 @@ function createTest( element: element, state: { ...element.animationState, - update( - newProps: any, - options: any, - type: any, - animateChanges = true - ): any { + update(newProps: any, type: any, animateChanges = true): any { element.update(newProps, null) return animateChanges === true - ? element.animationState?.animateChanges(options, type) + ? element.animationState?.animateChanges(type) : undefined }, }, diff --git a/packages/framer-motion/src/render/utils/animation-state.ts b/packages/framer-motion/src/render/utils/animation-state.ts index ff59550865..43cc531204 100644 --- a/packages/framer-motion/src/render/utils/animation-state.ts +++ b/packages/framer-motion/src/render/utils/animation-state.ts @@ -13,10 +13,7 @@ import { AnimationDefinition } from "../../animation/types" import { animateVisualElement } from "../../animation/interfaces/visual-element" export interface AnimationState { - animateChanges: ( - options?: VisualElementAnimationOptions, - type?: AnimationType - ) => Promise + animateChanges: (type?: AnimationType) => Promise setActive: ( type: AnimationType, isActive: boolean, @@ -56,19 +53,27 @@ export function createAnimationState( * This function will be used to reduce the animation definitions for * each active animation type into an object of resolved values for it. */ - const buildResolvedTypeValues = ( - acc: { [key: string]: any }, - definition: string | TargetAndTransition | undefined - ) => { - const resolved = resolveVariant(visualElement, definition) - - if (resolved) { - const { transition, transitionEnd, ...target } = resolved - acc = { ...acc, ...target, ...transitionEnd } - } + const buildResolvedTypeValues = + (type: AnimationType) => + ( + acc: { [key: string]: any }, + definition: string | TargetAndTransition | undefined + ) => { + const resolved = resolveVariant( + visualElement, + definition, + type === "exit" + ? visualElement.presenceContext?.custom + : undefined + ) - return acc - } + if (resolved) { + const { transition, transitionEnd, ...target } = resolved + acc = { ...acc, ...target, ...transitionEnd } + } + + return acc + } /** * This just allows us to inject mocked animation functions @@ -88,10 +93,7 @@ export function createAnimationState( * 3. Determine if any values have been removed from a type and figure out * what to animate those to. */ - function animateChanges( - options?: VisualElementAnimationOptions, - changedActiveType?: AnimationType - ) { + function animateChanges(changedActiveType?: AnimationType) { const props = visualElement.getProps() const context = visualElement.getVariantContext(true) || {} @@ -213,7 +215,7 @@ export function createAnimationState( * animateChanges calls to determine whether a value has changed. */ let resolvedValues = definitionList.reduce( - buildResolvedTypeValues, + buildResolvedTypeValues(type), {} ) @@ -261,7 +263,7 @@ export function createAnimationState( } if (valueHasChanged) { - if (next !== undefined) { + if (next !== undefined && next !== null) { // If next is defined and doesn't equal prev, it needs animating markToAnimate(key) } else { @@ -308,7 +310,7 @@ export function createAnimationState( animations.push( ...definitionList.map((animation) => ({ animation: animation as AnimationDefinition, - options: { type, ...options }, + options: { type }, })) ) } @@ -323,10 +325,8 @@ export function createAnimationState( const fallbackAnimation = {} removedKeys.forEach((key) => { const fallbackTarget = visualElement.getBaseTarget(key) - - if (fallbackTarget !== undefined) { - fallbackAnimation[key] = fallbackTarget - } + fallbackAnimation[key] = + fallbackTarget === undefined ? null : fallbackTarget }) animations.push({ animation: fallbackAnimation }) @@ -349,11 +349,7 @@ export function createAnimationState( /** * Change whether a certain animation type is active. */ - function setActive( - type: AnimationType, - isActive: boolean, - options?: VisualElementAnimationOptions - ) { + function setActive(type: AnimationType, isActive: boolean) { // If the active state hasn't changed, we can safely do nothing here if (state[type].isActive === isActive) return Promise.resolve() @@ -364,7 +360,7 @@ export function createAnimationState( state[type].isActive = isActive - const animations = animateChanges(options, type) + const animations = animateChanges(type) for (const key in state) { state[key].protectedKeys = {} diff --git a/packages/framer-motion/src/render/utils/setters.ts b/packages/framer-motion/src/render/utils/setters.ts index 74f58c2543..cd4904dc67 100644 --- a/packages/framer-motion/src/render/utils/setters.ts +++ b/packages/framer-motion/src/render/utils/setters.ts @@ -1,19 +1,6 @@ -import { AnimationDefinition } from "../../animation/types" -import { - Target, - TargetAndTransition, - TargetResolver, - TargetWithKeyframes, - Transition, -} from "../../types" -import { isNumericalString } from "../../utils/is-numerical-string" -import { isZeroValueString } from "../../utils/is-zero-value-string" +import { TargetAndTransition, TargetResolver } from "../../types" import { resolveFinalValueInKeyframes } from "../../utils/resolve-value" import { motionValue } from "../../value" -import { complex } from "../../value/types/complex" -import { getAnimatableNone } from "../dom/value-types/animatable-none" -import { findValueType } from "../dom/value-types/find" -import { ResolvedValues } from "../types" import type { VisualElement } from "../VisualElement" import { resolveVariant } from "./resolve-dynamic-variants" @@ -38,11 +25,7 @@ export function setTarget( definition: string | TargetAndTransition | TargetResolver ) { const resolved = resolveVariant(visualElement, definition) - let { - transitionEnd = {}, - transition = {}, - ...target - } = resolved ? visualElement.makeTargetAnimatable(resolved, false) : {} + let { transitionEnd = {}, transition = {}, ...target } = resolved || {} target = { ...target, ...transitionEnd } @@ -51,123 +34,3 @@ export function setTarget( setMotionValue(visualElement, key, value as string | number) } } - -function setVariants(visualElement: VisualElement, variantLabels: string[]) { - const reversedLabels = [...variantLabels].reverse() - - reversedLabels.forEach((key) => { - const variant = visualElement.getVariant(key) - variant && setTarget(visualElement, variant) - - if (visualElement.variantChildren) { - visualElement.variantChildren.forEach((child) => { - setVariants(child, variantLabels) - }) - } - }) -} - -export function setValues( - visualElement: VisualElement, - definition: AnimationDefinition -) { - if (Array.isArray(definition)) { - return setVariants(visualElement, definition) - } else if (typeof definition === "string") { - return setVariants(visualElement, [definition]) - } else { - setTarget(visualElement, definition as any) - } -} - -export function checkTargetForNewValues( - visualElement: VisualElement, - target: TargetWithKeyframes, - origin: ResolvedValues -) { - const newValueKeys = Object.keys(target).filter( - (key) => !visualElement.hasValue(key) - ) - - const numNewValues = newValueKeys.length - - if (!numNewValues) return - - for (let i = 0; i < numNewValues; i++) { - const key = newValueKeys[i] - const targetValue = target[key] - let value: string | number | null = null - - /** - * If the target is a series of keyframes, we can use the first value - * in the array. If this first value is null, we'll still need to read from the DOM. - */ - if (Array.isArray(targetValue)) { - value = targetValue[0] - } - - /** - * If the target isn't keyframes, or the first keyframe was null, we need to - * first check if an origin value was explicitly defined in the transition as "from", - * if not read the value from the DOM. As an absolute fallback, take the defined target value. - */ - if (value === null) { - value = origin[key] ?? visualElement.readValue(key) ?? target[key] - } - - /** - * If value is still undefined or null, ignore it. Preferably this would throw, - * but this was causing issues in Framer. - */ - if (value === undefined || value === null) continue - - if ( - typeof value === "string" && - (isNumericalString(value) || isZeroValueString(value)) - ) { - // If this is a number read as a string, ie "0" or "200", convert it to a number - value = parseFloat(value) - } else if (!findValueType(value) && complex.test(targetValue)) { - value = getAnimatableNone(key, targetValue) - } - - visualElement.addValue( - key, - motionValue(value, { owner: visualElement }) - ) - if (origin[key] === undefined) { - origin[key] = value as number | string - } - if (value !== null) visualElement.setBaseTarget(key, value) - } -} - -export function getOriginFromTransition(key: string, transition: Transition) { - if (!transition) return - const valueTransition = - transition[key] || transition["default"] || transition - return valueTransition.from -} - -export function getOrigin( - target: Target, - transition: Transition, - visualElement: VisualElement -) { - const origin: Target = {} - - for (const key in target) { - const transitionOrigin = getOriginFromTransition(key, transition) - - if (transitionOrigin !== undefined) { - origin[key] = transitionOrigin - } else { - const value = visualElement.getValue(key) - if (value) { - origin[key] = value.get() - } - } - } - - return origin -} diff --git a/packages/framer-motion/src/three-entry.ts b/packages/framer-motion/src/three-entry.ts index 06280a950b..3c5aa560b8 100644 --- a/packages/framer-motion/src/three-entry.ts +++ b/packages/framer-motion/src/three-entry.ts @@ -6,7 +6,6 @@ export type { export { AnimationType } from "./render/utils/types" export { animations } from "./motion/features/animations" export { MotionContext } from "./context/MotionContext" -export { checkTargetForNewValues } from "./render/utils/setters" export { createBox } from "./projection/geometry/models" export { calcLength } from "./projection/geometry/delta-calc" export { filterProps } from "./render/dom/utils/filter-props" diff --git a/packages/framer-motion/src/utils/interpolate.ts b/packages/framer-motion/src/utils/interpolate.ts index 1b47a58d95..54b48dbce6 100644 --- a/packages/framer-motion/src/utils/interpolate.ts +++ b/packages/framer-motion/src/utils/interpolate.ts @@ -74,6 +74,7 @@ export function interpolate( * that returns the output. */ if (inputLength === 1) return () => output[0] + if (inputLength === 2 && input[0] === input[1]) return () => output[1] // If input runs highest -> lowest, reverse both arrays if (input[0] > input[inputLength - 1]) { 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/index.ts b/packages/framer-motion/src/value/index.ts index d003dec0cd..335866bf10 100644 --- a/packages/framer-motion/src/value/index.ts +++ b/packages/framer-motion/src/value/index.ts @@ -77,7 +77,7 @@ export class MotionValue { /** * The current state of the `MotionValue`. */ - private current: V + private current: V | undefined /** * The previous state of the `MotionValue`. @@ -112,9 +112,7 @@ export class MotionValue { private stopPassiveEffect?: VoidFunction /** - * A reference to the currently-controlling Popmotion animation - * - * + * A reference to the currently-controlling animation. */ animation?: AnimationPlaybackControls @@ -287,11 +285,11 @@ export class MotionValue { * Set the state of the `MotionValue`, stopping any active animations, * effects, and resets velocity to `0`. */ - jump(v: V) { + jump(v: V, endAnimation = true) { this.updateAndNotify(v) this.prev = v this.prevUpdatedAt = this.prevFrameValue = undefined - this.stop() + endAnimation && this.stop() if (this.stopPassiveEffect) this.stopPassiveEffect() } @@ -334,7 +332,7 @@ export class MotionValue { collectMotionValues.current.push(this) } - return this.current + return this.current! } /** 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 = () => {