Skip to content

Commit

Permalink
[web] Add support for two finger pan (#3163)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
m-bert authored Oct 25, 2024
1 parent e20bf5e commit d45f8dd
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 2 deletions.
6 changes: 6 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import NestedButtons from './src/release_tests/nestedButtons';
import PointerType from './src/release_tests/pointerType';
import SwipeableReanimation from './src/release_tests/swipeableReanimation';
import NestedGestureHandlerRootViewWithModal from './src/release_tests/nestedGHRootViewWithModal';
import TwoFingerPan from 'src/release_tests/twoFingerPan';
import { PinchableBox } from './src/recipes/scaleAndRotate';
import PanAndScroll from './src/recipes/panAndScroll';
import { BottomSheet } from './src/showcase/bottomSheet';
Expand Down Expand Up @@ -209,6 +210,11 @@ const EXAMPLES: ExamplesSection[] = [
unsupportedPlatforms: new Set(['android', 'ios', 'macos']),
},
{ name: 'Stylus data', component: StylusData },
{
name: 'Two finger Pan',
component: TwoFingerPan,
unsupportedPlatforms: new Set(['android', 'macos']),
},
],
},
{
Expand Down
55 changes: 55 additions & 0 deletions example/src/release_tests/twoFingerPan/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { StyleSheet, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';

import React from 'react';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const BOX_SIZE = 270;

const clampColor = (v: number) => Math.min(255, Math.max(0, v));

export default function TwoFingerPan() {
const r = useSharedValue(128);
const b = useSharedValue(128);

const pan = Gesture.Pan()
.onChange((event) => {
r.value = clampColor(r.value - event.changeY);
b.value = clampColor(b.value + event.changeX);
})
.runOnJS(true)
.enableTrackpadTwoFingerGesture(true);

const animatedStyles = useAnimatedStyle(() => {
const backgroundColor = `rgb(${r.value}, 128, ${b.value})`;

return {
backgroundColor,
};
});

return (
<View style={styles.container} collapsable={false}>
<GestureDetector gesture={pan}>
<Animated.View style={[styles.box, animatedStyles]} />
</GestureDetector>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
box: {
width: BOX_SIZE,
height: BOX_SIZE,
borderRadius: BOX_SIZE / 2,
},
});
6 changes: 5 additions & 1 deletion src/web/handlers/GestureHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default abstract class GestureHandler implements IGestureHandler {
manager.setOnPointerOutOfBounds(this.onPointerOutOfBounds.bind(this));
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));

manager.registerListeners();
}
Expand Down Expand Up @@ -338,7 +339,10 @@ export default abstract class GestureHandler implements IGestureHandler {
protected onPointerMoveOut(_event: AdaptedEvent): void {
// Used only by hover gesture handler atm
}
private tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
protected onWheel(_event: AdaptedEvent): void {
// Used only by pan gesture handler
}
protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
if ((out && this.shouldCancelWhenOutside) || !this.enabled) {
return;
}
Expand Down
70 changes: 69 additions & 1 deletion src/web/handlers/PanGestureHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { State } from '../../State';
import { DEFAULT_TOUCH_SLOP } from '../constants';
import { AdaptedEvent, Config, StylusData } from '../interfaces';
import { AdaptedEvent, Config, StylusData, WheelDevice } from '../interfaces';

import GestureHandler from './GestureHandler';

Expand Down Expand Up @@ -57,6 +57,10 @@ export default class PanGestureHandler extends GestureHandler {
private activateAfterLongPress = 0;
private activationTimeout = 0;

private enableTrackpadTwoFingerGesture = false;
private endWheelTimeout = 0;
private wheelDevice = WheelDevice.UNDETERMINED;

public init(ref: number, propsRef: React.RefObject<unknown>): void {
super.init(ref, propsRef);
}
Expand Down Expand Up @@ -161,6 +165,11 @@ export default class PanGestureHandler extends GestureHandler {
this.failOffsetYStart = Number.MIN_SAFE_INTEGER;
}
}

if (this.config.enableTrackpadTwoFingerGesture !== undefined) {
this.enableTrackpadTwoFingerGesture =
this.config.enableTrackpadTwoFingerGesture;
}
}

protected resetConfig(): void {
Expand Down Expand Up @@ -351,6 +360,65 @@ export default class PanGestureHandler extends GestureHandler {
}
}

private scheduleWheelEnd(event: AdaptedEvent) {
clearTimeout(this.endWheelTimeout);

this.endWheelTimeout = setTimeout(() => {
if (this.currentState === State.ACTIVE) {
this.end();
this.tracker.removeFromTracker(event.pointerId);
this.currentState = State.UNDETERMINED;
}

this.wheelDevice = WheelDevice.UNDETERMINED;
}, 30);
}

protected onWheel(event: AdaptedEvent): void {
if (
this.wheelDevice === WheelDevice.MOUSE ||
!this.enableTrackpadTwoFingerGesture
) {
return;
}

if (this.currentState === State.UNDETERMINED) {
this.wheelDevice =
event.wheelDeltaY! % 120 !== 0
? WheelDevice.TOUCHPAD
: WheelDevice.MOUSE;

if (this.wheelDevice === WheelDevice.MOUSE) {
this.scheduleWheelEnd(event);
return;
}

this.tracker.addToTracker(event);

const lastCoords = this.tracker.getAbsoluteCoordsAverage();
this.lastX = lastCoords.x;
this.lastY = lastCoords.y;

this.startX = this.lastX;
this.startY = this.lastY;

this.begin();
this.activate();
}
this.tracker.track(event);

const lastCoords = this.tracker.getAbsoluteCoordsAverage();
this.lastX = lastCoords.x;
this.lastY = lastCoords.y;

const velocity = this.tracker.getVelocity(event.pointerId);
this.velocityX = velocity.x;
this.velocityY = velocity.y;

this.tryToSendMoveEvent(false, event);
this.scheduleWheelEnd(event);
}

private shouldActivate(): boolean {
const dx: number = this.getTranslationX();

Expand Down
8 changes: 8 additions & 0 deletions src/web/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface Config extends Record<string, ConfigArgs> {
shouldActivateOnStart?: boolean;
disallowInterruption?: boolean;
direction?: Directions;
enableTrackpadTwoFingerGesture?: boolean;
}

type NativeEventArgs = number | State | boolean | undefined;
Expand Down Expand Up @@ -151,6 +152,7 @@ export interface AdaptedEvent {
time: number;
button?: MouseButton;
stylusData?: StylusData;
wheelDeltaY?: number;
}

export enum EventTypes {
Expand All @@ -171,3 +173,9 @@ export enum TouchEventType {
UP,
CANCELLED,
}

export enum WheelDevice {
UNDETERMINED,
MOUSE,
TOUCHPAD,
}
4 changes: 4 additions & 0 deletions src/web/tools/EventManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default abstract class EventManager<T> {
protected onPointerOutOfBounds(_event: AdaptedEvent): void {}
protected onPointerMoveOver(_event: AdaptedEvent): void {}
protected onPointerMoveOut(_event: AdaptedEvent): void {}
protected onWheel(_event: AdaptedEvent): void {}

public setOnPointerDown(callback: PointerEventCallback): void {
this.onPointerDown = callback;
Expand Down Expand Up @@ -71,6 +72,9 @@ export default abstract class EventManager<T> {
public setOnPointerMoveOut(callback: PointerEventCallback): void {
this.onPointerMoveOut = callback;
}
public setOnWheel(callback: PointerEventCallback): void {
this.onWheel = callback;
}

protected markAsInBounds(pointerId: number): void {
if (this.pointersInBounds.indexOf(pointerId) >= 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/web/tools/GestureHandlerWebDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import EventManager from './EventManager';
import { Config } from '../interfaces';
import { MouseButton } from '../../handlers/gestureHandlerCommon';
import KeyboardEventManager from './KeyboardEventManager';
import WheelEventManager from './WheelEventManager';

interface DefaultViewStyles {
userSelect: string;
Expand Down Expand Up @@ -58,6 +59,7 @@ export class GestureHandlerWebDelegate

this.eventManagers.push(new PointerEventManager(this.view));
this.eventManagers.push(new KeyboardEventManager(this.view));
this.eventManagers.push(new WheelEventManager(this.view));

this.eventManagers.forEach((manager) =>
this.gestureHandler.attachEventManager(manager)
Expand Down
48 changes: 48 additions & 0 deletions src/web/tools/WheelEventManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import EventManager from './EventManager';
import { AdaptedEvent, EventTypes } from '../interfaces';
import { PointerType } from '../../PointerType';

export default class WheelEventManager extends EventManager<HTMLElement> {
private wheelDelta = { x: 0, y: 0 };

private resetDelta = (_event: PointerEvent) => {
this.wheelDelta = { x: 0, y: 0 };
};

private wheelCallback = (event: WheelEvent) => {
this.wheelDelta.x += event.deltaX;
this.wheelDelta.y += event.deltaY;

const adaptedEvent = this.mapEvent(event);
this.onWheel(adaptedEvent);
};

public registerListeners(): void {
this.view.addEventListener('pointermove', this.resetDelta);
this.view.addEventListener('wheel', this.wheelCallback);
}

public unregisterListeners(): void {
this.view.removeEventListener('pointermove', this.resetDelta);
this.view.removeEventListener('wheel', this.wheelCallback);
}

protected mapEvent(event: WheelEvent): AdaptedEvent {
return {
x: event.clientX + this.wheelDelta.x,
y: event.clientY + this.wheelDelta.y,
offsetX: event.offsetX - event.deltaX,
offsetY: event.offsetY - event.deltaY,
pointerId: -1,
eventType: EventTypes.MOVE,
pointerType: PointerType.OTHER,
time: event.timeStamp,
// @ts-ignore It does exist, but it's deprecated
wheelDeltaY: event.wheelDeltaY,
};
}

public resetManager(): void {
super.resetManager();
}
}

0 comments on commit d45f8dd

Please sign in to comment.