Skip to content

Commit c24a2eb

Browse files
committed
[Web] Add hover callbacks to Touchable
## 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. <details> <summary>Example code</summary> ```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<string[]>([]); 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 ( <GestureHandlerRootView style={styles.container}> <View style={styles.row}> <Touchable style={styles.box} {...callbacks('Touchable')}> <Text style={styles.text}>Touchable</Text> </Touchable> <Pressable style={styles.box} {...callbacks('Pressable')}> <Text style={styles.text}>Pressable</Text> </Pressable> </View> {log.slice(0, 12).map((entry, i) => ( <Text key={i}>{entry}</Text> ))} </GestureHandlerRootView> ); } 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' }, }); ``` </details>
1 parent 2dcad4a commit c24a2eb

1 file changed

Lines changed: 127 additions & 5 deletions

File tree

‎packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx‎

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,61 @@ import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native';
33
import { View } from 'react-native';
44

55
import { ActionType } from '../ActionType';
6+
import { PointerType } from '../PointerType';
67
import RNGestureHandlerModule from '../RNGestureHandlerModule.web';
78
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect';
89
import type { ButtonEvent } from '../v3/types';
910
import type { PropsRef } from '../web/interfaces';
1011
import { NativeGestureRole } from '../web/interfaces';
1112
import { ButtonEventName } from '../web/tools/ButtonEvents';
1213
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
14+
import {
15+
calculateViewScale,
16+
getEffectiveBoundingRect,
17+
PointerTypeMapping,
18+
} from '../web/utils';
1319

1420
const prefersReducedMotion = (): boolean =>
1521
typeof window !== 'undefined' &&
1622
!!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
1723

1824
const noopGestureEvent = () => undefined;
1925

26+
type ButtonPointerEvent = NativeSyntheticEvent<{
27+
clientX?: number;
28+
clientY?: number;
29+
pointerType?: string;
30+
}>;
31+
32+
// Same coordinate basis as the press path (see PointerEventManager's
33+
// `mapEvent`), so the two payloads agree. Both reads below force a layout flush,
34+
// hence the caller-side gate on a hover callback existing.
35+
const buttonEventFromPointerEvent = (
36+
event: ButtonPointerEvent
37+
): ButtonEvent => {
38+
const view = event.currentTarget as unknown as HTMLElement;
39+
const rect = getEffectiveBoundingRect(view);
40+
const { scaleX, scaleY } = calculateViewScale(view);
41+
const absoluteX = event.nativeEvent.clientX ?? 0;
42+
const absoluteY = event.nativeEvent.clientY ?? 0;
43+
44+
return {
45+
pointerInside:
46+
absoluteX >= rect.left &&
47+
absoluteX <= rect.right &&
48+
absoluteY >= rect.top &&
49+
absoluteY <= rect.bottom,
50+
x: (absoluteX - rect.left) / scaleX,
51+
y: (absoluteY - rect.top) / scaleY,
52+
absoluteX,
53+
absoluteY,
54+
numberOfPointers: 1,
55+
pointerType:
56+
PointerTypeMapping.get(event.nativeEvent.pointerType ?? '') ??
57+
PointerType.OTHER,
58+
};
59+
};
60+
2061
type ButtonProps = ViewProps & {
2162
ref?: React.Ref<React.ComponentRef<typeof View>>;
2263
enabled?: boolean;
@@ -62,6 +103,12 @@ type ButtonProps = ViewProps & {
62103
onButtonLongPress?:
63104
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
64105
| undefined;
106+
onButtonHoverIn?:
107+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
108+
| undefined;
109+
onButtonHoverOut?:
110+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
111+
| undefined;
65112
onButtonInteractionFinished?:
66113
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
67114
| undefined;
@@ -96,6 +143,8 @@ export const ButtonComponent = ({
96143
onButtonPressIn,
97144
onButtonPressOut,
98145
onButtonLongPress,
146+
onButtonHoverIn,
147+
onButtonHoverOut,
99148
onButtonInteractionFinished,
100149
style,
101150
children,
@@ -116,6 +165,12 @@ export const ButtonComponent = ({
116165
null
117166
);
118167
const gestureEnabledRef = React.useRef(true);
168+
// Hover events outlive the pointer event behind them (`enabled` flipping while
169+
// hovered), so the payload is copied out.
170+
const hoverSample = React.useRef<ButtonEvent | null>(null);
171+
// The hover state JS was last told about, which drifts from the effective one
172+
// on purpose — see `dispatchHoverEventIfNeeded`.
173+
const hoverReported = React.useRef(false);
119174
const viewRef = React.useRef<HTMLElement | null>(null);
120175
const gesturePropsRef = React.useRef<PropsRef>({
121176
// Managed button handlers dispatch their events through DOM CustomEvents,
@@ -379,36 +434,103 @@ export const ButtonComponent = ({
379434
longPressDuration,
380435
]);
381436

437+
// The payload costs a layout read, so only sample when someone is listening.
438+
// Truthiness, so `onHoverIn={cond && handler}` can't slip a `false` past.
439+
const hasHoverCallbacks =
440+
Boolean(onButtonHoverIn) || Boolean(onButtonHoverOut);
441+
442+
// Emits the balancing hover event whenever `hoverReported` drifts from the
443+
// effective hover state. Recomputed from an argument rather than read off the
444+
// render-scope `effectiveHovered`, because a handler reports a flag React
445+
// hasn't committed to `hovered` yet.
446+
const dispatchHoverEventIfNeeded = React.useCallback(
447+
(isHovered: boolean) => {
448+
const effectiveHovered = isHovered && enabled;
449+
const sample = hoverSample.current;
450+
451+
if (effectiveHovered === hoverReported.current || sample === null) {
452+
return;
453+
}
454+
455+
hoverReported.current = effectiveHovered;
456+
const event = {
457+
nativeEvent: sample,
458+
} as NativeSyntheticEvent<ButtonEvent>;
459+
460+
if (effectiveHovered) {
461+
onButtonHoverIn?.(event);
462+
} else {
463+
onButtonHoverOut?.(event);
464+
}
465+
},
466+
[enabled, onButtonHoverIn, onButtonHoverOut]
467+
);
468+
382469
const handlePointerEnter = React.useCallback(
383-
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
384-
if (!enabled || event.nativeEvent.pointerType === 'touch') {
470+
(event: ButtonPointerEvent) => {
471+
if (event.nativeEvent.pointerType === 'touch') {
385472
return;
386473
}
474+
475+
if (hasHoverCallbacks) {
476+
hoverSample.current = buttonEventFromPointerEvent(event);
477+
}
478+
// From the handler rather than the effect below, so a leave and a
479+
// re-enter batched into one render still produce both events.
480+
dispatchHoverEventIfNeeded(true);
481+
387482
// Skip duration update while pressed so the press transition owns it.
388483
if (!pressed) {
389484
setCurrentDuration(hoverAnimationInDuration);
390485
}
486+
// Tracked regardless of `enabled`, which is masked at render and when
487+
// reporting — so hover resumes if it flips back while inside.
391488
setHovered(true);
392489
},
393-
[enabled, pressed, hoverAnimationInDuration]
490+
[
491+
hasHoverCallbacks,
492+
hoverAnimationInDuration,
493+
pressed,
494+
dispatchHoverEventIfNeeded,
495+
]
394496
);
395497

396498
const handlePointerLeave = React.useCallback(
397-
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
499+
(event: ButtonPointerEvent) => {
398500
if (handlerTag === undefined) {
399501
pressOut(event);
400502
}
401503
if (event.nativeEvent.pointerType === 'touch') {
402504
return;
403505
}
506+
507+
if (hasHoverCallbacks) {
508+
hoverSample.current = buttonEventFromPointerEvent(event);
509+
}
510+
dispatchHoverEventIfNeeded(false);
511+
404512
if (!pressed) {
405513
setCurrentDuration(hoverAnimationOutDuration);
406514
}
407515
setHovered(false);
408516
},
409-
[handlerTag, hoverAnimationOutDuration, pressOut, pressed]
517+
[
518+
handlerTag,
519+
hasHoverCallbacks,
520+
hoverAnimationOutDuration,
521+
pressOut,
522+
pressed,
523+
dispatchHoverEventIfNeeded,
524+
]
410525
);
411526

527+
// The one transition the pointer handlers can't see: `enabled` flipping while
528+
// the pointer is inside. Plain `useEffect` keeps a consumer that toggles
529+
// `enabled` from its own hover callback out of the commit phase.
530+
React.useEffect(() => {
531+
dispatchHoverEventIfNeeded(hovered);
532+
}, [hovered, dispatchHoverEventIfNeeded]);
533+
412534
// Mask hover at render rather than clearing the state. Avoids a state
413535
// write inside an effect, and lets hover resume naturally when `enabled`
414536
// flips back to true while the pointer is still inside.

0 commit comments

Comments
 (0)