Skip to content

Commit d45f8dd

Browse files
authored
[web] Add support for two finger pan (#3163)
## Description This PR adds support for two finger panning on touchpad on `web`. >[!WARNING] > Two finger gestures may be used as system/browser gestures (for example `swipe` to go back). This PR doesn't handle these cases. ## Implementation Implementation is based on [WheelEvents](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event). This leads to some limitations - whole state flow of `Pan` has to be managed inside one callback (`onWheel`). ### Ending gesture Starting gesture is easy, in contrast to ending it. To finish gesture we use `timeout` - if no `wheel event` was received since `setTimeout` was called, we can end gesture. ### `Mouse` vs `Touchpad` It is hard to determine whether `mouse` or `touchpad` was used to start the gesture. `WheelEvent` doesn't have any information about the device, therefore we have to use heuristics to check that. >[!NOTE] > You cannot start gesture with mouse scroll. To see if events were generated with mouse, we check whether `wheelDeltaY` property (which is now [deprecated](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event#event_properties)) of event is multiple of `120`. In short, this is standard `wheel delta` for mouse. Here you can find useful links that will tell more about why it works: - https://stackoverflow.com/questions/10744645/detect-touchpad-vs-mouse-in-javascript - https://devblogs.microsoft.com/oldnewthing/20130123-00/?p=5473 >[!CAUTION] > While this will work most of the times, it is possible that user will somehow generate this specific `wheel delta` with touchpad and gesture will not be recognized correctly. Closes #800 ## Test plan Tested on new _**Two finger Pan**_ example
1 parent e20bf5e commit d45f8dd

File tree

8 files changed

+197
-2
lines changed

8 files changed

+197
-2
lines changed

example/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import NestedButtons from './src/release_tests/nestedButtons';
3939
import PointerType from './src/release_tests/pointerType';
4040
import SwipeableReanimation from './src/release_tests/swipeableReanimation';
4141
import NestedGestureHandlerRootViewWithModal from './src/release_tests/nestedGHRootViewWithModal';
42+
import TwoFingerPan from 'src/release_tests/twoFingerPan';
4243
import { PinchableBox } from './src/recipes/scaleAndRotate';
4344
import PanAndScroll from './src/recipes/panAndScroll';
4445
import { BottomSheet } from './src/showcase/bottomSheet';
@@ -209,6 +210,11 @@ const EXAMPLES: ExamplesSection[] = [
209210
unsupportedPlatforms: new Set(['android', 'ios', 'macos']),
210211
},
211212
{ name: 'Stylus data', component: StylusData },
213+
{
214+
name: 'Two finger Pan',
215+
component: TwoFingerPan,
216+
unsupportedPlatforms: new Set(['android', 'macos']),
217+
},
212218
],
213219
},
214220
{
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { StyleSheet, View } from 'react-native';
2+
import Animated, {
3+
useAnimatedStyle,
4+
useSharedValue,
5+
} from 'react-native-reanimated';
6+
7+
import React from 'react';
8+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
9+
10+
const BOX_SIZE = 270;
11+
12+
const clampColor = (v: number) => Math.min(255, Math.max(0, v));
13+
14+
export default function TwoFingerPan() {
15+
const r = useSharedValue(128);
16+
const b = useSharedValue(128);
17+
18+
const pan = Gesture.Pan()
19+
.onChange((event) => {
20+
r.value = clampColor(r.value - event.changeY);
21+
b.value = clampColor(b.value + event.changeX);
22+
})
23+
.runOnJS(true)
24+
.enableTrackpadTwoFingerGesture(true);
25+
26+
const animatedStyles = useAnimatedStyle(() => {
27+
const backgroundColor = `rgb(${r.value}, 128, ${b.value})`;
28+
29+
return {
30+
backgroundColor,
31+
};
32+
});
33+
34+
return (
35+
<View style={styles.container} collapsable={false}>
36+
<GestureDetector gesture={pan}>
37+
<Animated.View style={[styles.box, animatedStyles]} />
38+
</GestureDetector>
39+
</View>
40+
);
41+
}
42+
43+
const styles = StyleSheet.create({
44+
container: {
45+
flex: 1,
46+
alignItems: 'center',
47+
justifyContent: 'center',
48+
height: '100%',
49+
},
50+
box: {
51+
width: BOX_SIZE,
52+
height: BOX_SIZE,
53+
borderRadius: BOX_SIZE / 2,
54+
},
55+
});

src/web/handlers/GestureHandler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default abstract class GestureHandler implements IGestureHandler {
7474
manager.setOnPointerOutOfBounds(this.onPointerOutOfBounds.bind(this));
7575
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
7676
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
77+
manager.setOnWheel(this.onWheel.bind(this));
7778

7879
manager.registerListeners();
7980
}
@@ -338,7 +339,10 @@ export default abstract class GestureHandler implements IGestureHandler {
338339
protected onPointerMoveOut(_event: AdaptedEvent): void {
339340
// Used only by hover gesture handler atm
340341
}
341-
private tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
342+
protected onWheel(_event: AdaptedEvent): void {
343+
// Used only by pan gesture handler
344+
}
345+
protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
342346
if ((out && this.shouldCancelWhenOutside) || !this.enabled) {
343347
return;
344348
}

src/web/handlers/PanGestureHandler.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { State } from '../../State';
22
import { DEFAULT_TOUCH_SLOP } from '../constants';
3-
import { AdaptedEvent, Config, StylusData } from '../interfaces';
3+
import { AdaptedEvent, Config, StylusData, WheelDevice } from '../interfaces';
44

55
import GestureHandler from './GestureHandler';
66

@@ -57,6 +57,10 @@ export default class PanGestureHandler extends GestureHandler {
5757
private activateAfterLongPress = 0;
5858
private activationTimeout = 0;
5959

60+
private enableTrackpadTwoFingerGesture = false;
61+
private endWheelTimeout = 0;
62+
private wheelDevice = WheelDevice.UNDETERMINED;
63+
6064
public init(ref: number, propsRef: React.RefObject<unknown>): void {
6165
super.init(ref, propsRef);
6266
}
@@ -161,6 +165,11 @@ export default class PanGestureHandler extends GestureHandler {
161165
this.failOffsetYStart = Number.MIN_SAFE_INTEGER;
162166
}
163167
}
168+
169+
if (this.config.enableTrackpadTwoFingerGesture !== undefined) {
170+
this.enableTrackpadTwoFingerGesture =
171+
this.config.enableTrackpadTwoFingerGesture;
172+
}
164173
}
165174

166175
protected resetConfig(): void {
@@ -351,6 +360,65 @@ export default class PanGestureHandler extends GestureHandler {
351360
}
352361
}
353362

363+
private scheduleWheelEnd(event: AdaptedEvent) {
364+
clearTimeout(this.endWheelTimeout);
365+
366+
this.endWheelTimeout = setTimeout(() => {
367+
if (this.currentState === State.ACTIVE) {
368+
this.end();
369+
this.tracker.removeFromTracker(event.pointerId);
370+
this.currentState = State.UNDETERMINED;
371+
}
372+
373+
this.wheelDevice = WheelDevice.UNDETERMINED;
374+
}, 30);
375+
}
376+
377+
protected onWheel(event: AdaptedEvent): void {
378+
if (
379+
this.wheelDevice === WheelDevice.MOUSE ||
380+
!this.enableTrackpadTwoFingerGesture
381+
) {
382+
return;
383+
}
384+
385+
if (this.currentState === State.UNDETERMINED) {
386+
this.wheelDevice =
387+
event.wheelDeltaY! % 120 !== 0
388+
? WheelDevice.TOUCHPAD
389+
: WheelDevice.MOUSE;
390+
391+
if (this.wheelDevice === WheelDevice.MOUSE) {
392+
this.scheduleWheelEnd(event);
393+
return;
394+
}
395+
396+
this.tracker.addToTracker(event);
397+
398+
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
399+
this.lastX = lastCoords.x;
400+
this.lastY = lastCoords.y;
401+
402+
this.startX = this.lastX;
403+
this.startY = this.lastY;
404+
405+
this.begin();
406+
this.activate();
407+
}
408+
this.tracker.track(event);
409+
410+
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
411+
this.lastX = lastCoords.x;
412+
this.lastY = lastCoords.y;
413+
414+
const velocity = this.tracker.getVelocity(event.pointerId);
415+
this.velocityX = velocity.x;
416+
this.velocityY = velocity.y;
417+
418+
this.tryToSendMoveEvent(false, event);
419+
this.scheduleWheelEnd(event);
420+
}
421+
354422
private shouldActivate(): boolean {
355423
const dx: number = this.getTranslationX();
356424

src/web/interfaces.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export interface Config extends Record<string, ConfigArgs> {
7878
shouldActivateOnStart?: boolean;
7979
disallowInterruption?: boolean;
8080
direction?: Directions;
81+
enableTrackpadTwoFingerGesture?: boolean;
8182
}
8283

8384
type NativeEventArgs = number | State | boolean | undefined;
@@ -151,6 +152,7 @@ export interface AdaptedEvent {
151152
time: number;
152153
button?: MouseButton;
153154
stylusData?: StylusData;
155+
wheelDeltaY?: number;
154156
}
155157

156158
export enum EventTypes {
@@ -171,3 +173,9 @@ export enum TouchEventType {
171173
UP,
172174
CANCELLED,
173175
}
176+
177+
export enum WheelDevice {
178+
UNDETERMINED,
179+
MOUSE,
180+
TOUCHPAD,
181+
}

src/web/tools/EventManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default abstract class EventManager<T> {
3737
protected onPointerOutOfBounds(_event: AdaptedEvent): void {}
3838
protected onPointerMoveOver(_event: AdaptedEvent): void {}
3939
protected onPointerMoveOut(_event: AdaptedEvent): void {}
40+
protected onWheel(_event: AdaptedEvent): void {}
4041

4142
public setOnPointerDown(callback: PointerEventCallback): void {
4243
this.onPointerDown = callback;
@@ -71,6 +72,9 @@ export default abstract class EventManager<T> {
7172
public setOnPointerMoveOut(callback: PointerEventCallback): void {
7273
this.onPointerMoveOut = callback;
7374
}
75+
public setOnWheel(callback: PointerEventCallback): void {
76+
this.onWheel = callback;
77+
}
7478

7579
protected markAsInBounds(pointerId: number): void {
7680
if (this.pointersInBounds.indexOf(pointerId) >= 0) {

src/web/tools/GestureHandlerWebDelegate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import EventManager from './EventManager';
1111
import { Config } from '../interfaces';
1212
import { MouseButton } from '../../handlers/gestureHandlerCommon';
1313
import KeyboardEventManager from './KeyboardEventManager';
14+
import WheelEventManager from './WheelEventManager';
1415

1516
interface DefaultViewStyles {
1617
userSelect: string;
@@ -58,6 +59,7 @@ export class GestureHandlerWebDelegate
5859

5960
this.eventManagers.push(new PointerEventManager(this.view));
6061
this.eventManagers.push(new KeyboardEventManager(this.view));
62+
this.eventManagers.push(new WheelEventManager(this.view));
6163

6264
this.eventManagers.forEach((manager) =>
6365
this.gestureHandler.attachEventManager(manager)

src/web/tools/WheelEventManager.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import EventManager from './EventManager';
2+
import { AdaptedEvent, EventTypes } from '../interfaces';
3+
import { PointerType } from '../../PointerType';
4+
5+
export default class WheelEventManager extends EventManager<HTMLElement> {
6+
private wheelDelta = { x: 0, y: 0 };
7+
8+
private resetDelta = (_event: PointerEvent) => {
9+
this.wheelDelta = { x: 0, y: 0 };
10+
};
11+
12+
private wheelCallback = (event: WheelEvent) => {
13+
this.wheelDelta.x += event.deltaX;
14+
this.wheelDelta.y += event.deltaY;
15+
16+
const adaptedEvent = this.mapEvent(event);
17+
this.onWheel(adaptedEvent);
18+
};
19+
20+
public registerListeners(): void {
21+
this.view.addEventListener('pointermove', this.resetDelta);
22+
this.view.addEventListener('wheel', this.wheelCallback);
23+
}
24+
25+
public unregisterListeners(): void {
26+
this.view.removeEventListener('pointermove', this.resetDelta);
27+
this.view.removeEventListener('wheel', this.wheelCallback);
28+
}
29+
30+
protected mapEvent(event: WheelEvent): AdaptedEvent {
31+
return {
32+
x: event.clientX + this.wheelDelta.x,
33+
y: event.clientY + this.wheelDelta.y,
34+
offsetX: event.offsetX - event.deltaX,
35+
offsetY: event.offsetY - event.deltaY,
36+
pointerId: -1,
37+
eventType: EventTypes.MOVE,
38+
pointerType: PointerType.OTHER,
39+
time: event.timeStamp,
40+
// @ts-ignore It does exist, but it's deprecated
41+
wheelDeltaY: event.wheelDeltaY,
42+
};
43+
}
44+
45+
public resetManager(): void {
46+
super.resetManager();
47+
}
48+
}

0 commit comments

Comments
 (0)