diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md new file mode 100644 index 0000000000000..7af3d73a92a98 --- /dev/null +++ b/.changeset/uikit-motion-math.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index bbdf52edabea3..7fb90666fc0a9 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -56,6 +56,7 @@ installed); rows marked † also on iOS 18 for the pre-glass delta. iPhone | M-CA1 | Settling duration | none: `CASpringAnimation` mass 1, stiffness 100, damping 5, 10, 15, 20, 40 × initialVelocity 0, 5, −5 × allowsOverdamping on, off | `settlingDuration` of each (checks the critically damped and overdamped branches) | D | `springs.ts` (settling) | | M-T1 | Banner | A notification pushed to the simulator while an app is in the foreground | banner y(t) in and out; time on screen | B | `springs.banner` | | M-RM1 | Reduced Motion | Reduce Motion on, Prefer Cross-Fade Transitions on and off: Settings push and pop, an alert (sheets: M-SH13; controls: M-SW11, M-SL12, M-SG15) | which transitions become cross-fades; alpha(t), duration and curve; what still moves | B | `timing.reducedMotionCrossFadeMs` (a reference reading, not played: `spec:AST-094` FR8) | +| M-PR1 | Press release curve | Settings and Contacts: a row tapped and released (the deselect cross-fade) | the timing function Core Animation attaches to the deselect fade (control points) | A | `timing.press.highlightOutCurve` | | M-NB1 | Navigation bar hide / show | none | `UINavigationController.hideShowBarDuration` | D | `timing.navigationBarHideShowMs` | Done (UIKit lab, iOS 26.2 simulator, Oct 23 2026; values in `uikit/` tagged diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index eee925f2d8f11..1de471ca791e4 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -1,12 +1,12 @@ # Motion -Internal motion foundation for the touch presentations of core components. It -starts with the UIKit constants table: every spring, duration, deceleration -rate, rubber-band constant, curve and gesture threshold a touch presentation -uses, each with its source. Nothing here is exported from the package entry -point. Core's motion and gesture modules read it by path; components take -touch motion from the theme's motion roles (`spec:AST-094` FR4), not from -this table. +Internal motion foundation for the touch presentations of core components: the +UIKit constants table (every spring, duration, deceleration rate, rubber-band +constant, curve and gesture threshold a touch presentation uses, each with its +source) and the spring, decay, rubber-band and projection math UIKit uses. +Nothing here is exported from the package entry point. Core's motion and +gesture modules read the table by path; components take touch motion from the +theme's motion roles (`spec:AST-094` FR4), not from this table. ## Files @@ -19,11 +19,35 @@ this table. | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | | `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). | | `MEASUREMENTS.md` | The UIKit reference captures each unconfirmed value still needs. | +| `spring.ts` | Closed-form damped spring, every Apple parameterization, settling duration, a spring chasing a moving target. | +| `decay.ts` | UIScrollView's momentum coast. | +| `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. | +| `projection.ts` | Release targets: the rest point nearest the projected coast; paging. | Component-specific values (a sheet's detents, a switch's geometry, a context menu's timeline) live in a domain file per component, `uikit/.ts`, under the same rules. +## Math + +`spring.ts` solves m·x″ + c·x′ + k·x = 0 in closed form in all three regimes: +the model behind SwiftUI's `Spring`, `CASpringAnimation` and +`UISpringTimingParameters`. A spring description stays in its source's +vocabulary (mass, stiffness and damping; duration and bounce; response and +damping ratio) and `toSpring` normalizes it. A mass, stiffness and damping +description that does not allow overdamping is clamped to critical, as Core +Animation does for every spring UIKit attaches. The tests hold it to an +independent fourth-order Runge-Kutta integration over a sweep of damping +ratios, responses and release velocities; to a port of WebKit's own spring +solver; to the WWDC23 "Animate with springs" conversion equations; and to +values read from UIKit on the iOS 26.2 simulator: `settlingDuration` for a +grid of springs and velocities, and the durations UIKit gives its spring APIs, +which the settling formula reproduces to the last digit (underdamped) or within +0.02 % (critically damped). `decay.ts` is held to a literal per-millisecond +simulation of the deceleration rate and to WWDC18's projection function; +`rubberBand.ts` to UIScrollView's overscroll measured on the simulator. +`uikit/values.test.ts` plays the table's values through this math. + ## Sources Every value in `uikit/` carries its source next to it, as a JSDoc tag on the diff --git a/packages/core/src/motion/decay.test.ts b/packages/core/src/motion/decay.test.ts new file mode 100644 index 0000000000000..41d5bee1b6eb3 --- /dev/null +++ b/packages/core/src/motion/decay.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file decay.test.ts + * @input vitest, the decay model + * @output Checks the continuous coast against an independent per-millisecond + * simulation of UIScrollView's deceleration, against WWDC18's projection, + * and every derived time (settle, speed, reach) + * @position Testing; validates decay.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import {decayRestDistance, solveDecay} from './decay'; +import {projectDistance} from './projection'; +import {scroll} from './uikit/scroll'; + +/** + * Independent reference: the deceleration rate applied literally, the + * velocity multiplied by the rate once per millisecond and integrated + * exactly over each millisecond step. + */ +function perMillisecond( + from: number, + velocity: number, + rate: number, + ms: number, +) { + let x = from; + let v = velocity / 1000; // units per ms + const k = -Math.log(rate); + for (let i = 0; i < ms; i++) { + // Within one ms the speed falls from v to v·rate exponentially. + x += (v * (1 - rate)) / k; + v *= rate; + } + return {value: x, velocity: v * 1000}; +} + +describe('solveDecay', () => { + for (const rate of [ + scroll.decelerationRate.normal, + scroll.decelerationRate.fast, + ]) { + for (const velocity of [-3000, -400, 150, 2500, 8000]) { + it(`matches a per-millisecond simulation (rate ${rate}, ${velocity} units/s)`, () => { + const coast = solveDecay({from: 100, velocity, rate}); + for (const ms of [0, 1, 16, 100, 250, 600, 1500, 4000]) { + const reference = perMillisecond(100, velocity, rate, ms); + expect(coast.value(ms / 1000)).toBeCloseTo(reference.value, 6); + expect(coast.velocity(ms / 1000)).toBeCloseTo(reference.velocity, 6); + } + }); + } + } + + it('rests where the coast ends, 0.1 % past WWDC18\u2019s geometric projection', () => { + const coast = solveDecay({from: 0, velocity: 2000}); + expect(coast.rest).toBeCloseTo(2000 / (-1000 * Math.log(0.998)), 9); + expect(coast.value(60)).toBeCloseTo(coast.rest, 6); + // −1 ÷ ln(0.998) = 499.5 ms against 0.998 ÷ 0.002 = 499 ms. + expect(coast.rest / projectDistance(2000)).toBeCloseTo(499.5 / 499, 3); + expect(decayRestDistance(2000)).toBeCloseTo(coast.rest, 9); + }); + + it('a fast rate stops sooner and shorter', () => { + const normal = solveDecay({from: 0, velocity: 2000}); + const fast = solveDecay({ + from: 0, + velocity: 2000, + rate: scroll.decelerationRate.fast, + }); + expect(fast.rest).toBeLessThan(normal.rest); + expect(fast.settleTime(0.5)).toBeLessThan(normal.settleTime(0.5)); + }); + + it('settleTime bounds every later distance to rest', () => { + const coast = solveDecay({from: 0, velocity: -5000}); + for (const threshold of [5, 0.5, 0.01]) { + const settle = coast.settleTime(threshold); + expect(Math.abs(coast.value(settle) - coast.rest)).toBeCloseTo( + threshold, + 9, + ); + expect(Math.abs(coast.value(settle + 0.5) - coast.rest)).toBeLessThan( + threshold, + ); + } + expect(solveDecay({from: 0, velocity: 0.1}).settleTime(0.5)).toBe(0); + }); + + it('timeToSpeed lands on the asked speed', () => { + const coast = solveDecay({from: 0, velocity: 3000}); + const t = coast.timeToSpeed(100); + expect(coast.velocity(t)).toBeCloseTo(100, 9); + expect(coast.timeToSpeed(5000)).toBe(0); + expect(coast.timeToSpeed(0)).toBe(Infinity); + }); + + it('timeToReach finds the crossing, or null when the coast never gets there', () => { + const coast = solveDecay({from: 0, velocity: 2000}); + const t = coast.timeToReach(400); + expect(t).not.toBeNull(); + expect(coast.value(t ?? 0)).toBeCloseTo(400, 9); + expect(coast.timeToReach(-10)).toBeNull(); + expect(coast.timeToReach(coast.rest)).toBeNull(); + expect(coast.timeToReach(coast.rest + 1)).toBeNull(); + const back = solveDecay({from: 0, velocity: -2000}); + expect(back.value(back.timeToReach(-400) ?? 0)).toBeCloseTo(-400, 9); + expect(solveDecay({from: 3, velocity: 0}).timeToReach(3)).toBe(0); + }); + + it('rejects a rate outside (0, 1)', () => { + expect(() => solveDecay({from: 0, velocity: 1, rate: 1})).toThrow( + RangeError, + ); + expect(() => solveDecay({from: 0, velocity: 1, rate: 0})).toThrow( + RangeError, + ); + }); +}); diff --git a/packages/core/src/motion/decay.ts b/packages/core/src/motion/decay.ts new file mode 100644 index 0000000000000..be005048d06da --- /dev/null +++ b/packages/core/src/motion/decay.ts @@ -0,0 +1,126 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file decay.ts + * @input A release value, velocity and a UIScrollView deceleration rate + * @output Exports `solveDecay`, the closed-form momentum coast of a scroll + * view after the finger lifts (value, velocity, rest point, settle time, + * time to reach a value), and `decayRestDistance` + * @position Pure math under the motion animator; no DOM, no React + * + * UIScrollView's deceleration rate is a per-millisecond velocity factor: the + * speed is multiplied by the rate every millisecond (0.998 normal, 0.99 fast; + * see uikit/scroll.ts). As a continuous decay that is + * v(t) = v₀·e^(−λt), x(t) = x₀ + v₀·(1 − e^(−λt)) ÷ λ, λ = −1000·ln(rate) /s + * and the coast ends at x₀ + v₀ ÷ λ. WWDC18 "Designing Fluid Interfaces" + * projects the same rest point with the geometric series v₀·rate ÷ (1 − rate) + * per millisecond (projection.ts), 0.1 % shorter at the normal rate (499.0 + * against 499.5 ms of the release speed); the continuous form is the one + * that also gives the position and velocity at every instant, which the + * animator needs to grab a coasting surface mid-flight. + * + * Units: time in seconds, velocity in units per second. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/decay.test.ts + * - /packages/core/src/motion/README.md + */ + +import {scroll} from './uikit/scroll'; + +/** A coast released at `from` with `initialVelocity`. */ +export interface DecayCurve { + readonly from: number; + /** Units per second. */ + readonly initialVelocity: number; + /** Per-millisecond velocity factor. */ + readonly rate: number; + /** Where the coast comes to rest. */ + readonly rest: number; + /** Value `t` seconds after release. */ + value(t: number): number; + /** Velocity `t` seconds after release, units/s. */ + velocity(t: number): number; + /** Seconds until the value is within `threshold` of `rest` for good. */ + settleTime(threshold: number): number; + /** Seconds until the speed falls to `speed` units/s (0 if already slower). */ + timeToSpeed(speed: number): number; + /** + * Seconds until the coast reaches `target`, or null when it never gets + * there (behind the release point, or at or past the rest point). + */ + timeToReach(target: number): number | null; +} + +function decayConstant(rate: number): number { + if (!(rate > 0 && rate < 1)) { + throw new RangeError(`decay: rate must be in (0, 1), got ${rate}`); + } + return -1000 * Math.log(rate); +} + +/** How far a coast at `velocity` units/s travels before it stops. */ +export function decayRestDistance( + velocity: number, + rate: number = scroll.decelerationRate.normal, +): number { + return velocity / decayConstant(rate); +} + +/** + * The momentum coast UIScrollView performs after the finger lifts. + * + * @example + * ``` + * const coast = solveDecay({from: 0, velocity: 2000}); + * coast.rest; // ≈ 999 px + * coast.value(0.2); // px after 200 ms + * ``` + */ +export function solveDecay({ + from, + velocity: initialVelocity, + rate = scroll.decelerationRate.normal, +}: { + from: number; + velocity: number; + rate?: number; +}): DecayCurve { + const lambda = decayConstant(rate); + const rest = from + initialVelocity / lambda; + const speed0 = Math.abs(initialVelocity); + return { + from, + initialVelocity, + rate, + rest, + value: t => from + (initialVelocity * -Math.expm1(-lambda * t)) / lambda, + velocity: t => initialVelocity * Math.exp(-lambda * t), + settleTime: threshold => { + const remaining = speed0 / lambda; + if (!(threshold > 0)) { + return Infinity; + } + return remaining <= threshold + ? 0 + : Math.log(remaining / threshold) / lambda; + }, + timeToSpeed: speed => + speed0 <= speed + ? 0 + : speed > 0 + ? Math.log(speed0 / speed) / lambda + : Infinity, + timeToReach: target => { + if (initialVelocity === 0) { + return target === from ? 0 : null; + } + // 1 − e^(−λt) = λ·(target − from) ÷ v₀ + const fraction = (lambda * (target - from)) / initialVelocity; + if (fraction < 0 || fraction >= 1) { + return null; + } + return -Math.log1p(-fraction) / lambda; + }, + }; +} diff --git a/packages/core/src/motion/projection.test.ts b/packages/core/src/motion/projection.test.ts new file mode 100644 index 0000000000000..21bdae9f96c31 --- /dev/null +++ b/packages/core/src/motion/projection.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file projection.test.ts + * @input vitest, the projection functions + * @output Checks WWDC18's projection verbatim and the release-target rules + * (nearest projected rest, step limits, paging) + * @position Testing; validates projection.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import { + project, + projectDistance, + selectPage, + selectSnapPoint, +} from './projection'; +import {scroll} from './uikit/scroll'; + +describe('projectDistance', () => { + it('is WWDC18\u2019s (v ÷ 1000)·r ÷ (1 − r)', () => { + for (const velocity of [-2500, -1, 0, 640, 3000]) { + for (const rate of [0.998, 0.99]) { + expect(projectDistance(velocity, rate)).toBeCloseTo( + ((velocity / 1000) * rate) / (1 - rate), + 9, + ); + } + } + }); + + it('projects 499 ms of the release speed at the normal rate (the sheet\u2019s prior art)', () => { + expect(projectDistance(1000)).toBeCloseTo(499, 9); + expect(projectDistance(1000, scroll.decelerationRate.fast)).toBeCloseTo( + 99, + 9, + ); + }); + + it('a release at rest projects onto itself', () => { + expect(project(321, 0)).toBe(321); + }); + + it('rejects a rate outside (0, 1)', () => { + expect(() => projectDistance(10, 1)).toThrow(RangeError); + }); +}); + +describe('selectSnapPoint', () => { + const detents = [0, 300, 600]; + + it('goes to the point nearest the projected rest, not the release point', () => { + // Released nearer 300, but thrown toward 600. + expect(selectSnapPoint(detents, {position: 320, velocity: 900}).value).toBe( + 600, + ); + // A slow release places. + expect(selectSnapPoint(detents, {position: 320, velocity: 20}).value).toBe( + 300, + ); + // Thrown the other way. + expect( + selectSnapPoint(detents, {position: 320, velocity: -900}).value, + ).toBe(0); + }); + + it('a projection past the ends lands on the end', () => { + expect( + selectSnapPoint(detents, {position: 580, velocity: 6000}).value, + ).toBe(600); + expect( + selectSnapPoint(detents, {position: 10, velocity: -6000}).index, + ).toBe(0); + }); + + it('reports the projection it chose from', () => { + const choice = selectSnapPoint(detents, {position: 100, velocity: 400}); + expect(choice.projected).toBeCloseTo(100 + 0.4 * 499, 9); + }); + + it('limits the travel to maxSteps points from fromIndex', () => { + const pages = [0, 100, 200, 300, 400]; + expect(selectSnapPoint(pages, {position: 110, velocity: 5000}).value).toBe( + 400, + ); + expect( + selectSnapPoint(pages, { + position: 110, + velocity: 5000, + fromIndex: 1, + maxSteps: 1, + }).value, + ).toBe(200); + }); + + it('breaks an exact tie toward the direction of travel', () => { + expect( + selectSnapPoint([0, 100], {position: 50, velocity: 0.0001, rate: 0.5}) + .value, + ).toBe(100); + expect( + selectSnapPoint([0, 100], {position: 50, velocity: -0.0001, rate: 0.5}) + .value, + ).toBe(0); + }); + + it('needs at least one point', () => { + expect(() => selectSnapPoint([], {position: 0, velocity: 0})).toThrow( + RangeError, + ); + }); +}); + +describe('selectPage', () => { + it('lands on a real page when the current page is past the end', () => { + const result = selectPage({ + position: 400, + velocity: 0, + pageSize: 100, + pageCount: 3, + currentPage: 4, + }); + expect(result.value).toBeDefined(); + expect(result.index).toBeGreaterThanOrEqual(0); + expect(result.index).toBeLessThanOrEqual(2); + }); + + it('moves at most one page per release', () => { + const page = (position: number, velocity: number, currentPage: number) => + selectPage({position, velocity, pageSize: 390, pageCount: 6, currentPage}) + .index; + expect(page(390, 0, 1)).toBe(1); + expect(page(430, 8000, 1)).toBe(2); + expect(page(350, -8000, 1)).toBe(0); + // A slow drag past half a page settles on the next page. + expect(page(600, 0, 1)).toBe(2); + expect(page(1950, 9000, 5)).toBe(5); + }); +}); diff --git a/packages/core/src/motion/projection.ts b/packages/core/src/motion/projection.ts new file mode 100644 index 0000000000000..8f0d0c8b920c0 --- /dev/null +++ b/packages/core/src/motion/projection.ts @@ -0,0 +1,148 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file projection.ts + * @input A release position and velocity, candidate rest points + * @output Exports `projectDistance` / `project` (WWDC18's momentum + * projection), `selectSnapPoint` (the release target UIKit-style) and + * `selectPage` (paging: at most one page per release) + * @position Pure math; no DOM, no React + * + * WWDC18 "Designing Fluid Interfaces" (session 803): a thrown object goes to + * the endpoint nearest to where it WOULD coast if it were scrolled, not the + * endpoint nearest to where the finger let go. The talk's projection + * function, verbatim in units/s: + * + * func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { + * return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) + * } + * + * used with `UIScrollView.DecelerationRate.normal`. A slow release projects + * onto itself, so it places; a flick continues past the finger. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/projection.test.ts + * - /packages/core/src/motion/README.md + */ + +import {scroll} from './uikit/scroll'; + +/** Distance a surface released at `velocity` units/s coasts (WWDC18). */ +export function projectDistance( + velocity: number, + rate: number = scroll.decelerationRate.normal, +): number { + if (!(rate > 0 && rate < 1)) { + throw new RangeError(`projection: rate must be in (0, 1), got ${rate}`); + } + return ((velocity / 1000) * rate) / (1 - rate); +} + +/** Where a surface released at `position` with `velocity` would come to rest. */ +export function project( + position: number, + velocity: number, + rate: number = scroll.decelerationRate.normal, +): number { + return position + projectDistance(velocity, rate); +} + +export interface SnapSelection { + /** Index of the chosen point in the list passed in. */ + readonly index: number; + /** The chosen point. */ + readonly value: number; + /** The projected rest position the choice was made from. */ + readonly projected: number; +} + +/** + * The rest point a release goes to: the one nearest the projected rest + * position. `fromIndex` + `maxSteps` limit how many points away from the + * starting one a single release may travel (by index order; pass points + * sorted along the axis). Ties go to the point in the direction of travel. + * + * @example + * ``` + * // A sheet with detents at 0, 300 and 600 px, released at 320 px moving + * // 900 px/s toward 600: projected ≈ 769 → 600. + * selectSnapPoint([0, 300, 600], {position: 320, velocity: 900}); + * ``` + */ +export function selectSnapPoint( + points: ReadonlyArray, + { + position, + velocity, + rate = scroll.decelerationRate.normal, + fromIndex, + maxSteps, + }: { + position: number; + velocity: number; + rate?: number; + fromIndex?: number; + maxSteps?: number; + }, +): SnapSelection { + if (points.length === 0) { + throw new RangeError( + 'projection: selectSnapPoint needs at least one point', + ); + } + const projected = project(position, velocity, rate); + let lo = 0; + let hi = points.length - 1; + if (fromIndex != null && maxSteps != null) { + // A stale index past either end counts from the nearest real point. + const from = Math.min(Math.max(fromIndex, 0), points.length - 1); + lo = Math.max(lo, from - maxSteps); + hi = Math.min(hi, from + maxSteps); + } + let best = lo; + let bestDistance = Infinity; + for (let i = lo; i <= hi; i++) { + const distance = Math.abs(points[i] - projected); + const isTie = Math.abs(distance - bestDistance) < 1e-9; + const isAhead = Math.sign(points[i] - points[best]) === Math.sign(velocity); + if (distance < bestDistance - 1e-9 || (isTie && isAhead)) { + best = i; + bestDistance = distance; + } + } + return {index: best, value: points[best], projected}; +} + +/** + * UIScrollView paging (`isPagingEnabled`): the page a release settles on, at + * most one page from `currentPage`, chosen by projecting the release with + * the paging projection rate (uikit/scroll.ts; a GUESS until measured). + * Pages are `pageSize` apart starting at 0. + */ +export function selectPage({ + position, + velocity, + pageSize, + pageCount, + currentPage, + rate = scroll.paging.projectionRate, +}: { + position: number; + velocity: number; + pageSize: number; + pageCount: number; + currentPage: number; + rate?: number; +}): SnapSelection { + const points = Array.from( + {length: Math.max(1, pageCount)}, + (_, i) => i * pageSize, + ); + return selectSnapPoint(points, { + position, + velocity, + rate, + fromIndex: currentPage, + maxSteps: 1, + }); +} diff --git a/packages/core/src/motion/rubberBand.test.ts b/packages/core/src/motion/rubberBand.test.ts new file mode 100644 index 0000000000000..3328d94eab608 --- /dev/null +++ b/packages/core/src/motion/rubberBand.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rubberBand.test.ts + * @input vitest, the rubber-band functions + * @output Checks the formula against a published device measurement of + * UIScrollView, its slope, inverse and clamp round trips + * @position Testing; validates rubberBand.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import { + rubberBand, + rubberBandClamp, + rubberBandInverse, + rubberBandSlope, + rubberBandUnclamp, +} from './rubberBand'; + +describe('rubberBand', () => { + it('reproduces the published UIScrollView measurement (812 pt view)', () => { + // Finger 500 / 1000 / 1500 pt past the edge displayed 205 / 328 / 409 pt + // (I. Lobanov, "How UIScrollView works"). + expect(Math.abs(rubberBand(500, 812) - 205)).toBeLessThan(0.5); + expect(Math.abs(rubberBand(1000, 812) - 328)).toBeLessThan(0.5); + expect(Math.abs(rubberBand(1500, 812) - 409)).toBeLessThan(0.5); + }); + + it('is (1 − 1 ÷ (x·c ÷ d + 1))·d with c = 0.55', () => { + for (const x of [0, 1, 10, 80, 300, 2000]) { + expect(rubberBand(x, 600)).toBeCloseTo( + (1 - 1 / ((x * 0.55) / 600 + 1)) * 600, + 9, + ); + } + expect(rubberBand(100, 600, 0.3)).toBeCloseTo( + (1 - 1 / ((100 * 0.3) / 600 + 1)) * 600, + 9, + ); + }); + + it('keeps the sign, never reaches the dimension, starts at slope c', () => { + expect(rubberBand(-120, 700)).toBeCloseTo(-rubberBand(120, 700), 12); + expect(rubberBand(1e9, 700)).toBeLessThan(700); + expect(rubberBandSlope(0, 700)).toBeCloseTo(0.55, 12); + for (const x of [0, 50, 400, 3000]) { + const h = 1e-4; + const numeric = + (rubberBand(x + h, 700) - rubberBand(x - h, 700)) / (2 * h); + expect(rubberBandSlope(x, 700)).toBeCloseTo(numeric, 6); + } + }); + + it('inverts exactly', () => { + for (const x of [-900, -40, 0, 3, 75, 640, 5000]) { + expect(rubberBandInverse(rubberBand(x, 812), 812)).toBeCloseTo(x, 6); + } + expect(rubberBandInverse(812, 812)).toBe(Infinity); + expect(rubberBandInverse(-900, 812)).toBe(-Infinity); + }); + + it('a degenerate dimension bands nothing', () => { + expect(rubberBand(50, 0)).toBe(0); + expect(rubberBandInverse(50, 0)).toBe(0); + expect(rubberBandSlope(50, 0)).toBe(0); + }); +}); + +describe('rubberBandClamp', () => { + it('passes values inside the range through and bands past either edge', () => { + expect(rubberBandClamp(120, 0, 500, 800)).toBe(120); + expect(rubberBandClamp(-100, 0, 500, 800)).toBeCloseTo( + rubberBand(-100, 800), + 12, + ); + expect(rubberBandClamp(650, 0, 500, 800)).toBeCloseTo( + 500 + rubberBand(150, 800), + 12, + ); + }); + + it('round-trips through rubberBandUnclamp (a grab mid-bounce resumes in place)', () => { + for (const raw of [-600, -12, 0, 250, 500, 510, 1400]) { + const shown = rubberBandClamp(raw, 0, 500, 800); + expect(rubberBandUnclamp(shown, 0, 500, 800)).toBeCloseTo(raw, 6); + } + }); +}); diff --git a/packages/core/src/motion/rubberBand.ts b/packages/core/src/motion/rubberBand.ts new file mode 100644 index 0000000000000..bde101ec3cc5b --- /dev/null +++ b/packages/core/src/motion/rubberBand.ts @@ -0,0 +1,115 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rubberBand.ts + * @input An overflow past an edge, the scroll view's dimension and UIKit's + * rubber-band coefficient + * @output Exports `rubberBand` (finger overflow → displayed overflow), its + * inverse, its slope, and the clamp/unclamp pair over a [min, max] range + * @position Pure math; no DOM, no React + * + * UIScrollView resists a drag past its content edge with + * b = (1 − 1 ÷ (x·c ÷ d + 1))·d + * where x is how far the finger has gone past the edge, d the scroll view's + * size on that axis and c = 0.55 (uikit/scroll.ts records the sources). The + * displayed overflow b approaches d but never reaches it, and its slope at + * the edge is c: the first points of overflow move at 55 % of the finger. + * + * The inverse matters for spec:AST-094 FR6 (a finger catches any animation + * mid-flight): a finger that lands on content still bouncing back has to + * resume the drag from the finger position that would produce the overflow + * on screen, or the content jumps. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/rubberBand.test.ts + * - /packages/core/src/motion/README.md + */ + +import {scroll} from './uikit/scroll'; + +/** + * Displayed overflow for a finger `overflow` past the edge (signed: the sign + * is kept). `dimension` is the scroll view's size on the axis. + */ +export function rubberBand( + overflow: number, + dimension: number, + coefficient: number = scroll.rubberBand.coefficient, +): number { + if (!(dimension > 0)) { + return 0; + } + const x = Math.abs(overflow); + const banded = (1 - 1 / ((x * coefficient) / dimension + 1)) * dimension; + return overflow < 0 ? -banded : banded; +} + +/** The finger overflow that displays `displayed`; ±Infinity at ±dimension. */ +export function rubberBandInverse( + displayed: number, + dimension: number, + coefficient: number = scroll.rubberBand.coefficient, +): number { + if (!(dimension > 0)) { + return 0; + } + const b = Math.abs(displayed); + if (b >= dimension) { + return displayed < 0 ? -Infinity : Infinity; + } + const x = ((dimension / coefficient) * b) / (dimension - b); + return displayed < 0 ? -x : x; +} + +/** + * d(displayed) ÷ d(finger) at a finger overflow: what a finger velocity + * becomes on screen while banding (c at the edge, falling toward 0). + */ +export function rubberBandSlope( + overflow: number, + dimension: number, + coefficient: number = scroll.rubberBand.coefficient, +): number { + if (!(dimension > 0)) { + return 0; + } + const u = (Math.abs(overflow) * coefficient) / dimension + 1; + return coefficient / (u * u); +} + +/** + * The displayed value for a raw (finger-driven) `value` constrained to + * [min, max]: unchanged inside, rubber-banded past either edge. + */ +export function rubberBandClamp( + value: number, + min: number, + max: number, + dimension: number, + coefficient: number = scroll.rubberBand.coefficient, +): number { + if (value < min) { + return min + rubberBand(value - min, dimension, coefficient); + } + if (value > max) { + return max + rubberBand(value - max, dimension, coefficient); + } + return value; +} + +/** Inverse of `rubberBandClamp`: the raw value that displays `displayed`. */ +export function rubberBandUnclamp( + displayed: number, + min: number, + max: number, + dimension: number, + coefficient: number = scroll.rubberBand.coefficient, +): number { + if (displayed < min) { + return min + rubberBandInverse(displayed - min, dimension, coefficient); + } + if (displayed > max) { + return max + rubberBandInverse(displayed - max, dimension, coefficient); + } + return displayed; +} diff --git a/packages/core/src/motion/spring.test.ts b/packages/core/src/motion/spring.test.ts new file mode 100644 index 0000000000000..e770107b77dda --- /dev/null +++ b/packages/core/src/motion/spring.test.ts @@ -0,0 +1,650 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file spring.test.ts + * @input vitest, the spring model + * @output Checks the closed-form spring against an independent RK4 + * integration of the ODE across damping regimes, every conversion against + * Apple's published equations, and the settling duration against every + * published reference value + * @position Testing; validates spring.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import { + absoluteVelocity, + dampingRatioOf, + durationBounceOf, + relativeVelocity, + responseDampingOf, + responseOf, + settlingDuration, + solveSpring, + solveSpringTowardRamp, + springFromDurationBounce, + springFromPhysics, + springFromResponse, + springFromUIKitDampingDuration, + type Spring, +} from './spring'; + +/** + * Independent reference: classic fourth-order Runge-Kutta on + * m·x″ + c·x′ + k·(x − to) = 0. Shares nothing with the closed form. + */ +function rk4( + spring: Spring, + from: number, + to: number, + velocity: number, + until: number, + dt = 1e-4, +): {t: number; value: number; velocity: number}[] { + const {mass: m, stiffness: k, damping: c} = spring; + const accel = (x: number, v: number) => (-k * (x - to) - c * v) / m; + let x = from; + let v = velocity; + const out = [{t: 0, value: x, velocity: v}]; + const steps = Math.round(until / dt); + for (let i = 1; i <= steps; i++) { + const k1x = v; + const k1v = accel(x, v); + const k2x = v + (dt / 2) * k1v; + const k2v = accel(x + (dt / 2) * k1x, v + (dt / 2) * k1v); + const k3x = v + (dt / 2) * k2v; + const k3v = accel(x + (dt / 2) * k2x, v + (dt / 2) * k2v); + const k4x = v + dt * k3v; + const k4v = accel(x + dt * k3x, v + dt * k3v); + x += (dt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x); + v += (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v); + if (i % 100 === 0) { + out.push({t: i * dt, value: x, velocity: v}); + } + } + return out; +} + +const RATIOS = [ + 0, 0.05, 0.3, 0.5, 0.75, 0.9, 0.99, 0.999999, 1, 1.000001, 1.2, 2, 5, +]; +const RESPONSES = [0.15, 0.35, 0.5, 1]; +const RELEASES = [ + {from: 0, to: 1, velocity: 0}, + {from: 0, to: 300, velocity: 0}, + {from: 300, to: 0, velocity: -2500}, + {from: -120, to: 80, velocity: 4000}, + // Zero displacement, all velocity: the case UIKit's relative velocity + // cannot express. + {from: 50, to: 50, velocity: 1800}, +]; + +describe('solveSpring matches an RK4 integration of the ODE', () => { + for (const dampingRatio of RATIOS) { + for (const response of RESPONSES) { + it(`ζ=${dampingRatio}, response=${response}s`, () => { + // springFromResponse keeps overdamping, so every regime is covered. + const spring = springFromResponse({response, dampingRatio}); + for (const release of RELEASES) { + const curve = solveSpring(spring, release); + const scale = Math.max( + 1, + Math.abs(release.from - release.to), + Math.abs(release.velocity) * response, + ); + for (const sample of rk4( + spring, + release.from, + release.to, + release.velocity, + 1.5, + )) { + expect(Math.abs(curve.value(sample.t) - sample.value)).toBeLessThan( + 1e-6 * scale, + ); + expect( + Math.abs(curve.velocity(sample.t) - sample.velocity), + ).toBeLessThan((1e-6 * scale * 2 * Math.PI) / response); + } + } + }); + } + } +}); + +describe('solveSpring', () => { + it('agrees with WebKit\u2019s SpringSolver (an independent port of its code)', () => { + // Source/WebCore/platform/graphics/SpringSolver.h, verbatim in + // structure: unit move, initial velocity positive toward the target. + const webkitSolve = ( + m: number, + k: number, + c: number, + v0: number, + t: number, + ) => { + const w0 = Math.sqrt(k / m); + const zeta = c / (2 * Math.sqrt(k * m)); + let x: number; + if (zeta < 1) { + const wd = w0 * Math.sqrt(1 - zeta * zeta); + const b = (zeta * w0 + -v0) / wd; + x = + Math.exp(-t * zeta * w0) * (Math.cos(wd * t) + b * Math.sin(wd * t)); + } else { + x = (1 + (-v0 + w0) * t) * Math.exp(-t * w0); + } + return 1 - x; + }; + const cases = [ + [1, 438.64908449286042, 31.415926535897931, 0], + [1, 322.27279677026473, 31.2364069556928, 0], + [1, 100, 5, 0], + [1, 100, 20, 0], + [2, 300, 12, 4], + [1, 250, 31.6227766016838, -3], + ]; + for (const [m, k, c, v0] of cases) { + const curve = solveSpring( + springFromPhysics({mass: m, stiffness: k, damping: c}), + { + from: 0, + to: 1, + velocity: v0, + }, + ); + for (let t = 0; t <= 1.5; t += 0.01) { + expect(curve.value(t)).toBeCloseTo(webkitSolve(m, k, c, v0, t), 6); + } + } + }); + + it('starts at the release value and velocity in every regime', () => { + for (const dampingRatio of RATIOS) { + const curve = solveSpring( + springFromResponse({response: 0.4, dampingRatio}), + { + from: 10, + to: 250, + velocity: -700, + }, + ); + expect(curve.value(0)).toBeCloseTo(10, 9); + expect(curve.velocity(0)).toBeCloseTo(-700, 6); + } + }); + + it('is continuous across critical damping', () => { + const release = {from: 0, to: 300, velocity: 1200}; + const critical = solveSpring( + springFromResponse({response: 0.5, dampingRatio: 1}), + release, + ); + for (const dampingRatio of [1 - 1e-5, 1 - 1e-7, 1 + 1e-7, 1 + 1e-5]) { + const near = solveSpring( + springFromResponse({response: 0.5, dampingRatio}), + release, + ); + for (let t = 0; t <= 1.5; t += 0.01) { + expect(Math.abs(near.value(t) - critical.value(t))).toBeLessThan(0.01); + } + } + }); + + it('comes to rest at the target', () => { + for (const dampingRatio of RATIOS.filter(r => r > 0)) { + const curve = solveSpring( + springFromResponse({response: 0.3, dampingRatio}), + {from: 0, to: 100}, + ); + const settle = curve.settleTime(0.01); + expect(Math.abs(curve.value(settle + 5) - 100)).toBeLessThan(0.01); + } + }); + + it('settleTime bounds every later displacement (property sweep)', () => { + for (const dampingRatio of RATIOS.filter(r => r > 0)) { + for (const response of RESPONSES) { + for (const release of RELEASES) { + const curve = solveSpring( + springFromResponse({response, dampingRatio}), + release, + ); + for (const threshold of [1, 0.1, 0.001]) { + const settle = curve.settleTime(threshold); + expect(Number.isFinite(settle)).toBe(true); + for (let i = 0; i <= 400; i++) { + const t = settle + (i / 400) * 3 * response; + expect(Math.abs(curve.value(t) - release.to)).toBeLessThanOrEqual( + threshold * (1 + 1e-9), + ); + } + } + } + } + } + }); + + it('settleTime is 0 when already settled, Infinity when undamped and displaced', () => { + const spring = springFromResponse({response: 0.4, dampingRatio: 0.8}); + expect(solveSpring(spring, {from: 5, to: 5}).settleTime(0.5)).toBe(0); + expect(solveSpring(spring, {from: 5.1, to: 5}).settleTime(0.5)).toBe(0); + const undamped = springFromResponse({response: 0.4, dampingRatio: 0}); + expect(solveSpring(undamped, {from: 0, to: 1}).settleTime(0.001)).toBe( + Infinity, + ); + }); + + it('an undamped spring oscillates with the response as its period (WWDC23: bounce 1 is a cosine)', () => { + const curve = solveSpring( + springFromDurationBounce({duration: 0.5, bounce: 1}), + {from: 0, to: 1}, + ); + for (let t = 0; t <= 2; t += 0.05) { + expect(curve.value(t)).toBeCloseTo( + 1 - Math.cos((2 * Math.PI * t) / 0.5), + 9, + ); + } + }); +}); + +describe('solveSpringTowardRamp (a target moving at constant speed)', () => { + /** RK4 on m·x″ + c·x′ + k·(x − (target + slope·t)) = 0. */ + function rk4Ramp( + spring: Spring, + { + from, + velocity, + target, + slope, + }: {from: number; velocity: number; target: number; slope: number}, + until: number, + dt = 1e-4, + ): {t: number; value: number; velocity: number}[] { + const {mass: m, stiffness: k, damping: c} = spring; + const accel = (time: number, x: number, v: number) => + (-k * (x - (target + slope * time)) - c * v) / m; + let x = from; + let v = velocity; + const out = [{t: 0, value: x, velocity: v}]; + const steps = Math.round(until / dt); + for (let i = 1; i <= steps; i++) { + const time = (i - 1) * dt; + const k1x = v; + const k1v = accel(time, x, v); + const k2x = v + (dt / 2) * k1v; + const k2v = accel(time + dt / 2, x + (dt / 2) * k1x, v + (dt / 2) * k1v); + const k3x = v + (dt / 2) * k2v; + const k3v = accel(time + dt / 2, x + (dt / 2) * k2x, v + (dt / 2) * k2v); + const k4x = v + dt * k3v; + const k4v = accel(time + dt, x + dt * k3x, v + dt * k3v); + x += (dt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x); + v += (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v); + if (i % 100 === 0) { + out.push({t: i * dt, value: x, velocity: v}); + } + } + return out; + } + + it.each([ + { + name: 'underdamped', + spring: springFromResponse({response: 0.32, dampingRatio: 0.8}), + slope: 0.16, + }, + { + name: 'critically damped', + spring: springFromResponse({response: 0.3, dampingRatio: 1}), + slope: -40, + }, + { + name: 'overdamped', + spring: springFromPhysics({ + stiffness: 100, + damping: 40, + allowOverdamping: true, + }), + slope: 250, + }, + ])('matches an RK4 integration ($name)', ({spring, slope}) => { + const start = {from: 1, velocity: 0.5, target: 1.02, slope}; + const curve = solveSpringTowardRamp(spring, start); + const scale = Math.max(1, Math.abs(slope)); + for (const sample of rk4Ramp(spring, start, 1.5)) { + expect(Math.abs(curve.value(sample.t) - sample.value)).toBeLessThan( + 1e-7 * scale, + ); + expect(Math.abs(curve.velocity(sample.t) - sample.velocity)).toBeLessThan( + 1e-6 * scale, + ); + } + }); + + it('starts where it is told and settles lag behind, at the target\u2019s speed', () => { + const spring = springFromResponse({response: 0.3, dampingRatio: 1}); + const curve = solveSpringTowardRamp(spring, { + from: 0, + velocity: 2, + target: 0, + slope: 10, + }); + expect(curve.value(0)).toBeCloseTo(0, 12); + expect(curve.velocity(0)).toBeCloseTo(2, 12); + expect(curve.lag).toBeCloseTo((spring.damping * 10) / spring.stiffness, 12); + expect(curve.value(5) - (10 * 5 - curve.lag)).toBeCloseTo(0, 6); + expect(curve.velocity(5)).toBeCloseTo(10, 6); + }); + + it('with no slope it is the ordinary spring', () => { + const spring = springFromResponse({response: 0.35, dampingRatio: 0.85}); + const ramp = solveSpringTowardRamp(spring, { + from: 0, + velocity: 3, + target: 1, + slope: 0, + }); + const plain = solveSpring(spring, {from: 0, to: 1, velocity: 3}); + for (let t = 0; t <= 1; t += 0.05) { + expect(ramp.value(t)).toBeCloseTo(plain.value(t), 12); + expect(ramp.velocity(t)).toBeCloseTo(plain.velocity(t), 12); + } + }); +}); + +describe('conversions', () => { + it('(duration, bounce) follows the WWDC23 equations', () => { + // stiffness = (2π ÷ duration)², damping = (1 − bounce) × 4π ÷ duration. + const bouncy = springFromDurationBounce({duration: 0.5, bounce: 0.3}); + expect(bouncy.mass).toBe(1); + expect(bouncy.stiffness).toBeCloseTo(((2 * Math.PI) / 0.5) ** 2, 9); + expect(bouncy.damping).toBeCloseTo((0.7 * 4 * Math.PI) / 0.5, 9); + // damping = 4π ÷ (duration × (1 + bounce)) for negative bounce. + const flattened = springFromDurationBounce({duration: 0.4, bounce: -0.5}); + expect(flattened.damping).toBeCloseTo((4 * Math.PI) / (0.4 * 0.5), 9); + expect(dampingRatioOf(flattened)).toBeCloseTo(2, 9); + }); + + it('round-trips (duration, bounce) over the whole range', () => { + for (const duration of [0.15, 0.3, 0.5, 0.75, 1.2]) { + for (const bounce of [-0.9, -0.5, -0.1, 0, 0.15, 0.3, 0.6, 0.95, 1]) { + const back = durationBounceOf( + springFromDurationBounce({duration, bounce}), + ); + expect(back.duration).toBeCloseTo(duration, 9); + expect(back.bounce).toBeCloseTo(bounce, 9); + } + } + }); + + it('SwiftUI presets map to their damping ratios (smooth 0, snappy 0.15, bouncy 0.3)', () => { + expect( + dampingRatioOf(springFromDurationBounce({duration: 0.5, bounce: 0})), + ).toBeCloseTo(1, 12); + expect( + dampingRatioOf(springFromDurationBounce({duration: 0.5, bounce: 0.15})), + ).toBeCloseTo(0.85, 12); + expect( + dampingRatioOf(springFromDurationBounce({duration: 0.5, bounce: 0.3})), + ).toBeCloseTo(0.7, 12); + }); + + it('(response, dampingRatio) reproduces WebKit\u2019s UIKit-matched switch springs exactly', () => { + // Source/WebCore/rendering/cocoa/RenderThemeCocoa.mm: + // SpringSolver(1.0, 438.64908449286042, 31.415926535897931, 0.0) and + // SpringSolver(1.0, 322.27279677026473, 31.2364069556928, 0.0). + const move = springFromResponse({response: 0.3, dampingRatio: 0.75}); + expect(move.stiffness).toBeCloseTo(438.64908449286042, 9); + expect(move.damping).toBeCloseTo(31.415926535897931, 9); + const press = springFromResponse({response: 0.35, dampingRatio: 0.87}); + expect(press.stiffness).toBeCloseTo(322.27279677026473, 9); + expect(press.damping).toBeCloseTo(31.2364069556928, 9); + }); + + it('round-trips (response, dampingRatio) and physics', () => { + const spring = springFromPhysics({mass: 2, stiffness: 300, damping: 20}); + const {response, dampingRatio} = responseDampingOf(spring); + expect(response).toBeCloseTo((2 * Math.PI) / Math.sqrt(150), 12); + expect(dampingRatio).toBeCloseTo(20 / (2 * Math.sqrt(600)), 12); + // A different mass with the same ω₀ and ζ is the same motion. + const unit = springFromResponse({response, dampingRatio}); + const a = solveSpring(spring, {from: 0, to: 1, velocity: 3}); + const b = solveSpring(unit, {from: 0, to: 1, velocity: 3}); + for (let t = 0; t < 1; t += 0.05) { + expect(a.value(t)).toBeCloseTo(b.value(t), 9); + } + expect(responseOf(unit)).toBeCloseTo(response, 12); + }); + + it('treats an overdamped physics spring as critical unless allowed (SwiftUI, UIKit)', () => { + const clamped = springFromPhysics({stiffness: 100, damping: 50}); + expect(dampingRatioOf(clamped)).toBeCloseTo(1, 12); + const kept = springFromPhysics({ + stiffness: 100, + damping: 50, + allowOverdamping: true, + }); + expect(dampingRatioOf(kept)).toBeCloseTo(2.5, 12); + }); + + it('rejects impossible parameters', () => { + expect(() => springFromDurationBounce({duration: 0, bounce: 0})).toThrow( + RangeError, + ); + expect(() => springFromResponse({response: -1, dampingRatio: 1})).toThrow( + RangeError, + ); + expect(() => springFromPhysics({stiffness: 100, damping: -1})).toThrow( + RangeError, + ); + expect(() => + springFromPhysics({mass: 0, stiffness: 100, damping: 1}), + ).toThrow(RangeError); + }); + + it('relative velocity follows UIKit\u2019s documented example', () => { + // "if the total animation distance is 200 points and you want the start + // of the animation to match a view velocity of 100 pt/s, use 0.5" + expect(relativeVelocity(100, 0, 200)).toBe(0.5); + expect(absoluteVelocity(0.5, 0, 200)).toBe(100); + expect(relativeVelocity(-100, 300, 100)).toBe(0.5); + }); +}); + +describe('settlingDuration (CASpringAnimation semantics)', () => { + it('matches Apple\u2019s CASpringAnimation documentation example', () => { + // mass 1, stiffness 100: damping 5 → "approximately 2.85 seconds"; + // damping 15 → "just over 1 second". + const loose = settlingDuration( + springFromPhysics({stiffness: 100, damping: 5}), + ); + expect(loose).toBeGreaterThan(2.845); + expect(loose).toBeLessThan(2.865); + const tight = settlingDuration( + springFromPhysics({stiffness: 100, damping: 15}), + ); + expect(tight).toBeGreaterThan(1); + expect(tight).toBeLessThan(1.05); + }); + + it('matches WebKit\u2019s iOS switch durations, the settling durations of its springs', () => { + // Source/WebCore/rendering/ios/RenderThemeIOS.h: + // switchAnimationVisuallyOnDuration 0.4880138408543766_s, + // switchAnimationHeldDuration 0.5073965509413827_s. + expect( + settlingDuration(springFromResponse({response: 0.3, dampingRatio: 0.75})), + ).toBeCloseTo(0.4880138408543766, 9); + expect( + settlingDuration( + springFromResponse({response: 0.35, dampingRatio: 0.87}), + ), + ).toBeCloseTo(0.5073965509413827, 9); + }); + + it('is shorter for a stiffer or a better-damped spring, as documented', () => { + const base = settlingDuration( + springFromPhysics({stiffness: 100, damping: 10}), + ); + expect( + settlingDuration(springFromPhysics({stiffness: 200, damping: 10})), + ).toBeLessThan(base); + expect( + settlingDuration(springFromPhysics({stiffness: 100, damping: 15})), + ).toBeLessThan(base); + expect( + settlingDuration( + springFromPhysics({mass: 2, stiffness: 100, damping: 10}), + ), + ).toBeGreaterThan(base); + }); + + it('a critically damped unit move settles at (1 + u)·e^(−u) = ε', () => { + const spring = springFromResponse({response: 0.5, dampingRatio: 1}); + const omega = (2 * Math.PI) / 0.5; + const u = settlingDuration(spring) * omega; + expect((1 + u) * Math.exp(-u)).toBeCloseTo(0.001, 9); + }); + + it('accounts for the initial velocity', () => { + const spring = springFromResponse({response: 0.5, dampingRatio: 0.8}); + const still = settlingDuration(spring); + // Moving away from the target, or toward it hard enough to overshoot + // further, settles later; a velocity close to the spring's own natural + // start (here ζ·ω₀ ≈ 10 distances/s) shrinks the oscillation instead. + expect(settlingDuration(spring, {initialVelocity: -8})).toBeGreaterThan( + still, + ); + expect(settlingDuration(spring, {initialVelocity: 40})).toBeGreaterThan( + still, + ); + expect(settlingDuration(spring, {initialVelocity: 8})).toBeLessThan(still); + }); +}); + +// Read from UIKit on the iOS 26.2 simulator (UIKit lab, injected host app, +// iPhone 17 Pro, Oct 23 2026): CASpringAnimation.settlingDuration for mass 1, +// stiffness 100, and the duration UIKit gives UIView.animate(springDuration: +// bounce:initialSpringVelocity:). +const CA_SETTLING = [ + {damping: 5, velocity: 0, settling: 2.854974609888761}, + {damping: 5, velocity: 5, settling: 2.854974609888761}, + {damping: 5, velocity: -5, settling: 2.992531378997136}, + {damping: 10, velocity: 0, settling: 1.4727003346780927}, + {damping: 10, velocity: 5, settling: 1.3815510557964277}, + {damping: 10, velocity: -5, settling: 1.5350814063145797}, + {damping: 15, velocity: 0, settling: 1.0220937981854987}, + {damping: 15, velocity: 5, settling: 0.9637816892907282}, + {damping: 15, velocity: -5, settling: 1.0625267085113923}, +]; +const UIKIT_SPRING_DURATION = [ + {duration: 0.3, bounce: 0, velocity: 0, uikit: 0.4409405178437803}, + {duration: 0.3, bounce: 0, velocity: 2, uikit: 0.43617534275714415}, + {duration: 0.3, bounce: 0.15, velocity: 0, uikit: 0.4419904194536484}, + {duration: 0.3, bounce: 0.15, velocity: 2, uikit: 0.4379526271453696}, + {duration: 0.3, bounce: 0.3, velocity: 0, uikit: 0.5177731699046524}, + {duration: 0.5, bounce: 0, velocity: 0, uikit: 0.734900863}, + {duration: 0.5, bounce: 0.15, velocity: 0, uikit: 0.736650699089414}, + {duration: 0.5, bounce: 0.3, velocity: 2, uikit: 0.8493819071116907}, + {duration: 1, bounce: 0, velocity: 0, uikit: 1.469801726}, + {duration: 1, bounce: 0, velocity: 2, uikit: 1.410252226}, + {duration: 1, bounce: 0.15, velocity: 2, uikit: 1.4240715395795775}, + {duration: 1, bounce: 0.3, velocity: 0, uikit: 1.725910566348841}, +]; + +describe('settlingDuration against values read from UIKit', () => { + it.each(CA_SETTLING)( + 'CASpringAnimation(stiffness 100, damping $damping, v $velocity).settlingDuration', + ({damping, velocity, settling}) => { + const spring = springFromPhysics({stiffness: 100, damping}); + expect(settlingDuration(spring, {initialVelocity: velocity})).toBeCloseTo( + settling, + 12, + ); + }, + ); + + it.each(UIKIT_SPRING_DURATION)( + 'UIView.animate(springDuration: $duration, bounce: $bounce, v $velocity) runs $uikit s', + ({duration, bounce, velocity, uikit}) => { + const spring = springFromDurationBounce({duration, bounce}); + const mine = settlingDuration(spring, {initialVelocity: velocity}); + if (bounce > 0) { + expect(mine).toBeCloseTo(uikit, 12); + } else { + // Critically damped: UIKit stops 0.0016 later in ω₀·t. + expect(Math.abs(mine - uikit) / uikit).toBeLessThan(3e-4); + } + }, + ); + + it('UIKit’s default spring runs 0.505824 s: overdamping clamped to critical', () => { + const spring = springFromPhysics({mass: 3, stiffness: 1000, damping: 500}); + expect(dampingRatioOf(spring)).toBeCloseTo(1, 12); + expect( + Math.abs(settlingDuration(spring) - 0.505824) / 0.505824, + ).toBeLessThan(3e-4); + }); +}); + +describe('springFromUIKitDampingDuration (legacy damping/duration API)', () => { + // Read from UIKit (same capture): UIView.animate(withDuration: D, + // usingSpringWithDamping: ζ, initialSpringVelocity: 0) → mass 1 and these. + it.each([ + {duration: 0.3, dampingRatio: 0.5, stiffness: 1796.883, damping: 42.3897}, + {duration: 0.3, dampingRatio: 0.8, stiffness: 898.86, damping: 47.9696}, + {duration: 0.3, dampingRatio: 1, stiffness: 947.288, damping: 61.5561}, + {duration: 0.5, dampingRatio: 0.5, stiffness: 646.878, damping: 25.4338}, + {duration: 0.5, dampingRatio: 0.8, stiffness: 323.589, damping: 28.7817}, + {duration: 0.5, dampingRatio: 1, stiffness: 341.024, damping: 36.9337}, + {duration: 1, dampingRatio: 0.5, stiffness: 161.72, damping: 12.7169}, + {duration: 1, dampingRatio: 0.8, stiffness: 80.897, damping: 14.3909}, + {duration: 1, dampingRatio: 1, stiffness: 85.256, damping: 18.4668}, + ])( + 'D $duration, ζ $dampingRatio → stiffness $stiffness', + ({duration, dampingRatio, stiffness, damping}) => { + const spring = springFromUIKitDampingDuration({duration, dampingRatio}); + expect(spring.mass).toBe(1); + expect(Math.abs(spring.stiffness - stiffness) / stiffness).toBeLessThan( + 1e-4, + ); + expect(Math.abs(spring.damping - damping) / damping).toBeLessThan(1e-4); + }, + ); + + it('at ζ 1 the derived spring settles exactly at the duration', () => { + const spring = springFromUIKitDampingDuration({ + duration: 0.4, + dampingRatio: 1, + }); + expect(settlingDuration(spring)).toBeCloseTo(0.4, 3); + }); + + it.each([ + {dampingRatio: 0.1, omega: 46.1019536228216}, + {dampingRatio: 0.3, omega: 19.1698}, + {dampingRatio: 0.65, omega: 10.3869}, + {dampingRatio: 0.95, omega: 8.4425}, + ])( + 'reproduces the wide read at D 1, ζ $dampingRatio', + ({dampingRatio, omega}) => { + const spring = springFromUIKitDampingDuration({ + duration: 1, + dampingRatio, + }); + expect(Math.sqrt(spring.stiffness)).toBeCloseTo(omega, 3); + }, + ); + + it('interpolates between the measured ratios', () => { + const a = springFromUIKitDampingDuration({duration: 1, dampingRatio: 0.6}); + const b = springFromUIKitDampingDuration({ + duration: 1, + dampingRatio: 0.625, + }); + const c = springFromUIKitDampingDuration({duration: 1, dampingRatio: 0.65}); + expect(Math.sqrt(b.stiffness)).toBeCloseTo( + (Math.sqrt(a.stiffness) + Math.sqrt(c.stiffness)) / 2, + 9, + ); + }); +}); diff --git a/packages/core/src/motion/spring.ts b/packages/core/src/motion/spring.ts new file mode 100644 index 0000000000000..bfd5fa91c9bf8 --- /dev/null +++ b/packages/core/src/motion/spring.ts @@ -0,0 +1,551 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file spring.ts + * @input Spring parameters in any of the four vocabularies Apple uses + * @output Exports the `Spring` model, conversions between SwiftUI + * (duration, bounce), SwiftUI/WWDC (response, damping ratio), + * CASpringAnimation (mass, stiffness, damping) and UIKit's legacy + * `usingSpringWithDamping:initialSpringVelocity:` parameters, the closed-form + * solution of the spring (`solveSpring`) and of a spring chasing a target + * that moves at a constant speed (`solveSpringTowardRamp`), and + * `settlingDuration` + * @position Pure math under the motion animator; no DOM, no React + * + * One model, the damped harmonic oscillator m·x″ + c·x′ + k·x = 0, solved in + * closed form for its three regimes (under-, critically and over-damped). It + * is the model behind SwiftUI's `Spring`, `CASpringAnimation` and UIKit's + * spring timing parameters, so a curve computed here is the curve UIKit + * computes for the same parameters. + * + * Units: time in seconds, velocity in units per second (the units of the + * animated value: px for a translation, 1 for a scale or an opacity), the + * same units SwiftUI and Core Animation use. + * + * Conversions, with their sources: + * - (duration, bounce) → (mass, stiffness, damping): the three equations of + * WWDC23 "Animate with springs" (session 10158): + * mass = 1, stiffness = (2π ÷ duration)², + * damping = (1 − bounce) × 4π ÷ duration for bounce ≥ 0, + * damping = 4π ÷ (duration × (1 + bounce)) for bounce < 0. + * - (response, dampingRatio) → physics: WWDC18 "Designing Fluid Interfaces" + * (session 803) defines the response as the period of the undamped spring, + * so ω₀ = 2π ÷ response; damping ratio ζ = c ÷ (2·√(k·m)) as stated in + * https://developer.apple.com/documentation/uikit/uispringtimingparameters/init(mass:stiffness:damping:initialvelocity:) + * - UIKit's relative initial velocity: "A value of 1 corresponds to the total + * animation distance traversed in one second" + * (https://developer.apple.com/documentation/uikit/uiview/animate(withduration:delay:usingspringwithdamping:initialspringvelocity:options:animations:completion:)). + * + * Settling duration follows UIKit's own duration solver: the time after which + * the displacement from the target, for a unit move, stays below ε = 0.001 + * (the epsilon SwiftUI documents for `Spring.settlingDuration`). Underdamped, + * it is the closed-form time at which the decaying envelope |A| + |B| of + * A·cos + B·sin falls to ε; critically damped and overdamped, the root of the + * envelope found numerically. Read from UIKit on the iOS 26.2 simulator, the + * underdamped values match to the last digit (`CASpringAnimation` + * `settlingDuration` for every stiffness/damping/velocity in the grid, and the + * duration UIKit gives `UIView.animate(springDuration:bounce:)`); critically + * damped ones land within 0.02 % of the duration UIKit picks (it stops 0.0016 + * later in ω₀·t). Core Animation's `settlingDuration` PROPERTY additionally + * rounds critically damped and overdamped springs up to the next 0.1 s; that + * rounding is not modeled. Nor is Core Animation's overdamped rendering: with + * `allowsOverdamping` on (only `UIView.animate(springDuration:bounce:)` with a + * negative bounce turns it on) Core Animation swaps the two exponentials' + * coefficients and flips the initial velocity's sign, so its "overdamped" + * curve overshoots (stiffness 100, damping 40: 104.5 % at 200 ms); every other + * UIKit spring has it off and is clamped to critical, as `springFromPhysics` + * does. The bound also reproduces Apple's + * CASpringAnimation documentation example (stiffness 100, damping 5 → + * "approximately 2.85 seconds"; damping 15 → "just over 1 second") and + * WebKit's iOS switch durations, the settling durations of its UIKit-matched + * springs (0.4880138408543766 s and 0.5073965509413827 s in + * Source/WebCore/rendering/ios/RenderThemeIOS.h). See spring.test.ts. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/spring.test.ts + * - /packages/core/src/motion/README.md + */ + +const TAU = 2 * Math.PI; + +/** + * A spring, in Core Animation's physical vocabulary. Every other way of + * describing a spring converts to this one. + */ +export interface Spring { + /** Mass m of the attached object (`CASpringAnimation.mass`). */ + readonly mass: number; + /** Stiffness k (`CASpringAnimation.stiffness`). */ + readonly stiffness: number; + /** Viscous damping coefficient c (`CASpringAnimation.damping`). */ + readonly damping: number; +} + +/** SwiftUI's two-parameter description: `Spring(duration:bounce:)`. */ +export interface DurationBounce { + /** + * Perceptual duration in seconds: the period of the undamped spring, "the + * pace of the spring". + */ + readonly duration: number; + /** + * 0 is critically damped (no overshoot), up to 1 (undamped oscillation); + * negative values, down to −1, are overdamped ("flattened") springs. + */ + readonly bounce: number; +} + +/** WWDC18's design-friendly description: response and damping ratio. */ +export interface ResponseDamping { + /** Period of the undamped spring in seconds. */ + readonly response: number; + /** + * Damping as a fraction of critical damping (SwiftUI's `dampingFraction`): + * 1 settles without overshoot, values toward 0 oscillate longer. + */ + readonly dampingRatio: number; +} + +function assertPositiveFinite(name: string, value: number): void { + if (!(Number.isFinite(value) && value > 0)) { + throw new RangeError( + `spring: ${name} must be a positive number, got ${value}`, + ); + } +} + +/** + * SwiftUI's `Spring(duration:bounce:)` and UIKit's + * `UIView.animate(springDuration:bounce:…)`: the WWDC23 equations. Bounce is + * clamped to SwiftUI's documented range (−1, 1]. + * + * @example + * ``` + * springFromDurationBounce({duration: 0.5, bounce: 0}); // SwiftUI .smooth + * ``` + */ +export function springFromDurationBounce({ + duration, + bounce, +}: DurationBounce): Spring { + assertPositiveFinite('duration', duration); + if (!Number.isFinite(bounce)) { + throw new RangeError(`spring: bounce must be finite, got ${bounce}`); + } + // −1 itself would be infinite damping. + const b = Math.min(1, Math.max(-0.999999, bounce)); + const stiffness = (TAU / duration) ** 2; + const damping = + b >= 0 ? ((1 - b) * 2 * TAU) / duration : (2 * TAU) / (duration * (1 + b)); + return {mass: 1, stiffness, damping}; +} + +/** + * SwiftUI's `Spring(response:dampingRatio:)` and + * `Animation.spring(response:dampingFraction:)`. Overdamping (ratio > 1) is + * kept, as SwiftUI keeps it for this initializer. + */ +export function springFromResponse({ + response, + dampingRatio, +}: ResponseDamping): Spring { + assertPositiveFinite('response', response); + if (!(Number.isFinite(dampingRatio) && dampingRatio >= 0)) { + throw new RangeError( + `spring: dampingRatio must be ≥ 0, got ${dampingRatio}`, + ); + } + const omega = TAU / response; + return {mass: 1, stiffness: omega * omega, damping: 2 * dampingRatio * omega}; +} + +/** + * `CASpringAnimation` / `UISpringTimingParameters(mass:stiffness:damping:)` + * / SwiftUI `Spring(mass:stiffness:damping:allowOverDamping:)`. Like SwiftUI, + * an overdamped spring is treated as critically damped unless + * `allowOverdamping` is set: "a value of false specifies that such cases + * should instead be treated as critically damped". + */ +export function springFromPhysics({ + mass = 1, + stiffness, + damping, + allowOverdamping = false, +}: { + mass?: number; + stiffness: number; + damping: number; + allowOverdamping?: boolean; +}): Spring { + assertPositiveFinite('mass', mass); + assertPositiveFinite('stiffness', stiffness); + if (!(Number.isFinite(damping) && damping >= 0)) { + throw new RangeError(`spring: damping must be ≥ 0, got ${damping}`); + } + const critical = 2 * Math.sqrt(stiffness * mass); + return { + mass, + stiffness, + damping: allowOverdamping ? damping : Math.min(damping, critical), + }; +} + +/** Undamped angular frequency ω₀ = √(k/m), rad/s. */ +export function naturalFrequency(spring: Spring): number { + return Math.sqrt(spring.stiffness / spring.mass); +} + +/** Damping ratio ζ = c / (2·√(k·m)). */ +export function dampingRatioOf(spring: Spring): number { + return spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass)); +} + +/** Response (period of the undamped spring), seconds. */ +export function responseOf(spring: Spring): number { + return TAU / naturalFrequency(spring); +} + +/** The spring as SwiftUI's (duration, bounce), inverting the WWDC23 equations. */ +export function durationBounceOf(spring: Spring): DurationBounce { + const zeta = dampingRatioOf(spring); + return { + duration: responseOf(spring), + bounce: zeta <= 1 ? 1 - zeta : 1 / zeta - 1, + }; +} + +/** The spring as (response, dampingRatio). */ +export function responseDampingOf(spring: Spring): ResponseDamping { + return {response: responseOf(spring), dampingRatio: dampingRatioOf(spring)}; +} + +/** + * A spring in any of the vocabularies the constants table uses: physical + * (mass, stiffness, damping), SwiftUI (duration, bounce) or WWDC18 + * (response, dampingRatio). Tables keep the vocabulary of their source so a + * value reads exactly as it was published. + */ +export type SpringDescription = + | { + readonly mass?: number; + readonly stiffness: number; + readonly damping: number; + } + | DurationBounce + | ResponseDamping; + +/** + * Normalizes a description to the physical `Spring`. A physical description + * gets `springFromPhysics`' treatment: overdamping is clamped to critical, as + * SwiftUI and Core Animation do by default. + */ +export function toSpring(description: SpringDescription): Spring { + if ('stiffness' in description) { + return springFromPhysics(description); + } + if ('bounce' in description) { + return springFromDurationBounce(description); + } + return springFromResponse(description); +} + +/** + * UIKit's relative initial spring velocity for a move from `from` to `to` + * at `velocity` units/s: 1 means the whole distance in one second. Undefined + * (±Infinity) when `from === to`; animate in absolute units instead + * (`solveSpring` takes an absolute velocity). + */ +export function relativeVelocity( + velocity: number, + from: number, + to: number, +): number { + return velocity / (to - from); +} + +/** Inverse of `relativeVelocity`: absolute units/s. */ +export function absoluteVelocity( + relative: number, + from: number, + to: number, +): number { + return relative * (to - from); +} + +/** A spring released from a value with a velocity, heading for a target. */ +export interface SpringCurve { + readonly spring: Spring; + readonly from: number; + readonly to: number; + /** Initial velocity, units/s. */ + readonly initialVelocity: number; + /** Value at `t` seconds after release. */ + value(t: number): number; + /** Velocity at `t` seconds after release, units/s. */ + velocity(t: number): number; + /** + * The time after which |value(t) − to| stays below `threshold` (in the + * value's own units) for good: the envelope bound used by + * `settlingDuration`, applied in absolute units. 0 when already within. + * Infinity for an undamped spring that is displaced or moving. + */ + settleTime(threshold: number): number; +} + +// |ζ − 1| below this is solved as critically damped: the under/overdamped +// forms lose precision as ωd or the root gap goes to zero, and the critical +// form differs from them by O(|ζ − 1|) there. +const CRITICAL_TOLERANCE = 1e-6; + +/** + * Closed-form solution of the spring released at `from` with + * `initialVelocity` (units/s), heading for `to`. + * + * @example + * ``` + * const curve = solveSpring(springFromDurationBounce({duration: 0.5, bounce: 0}), {from: 0, to: 300}); + * curve.value(0.1); // px after 100 ms + * ``` + */ +export function solveSpring( + spring: Spring, + { + from, + to, + velocity: initialVelocity = 0, + }: {from: number; to: number; velocity?: number}, +): SpringCurve { + const omega0 = naturalFrequency(spring); + const zeta = dampingRatioOf(spring); + // Displacement from the target; the ODE is solved for x(t) = value − to. + const x0 = from - to; + const v0 = initialVelocity; + let displacement: (t: number) => number; + let speed: (t: number) => number; + let envelope: (t: number) => number; + // Where the envelope peaks (critically damped only; elsewhere 0). + let envelopePeak = 0; + + if (zeta < 1 - CRITICAL_TOLERANCE) { + const decay = zeta * omega0; + const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); + const a = x0; + const b = (v0 + decay * x0) / omegaD; + const bv = (decay * v0 + omega0 * omega0 * x0) / omegaD; + displacement = t => + Math.exp(-decay * t) * + (a * Math.cos(omegaD * t) + b * Math.sin(omegaD * t)); + speed = t => + Math.exp(-decay * t) * + (v0 * Math.cos(omegaD * t) - bv * Math.sin(omegaD * t)); + const amplitude = Math.abs(a) + Math.abs(b); + envelope = t => amplitude * Math.exp(-decay * t); + } else if (zeta <= 1 + CRITICAL_TOLERANCE) { + const b = v0 + omega0 * x0; + displacement = t => (x0 + b * t) * Math.exp(-omega0 * t); + speed = t => (v0 - omega0 * b * t) * Math.exp(-omega0 * t); + const absA = Math.abs(x0); + const absB = Math.abs(b); + envelope = t => (absA + absB * t) * Math.exp(-omega0 * t); + // (|A| + |B|·t)·e^(−ω₀t) rises until t = 1/ω₀ − |A|/|B|, then decays. + envelopePeak = absB > 0 ? Math.max(0, 1 / omega0 - absA / absB) : 0; + } else { + const root = omega0 * Math.sqrt(zeta * zeta - 1); + const r1 = -zeta * omega0 + root; // slow root + const r2 = -zeta * omega0 - root; // fast root + const c1 = (v0 - r2 * x0) / (r1 - r2); + const c2 = x0 - c1; + displacement = t => c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t); + speed = t => r1 * c1 * Math.exp(r1 * t) + r2 * c2 * Math.exp(r2 * t); + const absC1 = Math.abs(c1); + const absC2 = Math.abs(c2); + envelope = t => absC1 * Math.exp(r1 * t) + absC2 * Math.exp(r2 * t); + } + + const settleTime = (threshold: number): number => { + if (!(threshold > 0)) { + return Infinity; + } + if (zeta === 0) { + return x0 === 0 && v0 === 0 ? 0 : Infinity; + } + const start = envelopePeak; + if (envelope(start) <= threshold) { + // `start` is where the bound peaks (0 when it only decays), so the + // bound is within the threshold everywhere. + return 0; + } + if (zeta < 1 - CRITICAL_TOLERANCE) { + // Closed form: amplitude · e^(−ζω₀t) = threshold. + const amplitude = envelope(0); + return Math.log(amplitude / threshold) / (zeta * omega0); + } + // Monotone decreasing after `start`: bracket, then bisect. + let lo = start; + let hi = Math.max(start, 1 / omega0); + while (envelope(hi) > threshold) { + lo = hi; + hi *= 2; + if (hi > 1e6) { + return Infinity; + } + } + for (let i = 0; i < 100 && hi - lo > 1e-12; i++) { + const mid = (lo + hi) / 2; + if (envelope(mid) > threshold) { + lo = mid; + } else { + hi = mid; + } + } + return hi; + }; + + return { + spring, + from, + to, + initialVelocity, + value: t => to + displacement(t), + velocity: t => speed(t), + settleTime, + }; +} + +/** A spring chasing a target that moves at a constant speed. */ +export interface SpringRampCurve { + readonly spring: Spring; + /** Value at `t` seconds after the start. */ + value(t: number): number; + /** Velocity at `t` seconds after the start, units/s. */ + velocity(t: number): number; + /** + * How far behind the moving target the spring settles: c·slope ÷ k + * (it then moves at the target's speed). + */ + readonly lag: number; +} + +/** + * A spring whose target moves at a constant speed, `target + slope · t`: the + * "tracking" springs UIKit drives per frame (a press that keeps deepening, a + * lift that follows the finger). Closed form: the spring settles `lag` behind + * the moving target, and what is left is a free spring around that point, + * solved by `solveSpring`. Chain pieces (a new slope, a fixed target) by + * starting each from the previous piece's value and velocity. + */ +export function solveSpringTowardRamp( + spring: Spring, + { + from, + velocity: initialVelocity = 0, + target, + slope, + }: {from: number; velocity?: number; target: number; slope: number}, +): SpringRampCurve { + const lag = (spring.damping * slope) / spring.stiffness; + // x(t) = target + slope·t − lag + h(t), with h a free spring around 0. + const free = solveSpring(spring, { + from: from - target + lag, + to: 0, + velocity: initialVelocity - slope, + }); + return { + spring, + lag, + value: t => target + slope * t - lag + free.value(t), + velocity: t => slope + free.velocity(t), + }; +} + +/** Core Animation's settling epsilon (documented for SwiftUI's Spring). */ +export const SETTLING_EPSILON = 0.001; + +/** + * `CASpringAnimation.settlingDuration` / SwiftUI `Spring.settlingDuration`: + * seconds until a unit move (target 1) stays within `epsilon` of its target. + * `initialVelocity` is UIKit's relative velocity (distance fractions per + * second, positive toward the target), 0 by default as in SwiftUI. + */ +export function settlingDuration( + spring: Spring, + { + initialVelocity = 0, + epsilon = SETTLING_EPSILON, + }: {initialVelocity?: number; epsilon?: number} = {}, +): number { + return solveSpring(spring, { + from: 0, + to: 1, + velocity: initialVelocity, + }).settleTime(epsilon); +} + +/** + * ω₀ · duration that UIKit's legacy spring derivation lands on, by damping + * ratio, for a release at rest. Read from UIKit + * (`UIView.animate(withDuration: 1, usingSpringWithDamping: ζ, + * initialSpringVelocity: 0)`; `UIViewPropertyAnimator(duration:dampingRatio:)` + * gives identical springs, and ω₀ scales as 1 ÷ duration): measured: UIKit + * lab (injected host app), iOS 26.2 sim iPhone 17 Pro, CAAnimation read, + * Oct 23 2026. + */ +const UIKIT_LEGACY_OMEGA_DURATION: ReadonlyArray = [ + [0.1, 46.102], + [0.15, 33.4801], + [0.2, 26.5936], + [0.25, 22.2149], + [0.3, 19.1698], + [0.35, 16.9236], + [0.4, 15.1966], + [0.45, 13.8275], + [0.5, 12.7169], + [0.55, 11.8001], + [0.6, 11.0335], + [0.65, 10.3869], + [0.7, 9.8396], + [0.75, 9.3779], + [0.8, 8.9943], + [0.85, 8.6897], + [0.9, 8.4808], + [0.95, 8.4425], + [1, 9.2334], +]; + +/** + * UIKit's legacy `animate(withDuration:delay:usingSpringWithDamping: + * initialSpringVelocity:…)`: the caller fixes the total duration and the + * damping ratio, and UIKit derives the spring: mass 1, the given damping + * ratio, and ω₀ = c(ζ) ÷ duration with c read from UIKit every 0.05 of ζ from + * 0.1 to 1 (at ζ 1 the spring settles exactly at the duration). The animation + * is cut at the duration. + * + * GUESS between the measured ratios (linear interpolation, within 0.05 of a + * read) and below 0.1 (extrapolated as c ∝ 1 ÷ ζ). A non-zero initial + * velocity shifts UIKit's root slightly, and at low ratios makes its solver + * jump to a far smaller one (ζ 0.1, velocity 1 → ω₀·D 9.74); neither is + * modeled. Measurement task M-K1. + */ +export function springFromUIKitDampingDuration({ + duration, + dampingRatio, +}: { + duration: number; + dampingRatio: number; +}): Spring { + assertPositiveFinite('duration', duration); + // UIKit's ratio is clamped to (0, 1]: it has no overdamped springs. + const zeta = Math.min(1, Math.max(1e-3, dampingRatio)); + const table = UIKIT_LEGACY_OMEGA_DURATION; + let c = (table[0][1] * table[0][0]) / zeta; + for (let i = 0; i + 1 < table.length; i++) { + const [z0, c0] = table[i]; + const [z1, c1] = table[i + 1]; + if (zeta >= z0 && zeta <= z1) { + c = c0 + ((c1 - c0) * (zeta - z0)) / (z1 - z0); + break; + } + } + const omega0 = c / duration; + return {mass: 1, stiffness: omega0 * omega0, damping: 2 * zeta * omega0}; +} diff --git a/packages/core/src/motion/uikit/gestures.ts b/packages/core/src/motion/uikit/gestures.ts index 4e6963b375762..6f6d4106286cc 100644 --- a/packages/core/src/motion/uikit/gestures.ts +++ b/packages/core/src/motion/uikit/gestures.ts @@ -253,8 +253,9 @@ export const UIKIT_VELOCITY = { * instantaneous velocity plus the rest times the previous event's * (instantaneous velocity: position change over timestamp change between * consecutive events, the touch-down counting as the first). The lift - * does not update it, and a finger at rest sends no events, so a release - * after a pause reports the last moving value. + * does not update it, and a finger at rest sends no events, so UIKit + * reports the last moving value after a pause; Astryx's release reads 0 + * once the finger has rested (`spec:AST-100` FR2). * @source measured: UIKit lab, iOS 26.2 simulator, ivar reads confirmed * with HID touches, Oct 23 2026: fitted to all 69 velocity(in:) values of * six HID streams (constant, accelerating, pauses before the lift) to 0.0 diff --git a/packages/core/src/motion/uikit/timing.ts b/packages/core/src/motion/uikit/timing.ts index c3c4c461d716e..8ce8079df076c 100644 --- a/packages/core/src/motion/uikit/timing.ts +++ b/packages/core/src/motion/uikit/timing.ts @@ -54,6 +54,15 @@ export const press = { * packages/core/src/utils/pressGesture.ts, 2026-10-06) */ highlightOutMs: 200, + /** + * The highlight fade's curve after release: an ease-out that spends the + * whole clock fading (strength about 0.2 at the midpoint). + * @source GUESS — the release curve the touch press model ships with + * (`RELEASE_EASE`, packages/core/src/utils/interactionOverlay.stylex.ts), + * chosen to read as UIKit's deselect cross-fade, not read from UIKit + * @measure M-PR1 + */ + highlightOutCurve: [0, 0, 0.2, 1], /** * A touch on a scroller that moved this recently brakes it instead of * pressing. diff --git a/packages/core/src/motion/uikit/values.test.ts b/packages/core/src/motion/uikit/values.test.ts new file mode 100644 index 0000000000000..e64e09d3c9de8 --- /dev/null +++ b/packages/core/src/motion/uikit/values.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file values.test.ts + * @input vitest, the UIKit constants table and the motion math + * @output Checks the table's values through the math that plays them: every + * spring is valid and settles; the alert spring is UIKit's legacy + * derivation; a sheet's detent and presentation springs render alike; + * scroll-to-top and the rubber band reproduce what UIKit was measured doing; + * the press release curve is the press model's + * @position Testing; the source rules are uikit.test.ts + */ + +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {solveDecay} from '../decay'; +import {rubberBand} from '../rubberBand'; +import { + naturalFrequency, + settlingDuration, + springFromUIKitDampingDuration, + toSpring, + type SpringDescription, +} from '../spring'; +import {scroll, springs} from '.'; +import {press} from './timing'; + +describe('UIKit constants table through the motion math', () => { + const springEntries: [string, SpringDescription][] = [ + ...Object.entries(springs), + ['scroll.bounce', scroll.bounce], + ['scroll.paging.snap', scroll.paging.snap], + ]; + + it.each(springEntries)( + '%s is a valid spring that settles', + (_, description) => { + const spring = toSpring(description); + const settle = settlingDuration(spring); + expect(settle).toBeGreaterThan(0.05); + expect(settle).toBeLessThan(2); + }, + ); + + it('the alert spring is UIKit\u2019s legacy derivation at 0.404 s, critically damped', () => { + // UIKit asks for damping 600 (clamped to 1) over 0.404 s; the measured + // grid predicts the spring Core Animation was handed. + const derived = springFromUIKitDampingDuration({ + duration: 0.404, + dampingRatio: 1, + }); + expect(derived.stiffness).toBeCloseTo(springs.alertPresent.stiffness, 1); + expect(derived.damping).toBeCloseTo(springs.alertPresent.damping, 2); + }); + + it('a sheet\u2019s detent spring is its presentation spring as rendered (critical, same pace)', () => { + // Core Animation clamps UIKit\u2019s default spring to critical damping. + expect(naturalFrequency(toSpring(springs.sheetDetent))).toBeCloseTo( + naturalFrequency(toSpring(springs.sheetPresent)), + 6, + ); + }); + + it('scroll-to-top as a decay lands on the top along the measured curve', () => { + // 2000 pt from the top: remaining distance every ~50 ms after the start. + const measured = [1257, 768, 475, 296, 186, 118]; + const coast = solveDecay({ + from: 0, + velocity: 9.7 * 2000, + rate: scroll.scrollToTopRate, + }); + expect(coast.rest).toBeCloseTo(2000, -1); + measured.forEach((remaining, i) => { + expect( + Math.abs(2000 - coast.value((i + 1) * 0.05) - remaining), + ).toBeLessThan(30); + }); + }); + + it('the rubber band reproduces UIScrollView\u2019s released overscroll', () => { + // Finger 100 / 300 / 600 pt past the edge of an 874 pt view, minus the + // pan\u2019s 10 pt hysteresis. + const cases: [number, number][] = [ + [90, 47], + [290, 135], + [590, 236.67], + ]; + for (const [overflow, shown] of cases) { + expect( + Math.abs( + rubberBand(overflow, 874, scroll.rubberBand.coefficient) - shown, + ), + ).toBeLessThan(0.5); + } + }); + + it('the press release curve is the press model\u2019s RELEASE_EASE', () => { + // The table records the curve the touch press model ships with; this pins + // the two together so neither drifts alone. + const source = readFileSync( + join(__dirname, '../../utils/interactionOverlay.stylex.ts'), + 'utf8', + ); + const match = /const RELEASE_EASE = 'cubic-bezier\(([^)]*)\)'/.exec(source); + expect(match?.[1].split(',').map(Number)).toEqual([ + ...press.highlightOutCurve, + ]); + }); +});