Skip to content

Commit 4363f48

Browse files
authored
[Native] Add delaysChildPressedState to native gesture (#4308)
## Description Based on react/react-native#57259, adds a new `delaysChildPressedState` config entry to the native gesture handler and exposes it on the `ScrollView`. The default behavior remains unchanged (visual feedback is delayed in scrollable containers), but can be disabled using the new config entry. Since `HasChildPressedStateDelay` interface is available starting with React Native 0.87, it's currently accessed using reflection to keep backwards compatibility. On older versions, `delaysChildPressedState` is a no-op. Once 0.87 is the lowest compatible version with RNGH, we can drop the reflection and use it directly. ## Test plan ```jsx import * as React from 'react'; import { Platform, SafeAreaView } from 'react-native'; import { GestureHandlerRootView, ScrollView, Touchable, } from 'react-native-gesture-handler'; export default function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <SafeAreaView style={[ { flex: 1, flexDirection: 'row' }, Platform.OS === 'android' && { paddingTop: 50 }, ]}> <ScrollView delaysChildPressedState={false} style={{ width: '50%', height: 200, backgroundColor: 'lightgray' }}> <Touchable style={{ width: 100, height: 100, backgroundColor: 'red' }} activeScale={0.9} onPress={() => console.log('Pressed!')} /> </ScrollView> <ScrollView delaysChildPressedState={true} style={{ width: '50%', height: 200, backgroundColor: 'lightgray' }}> <Touchable style={{ width: 100, height: 100, backgroundColor: 'red' }} activeScale={0.9} onPress={() => console.log('Pressed!')} /> </ScrollView> </SafeAreaView> </GestureHandlerRootView> ); } ```
1 parent 201493f commit 4363f48

4 files changed

Lines changed: 99 additions & 2 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ yieldsToContinuousGestures: boolean | SharedValue<boolean>;
123123

124124
Composes with [`disallowInterruption`](#disallowinterruption). When both are `true`, this handler still cancels discrete gestures (`Tap`, `LongPress`, `Fling`) on activation but allows continuous gestures (`Pan`, `Pinch`, `Rotation`, `Native`, `Manual`, `Hover`) to interrupt it. No-op when `disallowInterruption` is `false`. Defaults to `false`.
125125

126+
<Badges platforms={['android', 'ios']}>
127+
### delaysChildPressedState
128+
</Badges>
129+
130+
```ts
131+
delaysChildPressedState: boolean | SharedValue<boolean>;
132+
```
133+
134+
When `true`, the wrapped scrollable container delays displaying the pressed state of its children until it's clear that the gesture is not a scroll. Set it to `false` to display the pressed state immediately. Defaults to `true`.
135+
136+
On iOS this controls [`delaysContentTouches`](https://developer.apple.com/documentation/uikit/uiscrollview/delayscontenttouches) on the underlying `UIScrollView`. On Android it controls whether the container delays the pressed state of its children (see [`shouldDelayChildPressedState`](https://developer.android.com/reference/android/view/ViewGroup#shouldDelayChildPressedState%28%29)) — on Android, this requires React Native 0.87 or newer and is a no-op on older versions.
137+
126138
<BaseGestureConfig />
127139

128140
## Callbacks

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.facebook.react.views.textinput.ReactEditText
1616
import com.facebook.react.views.view.ReactViewGroup
1717
import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper
1818
import com.swmansion.gesturehandler.react.events.eventbuilders.NativeGestureHandlerEventDataBuilder
19+
import java.lang.reflect.Method
1920

2021
class NativeViewGestureHandler : GestureHandler() {
2122
override val isContinuous = true
@@ -37,6 +38,17 @@ class NativeViewGestureHandler : GestureHandler() {
3738
var yieldsToContinuousGestures = false
3839
private set
3940

41+
/**
42+
* When set, overrides whether the connected scrollable container delays the pressed state of
43+
* its children (see [android.view.ViewGroup.shouldDelayChildPressedState]). Only applies to
44+
* views implementing `HasChildPressedStateDelay` (React Native 0.87+), no-op otherwise. The
45+
* override is applied for the duration of a gesture.
46+
*/
47+
var delaysChildPressedState: Boolean? = null
48+
private set
49+
50+
private var pressedStateDelayOverriddenView: View? = null
51+
4052
private var hook: NativeViewGestureHandlerHook = defaultHook
4153

4254
private data class ActiveUpdateSnapshot(val pointerInside: Boolean, val numberOfPointers: Int, val pointerType: Int)
@@ -53,6 +65,7 @@ class NativeViewGestureHandler : GestureHandler() {
5365
disallowInterruption = DEFAULT_DISALLOW_INTERRUPTION
5466
yieldsToContinuousGestures = DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES
5567
shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE
68+
delaysChildPressedState = DEFAULT_DELAYS_CHILD_PRESSED_STATE
5669
}
5770

5871
override fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean {
@@ -115,6 +128,14 @@ class NativeViewGestureHandler : GestureHandler() {
115128
is ReactTextView -> this.hook = TextViewHook()
116129
is ReactViewGroup -> this.hook = ReactViewGroupHook()
117130
}
131+
132+
delaysChildPressedState?.let { delays ->
133+
this.view?.let {
134+
if (trySetChildPressedStateDelay(it, delays)) {
135+
pressedStateDelayOverriddenView = it
136+
}
137+
}
138+
}
118139
}
119140

120141
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
@@ -179,6 +200,9 @@ class NativeViewGestureHandler : GestureHandler() {
179200
override fun onReset() {
180201
this.hook = defaultHook
181202
lastActiveUpdate = null
203+
// `null` restores the view's default pressed state delay behavior.
204+
pressedStateDelayOverriddenView?.let { trySetChildPressedStateDelay(it, null) }
205+
pressedStateDelayOverriddenView = null
182206
}
183207

184208
override fun dispatchHandlerUpdate(event: MotionEvent) {
@@ -214,6 +238,9 @@ class NativeViewGestureHandler : GestureHandler() {
214238
if (config.hasKey(KEY_YIELDS_TO_CONTINUOUS_GESTURES)) {
215239
handler.yieldsToContinuousGestures = config.getBoolean(KEY_YIELDS_TO_CONTINUOUS_GESTURES)
216240
}
241+
if (config.hasKey(KEY_DELAYS_CHILD_PRESSED_STATE)) {
242+
handler.delaysChildPressedState = config.getBoolean(KEY_DELAYS_CHILD_PRESSED_STATE)
243+
}
217244
}
218245

219246
override fun createEventBuilder(handler: NativeViewGestureHandler) = NativeGestureHandlerEventDataBuilder(handler)
@@ -222,6 +249,7 @@ class NativeViewGestureHandler : GestureHandler() {
222249
private const val KEY_SHOULD_ACTIVATE_ON_START = "shouldActivateOnStart"
223250
private const val KEY_DISALLOW_INTERRUPTION = "disallowInterruption"
224251
private const val KEY_YIELDS_TO_CONTINUOUS_GESTURES = "yieldsToContinuousGestures"
252+
private const val KEY_DELAYS_CHILD_PRESSED_STATE = "delaysChildPressedState"
225253
}
226254
}
227255

@@ -230,6 +258,33 @@ class NativeViewGestureHandler : GestureHandler() {
230258
private const val DEFAULT_SHOULD_ACTIVATE_ON_START = false
231259
private const val DEFAULT_DISALLOW_INTERRUPTION = false
232260
private const val DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES = false
261+
private val DEFAULT_DELAYS_CHILD_PRESSED_STATE: Boolean = true
262+
263+
// `HasChildPressedStateDelay` was introduced in React Native 0.87 — it's accessed via
264+
// reflection so that the library compiles and runs on older versions.
265+
private val childPressedStateDelaySetter: Method? by lazy {
266+
try {
267+
Class
268+
.forName("com.facebook.react.uimanager.HasChildPressedStateDelay")
269+
.getMethod("setHasChildPressedStateDelay", Boolean::class.javaObjectType)
270+
} catch (e: ReflectiveOperationException) {
271+
null
272+
}
273+
}
274+
275+
private fun trySetChildPressedStateDelay(view: View, value: Boolean?): Boolean {
276+
val setter = childPressedStateDelaySetter ?: return false
277+
if (!setter.declaringClass.isInstance(view)) {
278+
return false
279+
}
280+
281+
return try {
282+
setter.invoke(view, value)
283+
true
284+
} catch (e: ReflectiveOperationException) {
285+
false
286+
}
287+
}
233288

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

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,15 @@ @implementation RNNativeViewGestureHandler {
114114
BOOL _shouldActivateOnStart;
115115
BOOL _disallowInterruption;
116116
BOOL _yieldsToContinuousGestures;
117+
BOOL _delaysChildPressedState;
117118
RNGestureHandlerEventExtraData *_lastActiveExtraData;
118119
}
119120

120121
- (instancetype)initWithTag:(NSNumber *)tag
121122
{
122123
if ((self = [super initWithTag:tag])) {
123124
_recognizer = [[RNDummyGestureRecognizer alloc] initWithGestureHandler:self];
125+
_delaysChildPressedState = YES;
124126
}
125127
return self;
126128
}
@@ -131,6 +133,17 @@ - (void)updateConfig:(NSDictionary *)config
131133
_shouldActivateOnStart = [RCTConvert BOOL:config[@"shouldActivateOnStart"]];
132134
_disallowInterruption = [RCTConvert BOOL:config[@"disallowInterruption"]];
133135
_yieldsToContinuousGestures = [RCTConvert BOOL:config[@"yieldsToContinuousGestures"]];
136+
137+
id delaysChildPressedState = config[@"delaysChildPressedState"];
138+
_delaysChildPressedState = delaysChildPressedState == nil ? YES : [RCTConvert BOOL:delaysChildPressedState];
139+
140+
#if !TARGET_OS_OSX
141+
// Config may be updated after the handler is bound to a view — re-apply to the connected
142+
// scroll view if there is one.
143+
if (self.recognizer.view != nil) {
144+
[self retrieveScrollView:self.recognizer.view].delaysContentTouches = _delaysChildPressedState;
145+
}
146+
#endif
134147
}
135148

136149
#if !TARGET_OS_OSX
@@ -177,9 +190,10 @@ - (void)bindToView:(UIView *)view
177190

178191
// We can restore default scrollview behaviour to delay touches to scrollview's children
179192
// because gesture handler system can handle cancellation of scroll recognizer when JS responder
180-
// is set
193+
// is set. Setting `delaysChildPressedState` to `false` opts out of this, keeping touches delivered
194+
// to children immediately.
181195
UIScrollView *scrollView = [self retrieveScrollView:view];
182-
scrollView.delaysContentTouches = YES;
196+
scrollView.delaysContentTouches = _delaysChildPressedState;
183197
}
184198

185199
- (void)unbindFromView

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export type NativeGestureNativeProperties = {
2828
* `false`.
2929
*/
3030
yieldsToContinuousGestures?: boolean;
31+
32+
/**
33+
* When `true`, the wrapped scrollable container delays displaying the
34+
* pressed state of its children until it's clear that the gesture is not a
35+
* scroll. Set it to `false` to display the pressed state immediately.
36+
*
37+
* On iOS this controls `delaysContentTouches` on the underlying
38+
* `UIScrollView`. On Android it controls whether the container delays the
39+
* pressed state of its children (see
40+
* `ViewGroup.shouldDelayChildPressedState`) — this requires React Native
41+
* 0.87 or newer and is a no-op on older versions.
42+
*
43+
* Defaults to `true`.
44+
*/
45+
delaysChildPressedState?: boolean;
3146
};
3247

3348
export const NativeHandlerNativeProperties = new Set<
@@ -36,6 +51,7 @@ export const NativeHandlerNativeProperties = new Set<
3651
'shouldActivateOnStart',
3752
'disallowInterruption',
3853
'yieldsToContinuousGestures',
54+
'delaysChildPressedState',
3955
]);
4056

4157
export type NativeHandlerData = {

0 commit comments

Comments
 (0)