Repository navigation
chore(core): swipe actions' UIKit values and release rule #8069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 37 commits
c784084
0c35c43
17c181b
6bd2851
76be493
4b3de64
530315f
7fd8edf
e7df427
439dcc3
0bf13da
bbbcb60
7c5f71c
03f66c3
1133bf9
eded944
f9d0900
9db9315
5d3cab6
7e36bcd
c1fa660
59a748f
6ae3390
51b3ecb
1f751c9
96f8240
1ae9200
14935b0
7234d44
25330a3
d3a2e41
3dca43f
3918f31
13ecb14
a269997
fa6c198
f5f7346
bbcb3f4
e805797
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: the UIKit values of a swipe-action row (the swipe threshold, the spring the row follows the finger and settles with, the touch claim distance the web needs) and the release rule that picks closed, open or fire (a fast release goes the way it moves, a slow one by where it is, and only a release past the commit point fires), checked against 50 releases read from UIKit. Not exported from the package entry point. | ||
|
|
||
| @vjeux | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file swipeRelease.test.ts | ||
| * @input vitest; swipeRelease.ts and the UIKit constants table | ||
| * @output The release rule against the UIKit lab's releases; the commit | ||
| * point and the resistance past it; the swipe spring is the one UIKit | ||
| * reported | ||
| * @position Unit tests of packages/core/src/Item/swipeRelease.ts | ||
| */ | ||
|
|
||
| import {describe, expect, it} from 'vitest'; | ||
| import { | ||
| dampingRatioOf, | ||
| naturalFrequency, | ||
| settlingDuration, | ||
| toSpring, | ||
| } from '../motion/spring'; | ||
| import {swipeActions} from '../motion/uikit/swipeActions'; | ||
| import {commitPoint, resistedTravel, resolveRelease} from './swipeRelease'; | ||
|
|
||
| describe('resolveRelease', () => { | ||
| // The UIKit lab's releases (batch 10a: plain table, 402 pt rows, two | ||
| // trailing actions, a 156.67 pt open width; the full-swipe point far beyond | ||
| // them), as the swipe pan reported them when the finger lifted: the | ||
| // swipe's travel (past the 25 pt the pan needs to begin; negative when the | ||
| // finger came back past where it began) and its velocity in pt/s, positive | ||
| // toward open. A scripted "closing" release can still be opening when it | ||
| // lifts (the 36 pt one at +458), so the reported values are the inputs. | ||
| const OPEN_WIDTH = 156.67; | ||
| const FAR = 373; | ||
| const RELEASES: [travel: number, velocity: number, lands: string][] = [ | ||
| [35, -19, 'closed'], | ||
| [65, 490, 'open'], | ||
| [125, 1506, 'open'], | ||
| [5, -492, 'closed'], | ||
| [-55, -1532, 'closed'], | ||
| [95, -16, 'open'], | ||
| [125, 478, 'open'], | ||
| [185, 1565, 'open'], | ||
| [65, -468, 'closed'], | ||
| // [115, +135]: closed. Scripted as a 1500 pt/s closing flick, it lifted | ||
| // with the pan reporting a slow opening one: the one release of the 51 | ||
| // neither this rule nor projection explains. | ||
| [-67, -2545, 'closed'], | ||
| [-47.7, -1025, 'closed'], | ||
| [-3, -256, 'closed'], | ||
| [12.3, -22, 'closed'], | ||
| [30.3, 293, 'open'], | ||
| [64, 1058, 'open'], | ||
| [132.3, 1983, 'open'], | ||
| [-67, -2060, 'closed'], | ||
| [-10, -1087, 'closed'], | ||
| [35.7, 458, 'open'], | ||
| [50, -18, 'closed'], | ||
| [63, 286, 'open'], | ||
| [93, 1372, 'open'], | ||
| [170, 2031, 'open'], | ||
| [1.7, -2168, 'closed'], | ||
| [27.3, -996, 'closed'], | ||
| [69.3, -287, 'closed'], | ||
| [87.7, -5, 'open'], | ||
| [100.3, 148, 'open'], | ||
| [147.3, 1271, 'open'], | ||
| [190.3, 608, 'open'], | ||
| [39.3, -2052, 'closed'], | ||
| [65, -983, 'closed'], | ||
| [107, -308, 'closed'], | ||
| [125, -18, 'open'], | ||
| [143, 347, 'open'], | ||
| [185, 948, 'open'], | ||
| [245, 2082, 'open'], | ||
| [42.3, -1975, 'closed'], | ||
| [102.7, -991, 'closed'], | ||
| [144.3, -283, 'closed'], | ||
| [162.3, -19, 'open'], | ||
| [180.3, 271, 'open'], | ||
| [222.7, 1002, 'open'], | ||
| [282.3, 1971, 'open'], | ||
| ]; | ||
|
|
||
| it.each(RELEASES)( | ||
| 'lands where UIKit did: %s pt of swipe at %s pt/s → %s', | ||
| (travel, velocity, lands) => { | ||
| expect( | ||
| resolveRelease({ | ||
| behavior: 'reveal', | ||
| commitAt: FAR, | ||
| openWidth: OPEN_WIDTH, | ||
| travel: Math.max(0, travel), | ||
| velocity, | ||
| }), | ||
| ).toBe(lands); | ||
| }, | ||
| ); | ||
|
|
||
| it('goes the way a release moves from about 50 pt/s, and by where it is below that (batches 38c, 38d)', () => { | ||
| // 204.67 pt open width, released at 0.7 of it: past half. The speeds | ||
| // are the pan's at its end, as UIKit read them. | ||
| const base = { | ||
| behavior: 'reveal' as const, | ||
| commitAt: 325, | ||
| openWidth: 204.67, | ||
| travel: 143, | ||
| }; | ||
| for (const velocity of [-19.9, -21.2, -35.7, -40.7, 0, 56.1]) { | ||
| expect(resolveRelease({...base, velocity})).toBe('open'); | ||
| } | ||
| for (const velocity of [-56.1, -56.5, -68.5, -105, -200, -308]) { | ||
| expect(resolveRelease({...base, velocity})).toBe('closed'); | ||
| } | ||
| }); | ||
|
|
||
| it('closes a release past the open width that is moving back, as UIKit did (batch 38)', () => { | ||
| // 204.67 pt open width; released about 300 pt out while coming back at | ||
| // 300 pt/s: UIKit closed the row, though it would coast to rest past half | ||
| // the open width. | ||
| expect( | ||
| resolveRelease({ | ||
| behavior: 'reveal', | ||
| commitAt: 325, | ||
| openWidth: 204.67, | ||
| travel: 305, | ||
| velocity: -300, | ||
| }), | ||
| ).toBe('closed'); | ||
| // The same release at rest, or still opening, stays open. | ||
| for (const velocity of [0, 300]) { | ||
| expect( | ||
| resolveRelease({ | ||
| behavior: 'reveal', | ||
| commitAt: 325, | ||
| openWidth: 204.67, | ||
| travel: 305, | ||
| velocity, | ||
| }), | ||
| ).toBe('open'); | ||
| } | ||
| }); | ||
|
|
||
| it('fires under reveal on a release past the commit point, whatever the velocity, and never on a flick short of it', () => { | ||
| const base = {behavior: 'reveal' as const, commitAt: 192, openWidth: 144}; | ||
| expect(resolveRelease({...base, travel: 192, velocity: 0})).toBe('perform'); | ||
| expect(resolveRelease({...base, travel: 200, velocity: -2000})).toBe( | ||
| 'perform', | ||
| ); | ||
| expect(resolveRelease({...base, travel: 44, velocity: 3000})).toBe('open'); | ||
| expect(resolveRelease({...base, travel: 191, velocity: 3000})).toBe('open'); | ||
| }); | ||
|
|
||
| it('fires under commit only past the commit point, and never rests', () => { | ||
| const base = {behavior: 'commit' as const, commitAt: 150, openWidth: 72}; | ||
| expect(resolveRelease({...base, travel: 150, velocity: 0})).toBe('perform'); | ||
| expect(resolveRelease({...base, travel: 160, velocity: -1000})).toBe( | ||
| 'perform', | ||
| ); | ||
| expect(resolveRelease({...base, travel: 100, velocity: 0})).toBe('closed'); | ||
| expect(resolveRelease({...base, travel: 40, velocity: 3000})).toBe( | ||
| 'closed', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('commitPoint and resistedTravel', () => { | ||
| it('commits past the panel, or past the fraction of the row when that is further', () => { | ||
| expect(commitPoint(72, 300)).toBe(150); | ||
| expect(commitPoint(144, 300)).toBe(192); | ||
| }); | ||
|
|
||
| it('follows the finger up to the commit point and resists past it', () => { | ||
| expect(resistedTravel(150, 150)).toBe(150); | ||
| expect(resistedTravel(250, 150)).toBeCloseTo( | ||
| 150 + 100 * swipeActions.overdragResistance, | ||
| 10, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('the swipe spring', () => { | ||
| it('is the one UIKit reported: critically damped at 10 rad/s, settling in 0.9235 s', () => { | ||
| for (const description of [swipeActions.tracking, swipeActions.settle]) { | ||
| const spring = toSpring(description); | ||
| expect(dampingRatioOf(spring)).toBeCloseTo(1, 10); | ||
| expect(naturalFrequency(spring)).toBeCloseTo(10, 10); | ||
| // UIKit's settlingDuration for it, within the 0.02 % the solver's | ||
| // critically damped branch is documented to land. | ||
| expect(settlingDuration(spring)).toBeCloseTo(0.9235, 3); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file swipeRelease.ts | ||
| * @input The UIKit constants table (motion/uikit/swipeActions.ts) | ||
| * @output Exports resolveRelease (where a swipe-action release lands: closed, | ||
| * open, or the outermost entry fires), commitPoint and resistedTravel | ||
| * @position Pure pieces of Item's swipe actions, beside useSwipeAction; | ||
| * tested by swipeRelease.test.ts against the UIKit lab's releases | ||
| * | ||
| * A UIKit list row decides a release by its speed first. A release at | ||
| * `releaseVelocity` or faster goes the way it moves: it opens the row when | ||
| * it opens and closes it when it closes, from anywhere short of the commit | ||
| * point. A slower one goes by where it is, against `openFraction` of the | ||
| * open width. So a flick opens the row even from a short drag and closes it | ||
| * from anywhere, and a flick never fires an action: only a release past the | ||
| * commit point does. Travel and velocity here are logical: measured toward | ||
| * the side being uncovered, whatever the writing direction. | ||
| */ | ||
|
|
||
| import {swipeActions} from '../motion/uikit/swipeActions'; | ||
|
|
||
| /** What a swipe does (Item's `swipeBehavior`). */ | ||
| export type SwipeReleaseBehavior = 'reveal' | 'commit'; | ||
|
|
||
| /** Where a release lands. */ | ||
| export type SwipeRelease = 'closed' | 'open' | 'perform'; | ||
|
|
||
| /** | ||
| * Where a release lands. `travel` (px, ≥ 0) and `velocity` (px/s) are | ||
| * logical: positive toward the side being uncovered. A release past the | ||
| * commit point fires the outermost entry, whatever its velocity; under | ||
| * `commit` nothing else fires and nothing rests. Under `reveal` a release at | ||
| * `releaseVelocity` or faster opens the row if it is opening and closes it if | ||
| * it is closing; a slower one opens it past `openFraction` of its open width | ||
| * and closes it short of that. | ||
| */ | ||
| export function resolveRelease({ | ||
| travel, | ||
| velocity, | ||
| openWidth, | ||
| commitAt, | ||
| behavior, | ||
| }: { | ||
| travel: number; | ||
| velocity: number; | ||
| openWidth: number; | ||
| commitAt: number; | ||
| behavior: SwipeReleaseBehavior; | ||
| }): SwipeRelease { | ||
| if (travel >= commitAt) { | ||
| return 'perform'; | ||
| } | ||
| if (behavior === 'commit') { | ||
| return 'closed'; | ||
| } | ||
| if (velocity >= swipeActions.releaseVelocity) { | ||
| return 'open'; | ||
| } | ||
| if (velocity <= -swipeActions.releaseVelocity) { | ||
| return 'closed'; | ||
| } | ||
|
Comment on lines
+54
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Under |
||
| return travel >= swipeActions.openFraction * openWidth ? 'open' : 'closed'; | ||
| } | ||
|
|
||
| /** | ||
| * The commit point: `commitPastPanel` past the open width, or | ||
| * `commitFraction` of the row when that is further. | ||
| */ | ||
| export function commitPoint(openWidth: number, rowWidth: number): number { | ||
| return Math.max( | ||
| openWidth + swipeActions.commitPastPanel, | ||
| rowWidth * swipeActions.commitFraction, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The row's travel for the finger's: one for one up to the commit point, | ||
| * `overdragResistance` per pixel past it. | ||
| */ | ||
| export function resistedTravel(raw: number, commitAt: number): number { | ||
| return raw <= commitAt | ||
| ? raw | ||
| : commitAt + (raw - commitAt) * swipeActions.overdragResistance; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
swipeRelease.test.tschecks 44 of batch 10a's 51 releases (the table at lines 32-80, with a 45th commented out at 42-44), not 50: add the six missing releases, or say 44. This text ships in the@astryxdesign/corechangelog.