Skip to content

Keep a touch that stops a fling from inheriting the fling's axis - #14

Merged
huacnlee merged 2 commits into
mainfrom
fix/fling-guard
Sep 17, 2026
Merged

huacnlee merged 2 commits into
mainfrom
fix/fling-guard

Conversation

@huacnlee

@huacnlee huacnlee commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Problem

GPUI's touch recognizer (gpui-pre 0.3.5) treats a contact that lands during scroll momentum as a pan from its first pixel — and locks it to the axis of the fling it stopped. A quick sideways swipe (across a wide table, or just across text: momentum starts on any fast release) followed by a swipe up produced only horizontal deltas, so the page looked frozen until the finger lifted. Reproduced on an iPhone 17 Pro in the AI chat.

The same catch also swallows control drags: a contact that catches a fling is never offered as a touch drag (the recognizer enters Panning straight from Started; only a pending contact is offered). A scrollbar thumb is visible only while its content scrolls and coasts, so in practice it could not be grabbed until an earlier touch had stopped the coasting — which read as needing a long press on the thumb.

The fix belongs in the recognizer (zed-industries/zed#64239 for the axis; offering a drag to a catching contact is a second upstream gap); this is what the platform can do until that reaches a gpui-pre release.

Change

src/fling_guard.rs, relayed through on both platforms (handle_touch on iOS, the on_touch bridge on Android):

  • The recognizer takes its momentum on every Started, and a contact that is cancelled while still pending emits nothing. So before a real contact that may land on momentum, the guard relays a synthetic contact that begins and is cancelled at the same point: it stops the momentum (a zero-delta Started/Cancelled scroll pair the consumers treat as a no-op), and the real contact then begins on an idle recognizer, which offers it as a drag and otherwise lets it pick its own axis at the slop.
  • Momentum is not visible from the platform, so the guard estimates it: it runs for any contact that begins within 4 s (longer than any fling either physics runs) of a release that had panned past the slop. When no momentum is left, the synthetic pair is a no-op.
  • Synthetic contact IDs come from the same sequence as the real ones.

What the guard cannot restore is the catching contact's no-tap rule: a contact that stops a fling and lifts without moving is an ordinary tap here.

Test

5 host-side unit tests on FlingGuard (armed after a pan release, once per release, not after the window, not after a tap, not after a cancelled pan), plus one end-to-end test that drives GPUI's real recognizer (gpui-pre test-support, dev-dependency only) through the guard against an element that claims touch drags in a strip along the right edge, the way a scrollbar thumb does: after a swipe with velocity, an unguarded contact on the thumb is swallowed by the fling, a guarded one is claimed as a drag. cargo check --target aarch64-apple-ios and cargo ndk -t arm64-v8a check both build.

🤖 Generated with Claude Code

huacnlee and others added 2 commits September 17, 2026 18:40
GPUI's touch recognizer (gpui-pre 0.3.5) treats a contact that lands
during scroll momentum as a pan from its first pixel — and locks it to
the axis of the fling it stopped. A quick sideways swipe (across a wide
table, or across text: momentum starts on any fast release) followed by
a swipe up produced only horizontal deltas, so the page looked frozen
until the finger lifted. The fix belongs in the recognizer (zed#64239);
this is what the platform can do until that reaches a release.

The recognizer takes its momentum on every `Started`, and a contact that
is cancelled while still pending emits nothing. So before a contact that
may land on momentum, `FlingGuard` relays a synthetic contact that begins
and is cancelled at the same point: it stops the momentum without moving
anything, and the real contact then begins on an idle recognizer and
picks its own axis. Momentum is not visible from the platform, so the
guard runs for any contact that begins within 4 s of a release that had
panned; when no momentum is left the synthetic pair is a no-op.

Both platforms relay through it: `handle_touch` on iOS and the
`on_touch` bridge on Android.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact that catches a fling is never offered as a touch drag either:
the recognizer enters `Panning` straight from `Started`, and only a
pending contact is offered. A scrollbar thumb is visible only while its
content scrolls and coasts, so in practice it could not be grabbed until
an earlier touch had stopped the coasting — which read as needing a long
press. The guard already fixes this by starting the real contact on an
idle recognizer; say so in the module docs, and pin it with a test that
drives GPUI's recognizer through the guard against a drag-claiming
element: unguarded, the contact on the thumb is swallowed by the fling;
guarded, it is claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee merged commit f706039 into main Sep 17, 2026
5 checks passed
@huacnlee
huacnlee deleted the fix/fling-guard branch September 17, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant