Skip to content

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

Description

@ozam-tr

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:

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:

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions