fix(scroll): withhold native MVCP from snapping lists on Android - #2456
Open
dennytosp wants to merge 1 commit into
Open
fix(scroll): withhold native MVCP from snapping lists on Android#2456dennytosp wants to merge 1 commit into
dennytosp wants to merge 1 commit into
Conversation
dennytosp
force-pushed
the
fix/snap-mvcp-drift-correction
branch
from
August 20, 2026 05:33
61d0636 to
b45e50c
Compare
dennytosp
marked this pull request as draft
August 20, 2026 05:56
A backward swipe on a horizontal snapping carousel on Android flies past the intended card and lands on index 0, once the list has scrolled far enough for recycling to start. FlashList hands the native maintainVisibleContentPosition to the ScrollView, which puts Android's MaintainVisibleScrollPositionHelper in charge. That helper re-anchors on every layout change of the first visible child, and a recycling list repositions its cells constantly, so it fires throughout a fling. Each time it calls scrollToPreservingMomentum -> recreateFlingAnimation(x, Integer.MAX_VALUE), which cancels the in-flight snap animator and re-flings the OverScroller with no upper bound. The velocity it carries over is not the user's. flingAndSnap boosts it by (distance to snap point) * 10 so a slow swipe still reaches its target, which is safe only because the same call clamps it with minX == maxX == targetOffset. Unclamped, that boosted velocity runs to the end of its range - offset 0 for a backward swipe. The helper watches the cells, not FlashList's ScrollAnchor, so there is nothing to suppress on the JS side. Withhold the prop instead, for the lists ScrollView.js marks as snapping on Android - pagingEnabled, snapToInterval or snapToOffsets - and let their offset corrections drive the scroll position through scrollTo, the same path web already uses. Prepending to a snapping list keeps working; only the mechanism changes. Verified on a Pixel 9 Pro emulator with the added fixture screen: card 17 -> one backward swipe -> card 16 with the fix, card 1 without it. Fixes Shopify#2427
dennytosp
force-pushed
the
fix/snap-mvcp-drift-correction
branch
from
August 20, 2026 06:11
b45e50c to
072a492
Compare
dennytosp
marked this pull request as ready for review
August 20, 2026 06:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2427
Problem
On Android, a backward swipe on a horizontal snapping carousel flies past the intended card and lands on index 0, once the list has scrolled far enough that recycling has started. Forward swiping looks fine, iOS never reproduces it, and
maintainVisibleContentPosition={{ disabled: true }}makes it go away.Every link below is in the RN sources:
maintainVisibleContentPositionto the ScrollView (RecyclerView.tsx), which puts Android'sMaintainVisibleScrollPositionHelperin charge.scrollToPreservingMomentum(scrollX + deltaX, …).scrollToPreservingMomentumcallsrecreateFlingAnimation(x, Integer.MAX_VALUE). Its own comment says what that does: "If we have any pending custom flings (e.g. from animatedscrollTo, or flinging to a snap point), cancel them." It cancels the snap animator and re-flings theOverScrollerwithmaxX = Integer.MAX_VALUE.flingAndSnapboosts it first —velocityX -= (int) ((targetOffset - smallerOffset) * 10.0), commented "The default animator requires boost on initial velocity as when snapping velocity can feel sluggish for slow swipes" — and that is safe only because the same call clamps it withminX == maxX == targetOffset("setting both minX and maxX to the same value will guarantee that we scroll to it").Step 3 keeps the 10x velocity and throws away the clamp that made it safe. A backward swipe runs unbounded down to
minX = 0.Fix
The helper watches the cells, not FlashList's
ScrollAnchor, so there is nothing to suppress on the JS side — no guard aroundapplyOffsetCorrectioncan help (I verified this the hard way; see below). Withholding the native prop is what stops it.That predicate is copied from
ScrollView.js, which is what turns the native snapping path on for Android:flingAndSnapis only reachable when that flag is true, so matching it covers exactly the affected lists. CheckingsnapToIntervalalone would miss plainpagingEnabledcarousels.Gated behind a new
PlatformConfig.nativeMvcpBreaksSnapFling, true only on Android — iOS does not have this failure mode and keeps the prop.Prepending keeps working, only the mechanism changes: when the native prop is withheld,
applyOffsetCorrectiondrives the scroll position throughscrollToinstead of nudging the anchor — the same path web already uses. Verified on device (below).Device verification
Pixel 9 Pro emulator, RN 0.84, Fabric, using the
Snap Carousel Reprofixture screen added here (snapToInterval, 20 cards, "settled on" readout):mainmain(repeat)Prepend regression,
Horizontal MVCPscreen with asnapToIntervaladded: tapped Prepend 5 twice (95 → 100 → 105 items), visible cards stayed on 16/17/18 both times.Tests
snapping.test.tscovers the predicate (includingsnapToInterval: 0, emptysnapToOffsets, andnullvalues, which have to behave the wayScrollView.jstreats them), andRecyclerView.test.tsxasserts on the prop the ScrollView actually receives for snapping / non-snapping / unaffected-platform cases.Both layers were mutation-tested rather than assumed:
RecyclerView.tsx, leave the helper correctpagingEnabledfrom the predicatepagingEnabledwiring test failNote on the earlier revision of this PR
The first version of this PR guarded
applyOffsetCorrectionso it would not nudgeScrollAnchoron a snapping list. It had passing tests and it did not work: on device the bug reproduced unchanged. InstrumentingScrollAnchor.scrollByshowed it is called zero times during this scenario — the anchor is never involved. The analysis in the issue is right aboutrecreateFlingAnimation, the 10x boost and the lost clamp, but the trigger is the cells moving, not the anchor. That is why this revision moves the fix to the prop and why the tests now assert on the prop rather than on the correction.