Skip to content

Commit 3d994d3

Browse files
authored
[Web] Cancel button gesture when other handlers are extracted before it (#4128)
## Description Applies the same solution for the problem described in #4127. There's no native gesture hooks mechanism on web, so the check happens directly in `NativeViewGestureHandler`. Since the button feedback animation is independent of the gesture logic currently, I added custom DOM events dispatched by `NativeViewGestureHandler` when it begins and gets canceled. This way, the button can block/enable the feedback depending on gesture state. ## Test plan Tested on the example from #4127 |Before|After| |-|-| |<video src="https://github.com/user-attachments/assets/1304c59d-4464-410d-a9f2-8508a298d946" />|<video src="https://github.com/user-attachments/assets/863c454f-ec8f-471e-bfad-8cfec85eb504" />|
1 parent 9b87dbc commit 3d994d3

6 files changed

Lines changed: 110 additions & 4 deletions

File tree

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

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

5+
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
6+
57
type ButtonProps = ViewProps & {
68
ref?: React.Ref<React.ComponentRef<typeof View>>;
79
enabled?: boolean;
@@ -17,6 +19,7 @@ type ButtonProps = ViewProps & {
1719
};
1820

1921
export const ButtonComponent = ({
22+
ref: externalRef,
2023
enabled = true,
2124
pressAndHoldAnimationDuration: pressAndHoldAnimationDurationProp = -1,
2225
tapAnimationDuration: tapAnimationDurationProp = 100,
@@ -46,9 +49,52 @@ export const ButtonComponent = ({
4649
const pressOutTimer = React.useRef<ReturnType<typeof setTimeout> | null>(
4750
null
4851
);
52+
const gestureEnabledRef = React.useRef(true);
53+
const viewRef = React.useRef<HTMLElement | null>(null);
54+
55+
const setRef = React.useCallback(
56+
(node: React.ComponentRef<typeof View> | null) => {
57+
viewRef.current = node as unknown as HTMLElement | null;
58+
if (typeof externalRef === 'function') {
59+
externalRef(node);
60+
} else if (externalRef != null) {
61+
externalRef.current = node;
62+
}
63+
},
64+
[externalRef]
65+
);
4966

5067
React.useEffect(() => {
68+
const node = viewRef.current;
69+
70+
const handleGestureBegan = () => {
71+
gestureEnabledRef.current = true;
72+
};
73+
const handleGestureCanceled = () => {
74+
gestureEnabledRef.current = false;
75+
if (pressOutTimer.current != null) {
76+
clearTimeout(pressOutTimer.current);
77+
pressOutTimer.current = null;
78+
}
79+
pressInTimestamp.current = 0;
80+
setPressed(false);
81+
};
82+
83+
node?.addEventListener(GestureLifecycleEvent.Began, handleGestureBegan);
84+
node?.addEventListener(
85+
GestureLifecycleEvent.Canceled,
86+
handleGestureCanceled
87+
);
88+
5189
return () => {
90+
node?.removeEventListener(
91+
GestureLifecycleEvent.Began,
92+
handleGestureBegan
93+
);
94+
node?.removeEventListener(
95+
GestureLifecycleEvent.Canceled,
96+
handleGestureCanceled
97+
);
5298
if (pressOutTimer.current != null) {
5399
clearTimeout(pressOutTimer.current);
54100
}
@@ -57,7 +103,7 @@ export const ButtonComponent = ({
57103

58104
const pressIn = React.useCallback(
59105
(event: NativeSyntheticEvent<unknown>) => {
60-
if (!enabled) {
106+
if (!enabled || !gestureEnabledRef.current) {
61107
return;
62108
}
63109

@@ -78,7 +124,7 @@ export const ButtonComponent = ({
78124
// Only release if a press-in was actually recorded — guards against
79125
// stray pointer events and lets us complete the release cycle even if
80126
// `enabled` flipped to false between press-in and press-out.
81-
if (pressInTimestamp.current === 0) {
127+
if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) {
82128
return;
83129
}
84130

@@ -131,6 +177,7 @@ export const ButtonComponent = ({
131177

132178
return (
133179
<View
180+
ref={setRef}
134181
accessibilityRole="button"
135182
style={[
136183
style,

packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ export default abstract class GestureHandler implements IGestureHandler {
303303
);
304304
}
305305

306+
public shouldBeginWithRecordedHandlers(
307+
_recorded: IGestureHandler[]
308+
): boolean {
309+
return true;
310+
}
311+
306312
public shouldAttachGestureToChildView(): boolean {
307313
return false;
308314
}

packages/react-native-gesture-handler/src/web/handlers/IGestureHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default interface IGestureHandler {
5353
shouldRequireToWaitForFailure: (handler: IGestureHandler) => boolean;
5454
shouldRecognizeSimultaneously: (handler: IGestureHandler) => boolean;
5555
shouldBeCancelledByOther: (handler: IGestureHandler) => boolean;
56+
shouldBeginWithRecordedHandlers: (recorded: IGestureHandler[]) => boolean;
5657
shouldAttachGestureToChildView: () => boolean;
5758

5859
sendEvent: (newState: State, oldState: State) => void;

packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { SingleGestureName } from '../../v3/types';
99
import { DEFAULT_TOUCH_SLOP } from '../constants';
1010
import type { AdaptedEvent, Config, PropsRef } from '../interfaces';
1111
import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate';
12+
import {
13+
dispatchGestureLifecycleEvent,
14+
GestureLifecycleEvent,
15+
} from '../tools/GestureLifecycleEvents';
1216
import GestureHandler from './GestureHandler';
1317
import type IGestureHandler from './IGestureHandler';
1418

@@ -101,6 +105,11 @@ export default class NativeViewGestureHandler extends GestureHandler {
101105

102106
this.begin();
103107

108+
dispatchGestureLifecycleEvent(
109+
this.delegate.view as HTMLElement | null,
110+
GestureLifecycleEvent.Began
111+
);
112+
104113
const view = this.delegate.view as HTMLElement;
105114
const isRNGHText = view.hasAttribute('rnghtext');
106115

@@ -208,6 +217,31 @@ export default class NativeViewGestureHandler extends GestureHandler {
208217
return this.buttonRole;
209218
}
210219

220+
public override shouldBeginWithRecordedHandlers(
221+
recorded: IGestureHandler[]
222+
): boolean {
223+
if (!this.isButton()) {
224+
return true;
225+
}
226+
227+
const self = this as IGestureHandler;
228+
return recorded.every(
229+
(other) =>
230+
other.shouldRecognizeSimultaneously(self) ||
231+
self.shouldRecognizeSimultaneously(other) ||
232+
other.delegate.view === this.delegate.view ||
233+
other.name === SingleGestureName.Hover
234+
);
235+
}
236+
237+
protected override onCancel(): void {
238+
super.onCancel();
239+
dispatchGestureLifecycleEvent(
240+
this.delegate.view as HTMLElement | null,
241+
GestureLifecycleEvent.Canceled
242+
);
243+
}
244+
211245
protected override transformNativeEvent(): Record<string, unknown> {
212246
return {
213247
pointerInside: this.delegate.isPointerInBounds(

packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,15 @@ export default class GestureHandlerOrchestrator {
270270
return;
271271
}
272272

273-
this.gestureHandlers.push(handler);
274-
275273
handler.active = false;
276274
handler.awaiting = false;
277275
handler.activationIndex = Number.MAX_SAFE_INTEGER;
276+
277+
if (!handler.shouldBeginWithRecordedHandlers(this.gestureHandlers)) {
278+
handler.cancel();
279+
}
280+
281+
this.gestureHandlers.push(handler);
278282
}
279283

280284
private shouldHandlerWaitForOther(
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const GestureLifecycleEvent = {
2+
Began: 'gh:gestureBegan',
3+
Canceled: 'gh:gestureCanceled',
4+
} as const;
5+
6+
export type GestureLifecycleEventName =
7+
(typeof GestureLifecycleEvent)[keyof typeof GestureLifecycleEvent];
8+
9+
export function dispatchGestureLifecycleEvent(
10+
view: HTMLElement | null | undefined,
11+
name: GestureLifecycleEventName
12+
): void {
13+
view?.dispatchEvent(new CustomEvent(name));
14+
}

0 commit comments

Comments
 (0)