Skip to content

Commit 612ef95

Browse files
committed
Handle button press events on web
1 parent f664e82 commit 612ef95

1 file changed

Lines changed: 132 additions & 68 deletions

File tree

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

Lines changed: 132 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import * as React from 'react';
22
import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native';
33
import { View } from 'react-native';
44

5+
import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent';
56
import { NativeGestureRole } from '../web/interfaces';
7+
import { ButtonEventName } from '../web/tools/ButtonEvents';
68
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
79

810
const prefersReducedMotion = (): boolean =>
@@ -28,6 +30,21 @@ type ButtonProps = ViewProps & {
2830
defaultScale?: number;
2931
defaultUnderlayOpacity?: number;
3032
underlayColor?: ColorValue;
33+
onButtonPress?:
34+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
35+
| undefined;
36+
onButtonPressIn?:
37+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
38+
| undefined;
39+
onButtonPressOut?:
40+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
41+
| undefined;
42+
onButtonLongPress?:
43+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
44+
| undefined;
45+
onButtonInteractionFinished?:
46+
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
47+
| undefined;
3148
};
3249

3350
export const ButtonComponent = ({
@@ -49,6 +66,11 @@ export const ButtonComponent = ({
4966
defaultScale = 1,
5067
defaultUnderlayOpacity = 0,
5168
underlayColor,
69+
onButtonPress,
70+
onButtonPressIn,
71+
onButtonPressOut,
72+
onButtonLongPress,
73+
onButtonInteractionFinished,
5274
style,
5375
children,
5476
...rest
@@ -119,73 +141,119 @@ export const ButtonComponent = ({
119141
};
120142
}, []);
121143

122-
const pressIn = React.useCallback(
123-
(event: NativeSyntheticEvent<unknown>) => {
124-
if (!enabled || !gestureEnabledRef.current) {
125-
return;
126-
}
144+
const pressIn = React.useCallback(() => {
145+
if (!enabled || !gestureEnabledRef.current) {
146+
return;
147+
}
127148

128-
event.stopPropagation();
129-
if (pressOutTimer.current != null) {
130-
clearTimeout(pressOutTimer.current);
131-
pressOutTimer.current = null;
132-
}
133-
pressInTimestamp.current = performance.now();
134-
setCurrentDuration(tapAnimationInDuration);
135-
setPressed(true);
136-
},
137-
[enabled, tapAnimationInDuration]
138-
);
149+
if (pressOutTimer.current != null) {
150+
clearTimeout(pressOutTimer.current);
151+
pressOutTimer.current = null;
152+
}
153+
pressInTimestamp.current = performance.now();
154+
setCurrentDuration(tapAnimationInDuration);
155+
setPressed(true);
156+
}, [enabled, tapAnimationInDuration]);
139157

140-
const pressOut = React.useCallback(
141-
(event: NativeSyntheticEvent<unknown>) => {
142-
// Only release if a press-in was actually recorded — guards against
143-
// stray pointer events and lets us complete the release cycle even if
144-
// `enabled` flipped to false between press-in and press-out.
145-
if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) {
146-
return;
147-
}
158+
const pressOut = React.useCallback(() => {
159+
// Only release if a press-in was actually recorded — guards against
160+
// stray pointer events and lets us complete the release cycle even if
161+
// `enabled` flipped to false between press-in and press-out.
162+
if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) {
163+
return;
164+
}
148165

149-
event.stopPropagation();
150-
if (pressOutTimer.current != null) {
151-
clearTimeout(pressOutTimer.current);
152-
pressOutTimer.current = null;
153-
}
154-
const elapsed = performance.now() - pressInTimestamp.current;
155-
pressInTimestamp.current = 0;
166+
if (pressOutTimer.current != null) {
167+
clearTimeout(pressOutTimer.current);
168+
pressOutTimer.current = null;
169+
}
170+
const elapsed = performance.now() - pressInTimestamp.current;
171+
pressInTimestamp.current = 0;
156172

157-
if (longPressDuration >= 0 && elapsed >= longPressDuration) {
158-
// Long-press release — use the configured long-press out duration.
159-
setCurrentDuration(longPressAnimationOutDuration);
160-
setPressed(false);
161-
} else if (elapsed >= tapAnimationInDuration) {
162-
// Press-in animation fully finished - release with the configured out duration.
163-
setCurrentDuration(tapAnimationOutDuration);
164-
setPressed(false);
165-
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
166-
} else if (elapsed * 2 >= tapAnimationOutDuration) {
167-
setCurrentDuration(elapsed);
168-
setPressed(false);
169-
} else {
170-
// Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
171-
const remaining = tapAnimationInDuration - elapsed;
172-
pressOutTimer.current = setTimeout(
173-
() => {
174-
pressOutTimer.current = null;
175-
setCurrentDuration(tapAnimationOutDuration);
176-
setPressed(false);
177-
},
178-
prefersReducedMotion() ? 0 : remaining
179-
);
180-
}
181-
},
182-
[
183-
longPressDuration,
184-
longPressAnimationOutDuration,
185-
tapAnimationInDuration,
186-
tapAnimationOutDuration,
187-
]
188-
);
173+
if (longPressDuration >= 0 && elapsed >= longPressDuration) {
174+
// Long-press release — use the configured long-press out duration.
175+
setCurrentDuration(longPressAnimationOutDuration);
176+
setPressed(false);
177+
} else if (elapsed >= tapAnimationInDuration) {
178+
// Press-in animation fully finished - release with the configured out duration.
179+
setCurrentDuration(tapAnimationOutDuration);
180+
setPressed(false);
181+
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
182+
} else if (elapsed * 2 >= tapAnimationOutDuration) {
183+
setCurrentDuration(elapsed);
184+
setPressed(false);
185+
} else {
186+
// Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
187+
const remaining = tapAnimationInDuration - elapsed;
188+
pressOutTimer.current = setTimeout(
189+
() => {
190+
pressOutTimer.current = null;
191+
setCurrentDuration(tapAnimationOutDuration);
192+
setPressed(false);
193+
},
194+
prefersReducedMotion() ? 0 : remaining
195+
);
196+
}
197+
}, [
198+
longPressDuration,
199+
longPressAnimationOutDuration,
200+
tapAnimationInDuration,
201+
tapAnimationOutDuration,
202+
]);
203+
204+
React.useEffect(() => {
205+
const node = viewRef.current;
206+
const wrapEvent = (event: Event): NativeSyntheticEvent<ButtonEvent> =>
207+
({
208+
nativeEvent: (event as CustomEvent<ButtonEvent>).detail,
209+
}) as NativeSyntheticEvent<ButtonEvent>;
210+
211+
const handlePress = (event: Event) => {
212+
onButtonPress?.(wrapEvent(event));
213+
};
214+
const handlePressIn = (event: Event) => {
215+
pressIn();
216+
onButtonPressIn?.(wrapEvent(event));
217+
};
218+
const handlePressOut = (event: Event) => {
219+
pressOut();
220+
onButtonPressOut?.(wrapEvent(event));
221+
};
222+
const handleLongPress = (event: Event) => {
223+
onButtonLongPress?.(wrapEvent(event));
224+
};
225+
const handleInteractionFinished = (event: Event) => {
226+
onButtonInteractionFinished?.(wrapEvent(event));
227+
};
228+
229+
node?.addEventListener(ButtonEventName.Press, handlePress);
230+
node?.addEventListener(ButtonEventName.PressIn, handlePressIn);
231+
node?.addEventListener(ButtonEventName.PressOut, handlePressOut);
232+
node?.addEventListener(ButtonEventName.LongPress, handleLongPress);
233+
node?.addEventListener(
234+
ButtonEventName.InteractionFinished,
235+
handleInteractionFinished
236+
);
237+
238+
return () => {
239+
node?.removeEventListener(ButtonEventName.Press, handlePress);
240+
node?.removeEventListener(ButtonEventName.PressIn, handlePressIn);
241+
node?.removeEventListener(ButtonEventName.PressOut, handlePressOut);
242+
node?.removeEventListener(ButtonEventName.LongPress, handleLongPress);
243+
node?.removeEventListener(
244+
ButtonEventName.InteractionFinished,
245+
handleInteractionFinished
246+
);
247+
};
248+
}, [
249+
onButtonInteractionFinished,
250+
onButtonLongPress,
251+
onButtonPress,
252+
onButtonPressIn,
253+
onButtonPressOut,
254+
pressIn,
255+
pressOut,
256+
]);
189257

190258
const handlePointerEnter = React.useCallback(
191259
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
@@ -203,7 +271,6 @@ export const ButtonComponent = ({
203271

204272
const handlePointerLeave = React.useCallback(
205273
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
206-
pressOut(event);
207274
if (event.nativeEvent.pointerType === 'touch') {
208275
return;
209276
}
@@ -212,7 +279,7 @@ export const ButtonComponent = ({
212279
}
213280
setHovered(false);
214281
},
215-
[pressOut, pressed, hoverAnimationOutDuration]
282+
[pressed, hoverAnimationOutDuration]
216283
);
217284

218285
// Mask hover at render rather than clearing the state. Avoids a state
@@ -268,9 +335,6 @@ export const ButtonComponent = ({
268335
},
269336
]}
270337
onPointerEnter={handlePointerEnter}
271-
onPointerDown={pressIn}
272-
onPointerUp={pressOut}
273-
onPointerCancel={pressOut}
274338
onPointerLeave={handlePointerLeave}>
275339
{hasUnderlay && (
276340
<View

0 commit comments

Comments
 (0)