Skip to content

[iOS] Move Tap cancelation to dispatch_after#4280

Merged
m-bert merged 3 commits into
mainfrom
@mbert/fix-delayed-tap
Jun 23, 2026
Merged

[iOS] Move Tap cancelation to dispatch_after#4280
m-bert merged 3 commits into
mainfrom
@mbert/fix-delayed-tap

Conversation

@m-bert

@m-bert m-bert commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Fixes #3471

On iOS, in a multi-gesture setup (e.g. an Exclusive single-tap / double-tap combination), a tap's onDeactivate/onFinalize was delayed until a ScrollView/FlatList finished its drag or momentum scroll. onBegin fired immediately, but the single tap never run onActivate/onEnd — it stayed in the began state and finalized as failed only once scrolling settled.

RNBetterTapGestureRecognizer armed its maxDuration and maxDelay failure timers with performSelector:withObject:afterDelay:, which schedules an NSTimer in NSDefaultRunLoopMode only. While a UIScrollView is dragging or decelerating, the main run loop runs in UITrackingRunLoopMode, so those timers are starved until the scroll stops.

To fix this, I replaced the two performSelector:…afterDelay: calls with cancellable dispatch_after blocks which fire regardless of run-loop mode (it is also consistent with current LongPress / Fling logic).

Test plan

Tested on the following code:
import * as React from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import {
  GestureDetector,
  GestureHandlerRootView,
  useExclusiveGestures,
  useTapGesture,
} from 'react-native-gesture-handler';

// Repro for https://github.com/software-mansion/react-native-gesture-handler/issues/3471
//
// A FlatList (a plain RN ScrollView under the hood) lives OUTSIDE the
// GestureDetector. Below it, a tracked box has a single-tap / double-tap
// Exclusive combination (v3 API).
//
// Bug (iOS only): when the list has an active touch or is still in momentum
// scroll, tapping the box fires onBegin immediately, but onDeactivate (v2
// onEnd) / onFinalize of the single tap are delayed until the list's touch is
// released / momentum finishes. The log below timestamps every lifecycle
// callback so the delay is visible.

const DATA = Array.from({ length: 60 }, (_, i) => `List item ${i + 1}`);

const MAX_LOG = 12;

export default function App() {
  const [log, setLog] = React.useState<string[]>([]);

  const seq = React.useRef(0);

  const append = React.useCallback((line: string) => {
    const t = new Date();
    const pad = (n: number, len = 2) => n.toString().padStart(len, '0');
    const stamp = `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(
      t.getSeconds()
    )}.${pad(t.getMilliseconds(), 3)}`;
    const n = (seq.current += 1);
    setLog((prev) =>
      [`#${pad(n, 3)}  ${stamp}  ${line}`, ...prev].slice(0, MAX_LOG)
    );
  }, []);

  const singleTap = useTapGesture({
    disableReanimated: true,
    onBegin: () => append('single  onBegin'),
    onActivate: () => append('single  onActivate (v2 onStart)'),
    onDeactivate: () => append('single  onDeactivate (v2 onEnd)'),
    onFinalize: () => append('single  onFinalize'),
  });

  const doubleTap = useTapGesture({
    disableReanimated: true,
    numberOfTaps: 2,
    onBegin: () => append('double  onBegin'),
    onActivate: () => append('double  onActivate (v2 onStart)'),
    onDeactivate: () => append('double  onDeactivate (v2 onEnd)'),
    onFinalize: () => append('double  onFinalize'),
  });

  const tap = useExclusiveGestures(doubleTap, singleTap);

  return (
    <GestureHandlerRootView style={styles.root}>
      <FlatList
        style={styles.list}
        data={DATA}
        keyExtractor={(item) => item}
        renderItem={({ item }) => (
          <View style={styles.row}>
            <Text style={styles.rowText}>{item}</Text>
          </View>
        )}
      />

      <GestureDetector gesture={tap}>
        <View style={styles.tapArea}>
          <Text style={styles.tapAreaText}>Tap here (single / double)</Text>
        </View>
      </GestureDetector>

      <View style={styles.logBox}>
        <View style={styles.logHeader}>
          <Text style={styles.logTitle}>Log</Text>
          <Pressable
            style={styles.clearButton}
            onPress={() => {
              seq.current = 0;
              setLog([]);
            }}>
            <Text style={styles.clearButtonText}>Clear</Text>
          </Pressable>
        </View>
        {log.map((line, i) => (
          <Text key={`${line}-${i}`} style={styles.logLine}>
            {line}
          </Text>
        ))}
      </View>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: '#ecf0f1',
  },
  list: {
    flex: 1,
  },
  row: {
    paddingVertical: 16,
    paddingHorizontal: 12,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#bdc3c7',
  },
  rowText: {
    fontSize: 16,
  },
  tapArea: {
    height: 100,
    backgroundColor: '#3498db',
    alignItems: 'center',
    justifyContent: 'center',
  },
  tapAreaText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  logBox: {
    height: 220,
    backgroundColor: '#2c3e50',
    padding: 8,
  },
  logHeader: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginBottom: 6,
  },
  logTitle: {
    color: '#ecf0f1',
    fontSize: 14,
    fontWeight: 'bold',
  },
  clearButton: {
    backgroundColor: '#e74c3c',
    paddingVertical: 4,
    paddingHorizontal: 12,
    borderRadius: 4,
  },
  clearButtonText: {
    color: 'white',
    fontSize: 13,
    fontWeight: 'bold',
  },
  logLine: {
    color: '#ecf0f1',
    fontFamily: 'Courier',
    fontSize: 13,
  },
});

Copilot AI review requested due to automatic review settings June 22, 2026 10:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an iOS-specific timing issue where Tap gesture failure/finalization could be delayed during UIScrollView drag/deceleration by replacing performSelector:...afterDelay: timers (default run-loop mode) with cancellable dispatch_after blocks (main queue), ensuring timers fire even in UITrackingRunLoopMode.

Changes:

  • Added cancellable dispatch_after-based scheduling for tap cancel/failure timers.
  • Replaced cancelPreviousPerformRequestsWithTarget:selector: with explicit cancellation of pending GCD blocks.
  • Added internal tracking for pending cancellations to allow cleanup/reset.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@coado coado left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me and my agents approve!

@m-bert m-bert merged commit 571e3cc into main Jun 23, 2026
3 checks passed
@m-bert m-bert deleted the @mbert/fix-delayed-tap branch June 23, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Single tap not processed on time

3 participants