Repository navigation
[iOS] Cancel an in-progress press when the button gets disabled - #4501
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughThe 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. ChangesButton press cancellation
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to 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)
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. Comment |
There was a problem hiding this comment.
🟢 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 toNO(iOS-only), including UIControl tracking cancellation andUIControlEventTouchCanceldispatch. - 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.
## 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>
Description
When a
Touchable/Pressableis disabled while a press is in progress, the button kept tracking the touch:onPressOutonly arrived on release andonPressstill fired even though the button was already disabled.setUserEnabled:now ends the press when it flips toNO. It cancelsUIControltracking and sendsUIControlEventTouchCancelto the targets, so the managed handler moves toCANCELLEDand JS receivesonPressOutwithoutonPress. 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
Touchablethat disables itself shortly afteronPressIn, e.g. a 700 ms timeout, and hold it past that point:onPressOutfires the moment the button gets disabledonPressdoes not fire on releaseTested on the following example: