From 64404bbd2ea79f5e2f7a9dae941457a665599d61 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 11:15:29 -0700 Subject: [PATCH] feat(core): UISwitch's measured values and its drag rule join the UIKit constants table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UISwitch, measured on iOS 26.2 (the switch table, motion/uikit/switch.ts): the 63 × 28 track and the 37 × 24 thumb lifted to 58 × 38.33; the lift, settle, travel and drag springs; the 235 ms minimum lift; the drag's rubber band and flip distance; the track colour's springs; no haptic. The springs table's SWITCH_THUMB_MOVE_SPRING holds the measured travel the switch table reads, WebKit's stretch spring goes, and the segmented thumb's guess cites WebKit's spring itself until UISegmentedControl's measured spring replaces it. Switch/switchTouch.ts: UIKit's proportions in any track, the thumb under a dragging finger, the flip rule (the finger flips the switch once it has travelled the track minus the thumb from where it landed), the commit at the lift and the settle time, as pure functions. Every value stays an internal constant with no theme surface; no component plays them yet. --- .changeset/uikit-switch-values.md | 7 + packages/core/src/Switch/switchTouch.test.ts | 294 +++++++++++++++++++ packages/core/src/Switch/switchTouch.ts | 170 +++++++++++ packages/core/src/motion/MEASUREMENTS.md | 13 +- packages/core/src/motion/README.md | 1 + packages/core/src/motion/uikit/index.ts | 8 +- packages/core/src/motion/uikit/springs.ts | 47 ++- packages/core/src/motion/uikit/switch.ts | 171 +++++++++++ 8 files changed, 671 insertions(+), 40 deletions(-) create mode 100644 .changeset/uikit-switch-values.md create mode 100644 packages/core/src/Switch/switchTouch.test.ts create mode 100644 packages/core/src/Switch/switchTouch.ts create mode 100644 packages/core/src/motion/uikit/switch.ts diff --git a/.changeset/uikit-switch-values.md b/.changeset/uikit-switch-values.md new file mode 100644 index 0000000000000..275a64d13c841 --- /dev/null +++ b/.changeset/uikit-switch-values.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UISwitch's values measured on iOS 26.2 (its geometry, the springs of the thumb's lift, settle, travel and drag, the track colour, its minimum lift time and its flip distance) join the UIKit constants table as internal constants, with the rule a switch's drag follows as a pure function: the finger flips the switch once it has travelled the track width minus the thumb width from where it landed, coming back past that point flips it back, and lifting the finger commits the side shown, or toggles when nothing flipped. Not exported from the package entry point, and no theme sets them. No component plays them yet. + +@vjeux diff --git a/packages/core/src/Switch/switchTouch.test.ts b/packages/core/src/Switch/switchTouch.test.ts new file mode 100644 index 0000000000000..2fcd7fda0926c --- /dev/null +++ b/packages/core/src/Switch/switchTouch.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file switchTouch.test.ts + * @input vitest, switchTouch.ts, the UIKit switch constants and the spring math + * @output Unit tests for Switch's touch model: geometry, the drag's thumb + * target, the flip rule, the release commit and the settle time, held to + * UISwitch on iOS 26.2; and the switch springs held to UIKit's own frames + * @position Testing; the pure half of Switch's touch presentation + * + * The UIKit reference points below were read with the UIKit lab on the iOS + * 26.2 simulator (a UISwitch in a host app, per-frame presentation samples of + * its lens and the touch stream; switch-local pt, the lens at rest 37 × 24 in + * a 63 × 28 track). They are the measurement the springs in + * motion/uikit/switch.ts were fitted to, kept here so a change to the table + * or to the spring math that leaves UIKit's curve fails. + */ + +import {describe, expect, it} from 'vitest'; +import {listMotionRoles, motionRoleOf} from '../motion/roles'; +import {solveSpring, toSpring} from '../motion/spring'; +import {switchControl} from '../motion/uikit/switch'; +import { + commitOnRelease, + dragTarget, + settleTime, + startFlip, + stepFlip, + switchTouchGeometry, + type SwitchFlip, +} from './switchTouch'; + +const UIKIT = switchTouchGeometry(63, 28); + +/** Feed a drag's events (finger travel since it landed) through the rule. */ +function drag( + isOn: boolean, + travels: number[], + geometry = UIKIT, +): SwitchFlip[] { + const states: SwitchFlip[] = []; + let flip = startFlip(isOn); + for (const travel of travels) { + flip = stepFlip(flip, travel, geometry); + states.push(flip); + } + return states; +} + +describe('switchTouchGeometry', () => { + it('is UIKit itself at UIKit size', () => { + expect(UIKIT.thumbWidth).toBeCloseTo(37, 9); + expect(UIKIT.thumbHeight).toBeCloseTo(24, 9); + expect(UIKIT.inset).toBeCloseTo(2, 9); + expect(UIKIT.travel).toBeCloseTo(22, 9); + expect(UIKIT.flipDistance).toBeCloseTo(26, 9); + expect(UIKIT.rubberBandDimension).toBeCloseTo(12, 9); + // The lifted lens: 58 × 38.33 from 37 × 24. + expect(UIKIT.thumbWidth * UIKIT.liftScaleX).toBeCloseTo(58, 9); + expect(UIKIT.thumbHeight * UIKIT.liftScaleY).toBeCloseTo(38.333, 9); + }); + + it('keeps UIKit proportions in a smaller track (md 40 × 24, sm 32 × 20)', () => { + for (const [width, height] of [ + [40, 24], + [32, 20], + ] as const) { + const geometry = switchTouchGeometry(width, height); + expect(geometry.thumbWidth / width).toBeCloseTo(37 / 63, 9); + expect(geometry.thumbHeight / height).toBeCloseTo(24 / 28, 9); + // Inset equally on three sides; the thumb reaches the far inset. + expect(geometry.inset * 2 + geometry.thumbHeight).toBeCloseTo(height, 9); + expect( + geometry.inset * 2 + geometry.thumbWidth + geometry.travel, + ).toBeCloseTo(width, 9); + // The finger flips the switch after the track minus the thumb. + expect(geometry.flipDistance).toBeCloseTo(width - geometry.thumbWidth, 9); + } + }); +}); + +describe('dragTarget', () => { + it('moves 1:1 with the finger between the ends', () => { + expect(dragTarget(0, 5, UIKIT)).toBeCloseTo(5, 9); + expect(dragTarget(22, -7, UIKIT)).toBeCloseTo(15, 9); + }); + + it("rubber-bands past either end with UIScrollView's curve (c 0.55, d 12)", () => { + // UIKit: 46 pt past the on end shows 8.15 pt (measured 8.5 with lag). + const past = 46; + const expected = (1 - 1 / ((past * 0.55) / 12 + 1)) * 12; + expect(dragTarget(0, 22 + past, UIKIT)).toBeCloseTo(22 + expected, 9); + expect(dragTarget(22, -22 - past, UIKIT)).toBeCloseTo(-expected, 9); + // Never further than d past the end. + expect(dragTarget(0, 1e6, UIKIT)).toBeLessThan(22 + 12); + }); + + it('starts from a banded thumb without a jump (grab mid-flight)', () => { + const banded = dragTarget(0, 40, UIKIT); + // A new finger lands on the thumb where it is: no movement, no jump. + expect(dragTarget(banded, 0, UIKIT)).toBeCloseTo(banded, 9); + }); +}); + +describe('stepFlip (UIKit, 1 pt steps)', () => { + it('flips on the event after the finger reaches 26 pt toward on, from any press point', () => { + // UIKit: armed at +26, flipped at +27, for presses at x 8, 20.5 and 45. + const states = drag(false, [24, 25, 26, 27, 28]); + expect(states.map(s => s.isVisuallyOn)).toEqual([ + false, + false, + false, + true, + true, + ]); + expect(states[2].isArmed).toBe(true); + expect(states[3].didFlip).toBe(true); + }); + + it('flips back when the finger returns past where it landed, not before', () => { + // UIKit: still on at +0.67, back off at −0.33. + const states = drag(false, [26, 27, 40, 10, 0.67, -0.33]); + expect(states.map(s => s.isVisuallyOn)).toEqual([ + false, + true, + true, + true, + true, + false, + ]); + expect(states[5].didFlip).toBe(true); + }); + + it('mirrors on an on switch: off 27 pt to the left, back on past the press', () => { + const states = drag(true, [-26, -27, -0.33, 0.67]); + expect(states.map(s => s.isVisuallyOn)).toEqual([true, false, false, true]); + }); + + it('never flips toward the side the switch is already on', () => { + // UIKit: a quick 40 pt swipe left on an off switch flips nothing (the + // lift then toggles it, like a tap). + expect(drag(false, [-10, -30, -40]).some(s => s.didFlip)).toBe(false); + }); + + it('arms on the last move of a fast swipe and flips on the lift', () => { + // UIKit: +4, +20, +32 then the lift at +40: the flip came with the lift. + const states = drag(false, [4, 20, 32]); + expect(states[2]).toMatchObject({isVisuallyOn: false, isArmed: true}); + expect(stepFlip(states[2], 40, UIKIT).isVisuallyOn).toBe(true); + }); + + it('disarms when the finger falls back short of the line before the next event', () => { + const states = drag(false, [26, 20, 27]); + expect(states.map(s => s.isVisuallyOn)).toEqual([false, false, false]); + expect(states[2].isArmed).toBe(true); + }); + + it('uses the track minus the thumb as the distance in a smaller track', () => { + const md = switchTouchGeometry(40, 24); + const states = drag( + false, + [md.flipDistance - 0.01, md.flipDistance, md.flipDistance + 1], + md, + ); + expect(states.map(s => s.isVisuallyOn)).toEqual([false, false, true]); + }); +}); + +describe('commitOnRelease (UIKit)', () => { + it('toggles when nothing flipped: a tap, a hold, a slide off, a 15 pt drag', () => { + expect(commitOnRelease(null, false)).toBe(true); + expect(commitOnRelease(null, true)).toBe(false); + expect(commitOnRelease(drag(false, [15]).at(-1)!, false)).toBe(true); + }); + + it('commits the side a drag shows once it flipped: a 30 pt drag stays on', () => { + expect(commitOnRelease(drag(false, [26, 27, 30]).at(-1)!, false)).toBe( + true, + ); + }); + + it('commits nothing new for a drag on and back off', () => { + expect(commitOnRelease(drag(false, [26, 27, -1]).at(-1)!, false)).toBe( + false, + ); + }); +}); + +describe('settleTime', () => { + it('settles at the release, but no sooner than the minimum lift', () => { + expect(settleTime(1000, 0)).toBe(1000); + expect(settleTime(50, 30)).toBe(30 + switchControl.minimumLiftMs); + // A caller's minimum, when it passes one, replaces it. + expect(settleTime(50, 30, 400)).toBe(430); + }); +}); + +describe('the switch springs against UIKit frames (iOS 26.2)', () => { + // [ms after the spring's start, value] read per frame from UIKit. + const cases = [ + { + name: 'lift: lens width 37 → 58 during a hold', + spring: switchControl.lift, + from: 37, + to: 58, + // Started 30.5 ms after the finger landed. + frames: [ + [49.8, 48.5], + [82.5, 56.34], + [116.9, 59.92], + [151.5, 60.58], + [186.8, 59.88], + [282.8, 58.0], + ], + // The fit's rms is 0.29 pt; UIKit writes the lens once per frame. + tolerance: 0.7, + }, + { + name: 'settle: lens width 58 → 37 after a hold', + spring: switchControl.settle, + from: 58, + to: 37, + // Started 23 ms after the finger lifted. + frames: [ + [42.7, 55.6], + [76.0, 51.72], + [109.4, 47.64], + [176.4, 41.03], + [277.9, 36.55], + [410.6, 36.23], + ], + // UIKit's own settings (0.5 s, 0.7) against frames whose start time is + // read to a frame; the best one-spring fit is 0.49 s (rms 0.065 pt). + tolerance: 0.45, + }, + { + name: 'travel: lens center 20.33 → 42.5 after a hold', + spring: switchControl.travel, + from: 20.333, + to: 42.5, + // Started 34.5 ms after the finger lifted. + frames: [ + [31.2, 23.17], + [64.5, 28.73], + [97.9, 33.55], + [133.4, 36.94], + [198.7, 40.42], + [300.8, 42.1], + ], + // UIKit's own settings (0.3 s, 1); the best one-spring fit is 0.31 s. + tolerance: 0.45, + }, + ] as const; + + for (const {name, spring, from, to, frames, tolerance} of cases) { + it(name, () => { + const curve = solveSpring(toSpring(spring), {from, to}); + for (const [ms, value] of frames) { + expect(Math.abs(curve.value(ms / 1000) - value)).toBeLessThan( + tolerance, + ); + } + }); + } + + it('the track color springs are the CASpringAnimations UIKit attached', () => { + // Read exactly: response 0.68 s and 0.40 s, critically damped. + for (const [spring, response] of [ + [switchControl.wellOn, 0.68], + [switchControl.wellOff, 0.4], + ] as const) { + const physics = toSpring(spring); + expect( + (2 * Math.PI) / Math.sqrt(physics.stiffness / physics.mass), + ).toBeCloseTo(response, 6); + expect( + physics.damping / (2 * Math.sqrt(physics.stiffness * physics.mass)), + ).toBeCloseTo(1, 6); + } + }); +}); + +describe('switch values', () => { + it('are internal constants, not motion roles', () => { + // Until Switch's record names its motion roles (spec:AST-094 FR4, + // DEC-5), the springs and the minimum lift time are plain constants. + expect( + listMotionRoles().filter(role => role.id.startsWith('switchControl.')), + ).toEqual([]); + expect(motionRoleOf(switchControl.lift)).toBeUndefined(); + expect(motionRoleOf(switchControl.travel)).toBeUndefined(); + expect(settleTime(0, 0)).toBe(switchControl.minimumLiftMs); + }); +}); diff --git a/packages/core/src/Switch/switchTouch.ts b/packages/core/src/Switch/switchTouch.ts new file mode 100644 index 0000000000000..a0090d2534d0c --- /dev/null +++ b/packages/core/src/Switch/switchTouch.ts @@ -0,0 +1,170 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file switchTouch.ts + * @input The UIKit switch constants (motion/uikit/switch.ts) and a track size + * @output Exports the pure model of Switch's touch presentation: the thumb + * geometry scaled to a track, the drag's thumb target, the flip rule, the + * commit on release and when the lifted thumb may settle + * @position Pure half of Switch's touch presentation, tested by + * switchTouch.test.ts without a browser; the presentation itself (thumb, + * gestures, compositor motion) drives it + * + * UISwitch on iOS 26.2 is the reference (see motion/uikit/switch.ts): + * + * - The thumb is a capsule of constant size. UIKit's proportions (a 37 × 24 + * thumb in a 63 × 28 track) are applied to whatever box the track has, so + * the touch presentation keeps the component's sizes. + * - Positions are offsets of the thumb from its off end, 0 … `travel`, in the + * writing direction; the caller mirrors them for right-to-left. + * - A drag moves the thumb's TARGET with the finger's translation since it + * landed, rubber-banded past either end; the thumb chases the target on a + * spring (the caller animates that). + * - The switch flips by the finger: once it is more than `flipDistance` (the + * track width minus the thumb width) from where it landed toward the other + * end, the next touch event shows the other side; coming back past where + * it landed shows the starting side again. Flips happen on events only. + * - A release commits the side the drag shows if it ever flipped, or toggles + * when it flipped nothing (a tap, a hold, a slide off, a short drag). + * + * SYNC: When modified, update switchTouch.test.ts. + */ + +import {rubberBandClamp, rubberBandUnclamp} from '../motion/rubberBand'; +import {switchControl} from '../motion/uikit/switch'; + +export interface SwitchTouchGeometry { + readonly trackWidth: number; + readonly trackHeight: number; + /** The thumb at rest, px. */ + readonly thumbWidth: number; + readonly thumbHeight: number; + /** Gap between the thumb and the track's top, bottom and nearer end, px. */ + readonly inset: number; + /** Offset of the thumb at the on end, px. */ + readonly travel: number; + /** Scale of the lifted thumb. */ + readonly liftScaleX: number; + readonly liftScaleY: number; + /** The drag's rubber-band dimension, px. */ + readonly rubberBandDimension: number; + /** How far the finger goes toward the other end before a flip, px. */ + readonly flipDistance: number; +} + +const [UIKIT_TRACK_WIDTH, UIKIT_TRACK_HEIGHT] = switchControl.trackSize; +const [UIKIT_THUMB_WIDTH, UIKIT_THUMB_HEIGHT] = switchControl.thumbSize; +const [LIFTED_WIDTH, LIFTED_HEIGHT] = switchControl.liftedThumbSize; + +/** UIKit's thumb geometry, in proportion to a track of this size. */ +export function switchTouchGeometry( + trackWidth: number, + trackHeight: number, +): SwitchTouchGeometry { + const thumbHeight = (trackHeight * UIKIT_THUMB_HEIGHT) / UIKIT_TRACK_HEIGHT; + const thumbWidth = (trackWidth * UIKIT_THUMB_WIDTH) / UIKIT_TRACK_WIDTH; + const inset = (trackHeight - thumbHeight) / 2; + return { + trackWidth, + trackHeight, + thumbWidth, + thumbHeight, + inset, + travel: Math.max(0, trackWidth - thumbWidth - 2 * inset), + liftScaleX: LIFTED_WIDTH / UIKIT_THUMB_WIDTH, + liftScaleY: LIFTED_HEIGHT / UIKIT_THUMB_HEIGHT, + rubberBandDimension: + (switchControl.rubberBand.dimension * trackHeight) / UIKIT_TRACK_HEIGHT, + flipDistance: (switchControl.flipDistance * trackWidth) / UIKIT_TRACK_WIDTH, + }; +} + +/** + * Where the drag puts the thumb's target: the offset it had when the finger + * landed moved by the finger's translation along the writing direction, + * rubber-banded past either end. + * + * `start` is the thumb's offset on screen when the finger landed (it may be + * mid-flight, or banded); it is unbanded first, so a finger that grabs a + * moving thumb continues from where the thumb is, without a jump. + */ +export function dragTarget( + start: number, + translation: number, + geometry: SwitchTouchGeometry, +): number { + const {travel, rubberBandDimension} = geometry; + const coefficient = switchControl.rubberBand.coefficient; + const raw = + rubberBandUnclamp(start, 0, travel, rubberBandDimension, coefficient) + + translation; + return rubberBandClamp(raw, 0, travel, rubberBandDimension, coefficient); +} + +/** A drag's flip state: the side it shows and how it got there. */ +export interface SwitchFlip { + /** The value when the finger landed. */ + readonly startedOn: boolean; + readonly isVisuallyOn: boolean; + /** The finger passed `flipDistance` on the last event: the next one flips. */ + readonly isArmed: boolean; + /** The drag has flipped at least once. */ + readonly didFlip: boolean; +} + +export function startFlip(isOn: boolean): SwitchFlip { + return {startedOn: isOn, isVisuallyOn: isOn, isArmed: false, didFlip: false}; +} + +/** + * One touch event of the drag. `translation` is the finger's travel since it + * landed along the writing direction (positive toward the on end). + */ +export function stepFlip( + flip: SwitchFlip, + translation: number, + geometry: SwitchTouchGeometry, +): SwitchFlip { + // Travel toward the other end: right from off, left from on. + const toward = flip.startedOn ? -translation : translation; + if (flip.isVisuallyOn !== flip.startedOn) { + return toward < 0 + ? {...flip, isVisuallyOn: flip.startedOn, isArmed: false} + : flip; + } + const isPast = toward >= geometry.flipDistance; + if (flip.isArmed && isPast) { + return { + ...flip, + isVisuallyOn: !flip.startedOn, + isArmed: false, + didFlip: true, + }; + } + return isPast === flip.isArmed ? flip : {...flip, isArmed: isPast}; +} + +/** + * What a release commits: the side a drag flipped to, or the other side of + * the value when nothing flipped during the gesture. + */ +export function commitOnRelease( + flip: SwitchFlip | null, + isOn: boolean, +): boolean { + return flip?.didFlip === true ? flip.isVisuallyOn : !isOn; +} + +/** + * When the lifted thumb may settle, on the document timeline (ms): at the + * release, but never sooner than the minimum lift time after the lift began. + * A caller may pass the minimum it plays; the table's measured one by + * default. + */ +export function settleTime( + releaseTime: number, + liftTime: number, + minimumLiftMs: number = switchControl.minimumLiftMs, +): number { + return Math.max(releaseTime, liftTime + minimumLiftMs); +} diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index 96b5578d38fb0..3f497aa1747ca 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -120,12 +120,13 @@ Subject "switch S": the first switch in Settings ▸ Accessibility ▸ Display & Text Size (Bold Text row); a minimal host app holds a UISwitch on a plain view where a switch outside any scroll view is needed. -Captured (iOS 26.2 simulator, Oct 23 2026), with their values landing in the -switch's own table: M-SW02, M-SW03, M-SW04, M-SW05, M-SW06, M-SW09 (UISwitch -makes no haptic call), M-SW11 (Reduce Motion: the lift stays, the stretch -goes), M-SW16 (iOS 26.2). Until that table lands they stay open here, and -`springs.switchThumbMove` and `switchThumbStretch` keep WebKit's values. -Partly: M-SW07 (a single tap only). Still open: M-SW17, and M-SW11's On/Off +Captured (iOS 26.2 simulator, Oct 23 2026), with their values in the +switch's own table (`uikit/switch.ts`): M-SW02, M-SW03, M-SW04, M-SW05, +M-SW06, M-SW09 (UISwitch makes no haptic call), M-SW11 (Reduce Motion: the +lift stays, the stretch goes), M-SW16 (iOS 26.2). `springs.switchThumbMove` +holds the measured travel the switch's table reads; WebKit's +`switchThumbStretch` is retired for the table's `lift`. Partly: M-SW07 (a single +tap only). Still open: M-SW17, and M-SW11's On/Off labels, Reduce Transparency and Bold Text. | ID | Scene | Gesture | Extract | Method | diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 8b8fa725a524a..52746f98bfd97 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -19,6 +19,7 @@ so each keeps its measured value; no shipped component reads the roles yet. | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | | `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). | +| `uikit/switch.ts` | UISwitch on iOS 26.2: geometry, the lift, settle, travel and tracking springs, the flip distance, the track colour. | | `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. | diff --git a/packages/core/src/motion/uikit/index.ts b/packages/core/src/motion/uikit/index.ts index 317b51207a562..b11c9229e97d0 100644 --- a/packages/core/src/motion/uikit/index.ts +++ b/packages/core/src/motion/uikit/index.ts @@ -4,9 +4,9 @@ * @file index.ts * @input The domain files of the UIKit constants table * @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`, - * …), `scroll`, `timing`, `navigation` and the - * gesture thresholds (one `UIKIT_*` group per recognizer, their union - * `UIKIT_GESTURES`, and the haptic tables) + * …), `scroll`, `timing`, `navigation`, the gesture thresholds (one + * `UIKIT_*` group per recognizer, their union `UIKIT_GESTURES`, and the + * haptic tables) and the switch domain (`switchControl`) * @position Entry of the UIKit constants table (motion/uikit). Internal to * core: not exported from the package entry point * @@ -71,10 +71,10 @@ export { SUB_MENU_BACK_SPRING, SUB_MENU_OPEN_SPRING, SWITCH_THUMB_MOVE_SPRING, - SWITCH_THUMB_STRETCH_SPRING, TIMED_CHANGE_SPRING, VIEW_CHANGE_SPRING, registerSpringsTable, springs, } from './springs'; +export {switchControl} from './switch'; export {registerTimingTable, timing} from './timing'; diff --git a/packages/core/src/motion/uikit/springs.ts b/packages/core/src/motion/uikit/springs.ts index f94b102884366..ba4e29c082b4a 100644 --- a/packages/core/src/motion/uikit/springs.ts +++ b/packages/core/src/motion/uikit/springs.ts @@ -16,10 +16,11 @@ * uikit.test.ts enforces both. * * Most springs are measured on iOS 26.2 or documented by Apple; two are still - * GUESSES (`segmentedThumb`, `banner`) and two come from WebKit - * (`switchThumbMove`, `switchThumbStretch`). A guess starts from the - * nearest sourced spring and names it, so a component built on the table - * gets the right value the moment its measurement lands, without changing. + * GUESSES (`segmentedThumb`, `banner`); `switchThumbMove` is the switch's + * measured travel, which its own table (uikit/switch.ts) reads. A guess + * starts from the nearest sourced spring and names it, so a component built + * on the table gets the right value the moment its measurement lands, + * without changing. * * Press highlight in/out are fades, not springs: see timing.ts. * @@ -373,38 +374,25 @@ export const MENU_REDUCED_MOTION_SPRING = { // --------------------------------------------------------------------- /** - * UISwitch thumb travelling between off and on. - * @source webkit Source/WebCore/rendering/cocoa/RenderThemeCocoa.mm - * `thumbMoveSpring = SpringSolver(1.0, 438.649…, 31.4159…)`, "Values - * chosen for consistency with UIKit" (= response 0.30 s, ratio 0.75); - * its settling duration is WebKit's iOS switch duration 0.48801 s - * (RenderThemeIOS.h). These are WebKit's iOS 18 values: UIKit's own - * switch frames differ, and a switch reads its measured table instead. - * @measure M-SW02 + * UISwitch thumb travelling between off and on, measured: the switch table's + * `travel` reads this row, one value for one use (`spec:AST-094` FR1). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the lens's position behavior read on a tap (damping 1, + * response 0.3); the lens center after a 50 ms tap and after a 1 s hold + * fits one spring at 0.31 / 1 (rms 0.11 pt), Oct 23 2026 */ export const SWITCH_THUMB_MOVE_SPRING = { response: 0.3, - dampingRatio: 0.75, -} as const; - -/** - * UISwitch thumb stretching while pressed and relaxing on release. - * @source webkit Source/WebCore/rendering/cocoa/RenderThemeCocoa.mm - * `thumbPressSpring = SpringSolver(1.0, 322.272…, 31.2364…)`, "Values - * chosen for consistency with UIKit" (= response 0.35 s, ratio 0.87); - * settles in WebKit's held duration 0.50740 s (RenderThemeIOS.h). As - * for `switchThumbMove`, UIKit's own frames differ. - * @measure M-SW03 - */ -export const SWITCH_THUMB_STRETCH_SPRING = { - response: 0.35, - dampingRatio: 0.87, + dampingRatio: 1, } as const; /** * UISegmentedControl selection thumb sliding to a new segment. - * @source GUESS — WebKit's UIKit-matched switch thumb spring, the nearest - * sourced control thumb + * @source GUESS — WebKit's UIKit-matched switch thumb spring + * (RenderThemeCocoa.mm `thumbMoveSpring`: response 0.30 s, ratio 0.75), + * kept only until UISegmentedControl's measured spring replaces it + * (#8094); UISwitch's measured travel (`SWITCH_THUMB_MOVE_SPRING`) + * belongs to another control * @measure M-SG02 * @measure M-SG03 */ @@ -474,7 +462,6 @@ export const springs = { contextMenuDismiss: CONTEXT_MENU_DISMISS_SPRING, menuReducedMotion: MENU_REDUCED_MOTION_SPRING, switchThumbMove: SWITCH_THUMB_MOVE_SPRING, - switchThumbStretch: SWITCH_THUMB_STRETCH_SPRING, segmentedThumb: SEGMENTED_THUMB_SPRING, keyboard: KEYBOARD_SPRING, banner: BANNER_SPRING, diff --git a/packages/core/src/motion/uikit/switch.ts b/packages/core/src/motion/uikit/switch.ts new file mode 100644 index 0000000000000..48042e8384a3f --- /dev/null +++ b/packages/core/src/motion/uikit/switch.ts @@ -0,0 +1,171 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file switch.ts + * @input The springs table's measured switch travel (springs.ts) + * @output Exports `switchControl`: UISwitch's geometry, the springs of its + * Liquid Glass thumb and of its track color, the drag's rubber band and + * flip distance, and the release timing + * @position The switch domain of the UIKit constants table (motion/uikit) + * + * The reference is UISwitch on iOS 26.2 (style `.sliding`): a white capsule + * thumb (a `_UILiquidLensView`) that, while a finger is on the control, lifts + * into a larger clear glass lens, follows a drag with a rubber band past + * either end, and settles back to a white capsule when the finger lifts. + * The track color is a wide image ("the well") that slides under the track + * on a spring of its own. UISwitch plays no haptic. + * + * Values read with the UIKit lab (a UIKit host app with hooks injected) on + * the iOS 26.2 simulator: subview frames, the `CASpringAnimation`s UIKit + * attaches (read exactly), and per-frame presentation samples of the lens, + * which UIKit drives from the main thread with presentation modifiers. The + * lens springs are fits of those samples (closed-form spring step, least + * squares, rms residual given per entry). Lengths are in pt (CSS px). + * + * Every leaf carries one or more `@source`; GUESS values also carry the + * `@measure` task that will confirm or replace them. uikit.test.ts enforces + * both. + * + * Every value here is an internal constant. The springs and the minimum lift + * stay plain constants until `component:Switch` names its motion roles + * (`spec:AST-094` FR4, DEC-5; DEC-11 keeps every role from themes); the + * geometry, rubber band, flip distance, shadow and haptic are not motion. + * + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md. + */ + +import {SWITCH_THUMB_MOVE_SPRING} from './springs'; + +export const switchControl = { + /** + * The control's size (width × height): the track. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `UISwitch` frame 63 × 28 (intrinsic 61 × 28), track view + * 63 × 28 with corner radius 14, Oct 23 2026 + */ + trackSize: [63, 28], + /** + * The thumb at rest (width × height), inset equally from the track's top, + * bottom and nearer end; it never changes size between off and on. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `_UILiquidLensView` frame 37 × 24 at x 2 (off) and + * x 24 (on) inside the 63 × 28 track, Oct 23 2026 + */ + thumbSize: [37, 24], + /** + * The thumb lifted under a finger (width × height): the lens grows about + * its own center, overflowing the track, and holds this size while the + * finger stays down. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, lens bounds settled at 58 × 38.33 during a 1 s hold + * (center unmoved), Oct 23 2026 + */ + liftedThumbSize: [58, 38.333], + /** + * The lift: the thumb growing to `liftedThumbSize` while its white fill + * fades out and the clear glass fades in (one spring drives all three). + * Starts with the touch outside a scroll view, after its content delay + * inside one. UISwitch keeps it under Reduce Motion; core's Switch does not + * lift then (design:reduced-motion). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, per-frame lens bounds during a 1 s hold fitted with one + * spring for width and height (rms 0.29 pt; the fill's and the glass's + * opacity overshoot by the same 13 %; the lens's settings read on a tap + * say damping 0.625, response 0.27, whose 8 % overshoot is short of the + * frames' 12 %), Oct 23 2026 + */ + lift: {response: 0.23, dampingRatio: 0.55}, + /** + * The settle after the finger lifts: the lens shrinking back to + * `thumbSize` while the white fill returns over the glass. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the lens's spring settings read on a tap (damping 0.7, + * response 0.5); per-frame lens bounds after the lift of a 1 s hold fit + * one spring at 0.49 / 0.70 (rms 0.065 pt; the opacities undershoot by + * the same 4.5 %), Oct 23 2026 + */ + settle: {response: 0.5, dampingRatio: 0.7}, + /** + * The settle never starts sooner than this after the lift began, so a + * quick tap still shows the lifted lens, in ms. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, a 50 ms tap: the lift→settle hand-off fitted at 235 ms + * after the lift began (rms 0.33 pt), Oct 23 2026 + * @measure M-SW07 + */ + minimumLiftMs: 235, + /** + * The thumb travelling to the committed end after a release (a tap, a + * hold, a drag) and on a programmatic change, from the thumb's own + * velocity. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the lens's position behavior read on a tap (damping 1, + * response 0.3); the lens center after a 50 ms tap and after a 1 s hold + * fits one spring at 0.31 / 1 (rms 0.11 pt), Oct 23 2026; the springs + * table's `SWITCH_THUMB_MOVE_SPRING` holds the one value + */ + travel: SWITCH_THUMB_MOVE_SPRING, + /** + * During a drag the thumb chases the finger: each move retargets this + * spring at the finger's position, rubber-banded past either end. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, a 4 s drag off → on → past both ends: the lens center + * simulated as this spring chasing the rubber-banded finger fits every + * frame (rms 0.21 pt), Oct 23 2026 + */ + tracking: {response: 0.36, dampingRatio: 1}, + /** + * The drag's rubber band past either end: UIScrollView's + * `(1 − 1 ÷ (x·c ÷ d + 1))·d`, with this coefficient c and dimension d in pt + * for the 28 pt tall control (scale d with the track height). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the same drag fit: c 0.55, d 11.75–12 pt, the + * translation measured from where the finger landed, Oct 23 2026 + */ + rubberBand: {coefficient: 0.55, dimension: 12}, + /** + * A drag flips the switch by the finger, not the thumb: once the finger + * is more than this far from where it landed, toward the other end, in pt + * (the track width minus the thumb width: 63 − 37), the switch shows the + * other side; it shows the side it started on again once the finger comes + * back past where it landed. Flips happen on touch events only, so a fast + * swipe whose last move crosses the line flips on the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `UISwitchModernVisualElement` ivars per frame and the + * "SwitchWellPosition" springs during 1 pt-step drags: the same 26 pt for + * presses at x 8, 20.5 and 45, with and without a 400 ms hold, mirrored + * on an on switch; flipping back at the press point, Oct 23 2026 + */ + flipDistance: 26, + /** + * The track color turning on: the well image sliding in on a critically + * damped spring, starting when the switch flips (the lift of a tap). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `CASpringAnimation` "SwitchWellPosition" read: mass 1, + * stiffness 85.377, damping 18.480 (response 0.68 s), Oct 23 2026 + */ + wellOn: {mass: 1, stiffness: 85.37720070146501, damping: 18.47995678582231}, + /** + * The track color turning off: the same well sliding back, faster. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `CASpringAnimation` "SwitchWellPosition" read: mass 1, + * stiffness 246.740, damping 31.416 (response 0.40 s), Oct 23 2026 + */ + wellOff: {mass: 1, stiffness: 246.74011002723395, damping: 31.41592653589793}, + /** + * The lifted lens's drop shadow: y offset and radius in pt, then black + * alpha; none at rest. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the lens layer's presented `shadowOffset` (0, 7), + * `shadowRadius` 3, `shadowOpacity` 0.12 once lifted, 0 at rest, + * Oct 23 2026 + */ + liftedShadow: [7, 3, 0.12], + /** + * UISwitch plays no haptic on a tap, a hold or a drag. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, every `UIFeedbackGenerator` entry point hooked: no call + * on tap, hold, drag or flip, Oct 23 2026 + */ + haptic: 'none', +} as const;