From a704e32bd5190e08308dc7b2a2055fc16fd00072 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 09:20:01 +0200 Subject: [PATCH 1/6] Allow hooks to listen to handler updates and state changes --- .../core/NativeViewGestureHandler.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 ef39138c1c..a3d439b535 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 @@ -212,11 +212,15 @@ class NativeViewGestureHandler : GestureHandler() { } lastActiveUpdate = snapshot super.dispatchHandlerUpdate(event) + + hook.onHandlerUpdate(this) } override fun dispatchStateChange(newState: Int, prevState: Int) { lastActiveUpdate = null super.dispatchStateChange(newState, prevState) + + hook.onHandlerStateChange(this, newState, prevState) } override fun wantsToAttachDirectlyToView() = true @@ -356,6 +360,16 @@ class NativeViewGestureHandler : GestureHandler() { * Passes the event down to the underlying view using the correct method. */ fun sendTouchEvent(view: View?, event: MotionEvent) = view?.onTouchEvent(event) + + /* + * Called when the handler processes a new update event. + */ + fun onHandlerUpdate(handler: NativeViewGestureHandler) = Unit + + /* + * Called when the handler moves to a new state. + */ + fun onHandlerStateChange(handler: NativeViewGestureHandler, newState: Int, prevState: Int) = Unit } private class TextViewHook : NativeViewGestureHandlerHook { From 6c73c0000c134ba1564dffc35b4cb5c50d4f5c26 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 09:20:01 +0200 Subject: [PATCH 2/6] Implement basic state machine for button --- .../RNGestureHandlerButtonViewManager.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 38d5a30226..d2700bb8fc 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -436,6 +436,7 @@ class RNGestureHandlerButtonViewManager : private var underlayDrawable: PaintDrawable? = null private var pressInTimestamp = 0L private var pendingPressOut: Runnable? = null + private var pendingLongPress: Runnable? = null private var pendingHoverOut: Choreographer.FrameCallback? = null private var isPointerInsideBounds = false private var isHovered = false @@ -520,6 +521,93 @@ class RNGestureHandlerButtonViewManager : } } + private var longPressDetected = false + + // Cannot rely on isPointerInsideBounds because it's set to false during motion event processing + // which happens before the final state change is handled (state change is triggered by the motion + // event). + private var lastEventWasInside = false + + override fun onHandlerUpdate(handler: NativeViewGestureHandler) { + if (handler.isWithinBounds == lastEventWasInside) { + return + } + + if (handler.isWithinBounds) { + dispatchJSEvent(EventType.PressIn) + } else { + dispatchJSEvent(EventType.PressOut) + + pendingLongPress?.let { + this.handler?.removeCallbacks(it) + pendingLongPress = null + } + } + } + + override fun onHandlerStateChange(handler: NativeViewGestureHandler, newState: Int, prevState: Int) { + // Capture local copy, since lastEventWasInside can change during this method + // Specifically PressOut -> Press scenario on STATE_END + val localLastEventWasInside = lastEventWasInside + + if (newState == GestureHandler.STATE_BEGAN) { + dispatchJSEvent(EventType.PressIn) + + val runnable = Runnable { + pendingLongPress = null + longPressDetected = true + dispatchJSEvent(EventType.LongPress) + } + longPressDetected = false + pendingLongPress = runnable + this.handler?.postDelayed(runnable, longPressDuration.toLong()) + } + + if (newState == GestureHandler.STATE_END || + newState == GestureHandler.STATE_FAILED || + newState == GestureHandler.STATE_CANCELLED + ) { + if (localLastEventWasInside) { + dispatchJSEvent(EventType.PressOut) + } + + pendingLongPress?.let { + this.handler?.removeCallbacks(it) + pendingLongPress = null + } + } + + if (newState == GestureHandler.STATE_END && !longPressDetected && localLastEventWasInside) { + dispatchJSEvent(EventType.Press) + } + + if (newState == GestureHandler.STATE_END || + newState == GestureHandler.STATE_FAILED || + newState == GestureHandler.STATE_CANCELLED + ) { + dispatchJSEvent(EventType.InteractionFinished) + } + } + + private fun dispatchJSEvent(type: EventType) { + when (type) { + EventType.Press -> { + Log.w("RNGH", "onPress") + } + EventType.PressIn -> { + lastEventWasInside = true + Log.w("RNGH", "onPressIn") + } + EventType.PressOut -> { + lastEventWasInside = false + Log.w("RNGH", "onPressOut") + } + EventType.LongPress -> { + Log.w("RNGH", "longPress") + } + } + } + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { if (super.onInterceptTouchEvent(event)) { return true @@ -912,6 +1000,8 @@ class RNGestureHandlerButtonViewManager : override fun onDetachedFromWindow() { pendingPressOut?.let { handler?.removeCallbacks(it) } pendingPressOut = null + pendingLongPress?.let { handler?.removeCallbacks(it) } + pendingLongPress = null cancelPendingHoverOut() currentAnimator?.cancel() currentAnimator = null @@ -1104,6 +1194,13 @@ class RNGestureHandlerButtonViewManager : } } } + + enum class EventType { + Press, + PressIn, + PressOut, + LongPress + } } companion object { From 4ae875b868c8645bfef102f22c6eab3c4d84edfb Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 09:20:01 +0200 Subject: [PATCH 3/6] Dispatch `press` events directly from the native side --- .../RNGestureHandlerButtonViewManager.kt | 65 ++++---- .../events/RNGestureHandlerButtonEvent.kt | 93 ++++++++++++ .../src/components/GestureHandlerButton.tsx | 7 +- .../RNGestureHandlerButtonNativeComponent.ts | 19 +++ .../src/v3/components/Touchable/Touchable.tsx | 141 ++++-------------- .../v3/components/Touchable/TouchableProps.ts | 18 ++- 6 files changed, 193 insertions(+), 150 deletions(-) create mode 100644 packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index d2700bb8fc..31b54a5047 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -27,6 +27,7 @@ import androidx.core.view.children import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.facebook.react.R import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.BackgroundStyleApplicator @@ -35,6 +36,7 @@ import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactPointerEventsView import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.ViewProps @@ -48,6 +50,7 @@ import com.swmansion.gesturehandler.core.GestureHandler import com.swmansion.gesturehandler.core.HoverGestureHandler import com.swmansion.gesturehandler.core.NativeViewGestureHandler import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup +import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonEvent @ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS) class RNGestureHandlerButtonViewManager : @@ -63,6 +66,11 @@ class RNGestureHandlerButtonViewManager : public override fun createViewInstance(context: ThemedReactContext) = ButtonViewGroup(context) + @ReactProp(name = "hasLongPressHandler") + override fun setHasLongPressHandler(view: ButtonViewGroup, hasLongPressHandler: Boolean) { + view.hasLongPressHandler = hasLongPressHandler + } + @ReactProp(name = "foreground") override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) { view.useDrawableOnForeground = useDrawableOnForeground @@ -398,6 +406,7 @@ class RNGestureHandlerButtonViewManager : var useBorderlessDrawable = false var exclusive = true + var hasLongPressHandler = false var tapAnimationInDuration: Int = 50 var tapAnimationOutDuration: Int = 100 var longPressDuration: Int = -1 @@ -534,9 +543,9 @@ class RNGestureHandlerButtonViewManager : } if (handler.isWithinBounds) { - dispatchJSEvent(EventType.PressIn) + dispatchJSEvent(EventType.PressIn, handler) } else { - dispatchJSEvent(EventType.PressOut) + dispatchJSEvent(EventType.PressOut, handler) pendingLongPress?.let { this.handler?.removeCallbacks(it) @@ -551,16 +560,18 @@ class RNGestureHandlerButtonViewManager : val localLastEventWasInside = lastEventWasInside if (newState == GestureHandler.STATE_BEGAN) { - dispatchJSEvent(EventType.PressIn) + dispatchJSEvent(EventType.PressIn, handler) + longPressDetected = false - val runnable = Runnable { - pendingLongPress = null - longPressDetected = true - dispatchJSEvent(EventType.LongPress) + if (hasLongPressHandler && longPressDuration >= 0) { + val runnable = Runnable { + pendingLongPress = null + longPressDetected = true + dispatchJSEvent(EventType.LongPress, handler) + } + pendingLongPress = runnable + this.handler?.postDelayed(runnable, longPressDuration.toLong()) } - longPressDetected = false - pendingLongPress = runnable - this.handler?.postDelayed(runnable, longPressDuration.toLong()) } if (newState == GestureHandler.STATE_END || @@ -568,7 +579,7 @@ class RNGestureHandlerButtonViewManager : newState == GestureHandler.STATE_CANCELLED ) { if (localLastEventWasInside) { - dispatchJSEvent(EventType.PressOut) + dispatchJSEvent(EventType.PressOut, handler) } pendingLongPress?.let { @@ -578,33 +589,26 @@ class RNGestureHandlerButtonViewManager : } if (newState == GestureHandler.STATE_END && !longPressDetected && localLastEventWasInside) { - dispatchJSEvent(EventType.Press) + dispatchJSEvent(EventType.Press, handler) } if (newState == GestureHandler.STATE_END || newState == GestureHandler.STATE_FAILED || newState == GestureHandler.STATE_CANCELLED ) { - dispatchJSEvent(EventType.InteractionFinished) + dispatchJSEvent(EventType.InteractionFinished, handler) } } - private fun dispatchJSEvent(type: EventType) { - when (type) { - EventType.Press -> { - Log.w("RNGH", "onPress") - } - EventType.PressIn -> { - lastEventWasInside = true - Log.w("RNGH", "onPressIn") - } - EventType.PressOut -> { - lastEventWasInside = false - Log.w("RNGH", "onPressOut") - } - EventType.LongPress -> { - Log.w("RNGH", "longPress") - } + private fun dispatchJSEvent(type: EventType, handler: NativeViewGestureHandler) { + val reactContext = context as? ReactContext ?: return + val eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext) ?: return + eventDispatcher.dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, handler, type)) + + if (type == EventType.PressIn) { + lastEventWasInside = true + } else if (type == EventType.PressOut) { + lastEventWasInside = false } } @@ -1199,7 +1203,8 @@ class RNGestureHandlerButtonViewManager : Press, PressIn, PressOut, - LongPress + LongPress, + InteractionFinished, } } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt new file mode 100644 index 0000000000..eef1530a48 --- /dev/null +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonEvent.kt @@ -0,0 +1,93 @@ +package com.swmansion.gesturehandler.react.events + +import androidx.core.util.Pools +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.swmansion.gesturehandler.core.NativeViewGestureHandler +import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager + +class RNGestureHandlerButtonEvent private constructor() : Event() { + private lateinit var type: RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType + private lateinit var eventData: ButtonEventData + + private fun init( + button: RNGestureHandlerButtonViewManager.ButtonViewGroup, + handler: NativeViewGestureHandler, + type: RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType, + ) { + super.init(UIManagerHelper.getSurfaceId(button), button.id) + + this.type = type + this.eventData = ButtonEventData( + x = PixelUtil.toDIPFromPixel(handler.lastRelativePositionX).toDouble(), + y = PixelUtil.toDIPFromPixel(handler.lastRelativePositionY).toDouble(), + absoluteX = PixelUtil.toDIPFromPixel(handler.lastPositionInWindowX).toDouble(), + absoluteY = PixelUtil.toDIPFromPixel(handler.lastPositionInWindowY).toDouble(), + numberOfPointers = handler.numberOfPointers, + pointerType = handler.pointerType, + pointerInside = handler.isWithinBounds, + ) + } + + override fun onDispose() { + EVENTS_POOL.release(this) + } + + override fun getEventName() = when (type) { + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.Press -> ON_PRESS_EVENT_NAME + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.PressIn -> ON_PRESS_IN_EVENT_NAME + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.PressOut -> ON_PRESS_OUT_EVENT_NAME + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.LongPress -> ON_LONG_PRESS_EVENT_NAME + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.InteractionFinished -> + ON_INTERACTION_FINISHED_EVENT_NAME + } + + // Unfortunately getCoalescingKey is not considered when sending event to C++, therefore we have to disable coalescing in v3 + override fun canCoalesce() = false + + override fun getCoalescingKey(): Short = 0 + + override fun getEventData(): WritableMap = this.eventData.toWritableMap() + + private data class ButtonEventData( + val x: Double, + val y: Double, + val absoluteX: Double, + val absoluteY: Double, + val numberOfPointers: Int, + val pointerType: Int, + val pointerInside: Boolean, + ) { + fun toWritableMap(): WritableMap = Arguments.createMap().apply { + putDouble("x", x) + putDouble("y", y) + putDouble("absoluteX", absoluteX) + putDouble("absoluteY", absoluteY) + putInt("numberOfPointers", numberOfPointers) + putInt("pointerType", pointerType) + putBoolean("pointerInside", pointerInside) + } + } + + companion object { + private const val ON_PRESS_EVENT_NAME = "onPress" + private const val ON_PRESS_IN_EVENT_NAME = "onPressIn" + private const val ON_PRESS_OUT_EVENT_NAME = "onPressOut" + private const val ON_LONG_PRESS_EVENT_NAME = "onLongPress" + private const val ON_INTERACTION_FINISHED_EVENT_NAME = "onInteractionFinished" + + private const val TOUCH_EVENTS_POOL_SIZE = 7 // magic + private val EVENTS_POOL = Pools.SynchronizedPool(TOUCH_EVENTS_POOL_SIZE) + + fun obtain( + button: RNGestureHandlerButtonViewManager.ButtonViewGroup, + handler: NativeViewGestureHandler, + type: RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType, + ): RNGestureHandlerButtonEvent = (EVENTS_POOL.acquire() ?: RNGestureHandlerButtonEvent()).apply { + init(button, handler, type) + } + } +} diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index cea40b0893..47a41a0e73 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -8,7 +8,9 @@ import type { ViewStyle, } from 'react-native'; -import RNGestureHandlerButtonNativeComponent from '../specs/RNGestureHandlerButtonNativeComponent'; +import RNGestureHandlerButtonNativeComponent, { + type ButtonEvent, +} from '../specs/RNGestureHandlerButtonNativeComponent'; export interface ButtonProps extends ViewProps, AccessibilityProps { children?: React.ReactNode; @@ -18,6 +20,8 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ enabled?: boolean | undefined; + hasLongPressHandler?: boolean | undefined; + /** * Defines if more than one button could be pressed simultaneously. By default * set true. @@ -203,3 +207,4 @@ export const ButtonComponent = RNGestureHandlerButtonNativeComponent as HostComponent; export default ButtonComponent; +export type { ButtonEvent }; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 67fff7a00d..92810bb1ee 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -6,8 +6,27 @@ import type { } from 'react-native'; import { codegenNativeComponent } from 'react-native'; +export type ButtonEvent = Readonly<{ + pointerInside: boolean; + x: CodegenTypes.Double; + y: CodegenTypes.Double; + absoluteX: CodegenTypes.Double; + absoluteY: CodegenTypes.Double; + numberOfPointers: CodegenTypes.Int32; + pointerType: CodegenTypes.Int32; +}>; + // @ts-ignore - Redefining pointerEvents with WithDefault for codegen, conflicts with ViewProps type but codegen needs it interface NativeProps extends ViewProps { + onPress?: CodegenTypes.DirectEventHandler | undefined; + onPressIn?: CodegenTypes.DirectEventHandler | undefined; + onPressOut?: CodegenTypes.DirectEventHandler | undefined; + onLongPress?: CodegenTypes.DirectEventHandler | undefined; + onInteractionFinished?: + | CodegenTypes.DirectEventHandler + | undefined; + + hasLongPressHandler?: CodegenTypes.WithDefault; exclusive?: CodegenTypes.WithDefault; foreground?: boolean; borderless?: boolean; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index ff14e4da49..4a1cf83cfd 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -1,7 +1,10 @@ import React, { use, useCallback, useRef } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; import { Platform } from 'react-native'; -import GestureHandlerButton from '../../../components/GestureHandlerButton'; +import GestureHandlerButton, { + type ButtonEvent, +} from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; import { NativeDetector } from '../../detectors/NativeDetector'; import { useNativeGesture } from '../../hooks'; @@ -99,130 +102,35 @@ export const Touchable = (props: TouchableProps) => { const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; - const pointerState = useRef(PointerState.UNKNOWN); - const longPressDetected = useRef(false); - const longPressTimeout = useRef | undefined>( - undefined + const internalOnPress = useCallback( + (e: NativeSyntheticEvent) => { + onPress?.(e.nativeEvent); + }, + [onPress] ); - // Swallow the tap that dismisses the keyboard in - // keyboardShouldPersistTaps="never", matching RN's touchables. - const jsResponderContext = use(JSResponderContext); - const dropKeyboardTapRef = useRef(null); - - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); - - const resetKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current = null; - }, []); - - const wrappedLongPress = useCallback(() => { - longPressDetected.current = true; - onLongPress?.(); - }, [onLongPress]); - - const startLongPressTimer = useCallback(() => { - longPressDetected.current = false; - - if (onLongPress && !longPressTimeout.current) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - resolvedDelayLongPress - ); - } - }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); - - const onBegin = useCallback( - (e: CallbackEventType) => { - captureKeyboardDismiss(); - - if (!e.pointerInside || dropKeyboardTapRef.current) { - pointerState.current = PointerState.OUTSIDE; - return; - } - - onPressIn?.(e); - startLongPressTimer(); - - pointerState.current = PointerState.INSIDE; + const internalOnPressIn = useCallback( + (e: NativeSyntheticEvent) => { + onPressIn?.(e.nativeEvent); }, - [captureKeyboardDismiss, startLongPressTimer, onPressIn] + [onPressIn] ); - const onActivate = useCallback((e: CallbackEventType) => { - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, []); - - const onFinalize = useCallback( - (e: EndCallbackEventType) => { - if ( - !dropKeyboardTapRef.current && - pointerState.current === PointerState.INSIDE - ) { - onPressOut?.(e); - } - - if ( - !dropKeyboardTapRef.current && - !e.canceled && - !longPressDetected.current && - e.pointerInside - ) { - onPress?.(e); - } - - pointerState.current = PointerState.UNKNOWN; - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - - resetKeyboardDismiss(); + const internalOnPressOut = useCallback( + (e: NativeSyntheticEvent) => { + onPressOut?.(e.nativeEvent); }, - [resetKeyboardDismiss, onPressOut, onPress] + [onPressOut] ); - const onUpdate = useCallback( - (e: CallbackEventType) => { - if (dropKeyboardTapRef.current) { - return; - } - - if (pointerState.current === PointerState.UNKNOWN) { - return; - } - - if (e.pointerInside) { - if (pointerState.current === PointerState.OUTSIDE) { - onPressIn?.(e); - } - pointerState.current = PointerState.INSIDE; - } else { - if (pointerState.current === PointerState.INSIDE) { - onPressOut?.(e); - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - } - pointerState.current = PointerState.OUTSIDE; - } + const internalOnLongPress = useCallback( + (e: NativeSyntheticEvent) => { + onLongPress?.(e.nativeEvent); }, - [onPressIn, onPressOut] + [onLongPress] ); const nativeGesture = useNativeGesture({ - onBegin, - onActivate, - onFinalize, - onUpdate, hitSlop: props.hitSlop, testID: props.testID, enabled: !disabled, @@ -257,7 +165,12 @@ export const Touchable = (props: TouchableProps) => { defaultUnderlayOpacity={defaultUnderlayOpacity} activeUnderlayOpacity={activeUnderlayOpacity} underlayColor={underlayColor} - longPressDuration={resolvedDelayLongPress}> + longPressDuration={resolvedDelayLongPress} + hasLongPressHandler={onLongPress !== undefined} + onPress={internalOnPress} + onPressIn={internalOnPressIn} + onPressOut={internalOnPressOut} + onLongPress={internalOnLongPress}> {children} diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts index 9fa50d489a..ed7364beea 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts @@ -1,6 +1,9 @@ import type { PressableAndroidRippleConfig as RNPressableAndroidRippleConfig } from 'react-native'; -import type { ButtonProps } from '../../../components/GestureHandlerButton'; +import type { + ButtonEvent, + ButtonProps, +} from '../../../components/GestureHandlerButton'; import type { NativeHandlerData } from '../../hooks/gestures/native/NativeTypes'; import type { GestureEndEvent, GestureEvent } from '../../types'; import type { BaseButtonProps, RawButtonProps } from '../GestureButtonsProps'; @@ -61,7 +64,7 @@ export type TouchableProps = Omit< > & Omit< BaseButtonProps, - keyof RawButtonProps | 'onActiveStateChange' | 'onPress' + keyof RawButtonProps | 'onActiveStateChange' | 'onPress' | 'onLongPress' > & { /** * Press and hover animation durations, in milliseconds. Pass a single @@ -78,17 +81,22 @@ export type TouchableProps = Omit< /** * Called when the component gets pressed. */ - onPress?: ((event: CallbackEventType) => void) | undefined; + onPress?: ((event: ButtonEvent) => void) | undefined; + + /** + * Called when the component gets long pressed. + */ + onLongPress?: ((event: ButtonEvent) => void) | undefined; /** * Called when pointer touches the component. */ - onPressIn?: ((event: CallbackEventType) => void) | undefined; + onPressIn?: ((event: ButtonEvent) => void) | undefined; /** * Called when pointer is released from the component. */ - onPressOut?: ((event: CallbackEventType) => void) | undefined; + onPressOut?: ((event: ButtonEvent) => void) | undefined; /** * Whether the component should ignore touches. By default set to false. From 54c3724650bf487d23844d0e892ce1dbb670023b Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 11:35:08 +0200 Subject: [PATCH 4/6] Handle `press` events --- .../src/components/GestureHandlerButton.tsx | 31 ++++++++++++ .../src/v3/components/GestureButtonsProps.ts | 8 +++ .../src/v3/components/Touchable/Touchable.tsx | 49 +++++++++++++------ .../v3/components/Touchable/TouchableProps.ts | 11 ++++- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 47a41a0e73..424dab65d1 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -3,6 +3,7 @@ import type { ColorValue, HostComponent, LayoutChangeEvent, + NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle, @@ -28,6 +29,36 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ exclusive?: boolean | undefined; + /** + * Called when the button gets pressed. + */ + onPress?: ((event: NativeSyntheticEvent) => void) | undefined; + + /** + * Called when the pointer touches the button. + */ + onPressIn?: ((event: NativeSyntheticEvent) => void) | undefined; + + /** + * Called when the pointer is released or leaves the button. + */ + onPressOut?: ((event: NativeSyntheticEvent) => void) | undefined; + + /** + * Called when the button gets pressed and held past `longPressDuration`. + */ + onLongPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + + /** + * Called when the interaction with the button ends, after any terminal + * `onPressOut`/`onPress` events, regardless of how it ended. + */ + onInteractionFinished?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + /** * Android only. * diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts index 71192d1ad2..ca86f6d01d 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -10,6 +10,14 @@ import type { NativeWrapperProperties } from '../types/NativeWrapperType'; export interface RawButtonProps extends Omit< ButtonProps, + // The native press events are omitted — the deprecated buttons drive + // their press callbacks from the gesture in JS and redeclare them with + // their own signatures. + | 'onPress' + | 'onPressIn' + | 'onPressOut' + | 'onLongPress' + | 'onInteractionFinished' | 'defaultOpacity' | 'defaultScale' | 'defaultUnderlayOpacity' diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 4a1cf83cfd..cf0a11c354 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -12,24 +12,13 @@ import { isKeyboardDismissingTap, JSResponderContext, } from '../ScrollViewResponderInterceptor'; -import type { - AnimationDuration, - CallbackEventType, - EndCallbackEventType, - TouchableProps, -} from './TouchableProps'; +import type { AnimationDuration, TouchableProps } from './TouchableProps'; const isAndroid = Platform.OS === 'android'; const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; const DEFAULT_IN_DURATION_MS = 50; const DEFAULT_OUT_DURATION_MS = 100; -enum PointerState { - UNKNOWN, - INSIDE, - OUTSIDE, -} - // Clamp user-supplied durations to finite, non-negative milliseconds. // Negative, NaN, or Infinity values would produce invalid CSS transitions // on web and negative setTimeout delays in branch 3 of the press-out path. @@ -102,8 +91,15 @@ export const Touchable = (props: TouchableProps) => { const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + const jsResponderContext = use(JSResponderContext); + const dropKeyboardTapRef = useRef(null); + const internalOnPress = useCallback( (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + onPress?.(e.nativeEvent); }, [onPress] @@ -111,13 +107,27 @@ export const Touchable = (props: TouchableProps) => { const internalOnPressIn = useCallback( (e: NativeSyntheticEvent) => { + // PressIn opens every press sequence; capture the verdict once per + // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't + // overwrite it after the keyboard is already dismissed. + dropKeyboardTapRef.current ??= + isKeyboardDismissingTap(jsResponderContext); + + if (dropKeyboardTapRef.current) { + return; + } + onPressIn?.(e.nativeEvent); }, - [onPressIn] + [jsResponderContext, onPressIn] ); const internalOnPressOut = useCallback( (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + onPressOut?.(e.nativeEvent); }, [onPressOut] @@ -125,11 +135,21 @@ export const Touchable = (props: TouchableProps) => { const internalOnLongPress = useCallback( (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + onLongPress?.(e.nativeEvent); }, [onLongPress] ); + // InteractionFinished is dispatched after the terminal PressOut/Press + // events, so resetting synchronously here is safe. + const internalOnInteractionFinished = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); + const nativeGesture = useNativeGesture({ hitSlop: props.hitSlop, testID: props.testID, @@ -170,7 +190,8 @@ export const Touchable = (props: TouchableProps) => { onPress={internalOnPress} onPressIn={internalOnPressIn} onPressOut={internalOnPressOut} - onLongPress={internalOnLongPress}> + onLongPress={internalOnLongPress} + onInteractionFinished={internalOnInteractionFinished}> {children} diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts index ed7364beea..5da2a21c41 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts @@ -20,6 +20,15 @@ type PressableAndroidRippleConfig = { type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; +// The press events are redeclared below with the unwrapped `ButtonEvent` +// signature; `onInteractionFinished` is consumed internally by `Touchable`. +type PressProps = + | 'onPress' + | 'onPressIn' + | 'onPressOut' + | 'onLongPress' + | 'onInteractionFinished'; + type DurationProps = | 'tapAnimationInDuration' | 'tapAnimationOutDuration' @@ -60,7 +69,7 @@ export type AnimationDuration = export type TouchableProps = Omit< ButtonProps, - RippleProps | 'enabled' | DurationProps + RippleProps | PressProps | 'enabled' | DurationProps > & Omit< BaseButtonProps, From a60d2f8a8fd06a439e81cf11e53e492bf892a872 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 12:01:55 +0200 Subject: [PATCH 5/6] Attach native gesture directly to button on the native side --- .../gesturehandler/core/GestureHandler.kt | 1 + .../RNGestureHandlerButtonViewManager.kt | 104 +++++++++++++++++- .../src/components/GestureHandlerButton.tsx | 11 ++ .../RNGestureHandlerButtonNativeComponent.ts | 10 ++ .../src/v3/components/Touchable/Touchable.tsx | 73 ++++++------ 5 files changed, 162 insertions(+), 37 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 4dab48acc4..6e0097aab5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -1064,6 +1064,7 @@ open class GestureHandler { const val DIRECTION_LEFT = 2 const val DIRECTION_UP = 4 const val DIRECTION_DOWN = 8 + const val ACTION_TYPE_NONE = 0 const val ACTION_TYPE_REANIMATED_WORKLET = 1 const val ACTION_TYPE_NATIVE_ANIMATED_EVENT = 2 const val ACTION_TYPE_JS_FUNCTION_OLD_API = 3 diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 31b54a5047..6d6b761bd9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -26,9 +26,11 @@ import android.view.accessibility.AccessibilityNodeInfo import androidx.core.view.children import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.facebook.react.R +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Dynamic import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.LengthPercentage @@ -66,6 +68,85 @@ class RNGestureHandlerButtonViewManager : public override fun createViewInstance(context: ThemedReactContext) = ButtonViewGroup(context) + override fun onDropViewInstance(view: ButtonViewGroup) { + view.managedHandlerTag?.let { tag -> + getGestureHandlerModule(view).dropGestureHandler(tag) + } + + super.onDropViewInstance(view) + } + + private fun getGestureHandlerModule(view: ButtonViewGroup) = + (view.context as ReactContext).getNativeModule(RNGestureHandlerModule::class.java)!! + + private fun updateManagedHandlerConfig(view: ButtonViewGroup, config: ReadableMap) { + view.managedHandlerTag?.let { getGestureHandlerModule(view).updateGestureHandlerConfig(it, config) } + } + + @ReactProp(name = "handlerTag") + override fun setHandlerTag(view: ButtonViewGroup, handlerTag: Double) { + if (view.managedHandlerTag == handlerTag) { + return + } + + view.managedHandlerTag?.let { oldTag -> + getGestureHandlerModule(view).dropGestureHandler(oldTag) + } + + view.managedHandlerTag = handlerTag + val module = getGestureHandlerModule(view) + + module.createGestureHandler( + "NativeViewGestureHandler", + handlerTag, + Arguments.createMap().apply { + putBoolean("shouldActivateOnStart", false) + putBoolean("disallowInterruption", true) + putBoolean("yieldsToContinuousGestures", true) + putBoolean("enabled", view.isEnabled) + view.managedHandlerCancelOnLeave?.let { putBoolean("shouldCancelWhenOutside", it) } + view.managedHandlerTestID?.let { putString("testID", it) } + view.managedHandlerHitSlop?.let { putMap("hitSlop", it) } + }, + ) + module.attachGestureHandler(handlerTag, view.id.toDouble(), GestureHandler.ACTION_TYPE_NONE.toDouble()) + } + + @ReactProp(name = "cancelOnLeave") + override fun setCancelOnLeave(view: ButtonViewGroup, cancelOnLeave: Boolean) { + view.managedHandlerCancelOnLeave = cancelOnLeave + updateManagedHandlerConfig( + view, + Arguments.createMap().apply { + putBoolean("shouldCancelWhenOutside", cancelOnLeave) + }, + ) + } + + @ReactProp(name = "gestureTestID") + override fun setGestureTestID(view: ButtonViewGroup, gestureTestID: String?) { + view.managedHandlerTestID = gestureTestID + updateManagedHandlerConfig( + view, + Arguments.createMap().apply { + putString("testID", gestureTestID) + }, + ) + } + + @ReactProp(name = "gestureHitSlop") + override fun setGestureHitSlop(view: ButtonViewGroup, gestureHitSlop: ReadableMap?) { + view.managedHandlerHitSlop = gestureHitSlop + if (gestureHitSlop != null) { + updateManagedHandlerConfig( + view, + Arguments.createMap().apply { + putMap("hitSlop", gestureHitSlop) + }, + ) + } + } + @ReactProp(name = "hasLongPressHandler") override fun setHasLongPressHandler(view: ButtonViewGroup, hasLongPressHandler: Boolean) { view.hasLongPressHandler = hasLongPressHandler @@ -89,6 +170,13 @@ class RNGestureHandlerButtonViewManager : @ReactProp(name = "enabled") override fun setEnabled(view: ButtonViewGroup, enabled: Boolean) { view.isEnabled = enabled + + updateManagedHandlerConfig( + view, + Arguments.createMap().apply { + putBoolean("enabled", enabled) + }, + ) } @ReactProp(name = "borderWidth") @@ -405,6 +493,16 @@ class RNGestureHandlerButtonViewManager : } var useBorderlessDrawable = false + var managedHandlerTag: Double? = null + + // Config props may be applied before `handlerTag` (prop order is not guaranteed) and + // `updateManagedHandlerConfig` no-ops until the handler exists, so the values are cached + // here and seeded into the config when `setHandlerTag` creates the handler. This also + // re-applies them when the handler is recreated for a new tag. + var managedHandlerCancelOnLeave: Boolean? = null + var managedHandlerTestID: String? = null + var managedHandlerHitSlop: ReadableMap? = null + var exclusive = true var hasLongPressHandler = false var tapAnimationInDuration: Int = 50 @@ -538,7 +636,7 @@ class RNGestureHandlerButtonViewManager : private var lastEventWasInside = false override fun onHandlerUpdate(handler: NativeViewGestureHandler) { - if (handler.isWithinBounds == lastEventWasInside) { + if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) { return } @@ -555,6 +653,10 @@ class RNGestureHandlerButtonViewManager : } override fun onHandlerStateChange(handler: NativeViewGestureHandler, newState: Int, prevState: Int) { + if (managedHandlerTag == null) { + return + } + // Capture local copy, since lastEventWasInside can change during this method // Specifically PressOut -> Press scenario on STATE_END val localLastEventWasInside = lastEventWasInside diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 424dab65d1..1ec9e1c265 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -22,6 +22,17 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { enabled?: boolean | undefined; hasLongPressHandler?: boolean | undefined; + handlerTag?: number | undefined; + cancelOnLeave?: boolean | undefined; + gestureTestID?: string | undefined; + gestureHitSlop?: + | { + top?: number | undefined; + left?: number | undefined; + bottom?: number | undefined; + right?: number | undefined; + } + | undefined; /** * Defines if more than one button could be pressed simultaneously. By default diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 92810bb1ee..8ae32fcd4a 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -27,6 +27,16 @@ interface NativeProps extends ViewProps { | undefined; hasLongPressHandler?: CodegenTypes.WithDefault; + handlerTag?: CodegenTypes.Double | undefined; + cancelOnLeave?: CodegenTypes.WithDefault; + gestureTestID?: string; + gestureHitSlop?: Readonly<{ + top?: CodegenTypes.Double | undefined; + left?: CodegenTypes.Double | undefined; + bottom?: CodegenTypes.Double | undefined; + right?: CodegenTypes.Double | undefined; + }>; + exclusive?: CodegenTypes.WithDefault; foreground?: boolean; borderless?: boolean; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index cf0a11c354..b00c47bfa5 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -1,4 +1,4 @@ -import React, { use, useCallback, useRef } from 'react'; +import React, { use, useCallback, useRef, useState } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { Platform } from 'react-native'; @@ -6,8 +6,7 @@ import GestureHandlerButton, { type ButtonEvent, } from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; -import { NativeDetector } from '../../detectors/NativeDetector'; -import { useNativeGesture } from '../../hooks'; +import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; import { isKeyboardDismissingTap, JSResponderContext, @@ -85,6 +84,7 @@ export const Touchable = (props: TouchableProps) => { ref, ...rest } = props; + const [handlerTag] = useState(() => getNextHandlerTag()); const resolvedDurations = resolveAnimationDuration(animationDuration); const resolvedDelayLongPress = sanitizeDuration(delayLongPress); @@ -150,17 +150,6 @@ export const Touchable = (props: TouchableProps) => { dropKeyboardTapRef.current = null; }, []); - const nativeGesture = useNativeGesture({ - hitSlop: props.hitSlop, - testID: props.testID, - enabled: !disabled, - shouldCancelWhenOutside: cancelOnLeave, - disableReanimated: true, - shouldActivateOnStart: false, - disallowInterruption: true, - yieldsToContinuousGestures: true, - }); - const rippleProps = shouldUseNativeRipple ? { rippleColor: androidRipple?.color, @@ -172,28 +161,40 @@ export const Touchable = (props: TouchableProps) => { const tvProps = getTVProps(rest); + const hitSlop = + typeof props.hitSlop === 'number' + ? { + top: props.hitSlop, + left: props.hitSlop, + bottom: props.hitSlop, + right: props.hitSlop, + } + : (props.hitSlop ?? undefined); + return ( - - - {children} - - + + {children} + ); }; From 496a9c1df551e37aa8ee7387bd89e0f261620d55 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 13:01:18 +0200 Subject: [PATCH 6/6] Make the new impl Android-only --- .../Touchable/Touchable.android.tsx | 200 ++++++++++++++++ .../src/v3/components/Touchable/Touchable.tsx | 216 ++++++++++++------ 2 files changed, 341 insertions(+), 75 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx new file mode 100644 index 0000000000..f694c62189 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.android.tsx @@ -0,0 +1,200 @@ +import React, { use, useCallback, useRef, useState } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; +import { Platform } from 'react-native'; + +import GestureHandlerButton, { + type ButtonEvent, +} from '../../../components/GestureHandlerButton'; +import { getTVProps } from '../../../components/utils'; +import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; +import { + isKeyboardDismissingTap, + JSResponderContext, +} from '../ScrollViewResponderInterceptor'; +import type { AnimationDuration, TouchableProps } from './TouchableProps'; + +const isAndroid = Platform.OS === 'android'; +const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; +const DEFAULT_IN_DURATION_MS = 50; +const DEFAULT_OUT_DURATION_MS = 100; + +// Clamp user-supplied durations to finite, non-negative milliseconds. +// Negative, NaN, or Infinity values would produce invalid CSS transitions +// on web and negative setTimeout delays in branch 3 of the press-out path. +function sanitizeDuration(value: number): number { + return Number.isFinite(value) && value >= 0 ? value : 0; +} + +function resolveAnimationDuration(value: AnimationDuration | undefined) { + if (value === undefined) { + return { + tapAnimationInDuration: DEFAULT_IN_DURATION_MS, + tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + hoverAnimationInDuration: DEFAULT_IN_DURATION_MS, + hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS, + }; + } + + if (typeof value === 'number') { + const sanitized = sanitizeDuration(value); + return { + tapAnimationInDuration: sanitized, + tapAnimationOutDuration: sanitized, + longPressAnimationOutDuration: sanitized, + hoverAnimationInDuration: sanitized, + hoverAnimationOutDuration: sanitized, + }; + } + + // The union guarantees variant 2 supplies top-level `in`/`out`, variant 3 + // supplies both category objects — so per-category fallback to base is + // always defined for well-typed input; the 0 fallbacks here are unreachable. + const baseIn = 'in' in value ? value.in : 0; + const baseOut = 'out' in value ? value.out : 0; + const tapOut = value.tap?.out ?? baseOut; + + return { + tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn), + tapAnimationOutDuration: sanitizeDuration(tapOut), + longPressAnimationOutDuration: sanitizeDuration( + value.longPress?.out ?? tapOut + ), + hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn), + hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut), + }; +} + +export const Touchable = (props: TouchableProps) => { + const { + underlayColor = 'transparent', + defaultUnderlayOpacity = 0, + activeUnderlayOpacity = 0.105, + defaultOpacity = 1, + animationDuration, + androidRipple, + delayLongPress = 600, + onLongPress, + onPress, + onPressIn, + onPressOut, + children, + disabled = false, + cancelOnLeave = true, + ref, + ...rest + } = props; + const [handlerTag] = useState(() => getNextHandlerTag()); + + const resolvedDurations = resolveAnimationDuration(animationDuration); + const resolvedDelayLongPress = sanitizeDuration(delayLongPress); + + const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + + const jsResponderContext = use(JSResponderContext); + const dropKeyboardTapRef = useRef(null); + + const internalOnPress = useCallback( + (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + + onPress?.(e.nativeEvent); + }, + [onPress] + ); + + const internalOnPressIn = useCallback( + (e: NativeSyntheticEvent) => { + // PressIn opens every press sequence; capture the verdict once per + // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't + // overwrite it after the keyboard is already dismissed. + dropKeyboardTapRef.current ??= + isKeyboardDismissingTap(jsResponderContext); + + if (dropKeyboardTapRef.current) { + return; + } + + onPressIn?.(e.nativeEvent); + }, + [jsResponderContext, onPressIn] + ); + + const internalOnPressOut = useCallback( + (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + + onPressOut?.(e.nativeEvent); + }, + [onPressOut] + ); + + const internalOnLongPress = useCallback( + (e: NativeSyntheticEvent) => { + if (dropKeyboardTapRef.current) { + return; + } + + onLongPress?.(e.nativeEvent); + }, + [onLongPress] + ); + + // InteractionFinished is dispatched after the terminal PressOut/Press + // events, so resetting synchronously here is safe. + const internalOnInteractionFinished = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); + + const rippleProps = shouldUseNativeRipple + ? { + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, + } + : TRANSPARENT_RIPPLE; + + const tvProps = getTVProps(rest); + + const hitSlop = + typeof props.hitSlop === 'number' || props.hitSlop == null + ? { + top: props.hitSlop ?? 0, + left: props.hitSlop ?? 0, + bottom: props.hitSlop ?? 0, + right: props.hitSlop ?? 0, + } + : props.hitSlop; + + return ( + + {children} + + ); +}; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index b00c47bfa5..836aba7b4f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -1,23 +1,32 @@ -import React, { use, useCallback, useRef, useState } from 'react'; -import type { NativeSyntheticEvent } from 'react-native'; +import React, { use, useCallback, useRef } from 'react'; import { Platform } from 'react-native'; -import GestureHandlerButton, { - type ButtonEvent, -} from '../../../components/GestureHandlerButton'; +import GestureHandlerButton from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; -import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; +import { NativeDetector } from '../../detectors/NativeDetector'; +import { useNativeGesture } from '../../hooks'; import { isKeyboardDismissingTap, JSResponderContext, } from '../ScrollViewResponderInterceptor'; -import type { AnimationDuration, TouchableProps } from './TouchableProps'; +import type { + AnimationDuration, + CallbackEventType, + EndCallbackEventType, + TouchableProps, +} from './TouchableProps'; const isAndroid = Platform.OS === 'android'; const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; const DEFAULT_IN_DURATION_MS = 50; const DEFAULT_OUT_DURATION_MS = 100; +enum PointerState { + UNKNOWN, + INSIDE, + OUTSIDE, +} + // Clamp user-supplied durations to finite, non-negative milliseconds. // Negative, NaN, or Infinity values would produce invalid CSS transitions // on web and negative setTimeout delays in branch 3 of the press-out path. @@ -84,71 +93,146 @@ export const Touchable = (props: TouchableProps) => { ref, ...rest } = props; - const [handlerTag] = useState(() => getNextHandlerTag()); const resolvedDurations = resolveAnimationDuration(animationDuration); const resolvedDelayLongPress = sanitizeDuration(delayLongPress); const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + const pointerState = useRef(PointerState.UNKNOWN); + const longPressDetected = useRef(false); + const longPressTimeout = useRef | undefined>( + undefined + ); + + // Swallow the tap that dismisses the keyboard in + // keyboardShouldPersistTaps="never", matching RN's touchables. const jsResponderContext = use(JSResponderContext); const dropKeyboardTapRef = useRef(null); - const internalOnPress = useCallback( - (e: NativeSyntheticEvent) => { - if (dropKeyboardTapRef.current) { + const captureKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); + }, [jsResponderContext]); + + const resetKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); + + const wrappedLongPress = useCallback(() => { + longPressDetected.current = true; + // @ts-ignore - the detector-based implementation is missing the event argument + onLongPress?.(); + }, [onLongPress]); + + const startLongPressTimer = useCallback(() => { + longPressDetected.current = false; + + if (onLongPress && !longPressTimeout.current) { + longPressTimeout.current = setTimeout( + wrappedLongPress, + resolvedDelayLongPress + ); + } + }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); + + const onBegin = useCallback( + (e: CallbackEventType) => { + captureKeyboardDismiss(); + + if (!e.pointerInside || dropKeyboardTapRef.current) { + pointerState.current = PointerState.OUTSIDE; return; } - onPress?.(e.nativeEvent); + onPressIn?.(e); + startLongPressTimer(); + + pointerState.current = PointerState.INSIDE; }, - [onPress] + [captureKeyboardDismiss, startLongPressTimer, onPressIn] ); - const internalOnPressIn = useCallback( - (e: NativeSyntheticEvent) => { - // PressIn opens every press sequence; capture the verdict once per - // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't - // overwrite it after the keyboard is already dismissed. - dropKeyboardTapRef.current ??= - isKeyboardDismissingTap(jsResponderContext); + const onActivate = useCallback((e: CallbackEventType) => { + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, []); - if (dropKeyboardTapRef.current) { - return; + const onFinalize = useCallback( + (e: EndCallbackEventType) => { + if ( + !dropKeyboardTapRef.current && + pointerState.current === PointerState.INSIDE + ) { + onPressOut?.(e); } - onPressIn?.(e.nativeEvent); - }, - [jsResponderContext, onPressIn] - ); + if ( + !dropKeyboardTapRef.current && + !e.canceled && + !longPressDetected.current && + e.pointerInside + ) { + onPress?.(e); + } - const internalOnPressOut = useCallback( - (e: NativeSyntheticEvent) => { - if (dropKeyboardTapRef.current) { - return; + pointerState.current = PointerState.UNKNOWN; + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; } - onPressOut?.(e.nativeEvent); + resetKeyboardDismiss(); }, - [onPressOut] + [resetKeyboardDismiss, onPressOut, onPress] ); - const internalOnLongPress = useCallback( - (e: NativeSyntheticEvent) => { + const onUpdate = useCallback( + (e: CallbackEventType) => { if (dropKeyboardTapRef.current) { return; } - onLongPress?.(e.nativeEvent); + if (pointerState.current === PointerState.UNKNOWN) { + return; + } + + if (e.pointerInside) { + if (pointerState.current === PointerState.OUTSIDE) { + onPressIn?.(e); + } + pointerState.current = PointerState.INSIDE; + } else { + if (pointerState.current === PointerState.INSIDE) { + onPressOut?.(e); + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + } + pointerState.current = PointerState.OUTSIDE; + } }, - [onLongPress] + [onPressIn, onPressOut] ); - // InteractionFinished is dispatched after the terminal PressOut/Press - // events, so resetting synchronously here is safe. - const internalOnInteractionFinished = useCallback(() => { - dropKeyboardTapRef.current = null; - }, []); + const nativeGesture = useNativeGesture({ + onBegin, + onActivate, + onFinalize, + onUpdate, + hitSlop: props.hitSlop, + testID: props.testID, + enabled: !disabled, + shouldCancelWhenOutside: cancelOnLeave, + disableReanimated: true, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + }); const rippleProps = shouldUseNativeRipple ? { @@ -161,40 +245,22 @@ export const Touchable = (props: TouchableProps) => { const tvProps = getTVProps(rest); - const hitSlop = - typeof props.hitSlop === 'number' - ? { - top: props.hitSlop, - left: props.hitSlop, - bottom: props.hitSlop, - right: props.hitSlop, - } - : (props.hitSlop ?? undefined); - return ( - - {children} - + + + {children} + + ); };