Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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-switch-values.md
Original file line number Diff line number Diff line change
@@ -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
294 changes: 294 additions & 0 deletions packages/core/src/Switch/switchTouch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading