Skip to content

[iOS] Cancel an in-progress press when the button gets disabled - #4501

Merged
m-bert merged 1 commit into
mainfrom
@mbert/ios-disabled-button-mid-press
Sep 9, 2026
Merged

m-bert merged 1 commit into
mainfrom
@mbert/ios-disabled-button-mid-press

Conversation

@m-bert

@m-bert m-bert commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Description

When a Touchable/Pressable is disabled while a press is in progress, the button kept tracking the touch: onPressOut only arrived on release and onPress still fired even though the button was already disabled.

setUserEnabled: now ends the press when it flips to NO. It cancels UIControl tracking and sends UIControlEventTouchCancel to the targets, so the managed handler moves to CANCELLED and JS receives onPressOut without onPress. The press visuals are reset with a zero-duration animation instead of the regular press-out fade: no finger lifted, and a disable usually restyles the button in the same frame, so the fade showed as a brief flash.

Test plan

Add a Touchable that disables itself shortly after onPressIn, e.g. a 700 ms timeout, and hold it past that point:

  • onPressOut fires the moment the button gets disabled
  • onPress does not fire on release
  • the button snaps back to its resting look with no flash
  • re-enabling and pressing again works as before
Tested on the following example:
import React, { useCallback, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Touchable } from 'react-native-gesture-handler';

// A button that gets disabled while a press is in progress.
// Press and hold the blue box. After `DISABLE_AFTER_MS` it disables itself.
// Expected: onPressOut fires at that moment, onPress never fires on release,
// and the press animation resets instead of staying stuck (macOS especially).
const DISABLE_AFTER_MS = 700;

export default function EmptyExample() {
  const [disabled, setDisabled] = useState(false);
  const [log, setLog] = useState<string[]>([]);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const push = useCallback((entry: string) => {
    console.log(`RNGH-mid-press: ${entry}`);
    setLog((l) => [...l.slice(-7), entry]);
  }, []);

  const onPressIn = useCallback(() => {
    push('onPressIn');
    timer.current = setTimeout(() => {
      push(`disabled after ${DISABLE_AFTER_MS}ms`);
      setDisabled(true);
    }, DISABLE_AFTER_MS);
  }, [push]);

  const onPressOut = useCallback(() => {
    push('onPressOut');
    if (timer.current) {
      clearTimeout(timer.current);
      timer.current = null;
    }
  }, [push]);

  const onPress = useCallback(() => push('onPress'), [push]);

  const reset = useCallback(() => {
    setDisabled(false);
    setLog([]);
  }, []);

  return (
    <View style={styles.container}>
      <Text>
        Hold the box for longer than {DISABLE_AFTER_MS}ms. It disables itself
        mid-press.
      </Text>
      <Touchable
        disabled={disabled}
        onPressIn={onPressIn}
        onPressOut={onPressOut}
        onPress={onPress}
        activeOpacity={0.4}
        style={[styles.box, disabled ? styles.boxDisabled : styles.boxEnabled]}>
        <Text style={styles.boxText}>{disabled ? 'disabled' : 'hold me'}</Text>
      </Touchable>
      <Touchable onPress={reset} style={styles.reset}>
        <Text style={styles.boxText}>re-enable and clear log</Text>
      </Touchable>
      {log.map((entry, i) => (
        <Text key={i} style={styles.log}>
          {entry}
        </Text>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    gap: 12,
  },
  box: {
    height: 100,
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
  },
  boxEnabled: {
    backgroundColor: '#5b7bd5',
  },
  boxDisabled: {
    backgroundColor: '#9a9a9a',
  },
  boxText: {
    color: 'white',
    fontWeight: '600',
  },
  reset: {
    height: 44,
    borderRadius: 8,
    backgroundColor: '#3a3a3a',
    justifyContent: 'center',
    alignItems: 'center',
  },
  log: {
    fontVariant: ['tabular-nums'],
  },
});

Copilot AI lite review requested due to automatic review settings September 9, 2026 10:09
@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 74d6370f-7332-4731-83e5-b57838b17567

📥 Commits

Reviewing files that changed from the base of the PR and between 18b08dd and 4623093.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Disabling a button during an active press now properly cancels the interaction and immediately restores its resting visual state.
    • Pending press-out animations are canceled when the button is disabled.

Walkthrough

The button now cancels an active press when disabled on non-macOS platforms. It sends a touch-cancel event, stops tracking, cancels the press-out animation, and immediately restores the resting visual state. Whitespace-only lines were also removed.

Changes

Button press cancellation

Layer / File(s) Summary
Cancel active press and reset state
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm
When the button is disabled during tracking, cancelActivePress stops tracking, dispatches UIControlEventTouchCancel, cancels the pending press-out animation, and restores the resting visual state without a transition. Whitespace-only lines were removed from related handlers.

Suggested reviewers: j-piasecki, rileysay

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 46230

Disabling a button during an active iOS press now cancels the interaction, emits press-out behavior, prevents a later press action, and resets visuals immediately. No concrete merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: canceling an active iOS button press when the button becomes disabled.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

🟢 Approval recommended

The change is small, platform-gated to iOS, and directly addresses the reported mid-press disable behavior without widening API surface area.

Pull request overview

Fixes an iOS behavior gap in RNGestureHandlerButton where disabling a Touchable/Pressable mid-press would continue UIControl tracking until release, causing onPressOut to be delayed and onPress to still fire. The PR updates the native button so disabling immediately cancels the active press, dispatches a cancel event to targets, and resets press visuals without a fade flash.

Changes:

  • Cancel an in-progress press when setUserEnabled: flips to NO (iOS-only), including UIControl tracking cancellation and UIControlEventTouchCancel dispatch.
  • Reset pressed visuals instantly (duration 0) on cancel to avoid a “press-out fade” flash during disable/restyle.
File summaries
File Description
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm Cancels active UIControl tracking when disabling mid-press and snaps visuals back to resting state without the regular fade.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@m-bert
m-bert added this pull request to stack #4503 September 9, 2026 10:14
@m-bert
m-bert requested a review from j-piasecki September 9, 2026 13:11
@m-bert
m-bert merged commit b351a96 into main Sep 9, 2026
7 checks passed
@m-bert
m-bert deleted the @mbert/ios-disabled-button-mid-press branch September 9, 2026 14:48
m-bert added a commit that referenced this pull request Oct 27, 2026
## Description

On iOS, a `Pressable` with a relation prop (`simultaneousWith`,
`requireToFail` or `block`) renders `StatefulPressable`, which called
`onPressIn` and never `onPressOut` when it was disabled while a finger
held it. Since #4501 the button cancels the press at that moment, so
`onPressOut` is expected without `onPress`, which is what the default
`Pressable` and `LegacyPressable` do.

The native gesture's `onFinalize` arrives before the long press's
`onTouchesCancel`, which is the path that delivers the press-out.
`onFinalize` ran `handleFinalize`, clearing `isCurrentlyPressed`, so the
later `handlePressOut` returned early. The same ordering appears when
the native gesture fails because a `requireToFail` double tap wins;
there the long press ends with a touch up instead of a cancel, so no
press-out ever came and the long-press timer stayed armed.

`StatefulPressable` now delivers the press-out itself when the native
gesture is cancelled on iOS while the press is still held, using the
touch recorded at press-in. A later touch cancel from the long press
finds the press already ended and bails. Every other path is unchanged.

Fixes #4630.

## Test plan

Tested on an iPhone 17 Pro simulator with a `Pressable` that has
`simultaneousWith={[]}`:

- disabling it 300 ms into a hold logs `onPressIn`, `onPressOut`, no
`onPress`; same with `unstable_pressDelay` and `delayLongPress`
- double tapping it with a `requireToFail` double tap logs `onPressIn`,
`onPressOut`, no `onLongPress`
- dragging inside an RN `ScrollView` or a parent pan still presses out
through the touch cancel, as before
- a plain tap still logs `onPressIn`, `onPressOut`, `onPress`
- the default `Pressable` and `LegacyPressable` behave as before

<details>
<summary>Test code:</summary>

```tsx
// #4630 regression matrix: StatefulPressable (relation prop) in every
// situation where the native gesture can be cancelled while the press is held.
// Each row logs its callback sequence as `[4630] <row>: <event>`.
import React, { useEffect, useRef, useState } from 'react';
import {
  Modal,
  ScrollView as RNScrollView,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import type { PressableProps } from 'react-native-gesture-handler';
import {
  GestureDetector,
  GestureHandlerRootView,
  Pressable,
  ScrollView,
  usePanGesture,
  useTapGesture,
} from 'react-native-gesture-handler';

const DISABLE_AFTER_MS = 300;
const RECOVER_AFTER_MS = 800;

type Log = (line: string) => void;

export default function Matrix() {
  const [lines, setLines] = useState<string[]>([]);
  const t0 = useRef(Date.now());
  const log: Log = (line) => {
    console.log(`[4630] ${Date.now() - t0.current} ${line}`);
    setLines((current) => [...current.slice(-9), line]);
  };

  return (
    <GestureHandlerRootView style={styles.container}>
      <DisableRow id="A-longPress" log={log} delayLongPress={500} />
      <DisableRow id="B-pressDelay" log={log} unstable_pressDelay={500} />
      <RNScrollRow log={log} />
      <RNGHScrollRow log={log} />
      <PanParentRow log={log} />
      <RequireToFailRow log={log} />
      <ModalRow log={log} />
      <UnmountRow log={log} />
      <Text style={styles.log} testID="log">
        {lines.join('\n')}
      </Text>
    </GestureHandlerRootView>
  );
}

function pressProps(id: string, log: Log): PressableProps {
  return {
    testID: id,
    simultaneousWith: [],
    onPressIn: () => log(`${id}: onPressIn`),
    onPressOut: () => log(`${id}: onPressOut`),
    onPress: () => log(`${id}: onPress`),
    onLongPress: () => log(`${id}: onLongPress`),
  };
}

function Box({ id, disabled }: { id: string; disabled?: boolean }) {
  return (
    <View style={[styles.box, disabled && styles.disabled]}>
      <Text>{id}</Text>
    </View>
  );
}

// Disables itself under the finger, re-enables shortly after.
function DisableRow({
  id,
  log,
  ...rest
}: { id: string; log: Log } & Partial<PressableProps>) {
  const [disabled, setDisabled] = useState(false);
  const base = pressProps(id, log);
  return (
    <Pressable
      {...base}
      {...rest}
      disabled={disabled}
      onPressIn={(e) => {
        base.onPressIn?.(e);
        setTimeout(() => setDisabled(true), DISABLE_AFTER_MS);
        setTimeout(() => setDisabled(false), RECOVER_AFTER_MS);
      }}>
      <Box id={id} disabled={disabled} />
    </Pressable>
  );
}

// RN ScrollView steals the touch when dragged.
function RNScrollRow({ log }: { log: Log }) {
  const id = 'C-rnScroll';
  return (
    <RNScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
      <Pressable {...pressProps(id, log)}>
        <Box id={id} />
      </Pressable>
      <View style={styles.filler} />
    </RNScrollView>
  );
}

// RNGH ScrollView steals the touch when dragged.
function RNGHScrollRow({ log }: { log: Log }) {
  const id = 'D-rnghScroll';
  return (
    <ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
      <Pressable {...pressProps(id, log)}>
        <Box id={id} />
      </Pressable>
      <View style={styles.filler} />
    </ScrollView>
  );
}

// A parent pan (not simultaneous) activates when dragged.
function PanParentRow({ log }: { log: Log }) {
  const id = 'E-panParent';
  const pan = usePanGesture({
    runOnJS: true,
    onActivate: () => log(`${id}: pan onActivate`),
    onDeactivate: () => log(`${id}: pan onDeactivate`),
  });
  return (
    <GestureDetector gesture={pan}>
      <View>
        <Pressable {...pressProps(id, log)}>
          <Box id={id} />
        </Pressable>
      </View>
    </GestureDetector>
  );
}

// The press waits for a double tap to fail.
function RequireToFailRow({ log }: { log: Log }) {
  const id = 'F-requireToFail';
  const doubleTap = useTapGesture({
    numberOfTaps: 2,
    runOnJS: true,
    onActivate: () => log(`${id}: doubleTap onActivate`),
  });
  return (
    <GestureDetector gesture={doubleTap}>
      <View>
        <Pressable {...pressProps(id, log)} requireToFail={doubleTap}>
          <Box id={id} />
        </Pressable>
      </View>
    </GestureDetector>
  );
}

// A modal opens over the finger and closes by itself.
function ModalRow({ log }: { log: Log }) {
  const id = 'G-modal';
  const [visible, setVisible] = useState(false);
  const base = pressProps(id, log);
  return (
    <>
      <Pressable
        {...base}
        onPressIn={(e) => {
          base.onPressIn?.(e);
          setTimeout(() => setVisible(true), DISABLE_AFTER_MS);
          setTimeout(() => setVisible(false), RECOVER_AFTER_MS);
        }}>
        <Box id={id} />
      </Pressable>
      <Modal visible={visible} transparent animationType="none">
        <View style={styles.modal}>
          <Text>modal</Text>
        </View>
      </Modal>
    </>
  );
}

// The pressable unmounts under the finger and remounts shortly after.
function UnmountRow({ log }: { log: Log }) {
  const id = 'H-unmount';
  const [mounted, setMounted] = useState(true);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);
  const base = pressProps(id, log);
  if (!mounted) {
    return (
      <View style={[styles.box, styles.disabled]}>
        <Text>{id} (unmounted)</Text>
      </View>
    );
  }
  return (
    <Pressable
      {...base}
      onPressIn={(e) => {
        base.onPressIn?.(e);
        timers.current.push(
          setTimeout(() => setMounted(false), DISABLE_AFTER_MS),
          setTimeout(() => setMounted(true), RECOVER_AFTER_MS)
        );
      }}>
      <Box id={id} />
    </Pressable>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    paddingTop: 8,
    gap: 10,
  },
  box: {
    width: 300,
    height: 44,
    backgroundColor: '#b58df1',
    alignItems: 'center',
    justifyContent: 'center',
  },
  disabled: {
    backgroundColor: '#e0e0e0',
  },
  scroll: {
    height: 44,
    flexGrow: 0,
  },
  filler: {
    height: 200,
  },
  modal: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.3)',
    alignItems: 'center',
    justifyContent: 'center',
  },
  log: {
    width: 320,
    fontSize: 12,
    fontVariant: ['tabular-nums'],
  },
});
```

</details>
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.

3 participants