#2427·flash-list

Android: horizontal snapToInterval carousel over-snaps to index 0 on backward swipe (maintainVisibleContentPosition re-flings unbounded mid-snap)

Author: ozam-trCreated Aug 5, 2026Updated Aug 17, 2026

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

  1. maintainVisibleContentPosition is force-enabled unless explicitly disabled (shouldMaintainVisibleContentPosition() returns !disabled), regardless of horizontal.
  2. 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).
  3. That anchor nudge is implemented as a position change on an invisible view inside the ScrollView, which RN's Android ReactHorizontalScrollView observes via onLayoutChangemMaintainVisibleContentPositionHelper.updateScrollPosition()scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollY).
  4. 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.
  5. 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:

javascript
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):

  1. Swipe forward card-by-card from card 1 to card 8. Each swipe lands on the very next card — correct.
  2. 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:

diff
 <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.