diff --git a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts new file mode 100644 index 0000000000..14db42919d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts @@ -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 { + public style: Record = {}; + public scrollLeft = 0; + public scrollTop = 0; + private attributes = new Map(); + private listeners = new Map 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; + globals.HTMLElement = FakeHTMLElement; + globals.SVGElement = FakeHTMLElement; + globals.window = { document: { createElement: () => new FakeHTMLElement() } }; +}); + +afterAll(() => { + const globals = globalThis as Record; + 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; + + 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'); + 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); + }); +}); diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index 4d095193ad..75cd59c47c 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -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 @@ -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 + } protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void { if ((out && this.shouldCancelWhenOutside) || !this.enabled) { return; diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 12d23f5d2f..193d60e7f7 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -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 | null = null; private hasLongPressHandler = false; @@ -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; @@ -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 { @@ -144,6 +151,8 @@ export default class NativeViewGestureHandler extends GestureHandler { return; } + this.scrollDetected = false; + this.begin(); dispatchGestureLifecycleEvent( @@ -166,11 +175,6 @@ 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 @@ -178,7 +182,44 @@ export default class NativeViewGestureHandler extends GestureHandler { 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(); } } @@ -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 { diff --git a/packages/react-native-gesture-handler/src/web/tools/EventManager.ts b/packages/react-native-gesture-handler/src/web/tools/EventManager.ts index c18597f32c..33977d9f87 100644 --- a/packages/react-native-gesture-handler/src/web/tools/EventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/EventManager.ts @@ -38,6 +38,7 @@ export default abstract class EventManager { 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; @@ -75,6 +76,9 @@ export default abstract class EventManager { 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) { diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts index a2601991f7..edce77d9a8 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts @@ -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 { @@ -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) diff --git a/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts new file mode 100644 index 0000000000..a8e6067065 --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts @@ -0,0 +1,36 @@ +import { PointerType } from '../../PointerType'; +import type { AdaptedEvent } from '../interfaces'; +import { EventTypes } from '../interfaces'; +import EventManager from './EventManager'; + +export default class ScrollEventManager extends EventManager { + private scrollCallback = (event: Event) => { + this.onScroll(this.mapEvent(event)); + }; + + public registerListeners(): void { + this.view.addEventListener('scroll', this.scrollCallback, { + passive: true, + }); + } + + public unregisterListeners(): void { + this.view.removeEventListener('scroll', this.scrollCallback); + } + + protected mapEvent(event: Event): AdaptedEvent { + // Synthesize a pointer glued to the scrolled content - deltas between + // events equal the scrolled distance. getBoundingClientRect would only + // add a constant offset at the cost of a layout read on every event. + return { + x: -this.view.scrollLeft, + y: -this.view.scrollTop, + offsetX: -this.view.scrollLeft, + offsetY: -this.view.scrollTop, + pointerId: -1, + eventType: EventTypes.MOVE, + pointerType: PointerType.OTHER, + time: event.timeStamp, + }; + } +}