Skip to content

fix: prevent programmatic scrolls from cancelling active touches on iOS#57546

Open
tjzel wants to merge 1 commit into
react:mainfrom
tjzel:fix/ios-responder-ignore-programmatic-scroll
Open

fix: prevent programmatic scrolls from cancelling active touches on iOS#57546
tjzel wants to merge 1 commit into
react:mainfrom
tjzel:fix/ios-responder-ignore-programmatic-scroll

Conversation

@tjzel

@tjzel tjzel commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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

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:

App.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>
  );
};

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:

bugged_trim.mp4

After:

fixed_trim.mp4

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 14, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner labels Jul 14, 2026
@tjzel tjzel marked this pull request as ready for review July 14, 2026 13:04
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant