Repository navigation
feat(core): gesture recognizer core — UIKit's state machine and the arena - #8058
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.
|
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.
Approval waits on an owner decision: the gesture core implements the draft spec:AST-094 FR15, FR16 and FR19–FR21, and no current spec governs touch arbitration yet, so an engineering owner needs to approve that direction by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) or name the spec that should govern it. Independently of that decision, please address:
- A timer that fires late moves the event clock to wall time (
GestureArena.ts:835), so input queued behind a busy main thread is restamped and a long press can begin against a move that should have failed it. cancel()on an idle recognizer leaves itfailedfor good, because only recognizers in the current interaction are ever reset (GestureArena.ts:397-405).- A claim made while a recognizer waits for another to fail is never released, so
isClaimedcan stay true with no recognizer owning the touch (GestureArena.ts:434-437). - The diff carries 4,244 authored lines, including the stacked #8052 and #8053, and lacks writing rubric results for the AST-094 record it creates; landing those first and then splitting or justifying the remaining 2,205-line core would settle both.
[Automated review]
| this.realTimer = this.clock.setTimeout(() => { | ||
| this.realTimer = null; | ||
| this.realTimerDeadline = Number.POSITIVE_INFINITY; | ||
| this.advanceTime(Math.max(earliest, this.clock.now())); |
There was a problem hiding this comment.
A late timer runs at its deadline, but advancing to Math.max(earliest, this.clock.now()) then moves the event clock to wall time, so every input still queued behind the block is clamped to that time. With a scripted long press (deadline 500 ms), a block until 800 ms and the timer task first, the long press began at 500 although the finger moved 40 px at 300 ms, and the queued moves at 300 and 600 ms arrived stamped 800. Advancing only to earliest keeps queued timestamps; the timer-first order also needs a test.
| stop(recognizer: GestureRecognizer): void { | ||
| this.run(this.now(), () => { | ||
| if (isActive(recognizer.state)) { | ||
| this.apply(recognizer, 'cancelled'); | ||
| } else if (recognizer.state === 'possible') { | ||
| this.fail(recognizer); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
This fails a possible recognizer even when it is idle. An idle recognizer is not in this.active, so settle() never resets it, and wantsTouch refuses a failed one: after cancel() it never takes a touch again. The setEnabled guard (state !== 'possible' || isInvolved()) would fit here too.
| if (isWaiting) { | ||
| recognizer.pending = next; | ||
| this.claim(recognizer); | ||
| return; |
There was a problem hiding this comment.
This claims the touches before anything recognizes, and nothing releases the claim if the waiting recognition then fails (its shouldBegin turns false, or it is disabled while waiting): isClaimed(id) stays true with no recognizer active, so a host that follows isClaimed's contract keeps cancelling that touch's moves until the finger lifts.
|
Posted by Cindy's agent on her behalf. Cindy waives the split request for this PR. View delivery, the claims and browser-takeover parts, and the |
GestureArena.ts and GestureRecognizer.ts named GestureHost.ts, GestureArena.test.ts and README.md, none of which exists here; the arena's tests are __tests__/arena.test.ts.
There was a problem hiding this comment.
Recommending approval: the gesture core follows current spec:AST-094 (FR15 and FR16 for arbitration and the event clock, FR19–FR21 for claims, the browser taking a touch and click dispositions). The fix for a deadline that comes due inside a re-entrant entry holds, with a regression test that fails without it. Every arena issue raised in earlier reviews is fixed at this head, and the engineering owner's waiver of the split settles the size concern.
[Automated review]
The arena and recognizer base take main's landed versions; this branch makes no edits to them.
# Conflicts: # packages/core/src/gestures/GestureArena.ts # packages/core/src/gestures/GestureRecognizer.ts # packages/core/src/gestures/types.ts
…s a dependency
|
From a touch ContextMenu built on this core: the claim rule in 4bec6a0 ("claims come back") gives a dragging finger back to the browser when its gesture cancels itself on that finger's move.
A failing-first branch, one commit on main: Question: should a gesture that cancels itself on a finger's move keep that finger claimed until it lifts (FR14), with the release kept for a resting finger? |
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 #8053 (and its predecessors); review the last commit.
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
Deltas:
GestureRecognizer:requireToFail,recognizeSimultaneouslyWith,shouldBegin,shouldReceiveTouch;cancelsTouchesInView,delaysTouchesBegananddelaysTouchesEnded,allowedPointerTypes.GestureArena, with no DOM:Authority
spec:AST-094FR15 (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:
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
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.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.