Skip to content

Commit 45341e4

Browse files
authored
[iOS] Refactor Touchable not to rely on GestureDetector (#4343)
## Description - `RNGestureHandler` gains overridable `dispatchStateChange`/`dispatchHandlerUpdate` hooks. `RNNativeViewGestureHandler` forwards them to its bound view through the new `RNGHNativeViewHandlerStateObserver` protocol (it now keeps a `_boundView` reference, since for `UIControl`-based views the recognizer is never attached and `recognizer.view` can't be used). - `RNGestureHandlerButton` implements the same press state machine as Android, including the native long-press timer. - `RNGestureHandlerButtonComponentView` creates/updates/drops the managed `NativeViewGestureHandler` (attached with the new `RNGestureHandlerActionTypeNone`, so nothing is sent to JS) and emits the press events through the codegen event emitter. On iOS the handler manager can't be resolved from the view's context, so a new `moduleId` prop is used to look it up (no-op on Android). - The events are namespaced as `onButtonPress` etc., because the base iOS view config already registers `topPress` as a bubbling event — a direct event with the same top-level name fails view-config validation in dev. - `Touchable.android.tsx` is replaced by a shared `Touchable.tsx` used on all native platforms; the previous JS gesture-driven implementation moves to `Touchable.web.tsx` (removed in the next PR). - Jest tests updated to fire button events instead of gesture-state sequences, matching what the native side now dispatches. ## Test plan Existing tests in the Example app
1 parent 2de57ac commit 45341e4

20 files changed

Lines changed: 894 additions & 489 deletions

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ class RNGestureHandlerButtonViewManager :
152152
view.hasLongPressHandler = hasLongPressHandler
153153
}
154154

155+
// No-op — only iOS needs the module id to resolve the handler manager, Android
156+
// gets the module from the view's context.
157+
@ReactProp(name = "moduleId")
158+
override fun setModuleId(view: ButtonViewGroup, moduleId: Int) = Unit
159+
155160
@ReactProp(name = "foreground")
156161
override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) {
157162
view.useDrawableOnForeground = useDrawableOnForeground

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ class RNGestureHandlerButtonEvent private constructor() : Event<RNGestureHandler
7373
}
7474

7575
companion object {
76-
private const val ON_PRESS_EVENT_NAME = "onPress"
77-
private const val ON_PRESS_IN_EVENT_NAME = "onPressIn"
78-
private const val ON_PRESS_OUT_EVENT_NAME = "onPressOut"
79-
private const val ON_LONG_PRESS_EVENT_NAME = "onLongPress"
80-
private const val ON_INTERACTION_FINISHED_EVENT_NAME = "onInteractionFinished"
76+
private const val ON_PRESS_EVENT_NAME = "onButtonPress"
77+
private const val ON_PRESS_IN_EVENT_NAME = "onButtonPressIn"
78+
private const val ON_PRESS_OUT_EVENT_NAME = "onButtonPressOut"
79+
private const val ON_LONG_PRESS_EVENT_NAME = "onButtonLongPress"
80+
private const val ON_INTERACTION_FINISHED_EVENT_NAME = "onButtonInteractionFinished"
8181

8282
private const val TOUCH_EVENTS_POOL_SIZE = 7 // magic
8383
private val EVENTS_POOL = Pools.SynchronizedPool<RNGestureHandlerButtonEvent>(TOUCH_EVENTS_POOL_SIZE)

packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,25 @@
1111
@interface RNDummyGestureRecognizer : UIGestureRecognizer
1212
@end
1313

14+
/*
15+
* Views bound to a `RNNativeViewGestureHandler` may conform to this protocol to
16+
* be notified about the handler's updates and state changes.
17+
*/
18+
@protocol RNGHNativeViewHandlerStateObserver <NSObject>
19+
20+
/*
21+
* Called when the handler dispatches a new update event in the ACTIVE state.
22+
*/
23+
- (void)onHandlerUpdate:(nonnull RNGestureHandlerEventExtraData *)extraData;
24+
25+
/*
26+
* Called when the handler moves to a new state.
27+
*/
28+
- (void)onHandlerStateChange:(RNGestureHandlerState)newState
29+
prevState:(RNGestureHandlerState)prevState
30+
extraData:(nonnull RNGestureHandlerEventExtraData *)extraData;
31+
32+
@end
33+
1434
@interface RNNativeViewGestureHandler : RNGestureHandler
1535
@end

packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ @implementation RNNativeViewGestureHandler {
116116
BOOL _yieldsToContinuousGestures;
117117
BOOL _delaysChildPressedState;
118118
RNGestureHandlerEventExtraData *_lastActiveExtraData;
119+
120+
// For UIControl-based views the recognizer is never attached, so
121+
// `recognizer.view` cannot be used to retrieve the bound view.
122+
__weak RNGHUIView *_boundView;
123+
__weak id<RNGHNativeViewHandlerStateObserver> _stateObserver;
119124
}
120125

121126
- (instancetype)initWithTag:(NSNumber *)tag
@@ -146,9 +151,54 @@ - (void)updateConfig:(NSDictionary *)config
146151
#endif
147152
}
148153

154+
- (void)bindToView:(RNGHUIView *)view
155+
{
156+
_boundView = view;
157+
158+
if ([view conformsToProtocol:@protocol(RNGHNativeViewHandlerStateObserver)]) {
159+
_stateObserver = (id<RNGHNativeViewHandlerStateObserver>)view;
160+
}
161+
162+
#if !TARGET_OS_OSX
163+
[self bindToUIKitView:view];
164+
#else
165+
[super bindToView:view];
166+
#endif
167+
}
168+
169+
- (void)unbindFromView
170+
{
171+
#if !TARGET_OS_OSX
172+
if ([_boundView isKindOfClass:[UIControl class]]) {
173+
[(UIControl *)_boundView removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents];
174+
}
175+
176+
// Restore the React Native's overridden behavor for not delaying content touches
177+
UIScrollView *scrollView = [self retrieveScrollView:_boundView];
178+
scrollView.delaysContentTouches = NO;
179+
#endif
180+
181+
_boundView = nil;
182+
_stateObserver = nil;
183+
184+
[super unbindFromView];
185+
}
186+
187+
- (void)dispatchStateChange:(RNGestureHandlerState)newState
188+
prevState:(RNGestureHandlerState)prevState
189+
extraData:(RNGestureHandlerEventExtraData *)extraData
190+
{
191+
[_stateObserver onHandlerStateChange:newState prevState:prevState extraData:extraData];
192+
}
193+
194+
- (void)dispatchHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData
195+
{
196+
[_stateObserver onHandlerUpdate:extraData];
197+
}
198+
149199
#if !TARGET_OS_OSX
150200

151-
- (void)bindToView:(UIView *)view
201+
- (void)bindToUIKitView:(UIView *)view
152202
{
153203
// For UIControl based views (UIButton, UISwitch) we provide special handling that would allow
154204
// for properties like `disallowInterruption` to work.
@@ -196,21 +246,6 @@ - (void)bindToView:(UIView *)view
196246
scrollView.delaysContentTouches = _delaysChildPressedState;
197247
}
198248

199-
- (void)unbindFromView
200-
{
201-
UIView *view = self.recognizer.view;
202-
203-
if ([view isKindOfClass:[UIControl class]]) {
204-
[(UIControl *)view removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents];
205-
}
206-
207-
// Restore the React Native's overriden behavor for not delaying content touches
208-
UIScrollView *scrollView = [self retrieveScrollView:view];
209-
scrollView.delaysContentTouches = NO;
210-
211-
[super unbindFromView];
212-
}
213-
214249
- (RNGestureHandlerEventExtraData *)extraDataForView:(UIView *)sender
215250
event:(UIEvent *)event
216251
pointerInside:(BOOL)pointerInside

packages/react-native-gesture-handler/apple/RNGestureHandler.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@
127127

128128
- (void)stopActivationBlocker;
129129
- (void)reset;
130+
131+
/*
132+
* Called after a state-change event has been dispatched. No-op by default, may be
133+
* overridden by subclasses to observe the state flow regardless of the action type.
134+
*/
135+
- (void)dispatchStateChange:(RNGestureHandlerState)newState
136+
prevState:(RNGestureHandlerState)prevState
137+
extraData:(nonnull RNGestureHandlerEventExtraData *)extraData;
138+
139+
/*
140+
* Called after an update event has been dispatched in the ACTIVE state. No-op by
141+
* default, may be overridden by subclasses.
142+
*/
143+
- (void)dispatchHandlerUpdate:(nonnull RNGestureHandlerEventExtraData *)extraData;
144+
130145
- (void)sendEventsInState:(RNGestureHandlerState)state
131146
forViewWithTag:(nonnull NSNumber *)reactTag
132147
withExtraData:(nonnull RNGestureHandlerEventExtraData *)extraData;

packages/react-native-gesture-handler/apple/RNGestureHandler.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,21 +534,25 @@ - (void)sendEventsInState:(RNGestureHandlerState)state
534534
state == RNGestureHandlerStateEnd && _lastState != RNGestureHandlerStateActive && !fromManualStateChange &&
535535
!_manualActivation) {
536536
// Otherwise send activate state change event to preserve correct gesture flow
537+
RNGestureHandlerState prevSynthesizedState = _lastState;
537538
id event = [[RNGestureHandlerStateChange alloc] initWithReactTag:reactTag
538539
handlerTag:_tag
539540
state:RNGestureHandlerStateActive
540541
prevState:_lastState
541542
extraData:extraData];
542543
[self sendEvent:event];
543544
_lastState = RNGestureHandlerStateActive;
545+
[self dispatchStateChange:RNGestureHandlerStateActive prevState:prevSynthesizedState extraData:extraData];
544546
}
547+
RNGestureHandlerState prevState = _lastState;
545548
id stateEvent = [[RNGestureHandlerStateChange alloc] initWithReactTag:reactTag
546549
handlerTag:_tag
547550
state:state
548551
prevState:_lastState
549552
extraData:extraData];
550553
[self sendEvent:stateEvent];
551554
_lastState = state;
555+
[self dispatchStateChange:state prevState:prevState extraData:extraData];
552556
}
553557

554558
if (state == RNGestureHandlerStateActive) {
@@ -559,9 +563,22 @@ - (void)sendEventsInState:(RNGestureHandlerState)state
559563
forHandlerType:[self eventHandlerType]
560564
coalescingKey:self->_eventCoalescingKey];
561565
[self sendEvent:touchEvent];
566+
[self dispatchHandlerUpdate:extraData];
562567
}
563568
}
564569

570+
- (void)dispatchStateChange:(RNGestureHandlerState)newState
571+
prevState:(RNGestureHandlerState)prevState
572+
extraData:(RNGestureHandlerEventExtraData *)extraData
573+
{
574+
// no-op
575+
}
576+
577+
- (void)dispatchHandlerUpdate:(RNGestureHandlerEventExtraData *)extraData
578+
{
579+
// no-op
580+
}
581+
565582
- (RNGHUIView *)findViewForEvents
566583
{
567584
return [self usesNativeOrVirtualDetector] ? self.hostDetectorView : self.recognizer.view;

packages/react-native-gesture-handler/apple/RNGestureHandlerActionType.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#import <Foundation/Foundation.h>
22

33
typedef NS_ENUM(NSInteger, RNGestureHandlerActionType) {
4+
RNGestureHandlerActionTypeNone = 0, // Handler managed by a native component, doesn't dispatch events to JS
45
RNGestureHandlerActionTypeReanimatedWorklet = 1, // Reanimated worklet
56
RNGestureHandlerActionTypeNativeAnimatedEvent, // Animated.event with useNativeDriver: true
67
RNGestureHandlerActionTypeJSFunctionOldAPI, // JS function or Animated.event with useNativeDriver: false using old

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88

99
#import "RNGestureHandler.h"
1010

11+
typedef NS_ENUM(NSInteger, RNGHButtonEventType) {
12+
RNGHButtonEventTypePress,
13+
RNGHButtonEventTypePressIn,
14+
RNGHButtonEventTypePressOut,
15+
RNGHButtonEventTypeLongPress,
16+
RNGHButtonEventTypeInteractionFinished,
17+
};
18+
19+
/*
20+
* Receives press events produced by the button's state machine so that they can
21+
* be dispatched through the component's event emitter.
22+
*/
23+
@protocol RNGHButtonPressEventDelegate <NSObject>
24+
25+
- (void)dispatchButtonEvent:(RNGHButtonEventType)type
26+
withExtraData:(nullable RNGestureHandlerEventExtraData *)extraData;
27+
28+
@end
29+
1130
#if TARGET_OS_OSX
1231

1332
#include <react/renderer/core/LayoutMetrics.h>
@@ -43,6 +62,15 @@
4362
@property (nonatomic, assign) CGFloat hoverScale;
4463
@property (nonatomic, assign) CGFloat hoverUnderlayOpacity;
4564
@property (nonatomic, strong, nullable) RNGHColor *underlayColor;
65+
@property (nonatomic, assign) BOOL hasLongPressHandler;
66+
67+
/**
68+
* Tag of the gesture handler managed by the button component. The press event
69+
* state machine runs only while it's set.
70+
*/
71+
@property (nonatomic, strong, nullable) NSNumber *managedHandlerTag;
72+
73+
@property (nonatomic, weak, nullable) id<RNGHButtonPressEventDelegate> pressEventDelegate;
4674

4775
/**
4876
* The view that press animations are applied to. Defaults to self; set by the

0 commit comments

Comments
 (0)