Skip to content

Commit 2202d7b

Browse files
[Web] Handle moves that leave or re-enter the view as out-of-bounds moves (#4610)
The fix now lives in `GestureHandler`: `onPointerLeave` and `onPointerEnter` mark the event as a move and pass it to `onPointerOutOfBounds`, so every handler applies these moves. The Pan-specific overrides are removed. The description below explains the original Pan symptoms, which this change still fixes. ## Description On web, `PointerEventManager` dispatches the `pointermove` that takes a tracked pointer out of the view as `onPointerLeave`, and the one that brings it back as `onPointerEnter`. `PanGestureHandler` did not override either, so for those moves it never updated the tracker, the last coordinates or the velocity, and never checked whether to activate. As a result: - `onUpdate` does not fire for the move that leaves or re-enters the view. - If the pointer is released right after, `onEnd` reports the last in-bounds position instead of the release position. - A Pan in `BEGAN` whose activating move lands outside the view does not activate on that move. This PR makes `PanGestureHandler` handle the leave and enter moves like any other move: it tracks the pointer, updates the coordinates and velocity, checks activation and sends the update. With `shouldCancelWhenOutside`, leaving the view still cancels the gesture as before. The shared tracking code of `onPointerMove` and `onPointerOutOfBounds` moves to a private `trackMove` helper. Fixes #4607 ## Test plan Added tests to `src/web/handlers/__tests__/PanGestureHandler.test.ts`: - The move that leaves the view sends an update with the new translation, and `onEnd` reports the release position. - The move that re-enters the view does the same. - The move that leaves the view activates a Pan that is still in `BEGAN`. - With `shouldCancelWhenOutside`, leaving the view still cancels the gesture. Without the fix, the first three fail. With it, `yarn test` passes (23 suites, 227 tests), and `yarn ts-check`, ESLint and Prettier are clean. --------- Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com> Co-authored-by: Michał <michal.bert@swmansion.com>
1 parent 0b36e9d commit 2202d7b

2 files changed

Lines changed: 115 additions & 2 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,12 @@ export default abstract class GestureHandler implements IGestureHandler {
376376
return;
377377
}
378378

379-
this.tryToSendTouchEvent(event);
379+
event.eventType = EventTypes.MOVE;
380+
this.onPointerOutOfBounds(event);
380381
}
381382
protected onPointerEnter(event: AdaptedEvent): void {
382-
this.tryToSendTouchEvent(event);
383+
event.eventType = EventTypes.MOVE;
384+
this.onPointerMove(event);
383385
}
384386
protected onPointerCancel(_event: AdaptedEvent): void {
385387
// No need to send a cancel touch event explicitly here. `cancel` will

‎packages/react-native-gesture-handler/src/web/handlers/__tests__/PanGestureHandler.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ class TestPanGestureHandler extends PanGestureHandler {
2121
return this.transformNativeEvent();
2222
}
2323

24+
public pointerDown(event: AdaptedEvent): void {
25+
this.onPointerDown(event);
26+
}
27+
28+
public pointerMove(event: AdaptedEvent): void {
29+
this.onPointerMove(event);
30+
}
31+
32+
public pointerLeave(event: AdaptedEvent): void {
33+
this.onPointerLeave(event);
34+
}
35+
36+
public pointerEnter(event: AdaptedEvent): void {
37+
this.onPointerEnter(event);
38+
}
39+
40+
public pointerUp(event: AdaptedEvent): void {
41+
this.onPointerUp(event);
42+
}
43+
2444
protected override onWheel(event: AdaptedEvent): void {
2545
this.wheelEvents.push(event);
2646
super.onWheel(event);
@@ -42,6 +62,19 @@ function wheelEvent(): AdaptedEvent {
4262
};
4363
}
4464

65+
function pointerEvent(eventType: EventTypes, y: number): AdaptedEvent {
66+
return {
67+
x: 20,
68+
y,
69+
offsetX: 20,
70+
offsetY: y,
71+
pointerId: 0,
72+
eventType,
73+
pointerType: PointerType.MOUSE,
74+
time: 0,
75+
};
76+
}
77+
4578
function createHandler(eventManagers: EventManager<unknown>[] = []) {
4679
const delegate = {
4780
init: jest.fn(),
@@ -307,3 +340,81 @@ describe('PanGestureHandler trackpad coordinates', () => {
307340
).toMatchObject({ x: 75, y: 50, absoluteX: 65, absoluteY: 110 });
308341
});
309342
});
343+
344+
describe('PanGestureHandler pointer leaving and entering the view', () => {
345+
// The view is 40x40, the pointer goes down at its center.
346+
function startPan(config: Record<string, unknown> = {}) {
347+
const handler = createHandler();
348+
const updates: { state: State; translationY: unknown }[] = [];
349+
350+
handler.sendEvent = jest.fn((newState: State) => {
351+
updates.push({
352+
state: newState,
353+
translationY: handler.nativeEvent().translationY,
354+
});
355+
});
356+
357+
handler.setGestureConfig({ enabled: true, minDist: 0, ...config });
358+
handler.pointerDown(pointerEvent(EventTypes.DOWN, 20));
359+
360+
return { handler, updates };
361+
}
362+
363+
test('the move that leaves the view updates the position', () => {
364+
const { handler, updates } = startPan();
365+
366+
handler.pointerMove(pointerEvent(EventTypes.MOVE, 25));
367+
expect(handler.state).toBe(State.ACTIVE);
368+
369+
updates.length = 0;
370+
handler.pointerLeave(pointerEvent(EventTypes.LEAVE, 320));
371+
372+
expect(updates).toEqual([{ state: State.ACTIVE, translationY: 300 }]);
373+
374+
handler.pointerUp(pointerEvent(EventTypes.UP, 320));
375+
376+
expect(updates[updates.length - 1]).toEqual({
377+
state: State.END,
378+
translationY: 300,
379+
});
380+
});
381+
382+
test('the move that enters the view updates the position', () => {
383+
const { handler, updates } = startPan();
384+
385+
handler.pointerMove(pointerEvent(EventTypes.MOVE, 25));
386+
handler.pointerLeave(pointerEvent(EventTypes.LEAVE, 320));
387+
388+
updates.length = 0;
389+
handler.pointerEnter(pointerEvent(EventTypes.ENTER, 30));
390+
391+
expect(updates).toEqual([{ state: State.ACTIVE, translationY: 10 }]);
392+
393+
handler.pointerUp(pointerEvent(EventTypes.UP, 30));
394+
395+
expect(updates[updates.length - 1]).toEqual({
396+
state: State.END,
397+
translationY: 10,
398+
});
399+
});
400+
401+
test('the move that leaves the view can activate the gesture', () => {
402+
const { handler } = startPan({ minDist: 10 });
403+
404+
handler.pointerMove(pointerEvent(EventTypes.MOVE, 25));
405+
expect(handler.state).toBe(State.BEGAN);
406+
407+
handler.pointerLeave(pointerEvent(EventTypes.LEAVE, 320));
408+
409+
expect(handler.state).toBe(State.ACTIVE);
410+
});
411+
412+
test('the gesture is still cancelled when the pointer leaves the view with shouldCancelWhenOutside', () => {
413+
const { handler } = startPan({ shouldCancelWhenOutside: true });
414+
415+
handler.pointerMove(pointerEvent(EventTypes.MOVE, 25));
416+
handler.pointerLeave(pointerEvent(EventTypes.LEAVE, 320));
417+
418+
expect(handler.state).toBe(State.CANCELLED);
419+
});
420+
});

0 commit comments

Comments
 (0)