Skip to content

Commit 960072b

Browse files
committed
Attach native gesture directly to button on web
1 parent 612ef95 commit 960072b

6 files changed

Lines changed: 254 additions & 92 deletions

File tree

packages/react-native-gesture-handler/src/ActionType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const ActionType = {
2+
NONE: 0,
23
REANIMATED_WORKLET: 1,
34
NATIVE_ANIMATED_EVENT: 2,
45
JS_FUNCTION_OLD_API: 3,

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

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

5+
import { ActionType } from '../ActionType';
6+
import RNGestureHandlerModule from '../RNGestureHandlerModule.web';
57
import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent';
8+
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect';
9+
import type { PropsRef } from '../web/interfaces';
610
import { NativeGestureRole } from '../web/interfaces';
711
import { ButtonEventName } from '../web/tools/ButtonEvents';
812
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
@@ -11,6 +15,8 @@ const prefersReducedMotion = (): boolean =>
1115
typeof window !== 'undefined' &&
1216
!!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
1317

18+
const noopGestureEvent = () => undefined;
19+
1420
type ButtonProps = ViewProps & {
1521
ref?: React.Ref<React.ComponentRef<typeof View>>;
1622
enabled?: boolean;
@@ -30,6 +36,19 @@ type ButtonProps = ViewProps & {
3036
defaultScale?: number;
3137
defaultUnderlayOpacity?: number;
3238
underlayColor?: ColorValue;
39+
hasLongPressHandler?: boolean;
40+
moduleId?: number;
41+
handlerTag?: number;
42+
cancelOnLeave?: boolean;
43+
gestureTestID?: string;
44+
gestureHitSlop?:
45+
| {
46+
top?: number;
47+
left?: number;
48+
bottom?: number;
49+
right?: number;
50+
}
51+
| undefined;
3352
onButtonPress?:
3453
| ((event: NativeSyntheticEvent<ButtonEvent>) => void)
3554
| undefined;
@@ -66,6 +85,12 @@ export const ButtonComponent = ({
6685
defaultScale = 1,
6786
defaultUnderlayOpacity = 0,
6887
underlayColor,
88+
hasLongPressHandler = false,
89+
moduleId: _moduleId,
90+
handlerTag,
91+
cancelOnLeave = true,
92+
gestureTestID,
93+
gestureHitSlop,
6994
onButtonPress,
7095
onButtonPressIn,
7196
onButtonPressOut,
@@ -91,6 +116,37 @@ export const ButtonComponent = ({
91116
);
92117
const gestureEnabledRef = React.useRef(true);
93118
const viewRef = React.useRef<HTMLElement | null>(null);
119+
const gesturePropsRef = React.useRef<PropsRef>({
120+
// Managed button handlers dispatch their events through DOM CustomEvents,
121+
// so ActionType.NONE prevents these callbacks from being used.
122+
// They remain present because the web module shares its attachment API
123+
// with regular JS-driven gesture handlers.
124+
onGestureHandlerEvent: noopGestureEvent,
125+
onGestureHandlerStateChange: noopGestureEvent,
126+
onGestureHandlerTouchEvent: noopGestureEvent,
127+
});
128+
const managedGestureConfigRef = React.useRef({
129+
enabled,
130+
shouldCancelWhenOutside: cancelOnLeave,
131+
shouldActivateOnStart: false,
132+
disallowInterruption: true,
133+
yieldsToContinuousGestures: true,
134+
testID: gestureTestID,
135+
hitSlop: gestureHitSlop,
136+
hasLongPressHandler,
137+
longPressDuration,
138+
});
139+
managedGestureConfigRef.current = {
140+
enabled,
141+
shouldCancelWhenOutside: cancelOnLeave,
142+
shouldActivateOnStart: false,
143+
disallowInterruption: true,
144+
yieldsToContinuousGestures: true,
145+
testID: gestureTestID,
146+
hitSlop: gestureHitSlop,
147+
hasLongPressHandler,
148+
longPressDuration,
149+
};
94150

95151
const setRef = React.useCallback(
96152
(node: React.ComponentRef<typeof View> | null) => {
@@ -104,7 +160,7 @@ export const ButtonComponent = ({
104160
[externalRef]
105161
);
106162

107-
React.useEffect(() => {
163+
useIsomorphicLayoutEffect(() => {
108164
const node = viewRef.current;
109165

110166
const handleGestureBegan = () => {
@@ -141,67 +197,86 @@ export const ButtonComponent = ({
141197
};
142198
}, []);
143199

144-
const pressIn = React.useCallback(() => {
145-
if (!enabled || !gestureEnabledRef.current) {
146-
return;
147-
}
200+
const pressIn = React.useCallback(
201+
(event?: NativeSyntheticEvent<unknown>) => {
202+
const isManagedButtonEvent = event === undefined;
148203

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]);
157-
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-
}
204+
if (!enabled || (!isManagedButtonEvent && !gestureEnabledRef.current)) {
205+
return;
206+
}
165207

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;
208+
// Managed button events are emitted before the Began lifecycle event.
209+
// Unmanaged buttons are still driven by their wrapping native gesture,
210+
// so they must keep honoring its cancellation state.
211+
if (isManagedButtonEvent) {
212+
gestureEnabledRef.current = true;
213+
}
172214

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-
]);
215+
event?.stopPropagation();
216+
217+
if (pressOutTimer.current != null) {
218+
clearTimeout(pressOutTimer.current);
219+
pressOutTimer.current = null;
220+
}
221+
pressInTimestamp.current = performance.now();
222+
setCurrentDuration(tapAnimationInDuration);
223+
setPressed(true);
224+
},
225+
[enabled, tapAnimationInDuration]
226+
);
203227

204-
React.useEffect(() => {
228+
const pressOut = React.useCallback(
229+
(event?: NativeSyntheticEvent<unknown>) => {
230+
// Only release if a press-in was actually recorded — guards against
231+
// stray pointer events and lets us complete the release cycle even if
232+
// `enabled` flipped to false between press-in and press-out.
233+
if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) {
234+
return;
235+
}
236+
237+
event?.stopPropagation();
238+
239+
if (pressOutTimer.current != null) {
240+
clearTimeout(pressOutTimer.current);
241+
pressOutTimer.current = null;
242+
}
243+
const elapsed = performance.now() - pressInTimestamp.current;
244+
pressInTimestamp.current = 0;
245+
246+
if (longPressDuration >= 0 && elapsed >= longPressDuration) {
247+
// Long-press release — use the configured long-press out duration.
248+
setCurrentDuration(longPressAnimationOutDuration);
249+
setPressed(false);
250+
} else if (elapsed >= tapAnimationInDuration) {
251+
// Press-in animation fully finished - release with the configured out duration.
252+
setCurrentDuration(tapAnimationOutDuration);
253+
setPressed(false);
254+
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
255+
} else if (elapsed * 2 >= tapAnimationOutDuration) {
256+
setCurrentDuration(elapsed);
257+
setPressed(false);
258+
} else {
259+
// Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
260+
const remaining = tapAnimationInDuration - elapsed;
261+
pressOutTimer.current = setTimeout(
262+
() => {
263+
pressOutTimer.current = null;
264+
setCurrentDuration(tapAnimationOutDuration);
265+
setPressed(false);
266+
},
267+
prefersReducedMotion() ? 0 : remaining
268+
);
269+
}
270+
},
271+
[
272+
longPressDuration,
273+
longPressAnimationOutDuration,
274+
tapAnimationInDuration,
275+
tapAnimationOutDuration,
276+
]
277+
);
278+
279+
useIsomorphicLayoutEffect(() => {
205280
const node = viewRef.current;
206281
const wrapEvent = (event: Event): NativeSyntheticEvent<ButtonEvent> =>
207282
({
@@ -255,6 +330,49 @@ export const ButtonComponent = ({
255330
pressOut,
256331
]);
257332

333+
useIsomorphicLayoutEffect(() => {
334+
const node = viewRef.current;
335+
if (handlerTag === undefined || node === null) {
336+
return;
337+
}
338+
339+
RNGestureHandlerModule.createGestureHandler(
340+
'NativeViewGestureHandler',
341+
handlerTag,
342+
managedGestureConfigRef.current
343+
);
344+
RNGestureHandlerModule.attachGestureHandler(
345+
handlerTag,
346+
node,
347+
ActionType.NONE,
348+
gesturePropsRef
349+
);
350+
351+
return () => {
352+
RNGestureHandlerModule.detachGestureHandler(handlerTag);
353+
RNGestureHandlerModule.dropGestureHandler(handlerTag);
354+
};
355+
}, [handlerTag]);
356+
357+
useIsomorphicLayoutEffect(() => {
358+
if (handlerTag === undefined) {
359+
return;
360+
}
361+
362+
RNGestureHandlerModule.setGestureHandlerConfig(
363+
handlerTag,
364+
managedGestureConfigRef.current
365+
);
366+
}, [
367+
cancelOnLeave,
368+
enabled,
369+
gestureHitSlop,
370+
gestureTestID,
371+
handlerTag,
372+
hasLongPressHandler,
373+
longPressDuration,
374+
]);
375+
258376
const handlePointerEnter = React.useCallback(
259377
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
260378
if (!enabled || event.nativeEvent.pointerType === 'touch') {
@@ -271,6 +389,9 @@ export const ButtonComponent = ({
271389

272390
const handlePointerLeave = React.useCallback(
273391
(event: NativeSyntheticEvent<{ pointerType?: string }>) => {
392+
if (handlerTag === undefined) {
393+
pressOut(event);
394+
}
274395
if (event.nativeEvent.pointerType === 'touch') {
275396
return;
276397
}
@@ -279,7 +400,7 @@ export const ButtonComponent = ({
279400
}
280401
setHovered(false);
281402
},
282-
[pressed, hoverAnimationOutDuration]
403+
[handlerTag, hoverAnimationOutDuration, pressOut, pressed]
283404
);
284405

285406
// Mask hover at render rather than clearing the state. Avoids a state
@@ -335,6 +456,9 @@ export const ButtonComponent = ({
335456
},
336457
]}
337458
onPointerEnter={handlePointerEnter}
459+
onPointerDown={handlerTag === undefined ? pressIn : undefined}
460+
onPointerUp={handlerTag === undefined ? pressOut : undefined}
461+
onPointerCancel={handlerTag === undefined ? pressOut : undefined}
338462
onPointerLeave={handlePointerLeave}>
339463
{hasUnderlay && (
340464
<View
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import EventManager from '../tools/EventManager';
2+
import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate';
3+
import GestureHandler from './GestureHandler';
4+
import type IGestureHandler from './IGestureHandler';
5+
6+
class TestGestureHandler extends GestureHandler {}
7+
8+
class TestEventManager extends EventManager<unknown> {
9+
public registerListeners = jest.fn();
10+
public unregisterListeners = jest.fn();
11+
12+
protected mapEvent(): never {
13+
throw new Error('Test event manager does not map events');
14+
}
15+
}
16+
17+
function createHandler(enabled: boolean) {
18+
const delegate = {
19+
onEnabledChange: jest.fn(),
20+
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;
21+
const handler = new TestGestureHandler(delegate);
22+
23+
handler.setGestureConfig({ enabled });
24+
25+
return handler;
26+
}
27+
28+
describe('GestureHandler web event manager attachment', () => {
29+
test('registers listeners for an initially enabled handler', () => {
30+
const manager = new TestEventManager({});
31+
32+
createHandler(true).attachEventManager(manager);
33+
34+
expect(manager.registerListeners).toHaveBeenCalledTimes(1);
35+
});
36+
37+
test('does not register listeners for an initially disabled handler', () => {
38+
const manager = new TestEventManager({});
39+
40+
createHandler(false).attachEventManager(manager);
41+
42+
expect(manager.registerListeners).not.toHaveBeenCalled();
43+
});
44+
});

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,12 @@ export default abstract class GestureHandler implements IGestureHandler {
144144
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
145145
manager.setOnWheel(this.onWheel.bind(this));
146146

147-
manager.registerListeners();
147+
// The initial config is applied before the handler is attached. Honor an
148+
// initially disabled handler here because the delegate's enabled-change
149+
// callback cannot update event managers until attachment has finished.
150+
if (this.enabled) {
151+
manager.registerListeners();
152+
}
148153
}
149154

150155
//
@@ -445,6 +450,10 @@ export default abstract class GestureHandler implements IGestureHandler {
445450
//
446451

447452
public sendEvent = (newState: State, oldState: State): void => {
453+
if (this.actionType === ActionType.NONE) {
454+
return;
455+
}
456+
448457
const {
449458
onGestureHandlerEvent,
450459
onGestureHandlerStateChange,

0 commit comments

Comments
 (0)