From 9ebd8ebf049033d3844a18393977c275c9fa00d2 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 9 Jul 2026 15:05:00 +0200 Subject: [PATCH 1/6] Add `delaysChildPressedState` to native gesture --- .../core/NativeViewGestureHandler.kt | 50 +++++++++++++++++++ .../apple/Handlers/RNNativeViewHandler.mm | 18 ++++++- .../v3/hooks/gestures/native/NativeTypes.ts | 14 ++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index 7d033288f7..a49db510c7 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -16,6 +16,7 @@ import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper import com.swmansion.gesturehandler.react.events.eventbuilders.NativeGestureHandlerEventDataBuilder +import java.lang.reflect.Method class NativeViewGestureHandler : GestureHandler() { override val isContinuous = true @@ -37,6 +38,17 @@ class NativeViewGestureHandler : GestureHandler() { var yieldsToContinuousGestures = false private set + /** + * When set, overrides whether the connected scrollable container delays the pressed state of + * its children (see [android.view.ViewGroup.shouldDelayChildPressedState]). Only applies to + * views implementing `HasChildPressedStateDelay` (React Native 0.87+), no-op otherwise. The + * override is applied for the duration of a gesture. + */ + var delaysChildPressedState: Boolean? = null + private set + + private var pressedStateDelayOverriddenView: View? = null + private var hook: NativeViewGestureHandlerHook = defaultHook private data class ActiveUpdateSnapshot(val pointerInside: Boolean, val numberOfPointers: Int, val pointerType: Int) @@ -53,6 +65,7 @@ class NativeViewGestureHandler : GestureHandler() { disallowInterruption = DEFAULT_DISALLOW_INTERRUPTION yieldsToContinuousGestures = DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE + delaysChildPressedState = DEFAULT_DELAYS_CHILD_PRESSED_STATE } override fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean { @@ -115,6 +128,14 @@ class NativeViewGestureHandler : GestureHandler() { is ReactTextView -> this.hook = TextViewHook() is ReactViewGroup -> this.hook = ReactViewGroupHook() } + + delaysChildPressedState?.let { delays -> + this.view?.let { + if (trySetChildPressedStateDelay(it, delays)) { + pressedStateDelayOverriddenView = it + } + } + } } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { @@ -179,6 +200,9 @@ class NativeViewGestureHandler : GestureHandler() { override fun onReset() { this.hook = defaultHook lastActiveUpdate = null + // `null` restores the view's default pressed state delay behavior. + pressedStateDelayOverriddenView?.let { trySetChildPressedStateDelay(it, null) } + pressedStateDelayOverriddenView = null } override fun dispatchHandlerUpdate(event: MotionEvent) { @@ -214,6 +238,9 @@ class NativeViewGestureHandler : GestureHandler() { if (config.hasKey(KEY_YIELDS_TO_CONTINUOUS_GESTURES)) { handler.yieldsToContinuousGestures = config.getBoolean(KEY_YIELDS_TO_CONTINUOUS_GESTURES) } + if (config.hasKey(KEY_DELAYS_CHILD_PRESSED_STATE)) { + handler.delaysChildPressedState = config.getBoolean(KEY_DELAYS_CHILD_PRESSED_STATE) + } } override fun createEventBuilder(handler: NativeViewGestureHandler) = NativeGestureHandlerEventDataBuilder(handler) @@ -222,6 +249,7 @@ class NativeViewGestureHandler : GestureHandler() { private const val KEY_SHOULD_ACTIVATE_ON_START = "shouldActivateOnStart" private const val KEY_DISALLOW_INTERRUPTION = "disallowInterruption" private const val KEY_YIELDS_TO_CONTINUOUS_GESTURES = "yieldsToContinuousGestures" + private const val KEY_DELAYS_CHILD_PRESSED_STATE = "delaysChildPressedState" } } @@ -230,6 +258,28 @@ class NativeViewGestureHandler : GestureHandler() { private const val DEFAULT_SHOULD_ACTIVATE_ON_START = false private const val DEFAULT_DISALLOW_INTERRUPTION = false private const val DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES = false + private val DEFAULT_DELAYS_CHILD_PRESSED_STATE: Boolean? = null + + // `HasChildPressedStateDelay` was introduced in React Native 0.87 — it's accessed via + // reflection so that the library compiles and runs on older versions. + private val childPressedStateDelaySetter: Method? by lazy { + try { + Class + .forName("com.facebook.react.uimanager.HasChildPressedStateDelay") + .getMethod("setHasChildPressedStateDelay", Boolean::class.javaObjectType) + } catch (e: ReflectiveOperationException) { + null + } + } + + private fun trySetChildPressedStateDelay(view: View, value: Boolean?): Boolean { + val setter = childPressedStateDelaySetter ?: return false + if (!setter.declaringClass.isInstance(view)) { + return false + } + setter.invoke(view, value) + return true + } private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm index d39c863ca1..5c03183bad 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm +++ b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm @@ -114,6 +114,7 @@ @implementation RNNativeViewGestureHandler { BOOL _shouldActivateOnStart; BOOL _disallowInterruption; BOOL _yieldsToContinuousGestures; + BOOL _delaysChildPressedState; RNGestureHandlerEventExtraData *_lastActiveExtraData; } @@ -121,6 +122,7 @@ - (instancetype)initWithTag:(NSNumber *)tag { if ((self = [super initWithTag:tag])) { _recognizer = [[RNDummyGestureRecognizer alloc] initWithGestureHandler:self]; + _delaysChildPressedState = YES; } return self; } @@ -131,6 +133,17 @@ - (void)updateConfig:(NSDictionary *)config _shouldActivateOnStart = [RCTConvert BOOL:config[@"shouldActivateOnStart"]]; _disallowInterruption = [RCTConvert BOOL:config[@"disallowInterruption"]]; _yieldsToContinuousGestures = [RCTConvert BOOL:config[@"yieldsToContinuousGestures"]]; + + id delaysChildPressedState = config[@"delaysChildPressedState"]; + _delaysChildPressedState = delaysChildPressedState == nil ? YES : [RCTConvert BOOL:delaysChildPressedState]; + +#if !TARGET_OS_OSX + // Config may be updated after the handler is bound to a view — re-apply to the connected + // scroll view if there is one. + if (self.recognizer.view != nil) { + [self retrieveScrollView:self.recognizer.view].delaysContentTouches = _delaysChildPressedState; + } +#endif } #if !TARGET_OS_OSX @@ -177,9 +190,10 @@ - (void)bindToView:(UIView *)view // We can restore default scrollview behaviour to delay touches to scrollview's children // because gesture handler system can handle cancellation of scroll recognizer when JS responder - // is set + // is set. Setting `delaysChildPressedState` to `false` opts out of this, keeping touches delivered + // to children immediately. UIScrollView *scrollView = [self retrieveScrollView:view]; - scrollView.delaysContentTouches = YES; + scrollView.delaysContentTouches = _delaysChildPressedState; } - (void)unbindFromView diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts index fb3026e7b9..a1f8d798bf 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts @@ -28,6 +28,19 @@ export type NativeGestureNativeProperties = { * `false`. */ yieldsToContinuousGestures?: boolean; + + /** + * When `false`, the wrapped scrollable container doesn't delay the pressed + * state of its children, making them display press feedback immediately. + * + * On iOS this controls `delaysContentTouches` on the underlying + * `UIScrollView`. On Android it controls whether the container delays the + * pressed state of its children (see + * `ViewGroup.shouldDelayChildPressedState`); requires React Native 0.87+. + * + * Defaults to `true`. + */ + delaysChildPressedState?: boolean; }; export const NativeHandlerNativeProperties = new Set< @@ -36,6 +49,7 @@ export const NativeHandlerNativeProperties = new Set< 'shouldActivateOnStart', 'disallowInterruption', 'yieldsToContinuousGestures', + 'delaysChildPressedState', ]); export type NativeHandlerData = { From c1fa11a96193480307ba36940cd8b4f4ecf82d71 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 9 Jul 2026 15:13:20 +0200 Subject: [PATCH 2/6] Update docs --- .../docs/gestures/use-native-gesture.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index 0a3a26e340..68069ddd7c 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -123,6 +123,18 @@ yieldsToContinuousGestures: boolean | SharedValue; 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`. + +### delaysChildPressedState + + +```ts +delaysChildPressedState: boolean | SharedValue; +``` + +When `false`, the wrapped scrollable container displays the pressed state of its children immediately, instead of delaying it until it's clear that the gesture is not a scroll. Defaults to `true`. + +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)) — this requires React Native 0.87 or newer and is a no-op on older versions. + ## Callbacks From eba80323f548b3e3bc0e27af8b4be2e775763eea Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 10 Jul 2026 09:28:42 +0200 Subject: [PATCH 3/6] Make `true` the default --- .../swmansion/gesturehandler/core/NativeViewGestureHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index a49db510c7..e985fc880b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -258,7 +258,7 @@ class NativeViewGestureHandler : GestureHandler() { private const val DEFAULT_SHOULD_ACTIVATE_ON_START = false private const val DEFAULT_DISALLOW_INTERRUPTION = false private const val DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES = false - private val DEFAULT_DELAYS_CHILD_PRESSED_STATE: Boolean? = null + private val DEFAULT_DELAYS_CHILD_PRESSED_STATE: Boolean = true // `HasChildPressedStateDelay` was introduced in React Native 0.87 — it's accessed via // reflection so that the library compiles and runs on older versions. From 02bea7b158ba4acc2eb6ae18cbac0ad31d87ad49 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 10 Jul 2026 09:32:18 +0200 Subject: [PATCH 4/6] Wrap with try ... catch --- .../gesturehandler/core/NativeViewGestureHandler.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index e985fc880b..ef39138c1c 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -277,8 +277,13 @@ class NativeViewGestureHandler : GestureHandler() { if (!setter.declaringClass.isInstance(view)) { return false } - setter.invoke(view, value) - return true + + return try { + setter.invoke(view, value) + true + } catch (e: ReflectiveOperationException) { + false + } } private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) From 4b6fba4cbecde1d71ecff83b13508fb7a2779560 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 10 Jul 2026 09:33:22 +0200 Subject: [PATCH 5/6] Update docs --- .../docs-gesture-handler/docs/gestures/use-native-gesture.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index 68069ddd7c..52c67473c0 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -131,9 +131,9 @@ Composes with [`disallowInterruption`](#disallowinterruption). When both are `tr delaysChildPressedState: boolean | SharedValue; ``` -When `false`, the wrapped scrollable container displays the pressed state of its children immediately, instead of delaying it until it's clear that the gesture is not a scroll. Defaults to `true`. +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`. -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)) — this requires React Native 0.87 or newer and is a no-op on older versions. +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. From 2aacf3f2307603fe2b7c3057d0ea087f41bb6bec Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 10 Jul 2026 11:32:22 +0200 Subject: [PATCH 6/6] Update comment --- .../src/v3/hooks/gestures/native/NativeTypes.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts index a1f8d798bf..d418341466 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts @@ -30,13 +30,15 @@ export type NativeGestureNativeProperties = { yieldsToContinuousGestures?: boolean; /** - * When `false`, the wrapped scrollable container doesn't delay the pressed - * state of its children, making them display press feedback immediately. + * 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. * * On iOS this controls `delaysContentTouches` on the underlying * `UIScrollView`. On Android it controls whether the container delays the * pressed state of its children (see - * `ViewGroup.shouldDelayChildPressedState`); requires React Native 0.87+. + * `ViewGroup.shouldDelayChildPressedState`) — this requires React Native + * 0.87 or newer and is a no-op on older versions. * * Defaults to `true`. */