From c14c9dac149359f5a21261aee055f876b781b9ed Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 26 Sep 2024 14:28:35 +0200 Subject: [PATCH] Support for `linear()` easing function (#2812) * Adding support for linear easing * Fixing tests * Updating linear() * Tweaking linear easing * Make minimum number of linear points * Adding guard --- packages/framer-motion/package.json | 8 +- .../animators/AcceleratedAnimation.ts | 30 +++- .../animators/waapi/__tests__/easing.test.ts | 7 + .../src/animation/animators/waapi/easing.ts | 37 +++-- .../src/animation/animators/waapi/index.ts | 2 +- .../waapi/utils/__tests__/linear.test.ts | 14 ++ .../animation/animators/waapi/utils/linear.ts | 19 +++ .../animators/waapi/utils/memo-supports.ts | 10 ++ .../animators/waapi/utils/supports-flags.ts | 7 + .../src/motion/__tests__/waapi.test.tsx | 152 ++++++++++++++++-- 10 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 packages/framer-motion/src/animation/animators/waapi/utils/__tests__/linear.test.ts create mode 100644 packages/framer-motion/src/animation/animators/waapi/utils/linear.ts create mode 100644 packages/framer-motion/src/animation/animators/waapi/utils/memo-supports.ts create mode 100644 packages/framer-motion/src/animation/animators/waapi/utils/supports-flags.ts diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 70db13752d..51660699e6 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -98,7 +98,7 @@ "bundlesize": [ { "path": "./dist/size-rollup-motion.js", - "maxSize": "33.85 kB" + "maxSize": "34.02 kB" }, { "path": "./dist/size-rollup-m.js", @@ -106,15 +106,15 @@ }, { "path": "./dist/size-rollup-dom-animation.js", - "maxSize": "16.9 kB" + "maxSize": "17 kB" }, { "path": "./dist/size-rollup-dom-max.js", - "maxSize": "29 kB" + "maxSize": "29.1 kB" }, { "path": "./dist/size-rollup-animate.js", - "maxSize": "17.7 kB" + "maxSize": "17.9 kB" }, { "path": "./dist/size-rollup-scroll.js", diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index f43bcda7bb..105fcba7f0 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -1,3 +1,6 @@ +import { anticipate } from "../../easing/anticipate" +import { backInOut } from "../../easing/back" +import { circInOut } from "../../easing/circ" import { EasingDefinition } from "../../easing/types" import { DOMKeyframesResolver } from "../../render/dom/DOMKeyframesResolver" import { ResolvedKeyframes } from "../../render/utils/KeyframesResolver" @@ -20,7 +23,7 @@ import { import { MainThreadAnimation } from "./MainThreadAnimation" import { acceleratedValues } from "./utils/accelerated-values" import { animateStyle } from "./waapi" -import { isWaapiSupportedEasing } from "./waapi/easing" +import { isWaapiSupportedEasing, supportsLinearEasing } from "./waapi/easing" import { getFinalKeyframe } from "./waapi/utils/get-final-keyframe" const supportsWaapi = /*@__PURE__*/ memo(() => @@ -110,6 +113,18 @@ interface ResolvedAcceleratedAnimation { keyframes: string[] | number[] } +const unsupportedEasingFunctions = { + anticipate, + backInOut, + circInOut, +} + +function isUnsupportedEase( + key: string +): key is keyof typeof unsupportedEasingFunctions { + return key in unsupportedEasingFunctions +} + export class AcceleratedAnimation< T extends string | number > extends BaseAnimation { @@ -159,6 +174,19 @@ export class AcceleratedAnimation< return false } + /** + * If the user has provided an easing function name that isn't supported + * by WAAPI (like "anticipate"), we need to provide the corressponding + * function. This will later get converted to a linear() easing function. + */ + if ( + typeof ease === "string" && + supportsLinearEasing() && + isUnsupportedEase(ease) + ) { + ease = unsupportedEasingFunctions[ease] + } + /** * If this animation needs pre-generated keyframes then generate. */ diff --git a/packages/framer-motion/src/animation/animators/waapi/__tests__/easing.test.ts b/packages/framer-motion/src/animation/animators/waapi/__tests__/easing.test.ts index 9bb4e9ca21..d77ee88e89 100644 --- a/packages/framer-motion/src/animation/animators/waapi/__tests__/easing.test.ts +++ b/packages/framer-motion/src/animation/animators/waapi/__tests__/easing.test.ts @@ -1,4 +1,5 @@ import { isWaapiSupportedEasing } from "../easing" +import { supportsFlags } from "../utils/supports-flags" test("isWaapiSupportedEasing", () => { expect(isWaapiSupportedEasing()).toEqual(true) @@ -7,6 +8,9 @@ test("isWaapiSupportedEasing", () => { expect(isWaapiSupportedEasing("anticipate")).toEqual(false) expect(isWaapiSupportedEasing("backInOut")).toEqual(false) expect(isWaapiSupportedEasing([0, 1, 2, 3])).toEqual(true) + supportsFlags.linearEasing = true + expect(isWaapiSupportedEasing((v) => v)).toEqual(true) + supportsFlags.linearEasing = false expect(isWaapiSupportedEasing((v) => v)).toEqual(false) expect(isWaapiSupportedEasing(["linear", "easeIn"])).toEqual(true) expect(isWaapiSupportedEasing(["linear", "easeIn", [0, 1, 2, 3]])).toEqual( @@ -15,6 +19,9 @@ test("isWaapiSupportedEasing", () => { expect(isWaapiSupportedEasing(["linear", "easeIn", "anticipate"])).toEqual( false ) + supportsFlags.linearEasing = true + expect(isWaapiSupportedEasing(["linear", "easeIn", (v) => v])).toEqual(true) + supportsFlags.linearEasing = false expect(isWaapiSupportedEasing(["linear", "easeIn", (v) => v])).toEqual( false ) diff --git a/packages/framer-motion/src/animation/animators/waapi/easing.ts b/packages/framer-motion/src/animation/animators/waapi/easing.ts index 0a72353552..95b85a19d8 100644 --- a/packages/framer-motion/src/animation/animators/waapi/easing.ts +++ b/packages/framer-motion/src/animation/animators/waapi/easing.ts @@ -1,10 +1,25 @@ import { BezierDefinition, Easing } from "../../../easing/types" import { isBezierDefinition } from "../../../easing/utils/is-bezier-definition" +import { generateLinearEasing } from "./utils/linear" +import { memoSupports } from "./utils/memo-supports" + +export const supportsLinearEasing = /*@__PURE__*/ memoSupports(() => { + try { + document + .createElement("div") + .animate({ opacity: 0 }, { easing: "linear(0, 1)" }) + } catch (e) { + return false + } + return true +}, "linearEasing") export function isWaapiSupportedEasing(easing?: Easing | Easing[]): boolean { return Boolean( - !easing || - (typeof easing === "string" && easing in supportedWaapiEasing) || + (typeof easing === "function" && supportsLinearEasing()) || + !easing || + (typeof easing === "string" && + (easing in supportedWaapiEasing || supportsLinearEasing())) || isBezierDefinition(easing) || (Array.isArray(easing) && easing.every(isWaapiSupportedEasing)) ) @@ -25,22 +40,22 @@ export const supportedWaapiEasing = { backOut: /*@__PURE__*/ cubicBezierAsString([0.33, 1.53, 0.69, 0.99]), } -function mapEasingToNativeEasingWithDefault(easing: Easing): string { - return ( - (mapEasingToNativeEasing(easing) as string) || - supportedWaapiEasing.easeOut - ) -} - export function mapEasingToNativeEasing( - easing?: Easing | Easing[] + easing: Easing | Easing[] | undefined, + duration: number ): undefined | string | string[] { if (!easing) { return undefined + } else if (typeof easing === "function" && supportsLinearEasing()) { + return generateLinearEasing(easing, duration) } else if (isBezierDefinition(easing)) { return cubicBezierAsString(easing) } else if (Array.isArray(easing)) { - return easing.map(mapEasingToNativeEasingWithDefault) + return easing.map( + (segmentEasing) => + (mapEasingToNativeEasing(segmentEasing, duration) as string) || + supportedWaapiEasing.easeOut + ) } else { return supportedWaapiEasing[easing as keyof typeof supportedWaapiEasing] } diff --git a/packages/framer-motion/src/animation/animators/waapi/index.ts b/packages/framer-motion/src/animation/animators/waapi/index.ts index 5557a31cae..b24e15e674 100644 --- a/packages/framer-motion/src/animation/animators/waapi/index.ts +++ b/packages/framer-motion/src/animation/animators/waapi/index.ts @@ -17,7 +17,7 @@ export function animateStyle( const keyframeOptions: PropertyIndexedKeyframes = { [valueName]: keyframes } if (times) keyframeOptions.offset = times - const easing = mapEasingToNativeEasing(ease) + const easing = mapEasingToNativeEasing(ease, duration) /** * If this is an easing array, apply to keyframes, not animation as a whole diff --git a/packages/framer-motion/src/animation/animators/waapi/utils/__tests__/linear.test.ts b/packages/framer-motion/src/animation/animators/waapi/utils/__tests__/linear.test.ts new file mode 100644 index 0000000000..f56126e0ea --- /dev/null +++ b/packages/framer-motion/src/animation/animators/waapi/utils/__tests__/linear.test.ts @@ -0,0 +1,14 @@ +import { noop } from "../../../../../utils/noop" +import { generateLinearEasing } from "../linear" + +describe("generateLinearEasing", () => { + test("Converts easing function into string of points", () => { + expect(generateLinearEasing(noop, 110)).toEqual( + "linear(0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1)" + ) + expect(generateLinearEasing(() => 0.5, 200)).toEqual( + "linear(0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5)" + ) + expect(generateLinearEasing(() => 0.5, 0)).toEqual("linear(0.5, 0.5)") + }) +}) diff --git a/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts b/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts new file mode 100644 index 0000000000..6851917696 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts @@ -0,0 +1,19 @@ +import { EasingFunction } from "../../../../easing/types" +import { progress } from "../../../../utils/progress" + +// Create a linear easing point for every 10 ms +const resolution = 10 + +export const generateLinearEasing = ( + easing: EasingFunction, + duration: number // as milliseconds +): string => { + let points = "" + const numPoints = Math.max(Math.round(duration / resolution), 2) + + for (let i = 0; i < numPoints; i++) { + points += easing(progress(0, numPoints - 1, i)) + ", " + } + + return `linear(${points.substring(0, points.length - 2)})` +} diff --git a/packages/framer-motion/src/animation/animators/waapi/utils/memo-supports.ts b/packages/framer-motion/src/animation/animators/waapi/utils/memo-supports.ts new file mode 100644 index 0000000000..334a4b114f --- /dev/null +++ b/packages/framer-motion/src/animation/animators/waapi/utils/memo-supports.ts @@ -0,0 +1,10 @@ +import { memo } from "../../../../utils/memo" +import { supportsFlags } from "./supports-flags" + +export function memoSupports( + callback: () => T, + supportsFlag: keyof typeof supportsFlags +) { + const memoized = memo(callback) + return () => supportsFlags[supportsFlag] ?? memoized() +} diff --git a/packages/framer-motion/src/animation/animators/waapi/utils/supports-flags.ts b/packages/framer-motion/src/animation/animators/waapi/utils/supports-flags.ts new file mode 100644 index 0000000000..8544f44de5 --- /dev/null +++ b/packages/framer-motion/src/animation/animators/waapi/utils/supports-flags.ts @@ -0,0 +1,7 @@ +/** + * Add the ability for test suites to manually set support flags + * to better test more environments. + */ +export const supportsFlags: Record = { + linearEasing: undefined, +} diff --git a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx index a643abd632..c7f21f7c89 100644 --- a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx @@ -9,6 +9,7 @@ import { motion, spring, useMotionValue } from "../../" import { act, useState, createRef } from "react" import { nextFrame } from "../../gestures/__tests__/utils" import "../../animation/animators/waapi/__tests__/setup" +import { supportsFlags } from "../../animation/animators/waapi/utils/supports-flags" describe("WAAPI animations", () => { test("opacity animates with WAAPI at default settings", async () => { @@ -389,7 +390,8 @@ describe("WAAPI animations", () => { ) }) - test("WAAPI is called with expected arguments with pre-generated keyframes", async () => { + test("WAAPI is called with pre-generated keyframes when linear() is unsupported ", async () => { + supportsFlags.linearEasing = false const ref = createRef() const Component = () => ( { transition={{ duration: 0.05, delay: 2, - ease: () => 0.5, + ease: (p) => p, times: [0, 1], }} /> @@ -411,7 +413,10 @@ describe("WAAPI animations", () => { 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 }, + { + opacity: [0, 0.2, 0.4, 0.6, 0.8, 1], + offset: undefined, + }, { delay: 2000, duration: 50, @@ -421,6 +426,42 @@ describe("WAAPI animations", () => { iterations: 1, } ) + supportsFlags.linearEasing = undefined + }) + + test("WAAPI is called with generated linear() easing function when supported", async () => { + supportsFlags.linearEasing = true + const ref = createRef() + const Component = () => ( + p, + }} + /> + ) + const { rerender } = render() + rerender() + + await nextFrame() + + expect(ref.current!.animate).toBeCalled() + expect(ref.current!.animate).toBeCalledWith( + { opacity: [0, 1], offset: undefined }, + { + delay: 2000, + duration: 50, + direction: "normal", + easing: "linear(0, 0.25, 0.5, 0.75, 1)", + fill: "both", + iterations: 1, + } + ) + supportsFlags.linearEasing = undefined }) test("Maps 'easeIn' to 'ease-in'", async () => { @@ -742,14 +783,15 @@ describe("WAAPI animations", () => { expect(ref.current!.animate).not.toBeCalled() }) - test("Pregenerates keyframes if ease is function", async () => { + test("Pregenerates keyframes if ease is anticipate and linear() is not supported", async () => { + supportsFlags.linearEasing = false const ref = createRef() const Component = () => ( 0.5, duration: 0.05 }} + transition={{ ease: "anticipate", duration: 0.05 }} /> ) const { rerender } = render() @@ -760,7 +802,10 @@ describe("WAAPI animations", () => { expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { - opacity: [0.45, 0.45, 0.45, 0.45, 0.45, 0.45], + opacity: [ + 0, -0.038019759996313955, 0.14036703066311026, 0.7875, + 0.89296875, 0.899560546875, + ], offset: undefined, }, { @@ -772,9 +817,12 @@ describe("WAAPI animations", () => { iterations: 1, } ) + + supportsFlags.linearEasing = undefined }) - test("Pregenerates keyframes if ease is anticipate", async () => { + test("Generates linear() easing if ease is anticipate", async () => { + supportsFlags.linearEasing = true const ref = createRef() const Component = () => ( { expect(ref.current!.animate).toBeCalled() expect(ref.current!.animate).toBeCalledWith( { - opacity: [ - 0, -0.038019759996313955, 0.14036703066311026, 0.7875, - 0.89296875, 0.899560546875, - ], + opacity: [0, 0.9], offset: undefined, }, { delay: -0, direction: "normal", duration: 50, - easing: "linear", + easing: "linear(0, -0.033628590829175686, 0.5, 0.984375, 0.99951171875)", fill: "both", iterations: 1, } ) + supportsFlags.linearEasing = undefined }) - test("Pregenerates keyframes if ease is backInOut", async () => { + test("Pregenerates keyframes if ease is backInOut and linear() is not supported", async () => { + supportsFlags.linearEasing = false + const ref = createRef() const Component = () => ( { iterations: 1, } ) + supportsFlags.linearEasing = undefined + }) + + test("Generates linear() if ease is backInOut", async () => { + supportsFlags.linearEasing = true + + const ref = createRef() + const Component = () => ( + + ) + const { rerender } = render() + rerender() + + await nextFrame() + + expect(ref.current!.animate).toBeCalled() + expect(ref.current!.animate).toBeCalledWith( + { + opacity: [0, 0.7], + offset: undefined, + }, + { + delay: -0, + direction: "normal", + duration: 50, + easing: "linear(0, -0.033628590829175686, 0.5, 1.0336285908291756, 1)", + fill: "both", + iterations: 1, + } + ) + supportsFlags.linearEasing = undefined }) - test("Pregenerates keyframes if ease is circInOut", async () => { + test("Pregenerates keyframes if ease is circInOut and linear() is not supported", async () => { + supportsFlags.linearEasing = false const ref = createRef() const Component = () => ( { iterations: 1, } ) + supportsFlags.linearEasing = undefined + }) + + test("Generates linear() if ease is circInOut", async () => { + supportsFlags.linearEasing = true + const ref = createRef() + const Component = () => ( + + ) + const { rerender } = render() + rerender() + + await nextFrame() + + expect(ref.current!.animate).toBeCalled() + expect(ref.current!.animate).toBeCalledWith( + { + opacity: [0, 0.9], + offset: undefined, + }, + { + delay: -0, + direction: "normal", + duration: 50, + easing: "linear(0, 0.06698729810778065, 0.5, 0.9330127018922194, 1)", + fill: "both", + iterations: 1, + } + ) + supportsFlags.linearEasing = undefined }) test("Doesn't animate with WAAPI if repeatType is defined as mirror", async () => { @@ -933,6 +1053,7 @@ describe("WAAPI animations", () => { }) test("Animates with WAAPI if repeat is defined and we need to generate keyframes", async () => { + supportsFlags.linearEasing = false const ref = createRef() const Component = () => ( { iterations: 3, } ) + supportsFlags.linearEasing = undefined }) test("Animates with WAAPI if repeat is Infinity and we need to generate keyframes", async () => { + supportsFlags.linearEasing = false const ref = createRef() const Component = () => ( { iterations: Infinity, } ) + supportsFlags.linearEasing = undefined }) })