Android: horizontal snapToInterval carousel over-snaps to index 0 on backward swipe (maintainVisibleContentPosition re-flings unbounded mid-snap)
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.
Source: Shopify/flash-list