Shopify / Shopify/flash-list

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

Open
#2,427 0 comments 1 reaction 0 assignees View on GitHub

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

  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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.