Files
react-native/scripts
Tomasz Żelawski 06eb1fefab fix: prevent programmatic scrolls from cancelling active touches on iOS (#57546)
Summary:
On iOS, a programmatic non-animated scroll — `scrollTo` / `scrollToOffset({ animated: false })`, or any library driving the offset frame-by-frame — cancels every active touch in enclosing scroll views. Two mechanisms combine into this:

1. `scrollToOffset:animated:` calls `_forceDispatchNextScrollEvent` and, for non-animated scrolls, `_handleFinishedScrolling` — so every call emits `onScroll` (twice) plus `onMomentumScrollEnd`, bypassing `scrollEventThrottle` entirely. A per-frame driver produces a continuous stream of unthrottled `topScroll` events (~60/s measured with `scrollEventThrottle={2000}`).
2. In the responder system, any `topScroll` event without `responderIgnoreScroll: true` starts a responder negotiation, and `ScrollView`'s `onScrollShouldSetResponder` answers `true` whenever a finger is down inside it. Each event therefore steals the responder from a pressed `Pressable`/`Touchable` and the press is cancelled — `onPressIn` fires, `onPress` never does.

On Android scroll events carry `responderIgnoreScroll: true`.

I added `responderIgnoreScroll` to the C++ `ScrollEvent` payload and set it to `!_isUserTriggeredScrolling` in `_scrollViewMetrics`. Programmatic scrolls no longer transfer the responder, while user-initiated scrolls (drag, deceleration, scroll-to-top) keep today's behavior.

## Changelog:

[IOS] [FIXED] - Programmatic (non-user-initiated) scrolls no longer cancel active touches in enclosing scroll views

Pull Request resolved: https://github.com/react/react-native/pull/57546

Test Plan:
Reproducible code — an endless marquee `FlatList` nested in a `ScrollView`, driven by `requestAnimationFrame` + `scrollToOffset({ animated: false })`, with a sibling `TouchableOpacity` and a counter proving the touches reach JS:

<details><summary>App.tsx</summary>

```tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import {
  FlatList,
  ScrollView,
  Text,
  TouchableOpacity,
  View,
} from 'react-native';

const dpPerSecond = 20;
const size = 80;
const gap = 8;
const data = Array.from({ length: 6 }).map((_, i) => ({
  id: `id-${i}`,
  n: i + 1,
}));
const renderItem = ({ item }: { item: (typeof data)[0] }) => (
  <View
    style={{
      width: size,
      height: size,
      backgroundColor: 'red',
      justifyContent: 'center',
      alignItems: 'center',
    }}>
    <Text>#{item.n}</Text>
  </View>
);

const Carousel = ({ paused }: { paused: boolean }) => {
  const ref = useRef<FlatList>(null);
  const [width, setWidth] = useState(0);
  const offset = useRef(0);

  useEffect(() => {
    if (paused) return;
    const x = (size + gap) * data.length;
    let last = Date.now();
    let raf: number;
    const loop = () => {
      const now = Date.now();
      offset.current =
        (offset.current + (dpPerSecond * (now - last)) / 1000) % x;
      last = now;
      ref.current?.scrollToOffset({ offset: offset.current, animated: false });
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [paused]);

  const neededToFill = Math.ceil(width / (size + gap));
  const extendedData = useMemo(
    () => [
      ...data,
      ...data.slice(0, neededToFill).map((d) => ({ ...d, id: d.id + '-dup' })),
    ],
    [neededToFill]
  );

  return (
    <FlatList
      scrollEnabled={false}
      scrollEventThrottle={2000}
      showsHorizontalScrollIndicator={false}
      windowSize={3}
      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
      contentContainerStyle={{ gap }}
      ref={ref}
      horizontal
      data={extendedData}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
    />
  );
};

export default () => {
  const [paused, setPaused] = useState(true);
  const [touches, setTouches] = useState(0);

  return (
    <View style={{ flex: 1 }} onTouchStart={() => setTouches((t) => t + 1)}>
      <ScrollView contentContainerStyle={{ paddingVertical: 64, gap: 32 }}>
        <Carousel paused={paused} />
        <TouchableOpacity onPress={() => setPaused((p) => !p)}>
          <Text style={{ fontSize: 20 }}>
            Try tapping me {paused ? '▶️' : '⏸️'}
          </Text>
        </TouchableOpacity>
        <Text style={{ fontSize: 16 }}>touches seen by JS: {touches}</Text>
      </ScrollView>
    </View>
  );
};
```

</details>

Before this change, only the first tap works (it starts the marquee); every following tap increments the touch counter but never toggles the button — the press is cancelled by the responder transfer. After this change, every tap toggles the marquee.

Recordings of the repro above (every touch is marked with a blue ring and counted on screen):

Before:

https://github.com/user-attachments/assets/bc5a8764-efe3-40ba-a9cb-a5023d140369

After:

https://github.com/user-attachments/assets/b2c1eea1-dcab-422c-9a4a-90fd69061e9d

Reviewed By: cipolleschi

Differential Revision: D116453881

Pulled By: j-piasecki

fbshipit-source-id: eb72fb0f1a4ac9fdde15f621201009d855d63890
2026-08-20 01:04:12 -07:00
..