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..52c67473c0 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 `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)) — on Android, this requires React Native 0.87 or newer and is a no-op on older versions. + ## Callbacks 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..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 @@ -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,33 @@ 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 = true + + // `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 + } + + return try { + setter.invoke(view, value) + true + } catch (e: ReflectiveOperationException) { + false + } + } 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..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 @@ -28,6 +28,21 @@ export type NativeGestureNativeProperties = { * `false`. */ yieldsToContinuousGestures?: boolean; + + /** + * 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`) — this requires React Native + * 0.87 or newer and is a no-op on older versions. + * + * Defaults to `true`. + */ + delaysChildPressedState?: boolean; }; export const NativeHandlerNativeProperties = new Set< @@ -36,6 +51,7 @@ export const NativeHandlerNativeProperties = new Set< 'shouldActivateOnStart', 'disallowInterruption', 'yieldsToContinuousGestures', + 'delaysChildPressedState', ]); export type NativeHandlerData = {