Repository navigation
feat(core): tap and long-press recognizers with UIKit's measured thresholds - #8059
Conversation
Springs, scroll physics, timing curves, navigation values and gesture thresholds for the touch presentations of core components, each with its source (Apple documentation, a WWDC session, WebKit's UIKit-matched source, or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess with its measurement task. A test enforces the source rules on every domain file, in their JSDoc and typed forms, and checks the list of simulator measurements still owed.
…rena An internal gesture system for touch presentations, not exported from the package. GestureRecognizer carries UIKit's states and allowed transitions, discrete and continuous recognizers, require-to-fail, simultaneous recognition on request, shouldBegin and shouldReceiveTouch, cancelsTouchesInView and the touch delays. GestureArena settles each touch sequence without the DOM: participants, resolution order, claims, delivery to the view, click dispositions (allow, suppress, defer and replay) and timers as deadlines on the event clock. Its rules are tested with scripted recognizers, independent of any real one.
…sholds Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on the event clock within 10 pt, even when the main thread was blocked, and a zero duration begins on the touch itself. The arena tests with these real recognizers cover tap against double tap, delegate requirements, deferred clicks and cancellation.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This pull request is on hold for a spec decision: the tap and long-press recognizers, the gesture core and the UIKit constants table rest only on draft spec:AST-094, so an engineering owner needs to approve FR1, FR2, FR12 and FR15–FR17 (approving the record as written also accepts FR4, FR11 and FR14's changes to theme-tokens, user-states and AST-025) by commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner, or the change waits until #8052 lands the record. Independently of that decision, please address these points:
- A two-finger double tap fails at its second tap because
TapGestureRecognizer.ts:93-101compares the next tap's first finger with the previous tap's last-lift position, so compare centroids (or matching fingers) and add a two-finger double-tap test. - After a long task, if the arena's own timer runs before a queued
touchend,GestureArena.ts:831-836begins a long press whose finger already lifted, so yield to queued input before running a late deadline and test that order. - The diff carries 5,065 lines because #8052, #8053 and #8058 ride along, so land them first to make this pull request its own 821-line commit, and rerun the Lint job, which aborted out of memory.
[Automated review]
| const first = touches[0]; | ||
| if ( | ||
| this.firstTapLocation != null && | ||
| distance({x: first.startX, y: first.startY}, this.firstTapLocation) > | ||
| this.allowableSeparation | ||
| ) { | ||
| this.setState('failed'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
A two-finger double tap fails here: touches[0] is the next tap's first finger, but firstTapLocation (line 145) is the previous tap's last-lift position, or its centroid when the fingers lift together. The same two-finger tap repeated at the same two points 100 px apart fails at the second tap's first finger. Compare the next tap's centroid once its required fingers are down with the first tap's centroid (or each finger with the nearest first-tap finger), and add a two-finger double-tap test.
| const delay = Math.max(0, earliest - this.clock.now()); | ||
| this.realTimer = this.clock.setTimeout(() => { | ||
| this.realTimer = null; | ||
| this.realTimerDeadline = Number.POSITIVE_INFINITY; | ||
| this.advanceTime(Math.max(earliest, this.clock.now())); | ||
| }, delay); |
There was a problem hiding this comment.
After a long task the browser may run this timer task before a queued touchend. advanceTime(Math.max(earliest, this.clock.now())) then runs the long-press deadline first, so a finger that lifted at 300 ms during a 100–900 ms block still begins a press at 500 and ends it at 900 instead of failing at 300. Consider yielding once to queued input before running a late deadline, and add a test whose clock fires the timer before the queued lift.
There was a problem hiding this comment.
Changes requested: the tap and long-press recognizers follow current spec:AST-094, and the taps phase now names its measurement (M-G20) with the second-finger case tested, but two points remain before this can land.
TapGestureRecognizer.tsandLongPressGestureRecognizer.tsimport theUIKIT_GESTURESunion, which bundles all eleven gesture groups into any component that adopts them (about 0.3 KB gzip of other recognizers' rows), so please importUIKIT_TAPandUIKIT_LONG_PRESSby name, asarchitecture:component-style-authoringINV6 asks.- The diff is 3,736 lines because #8058 rides along, so land #8058 first to make this pull request its own 948 lines.
A smaller fix would also help: the SYNC notes in GestureArena.ts and GestureRecognizer.ts name files that do not exist.
[Automated review]
| * tap that requires a double tap to fail waits the interval out, as in UIKit. | ||
| */ | ||
|
|
||
| import {UIKIT_GESTURES} from '../motion/uikit/gestures'; |
There was a problem hiding this comment.
The UIKIT_GESTURES union bundles every gesture group (object properties do not tree-shake, as motion/uikit/gestures.ts:277-282 notes): an esbuild bundle of this recognizer carries all eleven groups' rows, 2,626 B gzip against 2,301 B with import {UIKIT_TAP} from '../motion/uikit/gestures' and const TAP = UIKIT_TAP; (line 43). architecture:component-style-authoring INV6: "importing a component does not pull in rows only other components read".
| * a blocked main thread delays the callback, not the moment it reports. | ||
| */ | ||
|
|
||
| import {UIKIT_GESTURES} from '../motion/uikit/gestures'; |
There was a problem hiding this comment.
Same as the tap recognizer: this reads only the long-press and tap groups (lines 59-64, 165, 173), so import {UIKIT_LONG_PRESS, UIKIT_TAP} from '../motion/uikit/gestures' keeps the other nine groups out of an adopting component's bundle (2,521 B gzip today, 2,222 B with the named imports; INV6).
…ure groups They imported the UIKIT_GESTURES union, whose object properties do not tree-shake, so the first adopter bundled all eleven groups. They now import UIKIT_TAP and UIKIT_LONG_PRESS by name (INV6): the tap recognizer bundles to 2,288 B gzip, the long press to 2,220 B.
There was a problem hiding this comment.
Changes requested: the tap and long-press recognizers follow current spec:AST-094, and both now import only their own UIKit gesture groups, but the diff still carries #8058's gesture core (3,735 lines in all), so please land #8058 first to make this pull request its own 947 lines. Two smaller fixes would also help: the SYNC notes in GestureArena.ts and GestureRecognizer.ts still name files that do not exist (#8058's head already corrects them), and the long press still bundles the two UIKIT_TAP rows it never reads (about 14 B gzip).
[Automated review]
| * SYNC: When modified, update GestureRecognizer.ts, GestureHost.ts, | ||
| * GestureArena.test.ts and README.md. |
There was a problem hiding this comment.
This SYNC list names GestureHost.ts, GestureArena.test.ts and README.md, none of which exists under gestures/ at this head (the arena's tests are __tests__/arena.test.ts). #8058's head already lists only the files that exist; bringing it into this branch fixes this. Not blocking.
| * event, so a listener that writes a transform writes it in the same frame | ||
| * as the input, with no React render in between. | ||
| * | ||
| * SYNC: When modified, update GestureArena.ts and README.md. |
There was a problem hiding this comment.
README.md does not exist under gestures/ at this head; #8058's head already drops it from this SYNC note. Not blocking.
| * a blocked main thread delays the callback, not the moment it reports. | ||
| */ | ||
|
|
||
| import {UIKIT_LONG_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; |
There was a problem hiding this comment.
Importing the whole UIKIT_TAP group to read maximumTapDuration and maximumIntervalBetweenTaps also bundles its allowableMovement and allowableSeparation rows, which only the tap recognizer reads (about 14 B gzip). Not blocking: giving UIKIT_LONG_PRESS the taps phase's own two rows, labelled a guess naming M-G20, would keep a long-press-only adopter to its own rows (architecture:component-style-authoring INV6).
…rena (#8058) Depends on #8053 (and its predecessors); review the last commit. <!-- Template: other.md (internal infrastructure: nothing is exported, so new-feature.md does not fit). --> ## Primary intent Add the core of an internal gesture-recognizer system for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch sequence. Recognizers, the DOM host and component migrations follow in their own pull requests; nothing is exported from the package. ## User or maintainer impact Touch components each carry their own pointer handling (sheet drags, swipe actions, toast dismissal, long press, press feedback), with their own answer to "who owns this touch". Two can act on one touch, and a second finger can break a drag. With one arena every component gets UIKit's answers: exclusive recognition by default, `requireToFail`, simultaneous recognition only on request, a deterministic resolution order, and the view's touches cancelled (no click, no pressed state) once a gesture takes them. ## Problem and solution fit - Why does it happen? Ownership of a touch is decided separately in each component. - Why does that hurt? Ghost clicks, double actions, and rules that differ from component to component. - Why does it matter? Touch presentations aim at UIKit parity, and UIKit settles this in one place. Deltas: - `GestureRecognizer`: - UIKit's states (possible, began, changed, ended, cancelled, failed) and allowed transitions; - discrete and continuous recognizers; - `requireToFail`, `recognizeSimultaneouslyWith`, `shouldBegin`, `shouldReceiveTouch`; - `cancelsTouchesInView`, `delaysTouchesBegan` and `delaysTouchesEnded`, `allowedPointerTypes`. - `GestureArena`, with no DOM: - participants per touch sequence, resolution and claims; - delivery to the view; - click dispositions: allow, suppress, or defer and replay if the recognizer fails; - timers as deadlines on the event clock. ## Authority `spec:AST-094` FR15 (arbitration) and FR16 (the event clock), proposed; pending owner review. ## Change and evidence The arena's rules are tested with scripted recognizers, test doubles that begin, fail or wait on command. The rules hold independently of any real recognizer: - exclusivity and simultaneity; - require-to-fail: waiting, failing, delegate requirements, idle recognizers; - delegate hooks and innermost resolution; - independent touches; - delivery to the view, deferred and replayed clicks, the browser taking a touch; - cancellation; - a deadline that runs at its own time before a late input. Size: 2,198 lines, about 400 of them tests. This is one indivisible intent: the recognizer base and the arena call each other, so neither builds or tests alone, and the doubles-only tests are the smallest proof of the rules. ## Scope - [x] This PR has one primary intent. - [x] Independently removable deltas were removed or split. - [x] Public text and artifacts contain no internal Meta context. ## Testing - `pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion`: 159 tests passed. - `tsc --project packages/core/tsconfig.json --noEmit`: clean. - `eslint packages/core/src/gestures`: clean. - `pnpm check:changesets`, `node scripts/release/changeset-coverage.mjs pr --base origin/main`: clean. - Size: 6 files, 2,205 lines added. Stack (merge in this order): #8052 spec → #8053 constants table → #8058 gesture core → #8059 tap and long press → #8060 pan → #8061 other recognizers → #8054 math → #8055 animator → #8056 animator curves; the gesture DOM host, press tracking and browser proof follow the animator.
The arena and recognizer base take main's landed versions; this branch makes no edits to them.
There was a problem hiding this comment.
Ready for approval: both recognizers follow current spec:AST-094 (FR12, FR15–FR18), read UIKit's measured thresholds from the constants table, and have timestamped tests on both sides of every limit. Nothing else is required; optionally, giving UIKIT_LONG_PRESS its own two taps-phase rows (labelled a guess with @measure M-G20) would put that guess under the table's source check and keep the tap's other rows out of a long-press-only bundle, and the description could drop the stale "proposed" status and file count.
[Automated review]
# Conflicts: # packages/core/src/gestures/LongPressGestureRecognizer.ts # packages/core/src/gestures/TapGestureRecognizer.ts # packages/core/src/motion/MEASUREMENTS.md
…listeners only for an enabled consumer) into UIControl press tracking
The touch foundation's fold, mergeable against main cd4aca6: her chain on main (meta#8058, meta#8054, meta#8059, meta#8060) and meta#8258's rework, which keeps touch motion internal (no theme surface): the generated role table, its script and the role kinds file are gone, and a table registers only the roles it lists. The motion doc and its golden take meta#8076's side; the role kinds file stays deleted.
meta#8076's head is F1's fold on the maintainer's chain now on main (meta#8058, meta#8054, meta#8059, meta#8060): touch motion is internal (the tables register their values as internal motion roles, with no theme surface), and the `isRtlElement` resolver. Conflicts, resolved: - The theme-roles changeset and the motion guide take meta#8076's side; their sentence on ChatLayout's Reduce Motion cross-fade goes with the theme surface. - The CLI goldens for the empty icon set take meta#8076's side and regenerate. - keyboardModel.test.ts reads the internal role registry (`motionRoleIn`) now that the generated role-kind table is gone, and no longer names a theme.
Depends on #8058 (and its predecessors); review the last commit.
Primary intent
Add the first two recognizers on the gesture core: tap and long press, with UIKit's measured thresholds.
User or maintainer impact
Taps, double taps and long presses are re-implemented per component today, with their own slops and timers. A long press can fire late when the main thread is busy, and a single tap can fire alongside a double tap. These recognizers give every component UIKit's numbers. A long press begins at exactly touch-down + 0.5 s, even when the callback runs late.
Problem and solution fit
Deltas:
TapGestureRecognizer:LongPressGestureRecognizer:Authority
spec:AST-094(FR15–FR17), proposed; pending owner review. Thresholds come frommotion/uikit/gestures.tswith their sources.Change and evidence
Read from UIKit's recognizers in a reference app on the iOS 26.2 simulator, confirmed with HID touches:
Mobile Safari, which runs UIKit's tap and double-tap recognizers, agrees:
The tests use explicit timestamps only.
Scope
Testing
pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion: 184 tests passed.tsc --project packages/core/tsconfig.json --noEmit: clean.eslint packages/core/src/gestures: clean.pnpm check:changesets,node scripts/release/changeset-coverage.mjs pr --base origin/main: clean.Stack (merge in this order): #8052 spec → #8053 constants table → #8058 gesture core → #8059 tap and long press → #8060 pan → #8061 other recognizers → #8054 math → #8055 animator → #8056 animator curves; the gesture DOM host, press tracking and browser proof follow the animator.