From 6d7c6c5d3e46d4149fcf18013e5e41b06a47c798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 6 Oct 2026 16:57:29 +0200 Subject: [PATCH 1/4] [iOS] Implement on Touchable --- .../apple/RNGestureHandlerButton.h | 1 + .../apple/RNGestureHandlerButton.mm | 73 ++++++++++++++++++- .../RNGestureHandlerButtonComponentView.mm | 1 + 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index cd54495d52..16ce838206 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -51,6 +51,7 @@ typedef NS_ENUM(NSInteger, RNGHButtonEventType) { @property (nonatomic, assign) NSInteger tapAnimationInDuration; @property (nonatomic, assign) NSInteger tapAnimationOutDuration; @property (nonatomic, assign) NSInteger longPressDuration; +@property (nonatomic, assign) BOOL pressOutAfterAnimation; @property (nonatomic, assign) NSInteger longPressAnimationOutDuration; @property (nonatomic, assign) CGFloat activeOpacity; @property (nonatomic, assign) CGFloat defaultOpacity; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 994b3d38f1..f39a31c573 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -51,6 +51,11 @@ @implementation RNGestureHandlerButton { UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset NSTimeInterval _pressInTimestamp; dispatch_block_t _pendingPressOutBlock; + // `onPressOut` held back by `pressOutAfterAnimation` (the press-out animation has its own block above). + dispatch_block_t _pendingPressOutEventBlock; + // Whether UIKit deferred the press: an enclosing scroll view with `delaysContentTouches` holds + // the touch until it rules out a scroll (or delivers begin and end together on a quick tap). + BOOL _pressDeferredByPlatform; BOOL _isHovered; BOOL _isPressed; dispatch_block_t _pendingHoverOutBlock; @@ -100,6 +105,8 @@ - (void)commonInit _tapAnimationInDuration = 50; _tapAnimationOutDuration = 100; _longPressDuration = -1; + _pressOutAfterAnimation = NO; + _pressDeferredByPlatform = NO; _longPressAnimationOutDuration = -1; _activeOpacity = 1.0; _defaultOpacity = 1.0; @@ -188,6 +195,8 @@ - (void)prepareForRecycle [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; + [self cancelPendingPressOutEvent]; + _pressDeferredByPlatform = NO; _lastEventWasInside = NO; _longPressDetected = NO; _lastObservedExtraData = nil; @@ -681,7 +690,7 @@ - (void)onHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData if (pointerInside) { [self dispatchButtonEvent:RNGHButtonEventTypePressIn withExtraData:extraData]; } else { - [self dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + [self dispatchPressOutWithExtraData:extraData]; [self cancelPendingLongPress]; } } @@ -703,6 +712,7 @@ - (void)onHandlerStateChange:(RNGestureHandlerState)newState newState == RNGestureHandlerStateCancelled; if (newState == RNGestureHandlerStateBegan) { + [self flushPendingPressOutEvent]; [self dispatchButtonEvent:RNGHButtonEventTypePressIn withExtraData:extraData]; _longPressDetected = NO; @@ -714,7 +724,7 @@ - (void)onHandlerStateChange:(RNGestureHandlerState)newState if (isFinished) { if (localLastEventWasInside) { - [self dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + [self dispatchPressOutWithExtraData:extraData]; } [self cancelPendingLongPress]; @@ -729,6 +739,64 @@ - (void)onHandlerStateChange:(RNGestureHandlerState)newState } } +- (BOOL)isInsideDelayingScrollView +{ + for (UIView *view = self.superview; view != nil; view = view.superview) { + if ([view isKindOfClass:[UIScrollView class]] && ((UIScrollView *)view).delaysContentTouches) { + return YES; + } + } + return NO; +} + +// With `pressOutAfterAnimation`, a press UIKit deferred (`delaysContentTouches` in a scroll view) +// holds `onPressOut` until the press-in animation has played, like `handleAnimatePressOut` holds +// the animation. An immediate press keeps its native timing. `onPress` is never delayed, so on +// such taps `onPressOut` follows it. +- (void)dispatchPressOutWithExtraData:(RNGestureHandlerEventExtraData *)extraData +{ + NSTimeInterval remaining = _tapAnimationInDuration - (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; + if (!_pressOutAfterAnimation || !_pressDeferredByPlatform || remaining <= 0) { + [self dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + return; + } + + [self cancelPendingPressOutEvent]; + _lastEventWasInside = NO; + __weak auto weakSelf = self; + _pendingPressOutEventBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ + __strong auto strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_pendingPressOutEventBlock = nil; + [strongSelf.eventDelegate dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + } + }); + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), + dispatch_get_main_queue(), + _pendingPressOutEventBlock); +} + +- (void)flushPendingPressOutEvent +{ + if (_pendingPressOutEventBlock == nil) { + return; + } + + dispatch_block_t block = _pendingPressOutEventBlock; + dispatch_block_cancel(block); + _pendingPressOutEventBlock = nil; + block(); +} + +- (void)cancelPendingPressOutEvent +{ + if (_pendingPressOutEventBlock != nil) { + dispatch_block_cancel(_pendingPressOutEventBlock); + _pendingPressOutEventBlock = nil; + } +} + - (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHandlerEventExtraData *)extraData { [self.eventDelegate dispatchButtonEvent:type withExtraData:extraData]; @@ -1290,6 +1358,7 @@ - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event } _isTouchInsideBounds = YES; + _pressDeferredByPlatform = _pressOutAfterAnimation && [self isInsideDelayingScrollView]; // A pencil's hover-out arrives just before touch-down but only schedules the // clear, so `_isHovered` still reflects the open hover. Under Reduce Motion // that delay is zero and the block can land first — the press then brackets diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index d479d51570..c27419985d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -546,6 +546,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _buttonView.tapAnimationInDuration = newProps.tapAnimationInDuration > 0 ? newProps.tapAnimationInDuration : 0; _buttonView.tapAnimationOutDuration = newProps.tapAnimationOutDuration > 0 ? newProps.tapAnimationOutDuration : 0; _buttonView.longPressDuration = newProps.longPressDuration; + _buttonView.pressOutAfterAnimation = newProps.pressOutAfterAnimation; _buttonView.longPressAnimationOutDuration = newProps.longPressAnimationOutDuration; _buttonView.activeOpacity = newProps.activeOpacity; _buttonView.defaultOpacity = newProps.defaultOpacity; From de3417b98b40dba6b6d0271a20661fc4b3136913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 6 Oct 2026 19:25:54 +0200 Subject: [PATCH 2/4] Flush pending pressout on re-entry --- .../apple/RNGestureHandlerButton.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index f39a31c573..ecfedf834a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -688,6 +688,8 @@ - (void)onHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData } if (pointerInside) { + // Re-entry while a held onPressOut from leaving is pending (cancelOnLeave off). + [self flushPendingPressOutEvent]; [self dispatchButtonEvent:RNGHButtonEventTypePressIn withExtraData:extraData]; } else { [self dispatchPressOutWithExtraData:extraData]; From d6c953df460c626e8ba86246c8a6eef2ca94938b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 6 Oct 2026 19:36:26 +0200 Subject: [PATCH 3/4] Bots review --- .../apple/RNGestureHandlerButton.mm | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index ecfedf834a..36afa81bdf 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -52,7 +52,9 @@ @implementation RNGestureHandlerButton { NSTimeInterval _pressInTimestamp; dispatch_block_t _pendingPressOutBlock; // `onPressOut` held back by `pressOutAfterAnimation` (the press-out animation has its own block above). + // The payload is kept apart from the block: a cancelled dispatch block never runs its body. dispatch_block_t _pendingPressOutEventBlock; + RNGestureHandlerEventExtraData *_pendingPressOutExtraData; // Whether UIKit deferred the press: an enclosing scroll view with `delaysContentTouches` holds // the touch until it rules out a scroll (or delivers begin and end together on a quick tap). BOOL _pressDeferredByPlatform; @@ -231,6 +233,7 @@ - (void)viewWillMoveToWindow:(RNGHWindow *)newWindow [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; + [self flushPendingPressOutEvent]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; @@ -255,6 +258,7 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow [self cancelPendingPressOutAnimation]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; + [self flushPendingPressOutEvent]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; @@ -741,6 +745,7 @@ - (void)onHandlerStateChange:(RNGestureHandlerState)newState } } +#if !TARGET_OS_OSX - (BOOL)isInsideDelayingScrollView { for (UIView *view = self.superview; view != nil; view = view.superview) { @@ -750,6 +755,7 @@ - (BOOL)isInsideDelayingScrollView } return NO; } +#endif // With `pressOutAfterAnimation`, a press UIKit deferred (`delaysContentTouches` in a scroll view) // holds `onPressOut` until the press-in animation has played, like `handleAnimatePressOut` holds @@ -765,13 +771,10 @@ - (void)dispatchPressOutWithExtraData:(RNGestureHandlerEventExtraData *)extraDat [self cancelPendingPressOutEvent]; _lastEventWasInside = NO; + _pendingPressOutExtraData = extraData; __weak auto weakSelf = self; _pendingPressOutEventBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ - __strong auto strongSelf = weakSelf; - if (strongSelf) { - strongSelf->_pendingPressOutEventBlock = nil; - [strongSelf.eventDelegate dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; - } + [weakSelf deliverPendingPressOutEvent]; }); dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), @@ -779,16 +782,24 @@ - (void)dispatchPressOutWithExtraData:(RNGestureHandlerEventExtraData *)extraDat _pendingPressOutEventBlock); } +- (void)deliverPendingPressOutEvent +{ + RNGestureHandlerEventExtraData *extraData = _pendingPressOutExtraData; + _pendingPressOutEventBlock = nil; + _pendingPressOutExtraData = nil; + if (extraData != nil) { + [self.eventDelegate dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + } +} + - (void)flushPendingPressOutEvent { if (_pendingPressOutEventBlock == nil) { return; } - dispatch_block_t block = _pendingPressOutEventBlock; - dispatch_block_cancel(block); - _pendingPressOutEventBlock = nil; - block(); + dispatch_block_cancel(_pendingPressOutEventBlock); + [self deliverPendingPressOutEvent]; } - (void)cancelPendingPressOutEvent @@ -796,6 +807,7 @@ - (void)cancelPendingPressOutEvent if (_pendingPressOutEventBlock != nil) { dispatch_block_cancel(_pendingPressOutEventBlock); _pendingPressOutEventBlock = nil; + _pendingPressOutExtraData = nil; } } From f116e15cdd9258c1c53961deef713d740a43fc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 9 Oct 2026 15:27:27 +0200 Subject: [PATCH 4/4] Delay InteractionFinished event --- .../apple/RNGestureHandlerButton.mm | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 36afa81bdf..c0f8911470 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -55,6 +55,8 @@ @implementation RNGestureHandlerButton { // The payload is kept apart from the block: a cancelled dispatch block never runs its body. dispatch_block_t _pendingPressOutEventBlock; RNGestureHandlerEventExtraData *_pendingPressOutExtraData; + // InteractionFinished that arrived while onPressOut was held; delivered right after it. + RNGestureHandlerEventExtraData *_pendingInteractionFinishedExtraData; // Whether UIKit deferred the press: an enclosing scroll view with `delaysContentTouches` holds // the touch until it rules out a scroll (or delivers begin and end together on a quick tap). BOOL _pressDeferredByPlatform; @@ -741,7 +743,11 @@ - (void)onHandlerStateChange:(RNGestureHandlerState)newState } if (isFinished) { - [self dispatchButtonEvent:RNGHButtonEventTypeInteractionFinished withExtraData:extraData]; + if (_pendingPressOutEventBlock != nil) { + _pendingInteractionFinishedExtraData = extraData; + } else { + [self dispatchButtonEvent:RNGHButtonEventTypeInteractionFinished withExtraData:extraData]; + } } } @@ -784,11 +790,17 @@ - (void)dispatchPressOutWithExtraData:(RNGestureHandlerEventExtraData *)extraDat - (void)deliverPendingPressOutEvent { - RNGestureHandlerEventExtraData *extraData = _pendingPressOutExtraData; + RNGestureHandlerEventExtraData *pressOutExtraData = _pendingPressOutExtraData; + RNGestureHandlerEventExtraData *interactionFinishedExtraData = _pendingInteractionFinishedExtraData; _pendingPressOutEventBlock = nil; _pendingPressOutExtraData = nil; - if (extraData != nil) { - [self.eventDelegate dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:extraData]; + _pendingInteractionFinishedExtraData = nil; + if (pressOutExtraData != nil) { + [self.eventDelegate dispatchButtonEvent:RNGHButtonEventTypePressOut withExtraData:pressOutExtraData]; + } + if (interactionFinishedExtraData != nil) { + [self.eventDelegate dispatchButtonEvent:RNGHButtonEventTypeInteractionFinished + withExtraData:interactionFinishedExtraData]; } } @@ -808,6 +820,7 @@ - (void)cancelPendingPressOutEvent dispatch_block_cancel(_pendingPressOutEventBlock); _pendingPressOutEventBlock = nil; _pendingPressOutExtraData = nil; + _pendingInteractionFinishedExtraData = nil; } }