Skip to content

Android scroll ignores releaseBehavior: every controlled scroll flings ~60% past its drag #2371

Description

@thymikee

Problem

scroll <direction> [amount] is a controlled scroll by contract (ScrollReleaseBehavior in packages/contracts/src/scroll-command.ts: controlled by default, inertial only for the scroll top / scroll bottom edge passes). iOS honours it (scrollDragProfilecontrolledScroll, a cubic ease-out that reaches near-zero velocity before lift). Android ignores it: scrollAndroid (packages/platform-android/src/input-actions.ts) builds a two-sample pan, the lowering expands it to 16 ms MOVEs, and the helper injects UP at the last MOVE's timestamp. The list receives a fling and keeps moving after the finger lifts, so scroll down 0.3 does not scroll by 0.3 of the viewport, and the reported pixels (planned finger travel) says nothing about where the content ended up.

Measurements

Pixel 9 Pro XL emulator, Android 17 (API 37), 1344x2992 @ 480 dpi. Displacement = y of a fixed node before vs. after scroll down 0.3 (898 px drag over 300 ms). Snapshots taken ≥1 s after the gesture (an immediate snapshot can still show the pre-gesture tree).

gesture Settings (RecyclerView) RN 0.85 ScrollView (fixture app)
898 px / 300 ms, UP at the last MOVE (today) 1430 px 1423–1446 px
+ 150 ms stationary tail (MOVEs at the end point, then UP) 1423 px
+ 400 ms stationary tail 822 px (no fling) 1427 px
+ 400 ms tail drifting 40 px back toward the origin 786–807 px (no fling)
+ 160 ms tail alternating x by ±1 px, y fixed 843–847 px (no fling)
898 px / 1000 ms 944 px
898 px / 2000 ms 897 px (1:1)

So today's release is a fling on every list, the overshoot scales with pan velocity, and a purely stationary tail is not enough for an Android ScrollView.

Why a stationary tail does not work

  • InputConsumer::rewriteMessage keeps identical incoming coordinates as the last resampled sample, and VelocityTracker skips resampled samples (both in AOSP frameworks/native/libs/input). A tail whose samples repeat the previous coordinates never reaches the tracker, so the pan's velocity stays in place.
  • ScrollView.onTouchEvent computes the release velocity before addMovement(UP), so the 40 ms "pointer stopped" reset in VelocityTracker::addMovement never applies. RecyclerView adds UP first, which is why Settings does stop with a stationary tail and an RN ScrollView does not.
  • Any tail whose samples keep changing coordinates is honoured by both: the last two rows above.

Proposed fix

In scrollAndroid, honour options.releaseBehavior:

  • inertial (edge passes): keep today's plan.
  • controlled (default): append a short tail before lift whose samples keep moving but carry zero velocity along the scroll axis. A 160 ms tail sampled every 16 ms with x alternating by ±1 px and y fixed at the end point removed the fling on both lists in the table. A cubic ease-out that keeps moving to the end (what the iOS runner does) is the other candidate; measure it on both lists before choosing.

Sketch of the tail (single-pointer plans only; the lowering keeps canonical sample offsets, so 16 ms spacing in the plan survives):

const tail = [];
for (let offset = 16; offset <= holdMs; offset += 16) {
  tail.push({ offsetMs: plan.durationMs + offset, point: { x: end.x + ((offset / 16) % 2), y: end.y } });
}

Keep the user's --duration-ms as the move time and report it unchanged; the tail is release mechanics.

Acceptance

  • Unit tests on the emitted touch plan: controlled ends with a ≥100 ms tail whose y never changes and whose consecutive samples differ; inertial lifts at the move end.
  • Live: scroll down 0.3 on the Settings list and on the fixture's Automation Lab list moves content by the drag distance minus touch slop (≈ 820–850 px above), on the CI Pixel 7 image as well as locally.
  • Docs (website/docs/docs/commands.md, scroll section) promise reduced momentum, not exact content displacement: pan-recognition thresholds, bounds and app physics still apply.
  • The Android e2e scenarios that reveal targets with fixed amounts still pass (test(android): reveal smoke canaries by visibility #2369 already moved the smoke canaries to visibility-checked bounded scrolls; check live-lifecycle-scenario.ts / live-observability-scenario.ts, which use scroll bottom and scroll down 0.3/0.5).

Context

Found while chasing the post-#2345 Android smoke failure (press id="automation-press" after scroll down 0.7). That failure was the drag alone reaching the list's maximum offset after the fixture grew, fixed in #2369; the fling is a separate, pre-existing gap. Sources: AOSP InputConsumer.cpp, VelocityTracker.cpp, ScrollView.java; androidx RecyclerView.java; react-native v0.85.3 ReactScrollView.java (its non-paging fling is the platform fling() path).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions