Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { ActionType } from '../ActionType';
import { PointerType } from '../PointerType';
import { State } from '../State';
import { NATIVE_GESTURE_ROLE_ATTRIBUTE } from '../web/constants';
import type IGestureHandler from '../web/handlers/IGestureHandler';
import NativeViewGestureHandler from '../web/handlers/NativeViewGestureHandler';
import type { AdaptedEvent } from '../web/interfaces';
import { EventTypes, NativeGestureRole } from '../web/interfaces';
import type { GestureHandlerDelegate } from '../web/tools/GestureHandlerDelegate';
import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator';
import ScrollEventManager from '../web/tools/ScrollEventManager';

// The Jest environment is node — provide the minimal DOM surface the handler
// touches (canUseDOM, instanceof HTMLElement).
class FakeHTMLElement {
Comment thread
Copilot marked this conversation as resolved.
public style: Record<string, string> = {};
public scrollLeft = 0;
public scrollTop = 0;
private attributes = new Map<string, string>();
private listeners = new Map<string, Set<(event: unknown) => void>>();

public setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
public getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
public hasAttribute(name: string): boolean {
return this.attributes.has(name);
}
public addEventListener(type: string, listener: (event: unknown) => void) {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
public removeEventListener(type: string, listener: (event: unknown) => void) {
this.listeners.get(type)?.delete(listener);
}
public dispatchEvent(type: string): void {
this.listeners.get(type)?.forEach((listener) => listener({ type }));
}
}

beforeAll(() => {
const globals = globalThis as Record<string, unknown>;
globals.HTMLElement = FakeHTMLElement;
globals.SVGElement = FakeHTMLElement;
globals.window = { document: { createElement: () => new FakeHTMLElement() } };
});

afterAll(() => {
const globals = globalThis as Record<string, unknown>;
delete globals.HTMLElement;
delete globals.SVGElement;
delete globals.window;
});

class TestNativeViewGestureHandler extends NativeViewGestureHandler {
public pointerDown(event: AdaptedEvent): void {
this.onPointerDown(event);
}

public pointerMove(event: AdaptedEvent): void {
this.onPointerMove(event);
}
}

function touchEvent(x: number, y: number, eventType: EventTypes): AdaptedEvent {
return {
x,
y,
offsetX: x,
offsetY: y,
pointerId: 0,
eventType,
pointerType: PointerType.TOUCH,
time: 0,
};
}

function createHandler(view: FakeHTMLElement) {
const delegate = {
view,
init: jest.fn(),
detach: jest.fn(),
reset: jest.fn(),
onActivate: jest.fn(),
onFail: jest.fn(),
onCancel: jest.fn(),
onEnd: jest.fn(),
onEnabledChange: jest.fn(),
updateDOM: jest.fn(),
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;

const handler = new TestNativeViewGestureHandler(delegate);
handler.setGestureConfig({ enabled: true });
handler.init(1, { current: {} } as never, ActionType.NATIVE_DETECTOR);

// Route scroll events the same way the real delegate does.
handler.attachEventManager(
new ScrollEventManager(view as unknown as HTMLElement)
);

// The full event pipeline is not under test — silence event emission.
handler.sendEvent = jest.fn();

return handler;
}

describe('NativeViewGestureHandler activation', () => {
afterEach(() => {
// The orchestrator is a singleton — drop handlers recorded by the test.
(
GestureHandlerOrchestrator.instance as unknown as {
gestureHandlers: IGestureHandler[];
}
).gestureHandlers = [];
});

test('scrollable view does not activate on pointer distance alone', () => {
const view = new FakeHTMLElement();
view.setAttribute(
NATIVE_GESTURE_ROLE_ATTRIBUTE,
NativeGestureRole.ScrollView
);
const handler = createHandler(view);

handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
expect(handler.state).toBe(State.BEGAN);

handler.pointerMove(touchEvent(100, 200, EventTypes.MOVE));
expect(handler.state).toBe(State.BEGAN);
});

test('scrollable view activates when it really scrolls during a drag', () => {
const view = new FakeHTMLElement();
view.setAttribute(
NATIVE_GESTURE_ROLE_ATTRIBUTE,
NativeGestureRole.ScrollView
);
const handler = createHandler(view);

handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));

view.dispatchEvent('scroll');
Comment thread
j-piasecki marked this conversation as resolved.
expect(handler.state).toBe(State.ACTIVE);
});

test('scroll under a resting pointer does not activate (momentum stop)', () => {
const view = new FakeHTMLElement();
view.setAttribute(
NATIVE_GESTURE_ROLE_ATTRIBUTE,
NativeGestureRole.ScrollView
);
const handler = createHandler(view);

handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
view.dispatchEvent('scroll');
expect(handler.state).toBe(State.BEGAN);

// Once the pointer really moves, the earlier scroll counts.
handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE));
expect(handler.state).toBe(State.ACTIVE);
});

test('scroll with no tracked pointers is ignored', () => {
const view = new FakeHTMLElement();
view.setAttribute(
NATIVE_GESTURE_ROLE_ATTRIBUTE,
NativeGestureRole.ScrollView
);
const handler = createHandler(view);

view.dispatchEvent('scroll');
expect(handler.state).toBe(State.UNDETERMINED);
});

test('non-scrollable view keeps distance-based activation', () => {
const view = new FakeHTMLElement();
const handler = createHandler(view);

handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
expect(handler.state).toBe(State.BEGAN);

handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
expect(handler.state).toBe(State.ACTIVE);
});

test('role-less view keeps distance-based activation (scroll-driven mode is v3-only)', () => {
const view = new FakeHTMLElement();
view.style.overflowY = 'scroll';
const handler = createHandler(view);

handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
expect(handler.state).toBe(State.ACTIVE);
});

test('scroll on a role-less view does not add an activation path', () => {
const view = new FakeHTMLElement();
view.style.overflowY = 'scroll';
const handler = createHandler(view);

// Below DEFAULT_TOUCH_SLOP, a scroll of the view itself must not activate
// a handler that is not scroll-driven (e.g. legacy ScrollView, TextInput).
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE));
view.dispatchEvent('scroll');
expect(handler.state).toBe(State.BEGAN);

handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
expect(handler.state).toBe(State.ACTIVE);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export default abstract class GestureHandler implements IGestureHandler {
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));
manager.setOnScroll(this.onScroll.bind(this));

// The initial config is applied before the handler is attached. Honor an
// initially disabled handler here because the delegate's enabled-change
Expand Down Expand Up @@ -398,6 +399,9 @@ export default abstract class GestureHandler implements IGestureHandler {
protected onWheel(_event: AdaptedEvent): void {
// Used only by pan gesture handler
}
protected onScroll(_event: AdaptedEvent): void {
// Used only by native view gesture handler
}
Comment thread
m-bert marked this conversation as resolved.
protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
if ((out && this.shouldCancelWhenOutside) || !this.enabled) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export default class NativeViewGestureHandler extends GestureHandler {
private startY = 0;
private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP;

private readonly scrollActivationThresholdSq = 2 * 2;
private isScrollDriven = false;
private scrollDetected = false;

private lastActiveHandlerData: HandlerData<NativeHandlerData> | null = null;

private hasLongPressHandler = false;
Expand All @@ -65,6 +69,7 @@ export default class NativeViewGestureHandler extends GestureHandler {
super.init(ref, propsRef, actionType, hostDetector);

this.shouldCancelWhenOutside = true;
this.isScrollDriven = false;

const view = this.delegate.view;

Expand All @@ -86,6 +91,8 @@ export default class NativeViewGestureHandler extends GestureHandler {
this.role = NativeGestureRole.Switch;
}
}

this.isScrollDriven = this.role === NativeGestureRole.ScrollView;
}

public override updateGestureConfig(config: Config): void {
Expand Down Expand Up @@ -144,6 +151,8 @@ export default class NativeViewGestureHandler extends GestureHandler {
return;
}

this.scrollDetected = false;

this.begin();

dispatchGestureLifecycleEvent(
Expand All @@ -166,19 +175,51 @@ export default class NativeViewGestureHandler extends GestureHandler {
protected override onPointerMove(event: AdaptedEvent): void {
this.tracker.track(event);

const lastCoords = this.tracker.getAbsoluteCoordsAverage();
const dx = this.startX - lastCoords.x;
const dy = this.startY - lastCoords.y;
const distSq = dx * dx + dy * dy;

if (
this.role === NativeGestureRole.Switch ||
this.role === NativeGestureRole.Button
) {
return;
}

if (distSq >= this.minDistSq && this.state === State.BEGAN) {
if (this.isScrollDriven) {
this.tryScrollDrivenActivation();
return;
}

if (
this.pointerTravelSq() >= this.minDistSq &&
this.state === State.BEGAN
) {
this.activate();
}
}

private pointerTravelSq(): number {
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
const dx = this.startX - lastCoords.x;
const dy = this.startY - lastCoords.y;
return dx * dx + dy * dy;
}

protected override onScroll(_event: AdaptedEvent): void {
if (!this.isScrollDriven || this.tracker.trackedPointersCount === 0) {
return;
}

this.scrollDetected = true;
this.tryScrollDrivenActivation();
}

private tryScrollDrivenActivation(): void {
if (!this.scrollDetected || this.state !== State.BEGAN) {
return;
}

// Require some pointer travel on top of the scroll event — momentum
// scrolling keeps emitting `scroll` events after a touch that was only
// meant to stop it, and that touch must not activate the handler.
if (this.pointerTravelSq() >= this.scrollActivationThresholdSq) {
this.activate();
}
}
Expand Down Expand Up @@ -427,6 +468,7 @@ export default class NativeViewGestureHandler extends GestureHandler {
this.lastActiveHandlerData = null;
this.lastEventWasInside = false;
this.longPressDetected = false;
this.scrollDetected = false;
}

public override onDestroy(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default abstract class EventManager<T> {
protected onPointerMoveOver(_event: AdaptedEvent): void {}
protected onPointerMoveOut(_event: AdaptedEvent): void {}
protected onWheel(_event: AdaptedEvent): void {}
protected onScroll(_event: AdaptedEvent): void {}

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

protected markAsInBounds(pointerId: number): void {
if (this.pointersInBounds.indexOf(pointerId) >= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from './GestureHandlerDelegate';
import KeyboardEventManager from './KeyboardEventManager';
import PointerEventManager from './PointerEventManager';
import ScrollEventManager from './ScrollEventManager';
import WheelEventManager from './WheelEventManager';

interface DefaultViewStyles {
Expand Down Expand Up @@ -69,6 +70,7 @@ export class GestureHandlerWebDelegate
);
this.eventManagers.push(new KeyboardEventManager(this.view));
this.eventManagers.push(new WheelEventManager(this.view));
this.eventManagers.push(new ScrollEventManager(this.view));

this.eventManagers.forEach((manager) =>
this.gestureHandler.attachEventManager(manager)
Expand Down
Loading
Loading