Android: horizontal snapToInterval carousel over-snaps to index 0 on backward swipe (maintainVisibleContentPosition re-flings unbounded mid-snap)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 7.2k
- Forks
- 393
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 1
Description
Description
On Android, a horizontal FlashList with snapToInterval set will "over-snap" backward past several items straight to the first item (index 0), once the list has scrolled far enough that item recycling has started reclaiming off-screen items. Forward swiping is unaffected. iOS never reproduces this.
This is caused by maintainVisibleContentPosition (MVCP) being enabled by default — including on horizontal, non-prepending lists that never actually need it.
Root cause
maintainVisibleContentPositionis force-enabled unless explicitly disabled (shouldMaintainVisibleContentPosition()returns!disabled), regardless ofhorizontal.- As the user scrolls and recycling reclaims/re-measures items, the tracked "anchor" item's
xposition can shift by a few pixels between renders (layout estimate settling, etc). When it does,useRecyclerViewController'sapplyOffsetCorrectioncallsscrollAnchorRef.current?.scrollBy(diff)on Android (PlatformConfig.supportsOffsetCorrection === true). - That anchor nudge is implemented as a position change on an invisible view inside the
ScrollView, which RN's AndroidReactHorizontalScrollViewobserves viaonLayoutChange→mMaintainVisibleContentPositionHelper.updateScrollPosition()→scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollY). scrollToPreservingMomentumcallsrecreateFlingAnimation(x, Integer.MAX_VALUE). If a snap fling from the user's swipe is still in flight, this replaces its bounded target (OverScroller.fling(..., minX=targetOffset, maxX=targetOffset, ...), set up byflingAndSnap) with an unbounded[0, Integer.MAX_VALUE]range, while preserving the fling's current velocity.- Critically,
flingAndSnapinflates the velocity ×10 above the natural swipe velocity specifically so a slow swipe still reaches its snap target (velocityX -= (int) ((targetOffset - smallerOffset) * 10.0)for backward flings). OncerecreateFlingAnimationremoves the bound, that inflated velocity is no longer aimed at a nearby snap point — it's aimed at "as far as it can go," which for a backward swipe is scroll offset 0.
Net effect: any backward (or forward, symmetrically) swipe that happens to trigger an MVCP anchor correction mid-fling gets redirected to the start (or end) of the list instead of landing on the adjacent snap point.
This is not specific to snapping — any FlashList measurement settling combined with an in-flight fling on Android could in principle retarget the fling — but it's most visible and consistently reproducible with snapToInterval, since users expect (and get, on iOS, and with plain FlatList) an exact one-card-per-swipe result.
Reproduction
Minimal repro, no other dependencies beyond @shopify/flash-list:
import { FlashList } from '@shopify/flash-list';
import { useCallback, useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
const ITEMS = ['1', '2', '3', '4', '5', '6', '7', '8'];
const CARD_GAP = 12;
export default function App() {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = screenWidth - 48;
const snapInterval = cardWidth + CARD_GAP;
const [active, setActive] = useState(0);
const onSettle = useCallback((e) => {
setActive(Math.round(e.nativeEvent.contentOffset.x / snapInterval));
}, [snapInterval]);
return (
<View style={{ flex: 1, paddingTop: 80 }}>
<Text style={{ fontSize: 24 }}>active: {active + 1}</Text>
<FlashList
data={ITEMS}
keyExtractor={(item) => item}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={snapInterval}
decelerationRate="fast"
onMomentumScrollEnd={onSettle}
onScrollEndDrag={onSettle}
renderItem={({ item }) => (
<View style={{ width: cardWidth, height: 300, marginRight: CARD_GAP, backgroundColor: 'tomato', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 64, color: 'white' }}>{item}</Text>
</View>
)}
/>
</View>
);
}
Steps (Android device or emulator):
- Swipe forward card-by-card from card 1 to card 8. Each swipe lands on the very next card — correct.
- Swipe backward from card 8. Somewhere around card 4–5, a single backward swipe jumps straight to card 1 instead of landing on card 3/4.
Measured with scripted adb shell input swipe at fixed velocity/duration to rule out user input variance — same jump every time, deterministic.
Fix (workaround confirmed)
Explicitly disabling MVCP on the list eliminates the bug entirely, with no other prop changes:
<FlashList
horizontal
snapToInterval={snapInterval}
+ maintainVisibleContentPosition={{ disabled: true }}
...
/>
A/B on-device, scripted identical-velocity swipes, backward pass from card 8:
| Config | Result |
|---|---|
| Default (MVCP enabled) | 8→7→6→5→4→1→1→1 |
disableIntervalMomentum added |
8→7→6→5→4→1→1→1 (no change — doesn't touch the unbounded re-fling) |
maintainVisibleContentPosition={{ disabled: true }} |
8→7→6→5→4→3→2→1 (correct) |
Environment
@shopify/flash-list: 2.3.1react-native: 0.81.5 (New Architecture enabled)expo: ~54- Platform: Android only (emulator: Medium_Phone_API_35; also reproduced on a real Android device). iOS never reproduces — no
recreateFlingAnimationequivalent exists on the iOS ScrollView snap path.
Suggested fix upstream
Since maintainVisibleContentPosition is designed for lists that prepend content, and this default corrupts in-flight Android flings on any list (snapping or not) when an anchor correction happens to land mid-fling, consider either:
- Defaulting
maintainVisibleContentPositionto disabled forhorizontallists (prepending-at-the-start is a vertical/chat use case), or - Not calling
scrollToPreservingMomentum(which unbounds the fling target) while a fling is actively snapping — e.g. skip/defer the anchor correction if a snap fling is in progress, or preserve the fling's original min/max bounds when recreating it.
Happy to provide the full instrumented trace (console logs of offset per onScroll tick and scrollBy diff calls around the MVCP correction) if useful.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by locating shouldMaintainVisibleContentPosition(), useRecyclerViewController, and applyOffsetCorrection, then trace scrollAnchorRef.current?.scrollBy(diff) into ReactHorizontalScrollView's fling and MVCP handling. Reproduce the Android snapToInterval case from the issue and verify that backward swipes land on adjacent cards without being redirected to index 0, while preserving the intended MVCP behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, react-native, typescript
- Domain
- mobile, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100