Skip to content

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

Open
tjzel wants to merge 2 commits 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 2 commits 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
@TaduJR

TaduJR commented Aug 9, 2026

Copy link
Copy Markdown

Great Job @tjzel

One Question:

ScrollView isn't the only thing emitting topScroll on iOS.

A multiline TextInput scrolls its own content and goes through a different emitter:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
if (_eventEmitter) {
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onScroll([self _textInputMetrics]);
}
}

void TextInputEventEmitter::onScroll(const Metrics& textInputMetrics) const {
dispatchEvent("scroll", [textInputMetrics](jsi::Runtime& runtime) {
return textInputMetricsScrollPayload(runtime, textInputMetrics);
});
}

It dispatches under the same scroll name, so it reaches the same gate, but its payload builder never sets the key:

static jsi::Value textInputMetricsScrollPayload(
jsi::Runtime& runtime,
const TextInputEventEmitter::Metrics& textInputMetrics) {
auto payload = jsi::Object(runtime);
{
auto contentOffset = jsi::Object(runtime);
contentOffset.setProperty(runtime, "x", textInputMetrics.contentOffset.x);
contentOffset.setProperty(runtime, "y", textInputMetrics.contentOffset.y);
payload.setProperty(runtime, "contentOffset", contentOffset);
}
{
auto contentInset = jsi::Object(runtime);
contentInset.setProperty(runtime, "top", textInputMetrics.contentInset.top);
contentInset.setProperty(
runtime, "left", textInputMetrics.contentInset.left);
contentInset.setProperty(
runtime, "bottom", textInputMetrics.contentInset.bottom);
contentInset.setProperty(
runtime, "right", textInputMetrics.contentInset.right);
payload.setProperty(runtime, "contentInset", contentInset);
}
{
auto contentSize = jsi::Object(runtime);
contentSize.setProperty(
runtime, "width", textInputMetrics.contentSize.width);
contentSize.setProperty(
runtime, "height", textInputMetrics.contentSize.height);
payload.setProperty(runtime, "contentSize", contentSize);
}
{
auto layoutMeasurement = jsi::Object(runtime);
layoutMeasurement.setProperty(
runtime, "width", textInputMetrics.layoutMeasurement.width);
layoutMeasurement.setProperty(
runtime, "height", textInputMetrics.layoutMeasurement.height);
payload.setProperty(runtime, "layoutMeasurement", layoutMeasurement);
}
payload.setProperty(
runtime,
"zoomScale",
textInputMetrics.zoomScale != 0.0f ? textInputMetrics.zoomScale : 1);
return payload;
};

So an ancestor ScrollView can still take the responder from an active press when a text input scrolls.

Android already covers this by a different route. Its text input dispatches the shared ScrollEvent, which writes the key on its own:

val event = obtain(
surfaceId,
editText.id,
ScrollEventType.SCROLL,

and the field is declared in Android's TextInput onScroll payload type:

A constant is enough here, since a text input scrolling its own content is never a reason for an ancestor to claim the touch:

payload.setProperty(runtime, "responderIgnoreScroll", true);

We're running your change as a patch on 0.85.3 and it fixed the ScrollView case for us. The TextInput path still opens a negotiation in our traces, though we haven't caught it cancelling a press yet, since no finger was down at those moments.

WDYT?

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.

2 participants