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
maintainVisibleContentPosition is force-enabled unless explicitly disabled (shouldMaintainVisibleContentPosition() returns !disabled), regardless of horizontal.
- As the user scrolls and recycling reclaims/re-measures items, the tracked "anchor" item's
x position can shift by a few pixels between renders (layout estimate settling, etc). When it does, useRecyclerViewController's applyOffsetCorrection calls scrollAnchorRef.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 Android ReactHorizontalScrollView observes via onLayoutChange → mMaintainVisibleContentPositionHelper.updateScrollPosition() → scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollY).
scrollToPreservingMomentum calls recreateFlingAnimation(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 by flingAndSnap) with an unbounded [0, Integer.MAX_VALUE] range, while preserving the fling's current velocity.
- Critically,
flingAndSnap inflates 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). Once recreateFlingAnimation removes 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.1
react-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
recreateFlingAnimation equivalent 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
maintainVisibleContentPosition to disabled for horizontal lists (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.
Description
On Android, a horizontal
FlashListwithsnapToIntervalset 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.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).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.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 plainFlatList) an exact one-card-per-swipe result.Reproduction
Minimal repro, no other dependencies beyond
@shopify/flash-list:Steps (Android device or emulator):
Measured with scripted
adb shell input swipeat 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:
disableIntervalMomentumaddedmaintainVisibleContentPosition={{ disabled: true }}Environment
@shopify/flash-list: 2.3.1react-native: 0.81.5 (New Architecture enabled)expo: ~54recreateFlingAnimationequivalent exists on the iOS ScrollView snap path.Suggested fix upstream
Since
maintainVisibleContentPositionis 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:maintainVisibleContentPositionto disabled forhorizontallists (prepending-at-the-start is a vertical/chat use case), orscrollToPreservingMomentum(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
offsetperonScrolltick andscrollBydiff calls around the MVCP correction) if useful.