You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 (scrollDragProfile → controlledScroll, 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 beforeaddMovement(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):
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).
Problem
scroll <direction> [amount]is a controlled scroll by contract (ScrollReleaseBehaviorinpackages/contracts/src/scroll-command.ts:controlledby default,inertialonly for thescroll top/scroll bottomedge passes). iOS honours it (scrollDragProfile→controlledScroll, 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, soscroll down 0.3does not scroll by 0.3 of the viewport, and the reportedpixels(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).ScrollView(fixture app)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::rewriteMessagekeeps identical incoming coordinates as the last resampled sample, andVelocityTrackerskips resampled samples (both in AOSPframeworks/native/libs/input). A tail whose samples repeat the previous coordinates never reaches the tracker, so the pan's velocity stays in place.ScrollView.onTouchEventcomputes the release velocity beforeaddMovement(UP), so the 40 ms "pointer stopped" reset inVelocityTracker::addMovementnever applies.RecyclerViewadds UP first, which is why Settings does stop with a stationary tail and an RNScrollViewdoes not.Proposed fix
In
scrollAndroid, honouroptions.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):
Keep the user's
--duration-msas the move time and report it unchanged; the tail is release mechanics.Acceptance
controlledends with a ≥100 ms tail whose y never changes and whose consecutive samples differ;inertiallifts at the move end.scroll down 0.3on 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.website/docs/docs/commands.md, scroll section) promise reduced momentum, not exact content displacement: pan-recognition thresholds, bounds and app physics still apply.live-lifecycle-scenario.ts/live-observability-scenario.ts, which usescroll bottomandscroll down 0.3/0.5).Context
Found while chasing the post-#2345 Android smoke failure (
press id="automation-press"afterscroll 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: AOSPInputConsumer.cpp,VelocityTracker.cpp,ScrollView.java; androidxRecyclerView.java; react-native v0.85.3ReactScrollView.java(its non-paging fling is the platformfling()path).