From dabfdcb6179ed55f268dbc104fb356c0442e4e1d Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 27 Nov 2024 14:31:15 +0100 Subject: [PATCH] Spring updates (#2912) * Latest * Latest * Latest * Latest * Latest --- CHANGELOG.md | 7 + .../src/examples/Animation-spring-css.tsx | 131 ++++++++++++++++++ packages/framer-motion/package.json | 11 +- .../animators/waapi/NativeAnimation.ts | 1 + .../animation/animators/waapi/utils/linear.ts | 6 +- .../generators/__tests__/spring.test.ts | 53 +++++++ .../animation/generators/spring/defaults.ts | 28 ++++ .../src/animation/generators/spring/find.ts | 31 +++-- .../src/animation/generators/spring/index.ts | 94 ++++++++++--- .../src/animation/generators/spring/utils.ts | 1 + .../src/animation/generators/types.ts | 1 + .../generators/utils/calc-duration.ts | 4 +- packages/framer-motion/src/animation/types.ts | 1 + packages/framer-motion/src/types.ts | 20 +++ 14 files changed, 344 insertions(+), 45 deletions(-) create mode 100644 dev/react/src/examples/Animation-spring-css.tsx create mode 100644 packages/framer-motion/src/animation/generators/spring/defaults.ts create mode 100644 packages/framer-motion/src/animation/generators/spring/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be6c443db3..589234ce20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [11.12.0] 2024-11-27 + +### Added + +- New `visualDuration` option for `spring` animations. +- New `spring(visualDuration, bounce)` syntax. + ## [11.11.16] 2024-11-14 ### Fixed diff --git a/dev/react/src/examples/Animation-spring-css.tsx b/dev/react/src/examples/Animation-spring-css.tsx new file mode 100644 index 0000000000..8a5403cd33 --- /dev/null +++ b/dev/react/src/examples/Animation-spring-css.tsx @@ -0,0 +1,131 @@ +import { spring } from "framer-motion/dom" +import { motion } from "framer-motion" +import { useEffect, useState } from "react" + +const height = 100 +const width = 500 +const margin = 10 + +export function SpringVisualiser({ transition }: any) { + const { duration, bounce } = { + duration: transition.duration * 1000, + bounce: transition.bounce, + } + const springResolver = spring({ + bounce, + visualDuration: duration, + keyframes: [0, 1], + }) + + let curveLine = `M${margin} ${margin + height}` + let perceptualMarker = "" + + const step = 10 + for (let i = 0; i <= width; i++) { + const t = i * step + + if (t > duration && perceptualMarker === "") { + perceptualMarker = `M${margin + i} ${margin} L${margin + i} ${ + margin + height + }` + } + + curveLine += `L${margin + i} ${ + margin + (height - springResolver.next(t).value * (height / 2)) + }` + } + + return ( + + + + + ) +} + +/** + * An example of the tween transition type + */ + +const style = { + width: 100, + height: 100, + background: "white", +} +export const App = () => { + const [state, setState] = useState(false) + + const [duration, setDuration] = useState(1) + const [bounce, setBounce] = useState(0.2) + + useEffect(() => { + setTimeout(() => { + setState(true) + }, 300) + }, [state]) + + return ( + <> +
+ + + setBounce(Number(e.target.value))} + /> + setDuration(Number(e.target.value))} + /> + + ) +} diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 7b317a5da1..98bac75b50 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -108,7 +108,7 @@ "bundlesize": [ { "path": "./dist/size-rollup-motion.js", - "maxSize": "33.86 kB" + "maxSize": "34.15 kB" }, { "path": "./dist/size-rollup-m.js", @@ -116,15 +116,15 @@ }, { "path": "./dist/size-rollup-dom-animation.js", - "maxSize": "17 kB" + "maxSize": "17.3 kB" }, { "path": "./dist/size-rollup-dom-max.js", - "maxSize": "29.1 kB" + "maxSize": "29.4 kB" }, { "path": "./dist/size-rollup-animate.js", - "maxSize": "17.92 kB" + "maxSize": "18.2 kB" }, { "path": "./dist/size-rollup-scroll.js", @@ -134,6 +134,5 @@ "path": "./dist/size-rollup-waapi-animate.js", "maxSize": "2.54 kB" } - ], - "gitHead": "eeb1cc452e2b468d838ec76fd501b131b383c5c9" + ] } diff --git a/packages/framer-motion/src/animation/animators/waapi/NativeAnimation.ts b/packages/framer-motion/src/animation/animators/waapi/NativeAnimation.ts index 0178d7e92c..77090ed656 100644 --- a/packages/framer-motion/src/animation/animators/waapi/NativeAnimation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/NativeAnimation.ts @@ -112,6 +112,7 @@ export class NativeAnimation implements AnimationPlaybackControls { hydrateKeyframes(valueName, valueKeyframes, readInitialKeyframe) + // TODO: Replace this with toString()? if (isGenerator(options.type)) { const generatorOptions = createGeneratorEasing( options, diff --git a/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts b/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts index 6851917696..c27b6f48da 100644 --- a/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts +++ b/packages/framer-motion/src/animation/animators/waapi/utils/linear.ts @@ -1,12 +1,10 @@ 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 + duration: number, // as milliseconds + resolution: number = 10 // as milliseconds ): string => { let points = "" const numPoints = Math.max(Math.round(duration / resolution), 2) 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 16f687d896..cf11a4b0b6 100644 --- a/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts +++ b/packages/framer-motion/src/animation/generators/__tests__/spring.test.ts @@ -166,3 +166,56 @@ describe("spring", () => { expect(duration).toBe(600) }) }) + +describe("visualDuration", () => { + test("returns correct duration", () => { + const generator = spring({ keyframes: [0, 1], visualDuration: 0.5 }) + + expect(calcGeneratorDuration(generator)).toBe(1100) + }) + + test("correctly resolves shorthand", () => { + expect( + spring({ + keyframes: [0, 1], + visualDuration: 0.5, + bounce: 0.25, + }).toString() + ).toEqual(spring(0.5, 0.25).toString()) + }) +}) + +describe("toString", () => { + test("returns correct string", () => { + const physicsSpring = spring({ + keyframes: [0, 1], + stiffness: 100, + damping: 10, + mass: 1, + }) + + expect(physicsSpring.toString()).toBe( + "1100ms linear(0, 0.04194850778210579, 0.14932950126380995, 0.2963437796500159, 0.46082096429364294, 0.6250338421482647, 0.7759436260445078, 0.9050063854127511, 1.0076716369056948, 1.08269417017245, 1.1313632152119162, 1.1567322462156397, 1.162910735616452, 1.1544580838082632, 1.135901202975243, 1.1113817077446062, 1.0844267521809636, 1.0578292518205104, 1.0336182525290103, 1.0130980771054825, 0.9969350165150217, 0.9852721277431441, 0.9778555743946217, 0.9741593857917341, 0.9734990920012613, 0.9751280926296118, 0.9783136126561669, 0.9823915565398572, 0.9868014376321201, 0.9911038405316862, 0.9949836212722639, 0.9982423449158154, 1.000783397865038, 1.0025928919562468, 1.0037189926384433, 1.0042517362931451, 1)" + ) + + const durationSpring = spring({ + keyframes: [0, 1], + duration: 800, + bounce: 0.25, + }) + + expect(durationSpring.toString()).toBe( + "800ms linear(0, 0.054177405016021196, 0.17972848064273883, 0.3343501584874773, 0.4905262924508025, 0.6320839235932971, 0.7510610411804188, 0.8450670152703789, 0.9151922015692906, 0.9644450242720048, 0.9966514779382684, 1.0157332114866835, 1.025277758976255, 1.0283215605911404, 1.02727842591652, 1.023959776584356, 1.0196463059772216, 1.015182448481659, 1.0110747373077722, 1.0075826495638103, 1.0047960480865514, 1.0026971231302306, 1.001207153877063, 1.0002197848978414, 0.999623144749416, 0.9993132705317652, 1)" + ) + + const visualDurationSpring = spring({ + keyframes: [0, 1], + visualDuration: 0.5, + bounce: 0.25, + }) + + expect(visualDurationSpring.toString()).toBe( + "850ms linear(0, 0.04598659284844886, 0.1550978525021358, 0.293435416964683, 0.4378422541508299, 0.5736798672774968, 0.692748833850205, 0.7914734576133282, 0.8694017506567162, 0.9280251869268804, 0.9698937637734765, 0.9979863588386336, 1.0152902865362166, 1.024544165390638, 1.028102233067043, 1.027884258234232, 1.0253819114839424, 1.0216990275613216, 1.0176091156745415, 1.0136185020587367, 1.0100275420648654, 1.0069854535323504, 1.0045366006169996, 1.0026576306325918, 1.0012858767919226, 1.0003400208170226, 0.999734279313089, 1)" + ) + }) +}) diff --git a/packages/framer-motion/src/animation/generators/spring/defaults.ts b/packages/framer-motion/src/animation/generators/spring/defaults.ts new file mode 100644 index 0000000000..f90863022f --- /dev/null +++ b/packages/framer-motion/src/animation/generators/spring/defaults.ts @@ -0,0 +1,28 @@ +export const springDefaults = { + // Default spring physics + stiffness: 100, + damping: 10, + mass: 1.0, + velocity: 0.0, + + // Default duration/bounce-based options + duration: 800, // in ms + bounce: 0.3, + visualDuration: 0.3, // in seconds + + // Rest thresholds + restSpeed: { + granular: 0.01, + default: 2, + }, + restDelta: { + granular: 0.005, + default: 0.5, + }, + + // Limits + minDuration: 0.01, // in seconds + maxDuration: 10.0, // in seconds + minDamping: 0.05, + maxDamping: 1, +} diff --git a/packages/framer-motion/src/animation/generators/spring/find.ts b/packages/framer-motion/src/animation/generators/spring/find.ts index b7e3f60d1c..c1b7bcebf7 100644 --- a/packages/framer-motion/src/animation/generators/spring/find.ts +++ b/packages/framer-motion/src/animation/generators/spring/find.ts @@ -5,6 +5,7 @@ import { millisecondsToSeconds, secondsToMilliseconds, } from "../../../utils/time-conversion" +import { springDefaults } from "./defaults" /** * This is ported from the Framer implementation of duration-based spring resolution. @@ -13,22 +14,18 @@ import { type Resolver = (num: number) => number const safeMin = 0.001 -export const minDuration = 0.01 -export const maxDuration = 10.0 -export const minDamping = 0.05 -export const maxDamping = 1 export function findSpring({ - duration = 800, - bounce = 0.25, - velocity = 0, - mass = 1, + duration = springDefaults.duration, + bounce = springDefaults.bounce, + velocity = springDefaults.velocity, + mass = springDefaults.mass, }: SpringOptions) { let envelope: Resolver let derivative: Resolver warning( - duration <= secondsToMilliseconds(maxDuration), + duration <= secondsToMilliseconds(springDefaults.maxDuration), "Spring duration must be 10 seconds or less" ) @@ -37,8 +34,16 @@ export function findSpring({ /** * Restrict dampingRatio and duration to within acceptable ranges. */ - dampingRatio = clamp(minDamping, maxDamping, dampingRatio) - duration = clamp(minDuration, maxDuration, millisecondsToSeconds(duration)) + dampingRatio = clamp( + springDefaults.minDamping, + springDefaults.maxDamping, + dampingRatio + ) + duration = clamp( + springDefaults.minDuration, + springDefaults.maxDuration, + millisecondsToSeconds(duration) + ) if (dampingRatio < 1) { /** @@ -87,8 +92,8 @@ export function findSpring({ duration = secondsToMilliseconds(duration) if (isNaN(undampedFreq)) { return { - stiffness: 100, - damping: 10, + stiffness: springDefaults.stiffness, + damping: springDefaults.damping, duration, } } else { diff --git a/packages/framer-motion/src/animation/generators/spring/index.ts b/packages/framer-motion/src/animation/generators/spring/index.ts index e2965fcada..03775d5799 100644 --- a/packages/framer-motion/src/animation/generators/spring/index.ts +++ b/packages/framer-motion/src/animation/generators/spring/index.ts @@ -1,3 +1,4 @@ +import { generateLinearEasing } from "../../animators/waapi/utils/linear" import { millisecondsToSeconds, secondsToMilliseconds, @@ -6,6 +7,10 @@ import { ValueAnimationOptions, SpringOptions } from "../../types" import { AnimationState, KeyframeGenerator } from "../types" import { calcGeneratorVelocity } from "../utils/velocity" import { calcAngularFreq, findSpring } from "./find" +import { calcGeneratorDuration } from "../utils/calc-duration" +import { maxGeneratorDuration } from "../utils/calc-duration" +import { clamp } from "../../../utils/clamp" +import { springDefaults } from "./defaults" const durationKeys = ["duration", "bounce"] const physicsKeys = ["stiffness", "damping", "mass"] @@ -16,10 +21,10 @@ function isSpringType(options: SpringOptions, keys: string[]) { function getSpringOptions(options: SpringOptions) { let springOptions = { - velocity: 0.0, - stiffness: 100, - damping: 10, - mass: 1.0, + velocity: springDefaults.velocity, + stiffness: springDefaults.stiffness, + damping: springDefaults.damping, + mass: springDefaults.mass, isResolvedFromDuration: false, ...options, } @@ -28,27 +33,53 @@ function getSpringOptions(options: SpringOptions) { !isSpringType(options, physicsKeys) && isSpringType(options, durationKeys) ) { - const derived = findSpring(options) + if (options.visualDuration) { + const visualDuration = options.visualDuration + const root = (2 * Math.PI) / (visualDuration * 1.2) + const stiffness = root * root + const damping = + 2 * clamp(0.05, 1, 1 - options.bounce!) * Math.sqrt(stiffness) + + springOptions = { + ...springOptions, + mass: springDefaults.mass, + stiffness, + damping, + } + } else { + const derived = findSpring(options) - springOptions = { - ...springOptions, - ...derived, - mass: 1.0, + springOptions = { + ...springOptions, + ...derived, + mass: springDefaults.mass, + } + springOptions.isResolvedFromDuration = true } - springOptions.isResolvedFromDuration = true } return springOptions } -export function spring({ - keyframes, - restDelta, - restSpeed, - ...options -}: ValueAnimationOptions): KeyframeGenerator { - const origin = keyframes[0] - const target = keyframes[keyframes.length - 1] +export function spring( + optionsOrVisualDuration: + | ValueAnimationOptions + | number = springDefaults.visualDuration, + bounce = springDefaults.bounce +): KeyframeGenerator { + const options = + typeof optionsOrVisualDuration !== "object" + ? ({ + visualDuration: optionsOrVisualDuration, + keyframes: [0, 1], + bounce, + } as ValueAnimationOptions) + : optionsOrVisualDuration + + let { restSpeed, restDelta } = options + + const origin = options.keyframes[0] + const target = options.keyframes[options.keyframes.length - 1] /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator @@ -84,8 +115,12 @@ export function spring({ * ratio between feeling good and finishing as soon as changes are imperceptible. */ const isGranularScale = Math.abs(initialDelta) < 5 - restSpeed ||= isGranularScale ? 0.01 : 2 - restDelta ||= isGranularScale ? 0.005 : 0.5 + restSpeed ||= isGranularScale + ? springDefaults.restSpeed.granular + : springDefaults.restSpeed.default + restDelta ||= isGranularScale + ? springDefaults.restDelta.granular + : springDefaults.restDelta.default let resolveSpring: (v: number) => number if (dampingRatio < 1) { @@ -137,7 +172,7 @@ export function spring({ } } - return { + const generator = { calculatedDuration: isResolvedFromDuration ? duration || null : null, next: (t: number) => { const current = resolveSpring(t) @@ -172,5 +207,22 @@ export function spring({ return state }, + toString: () => { + const calculatedDuration = Math.min( + calcGeneratorDuration(generator), + maxGeneratorDuration + ) + + const easing = generateLinearEasing( + (progress: number) => + generator.next(calculatedDuration * progress).value, + calculatedDuration, + 30 + ) + + return calculatedDuration + "ms " + easing + }, } + + return generator } diff --git a/packages/framer-motion/src/animation/generators/spring/utils.ts b/packages/framer-motion/src/animation/generators/spring/utils.ts new file mode 100644 index 0000000000..0519ecba6e --- /dev/null +++ b/packages/framer-motion/src/animation/generators/spring/utils.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/framer-motion/src/animation/generators/types.ts b/packages/framer-motion/src/animation/generators/types.ts index ced28bd827..cb879b3373 100644 --- a/packages/framer-motion/src/animation/generators/types.ts +++ b/packages/framer-motion/src/animation/generators/types.ts @@ -6,4 +6,5 @@ export interface AnimationState { export interface KeyframeGenerator { calculatedDuration: null | number next: (t: number) => AnimationState + toString: () => string } diff --git a/packages/framer-motion/src/animation/generators/utils/calc-duration.ts b/packages/framer-motion/src/animation/generators/utils/calc-duration.ts index 8c1666cc7b..793d46fcc0 100644 --- a/packages/framer-motion/src/animation/generators/utils/calc-duration.ts +++ b/packages/framer-motion/src/animation/generators/utils/calc-duration.ts @@ -5,7 +5,9 @@ import { KeyframeGenerator } from "../types" * to prevent infinite loops */ export const maxGeneratorDuration = 20_000 -export function calcGeneratorDuration(generator: KeyframeGenerator) { +export function calcGeneratorDuration( + generator: KeyframeGenerator +): number { let duration = 0 const timeStep = 50 let state = generator.next(duration) diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 84c05b6024..e572abe687 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -206,6 +206,7 @@ export interface AnimationPlaybackOptions { export interface DurationSpringOptions { duration?: number + visualDuration?: number bounce?: number } diff --git a/packages/framer-motion/src/types.ts b/packages/framer-motion/src/types.ts index 3c58811618..53b97284e0 100644 --- a/packages/framer-motion/src/types.ts +++ b/packages/framer-motion/src/types.ts @@ -497,6 +497,26 @@ export interface Spring extends Repeat { */ duration?: number + /** + * If visualDuration is set, this will override duration. + * + * The visual duration is a time, set in seconds, that the animation will take to visually appear to reach its target. + * + * In other words, the bulk of the transition will occur before this time, and the "bouncy bit" will mostly happen after. + * + * This makes it easier to edit a spring, as well as visually coordinate it with other time-based animations. + * + * ```jsx + * + * ``` + * + * @public + */ + visualDuration?: number + /** * `bounce` determines the "bounciness" of a spring animation. *