Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts iOS Native gesture event/state emission to better align with Android behavior, reducing platform-specific workarounds in JS components (notably Pressable/buttons).
Changes:
- Update v3
Pressableto avoid usingNative.onActivateon iOS and to always run finalize cleanup. - Simplify v3
GestureButtonslong-press scheduling to consistently start fromonBegin(removing platform branching). - Modify iOS
RNNativeViewHandlerto emitBEGANon touch-down and to forward additional drag-inside/outside pointer updates; update Pressable iOS state machine config accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/v3/components/Pressable.tsx | Adjusts iOS handling to rely on onBegin/touch-down tracking instead of onActivate, and unifies finalize cleanup. |
| packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx | Removes platform-specific workaround by starting long-press logic from onBegin for all platforms. |
| packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts | Switches iOS Pressable state machine to trigger handlePressIn on NATIVE_BEGIN instead of NATIVE_START. |
| packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm | Changes iOS UIControl-based native handler state emission (touch-down now BEGAN) and adds drag-inside/outside event forwarding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [self sendEventsInState:RNGestureHandlerStateBegan | ||
| forViewWithTag:sender.reactTag | ||
| withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES | ||
| withNumberOfTouches:event.allTouches.count |
There was a problem hiding this comment.
handleTouchDown now emits RNGestureHandlerStateBegan instead of ...StateActive. With the current JS button implementations (both legacy src/components/GestureButtons.tsx and v3 src/v3/components/GestureButtons.tsx), the “pressed/active” UI feedback is driven by the gesture entering ACTIVE while the finger is down. If iOS stays in BEGAN until drag/finish, onActiveStateChange(true)/pressed underlay/opacity won’t update on touch down (it may only flash at release due to the synthetic ACTIVE sent right before END in sendEventsInState). If the intent is to start sending BEGAN for parity but keep pressed feedback, consider sending BEGAN and then immediately ACTIVE on touch down (or otherwise ensuring iOS enters ACTIVE at press start).
packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm
Show resolved
Hide resolved
| [self sendEventsInState:RNGestureHandlerStateBegan | ||
| forViewWithTag:sender.reactTag | ||
| withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES |
There was a problem hiding this comment.
Hmm, maybe the "correct" approach would be to send begin and active immediately when pressed down (and do the same on other platforms)?
This may be weird that the button isn't "active" unless the pointer moves.
There was a problem hiding this comment.
I also thought about it. This would also match current web logic.
packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx
Outdated
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx
Outdated
Show resolved
Hide resolved
Native gesture states are changedThere was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts:66
getIosStatesConfignow expectsLONG_PRESS_TOUCHES_DOWNbeforeNATIVE_BEGINso it can reuse the touch payload forhandlePressIn. However, in v3PressabletheNATIVE_BEGINevent is emitted from the native gesture’sonBegin(no payload) and can occur before the long-pressonTouchesDown. If that happens, the state machine will never reach this callback andhandlePressInwon’t fire. To make this robust, either (a) emitNATIVE_BEGINwith a payload, or (b) reorder the iOS steps soNATIVE_BEGINis first andhandlePressInis triggered on the event that reliably carries a payload.
return [
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
},
{
eventName: StateMachineEvent.NATIVE_BEGIN,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx:62
onBeginnow starts the long-press timeout and sets the active/pressed state, but there’s no handler that reacts to subsequentpointerInsidechanges while the NativeViewGestureHandler staysACTIVE(iOS drag in/out sendsACTIVEevents without a state change). As a result, dragging outside and holding can still fireonLongPress, andonActiveStateChange(false)won’t run until finalize. Consider handlingpointerInsideupdates viaonUpdate(or another callback that receives continuousACTIVEevents) to clear the timeout and update active state when the pointer leaves/enters.
const onBegin = (e: CallbackEventType) => {
if (!e.pointerInside) {
return;
}
onActiveStateChange?.(true);
longPressDetected.current = false;
if (onLongPress) {
longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress);
}
props.onBegin?.(e);
};
const onActivate = (e: CallbackEventType) => {
if (!e.pointerInside && longPressTimeout.current !== undefined) {
clearTimeout(longPressTimeout.current);
longPressTimeout.current = undefined;
}
props.onActivate?.(e);
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| onBegin: () => { | ||
| stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); | ||
| }, | ||
| onActivate: () => { | ||
| if (Platform.OS !== 'android') { | ||
| if (Platform.OS !== 'android' && Platform.OS !== 'ios') { | ||
| // Native.onActivate is broken with Android + hitSlop | ||
| // On iOS, onActivate fires on drag (not touch down), so we use onBegin + LONG_PRESS_TOUCHES_DOWN instead | ||
| stateMachine.handleEvent(StateMachineEvent.NATIVE_START); | ||
| } |
There was a problem hiding this comment.
On iOS, the state machine now uses NATIVE_BEGIN to trigger handlePressIn, but buttonGesture.onBegin calls stateMachine.handleEvent(NATIVE_BEGIN) without providing a PressableEvent payload. If NATIVE_BEGIN arrives before LONG_PRESS_TOUCHES_DOWN (as it does on Android), the state machine will consume the begin step with no payload and handlePressIn will never run, which can suppress onPressIn/pressed state on iOS. Consider passing an event payload into NATIVE_BEGIN (e.g. derive one from the onBegin event via gestureToPressableEvent) or adjusting the iOS state sequence so it can’t miss the begin event.
Description
Nativegesture is specific and its behavior differs across platforms. This leads to strange workarounds in our codebase (e.g. buttons).In this PR unifies buttons behavior by changing Native gesture.
Test plan
Tested on expo-example app (buttons / Pressable)