Skip to content

Commit 613be1c

Browse files
authored
[Web] Report zero Pan velocity when the pointer rested before release (#4641)
## Description On web, `PanGestureHandler.onPointerUp` ends the gesture without looking at the release event, so `onEnd` carries the velocity computed by the last `pointermove`, however long ago that was. A drag that stops, holds still, and then releases reports the velocity of the drag, and a `withDecay` started from it flings a pointer that had come to rest. Android reports 0 in that case: its `VelocityTracker` treats a pointer as stopped after 40 ms without a sample, [matching Flutter](https://github.com/flutter/flutter/blob/66b3ad90177ccbd2963836a5556076b370622fe4/packages/flutter/lib/src/gestures/velocity_tracker.dart#L142). The web `VelocityTracker` already uses the same 40 ms threshold to split the sample history during a drag. The constant is now exported as `POINTER_STOPPED_MILLISECONDS`, `PointerTracker.track` stamps the time of each pointer's last sample, and `PointerTracker.getVelocityAt` returns zero when the queried time is past that threshold. `onPointerUp` reads the velocity at the release time before ending. A release within 40 ms of the last move is unchanged. Feeding the release event into the tracker as a sample, as Android does, was tried first and dropped: a release landing a few ms after the last move at the same spot bent the least-squares fit and halved the reported velocity of an ordinary fling. Fixes #4636 ## Test plan Tested on the "Velocity Test" example. - Two tests in `src/web/handlers/__tests__/PanGestureHandler.test.ts`: a release at the last move keeps the drag's velocity, a release 500 ms later reports zero. The second fails without the fix. - Chrome, v3 `usePanGesture` screen with the reporter's drag (160 px in 48 ms) driven over CDP: released at the last move `velocityX` 3263 before and after; released 511 ms later 2707 before, 0 after.
1 parent 4733795 commit 613be1c

4 files changed

Lines changed: 68 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,12 @@ export default class PanGestureHandler extends GestureHandler {
261261
protected override onPointerUp(event: AdaptedEvent): void {
262262
this.stylusData = event.stylusData;
263263

264+
// A pointer that rested before lifting reports zero velocity instead of
265+
// the velocity of its last move.
266+
const velocity = this.tracker.getVelocityAt(event.pointerId, event.time);
267+
this.velocityX = velocity?.x ?? 0;
268+
this.velocityY = velocity?.y ?? 0;
269+
264270
super.onPointerUp(event);
265271
if (this.state === State.ACTIVE) {
266272
const lastCoords = this.tracker.getAbsoluteCoordsAverage();

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,45 @@ describe('PanGestureHandler pointer leaving and entering the view', () => {
418418
expect(handler.state).toBe(State.CANCELLED);
419419
});
420420
});
421+
422+
describe('PanGestureHandler velocity at release', () => {
423+
function timedEvent(eventType: EventTypes, y: number, time: number) {
424+
return { ...pointerEvent(eventType, y), time };
425+
}
426+
427+
// Four moves 8ms apart, 10px each: 1250px/s downwards.
428+
function dragThenRelease(releaseTime: number) {
429+
const handler = createHandler();
430+
431+
handler.setGestureConfig({ enabled: true, minDist: 0 });
432+
handler.pointerDown(timedEvent(EventTypes.DOWN, 20, 0));
433+
for (let move = 1; move <= 4; move++) {
434+
handler.pointerMove(
435+
timedEvent(EventTypes.MOVE, 20 + 10 * move, 8 * move)
436+
);
437+
}
438+
expect(handler.state).toBe(State.ACTIVE);
439+
440+
handler.pointerUp(timedEvent(EventTypes.UP, 60, releaseTime));
441+
expect(handler.state).toBe(State.END);
442+
443+
return handler.nativeEvent();
444+
}
445+
446+
test('a pointer released at its last move keeps the velocity of the drag', () => {
447+
expect(dragThenRelease(32).velocityY).toBeCloseTo(1250, -2);
448+
});
449+
450+
test('a pointer that rested before release reports zero velocity', () => {
451+
expect(dragThenRelease(532).velocityY).toBe(0);
452+
});
453+
454+
// The last move is at 32ms, the pointer counts as stopped after 40ms.
455+
test('a pointer released at the stopped threshold keeps its velocity', () => {
456+
expect(dragThenRelease(72).velocityY).toBeCloseTo(1250, -2);
457+
});
458+
459+
test('a pointer released past the stopped threshold reports zero velocity', () => {
460+
expect(dragThenRelease(73).velocityY).toBe(0);
461+
});
462+
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import VelocityTracker from './VelocityTracker';
44
export interface TrackerElement {
55
abosoluteCoords: Point;
66
relativeCoords: Point;
7-
timestamp: number;
87
velocityX: number;
98
velocityY: number;
109
}
@@ -43,7 +42,6 @@ export default class PointerTracker {
4342
const newElement: TrackerElement = {
4443
abosoluteCoords: { x: event.x, y: event.y },
4544
relativeCoords: { x: event.offsetX, y: event.offsetY },
46-
timestamp: event.time,
4745
velocityX: 0,
4846
velocityY: 0,
4947
};
@@ -122,6 +120,15 @@ export default class PointerTracker {
122120
: null;
123121
}
124122

123+
public getVelocityAt(pointerId: number, time: number) {
124+
if (!this.trackedPointers.has(pointerId)) {
125+
return null;
126+
}
127+
128+
const [x, y] = this.velocityTracker.velocityAt(time);
129+
return { x, y };
130+
}
131+
125132
public getLastAbsoluteCoords(pointerId?: number) {
126133
return this.trackedPointers.get(pointerId ?? this.lastMovedPointerId)
127134
?.abosoluteCoords;

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ export default class VelocityTracker {
9292
return [0, 0];
9393
}
9494

95+
// Velocity at `time`: zero once no sample arrived within the stopped
96+
// window, the current estimate otherwise.
97+
public velocityAt(time: number): [number, number] {
98+
const newest = this.samples.get(this.samples.size - 1);
99+
100+
return !newest ||
101+
time - newest.time > this.assumePointerMoveStoppedMilliseconds
102+
? [0, 0]
103+
: this.velocity;
104+
}
105+
95106
public reset(): void {
96107
this.samples.clear();
97108
}

0 commit comments

Comments
 (0)