From 69087cb87623beabaddb0f327dd04a442884746f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 09:56:14 +0200 Subject: [PATCH 1/6] Pass `moduleId` to the button --- .../react/RNGestureHandlerButtonViewManager.kt | 5 +++++ .../apple/RNGestureHandlerButtonComponentView.mm | 3 +++ .../src/components/GestureHandlerButton.tsx | 1 + .../src/specs/RNGestureHandlerButtonNativeComponent.ts | 4 ++++ .../src/v3/components/Touchable/Touchable.android.tsx | 1 + 5 files changed, 14 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 6d6b761bd9..161cc2009b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -152,6 +152,11 @@ class RNGestureHandlerButtonViewManager : view.hasLongPressHandler = hasLongPressHandler } + // No-op — only iOS needs the module id to resolve the handler manager, Android + // gets the module from the view's context. + @ReactProp(name = "moduleId") + override fun setModuleId(view: ButtonViewGroup, moduleId: Int) = Unit + @ReactProp(name = "foreground") override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) { view.useDrawableOnForeground = useDrawableOnForeground diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index f62df4170d..2e40731482 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -35,6 +35,7 @@ @interface RNGestureHandlerButtonComponentView () (); _props = defaultProps; + _moduleId = -1; _buttonView = [[RNGestureHandlerButton alloc] initWithFrame:self.bounds]; _buttonView.animationTarget = self; @@ -367,6 +369,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity; } + _moduleId = newProps.moduleId; _buttonView.userEnabled = newProps.enabled; _buttonView.tapAnimationInDuration = newProps.tapAnimationInDuration > 0 ? newProps.tapAnimationInDuration : 0; _buttonView.tapAnimationOutDuration = newProps.tapAnimationOutDuration > 0 ? newProps.tapAnimationOutDuration : 0; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 1ec9e1c265..ccd3bafb28 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -22,6 +22,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { enabled?: boolean | undefined; hasLongPressHandler?: boolean | undefined; + moduleId?: number | undefined; handlerTag?: number | undefined; cancelOnLeave?: boolean | undefined; gestureTestID?: string | undefined; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 8ae32fcd4a..f6308a4788 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -27,6 +27,10 @@ interface NativeProps extends ViewProps { | undefined; hasLongPressHandler?: CodegenTypes.WithDefault; + // Used on iOS to resolve the gesture handler module instance managing the + // handler created for `handlerTag`. Android resolves the module from the + // view's context instead. + moduleId?: CodegenTypes.WithDefault; handlerTag?: CodegenTypes.Double | undefined; cancelOnLeave?: CodegenTypes.WithDefault; gestureTestID?: string; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx index f694c62189..8c18cd9bb7 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx @@ -179,6 +179,7 @@ export const Touchable = (props: TouchableProps) => { {...resolvedDurations} ref={ref ?? null} enabled={!disabled} + moduleId={globalThis._RNGH_MODULE_ID} handlerTag={handlerTag} cancelOnLeave={cancelOnLeave} gestureTestID={props.testID} From ef53473360c4d488a843a8df409f0c82798fcb1b Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 09:59:13 +0200 Subject: [PATCH 2/6] Allow bound views to listen to handler updates and state changes --- .../apple/Handlers/RNNativeViewHandler.h | 20 ++++++ .../apple/Handlers/RNNativeViewHandler.mm | 67 ++++++++++++++----- .../apple/RNGestureHandler.h | 15 +++++ .../apple/RNGestureHandler.mm | 17 +++++ 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.h b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.h index cd7996e8ac..adb8d6d39a 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.h +++ b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.h @@ -11,5 +11,25 @@ @interface RNDummyGestureRecognizer : UIGestureRecognizer @end +/* + * Views bound to a `RNNativeViewGestureHandler` may conform to this protocol to + * be notified about the handler's updates and state changes. + */ +@protocol RNGHNativeViewHandlerStateObserver + +/* + * Called when the handler dispatches a new update event in the ACTIVE state. + */ +- (void)onHandlerUpdate:(nonnull RNGestureHandlerEventExtraData *)extraData; + +/* + * Called when the handler moves to a new state. + */ +- (void)onHandlerStateChange:(RNGestureHandlerState)newState + prevState:(RNGestureHandlerState)prevState + extraData:(nonnull RNGestureHandlerEventExtraData *)extraData; + +@end + @interface RNNativeViewGestureHandler : RNGestureHandler @end diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm index 84fc16b334..9c60ce6ae7 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm +++ b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm @@ -116,6 +116,11 @@ @implementation RNNativeViewGestureHandler { BOOL _yieldsToContinuousGestures; BOOL _delaysChildPressedState; RNGestureHandlerEventExtraData *_lastActiveExtraData; + + // For UIControl-based views the recognizer is never attached, so + // `recognizer.view` cannot be used to retrieve the bound view. + __weak RNGHUIView *_boundView; + __weak id _stateObserver; } - (instancetype)initWithTag:(NSNumber *)tag @@ -146,9 +151,54 @@ - (void)updateConfig:(NSDictionary *)config #endif } +- (void)bindToView:(RNGHUIView *)view +{ + _boundView = view; + + if ([view conformsToProtocol:@protocol(RNGHNativeViewHandlerStateObserver)]) { + _stateObserver = (id)view; + } + +#if !TARGET_OS_OSX + [self bindToUIKitView:view]; +#else + [super bindToView:view]; +#endif +} + +- (void)unbindFromView +{ +#if !TARGET_OS_OSX + if ([_boundView isKindOfClass:[UIControl class]]) { + [(UIControl *)_boundView removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents]; + } + + // Restore the React Native's overridden behavor for not delaying content touches + UIScrollView *scrollView = [self retrieveScrollView:_boundView]; + scrollView.delaysContentTouches = NO; +#endif + + _boundView = nil; + _stateObserver = nil; + + [super unbindFromView]; +} + +- (void)dispatchStateChange:(RNGestureHandlerState)newState + prevState:(RNGestureHandlerState)prevState + extraData:(RNGestureHandlerEventExtraData *)extraData +{ + [_stateObserver onHandlerStateChange:newState prevState:prevState extraData:extraData]; +} + +- (void)dispatchHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData +{ + [_stateObserver onHandlerUpdate:extraData]; +} + #if !TARGET_OS_OSX -- (void)bindToView:(UIView *)view +- (void)bindToUIKitView:(UIView *)view { // For UIControl based views (UIButton, UISwitch) we provide special handling that would allow // for properties like `disallowInterruption` to work. @@ -196,21 +246,6 @@ - (void)bindToView:(UIView *)view scrollView.delaysContentTouches = _delaysChildPressedState; } -- (void)unbindFromView -{ - UIView *view = self.recognizer.view; - - if ([view isKindOfClass:[UIControl class]]) { - [(UIControl *)view removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents]; - } - - // Restore the React Native's overriden behavor for not delaying content touches - UIScrollView *scrollView = [self retrieveScrollView:view]; - scrollView.delaysContentTouches = NO; - - [super unbindFromView]; -} - - (RNGestureHandlerEventExtraData *)extraDataForView:(UIView *)sender event:(UIEvent *)event pointerInside:(BOOL)pointerInside diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index c342cb48cb..950295af11 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -125,6 +125,21 @@ - (void)stopActivationBlocker; - (void)reset; + +/* + * Called after a state-change event has been dispatched. No-op by default, may be + * overridden by subclasses to observe the state flow regardless of the action type. + */ +- (void)dispatchStateChange:(RNGestureHandlerState)newState + prevState:(RNGestureHandlerState)prevState + extraData:(nonnull RNGestureHandlerEventExtraData *)extraData; + +/* + * Called after an update event has been dispatched in the ACTIVE state. No-op by + * default, may be overridden by subclasses. + */ +- (void)dispatchHandlerUpdate:(nonnull RNGestureHandlerEventExtraData *)extraData; + - (void)sendEventsInState:(RNGestureHandlerState)state forViewWithTag:(nonnull NSNumber *)reactTag withExtraData:(nonnull RNGestureHandlerEventExtraData *)extraData; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index e77d8eb751..a646aabbe2 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -534,6 +534,7 @@ - (void)sendEventsInState:(RNGestureHandlerState)state state == RNGestureHandlerStateEnd && _lastState != RNGestureHandlerStateActive && !fromManualStateChange && !_manualActivation) { // Otherwise send activate state change event to preserve correct gesture flow + RNGestureHandlerState prevSynthesizedState = _lastState; id event = [[RNGestureHandlerStateChange alloc] initWithReactTag:reactTag handlerTag:_tag state:RNGestureHandlerStateActive @@ -541,7 +542,9 @@ - (void)sendEventsInState:(RNGestureHandlerState)state extraData:extraData]; [self sendEvent:event]; _lastState = RNGestureHandlerStateActive; + [self dispatchStateChange:RNGestureHandlerStateActive prevState:prevSynthesizedState extraData:extraData]; } + RNGestureHandlerState prevState = _lastState; id stateEvent = [[RNGestureHandlerStateChange alloc] initWithReactTag:reactTag handlerTag:_tag state:state @@ -549,6 +552,7 @@ - (void)sendEventsInState:(RNGestureHandlerState)state extraData:extraData]; [self sendEvent:stateEvent]; _lastState = state; + [self dispatchStateChange:state prevState:prevState extraData:extraData]; } if (state == RNGestureHandlerStateActive) { @@ -559,9 +563,22 @@ - (void)sendEventsInState:(RNGestureHandlerState)state forHandlerType:[self eventHandlerType] coalescingKey:self->_eventCoalescingKey]; [self sendEvent:touchEvent]; + [self dispatchHandlerUpdate:extraData]; } } +- (void)dispatchStateChange:(RNGestureHandlerState)newState + prevState:(RNGestureHandlerState)prevState + extraData:(RNGestureHandlerEventExtraData *)extraData +{ + // no-op +} + +- (void)dispatchHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData +{ + // no-op +} + - (RNGHUIView *)findViewForEvents { return [self usesNativeOrVirtualDetector] ? self.hostDetectorView : self.recognizer.view; From 2ac71749f7d9e6c545959d0308feb26511cd52ca Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 10:01:51 +0200 Subject: [PATCH 3/6] Dispatch `press` events directly from the native side on iOS --- .../apple/RNGestureHandlerButton.h | 28 ++++ .../apple/RNGestureHandlerButton.mm | 134 ++++++++++++++++++ .../RNGestureHandlerButtonComponentView.mm | 47 +++++- 3 files changed, 208 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 75f565d3fe..3e06675652 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -8,6 +8,25 @@ #import "RNGestureHandler.h" +typedef NS_ENUM(NSInteger, RNGHButtonEventType) { + RNGHButtonEventTypePress, + RNGHButtonEventTypePressIn, + RNGHButtonEventTypePressOut, + RNGHButtonEventTypeLongPress, + RNGHButtonEventTypeInteractionFinished, +}; + +/* + * Receives press events produced by the button's state machine so that they can + * be dispatched through the component's event emitter. + */ +@protocol RNGHButtonPressEventDelegate + +- (void)dispatchButtonEvent:(RNGHButtonEventType)type + withExtraData:(nullable RNGestureHandlerEventExtraData *)extraData; + +@end + #if TARGET_OS_OSX #include @@ -43,6 +62,15 @@ @property (nonatomic, assign) CGFloat hoverScale; @property (nonatomic, assign) CGFloat hoverUnderlayOpacity; @property (nonatomic, strong, nullable) RNGHColor *underlayColor; +@property (nonatomic, assign) BOOL hasLongPressHandler; + +/** + * Tag of the gesture handler managed by the button component. The press event + * state machine runs only while it's set. + */ +@property (nonatomic, strong, nullable) NSNumber *managedHandlerTag; + +@property (nonatomic, weak, nullable) id pressEventDelegate; /** * The view that press animations are applied to. Defaults to self; set by the diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 07c51c156c..cfc5aa761e 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -7,6 +7,7 @@ // #import "RNGestureHandlerButton.h" +#import "Handlers/RNNativeViewHandler.h" #if !TARGET_OS_OSX #import @@ -18,6 +19,9 @@ #import #import +@interface RNGestureHandlerButton () +@end + /** * Gesture Handler Button components overrides standard mechanism used by RN * to determine touch target, which normally would reurn the UIView that is placed @@ -50,6 +54,14 @@ @implementation RNGestureHandlerButton { BOOL _isHovered; BOOL _isPressed; dispatch_block_t _pendingHoverOutBlock; + + // Press event state machine, driven by the state of the managed handler. + // Cannot rely on the pointerInside flag alone because it may change multiple + // times between the dispatched events. + BOOL _lastEventWasInside; + BOOL _longPressDetected; + dispatch_block_t _pendingLongPressBlock; + RNGestureHandlerEventExtraData *_lastObservedExtraData; #if TARGET_OS_OSX NSTrackingArea *_hoverTrackingArea; #endif @@ -87,6 +99,12 @@ - (void)commonInit _isHovered = NO; _isPressed = NO; _pendingHoverOutBlock = nil; + _hasLongPressHandler = NO; + _managedHandlerTag = nil; + _lastEventWasInside = NO; + _longPressDetected = NO; + _pendingLongPressBlock = nil; + _lastObservedExtraData = nil; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif @@ -148,6 +166,10 @@ - (void)prepareForRecycle // when defaults are unchanged between mounts. [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; + [self cancelPendingLongPress]; + _lastEventWasInside = NO; + _longPressDetected = NO; + _lastObservedExtraData = nil; RNGHUIView *target = self.animationTarget ?: self; target.layer.transform = CATransform3DIdentity; @@ -172,12 +194,16 @@ - (void)viewWillMoveToWindow:(RNGHWindow *)newWindow if (newWindow == nil) { [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; + [self cancelPendingLongPress]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; _isHovered = NO; _isPressed = NO; + _lastEventWasInside = NO; + _longPressDetected = NO; + _lastObservedExtraData = nil; } } #else @@ -187,12 +213,16 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow if (newWindow == nil) { [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; + [self cancelPendingLongPress]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; _isHovered = NO; _isPressed = NO; + _lastEventWasInside = NO; + _longPressDetected = NO; + _lastObservedExtraData = nil; } } #endif @@ -566,6 +596,110 @@ - (void)handleAnimatePressOut } } +#pragma mark - Press event state machine + +- (void)onHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData +{ + if (_managedHandlerTag == nil) { + return; + } + + _lastObservedExtraData = extraData; + BOOL pointerInside = [extraData.data[@"pointerInside"] boolValue]; + + if (pointerInside == _lastEventWasInside) { + return; + } + + if (pointerInside) { + [self dispatchButtonEvent:RNGHButtonEventTypePressIn withExtraData:extraData]; + } else { + [self dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + [self cancelPendingLongPress]; + } +} + +- (void)onHandlerStateChange:(RNGestureHandlerState)newState + prevState:(RNGestureHandlerState)prevState + extraData:(RNGestureHandlerEventExtraData *)extraData +{ + if (_managedHandlerTag == nil) { + return; + } + + _lastObservedExtraData = extraData; + + // Capture a local copy, since dispatching the events changes the flag. + // Specifically the PressOut -> Press scenario on the END state. + BOOL localLastEventWasInside = _lastEventWasInside; + BOOL isFinished = newState == RNGestureHandlerStateEnd || newState == RNGestureHandlerStateFailed || + newState == RNGestureHandlerStateCancelled; + + if (newState == RNGestureHandlerStateBegan) { + [self dispatchButtonEvent:RNGHButtonEventTypePressIn withExtraData:extraData]; + _longPressDetected = NO; + + // Duration 0 fires the long press immediately; -1 (unset) disables it. + if (_hasLongPressHandler && _longPressDuration >= 0) { + [self scheduleLongPress]; + } + } + + if (isFinished) { + if (localLastEventWasInside) { + [self dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + } + + [self cancelPendingLongPress]; + } + + if (newState == RNGestureHandlerStateEnd && !_longPressDetected && localLastEventWasInside) { + [self dispatchButtonEvent:RNGHButtonEventTypePress withExtraData:extraData]; + } + + if (isFinished) { + [self dispatchButtonEvent:RNGHButtonEventTypeInteractionFinished withExtraData:extraData]; + } +} + +- (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHandlerEventExtraData *)extraData +{ + [self.pressEventDelegate dispatchButtonEvent:type withExtraData:extraData]; + + if (type == RNGHButtonEventTypePressIn) { + _lastEventWasInside = YES; + } else if (type == RNGHButtonEventTypePressOut) { + _lastEventWasInside = NO; + } +} + +- (void)scheduleLongPress +{ + [self cancelPendingLongPress]; + + __weak auto weakSelf = self; + _pendingLongPressBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ + __strong auto strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_pendingLongPressBlock = nil; + strongSelf->_longPressDetected = YES; + [strongSelf dispatchButtonEvent:RNGHButtonEventTypeLongPress withExtraData:strongSelf->_lastObservedExtraData]; + } + }); + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_longPressDuration * NSEC_PER_MSEC)), + dispatch_get_main_queue(), + _pendingLongPressBlock); +} + +- (void)cancelPendingLongPress +{ + if (_pendingLongPressBlock) { + dispatch_block_cancel(_pendingLongPressBlock); + _pendingLongPressBlock = nil; + } +} + #if !TARGET_OS_OSX && !TARGET_OS_TV - (void)handleHover:(UIHoverGestureRecognizer *)recognizer { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 2e40731482..0f3d1ad548 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -29,7 +29,7 @@ static RNGestureHandlerPointerEvents RCTPointerEventsToEnum(facebook::react::Poi } } -@interface RNGestureHandlerButtonComponentView () +@interface RNGestureHandlerButtonComponentView () @end @implementation RNGestureHandlerButtonComponentView { @@ -63,6 +63,7 @@ - (instancetype)initWithFrame:(CGRect)frame _moduleId = -1; _buttonView = [[RNGestureHandlerButton alloc] initWithFrame:self.bounds]; _buttonView.animationTarget = self; + _buttonView.pressEventDelegate = self; self.contentView = _buttonView; } @@ -253,6 +254,49 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask left:borderMetrics.borderWidths.left]; } +#pragma mark - RNGHButtonPressEventDelegate + +- (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHandlerEventExtraData *)extraData +{ + if (_eventEmitter == nullptr) { + return; + } + + const auto &eventEmitter = static_cast(*_eventEmitter); + NSDictionary *data = extraData.data; + + // The generated event structs are distinct types with identical fields, hence + // the generic lambda. + auto fillEvent = [&](auto event) { + event.pointerInside = [data[@"pointerInside"] boolValue]; + event.x = [data[@"x"] doubleValue]; + event.y = [data[@"y"] doubleValue]; + event.absoluteX = [data[@"absoluteX"] doubleValue]; + event.absoluteY = [data[@"absoluteY"] doubleValue]; + event.numberOfPointers = [data[@"numberOfPointers"] intValue]; + event.pointerType = [data[@"pointerType"] intValue]; + return event; + }; + + switch (type) { + case RNGHButtonEventTypePress: + eventEmitter.onPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnPress{})); + break; + case RNGHButtonEventTypePressIn: + eventEmitter.onPressIn(fillEvent(RNGestureHandlerButtonEventEmitter::OnPressIn{})); + break; + case RNGHButtonEventTypePressOut: + eventEmitter.onPressOut(fillEvent(RNGestureHandlerButtonEventEmitter::OnPressOut{})); + break; + case RNGHButtonEventTypeLongPress: + eventEmitter.onLongPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnLongPress{})); + break; + case RNGHButtonEventTypeInteractionFinished: + eventEmitter.onInteractionFinished(fillEvent(RNGestureHandlerButtonEventEmitter::OnInteractionFinished{})); + break; + } +} + #pragma mark - RCTComponentViewProtocol + (ComponentDescriptorProvider)componentDescriptorProvider @@ -371,6 +415,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _moduleId = newProps.moduleId; _buttonView.userEnabled = newProps.enabled; + _buttonView.hasLongPressHandler = newProps.hasLongPressHandler; _buttonView.tapAnimationInDuration = newProps.tapAnimationInDuration > 0 ? newProps.tapAnimationInDuration : 0; _buttonView.tapAnimationOutDuration = newProps.tapAnimationOutDuration > 0 ? newProps.tapAnimationOutDuration : 0; _buttonView.longPressDuration = newProps.longPressDuration; From 1c45168982564ed331998c91eb726d8b26e82eba Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 10:04:22 +0200 Subject: [PATCH 4/6] Attach native gesture directly to button on the native side on iOS --- .../apple/RNGestureHandlerActionType.h | 1 + .../RNGestureHandlerButtonComponentView.mm | 105 ++++++++++++++++++ .../apple/RNGestureHandlerManager.mm | 4 + 3 files changed, 110 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerActionType.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerActionType.h index 9a251d2754..655e8e58e1 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerActionType.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerActionType.h @@ -1,6 +1,7 @@ #import typedef NS_ENUM(NSInteger, RNGestureHandlerActionType) { + RNGestureHandlerActionTypeNone = 0, // Handler managed by a native component, doesn't dispatch events to JS RNGestureHandlerActionTypeReanimatedWorklet = 1, // Reanimated worklet RNGestureHandlerActionTypeNativeAnimatedEvent, // Animated.event with useNativeDriver: true RNGestureHandlerActionTypeJSFunctionOldAPI, // JS function or Animated.event with useNativeDriver: false using old diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 0f3d1ad548..8e7b347983 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -11,6 +11,7 @@ #import #import "RNGestureHandlerButton.h" +#import "RNGestureHandlerModule.h" using namespace facebook::react; @@ -36,6 +37,7 @@ @implementation RNGestureHandlerButtonComponentView { RNGestureHandlerButton *_buttonView; BOOL _needsAnimationStateReset; int _moduleId; + NSNumber *_managedHandlerTag; } #if TARGET_OS_OSX @@ -122,6 +124,8 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context - (void)prepareForRecycle { + [self dropManagedHandler]; + [self.layer removeAnimationForKey:@"transform"]; self.layer.transform = CATransform3DIdentity; @@ -254,6 +258,103 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask left:borderMetrics.borderWidths.left]; } +#pragma mark - Managed gesture handler + +- (NSDictionary *)buildManagedHandlerConfig:(const RNGestureHandlerButtonProps &)props +{ + NSMutableDictionary *config = [NSMutableDictionary new]; + config[@"shouldActivateOnStart"] = @NO; + config[@"disallowInterruption"] = @YES; + config[@"yieldsToContinuousGestures"] = @YES; + config[@"enabled"] = @(props.enabled); + config[@"shouldCancelWhenOutside"] = @(props.cancelOnLeave); + + if (!props.gestureTestID.empty()) { + config[@"testID"] = RCTNSStringFromString(props.gestureTestID); + } + + if (props.gestureHitSlop.top != 0 || props.gestureHitSlop.left != 0 || props.gestureHitSlop.bottom != 0 || + props.gestureHitSlop.right != 0) { + config[@"hitSlop"] = @{ + @"top" : @(props.gestureHitSlop.top), + @"left" : @(props.gestureHitSlop.left), + @"bottom" : @(props.gestureHitSlop.bottom), + @"right" : @(props.gestureHitSlop.right), + }; + } + + return config; +} + +- (void)updateManagedHandler:(const RNGestureHandlerButtonProps &)newProps + oldProps:(const RNGestureHandlerButtonProps *)oldProps +{ + if (newProps.handlerTag <= 0) { + [self dropManagedHandler]; + return; + } + + RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + react_native_assert(manager != nil && "Tried to access a non-existent handler manager"); + + BOOL tagChanged = _managedHandlerTag == nil || [_managedHandlerTag doubleValue] != newProps.handlerTag; + + if (tagChanged) { + [self dropManagedHandler]; + + NSNumber *handlerTag = @(newProps.handlerTag); + [manager createGestureHandler:@"NativeViewGestureHandler" + tag:handlerTag + config:[self buildManagedHandlerConfig:newProps]]; + + // Events dispatched by the handler carry the view's reactTag; without it the + // dispatch is skipped (see `handleGesture:fromReset:fromManualStateChange:`). + _buttonView.reactTag = @(self.tag); + // The cast is needed on macOS, where the button is an NSControl and outside + // of the RCTUIView hierarchy. + [manager attachHandlerForDetectorWithTag:handlerTag + toView:(RNGHUIView *)_buttonView + withActionType:RNGestureHandlerActionTypeNone + withHostDetector:nil]; + + _managedHandlerTag = handlerTag; + _buttonView.managedHandlerTag = handlerTag; + return; + } + + BOOL configChanged = oldProps == nullptr || oldProps->enabled != newProps.enabled || + oldProps->cancelOnLeave != newProps.cancelOnLeave || oldProps->gestureTestID != newProps.gestureTestID || + oldProps->gestureHitSlop.top != newProps.gestureHitSlop.top || + oldProps->gestureHitSlop.left != newProps.gestureHitSlop.left || + oldProps->gestureHitSlop.bottom != newProps.gestureHitSlop.bottom || + oldProps->gestureHitSlop.right != newProps.gestureHitSlop.right; + + if (configChanged) { + // `setConfig:` resets to defaults before applying, so keys omitted from the + // dictionary (e.g. `hitSlop`, `testID`) get cleared rather than kept. + [manager setGestureHandlerConfig:_managedHandlerTag config:[self buildManagedHandlerConfig:newProps]]; + } +} + +- (void)dropManagedHandler +{ + if (_managedHandlerTag == nil) { + return; + } + + RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + [manager dropGestureHandler:_managedHandlerTag]; + + _managedHandlerTag = nil; + _buttonView.managedHandlerTag = nil; +} + +- (void)dealloc +{ + // On macOS the buttons are not recycled, `prepareForRecycle` may never run. + [self dropManagedHandler]; +} + #pragma mark - RNGHButtonPressEventDelegate - (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHandlerEventExtraData *)extraData @@ -475,6 +576,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (shouldApplyStartAnimationState) { [_buttonView applyStartAnimationState]; } + + const auto *oldButtonPropsPtr = + treatAsFirstMount ? nullptr : std::static_pointer_cast(oldProps).get(); + [self updateManagedHandler:newProps oldProps:oldButtonPropsPtr]; } #if !TARGET_OS_OSX diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm index 84961e74be..ba06fea99f 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm @@ -443,6 +443,10 @@ - (void)sendEvent:(RNGestureHandlerStateChange *)event case RNGestureHandlerActionTypeJSFunctionNewAPI: [self sendEventForJSFunctionNewAPI:event]; break; + + case RNGestureHandlerActionTypeNone: + // Consumed on the native side (e.g. by the button), no events are sent to JS. + break; } } From 4da63c1abf12b10984df82b4ea19c7304140ed20 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 10:06:36 +0200 Subject: [PATCH 5/6] Use the native Touchable implementation on all native platforms --- .../src/__tests__/api_v3.test.tsx | 150 ++++------ .../src/__tests__/mocks.test.tsx | 26 +- .../Touchable/Touchable.android.tsx | 201 ------------- .../src/v3/components/Touchable/Touchable.tsx | 217 +++++--------- .../v3/components/Touchable/Touchable.web.tsx | 274 ++++++++++++++++++ 5 files changed, 422 insertions(+), 446 deletions(-) delete mode 100644 packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx create mode 100644 packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index d040132620..45f2d75bda 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -1,4 +1,9 @@ -import { render, renderHook } from '@testing-library/react-native'; +import { + fireEvent, + render, + renderHook, + screen, +} from '@testing-library/react-native'; import { act } from 'react'; import { Keyboard, View } from 'react-native'; @@ -13,13 +18,35 @@ import { import { GestureDetector } from '../v3/detectors'; import { useSimultaneousGestures } from '../v3/hooks'; import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; -import type { SingleGesture } from '../v3/types'; const flushImmediate = () => new Promise((resolve) => { setImmediate(() => resolve(undefined)); }); +// The Touchable press state machine runs on the native side — the JS layer +// receives the resulting press events directly from the button. +const buttonEvent = (pointerInside = true) => ({ + nativeEvent: { + pointerInside, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + numberOfPointers: 1, + pointerType: 0, + }, +}); + +// Mirrors the event sequence the native side dispatches for a successful tap. +const fireNativeTap = (testID: string) => { + const button = screen.getByTestId(testID); + fireEvent(button, 'pressIn', buttonEvent()); + fireEvent(button, 'pressOut', buttonEvent()); + fireEvent(button, 'press', buttonEvent()); + fireEvent(button, 'interactionFinished', buttonEvent()); +}; + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -384,15 +411,6 @@ describe('[API v3] Components', () => { keyboardShouldPersistTaps, }); - const fireTap = (testID: string) => - act(() => { - fireGestureHandler(getByGestureTestId(testID), [ - { state: State.BEGAN }, - { state: State.ACTIVE }, - { state: State.END }, - ]); - }); - test('isKeyboardDismissingTap is true only in never mode while the keyboard is visible', async () => { const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); @@ -458,18 +476,18 @@ describe('[API v3] Components', () => { await act(flushImmediate); showKeyboard(addListenerSpy); - // Includes a move (second ACTIVE) so the onUpdate path is exercised too. - act(() => { - fireGestureHandler(getByGestureTestId('touchable'), [ - { state: State.BEGAN }, - { state: State.ACTIVE }, - { state: State.ACTIVE }, - { state: State.END }, - ]); - }); + // Includes a re-entry PressIn (finger dragged out and back in) so the + // capture-once verdict path is exercised too. + const button = screen.getByTestId('touchable'); + fireEvent(button, 'pressIn', buttonEvent()); + fireEvent(button, 'pressOut', buttonEvent(false)); + fireEvent(button, 'pressIn', buttonEvent()); + fireEvent(button, 'pressOut', buttonEvent()); + fireEvent(button, 'press', buttonEvent()); + fireEvent(button, 'interactionFinished', buttonEvent()); // The whole interaction is swallowed - not just onPress, but the press-in/ - // out side effects too (incl. via onUpdate as the finger moves). + // out side effects too (incl. the re-entry as the finger moves). expect(onPress).not.toHaveBeenCalled(); expect(onPressIn).not.toHaveBeenCalled(); expect(onPressOut).not.toHaveBeenCalled(); @@ -488,7 +506,7 @@ describe('[API v3] Components', () => { ); await act(flushImmediate); - fireTap('touchable'); + fireNativeTap('touchable'); expect(onPress).toHaveBeenCalledTimes(1); }); @@ -509,7 +527,7 @@ describe('[API v3] Components', () => { await act(flushImmediate); showKeyboard(addListenerSpy); - fireTap('touchable'); + fireNativeTap('touchable'); expect(onPress).toHaveBeenCalledTimes(1); addListenerSpy.mockRestore(); @@ -528,15 +546,7 @@ describe('[API v3] Components', () => { ); render(); - const gesture = getByGestureTestId('touchable'); - - act(() => { - fireGestureHandler(gesture, [ - { oldState: State.UNDETERMINED, state: State.BEGAN }, - { oldState: State.BEGAN, state: State.ACTIVE }, - { oldState: State.ACTIVE, state: State.END }, - ]); - }); + fireNativeTap('touchable'); expect(pressFn).toHaveBeenCalledTimes(1); }); @@ -551,22 +561,17 @@ describe('[API v3] Components', () => { ); render(); - const gesture = getByGestureTestId('touchable'); - act(() => { - fireGestureHandler(gesture, [ - { oldState: State.UNDETERMINED, state: State.BEGAN }, - { oldState: State.BEGAN, state: State.ACTIVE }, - { oldState: State.ACTIVE, state: State.FAILED }, - ]); - }); + // A cancelled interaction ends without a Press event on the native side. + const button = screen.getByTestId('touchable'); + fireEvent(button, 'pressIn', buttonEvent()); + fireEvent(button, 'pressOut', buttonEvent(false)); + fireEvent(button, 'interactionFinished', buttonEvent(false)); expect(pressFn).not.toHaveBeenCalled(); }); - test('calls onLongPress after delayLongPress and suppresses onPress', () => { - jest.useFakeTimers(); - + test('forwards onLongPress and requests the native long-press timer', () => { const pressFn = jest.fn(); const longPressFn = jest.fn(); const DELAY = 800; @@ -584,59 +589,20 @@ describe('[API v3] Components', () => { render(); - const gesture = getByGestureTestId('touchable') as SingleGesture< - any, - any, - any - >; - const { jsEventHandler } = gesture.detectorCallbacks; + // The long-press timer runs on the native side; passing a callback flips + // the props that arm it. + const button = screen.getByTestId('touchable'); + expect(button.props.hasLongPressHandler).toBe(true); + expect(button.props.longPressDuration).toBe(DELAY); - // Fire BEGAN — long press timer starts here - act(() => { - jsEventHandler?.({ - oldState: State.UNDETERMINED, - state: State.BEGAN, - handlerTag: gesture.handlerTag, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handlerData: { pointerInside: true, numberOfPointers: 1 } as any, - }); - }); - - // Fire ACTIVE - act(() => { - jsEventHandler?.({ - oldState: State.BEGAN, - state: State.ACTIVE, - handlerTag: gesture.handlerTag, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handlerData: { pointerInside: true, numberOfPointers: 1 } as any, - }); - }); - - expect(longPressFn).not.toHaveBeenCalled(); - - // Advance fake timers past delayLongPress - act(() => { - jest.advanceTimersByTime(DELAY); - }); + // A long press ends without a Press event on the native side. + fireEvent(button, 'pressIn', buttonEvent()); + fireEvent(button, 'longPress', buttonEvent()); + fireEvent(button, 'pressOut', buttonEvent()); + fireEvent(button, 'interactionFinished', buttonEvent()); expect(longPressFn).toHaveBeenCalledTimes(1); expect(pressFn).not.toHaveBeenCalled(); - - // Fire END — onPress should be suppressed because long press was detected - act(() => { - jsEventHandler?.({ - oldState: State.ACTIVE, - state: State.END, - handlerTag: gesture.handlerTag, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handlerData: { pointerInside: true, numberOfPointers: 1 } as any, - }); - }); - - expect(pressFn).not.toHaveBeenCalled(); - - jest.useRealTimers(); }); }); }); diff --git a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx index e20766cea6..efad6f0f4f 100644 --- a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx @@ -1,9 +1,8 @@ -import { render } from '@testing-library/react-native'; -import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; import { Text } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; -import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { LegacyBaseButton, LegacyBorderlessButton, @@ -25,7 +24,6 @@ import { TouchableOpacity, TouchableWithoutFeedback, } from '../mocks/Touchables'; -import { State } from '../State'; import { Touchable } from '../v3/components'; describe('Jest mocks – legacy components render without crashing', () => { @@ -128,14 +126,18 @@ test('Trigger Touchable press', () => { ); - const gesture = getByGestureTestId('touchable'); - - act(() => { - fireGestureHandler(gesture, [ - { oldState: State.UNDETERMINED, state: State.BEGAN }, - { oldState: State.BEGAN, state: State.ACTIVE }, - { oldState: State.ACTIVE, state: State.END }, - ]); + // The press state machine runs on the native side — the JS layer receives + // the resulting press events directly from the button. + fireEvent(screen.getByTestId('touchable'), 'press', { + nativeEvent: { + pointerInside: true, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + numberOfPointers: 1, + pointerType: 0, + }, }); expect(onPress).toHaveBeenCalled(); diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx deleted file mode 100644 index 8c18cd9bb7..0000000000 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React, { use, useCallback, useRef, useState } from 'react'; -import type { NativeSyntheticEvent } from 'react-native'; -import { Platform } from 'react-native'; - -import GestureHandlerButton, { - type ButtonEvent, -} from '../../../components/GestureHandlerButton'; -import { getTVProps } from '../../../components/utils'; -import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; -import { - isKeyboardDismissingTap, - JSResponderContext, -} from '../ScrollViewResponderInterceptor'; -import type { AnimationDuration, TouchableProps } from './TouchableProps'; - -const isAndroid = Platform.OS === 'android'; -const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; -const DEFAULT_IN_DURATION_MS = 50; -const DEFAULT_OUT_DURATION_MS = 100; - -// Clamp user-supplied durations to finite, non-negative milliseconds. -// Negative, NaN, or Infinity values would produce invalid CSS transitions -// on web and negative setTimeout delays in branch 3 of the press-out path. -function sanitizeDuration(value: number): number { - return Number.isFinite(value) && value >= 0 ? value : 0; -} - -function resolveAnimationDuration(value: AnimationDuration | undefined) { - if (value === undefined) { - return { - tapAnimationInDuration: DEFAULT_IN_DURATION_MS, - tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - hoverAnimationInDuration: DEFAULT_IN_DURATION_MS, - hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - }; - } - - if (typeof value === 'number') { - const sanitized = sanitizeDuration(value); - return { - tapAnimationInDuration: sanitized, - tapAnimationOutDuration: sanitized, - longPressAnimationOutDuration: sanitized, - hoverAnimationInDuration: sanitized, - hoverAnimationOutDuration: sanitized, - }; - } - - // The union guarantees variant 2 supplies top-level `in`/`out`, variant 3 - // supplies both category objects — so per-category fallback to base is - // always defined for well-typed input; the 0 fallbacks here are unreachable. - const baseIn = 'in' in value ? value.in : 0; - const baseOut = 'out' in value ? value.out : 0; - const tapOut = value.tap?.out ?? baseOut; - - return { - tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn), - tapAnimationOutDuration: sanitizeDuration(tapOut), - longPressAnimationOutDuration: sanitizeDuration( - value.longPress?.out ?? tapOut - ), - hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn), - hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut), - }; -} - -export const Touchable = (props: TouchableProps) => { - const { - underlayColor = 'transparent', - defaultUnderlayOpacity = 0, - activeUnderlayOpacity = 0.105, - defaultOpacity = 1, - animationDuration, - androidRipple, - delayLongPress = 600, - onLongPress, - onPress, - onPressIn, - onPressOut, - children, - disabled = false, - cancelOnLeave = true, - ref, - ...rest - } = props; - const [handlerTag] = useState(() => getNextHandlerTag()); - - const resolvedDurations = resolveAnimationDuration(animationDuration); - const resolvedDelayLongPress = sanitizeDuration(delayLongPress); - - const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; - - const jsResponderContext = use(JSResponderContext); - const dropKeyboardTapRef = useRef(null); - - const internalOnPress = useCallback( - (e: NativeSyntheticEvent) => { - if (dropKeyboardTapRef.current) { - return; - } - - onPress?.(e.nativeEvent); - }, - [onPress] - ); - - const internalOnPressIn = useCallback( - (e: NativeSyntheticEvent) => { - // PressIn opens every press sequence; capture the verdict once per - // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't - // overwrite it after the keyboard is already dismissed. - dropKeyboardTapRef.current ??= - isKeyboardDismissingTap(jsResponderContext); - - if (dropKeyboardTapRef.current) { - return; - } - - onPressIn?.(e.nativeEvent); - }, - [jsResponderContext, onPressIn] - ); - - const internalOnPressOut = useCallback( - (e: NativeSyntheticEvent) => { - if (dropKeyboardTapRef.current) { - return; - } - - onPressOut?.(e.nativeEvent); - }, - [onPressOut] - ); - - const internalOnLongPress = useCallback( - (e: NativeSyntheticEvent) => { - if (dropKeyboardTapRef.current) { - return; - } - - onLongPress?.(e.nativeEvent); - }, - [onLongPress] - ); - - // InteractionFinished is dispatched after the terminal PressOut/Press - // events, so resetting synchronously here is safe. - const internalOnInteractionFinished = useCallback(() => { - dropKeyboardTapRef.current = null; - }, []); - - const rippleProps = shouldUseNativeRipple - ? { - rippleColor: androidRipple?.color, - rippleRadius: androidRipple?.radius, - borderless: androidRipple?.borderless, - foreground: androidRipple?.foreground, - } - : TRANSPARENT_RIPPLE; - - const tvProps = getTVProps(rest); - - const hitSlop = - typeof props.hitSlop === 'number' || props.hitSlop == null - ? { - top: props.hitSlop ?? 0, - left: props.hitSlop ?? 0, - bottom: props.hitSlop ?? 0, - right: props.hitSlop ?? 0, - } - : props.hitSlop; - - return ( - - {children} - - ); -}; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 836aba7b4f..8c18cd9bb7 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -1,32 +1,23 @@ -import React, { use, useCallback, useRef } from 'react'; +import React, { use, useCallback, useRef, useState } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; import { Platform } from 'react-native'; -import GestureHandlerButton from '../../../components/GestureHandlerButton'; +import GestureHandlerButton, { + type ButtonEvent, +} from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; -import { NativeDetector } from '../../detectors/NativeDetector'; -import { useNativeGesture } from '../../hooks'; +import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; import { isKeyboardDismissingTap, JSResponderContext, } from '../ScrollViewResponderInterceptor'; -import type { - AnimationDuration, - CallbackEventType, - EndCallbackEventType, - TouchableProps, -} from './TouchableProps'; +import type { AnimationDuration, TouchableProps } from './TouchableProps'; const isAndroid = Platform.OS === 'android'; const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; const DEFAULT_IN_DURATION_MS = 50; const DEFAULT_OUT_DURATION_MS = 100; -enum PointerState { - UNKNOWN, - INSIDE, - OUTSIDE, -} - // Clamp user-supplied durations to finite, non-negative milliseconds. // Negative, NaN, or Infinity values would produce invalid CSS transitions // on web and negative setTimeout delays in branch 3 of the press-out path. @@ -93,146 +84,71 @@ export const Touchable = (props: TouchableProps) => { ref, ...rest } = props; + const [handlerTag] = useState(() => getNextHandlerTag()); const resolvedDurations = resolveAnimationDuration(animationDuration); const resolvedDelayLongPress = sanitizeDuration(delayLongPress); const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; - const pointerState = useRef(PointerState.UNKNOWN); - const longPressDetected = useRef(false); - const longPressTimeout = useRef | undefined>( - undefined - ); - - // Swallow the tap that dismisses the keyboard in - // keyboardShouldPersistTaps="never", matching RN's touchables. const jsResponderContext = use(JSResponderContext); const dropKeyboardTapRef = useRef(null); - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); - - const resetKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current = null; - }, []); - - const wrappedLongPress = useCallback(() => { - longPressDetected.current = true; - // @ts-ignore - the detector-based implementation is missing the event argument - onLongPress?.(); - }, [onLongPress]); - - const startLongPressTimer = useCallback(() => { - longPressDetected.current = false; - - if (onLongPress && !longPressTimeout.current) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - resolvedDelayLongPress - ); - } - }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); - - const onBegin = useCallback( - (e: CallbackEventType) => { - captureKeyboardDismiss(); - - if (!e.pointerInside || dropKeyboardTapRef.current) { - pointerState.current = PointerState.OUTSIDE; + const internalOnPress = useCallback( + (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { return; } - onPressIn?.(e); - startLongPressTimer(); - - pointerState.current = PointerState.INSIDE; + onPress?.(e.nativeEvent); }, - [captureKeyboardDismiss, startLongPressTimer, onPressIn] + [onPress] ); - const onActivate = useCallback((e: CallbackEventType) => { - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, []); - - const onFinalize = useCallback( - (e: EndCallbackEventType) => { - if ( - !dropKeyboardTapRef.current && - pointerState.current === PointerState.INSIDE - ) { - onPressOut?.(e); - } - - if ( - !dropKeyboardTapRef.current && - !e.canceled && - !longPressDetected.current && - e.pointerInside - ) { - onPress?.(e); - } - - pointerState.current = PointerState.UNKNOWN; + const internalOnPressIn = useCallback( + (e: NativeSyntheticEvent) => { + // PressIn opens every press sequence; capture the verdict once per + // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't + // overwrite it after the keyboard is already dismissed. + dropKeyboardTapRef.current ??= + isKeyboardDismissingTap(jsResponderContext); - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; + if (dropKeyboardTapRef.current) { + return; } - resetKeyboardDismiss(); + onPressIn?.(e.nativeEvent); }, - [resetKeyboardDismiss, onPressOut, onPress] + [jsResponderContext, onPressIn] ); - const onUpdate = useCallback( - (e: CallbackEventType) => { + const internalOnPressOut = useCallback( + (e: NativeSyntheticEvent) => { if (dropKeyboardTapRef.current) { return; } - if (pointerState.current === PointerState.UNKNOWN) { + onPressOut?.(e.nativeEvent); + }, + [onPressOut] + ); + + const internalOnLongPress = useCallback( + (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { return; } - if (e.pointerInside) { - if (pointerState.current === PointerState.OUTSIDE) { - onPressIn?.(e); - } - pointerState.current = PointerState.INSIDE; - } else { - if (pointerState.current === PointerState.INSIDE) { - onPressOut?.(e); - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - } - pointerState.current = PointerState.OUTSIDE; - } + onLongPress?.(e.nativeEvent); }, - [onPressIn, onPressOut] + [onLongPress] ); - const nativeGesture = useNativeGesture({ - onBegin, - onActivate, - onFinalize, - onUpdate, - hitSlop: props.hitSlop, - testID: props.testID, - enabled: !disabled, - shouldCancelWhenOutside: cancelOnLeave, - disableReanimated: true, - shouldActivateOnStart: false, - disallowInterruption: true, - yieldsToContinuousGestures: true, - }); + // InteractionFinished is dispatched after the terminal PressOut/Press + // events, so resetting synchronously here is safe. + const internalOnInteractionFinished = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); const rippleProps = shouldUseNativeRipple ? { @@ -245,22 +161,41 @@ export const Touchable = (props: TouchableProps) => { const tvProps = getTVProps(rest); + const hitSlop = + typeof props.hitSlop === 'number' || props.hitSlop == null + ? { + top: props.hitSlop ?? 0, + left: props.hitSlop ?? 0, + bottom: props.hitSlop ?? 0, + right: props.hitSlop ?? 0, + } + : props.hitSlop; + return ( - - - {children} - - + + {children} + ); }; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx new file mode 100644 index 0000000000..3552eb6b46 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx @@ -0,0 +1,274 @@ +import React, { use, useCallback, useRef } from 'react'; +import { Platform } from 'react-native'; + +import GestureHandlerButton from '../../../components/GestureHandlerButton'; +import { getTVProps } from '../../../components/utils'; +import { NativeDetector } from '../../detectors/NativeDetector'; +import { useNativeGesture } from '../../hooks'; +import { + isKeyboardDismissingTap, + JSResponderContext, +} from '../ScrollViewResponderInterceptor'; +import type { + AnimationDuration, + CallbackEventType, + EndCallbackEventType, + TouchableProps, +} from './TouchableProps'; + +const isAndroid = Platform.OS === 'android'; +const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; +const DEFAULT_IN_DURATION_MS = 50; +const DEFAULT_OUT_DURATION_MS = 100; + +enum PointerState { + UNKNOWN, + INSIDE, + OUTSIDE, +} + +// Clamp user-supplied durations to finite, non-negative milliseconds. +// Negative, NaN, or Infinity values would produce invalid CSS transitions +// on web and negative setTimeout delays in branch 3 of the press-out path. +function sanitizeDuration(value: number): number { + return Number.isFinite(value) && value >= 0 ? value : 0; +} + +function resolveAnimationDuration(value: AnimationDuration | undefined) { + if (value === undefined) { + return { + tapAnimationInDuration: DEFAULT_IN_DURATION_MS, + tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + hoverAnimationInDuration: DEFAULT_IN_DURATION_MS, + hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + }; + } + + if (typeof value === 'number') { + const sanitized = sanitizeDuration(value); + return { + tapAnimationInDuration: sanitized, + tapAnimationOutDuration: sanitized, + longPressAnimationOutDuration: sanitized, + hoverAnimationInDuration: sanitized, + hoverAnimationOutDuration: sanitized, + }; + } + + // The union guarantees variant 2 supplies top-level `in`/`out`, variant 3 + // supplies both category objects — so per-category fallback to base is + // always defined for well-typed input; the 0 fallbacks here are unreachable. + const baseIn = 'in' in value ? value.in : 0; + const baseOut = 'out' in value ? value.out : 0; + const tapOut = value.tap?.out ?? baseOut; + + return { + tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn), + tapAnimationOutDuration: sanitizeDuration(tapOut), + longPressAnimationOutDuration: sanitizeDuration( + value.longPress?.out ?? tapOut + ), + hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn), + hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut), + }; +} + +export const Touchable = (props: TouchableProps) => { + const { + underlayColor = 'transparent', + defaultUnderlayOpacity = 0, + activeUnderlayOpacity = 0.105, + defaultOpacity = 1, + animationDuration, + androidRipple, + delayLongPress = 600, + onLongPress, + onPress, + onPressIn, + onPressOut, + children, + disabled = false, + cancelOnLeave = true, + ref, + ...rest + } = props; + + const resolvedDurations = resolveAnimationDuration(animationDuration); + const resolvedDelayLongPress = sanitizeDuration(delayLongPress); + + const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + + const pointerState = useRef(PointerState.UNKNOWN); + const longPressDetected = useRef(false); + const longPressTimeout = useRef | undefined>( + undefined + ); + // The last event observed by the gesture, used as the payload of the + // long-press callback since it fires from a timer rather than an event. + const lastEvent = useRef(null); + + // Swallow the tap that dismisses the keyboard in + // keyboardShouldPersistTaps="never", matching RN's touchables. + const jsResponderContext = use(JSResponderContext); + const dropKeyboardTapRef = useRef(null); + + const captureKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); + }, [jsResponderContext]); + + const resetKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); + + const wrappedLongPress = useCallback(() => { + longPressDetected.current = true; + if (lastEvent.current) { + onLongPress?.(lastEvent.current); + } + }, [onLongPress]); + + const startLongPressTimer = useCallback(() => { + longPressDetected.current = false; + + if (onLongPress && !longPressTimeout.current) { + longPressTimeout.current = setTimeout( + wrappedLongPress, + resolvedDelayLongPress + ); + } + }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); + + const onBegin = useCallback( + (e: CallbackEventType) => { + captureKeyboardDismiss(); + lastEvent.current = e; + + if (!e.pointerInside || dropKeyboardTapRef.current) { + pointerState.current = PointerState.OUTSIDE; + return; + } + + onPressIn?.(e); + startLongPressTimer(); + + pointerState.current = PointerState.INSIDE; + }, + [captureKeyboardDismiss, startLongPressTimer, onPressIn] + ); + + const onActivate = useCallback((e: CallbackEventType) => { + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, []); + + const onFinalize = useCallback( + (e: EndCallbackEventType) => { + if ( + !dropKeyboardTapRef.current && + pointerState.current === PointerState.INSIDE + ) { + onPressOut?.(e); + } + + if ( + !dropKeyboardTapRef.current && + !e.canceled && + !longPressDetected.current && + e.pointerInside + ) { + onPress?.(e); + } + + pointerState.current = PointerState.UNKNOWN; + lastEvent.current = null; + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + + resetKeyboardDismiss(); + }, + [resetKeyboardDismiss, onPressOut, onPress] + ); + + const onUpdate = useCallback( + (e: CallbackEventType) => { + lastEvent.current = e; + + if (dropKeyboardTapRef.current) { + return; + } + + if (pointerState.current === PointerState.UNKNOWN) { + return; + } + + if (e.pointerInside) { + if (pointerState.current === PointerState.OUTSIDE) { + onPressIn?.(e); + } + pointerState.current = PointerState.INSIDE; + } else { + if (pointerState.current === PointerState.INSIDE) { + onPressOut?.(e); + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + } + pointerState.current = PointerState.OUTSIDE; + } + }, + [onPressIn, onPressOut] + ); + + const nativeGesture = useNativeGesture({ + onBegin, + onActivate, + onFinalize, + onUpdate, + hitSlop: props.hitSlop, + testID: props.testID, + enabled: !disabled, + shouldCancelWhenOutside: cancelOnLeave, + disableReanimated: true, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + }); + + const rippleProps = shouldUseNativeRipple + ? { + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, + } + : TRANSPARENT_RIPPLE; + + const tvProps = getTVProps(rest); + + return ( + + + {children} + + + ); +}; From 71d694dc962d3bb9455f7ddcc29bbcfa66943810 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 10:14:10 +0200 Subject: [PATCH 6/6] Namespace button press events to avoid colliding with core `topPress` --- .../events/RNGestureHandlerButtonEvent.kt | 10 +++--- .../RNGestureHandlerButtonComponentView.mm | 11 +++--- .../src/__tests__/api_v3.test.tsx | 34 +++++++++---------- .../src/__tests__/mocks.test.tsx | 2 +- .../src/components/GestureHandlerButton.tsx | 18 ++++++---- .../RNGestureHandlerButtonNativeComponent.ts | 13 ++++--- .../src/v3/components/GestureButtonsProps.ts | 10 +++--- .../src/v3/components/Touchable/Touchable.tsx | 10 +++--- .../v3/components/Touchable/TouchableProps.ts | 13 +++---- 9 files changed, 66 insertions(+), 55 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt index eef1530a48..802ff63a16 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt @@ -73,11 +73,11 @@ class RNGestureHandlerButtonEvent private constructor() : Event(TOUCH_EVENTS_POOL_SIZE) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 8e7b347983..f816682b78 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -381,19 +381,20 @@ - (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHa switch (type) { case RNGHButtonEventTypePress: - eventEmitter.onPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnPress{})); + eventEmitter.onButtonPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnButtonPress{})); break; case RNGHButtonEventTypePressIn: - eventEmitter.onPressIn(fillEvent(RNGestureHandlerButtonEventEmitter::OnPressIn{})); + eventEmitter.onButtonPressIn(fillEvent(RNGestureHandlerButtonEventEmitter::OnButtonPressIn{})); break; case RNGHButtonEventTypePressOut: - eventEmitter.onPressOut(fillEvent(RNGestureHandlerButtonEventEmitter::OnPressOut{})); + eventEmitter.onButtonPressOut(fillEvent(RNGestureHandlerButtonEventEmitter::OnButtonPressOut{})); break; case RNGHButtonEventTypeLongPress: - eventEmitter.onLongPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnLongPress{})); + eventEmitter.onButtonLongPress(fillEvent(RNGestureHandlerButtonEventEmitter::OnButtonLongPress{})); break; case RNGHButtonEventTypeInteractionFinished: - eventEmitter.onInteractionFinished(fillEvent(RNGestureHandlerButtonEventEmitter::OnInteractionFinished{})); + eventEmitter.onButtonInteractionFinished( + fillEvent(RNGestureHandlerButtonEventEmitter::OnButtonInteractionFinished{})); break; } } diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 45f2d75bda..b6a78429a1 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -41,10 +41,10 @@ const buttonEvent = (pointerInside = true) => ({ // Mirrors the event sequence the native side dispatches for a successful tap. const fireNativeTap = (testID: string) => { const button = screen.getByTestId(testID); - fireEvent(button, 'pressIn', buttonEvent()); - fireEvent(button, 'pressOut', buttonEvent()); - fireEvent(button, 'press', buttonEvent()); - fireEvent(button, 'interactionFinished', buttonEvent()); + fireEvent(button, 'buttonPressIn', buttonEvent()); + fireEvent(button, 'buttonPressOut', buttonEvent()); + fireEvent(button, 'buttonPress', buttonEvent()); + fireEvent(button, 'buttonInteractionFinished', buttonEvent()); }; describe('[API v3] Hooks', () => { @@ -479,12 +479,12 @@ describe('[API v3] Components', () => { // Includes a re-entry PressIn (finger dragged out and back in) so the // capture-once verdict path is exercised too. const button = screen.getByTestId('touchable'); - fireEvent(button, 'pressIn', buttonEvent()); - fireEvent(button, 'pressOut', buttonEvent(false)); - fireEvent(button, 'pressIn', buttonEvent()); - fireEvent(button, 'pressOut', buttonEvent()); - fireEvent(button, 'press', buttonEvent()); - fireEvent(button, 'interactionFinished', buttonEvent()); + fireEvent(button, 'buttonPressIn', buttonEvent()); + fireEvent(button, 'buttonPressOut', buttonEvent(false)); + fireEvent(button, 'buttonPressIn', buttonEvent()); + fireEvent(button, 'buttonPressOut', buttonEvent()); + fireEvent(button, 'buttonPress', buttonEvent()); + fireEvent(button, 'buttonInteractionFinished', buttonEvent()); // The whole interaction is swallowed - not just onPress, but the press-in/ // out side effects too (incl. the re-entry as the finger moves). @@ -564,9 +564,9 @@ describe('[API v3] Components', () => { // A cancelled interaction ends without a Press event on the native side. const button = screen.getByTestId('touchable'); - fireEvent(button, 'pressIn', buttonEvent()); - fireEvent(button, 'pressOut', buttonEvent(false)); - fireEvent(button, 'interactionFinished', buttonEvent(false)); + fireEvent(button, 'buttonPressIn', buttonEvent()); + fireEvent(button, 'buttonPressOut', buttonEvent(false)); + fireEvent(button, 'buttonInteractionFinished', buttonEvent(false)); expect(pressFn).not.toHaveBeenCalled(); }); @@ -596,10 +596,10 @@ describe('[API v3] Components', () => { expect(button.props.longPressDuration).toBe(DELAY); // A long press ends without a Press event on the native side. - fireEvent(button, 'pressIn', buttonEvent()); - fireEvent(button, 'longPress', buttonEvent()); - fireEvent(button, 'pressOut', buttonEvent()); - fireEvent(button, 'interactionFinished', buttonEvent()); + fireEvent(button, 'buttonPressIn', buttonEvent()); + fireEvent(button, 'buttonLongPress', buttonEvent()); + fireEvent(button, 'buttonPressOut', buttonEvent()); + fireEvent(button, 'buttonInteractionFinished', buttonEvent()); expect(longPressFn).toHaveBeenCalledTimes(1); expect(pressFn).not.toHaveBeenCalled(); diff --git a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx index efad6f0f4f..b226005b10 100644 --- a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx @@ -128,7 +128,7 @@ test('Trigger Touchable press', () => { // The press state machine runs on the native side — the JS layer receives // the resulting press events directly from the button. - fireEvent(screen.getByTestId('touchable'), 'press', { + fireEvent(screen.getByTestId('touchable'), 'buttonPress', { nativeEvent: { pointerInside: true, x: 0, diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index ccd3bafb28..6b9bd38c69 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -44,30 +44,36 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { /** * Called when the button gets pressed. */ - onPress?: ((event: NativeSyntheticEvent) => void) | undefined; + onButtonPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; /** * Called when the pointer touches the button. */ - onPressIn?: ((event: NativeSyntheticEvent) => void) | undefined; + onButtonPressIn?: + | ((event: NativeSyntheticEvent) => void) + | undefined; /** * Called when the pointer is released or leaves the button. */ - onPressOut?: ((event: NativeSyntheticEvent) => void) | undefined; + onButtonPressOut?: + | ((event: NativeSyntheticEvent) => void) + | undefined; /** * Called when the button gets pressed and held past `longPressDuration`. */ - onLongPress?: + onButtonLongPress?: | ((event: NativeSyntheticEvent) => void) | undefined; /** * Called when the interaction with the button ends, after any terminal - * `onPressOut`/`onPress` events, regardless of how it ended. + * `onButtonPressOut`/`onButtonPress` events, regardless of how it ended. */ - onInteractionFinished?: + onButtonInteractionFinished?: | ((event: NativeSyntheticEvent) => void) | undefined; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index f6308a4788..e29f2e6a77 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -18,11 +18,14 @@ export type ButtonEvent = Readonly<{ // @ts-ignore - Redefining pointerEvents with WithDefault for codegen, conflicts with ViewProps type but codegen needs it interface NativeProps extends ViewProps { - onPress?: CodegenTypes.DirectEventHandler | undefined; - onPressIn?: CodegenTypes.DirectEventHandler | undefined; - onPressOut?: CodegenTypes.DirectEventHandler | undefined; - onLongPress?: CodegenTypes.DirectEventHandler | undefined; - onInteractionFinished?: + // The events are namespaced with `Button` because the base view config on iOS + // already registers `topPress` as a bubbling event — a direct event with the + // same top-level name would fail view config validation in dev. + onButtonPress?: CodegenTypes.DirectEventHandler | undefined; + onButtonPressIn?: CodegenTypes.DirectEventHandler | undefined; + onButtonPressOut?: CodegenTypes.DirectEventHandler | undefined; + onButtonLongPress?: CodegenTypes.DirectEventHandler | undefined; + onButtonInteractionFinished?: | CodegenTypes.DirectEventHandler | undefined; diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts index ca86f6d01d..1da85ab73a 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -13,11 +13,11 @@ export interface RawButtonProps // The native press events are omitted — the deprecated buttons drive // their press callbacks from the gesture in JS and redeclare them with // their own signatures. - | 'onPress' - | 'onPressIn' - | 'onPressOut' - | 'onLongPress' - | 'onInteractionFinished' + | 'onButtonPress' + | 'onButtonPressIn' + | 'onButtonPressOut' + | 'onButtonLongPress' + | 'onButtonInteractionFinished' | 'defaultOpacity' | 'defaultScale' | 'defaultUnderlayOpacity' diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 8c18cd9bb7..9abbcadaac 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -190,11 +190,11 @@ export const Touchable = (props: TouchableProps) => { underlayColor={underlayColor} longPressDuration={resolvedDelayLongPress} hasLongPressHandler={onLongPress !== undefined} - onPress={internalOnPress} - onPressIn={internalOnPressIn} - onPressOut={internalOnPressOut} - onLongPress={internalOnLongPress} - onInteractionFinished={internalOnInteractionFinished}> + onButtonPress={internalOnPress} + onButtonPressIn={internalOnPressIn} + onButtonPressOut={internalOnPressOut} + onButtonLongPress={internalOnLongPress} + onButtonInteractionFinished={internalOnInteractionFinished}> {children} ); diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts index 5da2a21c41..df5b1d15d6 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts @@ -21,13 +21,14 @@ type PressableAndroidRippleConfig = { type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; // The press events are redeclared below with the unwrapped `ButtonEvent` -// signature; `onInteractionFinished` is consumed internally by `Touchable`. +// signature; `onButtonInteractionFinished` is consumed internally by +// `Touchable`. type PressProps = - | 'onPress' - | 'onPressIn' - | 'onPressOut' - | 'onLongPress' - | 'onInteractionFinished'; + | 'onButtonPress' + | 'onButtonPressIn' + | 'onButtonPressOut' + | 'onButtonLongPress' + | 'onButtonInteractionFinished'; type DurationProps = | 'tapAnimationInDuration'