From 7c6aa7bede9a06d2d867baf35ab10f46dbfc73ac Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 3 Aug 2026 11:35:30 +0200 Subject: [PATCH] [Android] Add hover callbacks to Touchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `Touchable` gains `onHoverIn`/`onHoverOut`, reported for a mouse, trackpad cursor or hovering stylus. The button already tracked hover to drive its animation — this exposes it to JS as the new `onButtonHoverIn`/`onButtonHoverOut` direct events, omitted from `RawButtonProps` since the deprecated buttons never report hover. - Reporting follows `isHovered && isEnabled`, the same expression that drives the hover visual, so disabling a hovered button reports a hover-out and re-enabling it with the pointer still inside reports a hover-in. - No hover events arrive while the button is held, so the transitions are derived from the touch stream during a press — gated so a press can only maintain a hover that was already open, never open one. The pointer type carries over from the previous sample, since those events belong to the pressing pointer. - The payload is snapshotted when the pointer is seen, because the events outlive the `MotionEvent` behind them. ## Test plan `yarn test` covers the prop forwarding; hover itself needs a device or emulator with a mouse, trackpad or stylus.
Example code ```tsx import React, { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureHandlerRootView, Touchable, } from 'react-native-gesture-handler'; export default function Example() { const [log, setLog] = useState([]); const callbacks = (source: string) => ({ onHoverIn: () => setLog((l) => [`${source} onHoverIn`, ...l]), onHoverOut: () => setLog((l) => [`${source} onHoverOut`, ...l]), onPressIn: () => setLog((l) => [`${source} onPressIn`, ...l]), onPressOut: () => setLog((l) => [`${source} onPressOut`, ...l]), }); return ( Touchable Pressable {log.slice(0, 12).map((entry, i) => ( {entry} ))} ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 24 }, row: { flexDirection: 'row', gap: 24, marginBottom: 24 }, box: { width: 120, height: 120, alignItems: 'center', justifyContent: 'center', backgroundColor: '#6941C6', }, text: { color: 'white' }, }); ```
--- .../gesturehandler/core/GestureHandler.kt | 23 +-- .../gesturehandler/react/Extensions.kt | 21 +++ .../RNGestureHandlerButtonViewManager.kt | 177 ++++++++++++++++-- .../events/RNGestureHandlerButtonEvent.kt | 32 ++++ .../src/__tests__/api_v3.test.tsx | 24 +++ .../src/components/GestureHandlerButton.tsx | 14 ++ .../RNGestureHandlerButtonNativeComponent.ts | 2 + .../src/v3/components/GestureButtonsProps.ts | 8 +- .../src/v3/components/Touchable/Touchable.tsx | 25 ++- .../v3/components/Touchable/TouchableProps.ts | 18 +- 10 files changed, 303 insertions(+), 41 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 ad3fd26c2e..dc6c663073 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 @@ -1,15 +1,12 @@ package com.swmansion.gesturehandler.core -import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.graphics.PointF import android.view.MotionEvent import android.view.MotionEvent.PointerCoords import android.view.MotionEvent.PointerProperties import android.view.View import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.UiThreadUtil @@ -20,6 +17,8 @@ import com.swmansion.gesturehandler.RNSVGHitTester import com.swmansion.gesturehandler.react.RNGestureHandlerDetectorView import com.swmansion.gesturehandler.react.events.RNGestureHandlerTouchEvent import com.swmansion.gesturehandler.react.events.eventbuilders.GestureHandlerEventDataBuilder +import com.swmansion.gesturehandler.react.findActivity +import com.swmansion.gesturehandler.react.getPointerType import com.swmansion.gesturehandler.react.isHoverAction import java.lang.IllegalStateException import java.util.* @@ -219,7 +218,7 @@ open class GestureHandler { this.view = view this.orchestrator = orchestrator - val content = getActivity(view?.context)?.findViewById(android.R.id.content) + val content = view?.context.findActivity()?.findViewById(android.R.id.content) if (content != null) { content.getLocationOnScreen(windowOffset) } else { @@ -232,13 +231,6 @@ open class GestureHandler { protected open fun onPrepare() {} - private fun getActivity(context: Context?): Activity? = when (context) { - is ReactContext -> context.currentActivity - is Activity -> context - is ContextWrapper -> getActivity(context.baseContext) - else -> null - } - private fun findNextLocalPointerId(): Int { var localPointerId = 0 while (localPointerId < trackedPointersIDsCount) { @@ -899,14 +891,7 @@ open class GestureHandler { } private fun setPointerType(event: MotionEvent) { - val pointerIndex = event.actionIndex - - pointerType = when (event.getToolType(pointerIndex)) { - MotionEvent.TOOL_TYPE_FINGER -> POINTER_TYPE_TOUCH - MotionEvent.TOOL_TYPE_STYLUS -> POINTER_TYPE_STYLUS - MotionEvent.TOOL_TYPE_MOUSE -> POINTER_TYPE_MOUSE - else -> POINTER_TYPE_OTHER - } + pointerType = event.getPointerType(event.actionIndex) } open fun wantsToAttachDirectlyToView() = false diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt index ef058ab66c..545cc19c37 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt @@ -1,15 +1,27 @@ package com.swmansion.gesturehandler.react +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.view.Display import android.view.MotionEvent import android.view.accessibility.AccessibilityManager import com.facebook.react.bridge.ReactContext import com.facebook.react.modules.core.DeviceEventManagerModule +import com.swmansion.gesturehandler.core.GestureHandler val ReactContext.deviceEventEmitter: DeviceEventManagerModule.RCTDeviceEventEmitter get() = this.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) +// Views are handed a ThemedReactContext, so the activity may sit behind a chain +// of context wrappers. +fun Context?.findActivity(): Activity? = when (this) { + is ReactContext -> currentActivity + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + fun Context.isScreenReaderOn() = (getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager).isTouchExplorationEnabled @@ -20,6 +32,15 @@ fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MO fun MotionEvent.isButtonAction(): Boolean = actionMasked == MotionEvent.ACTION_BUTTON_PRESS || actionMasked == MotionEvent.ACTION_BUTTON_RELEASE +// Defaults to the first pointer, which is the one every single-pointer stream +// (hover included) describes. +fun MotionEvent.getPointerType(pointerIndex: Int = 0): Int = when (getToolType(pointerIndex)) { + MotionEvent.TOOL_TYPE_FINGER -> GestureHandler.POINTER_TYPE_TOUCH + MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> GestureHandler.POINTER_TYPE_STYLUS + MotionEvent.TOOL_TYPE_MOUSE -> GestureHandler.POINTER_TYPE_MOUSE + else -> GestureHandler.POINTER_TYPE_OTHER +} + val Display.minimumFrameTime: Float get() { val supportedModes = this.supportedModes 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 a8f2f350a9..fa62a2b481 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 @@ -79,6 +79,7 @@ class RNGestureHandlerButtonViewManager : view.managedHandlerTestID = null view.managedHandlerHitSlop = null view.moduleId = null + view.resetHoverState() // Has to come last — every setter above flags the view as needing a managed handler update. view.managedHandlerNeedsUpdate = false @@ -613,14 +614,26 @@ class RNGestureHandlerButtonViewManager : private var isHovered = false // Whether a hover was active at press-start. A hovering pointer fires - // ACTION_HOVER_ENTER first (so isHovered is already true at DOWN). + // ACTION_HOVER_ENTER first, so isHovered is already true at DOWN. private var hoverActiveAtPressStart = false - private val shouldAnimateHover get() = isHovered && isEnabled + // Hover events outlive the MotionEvent behind them (the deferred hover-out, + // and `enabled` flipping while hovered), so the position is copied out. + private var lastHoverSample: HoverSample? = null - private val restingOpacity get() = if (shouldAnimateHover) hoverOpacity else defaultOpacity - private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale - private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity + // Content view's screen position, resolved once per hover session the way + // GestureHandler.prepare resolves it once per gesture. + private val hoverWindowOffset = IntArray(2) + + // The hover state JS was last told about, which drifts from [effectiveHover] + // on purpose — see [dispatchHoverEventIfNeeded] and [onDetachedFromWindow]. + private var hoverReported = false + + private val effectiveHover get() = isHovered && isEnabled + + private val restingOpacity get() = if (effectiveHover) hoverOpacity else defaultOpacity + private val restingScale get() = if (effectiveHover) hoverScale else defaultScale + private val restingUnderlayOpacity get() = if (effectiveHover) hoverUnderlayOpacity else defaultUnderlayOpacity private val hasOpacityAnimation get() = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f private val hasScaleAnimation get() = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f @@ -858,18 +871,41 @@ class RNGestureHandlerButtonViewManager : lastAction = action // No hover events arrive while the button is held, so derive hover from - // the touch stream (within bounds). Gated on hoverActiveAtPressStart so - // it only maintains an already-active hover. + // the touch stream — only ever to maintain one that was already open. when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> hoverActiveAtPressStart = isHovered MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, - -> + -> { if (hoverActiveAtPressStart) { - isHovered = isWithinBounds(event) + // A touch event describes the pressing pointer, which on a dual-input + // device isn't the hovering one — a finger drag says nothing about a + // mouse or stylus hovering in its own event stream. Only the pointer + // that opened the hover may maintain it. + val pointerType = event.getPointerType() + + if (pointerType == lastHoverSample?.pointerType) { + isHovered = isWithinBounds(event) + lastHoverSample = hoverSampleFrom(event, pointerType) + dispatchHoverEventIfNeeded() + } + } + } + // A cancel takes the gesture away from the button for good, and the hover + // stream isn't guaranteed to speak again — hover targets were dropped at + // press-start, so a pointer that ends up anywhere else sends this view + // nothing. The hover has to be closed here or it stays open forever; a + // pointer that is still hovering re-opens it on the next hover-in. + MotionEvent.ACTION_CANCEL -> { + if (hoverActiveAtPressStart && event.getPointerType() == lastHoverSample?.pointerType) { + isHovered = false + recordHoverSample(event) + dispatchHoverEventIfNeeded() } - MotionEvent.ACTION_CANCEL -> isHovered = false + + hoverActiveAtPressStart = false + } } val handled = super.onTouchEvent(event) @@ -904,8 +940,8 @@ class RNGestureHandlerButtonViewManager : override fun onHoverEvent(event: MotionEvent): Boolean { when (event.actionMasked) { - MotionEvent.ACTION_HOVER_ENTER -> onHoverIn() - MotionEvent.ACTION_HOVER_EXIT -> onHoverOut() + MotionEvent.ACTION_HOVER_ENTER -> onHoverIn(event) + MotionEvent.ACTION_HOVER_EXIT -> onHoverOut(event) } return super.onHoverEvent(event) @@ -1000,14 +1036,14 @@ class RNGestureHandlerButtonViewManager : return } - if (shouldAnimateHover) { + if (effectiveHover) { animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) } else { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) } } - private fun onHoverIn() { + private fun onHoverIn(event: MotionEvent) { cancelPendingHoverOut() if (isHovered) { @@ -1015,12 +1051,21 @@ class RNGestureHandlerButtonViewManager : } isHovered = true + // Ahead of the first sample — hoverSampleFrom converts with this offset. + captureHoverWindowOffset() + recordHoverSample(event) + dispatchHoverEventIfNeeded() animateHoverState() } - private fun onHoverOut() { + private fun onHoverOut(event: MotionEvent) { if (isPressed) { isHovered = false + // The hover is genuinely over, so stop deriving it — otherwise the next + // ACTION_MOVE re-opens it for the pressing pointer. + hoverActiveAtPressStart = false + recordHoverSample(event) + dispatchHoverEventIfNeeded() return } @@ -1028,9 +1073,14 @@ class RNGestureHandlerButtonViewManager : // Hover-out arrives just before a press-down, so defer a frame to let a // following press-in cancel it and keep the hover state through the press. + // The JS event goes out from the callback too, so a cancelled hover-out + // never reaches JS. + val sample = hoverSampleFrom(event, event.getPointerType()) val callback = Choreographer.FrameCallback { pendingHoverOut = null isHovered = false + lastHoverSample = sample + dispatchHoverEventIfNeeded() animateHoverState() } @@ -1043,6 +1093,81 @@ class RNGestureHandlerButtonViewManager : pendingHoverOut = null } + private fun hoverSampleFrom(event: MotionEvent, pointerType: Int) = HoverSample( + x = event.x, + y = event.y, + absoluteX = event.rawX - hoverWindowOffset[0], + absoluteY = event.rawY - hoverWindowOffset[1], + pointerType = pointerType, + pointerInside = isPointerInside(event.x, event.y), + ) + + // Asked of the handler so `pointerInside` means the same hitSlop-expanded + // rect press events report it from. A sample taken without a handler is + // never dispatched, so the fallback is arbitrary. + private fun isPointerInside(x: Float, y: Float): Boolean { + val moduleId = moduleId ?: return false + val handlerTag = managedHandlerTag ?: return false + val handler = RNGestureHandlerModule.registries[moduleId]?.getHandler(handlerTag) ?: return false + + return handler.isWithinBounds(this, x, y) + } + + // No content view leaves screen coordinates as the best available answer, + // the same fallback GestureHandler.prepare takes. + private fun captureHoverWindowOffset() { + val content = context.findActivity()?.findViewById(android.R.id.content) + if (content != null) { + content.getLocationOnScreen(hoverWindowOffset) + } else { + hoverWindowOffset[0] = 0 + hoverWindowOffset[1] = 0 + } + } + + private fun recordHoverSample(event: MotionEvent) { + lastHoverSample = hoverSampleFrom(event, event.getPointerType()) + } + + fun resetHoverState() { + isHovered = false + hoverReported = false + hoverActiveAtPressStart = false + lastHoverSample = null + } + + /** + * Emits the balancing hover event whenever [hoverReported] drifts from + * [effectiveHover]. Sharing that property with the hover visual is what + * keeps callbacks and appearance in step, so disabling a hovered button + * reports a hover-out and re-enabling it reports a hover-in. + */ + private fun dispatchHoverEventIfNeeded() { + // Only the v3 managed button listens for hover events. Checked before + // `hoverReported` is touched, so a tag attached midway through a hover + // can't leave it claiming a hover-in JS never received. + if (managedHandlerTag == null) { + return + } + + val effective = effectiveHover + + if (effective == hoverReported) { + return + } + + hoverReported = effective + dispatchHoverEvent(if (effective) EventType.HoverIn else EventType.HoverOut) + } + + private fun dispatchHoverEvent(type: EventType) { + val sample = lastHoverSample ?: return + val reactContext = context as? ReactContext ?: return + val eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext) ?: return + + eventDispatcher.dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, sample, type)) + } + private fun isWithinBounds(event: MotionEvent): Boolean = event.x >= 0 && event.y >= 0 && event.x < width && event.y < height @@ -1206,6 +1331,9 @@ class RNGestureHandlerButtonViewManager : cancelPendingHoverOut() currentAnimator?.cancel() currentAnimator = null + // `hoverReported` is deliberately left alone: Fabric reparents by removing + // and re-inserting, so detaching is not proof the pointer left. A genuine + // teardown clears it in `onDropViewInstance` instead. isHovered = false applyStartAnimationState() @@ -1348,6 +1476,8 @@ class RNGestureHandlerButtonViewManager : // The managed handler mirrors the button's enabled state. managedHandlerNeedsUpdate = true + dispatchHoverEventIfNeeded() + if (isHovered) { animateHoverState() } @@ -1403,11 +1533,28 @@ class RNGestureHandlerButtonViewManager : } } + /** + * Snapshot of the hovering pointer, in pixels (events convert to DIP). + * [x]/[y] are relative to the button, [absoluteX]/[absoluteY] to the window + * as it sat when the hover session opened — a window moved mid-hover leaves + * them stale until the pointer leaves and comes back. + */ + data class HoverSample( + val x: Float, + val y: Float, + val absoluteX: Float, + val absoluteY: Float, + val pointerType: Int, + val pointerInside: Boolean, + ) + enum class EventType { Press, PressIn, PressOut, LongPress, + HoverIn, + HoverOut, 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 index 802ff63a16..17f8fa2ac2 100644 --- 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 @@ -32,6 +32,26 @@ class RNGestureHandlerButtonEvent private constructor() : Event 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.HoverIn -> ON_HOVER_IN_EVENT_NAME + RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.HoverOut -> ON_HOVER_OUT_EVENT_NAME RNGestureHandlerButtonViewManager.ButtonViewGroup.EventType.InteractionFinished -> ON_INTERACTION_FINISHED_EVENT_NAME } @@ -77,6 +99,8 @@ class RNGestureHandlerButtonEvent private constructor() : Event { }); describe('Touchable', () => { + test('forwards hover callbacks with the native event payload', () => { + const onHoverIn = jest.fn(); + const onHoverOut = jest.fn(); + + render( + + + + ); + + const button = screen.getByTestId('touchable'); + const hoverInEvent = buttonEvent(true); + const hoverOutEvent = buttonEvent(false); + fireEvent(button, 'buttonHoverIn', hoverInEvent); + fireEvent(button, 'buttonHoverOut', hoverOutEvent); + + expect(onHoverIn).toHaveBeenCalledWith(hoverInEvent.nativeEvent); + expect(onHoverOut).toHaveBeenCalledWith(hoverOutEvent.nativeEvent); + }); + test('calls onPress on successful press', () => { const pressFn = jest.fn(); diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 907271b63f..5c17d14cad 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -69,6 +69,20 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { | ((event: NativeSyntheticEvent) => void) | undefined; + /** + * Called when a non-touch pointer starts hovering over the button. + */ + onButtonHoverIn?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + + /** + * Called when a non-touch pointer stops hovering over the button. + */ + onButtonHoverOut?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + /** * Called when the interaction with the button ends, after any terminal * `onButtonPressOut`/`onButtonPress` events, regardless of how it ended. diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index e29f2e6a77..ad8f412d04 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -25,6 +25,8 @@ interface NativeProps extends ViewProps { onButtonPressIn?: CodegenTypes.DirectEventHandler | undefined; onButtonPressOut?: CodegenTypes.DirectEventHandler | undefined; onButtonLongPress?: CodegenTypes.DirectEventHandler | undefined; + onButtonHoverIn?: CodegenTypes.DirectEventHandler | undefined; + onButtonHoverOut?: CodegenTypes.DirectEventHandler | undefined; onButtonInteractionFinished?: | CodegenTypes.DirectEventHandler | undefined; 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 c1e218dea0..44e6473b02 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -10,13 +10,15 @@ 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. + // The native interaction events are omitted — the deprecated buttons + // drive their press callbacks from the gesture in JS and redeclare them + // with their own signatures, and never report hover at all. | 'onButtonPress' | 'onButtonPressIn' | 'onButtonPressOut' | 'onButtonLongPress' + | 'onButtonHoverIn' + | 'onButtonHoverOut' | 'onButtonInteractionFinished' | 'defaultOpacity' | 'defaultScale' 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 4b3776d7d2..2c7ba4bc63 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, useState } from 'react'; +import React, { use, useCallback, useMemo, useRef, useState } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { Platform } from 'react-native'; @@ -79,6 +79,8 @@ export const Touchable = (props: TouchableProps) => { onPress, onPressIn, onPressOut, + onHoverIn, + onHoverOut, children, disabled = false, cancelOnLeave = true, @@ -157,6 +159,25 @@ export const Touchable = (props: TouchableProps) => { [onLongPress] ); + // Left undefined when the corresponding prop is absent so web can skip + // building a hover payload nobody consumes — it costs a synchronous layout + // read per pointer enter/leave. The native platforms emit either way. + const internalOnHoverIn = useMemo( + () => + onHoverIn + ? (e: NativeSyntheticEvent) => onHoverIn(e.nativeEvent) + : undefined, + [onHoverIn] + ); + + const internalOnHoverOut = useMemo( + () => + onHoverOut + ? (e: NativeSyntheticEvent) => onHoverOut(e.nativeEvent) + : undefined, + [onHoverOut] + ); + // InteractionFinished is dispatched after the terminal PressOut/Press // events, so resetting synchronously here is safe. const internalOnInteractionFinished = useCallback(() => { @@ -208,6 +229,8 @@ export const Touchable = (props: TouchableProps) => { onButtonPressIn={internalOnPressIn} onButtonPressOut={internalOnPressOut} onButtonLongPress={internalOnLongPress} + onButtonHoverIn={internalOnHoverIn} + onButtonHoverOut={internalOnHoverOut} onButtonInteractionFinished={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 df5b1d15d6..a2925c03f7 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,14 +20,16 @@ type PressableAndroidRippleConfig = { type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; -// The press events are redeclared below with the unwrapped `ButtonEvent` +// The interaction events are redeclared below with the unwrapped `ButtonEvent` // signature; `onButtonInteractionFinished` is consumed internally by // `Touchable`. -type PressProps = +type InteractionProps = | 'onButtonPress' | 'onButtonPressIn' | 'onButtonPressOut' | 'onButtonLongPress' + | 'onButtonHoverIn' + | 'onButtonHoverOut' | 'onButtonInteractionFinished'; type DurationProps = @@ -70,7 +72,7 @@ export type AnimationDuration = export type TouchableProps = Omit< ButtonProps, - RippleProps | PressProps | 'enabled' | DurationProps + RippleProps | InteractionProps | 'enabled' | DurationProps > & Omit< BaseButtonProps, @@ -108,6 +110,16 @@ export type TouchableProps = Omit< */ onPressOut?: ((event: ButtonEvent) => void) | undefined; + /** + * Called when a non-touch pointer starts hovering over the component. + */ + onHoverIn?: ((event: ButtonEvent) => void) | undefined; + + /** + * Called when a non-touch pointer stops hovering over the component. + */ + onHoverOut?: ((event: ButtonEvent) => void) | undefined; + /** * Whether the component should ignore touches. By default set to false. */