mirror of
https://github.com/react/react-native.git
synced 2026-09-28 13:23:09 +08:00
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