Skip to content

fix(android): honor scroll releaseBehavior to stop the fling overshoot - #2372

Open
thymikee wants to merge 4 commits into
mainfrom
claude/agent-device-issue-2371-10a860
Open

fix(android): honor scroll releaseBehavior to stop the fling overshoot#2372
thymikee wants to merge 4 commits into
mainfrom
claude/agent-device-issue-2371-10a860

Conversation

@thymikee

@thymikee thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

  • scrollAndroid ignored ScrollReleaseBehavior and always released the pan like a fling, so a "controlled" scroll overshot its requested distance by ~60% on both a plain RecyclerView and an RN ScrollView — measured and root-caused in Android scroll ignores releaseBehavior: every controlled scroll flings ~60% past its drag #2371.
  • For the default 'controlled' release, append a short (~160ms) tail after the pan's endpoint that holds the scroll axis exactly fixed at the endpoint (zero velocity there by construction) while nudging the orthogonal axis every frame, so no two consecutive samples repeat. That dodges an AOSP quirk where InputConsumer::rewriteMessage treats a truly stationary tail as a "resampled" duplicate that VelocityTracker ignores, so the list still flings even with a hold. 'inertial' (the scroll top/scroll bottom edge passes) is unchanged.
  • Direction-aware: for vertical scrolls the tail jitters x while holding y; for horizontal scrolls it jitters y while holding x, so the scroll axis itself always carries exactly zero release velocity rather than relying on the jitter axis happening to be orthogonal to it.

Known trade-off (not addressed here)

scrollAndroid's touch plan extends durationMs by the tail length before Android's shared densification (lowerAndroidTouchPlan) re-grids the whole gesture at ~16ms cadence. Because that re-grid is computed from the total (move + tail) duration rather than per-segment, it can interleave a few extra sub-pixel interpolated samples near the tail's own 16ms-spaced points (this already happens today for buildDragGesturePlan's hold segments — not something this PR introduces). It doesn't affect correctness (verified live below), just adds a handful of extra touch events. Fixing it properly means making the shared lowering segment-aware, which is a larger, separate change.

Test plan

  • packages/platform-android/src/__tests__/input-actions.test.ts — 16 tests, including new coverage for: the default controlled-release tail (zigzag, never-repeating, duration composition), the duration floor composing correctly with the tail, a horizontal scroll jittering the correct (orthogonal) axis, and the 'inertial' edge-pass path being unchanged.
  • pnpm tsc --noEmit (repo-wide) and full affected-package vitest runs (680 tests) — all green.
  • Live-measured on a Pixel 9 Pro XL / API 37 emulator: scroll down --pixels 900 --duration-ms 300 on the Settings app (matching the maintainer's original repro) now lands within touch-slop of the requested drag (828–837px measured, vs. the old fling's ~1350–1440px overshoot) across two independent runs; scroll bottom ('inertial') still completes cleanly and lands on the list's actual last row.

scrollAndroid ignored ScrollReleaseBehavior and always released the pan
like a fling, so a controlled `scroll` overshot its requested distance
by ~60% on both a plain RecyclerView and an RN ScrollView. Android's
VelocityTracker treats a truly stationary release as a resampled
duplicate and ignores it, so a hold alone can't stop the fling.

For the default 'controlled' release, append a short tail after the
pan's endpoint that holds the scroll axis exactly fixed (zero velocity
there) while nudging the orthogonal axis every frame so no two
consecutive samples repeat. 'inertial' (the scroll top/bottom edge
passes) is unchanged. Live-measured on a Pixel 9 Pro XL emulator:
displacement lands within touch-slop of the requested drag instead of
overshooting it.

Fixes #2371
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.51 MB 4.51 MB +1.0 kB
Package (unpacked) 4.51 MB 4.51 MB +1.0 kB
Package (download) 1.34 MB 1.34 MB +426 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.3 ms 30.5 ms +1.3 ms
CLI --help 85.1 ms 85.9 ms +0.8 ms

@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

At fce45a3, the release tail is added after gesture duration validation. A valid 10000ms scroll therefore produces a 10160ms plan, beyond the shared total-duration limit. Please account for the tail at the owning duration boundary and add a maximum-duration regression without silently shortening the requested move.

The reported Settings result is encouraging, but #2371 also requires RN ScrollView and CI Pixel 7 displacement evidence. Please add those measurements before marking this ready.

The provider integration failure is related: it still expects a 350ms plan instead of the new 510ms controlled gesture. Update it to assert controlled versus inertial release. Coverage also reports a stale eager-closure approval, which looks unrelated to this change.

… ceiling

The release tail was appended after buildGesturePlan's own duration
validation, so a maximum-duration (10000ms) scroll silently produced a
10160ms plan beyond GESTURE_DURATION_MAX_MS. Cap the tail so the
dispatched plan never exceeds that shared ceiling, shrinking (and, at
the exact maximum, dropping) the tail near the boundary instead of
truncating the requested move. Add regression coverage for the exact
maximum and near-maximum cases.

Also fix the stale android-lifecycle provider-integration expectation:
a plain scroll resolves to 'controlled' release by default, so its
dispatched plan is now 510ms (350ms move + 160ms tail), not 350ms.
@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

At 4a67a37, the duration cap is respected, but controlled scrolls near the maximum lose the required braking tail; at 10000ms there is no tail at all. Please preserve controlled-release behavior across the accepted duration range, or make an explicit validated duration constraint rather than silently dropping the braking step. Add a boundary regression and the RN ScrollView/Pixel 7 displacement evidence requested earlier.

Coverage also fails because the new explanatory comment grows the over-budget android-lifecycle.test.ts. Keep the corrected expectation without growing that legacy test. The IME recovery persistence failure and stale closure approval look unrelated.

… no room, instead of shrinking it

The previous fix capped the tail near GESTURE_DURATION_MAX_MS, which
silently dropped the fling-suppressing braking step for scrolls close
to the ceiling -- defeating the point of the fix exactly where a long
scroll needs it. A controlled scroll's own accepted duration range now
stops CONTROLLED_RELEASE_TAIL_MS short of that shared ceiling
(9840ms), rejecting anything past it with a clear INVALID_ARGS error
instead of truncating the move or the tail. Every accepted controlled
scroll now runs the full, unshortened tail; 'inertial' releases are
unaffected and keep the full 10000ms range.

Also drop the multi-line explanatory comment on the corrected
android-lifecycle.test.ts expectation (a same-line comment instead)
so the fix doesn't grow that file past its line-count tripwire.
@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed both rounds of feedback.

Duration boundary (fixed properly this time): the first fix I pushed shrank the tail near the ceiling, which — as you caught — silently dropped the braking step exactly where a long scroll needs it. Reworked at 9a3ea26: a controlled scroll's own accepted durationMs range now stops 160ms short of GESTURE_DURATION_MAX_MS (max 9840ms), and anything past that is rejected with a clear INVALID_ARGS error naming the constraint, rather than truncating the move or the tail. Every accepted controlled scroll now runs the full, unshortened tail. 'inertial' keeps the full 10000ms range (no tail to make room for). Added regression coverage for: the largest duration that still gets the full tail (lands exactly at 10000ms), the rejection just past that boundary, and 'inertial' still accepting the full range.

Provider integration + file size: fixed the stale 350510 expectation, this time as a same-line comment instead of a multi-line block — android-lifecycle.test.ts is back to 1260 lines (same as the merge-base), so it no longer trips the size ratchet.

RN ScrollView + Pixel 7 evidence, gathered live on a fresh pixel_7/API 36/google_apis_playstore AVD (matching android.yml's emulator-runner config exactly, not the reserved Pixel_7_CI):

Surface Drag Displacement
RN ScrollView (examples/test-app, gesture-lab screen) 900px 850px, then 828px (2 runs)
Settings RecyclerView, same Pixel 7/API 36 device 900px 828px

All three land at 92–94% of the requested drag (touch-slop territory), consistent with controlled release — not the ~1.5–1.6x overshoot the fling bug produced. Also re-confirmed scroll top/scroll bottom ('inertial') still completes cleanly on this same device/API level.

The IME recovery persistence failure and the planned-operations.ts stale-approval row are still there on the latest push and, as you noted, look unrelated to this change (no IME or command-registry code touched) — I re-ran the Integration Tests job once and it passed clean on retry, consistent with flakiness rather than a real regression.

@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

The duration fix at 9a3ea26 is sound: every accepted controlled scroll retains the full braking tail, and inertial scrolling keeps its existing range. All checks pass, and the reported Pixel 7 measurements cover both Settings and RN ScrollView.

One small acceptance item remains from #2371: update the scroll documentation to describe reduced momentum, not exact displacement, and explain the new 9840ms Android controlled-scroll limit. The current docs only contain the generic app-physics caveat.

…roll limit

The scroll docs only carried the generic app-physics caveat, not the
release-behavior contract itself. State plainly that scroll releases
with reduced momentum rather than an exact stop, and document
Android's controlled-release durationMs ceiling (9840ms, reserved
headroom for the braking tail added in #2371) and that a longer
request is rejected rather than silently truncated.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2372/

Built to branch gh-pages at 2026-09-08 05:57 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@thymikee

thymikee commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Updated the scroll docs at 0214cc4 — website/docs/docs/commands.md (scroll section) now states plainly that the gesture releases with reduced momentum rather than an exact stop, and explains Android's controlled-release durationMs ceiling (9840ms, headroom for the braking tail) with what happens past it (rejected, not silently truncated). Also tightened the scroll CLI --help summary in src/commands/interaction/metadata.ts with the same "reduced momentum" framing. All CI checks green on this commit.

@thymikee

thymikee commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

The help and duration-limit docs at 0214cc4 resolve the remaining review finding; no further code findings. Android smoke failed because the Back diff did not add the expected Open automation lab line: the connection to this scroll change is unclear and needs a reproduction or a justified rerun before merging. The iOS xcrun timeout appears unrelated. Ready for human review, not yet CI-green.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant