Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c784084
spec(AST-094): touch presentations feel native by default
cixzhang Oct 24, 2026
0c35c43
chore(core): the UIKit constants table for touch presentations
vjeux Oct 24, 2026
17c181b
chore(core): UIKit's spring, decay, rubber-band and projection math
vjeux Oct 24, 2026
6bd2851
chore(core): the constants table names only what exists, and dates ev…
vjeux Oct 24, 2026
76be493
fix(core): paging past the last page lands on a real page
vjeux Oct 24, 2026
4b3de64
Merge main into the constants table (lab typechecks with its tests)
vjeux Oct 25, 2026
530315f
Merge #8053 (main merged in) into the motion math
vjeux Oct 25, 2026
e7df427
perf(core): the gesture table's sources move to JSDoc, out of the bundle
vjeux Oct 25, 2026
439dcc3
docs(spec): the draft AST-094 copy leaves this stack
vjeux Oct 25, 2026
0bf13da
Merge main (the deploy typecheck gate test reads the step as written)…
vjeux Oct 25, 2026
bbbcb60
refactor(core): the platform presets are named for their use
vjeux Oct 25, 2026
7c5f71c
Merge main (67dc45f66d3) into #8054
cixzhang Oct 25, 2026
03f66c3
fix(core): the UIKit table keeps only what a touch presentation plays…
cixzhang Oct 25, 2026
1133bf9
Merge remote-tracking branch 'origin/main' into m8053
cixzhang Oct 25, 2026
eded944
style(core): MEASUREMENTS.md table widths after the dropped rows
cixzhang Oct 25, 2026
f9d0900
feat(core): the press release's curve is a press value
vjeux Oct 25, 2026
9db9315
docs(core): the velocity weight says a rested finger releases at 0
vjeux Oct 25, 2026
5d3cab6
Merge #8053 (the table keeps only what a touch presentation plays; th…
vjeux Oct 25, 2026
7e36bcd
fix(core): the table's notes point at what exists, and the press entr…
cixzhang Oct 25, 2026
c1fa660
Merge #8053's notes and press pins into the press release's curve and…
vjeux Oct 25, 2026
59a748f
Merge #8053 (the table's notes point at what exists; the press entrie…
vjeux Oct 25, 2026
6ae3390
Merge main (#8043's interaction proofs) into the UIKit constants table
vjeux Oct 25, 2026
51b3ecb
Merge #8053 (main with #8043's interaction proofs) into the motion math
vjeux Oct 25, 2026
1f751c9
docs(core): M-S2 feeds scroll.decelerationRate, where the coast readi…
cixzhang Oct 25, 2026
96f8240
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the press relea…
vjeux Oct 25, 2026
1ae9200
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the motion math
vjeux Oct 25, 2026
7234d44
Merge main (99ad3f6d906) into the motion math
cixzhang Oct 26, 2026
d3a2e41
fix(core): toSpring's clamp, FR6 for the rubber band's inverse, the p…
cixzhang Oct 26, 2026
3dca43f
Merge main (16770e30070) into the motion math
cixzhang Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/uikit-motion-math.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/core/src/motion/MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions packages/core/src/motion/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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/<component>.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
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/motion/decay.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
126 changes: 126 additions & 0 deletions packages/core/src/motion/decay.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
Loading
Loading