From 718880f016d277d232014e054d06fcb5305668e7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 3 Aug 2026 11:40:40 +0200 Subject: [PATCH] [Web] Add hover callbacks to Touchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Hover on web comes straight from the `onPointerEnter`/`onPointerLeave` the button already uses for its own animation, rather than through the press pipeline. - Reported from the pointer handlers rather than an effect, so a leave and a re-enter batched into one render still produce both events. The effect only covers `enabled` flipping while the pointer is inside. - `hovered` is now tracked regardless of `enabled` and masked at render, so hover resumes on its own when `enabled` flips back with the pointer still inside — matching the native platforms. - The payload uses the same coordinate basis as `PointerEventManager`'s `mapEvent`. Both reads force a layout flush, so it's only built when a hover callback is actually present. ## Test plan The `.web` variant isn't resolved by the React Native Jest preset, so this needs a browser — with a mouse and with a pen.
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' }, }); ```
--- .../components/GestureHandlerButton.web.tsx | 132 +++++++++++++++++- 1 file changed, 127 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 86bb7e09a5..53774a1826 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -3,6 +3,7 @@ import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native'; import { View } from 'react-native'; import { ActionType } from '../ActionType'; +import { PointerType } from '../PointerType'; import RNGestureHandlerModule from '../RNGestureHandlerModule.web'; import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; import type { ButtonEvent } from '../v3/types'; @@ -10,6 +11,11 @@ import type { PropsRef } from '../web/interfaces'; import { NativeGestureRole } from '../web/interfaces'; import { ButtonEventName } from '../web/tools/ButtonEvents'; import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; +import { + calculateViewScale, + getEffectiveBoundingRect, + PointerTypeMapping, +} from '../web/utils'; const prefersReducedMotion = (): boolean => typeof window !== 'undefined' && @@ -17,6 +23,41 @@ const prefersReducedMotion = (): boolean => const noopGestureEvent = () => undefined; +type ButtonPointerEvent = NativeSyntheticEvent<{ + clientX?: number; + clientY?: number; + pointerType?: string; +}>; + +// Same coordinate basis as the press path (see PointerEventManager's +// `mapEvent`), so the two payloads agree. Both reads below force a layout flush, +// hence the caller-side gate on a hover callback existing. +const buttonEventFromPointerEvent = ( + event: ButtonPointerEvent +): ButtonEvent => { + const view = event.currentTarget as unknown as HTMLElement; + const rect = getEffectiveBoundingRect(view); + const { scaleX, scaleY } = calculateViewScale(view); + const absoluteX = event.nativeEvent.clientX ?? 0; + const absoluteY = event.nativeEvent.clientY ?? 0; + + return { + pointerInside: + absoluteX >= rect.left && + absoluteX <= rect.right && + absoluteY >= rect.top && + absoluteY <= rect.bottom, + x: (absoluteX - rect.left) / scaleX, + y: (absoluteY - rect.top) / scaleY, + absoluteX, + absoluteY, + numberOfPointers: 1, + pointerType: + PointerTypeMapping.get(event.nativeEvent.pointerType ?? '') ?? + PointerType.OTHER, + }; +}; + type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; @@ -62,6 +103,12 @@ type ButtonProps = ViewProps & { onButtonLongPress?: | ((event: NativeSyntheticEvent) => void) | undefined; + onButtonHoverIn?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonHoverOut?: + | ((event: NativeSyntheticEvent) => void) + | undefined; onButtonInteractionFinished?: | ((event: NativeSyntheticEvent) => void) | undefined; @@ -96,6 +143,8 @@ export const ButtonComponent = ({ onButtonPressIn, onButtonPressOut, onButtonLongPress, + onButtonHoverIn, + onButtonHoverOut, onButtonInteractionFinished, style, children, @@ -116,6 +165,12 @@ export const ButtonComponent = ({ null ); const gestureEnabledRef = React.useRef(true); + // Hover events outlive the pointer event behind them (`enabled` flipping while + // hovered), so the payload is copied out. + const hoverSample = React.useRef(null); + // The hover state JS was last told about, which drifts from the effective one + // on purpose — see `dispatchHoverEventIfNeeded`. + const hoverReported = React.useRef(false); const viewRef = React.useRef(null); const gesturePropsRef = React.useRef({ // Managed button handlers dispatch their events through DOM CustomEvents, @@ -379,36 +434,103 @@ export const ButtonComponent = ({ longPressDuration, ]); + // The payload costs a layout read, so only sample when someone is listening. + // Truthiness, so `onHoverIn={cond && handler}` can't slip a `false` past. + const hasHoverCallbacks = + Boolean(onButtonHoverIn) || Boolean(onButtonHoverOut); + + // Emits the balancing hover event whenever `hoverReported` drifts from the + // effective hover state. Recomputed from an argument rather than read off the + // render-scope `effectiveHovered`, because a handler reports a flag React + // hasn't committed to `hovered` yet. + const dispatchHoverEventIfNeeded = React.useCallback( + (isHovered: boolean) => { + const effectiveHovered = isHovered && enabled; + const sample = hoverSample.current; + + if (effectiveHovered === hoverReported.current || sample === null) { + return; + } + + hoverReported.current = effectiveHovered; + const event = { + nativeEvent: sample, + } as NativeSyntheticEvent; + + if (effectiveHovered) { + onButtonHoverIn?.(event); + } else { + onButtonHoverOut?.(event); + } + }, + [enabled, onButtonHoverIn, onButtonHoverOut] + ); + const handlePointerEnter = React.useCallback( - (event: NativeSyntheticEvent<{ pointerType?: string }>) => { - if (!enabled || event.nativeEvent.pointerType === 'touch') { + (event: ButtonPointerEvent) => { + if (event.nativeEvent.pointerType === 'touch') { return; } + + if (hasHoverCallbacks) { + hoverSample.current = buttonEventFromPointerEvent(event); + } + // From the handler rather than the effect below, so a leave and a + // re-enter batched into one render still produce both events. + dispatchHoverEventIfNeeded(true); + // Skip duration update while pressed so the press transition owns it. if (!pressed) { setCurrentDuration(hoverAnimationInDuration); } + // Tracked regardless of `enabled`, which is masked at render and when + // reporting — so hover resumes if it flips back while inside. setHovered(true); }, - [enabled, pressed, hoverAnimationInDuration] + [ + hasHoverCallbacks, + hoverAnimationInDuration, + pressed, + dispatchHoverEventIfNeeded, + ] ); const handlePointerLeave = React.useCallback( - (event: NativeSyntheticEvent<{ pointerType?: string }>) => { + (event: ButtonPointerEvent) => { if (handlerTag === undefined) { pressOut(event); } if (event.nativeEvent.pointerType === 'touch') { return; } + + if (hasHoverCallbacks) { + hoverSample.current = buttonEventFromPointerEvent(event); + } + dispatchHoverEventIfNeeded(false); + if (!pressed) { setCurrentDuration(hoverAnimationOutDuration); } setHovered(false); }, - [handlerTag, hoverAnimationOutDuration, pressOut, pressed] + [ + handlerTag, + hasHoverCallbacks, + hoverAnimationOutDuration, + pressOut, + pressed, + dispatchHoverEventIfNeeded, + ] ); + // The one transition the pointer handlers can't see: `enabled` flipping while + // the pointer is inside. Plain `useEffect` keeps a consumer that toggles + // `enabled` from its own hover callback out of the commit phase. + React.useEffect(() => { + dispatchHoverEventIfNeeded(hovered); + }, [hovered, dispatchHoverEventIfNeeded]); + // Mask hover at render rather than clearing the state. Avoids a state // write inside an effect, and lets hover resume naturally when `enabled` // flips back to true while the pointer is still inside.