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/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 { 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..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,8 +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 @@ -35,6 +38,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 +52,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 +68,90 @@ 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 + } + @ReactProp(name = "foreground") override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) { view.useDrawableOnForeground = useDrawableOnForeground @@ -81,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") @@ -397,7 +493,18 @@ 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 var tapAnimationOutDuration: Int = 100 var longPressDuration: Int = -1 @@ -436,6 +543,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 +628,92 @@ 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 (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) { + return + } + + if (handler.isWithinBounds) { + dispatchJSEvent(EventType.PressIn, handler) + } else { + dispatchJSEvent(EventType.PressOut, handler) + + pendingLongPress?.let { + this.handler?.removeCallbacks(it) + pendingLongPress = null + } + } + } + + 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 + + if (newState == GestureHandler.STATE_BEGAN) { + dispatchJSEvent(EventType.PressIn, handler) + longPressDetected = false + + if (hasLongPressHandler && longPressDuration >= 0) { + val runnable = Runnable { + pendingLongPress = null + longPressDetected = true + dispatchJSEvent(EventType.LongPress, handler) + } + 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, handler) + } + + pendingLongPress?.let { + this.handler?.removeCallbacks(it) + pendingLongPress = null + } + } + + if (newState == GestureHandler.STATE_END && !longPressDetected && localLastEventWasInside) { + dispatchJSEvent(EventType.Press, handler) + } + + if (newState == GestureHandler.STATE_END || + newState == GestureHandler.STATE_FAILED || + newState == GestureHandler.STATE_CANCELLED + ) { + dispatchJSEvent(EventType.InteractionFinished, handler) + } + } + + 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 + } + } + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { if (super.onInterceptTouchEvent(event)) { return true @@ -912,6 +1106,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 +1300,14 @@ class RNGestureHandlerButtonViewManager : } } } + + enum class EventType { + Press, + PressIn, + PressOut, + LongPress, + InteractionFinished, + } } companion object { 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..1ec9e1c265 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -3,12 +3,15 @@ import type { ColorValue, HostComponent, LayoutChangeEvent, + NativeSyntheticEvent, StyleProp, ViewProps, 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,12 +21,55 @@ 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 * set true. */ 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. * @@ -203,3 +249,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..8ae32fcd4a 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -6,8 +6,37 @@ 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; + 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/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.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 ff14e4da49..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 @@ -120,6 +120,7 @@ export const Touchable = (props: TouchableProps) => { const wrappedLongPress = useCallback(() => { longPressDetected.current = true; + // @ts-ignore - the detector-based implementation is missing the event argument onLongPress?.(); }, [onLongPress]); 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..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 @@ -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'; @@ -17,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' @@ -57,11 +69,11 @@ export type AnimationDuration = export type TouchableProps = Omit< ButtonProps, - RippleProps | 'enabled' | DurationProps + RippleProps | PressProps | 'enabled' | DurationProps > & Omit< BaseButtonProps, - keyof RawButtonProps | 'onActiveStateChange' | 'onPress' + keyof RawButtonProps | 'onActiveStateChange' | 'onPress' | 'onLongPress' > & { /** * Press and hover animation durations, in milliseconds. Pass a single @@ -78,17 +90,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.