Skip to content

Commit 3c4d189

Browse files
authored
[General] Add yieldsToNativeGestures property to native gesture (#4135)
## Description Currently, `NativeViewGestureHandler` exposes `disallowInterruption` property, which causes it to cancel all other gestures upon activation. This is great for the `Touchable` component, since it shouldn't allow other gesture handlers to activate when it's pressed. The issue with that was the fact that `disallowInterruption` means that all native gestures are cancelled as well, including `ScrollView` or any other custom container, making `Touchable` not usable in real use-cases. This PR adds `yieldsToNativeGestures` property, which works only when `disallowInterruption` is `true`. It defaults to `false`, where it behaves as `disallowInterruption` does currently. When set to `true`, it allows the gesture to be canceled by other native gestures but not any other type of gesture. Touchable is now using this new config to make it work inside scrollable containers. ## Test plan Changed `RectButtons` with `Touchables` in the common app |-|Before|After| |-|-|-| |Android|<video src="https://github.com/user-attachments/assets/d5299802-e6f9-4987-aacb-ab41562fc660" />|<video src="https://github.com/user-attachments/assets/5f012d5b-7da7-4d1a-8a32-d380eb09906d" />| |iOS|<video src="https://github.com/user-attachments/assets/3e0ef50b-c928-4187-99f8-b3e4ea57a8cd" />|<video src="https://github.com/user-attachments/assets/13343460-93a7-47f6-9c90-8a547cb04c34" />| Since scrolling on web dispatches `pointercancel` event, this prop is effectively a no-op for `Touchable`.
1 parent 3456c0a commit 3c4d189

10 files changed

Lines changed: 91 additions & 20 deletions

File tree

packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ disallowInterruption: boolean | SharedValue<boolean>;
115115

116116
When `true`, this handler cancels all other gesture handlers when it activates.
117117

118+
### yieldsToNativeGestures
119+
120+
```ts
121+
yieldsToNativeGestures: boolean | SharedValue<boolean>;
122+
```
123+
124+
Composes with [`disallowInterruption`](#disallowinterruption). When both are `true`, this handler still cancels other gestures (such as `Pan` or `Tap`) on activation but allows other native gestures — for example a wrapping `ScrollView` — to interrupt it. No-op when `disallowInterruption` is `false`. Defaults to `false`.
125+
118126
<BaseGestureConfig />
119127

120128
## Callbacks

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.facebook.react.views.text.ReactTextView
1515
import com.facebook.react.views.textinput.ReactEditText
1616
import com.facebook.react.views.view.ReactViewGroup
1717
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager
18+
import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper
1819
import com.swmansion.gesturehandler.react.events.eventbuilders.NativeGestureHandlerEventDataBuilder
1920
import com.swmansion.gesturehandler.react.isScreenReaderOn
2021

@@ -29,6 +30,14 @@ class NativeViewGestureHandler : GestureHandler() {
2930
var disallowInterruption = false
3031
private set
3132

33+
/**
34+
* Composes with [disallowInterruption]. When both are `true`, the handler still resists
35+
* generic gesture peers (Pan, Tap, etc.) but yields to other [NativeViewGestureHandler] peers
36+
* such as a wrapping ScrollView. No-op when [disallowInterruption] is `false`.
37+
*/
38+
var yieldsToNativeGestures = false
39+
private set
40+
3241
private var hook: NativeViewGestureHandlerHook = defaultHook
3342

3443
private data class ActiveUpdateSnapshot(val pointerInside: Boolean, val numberOfPointers: Int, val pointerType: Int)
@@ -43,6 +52,7 @@ class NativeViewGestureHandler : GestureHandler() {
4352
super.resetConfig()
4453
shouldActivateOnStart = DEFAULT_SHOULD_ACTIVATE_ON_START
4554
disallowInterruption = DEFAULT_DISALLOW_INTERRUPTION
55+
yieldsToNativeGestures = DEFAULT_YIELDS_TO_NATIVE_GESTURES
4656
shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE
4757
}
4858

@@ -61,12 +71,15 @@ class NativeViewGestureHandler : GestureHandler() {
6171
// For the `disallowInterruption` to work correctly we need to check the property when
6272
// accessed as a peer, because simultaneous recognizers can be set on either side of the
6373
// connection.
64-
if (handler.state == STATE_ACTIVE && handler.disallowInterruption) {
74+
if (handler.state == STATE_ACTIVE &&
75+
handler.disallowInterruption &&
76+
!handler.yieldsToNativeGestures
77+
) {
6578
// other handler is active and it disallows interruption, we don't want to get into its way
6679
return false
6780
}
6881
}
69-
val canBeInterrupted = !disallowInterruption
82+
val canBeInterrupted = canBeInterruptedBy(handler)
7083
val otherState = handler.state
7184
return if (state == STATE_ACTIVE && otherState == STATE_ACTIVE && canBeInterrupted) {
7285
// if both handlers are active and the current handler can be interrupted it we return `false`
@@ -81,7 +94,17 @@ class NativeViewGestureHandler : GestureHandler() {
8194
// otherwise we can only return `true` if already in an active state
8295
}
8396

84-
override fun shouldBeCancelledBy(handler: GestureHandler): Boolean = !disallowInterruption
97+
override fun shouldBeCancelledBy(handler: GestureHandler): Boolean = canBeInterruptedBy(handler)
98+
99+
/**
100+
* Whether this handler permits [other] to take over the touch stream, given its
101+
* `disallowInterruption` and `yieldsToNativeGestures` configuration.
102+
*/
103+
fun canBeInterruptedBy(other: GestureHandler): Boolean = !disallowInterruption ||
104+
(
105+
yieldsToNativeGestures &&
106+
(other is NativeViewGestureHandler || other is RNGestureHandlerRootHelper.RootViewGestureHandler)
107+
)
85108

86109
override fun shouldBeginWithRecordedHandlers(recorded: List<GestureHandler>): Boolean =
87110
hook.shouldBeginWithRecordedHandlers(recorded, this)
@@ -203,20 +226,25 @@ class NativeViewGestureHandler : GestureHandler() {
203226
if (config.hasKey(KEY_DISALLOW_INTERRUPTION)) {
204227
handler.disallowInterruption = config.getBoolean(KEY_DISALLOW_INTERRUPTION)
205228
}
229+
if (config.hasKey(KEY_YIELDS_TO_NATIVE_GESTURES)) {
230+
handler.yieldsToNativeGestures = config.getBoolean(KEY_YIELDS_TO_NATIVE_GESTURES)
231+
}
206232
}
207233

208234
override fun createEventBuilder(handler: NativeViewGestureHandler) = NativeGestureHandlerEventDataBuilder(handler)
209235

210236
companion object {
211237
private const val KEY_SHOULD_ACTIVATE_ON_START = "shouldActivateOnStart"
212238
private const val KEY_DISALLOW_INTERRUPTION = "disallowInterruption"
239+
private const val KEY_YIELDS_TO_NATIVE_GESTURES = "yieldsToNativeGestures"
213240
}
214241
}
215242

216243
companion object {
217244
private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = true
218245
private const val DEFAULT_SHOULD_ACTIVATE_ON_START = false
219246
private const val DEFAULT_DISALLOW_INTERRUPTION = false
247+
private const val DEFAULT_YIELDS_TO_NATIVE_GESTURES = false
220248

221249
private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event)
222250

@@ -317,11 +345,10 @@ class NativeViewGestureHandler : GestureHandler() {
317345
}
318346
}
319347

320-
// recognize alongside every handler besides RootViewGestureHandler, which is a private inner class
321-
// of RNGestureHandlerRootHelper so no explicit type checks, but its tag is always negative
348+
// recognize alongside every handler besides RootViewGestureHandler;
322349
// also if other handler is NativeViewGestureHandler then don't override the default implementation
323350
override fun shouldRecognizeSimultaneously(handler: GestureHandler) =
324-
handler.tag > 0 && handler !is NativeViewGestureHandler
351+
handler !is RNGestureHandlerRootHelper.RootViewGestureHandler && handler !is NativeViewGestureHandler
325352

326353
override fun wantsToHandleEventBeforeActivation() = true
327354

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class RNGestureHandlerInteractionManager : GestureHandlerInteractionController {
5050

5151
override fun shouldHandlerBeCancelledBy(handler: GestureHandler, otherHandler: GestureHandler): Boolean {
5252
if (otherHandler is NativeViewGestureHandler) {
53-
return otherHandler.disallowInterruption
53+
return !otherHandler.canBeInterruptedBy(handler)
5454
}
5555

5656
if (otherHandler is RNGestureHandlerRootHelper.RootViewGestureHandler) {

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ - (void)updateStateIfScrollView
113113
@implementation RNNativeViewGestureHandler {
114114
BOOL _shouldActivateOnStart;
115115
BOOL _disallowInterruption;
116+
BOOL _yieldsToNativeGestures;
116117
RNGestureHandlerEventExtraData *_lastActiveExtraData;
117118
}
118119

@@ -129,6 +130,7 @@ - (void)updateConfig:(NSDictionary *)config
129130
[super updateConfig:config];
130131
_shouldActivateOnStart = [RCTConvert BOOL:config[@"shouldActivateOnStart"]];
131132
_disallowInterruption = [RCTConvert BOOL:config[@"disallowInterruption"]];
133+
_yieldsToNativeGestures = [RCTConvert BOOL:config[@"yieldsToNativeGestures"]];
132134
}
133135

134136
#if !TARGET_OS_OSX
@@ -239,11 +241,19 @@ - (void)handleTouchDown:(UIView *)sender forEvent:(UIEvent *)event
239241

240242
if (_disallowInterruption) {
241243
// When `disallowInterruption` is set we cancel all gesture handlers when this UIControl
242-
// gets DOWN event
244+
// gets DOWN event. When `yieldsToNativeGestures` is also set we leave alone:
245+
// - non-RNGH recognizers (e.g. UIScrollView's pan), so native containers can take over
246+
// - peer NativeViewGestureHandler recognizers (RNDummyGestureRecognizer), so wrapping
247+
// RNGH-managed scrollables/native handlers can still take over
243248
for (RNGHUITouch *touch in [event allTouches]) {
244-
for (UIGestureRecognizer *recogn in [touch gestureRecognizers]) {
245-
recogn.enabled = NO;
246-
recogn.enabled = YES;
249+
for (UIGestureRecognizer *recognizer in [touch gestureRecognizers]) {
250+
if (_yieldsToNativeGestures &&
251+
(recognizer.gestureHandler == nil || [recognizer isKindOfClass:[RNDummyGestureRecognizer class]])) {
252+
continue;
253+
}
254+
255+
recognizer.enabled = NO;
256+
recognizer.enabled = YES;
247257
}
248258
}
249259
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,7 @@
152152
#endif
153153

154154
@end
155+
156+
@interface UIGestureRecognizer (GestureHandler)
157+
@property (nonatomic, readonly, nullable) RNGestureHandler *gestureHandler;
158+
@end

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@
1414
#import <React/RCTParagraphComponentView.h>
1515
#import <React/RCTScrollViewComponentView.h>
1616

17-
@interface UIGestureRecognizer (GestureHandler)
18-
@property (nonatomic, readonly) RNGestureHandler *gestureHandler;
19-
@end
20-
2117
@implementation UIGestureRecognizer (GestureHandler)
2218

2319
- (RNGestureHandler *)gestureHandler

packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export const Touchable = (props: TouchableProps) => {
138138
disableReanimated: true,
139139
shouldActivateOnStart: false,
140140
disallowInterruption: true,
141+
yieldsToNativeGestures: true,
141142
});
142143

143144
const rippleProps = shouldUseNativeRipple

packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ export type NativeGestureNativeProperties = {
2020
* `NativeViewGestureHandler` receives an `ACTIVE` state event.
2121
*/
2222
disallowInterruption?: boolean;
23+
24+
/**
25+
* Composes with `disallowInterruption`. When both are `true`, the handler still
26+
* resists generic gesture peers (Pan, Tap, etc.) but yields to other
27+
* `NativeViewGestureHandler` peers such as a wrapping ScrollView. No-op when
28+
* `disallowInterruption` is `false`.
29+
*/
30+
yieldsToNativeGestures?: boolean;
2331
};
2432

2533
export const NativeHandlerNativeProperties = new Set<
2634
keyof NativeGestureNativeProperties
27-
>(['shouldActivateOnStart', 'disallowInterruption']);
35+
>(['shouldActivateOnStart', 'disallowInterruption', 'yieldsToNativeGestures']);
2836

2937
export type NativeHandlerData = {
3038
pointerInside: boolean;

packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default class NativeViewGestureHandler extends GestureHandler {
2323
// TODO: Implement logic for activation on start properly
2424
private shouldActivateOnStart = false;
2525
private disallowInterruption = false;
26+
private yieldsToNativeGestures = false;
2627

2728
private startX = 0;
2829
private startY = 0;
@@ -67,6 +68,9 @@ export default class NativeViewGestureHandler extends GestureHandler {
6768
if (config.disallowInterruption !== undefined) {
6869
this.disallowInterruption = config.disallowInterruption;
6970
}
71+
if (config.yieldsToNativeGestures !== undefined) {
72+
this.yieldsToNativeGestures = config.yieldsToNativeGestures;
73+
}
7074

7175
const view = this.delegate.view as HTMLElement;
7276
this.restoreViewStyles(view);
@@ -181,12 +185,16 @@ export default class NativeViewGestureHandler extends GestureHandler {
181185
if (
182186
handler instanceof NativeViewGestureHandler &&
183187
handler.state === State.ACTIVE &&
184-
handler.disallowsInterruption()
188+
handler.disallowsInterruption() &&
189+
!handler.yieldsToOtherNativeGestures()
185190
) {
186191
return false;
187192
}
188193

189-
const canBeInterrupted = !this.disallowInterruption;
194+
const canBeInterrupted =
195+
!this.disallowInterruption ||
196+
(this.yieldsToNativeGestures &&
197+
handler instanceof NativeViewGestureHandler);
190198

191199
if (
192200
this.state === State.ACTIVE &&
@@ -201,8 +209,12 @@ export default class NativeViewGestureHandler extends GestureHandler {
201209
);
202210
}
203211

204-
public override shouldBeCancelledByOther(_handler: IGestureHandler): boolean {
205-
return !this.disallowInterruption;
212+
public override shouldBeCancelledByOther(handler: IGestureHandler): boolean {
213+
return (
214+
!this.disallowInterruption ||
215+
(this.yieldsToNativeGestures &&
216+
handler instanceof NativeViewGestureHandler)
217+
);
206218
}
207219

208220
public override shouldAttachGestureToChildView(): boolean {
@@ -213,6 +225,10 @@ export default class NativeViewGestureHandler extends GestureHandler {
213225
return this.disallowInterruption;
214226
}
215227

228+
public yieldsToOtherNativeGestures(): boolean {
229+
return this.yieldsToNativeGestures;
230+
}
231+
216232
public isButton(): boolean {
217233
return this.buttonRole;
218234
}

packages/react-native-gesture-handler/src/web/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export interface Config extends Record<string, ConfigArgs> {
8888
maxDeltaY?: number;
8989
shouldActivateOnStart?: boolean;
9090
disallowInterruption?: boolean;
91+
yieldsToNativeGestures?: boolean;
9192
direction?: Directions;
9293
enableTrackpadTwoFingerGesture?: boolean;
9394
}

0 commit comments

Comments
 (0)