diff --git a/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts b/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts new file mode 100644 index 0000000000..ec73162572 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts @@ -0,0 +1,373 @@ +import { PointerType } from '../PointerType'; +import { State } from '../State'; +import type IGestureHandler from '../web/handlers/IGestureHandler'; +import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator'; + +interface EmittedEvent { + newState: State; + oldState: State; +} + +// Mimics the state-transition behavior of the web `GestureHandler` base class +// (moveToState + begin/activate/end/fail/cancel guards) without any DOM, +// pointer or delegate logic. Used to characterize the arbitration behavior +// of the orchestrator so it can be extracted into a platform-neutral core. +class FakeHandler { + public state: State = State.UNDETERMINED; + public active = false; + public awaiting = false; + public activationIndex = 0; + public shouldResetProgress = false; + public enabled = true; + public pointerType = PointerType.TOUCH; + + public events: EmittedEvent[] = []; + public resetCalls = 0; + + public delegate = { + view: this, + isPointerInBounds: () => false, + }; + public tracker = { + getLastAbsoluteCoords: () => undefined, + resetTracker: () => undefined, + }; + + private waitFor: FakeHandler[] = []; + private simultaneousWith: FakeHandler[] = []; + + // eslint-disable-next-line no-useless-constructor + constructor(public handlerTag: number) {} + + public asHandler(): IGestureHandler { + return this as unknown as IGestureHandler; + } + + public waitForFailureOf(other: FakeHandler) { + this.waitFor.push(other); + } + + public recognizeSimultaneouslyWith(other: FakeHandler) { + this.simultaneousWith.push(other); + other.simultaneousWith.push(this); + } + + private moveToState(newState: State) { + if (this.state === newState) { + return; + } + + const oldState = this.state; + this.state = newState; + + GestureHandlerOrchestrator.instance.onHandlerStateChange( + this.asHandler(), + newState, + oldState + ); + } + + public begin() { + GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent( + this.asHandler() + ); + + if (this.state === State.UNDETERMINED) { + this.moveToState(State.BEGAN); + } + } + + public activate() { + if (this.state === State.BEGAN) { + this.moveToState(State.ACTIVE); + } + } + + public end() { + if (this.state === State.BEGAN || this.state === State.ACTIVE) { + this.moveToState(State.END); + } + } + + public fail() { + if (this.state === State.ACTIVE || this.state === State.BEGAN) { + this.moveToState(State.FAILED); + } + } + + public cancel() { + if ( + this.state === State.ACTIVE || + this.state === State.UNDETERMINED || + this.state === State.BEGAN + ) { + this.moveToState(State.CANCELLED); + } + } + + public reset() { + this.resetCalls++; + this.state = State.UNDETERMINED; + } + + public sendEvent(newState: State, oldState: State) { + this.events.push({ newState, oldState }); + } + + public shouldWaitForHandlerFailure(other: FakeHandler) { + return this.waitFor.includes(other); + } + + public shouldRequireToWaitForFailure(_other: FakeHandler) { + return false; + } + + public shouldRecognizeSimultaneously(other: FakeHandler) { + return other === this || this.simultaneousWith.includes(other); + } + + public shouldBeCancelledByOther(_other: FakeHandler) { + // Mirrors the InteractionManager default, which returns true only for + // active native handlers. + return false; + } + + public shouldBeginWithRecordedHandlers(_recorded: IGestureHandler[]) { + return true; + } + + public getTrackedPointersID() { + // All fake handlers share a single pointer, like handlers attached to + // overlapping views receiving the same interaction. + return [0]; + } +} + +function flushMicrotasks() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +let createdHandlers: FakeHandler[] = []; +let nextTag = 1; + +function createHandler() { + const handler = new FakeHandler(nextTag++); + createdHandlers.push(handler); + return handler; +} + +afterEach(async () => { + await flushMicrotasks(); + for (const handler of createdHandlers) { + GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator( + handler.asHandler() + ); + } + createdHandlers = []; +}); + +describe('Orchestrator characterization: single handler', () => { + test('progresses through begin, activation and successful end', () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.end(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('failure before activation emits FAILED with BEGAN as previous state', () => { + const handler = createHandler(); + + handler.begin(); + handler.fail(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.FAILED, oldState: State.BEGAN }, + ]); + }); + + test('cancellation after activation emits CANCELLED with ACTIVE as previous state', () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.cancel(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.CANCELLED, oldState: State.ACTIVE }, + ]); + }); + + test('duplicate transition requests do not emit duplicate events', () => { + const handler = createHandler(); + + handler.begin(); + handler.begin(); + handler.activate(); + handler.activate(); + handler.end(); + handler.end(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('finished handler is reset and removed from the orchestrator', async () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.end(); + + await flushMicrotasks(); + + expect(handler.resetCalls).toBe(1); + expect(handler.active).toBe(false); + expect( + GestureHandlerOrchestrator.instance.isHandlerRecorded(handler.asHandler()) + ).toBe(false); + }); +}); + +describe('Orchestrator characterization: waiting for required handlers', () => { + test('handler awaiting a required handler does not emit activation', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + + expect(awaiting.awaiting).toBe(true); + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + ]); + }); + + test('awaiting handler activates after the required handler fails', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + required.fail(); + + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + ]); + expect(awaiting.awaiting).toBe(false); + expect(awaiting.active).toBe(true); + }); + + test('awaiting handler is cancelled after the required handler ends successfully', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + // Required handler would be cancelled by the awaiting one otherwise, + // as the fake handlers share a pointer. + required.recognizeSimultaneouslyWith(awaiting); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + + required.activate(); + required.end(); + + // The awaiting handler was never observably active, so cancellation is + // reported relative to BEGAN. + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + expect(awaiting.awaiting).toBe(false); + }); + + test('discrete handler that finished while awaiting receives synthetic cancellation', () => { + const required = createHandler(); + const discrete = createHandler(); + discrete.waitForFailureOf(required); + required.recognizeSimultaneouslyWith(discrete); + + required.begin(); + + // Discrete handlers may end immediately after requesting activation. + discrete.begin(); + discrete.activate(); + discrete.end(); + + expect(discrete.state).toBe(State.END); + expect(discrete.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + ]); + + required.activate(); + required.end(); + + // Synthetic BEGAN -> CANCELLED is sent even though the handler already + // reached END internally. + expect(discrete.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + }); +}); + +describe('Orchestrator characterization: concurrent handlers', () => { + test('simultaneous handlers are not mutually cancelled', () => { + const first = createHandler(); + const second = createHandler(); + first.recognizeSimultaneouslyWith(second); + + first.begin(); + second.begin(); + first.activate(); + second.activate(); + first.end(); + second.end(); + + expect(first.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + expect(second.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('activation cancels competing handlers', () => { + const winner = createHandler(); + const loser = createHandler(); + + winner.begin(); + loser.begin(); + winner.activate(); + + expect(loser.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + expect(winner.active).toBe(true); + }); +}); diff --git a/packages/react-native-gesture-handler/src/__tests__/recognizerAwareGesture.test.tsx b/packages/react-native-gesture-handler/src/__tests__/recognizerAwareGesture.test.tsx new file mode 100644 index 0000000000..16192f941d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/recognizerAwareGesture.test.tsx @@ -0,0 +1,491 @@ +import { render, renderHook } from '@testing-library/react-native'; +import React from 'react'; +import { Text, View } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; + +import GestureHandlerRootView from '../components/GestureHandlerRootView'; +import { simulatePointerGesture } from '../jestUtils'; +import { GestureDetector } from '../v3/detectors'; +import { useSimultaneousGestures } from '../v3/hooks'; +import { + useFlingGesture, + useLongPressGesture, + usePanGesture, + usePinchGesture, + useRotationGesture, + useTapGesture, +} from '../v3/hooks/gestures'; + +describe('recognizer-aware Jest gesture simulation', () => { + function getNativeDetector( + views: ReturnType['UNSAFE_getAllByType'] + ): ReactTestInstance { + const detector = views(View).find(({ props }) => props.handlerTags); + + if (!detector) { + throw new Error( + 'Expected rendered test tree to include a detector host.' + ); + } + + return detector; + } + + test('pan activates when pointer movement crosses activation threshold', () => { + const onBegin = jest.fn(); + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onDeactivate = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onBegin, + onActivate, + onUpdate, + onDeactivate, + }) + ).result.current; + + simulatePointerGesture({ on: panGesture, x: 50, steps: 2 }); + + expect(onBegin).toHaveBeenCalledTimes(1); + expect(onActivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 0, translationY: 0 }) + ); + expect(onUpdate).toHaveBeenCalled(); + expect(onUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ translationX: 25, translationY: 0 }) + ); + expect(onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 25, canceled: false }) + ); + }); + + test('pan fails when pointer movement does not cross activation threshold', () => { + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onFinalize = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onActivate, + onUpdate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ on: panGesture, x: 1 }); + + expect(onActivate).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('tap succeeds when pointer stays inside movement constraints', () => { + const onBegin = jest.fn(); + const onActivate = jest.fn(); + const onFinalize = jest.fn(); + + const tapGesture = renderHook(() => + useTapGesture({ + disableReanimated: true, + maxDistance: 10, + onBegin, + onActivate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ on: tapGesture }); + + expect(onBegin).toHaveBeenCalledTimes(1); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('tap fails when pointer movement exceeds max distance', () => { + const onActivate = jest.fn(); + const onFinalize = jest.fn(); + + const tapGesture = renderHook(() => + useTapGesture({ + disableReanimated: true, + maxDistance: 10, + onActivate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ on: tapGesture, x: 50 }); + + expect(onActivate).not.toHaveBeenCalled(); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('tap fails when held past max duration', () => { + jest.useFakeTimers(); + + try { + const onActivate = jest.fn(); + const onFinalize = jest.fn(); + + const tapGesture = renderHook(() => + useTapGesture({ + disableReanimated: true, + maxDuration: 100, + onActivate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ on: tapGesture, holdForMs: 101 }); + + expect(onActivate).not.toHaveBeenCalled(); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + } finally { + jest.useRealTimers(); + } + }); + + test('long press activates after hold duration', () => { + jest.useFakeTimers(); + + try { + const onActivate = jest.fn(); + const onFinalize = jest.fn(); + + const longPressGesture = renderHook(() => + useLongPressGesture({ + disableReanimated: true, + minDuration: 500, + onActivate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ on: longPressGesture, holdForMs: 500 }); + + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + } finally { + jest.useRealTimers(); + } + }); + + test('fling activates from a fast pointer path', () => { + const onActivate = jest.fn(); + const onFinalize = jest.fn(); + + const flingGesture = renderHook(() => + useFlingGesture({ + disableReanimated: true, + onActivate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ + on: flingGesture, + x: 120, + steps: 3, + timeStepMs: 10, + }); + + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('disabled gesture emits nothing', () => { + const onBegin = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + enabled: false, + onBegin, + }) + ).result.current; + + simulatePointerGesture({ on: panGesture, x: 50 }); + + expect(onBegin).not.toHaveBeenCalled(); + }); + + test('string targets resolve through gesture test IDs', () => { + const onUpdate = jest.fn(); + + function Example() { + const panGesture = usePanGesture({ + testID: 'draggable', + disableReanimated: true, + onUpdate, + }); + + return ( + + + drag me + + + ); + } + + render(); + + simulatePointerGesture({ on: 'draggable', x: 50, steps: 2 }); + + expect(onUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ translationX: 25 }) + ); + }); + + test('detector host targets stream to every attached recognizer', () => { + const onPanUpdate = jest.fn(); + const onTapActivate = jest.fn(); + const onTapFinalize = jest.fn(); + + function Example() { + const tapGesture = useTapGesture({ + disableReanimated: true, + maxDistance: 10, + onActivate: onTapActivate, + onFinalize: onTapFinalize, + }); + const panGesture = usePanGesture({ + disableReanimated: true, + onUpdate: onPanUpdate, + }); + const gesture = useSimultaneousGestures(tapGesture, panGesture); + + return ( + + + drag me + + + ); + } + + const { UNSAFE_getAllByType } = render(); + const detector = getNativeDetector(UNSAFE_getAllByType); + + simulatePointerGesture({ on: detector, x: 50, steps: 2 }); + + expect(onPanUpdate).toHaveBeenCalled(); + expect(onTapActivate).not.toHaveBeenCalled(); + expect(onTapFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('detector host targets preserve simultaneous relations', () => { + const firstTapActivate = jest.fn(); + const secondTapActivate = jest.fn(); + + function Example() { + const firstTap = useTapGesture({ + disableReanimated: true, + onActivate: firstTapActivate, + }); + const secondTap = useTapGesture({ + disableReanimated: true, + onActivate: secondTapActivate, + }); + const gesture = useSimultaneousGestures(firstTap, secondTap); + + return ( + + + tap me + + + ); + } + + const { UNSAFE_getAllByType } = render(); + const detector = getNativeDetector(UNSAFE_getAllByType); + + simulatePointerGesture({ on: detector }); + + expect(firstTapActivate).toHaveBeenCalledTimes(1); + expect(secondTapActivate).toHaveBeenCalledTimes(1); + }); + + test('pinch activates from a two-pointer path', () => { + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onFinalize = jest.fn(); + + const pinchGesture = renderHook(() => + usePinchGesture({ + disableReanimated: true, + onActivate, + onUpdate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ + on: pinchGesture, + pointers: [ + { + path: [ + { x: 45, y: 50 }, + { x: 30, y: 50 }, + { x: 15, y: 50 }, + ], + }, + { + path: [ + { x: 55, y: 50 }, + { x: 70, y: 50 }, + { x: 85, y: 50 }, + ], + }, + ], + }); + + expect(onActivate).toHaveBeenCalledTimes(1); + const update = onUpdate.mock.calls.at(-1)?.[0] as + | { scale?: unknown } + | undefined; + expect(typeof update?.scale).toBe('number'); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('rotation activates from a two-pointer path', () => { + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onFinalize = jest.fn(); + + const rotationGesture = renderHook(() => + useRotationGesture({ + disableReanimated: true, + onActivate, + onUpdate, + onFinalize, + }) + ).result.current; + + simulatePointerGesture({ + on: rotationGesture, + pointers: [ + { + path: [ + { x: 40, y: 50 }, + { x: 50, y: 35 }, + ], + }, + { + path: [ + { x: 60, y: 50 }, + { x: 50, y: 65 }, + ], + }, + ], + }); + + expect(onActivate).toHaveBeenCalledTimes(1); + const update = onUpdate.mock.calls.at(-1)?.[0] as + | { rotation?: unknown } + | undefined; + expect(typeof update?.rotation).toBe('number'); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('child view targets resolve to the nearest detector host', () => { + const onUpdate = jest.fn(); + + function Example() { + const panGesture = usePanGesture({ + disableReanimated: true, + onUpdate, + }); + + return ( + + + + + + ); + } + + const { getByTestId } = render(); + + simulatePointerGesture({ + on: getByTestId('draggable-child'), + x: 50, + steps: 2, + }); + + expect(onUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ translationX: 25 }) + ); + }); + + test('child view targets prefer the nearest nested detector host', () => { + const onOuterUpdate = jest.fn(); + const onInnerTapActivate = jest.fn(); + const onInnerTapFinalize = jest.fn(); + + function Example() { + const outerPan = usePanGesture({ + disableReanimated: true, + onUpdate: onOuterUpdate, + }); + const innerTap = useTapGesture({ + disableReanimated: true, + maxDistance: 10, + onActivate: onInnerTapActivate, + onFinalize: onInnerTapFinalize, + }); + + return ( + + + + + + + + + + ); + } + + const { getByTestId } = render(); + + simulatePointerGesture({ on: getByTestId('nested-child'), x: 50 }); + + expect(onOuterUpdate).not.toHaveBeenCalled(); + expect(onInnerTapActivate).not.toHaveBeenCalled(); + expect(onInnerTapFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('unsupported recognizer targets throw a useful error', () => { + expect(() => simulatePointerGesture({ on: {} as never, x: 50 })).toThrow( + 'simulatePointerGesture() supports only API v3 hook gestures, gesture test IDs, or rendered detector targets.' + ); + }); +}); diff --git a/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts new file mode 100644 index 0000000000..72c765e4e3 --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts @@ -0,0 +1,66 @@ +import type { State } from '../../State'; + +/** + * Narrow, platform-neutral contract required by the arbitration core. + */ +export interface ArbitratedGestureHandler { + readonly handlerTag: number; + readonly state: State; + readonly enabled: boolean | null; + + active: boolean; + awaiting: boolean; + activationIndex: number; + shouldResetProgress: boolean; + + sendEvent(newState: State, oldState: State): void; + cancel(): void; + fail(): void; + reset(): void; +} + +/** + * Relation decisions injected into the arbitration core. + */ +export interface GestureRelationPolicy< + THandler extends ArbitratedGestureHandler, +> { + /** + * Whether `handler` has to wait for `otherHandler` to fail before it can + * activate. Identity is already excluded by the core. + */ + shouldHandlerWaitForOther(handler: THandler, otherHandler: THandler): boolean; + + /** + * One-directional simultaneity check. The core treats handlers as + * simultaneous when the check passes in either direction. + */ + shouldRecognizeSimultaneously( + handler: THandler, + otherHandler: THandler + ): boolean; + + /** + * Whether an awaiting or active `handler` should be cancelled when + * `otherHandler` activates. + */ + shouldBeCancelledByOther(handler: THandler, otherHandler: THandler): boolean; + + /** + * Platform-specific fallback used for handlers that are neither awaiting + * nor active. + */ + shouldBeCancelledByDefault( + handler: THandler, + otherHandler: THandler + ): boolean; + + /** + * Whether `handler` may begin given the already recorded handlers. On the + * web this implements native-view cancellation rules. + */ + shouldBeginWithRecordedHandlers( + handler: THandler, + recordedHandlers: readonly THandler[] + ): boolean; +} diff --git a/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts new file mode 100644 index 0000000000..54ee6e2f08 --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts @@ -0,0 +1,358 @@ +import { State } from '../../State'; +import type { + ArbitratedGestureHandler, + GestureRelationPolicy, +} from './GestureArbitrationTypes'; + +/** + * Platform-neutral gesture state arbitration core. + * It records participating handlers, tracks + * active and awaiting handlers, defers activation while dependencies are + * unresolved, applies relation decisions supplied by the injected policy and + * emits only observable state transitions. + */ +export default class GestureArbitrator< + THandler extends ArbitratedGestureHandler, +> { + private gestureHandlers: THandler[] = []; + private awaitingHandlers: THandler[] = []; + private awaitingHandlersTags: Set = new Set(); + + private handlingChangeSemaphore = 0; + private activationIndex = 0; + + // eslint-disable-next-line no-useless-constructor + constructor(private relationPolicy: GestureRelationPolicy) {} + + public get recordedHandlers(): readonly THandler[] { + return this.gestureHandlers; + } + + private scheduleFinishedHandlersCleanup(): void { + if (this.handlingChangeSemaphore === 0) { + queueMicrotask(() => { + this.cleanupFinishedHandlers(); + }); + } + } + + private cleanHandler(handler: THandler): void { + handler.reset(); + handler.active = false; + handler.awaiting = false; + handler.activationIndex = Number.MAX_VALUE; + } + + public isHandlerRecorded(handler: THandler): boolean { + return this.gestureHandlers.includes(handler); + } + + public removeHandler(handler: THandler): void { + const indexInGestureHandlers = this.gestureHandlers.indexOf(handler); + const indexInAwaitingHandlers = this.awaitingHandlers.indexOf(handler); + + if (indexInGestureHandlers >= 0) { + this.gestureHandlers.splice(indexInGestureHandlers, 1); + } + + if (indexInAwaitingHandlers >= 0) { + this.awaitingHandlers.splice(indexInAwaitingHandlers, 1); + this.awaitingHandlersTags.delete(handler.handlerTag); + } + } + + private cleanupFinishedHandlers(): void { + const handlersToRemove = new Set(); + + for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { + const handler = this.gestureHandlers[i]; + + if (this.isFinished(handler.state) && !handler.awaiting) { + this.cleanHandler(handler); + handlersToRemove.add(handler); + } + } + + this.gestureHandlers = this.gestureHandlers.filter( + (handler) => !handlersToRemove.has(handler) + ); + } + + private hasOtherHandlerToWaitFor(handler: THandler): boolean { + const hasToWaitFor = (otherHandler: THandler) => { + return ( + !this.isFinished(otherHandler.state) && + this.shouldHandlerWaitForOther(handler, otherHandler) + ); + }; + + return this.gestureHandlers.some(hasToWaitFor); + } + + private shouldBeCancelledByFinishedHandler(handler: THandler): boolean { + const shouldBeCancelled = (otherHandler: THandler) => { + return ( + this.shouldHandlerWaitForOther(handler, otherHandler) && + otherHandler.state === State.END + ); + }; + + return this.gestureHandlers.some(shouldBeCancelled); + } + + private tryActivate(handler: THandler): void { + if (this.shouldBeCancelledByFinishedHandler(handler)) { + handler.cancel(); + return; + } + + if (this.hasOtherHandlerToWaitFor(handler)) { + this.addAwaitingHandler(handler); + return; + } + + const handlerState = handler.state; + + if (handlerState === State.CANCELLED || handlerState === State.FAILED) { + return; + } + + if (this.shouldActivate(handler)) { + this.makeActive(handler); + return; + } + + if (handlerState === State.ACTIVE) { + handler.fail(); + return; + } + + if (handlerState === State.BEGAN) { + handler.cancel(); + } + } + + private shouldActivate(handler: THandler): boolean { + const shouldBeCancelledBy = (otherHandler: THandler) => { + return this.shouldHandlerBeCancelledBy(handler, otherHandler); + }; + + return !this.gestureHandlers.some(shouldBeCancelledBy); + } + + private cleanupAwaitingHandlers(handler: THandler): void { + const shouldWait = (otherHandler: THandler) => { + return ( + !otherHandler.awaiting && + this.shouldHandlerWaitForOther(otherHandler, handler) + ); + }; + + for (const otherHandler of this.awaitingHandlers) { + if (shouldWait(otherHandler)) { + this.cleanHandler(otherHandler); + this.awaitingHandlersTags.delete(otherHandler.handlerTag); + } + } + + this.awaitingHandlers = this.awaitingHandlers.filter((otherHandler) => + this.awaitingHandlersTags.has(otherHandler.handlerTag) + ); + } + + public onHandlerStateChange( + handler: THandler, + newState: State, + oldState: State, + sendIfDisabled?: boolean + ): void { + if (!handler.enabled && !sendIfDisabled) { + return; + } + + this.handlingChangeSemaphore += 1; + + if (this.isFinished(newState)) { + for (const otherHandler of this.awaitingHandlers) { + if ( + !this.shouldHandlerWaitForOther(otherHandler, handler) || + !this.awaitingHandlersTags.has(otherHandler.handlerTag) + ) { + continue; + } + + if (newState !== State.END) { + this.tryActivate(otherHandler); + continue; + } + + otherHandler.cancel(); + + if (otherHandler.state === State.END) { + // Handle edge case, where discrete gestures end immediately after activation thus + // their state is set to END and when the gesture they are waiting for activates they + // should be cancelled, however `cancel` was never sent as gestures were already in the END state. + // Send synthetic BEGAN -> CANCELLED to properly handle JS logic + otherHandler.sendEvent(State.CANCELLED, State.BEGAN); + } + + otherHandler.awaiting = false; + } + } + + if (newState === State.ACTIVE) { + this.tryActivate(handler); + } else if (oldState === State.ACTIVE || oldState === State.END) { + if (handler.active) { + handler.sendEvent(newState, oldState); + } else if ( + oldState === State.ACTIVE && + (newState === State.CANCELLED || newState === State.FAILED) + ) { + handler.sendEvent(newState, State.BEGAN); + } + } else if ( + oldState !== State.UNDETERMINED || + newState !== State.CANCELLED + ) { + handler.sendEvent(newState, oldState); + } + + this.handlingChangeSemaphore -= 1; + + this.scheduleFinishedHandlersCleanup(); + + if (!this.awaitingHandlers.includes(handler)) { + this.cleanupAwaitingHandlers(handler); + } + } + + private makeActive(handler: THandler): void { + const currentState = handler.state; + + handler.active = true; + handler.shouldResetProgress = true; + handler.activationIndex = this.activationIndex++; + + for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { + if (this.shouldHandlerBeCancelledBy(this.gestureHandlers[i], handler)) { + this.gestureHandlers[i].cancel(); + } + } + + for (const otherHandler of this.awaitingHandlers) { + if (this.shouldHandlerBeCancelledBy(otherHandler, handler)) { + otherHandler.awaiting = false; + } + } + + handler.sendEvent(State.ACTIVE, State.BEGAN); + + if (currentState !== State.ACTIVE) { + handler.sendEvent(State.END, State.ACTIVE); + if (currentState !== State.END) { + handler.sendEvent(State.UNDETERMINED, State.END); + } + } + + if (!handler.awaiting) { + return; + } + + handler.awaiting = false; + + this.awaitingHandlers = this.awaitingHandlers.filter( + (otherHandler) => otherHandler !== handler + ); + } + + private addAwaitingHandler(handler: THandler): void { + if (this.awaitingHandlers.includes(handler)) { + return; + } + + this.awaitingHandlers.push(handler); + this.awaitingHandlersTags.add(handler.handlerTag); + + handler.awaiting = true; + handler.activationIndex = this.activationIndex++; + } + + public recordHandlerIfNotPresent(handler: THandler): void { + if (this.isHandlerRecorded(handler)) { + return; + } + + handler.active = false; + handler.awaiting = false; + handler.activationIndex = Number.MAX_SAFE_INTEGER; + + if ( + !this.relationPolicy.shouldBeginWithRecordedHandlers( + handler, + this.gestureHandlers + ) + ) { + handler.cancel(); + } + + this.gestureHandlers.push(handler); + } + + private shouldHandlerWaitForOther( + handler: THandler, + otherHandler: THandler + ): boolean { + return ( + handler !== otherHandler && + this.relationPolicy.shouldHandlerWaitForOther(handler, otherHandler) + ); + } + + private canRunSimultaneously(gh1: THandler, gh2: THandler): boolean { + return ( + gh1 === gh2 || + this.relationPolicy.shouldRecognizeSimultaneously(gh1, gh2) || + this.relationPolicy.shouldRecognizeSimultaneously(gh2, gh1) + ); + } + + private shouldHandlerBeCancelledBy( + handler: THandler, + otherHandler: THandler + ): boolean { + if (this.canRunSimultaneously(handler, otherHandler)) { + return false; + } + + if (handler.awaiting || handler.state === State.ACTIVE) { + return this.relationPolicy.shouldBeCancelledByOther( + handler, + otherHandler + ); + } + + return this.relationPolicy.shouldBeCancelledByDefault( + handler, + otherHandler + ); + } + + private isFinished(state: State): boolean { + return ( + state === State.END || state === State.FAILED || state === State.CANCELLED + ); + } + + /** + * Clears all recorded and awaiting handlers. Used by the Jest utilities to + * keep tests isolated; the web orchestrator does not use it. + */ + public reset(): void { + this.gestureHandlers = []; + this.awaitingHandlers = []; + this.awaitingHandlersTags.clear(); + this.handlingChangeSemaphore = 0; + this.activationIndex = 0; + } +} diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 2c4426d974..820d78a0f6 100644 --- a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts +++ b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts @@ -1,4 +1,5 @@ import { isTestEnv } from '../utils'; +import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; import type { SingleGesture } from '../v3/types'; import type { GestureEvent, @@ -8,10 +9,9 @@ import type { GestureType } from './gestures/gesture'; export const handlerIDToTag: Record = {}; -// There were attempts to create types that merge possible HandlerData and Config, -// but ts was not able to infer them properly in many cases, so we use any here. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const hookGestures = new Map>(); +// Stored as the discriminated union of concrete v3 gestures so lookups by +// test ID can narrow on the `type` field. +const hookGestures = new Map(); const gestures = new Map(); const oldHandlers = new Map(); const testIDs = new Map(); @@ -24,17 +24,23 @@ export function registerGesture< handlerTag: number, gesture: SingleGesture ) { - if (isTestEnv() && gesture.config.testID) { - hookGestures.set(handlerTag, gesture); - testIDs.set(gesture.config.testID, handlerTag); + if (isTestEnv()) { + // The generic parameters cannot be correlated with the union members + // here, but every gesture created by the v3 hooks is a union member. + hookGestures.set(handlerTag, gesture as unknown as AnySingleGesture); + if (gesture.config.testID) { + testIDs.set(gesture.config.testID, handlerTag); + } } } export function unregisterGesture(handlerTag: number) { const gesture = hookGestures.get(handlerTag); - if (gesture && isTestEnv() && gesture.config.testID) { - testIDs.delete(gesture.config.testID); + if (gesture && isTestEnv()) { + if (gesture.config.testID) { + testIDs.delete(gesture.config.testID); + } hookGestures.delete(handlerTag); } } diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 99351f2813..a09c95531f 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1 +1,3 @@ export { fireGestureHandler, getByGestureTestId } from './jestUtils'; +export type { SimulatePointerGestureOptions } from './simulatePointerGesture'; +export { simulatePointerGesture } from './simulatePointerGesture'; diff --git a/packages/react-native-gesture-handler/src/jestUtils/simulatePointerGesture.ts b/packages/react-native-gesture-handler/src/jestUtils/simulatePointerGesture.ts new file mode 100644 index 0000000000..fcd3be5d32 --- /dev/null +++ b/packages/react-native-gesture-handler/src/jestUtils/simulatePointerGesture.ts @@ -0,0 +1,539 @@ +import invariant from 'invariant'; +import type { RefObject } from 'react'; +import type { ReactTestInstance } from 'react-test-renderer'; + +import { ActionType } from '../ActionType'; +import { findGesture } from '../handlers/handlersRegistry'; +import { PointerType } from '../PointerType'; +import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; +import type { GestureHandlerEventWithHandlerData } from '../v3/types'; +import { SingleGestureName } from '../v3/types'; +import WebFlingGestureHandler from '../web/handlers/FlingGestureHandler'; +import type GestureHandler from '../web/handlers/GestureHandler'; +import type IGestureHandler from '../web/handlers/IGestureHandler'; +import WebLongPressGestureHandler from '../web/handlers/LongPressGestureHandler'; +import WebPanGestureHandler from '../web/handlers/PanGestureHandler'; +import WebPinchGestureHandler from '../web/handlers/PinchGestureHandler'; +import WebRotationGestureHandler from '../web/handlers/RotationGestureHandler'; +import WebTapGestureHandler from '../web/handlers/TapGestureHandler'; +import type { AdaptedEvent, Config, PropsRef } from '../web/interfaces'; +import { EventTypes } from '../web/interfaces'; +import EventManager from '../web/tools/EventManager'; +import type { + GestureHandlerDelegate, + MeasureResult, +} from '../web/tools/GestureHandlerDelegate'; +import InteractionManager from '../web/tools/InteractionManager'; +import { getByGestureTestId } from './jestUtils'; + +type RecognizerTarget = string | AnySingleGesture | ReactTestInstance; + +type Point = { + x: number; + y: number; +}; + +type PointerPath = { + id?: number; + path: Point[]; +}; + +type ResolvedPointerPath = { + id: number; + path: Point[]; +}; + +type TestViewGeometry = { + pageX: number; + pageY: number; + width: number; + height: number; +}; + +export type SimulatePointerGestureOptions = { + on: RecognizerTarget; + x?: number; + y?: number; + steps?: number; + path?: Point[]; + pointers?: PointerPath[]; + holdForMs?: number; + timeStepMs?: number; + layout?: Partial; +}; + +const DEFAULT_LAYOUT: TestViewGeometry = { + pageX: 0, + pageY: 0, + width: 100, + height: 100, +}; + +function noop(): void { + return; +} + +class JestPointerEventManager extends EventManager { + public registerListeners(): void { + // No-op: tests call the manager directly. + } + + public unregisterListeners(): void { + // No-op: tests call the manager directly. + } + + protected mapEvent(_event: Event, _eventType: EventTypes): AdaptedEvent { + throw new Error('JestPointerEventManager does not map DOM events.'); + } + + public pointerDown(event: AdaptedEvent): void { + this.onPointerDown(event); + } + + public pointerAdd(event: AdaptedEvent): void { + this.onPointerAdd(event); + } + + public pointerMove(event: AdaptedEvent): void { + this.onPointerMove(event); + } + + public pointerRemove(event: AdaptedEvent): void { + this.onPointerRemove(event); + } + + public pointerUp(event: AdaptedEvent): void { + this.onPointerUp(event); + } +} + +type RuntimeHandler = { + handler: GestureHandler; + manager: JestPointerEventManager; +}; + +type DetectorHostInstance = ReactTestInstance & { + props: { + handlerTags: number[]; + }; +}; + +class JestGestureHandlerDelegate + implements GestureHandlerDelegate +{ + public view: unknown = null; + + // eslint-disable-next-line no-useless-constructor + public constructor(private readonly layout: TestViewGeometry) {} + + public init(viewRef: number, _handler: IGestureHandler): void { + this.view = viewRef; + } + + public detach(): void { + this.view = null; + } + + public updateDOM(): void { + // No-op: Jest runtime has no DOM to update. + } + + public isPointerInBounds({ x, y }: Point): boolean { + return ( + x >= this.layout.pageX && + x <= this.layout.pageX + this.layout.width && + y >= this.layout.pageY && + y <= this.layout.pageY + this.layout.height + ); + } + + public measureView(): MeasureResult { + return this.layout; + } + + public absoluteToLocal(absoluteX: number, absoluteY: number): Point { + return { + x: absoluteX - this.layout.pageX, + y: absoluteY - this.layout.pageY, + }; + } + + public reset = noop; + public onBegin = noop; + public onActivate = noop; + public onEnd = noop; + public onCancel = noop; + public onFail = noop; + public onEnabledChange = noop; + public destroy(): void { + this.detach(); + } +} + +function isV3Gesture(value: unknown): value is AnySingleGesture { + return ( + typeof value === 'object' && + value !== null && + 'detectorCallbacks' in value && + 'gestureRelations' in value && + 'handlerTag' in value && + 'type' in value + ); +} + +function isReactTestInstance( + value: RecognizerTarget +): value is ReactTestInstance { + return ( + typeof value === 'object' && + value !== null && + 'props' in value && + typeof value.props === 'object' + ); +} + +function isDetectorHost( + value: ReactTestInstance +): value is DetectorHostInstance { + const props = value.props as { handlerTags?: unknown }; + + return Array.isArray(props.handlerTags); +} + +function findNearestDetectorHost( + target: ReactTestInstance +): DetectorHostInstance | null { + let current: ReactTestInstance | null = target; + + while (current) { + if (isDetectorHost(current)) { + return current; + } + + current = current.parent; + } + + return null; +} + +function resolveGestureTarget(target: RecognizerTarget): AnySingleGesture[] { + if (isReactTestInstance(target)) { + const detectorHost = findNearestDetectorHost(target); + + invariant( + detectorHost, + 'simulatePointerGesture() could not find an API v3 detector host for the rendered target.' + ); + + return detectorHost.props.handlerTags.map((handlerTag: number) => { + const gesture = findGesture(handlerTag); + + invariant( + gesture, + `simulatePointerGesture() could not find a registered hook gesture for handler tag ${handlerTag}.` + ); + + return gesture; + }); + } + + const resolvedTarget = + typeof target === 'string' ? getByGestureTestId(target) : target; + + invariant( + isV3Gesture(resolvedTarget), + 'simulatePointerGesture() supports only API v3 hook gestures, gesture test IDs, or rendered detector targets.' + ); + + return [resolvedTarget]; +} + +function createPropsRef(gesture: AnySingleGesture): RefObject { + const emit = (event: Parameters[0]) => { + const jsEventHandler = gesture.detectorCallbacks.jsEventHandler as + | ((event: GestureHandlerEventWithHandlerData) => void) + | undefined; + + jsEventHandler?.( + event as unknown as GestureHandlerEventWithHandlerData + ); + }; + + return { + current: { + onGestureHandlerEvent: emit, + onGestureHandlerStateChange: emit, + onGestureHandlerTouchEvent: emit, + onGestureHandlerReanimatedEvent: emit, + onGestureHandlerReanimatedStateChange: emit, + onGestureHandlerReanimatedTouchEvent: emit, + onGestureHandlerAnimatedEvent: emit, + }, + }; +} + +function createWebGestureHandler( + gesture: AnySingleGesture, + layout: TestViewGeometry +): GestureHandler { + const delegate = new JestGestureHandlerDelegate(layout); + + switch (gesture.type) { + case SingleGestureName.Pan: + return new WebPanGestureHandler(delegate); + case SingleGestureName.Tap: + return new WebTapGestureHandler(delegate); + case SingleGestureName.LongPress: + return new WebLongPressGestureHandler(delegate); + case SingleGestureName.Fling: + return new WebFlingGestureHandler(delegate); + case SingleGestureName.Pinch: + return new WebPinchGestureHandler(delegate); + case SingleGestureName.Rotation: + return new WebRotationGestureHandler(delegate); + default: + throw new Error( + `simulatePointerGesture() does not support ${gesture.type} yet.` + ); + } +} + +function createLocalPath({ + x, + y, + steps = 1, + path, +}: Pick): Point[] { + if (path) { + invariant( + path.length > 0, + 'simulatePointerGesture() expected path to contain at least one point.' + ); + return path; + } + + const hasMovement = x !== undefined || y !== undefined; + if (!hasMovement) { + return [{ x: 0, y: 0 }]; + } + + invariant( + Number.isInteger(steps) && steps > 0, + `simulatePointerGesture() expected steps to be a positive integer, received ${steps}.` + ); + + return [ + { x: 0, y: 0 }, + ...Array.from({ length: steps }, (_, index) => { + const progress = (index + 1) / steps; + + return { + x: (x ?? 0) * progress, + y: (y ?? 0) * progress, + }; + }), + ]; +} + +function createPointerPaths({ + pointers, + ...pathOptions +}: Pick< + SimulatePointerGestureOptions, + 'x' | 'y' | 'steps' | 'path' | 'pointers' +>): ResolvedPointerPath[] { + if (!pointers) { + return [ + { + id: 1, + path: createLocalPath(pathOptions), + }, + ]; + } + + invariant( + pointers.length > 0, + 'simulatePointerGesture() expected pointers to contain at least one pointer path.' + ); + + return pointers.map((pointer, index) => { + invariant( + pointer.path.length > 0, + 'simulatePointerGesture() expected each pointer path to contain at least one point.' + ); + + return { + id: pointer.id ?? index + 1, + path: pointer.path, + }; + }); +} + +function createAdaptedEvent( + point: Point, + layout: TestViewGeometry, + eventType: EventTypes, + time: number, + pointerId: number +): AdaptedEvent { + return { + x: layout.pageX + point.x, + y: layout.pageY + point.y, + offsetX: point.x, + offsetY: point.y, + pointerId, + eventType, + pointerType: PointerType.TOUCH, + time, + }; +} + +function getPathPoint(path: Point[], index: number): Point { + return path[Math.min(index, path.length - 1)]; +} + +function advanceTimersByTime(ms: number): void { + if (ms === 0) { + return; + } + + invariant( + typeof jest !== 'undefined' && + typeof jest.advanceTimersByTime === 'function', + 'simulatePointerGesture() with holdForMs requires Jest fake timers.' + ); + + jest.advanceTimersByTime(ms); +} + +export function simulatePointerGesture({ + on, + layout: partialLayout, + holdForMs = 0, + timeStepMs = 16, + ...pathOptions +}: SimulatePointerGestureOptions): void { + const gestures = resolveGestureTarget(on); + const layout = { ...DEFAULT_LAYOUT, ...partialLayout }; + const pointerPaths = createPointerPaths(pathOptions); + const maxPathLength = Math.max( + ...pointerPaths.map((pointer) => pointer.path.length) + ); + let currentTime = 0; + const handlers = gestures.map((gesture): RuntimeHandler => { + const handler = createWebGestureHandler(gesture, layout); + const manager = new JestPointerEventManager({}); + const propsRef = createPropsRef(gesture); + + handler.handlerTag = gesture.handlerTag; + handler.setGestureConfig(gesture.config as unknown as Config); + InteractionManager.instance.configureInteractions( + handler, + gesture.gestureRelations + ); + handler.init(1, propsRef, ActionType.NATIVE_DETECTOR); + handler.attachEventManager(manager); + + return { + handler, + manager, + }; + }); + + try { + invariant( + timeStepMs > 0, + `simulatePointerGesture() expected timeStepMs to be greater than 0, received ${timeStepMs}.` + ); + + const [firstPointer, ...additionalPointers] = pointerPaths; + + handlers.forEach(({ manager }) => { + manager.pointerDown( + createAdaptedEvent( + firstPointer.path[0], + layout, + EventTypes.DOWN, + currentTime, + firstPointer.id + ) + ); + }); + + additionalPointers.forEach((pointer) => + handlers.forEach(({ manager }) => { + manager.pointerAdd( + createAdaptedEvent( + pointer.path[0], + layout, + EventTypes.ADDITIONAL_POINTER_DOWN, + currentTime, + pointer.id + ) + ); + }) + ); + + for (let i = 1; i < maxPathLength; i++) { + currentTime += timeStepMs; + const eventTime = currentTime; + + pointerPaths.forEach((pointer) => { + handlers.forEach(({ manager }) => { + manager.pointerMove( + createAdaptedEvent( + getPathPoint(pointer.path, i), + layout, + EventTypes.MOVE, + eventTime, + pointer.id + ) + ); + }); + }); + } + + invariant( + holdForMs >= 0, + `simulatePointerGesture() expected holdForMs to be greater than or equal to 0, received ${holdForMs}.` + ); + + if (holdForMs > 0) { + advanceTimersByTime(holdForMs); + currentTime += holdForMs; + } + + [...additionalPointers].reverse().forEach((pointer) => + handlers.forEach(({ manager }) => { + manager.pointerRemove( + createAdaptedEvent( + getPathPoint(pointer.path, maxPathLength - 1), + layout, + EventTypes.ADDITIONAL_POINTER_UP, + currentTime, + pointer.id + ) + ); + }) + ); + + handlers.forEach(({ manager }) => + manager.pointerUp( + createAdaptedEvent( + getPathPoint(firstPointer.path, maxPathLength - 1), + layout, + EventTypes.UP, + currentTime, + firstPointer.id + ) + ) + ); + } finally { + handlers.forEach(({ handler }) => { + InteractionManager.instance.dropRelationsForHandlerWithTag( + handler.handlerTag + ); + handler.onDestroy(); + }); + } +} diff --git a/packages/react-native-gesture-handler/src/mocks/hostDetector.tsx b/packages/react-native-gesture-handler/src/mocks/hostDetector.tsx index 2739c45e0a..514abae3c5 100644 --- a/packages/react-native-gesture-handler/src/mocks/hostDetector.tsx +++ b/packages/react-native-gesture-handler/src/mocks/hostDetector.tsx @@ -1,5 +1,11 @@ +import type { ComponentType } from 'react'; +import type { ViewProps } from 'react-native'; import { View } from 'react-native'; -const HostGestureDetector = View; +type HostGestureDetectorMockProps = ViewProps & { + handlerTags?: number[]; +}; + +const HostGestureDetector = View as ComponentType; export default HostGestureDetector; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts index cf093c3afa..fea6a59927 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -56,5 +57,7 @@ export type FlingGestureActiveEvent = FlingGestureEvent; export type FlingGesture = SingleGesture< FlingGestureProperties, - FlingHandlerData + FlingHandlerData, + FlingHandlerData, + SingleGestureName.Fling >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts index 5bf13293cc..123f67c8b2 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts @@ -5,6 +5,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -72,5 +73,6 @@ export type HoverGestureActiveEvent = GestureEvent; export type HoverGesture = SingleGesture< HoverGestureInternalProperties, HoverHandlerData, - HoverExtendedHandlerData + HoverExtendedHandlerData, + SingleGestureName.Hover >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts index ca6c7fd015..a990ed2b91 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts @@ -124,17 +124,8 @@ export type { PanGestureEvent, }; export { usePanGesture } from './pan/usePanGesture'; - -export type SingleGesture = - | TapGesture - | FlingGesture - | LongPressGesture - | PinchGesture - | RotationGesture - | HoverGesture - | ManualGesture - | NativeGesture - | PanGesture; +export type { AnySingleGesture } from './singleGestureUnion'; +export type { AnySingleGesture as SingleGesture } from './singleGestureUnion'; /* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ export type SingleGestureEvent = diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts index 5736206cf7..ca80660f20 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -68,5 +69,7 @@ export type LongPressGestureActiveEvent = LongPressGestureEvent; export type LongPressGesture = SingleGesture< LongPressGestureProperties, - LongPressHandlerData + LongPressHandlerData, + LongPressHandlerData, + SingleGestureName.LongPress >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts index 144dd4d7d8..5ae23c7911 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts @@ -39,8 +39,10 @@ export function useLongPressGesture( LongPressGestureInternalProperties >(config, LongPressPropsMapping, transformLongPressProps); - return useGesture( - SingleGestureName.LongPress, - longPressConfig - ); + return useGesture< + LongPressGestureInternalProperties, + LongPressHandlerData, + LongPressHandlerData, + SingleGestureName.LongPress + >(SingleGestureName.LongPress, longPressConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts index 81946bcac7..7feed55b71 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type ManualGestureNativeProperties = Record; @@ -24,5 +25,7 @@ export type ManualGestureActiveEvent = ManualGestureEvent; export type ManualGesture = SingleGesture< ManualGestureProperties, - ManualHandlerData + ManualHandlerData, + ManualHandlerData, + SingleGestureName.Manual >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts index fb3026e7b9..726232f630 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -58,5 +59,7 @@ export type NativeGestureActiveEvent = NativeGestureEvent; export type NativeGesture = SingleGesture< NativeGestureProperties, - NativeHandlerData + NativeHandlerData, + NativeHandlerData, + SingleGestureName.Native >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts index db5b879912..3d70bfa269 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts @@ -4,6 +4,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -170,5 +171,6 @@ export type PanGestureActiveEvent = GestureEvent; export type PanGesture = SingleGesture< PanGestureInternalProperties, PanHandlerData, - PanExtendedHandlerData + PanExtendedHandlerData, + SingleGestureName.Pan >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts index 0f714421c0..44b518dfc9 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts @@ -158,6 +158,7 @@ export function usePanGesture( return useGesture< PanGestureInternalProperties, PanHandlerData, - PanExtendedHandlerData + PanExtendedHandlerData, + SingleGestureName.Pan >(SingleGestureName.Pan, panConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts index e6b7aa9877..d0f64fffb2 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type PinchGestureNativeProperties = Record; @@ -36,5 +37,6 @@ export type PinchGestureActiveEvent = GestureEvent; export type PinchGesture = SingleGesture< PinchGestureProperties, PinchHandlerData, - PinchExtendedHandlerData + PinchExtendedHandlerData, + SingleGestureName.Pinch >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts index 9327ca1d67..abb0f0c42d 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type RotationGestureNativeProperties = Record; @@ -37,5 +38,6 @@ export type RotationGestureActiveEvent = export type RotationGesture = SingleGesture< RotationGestureProperties, RotationHandlerData, - RotationExtendedHandlerData + RotationExtendedHandlerData, + SingleGestureName.Rotation >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts new file mode 100644 index 0000000000..f42b78a8a5 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts @@ -0,0 +1,20 @@ +import type { FlingGesture } from './fling/FlingTypes'; +import type { HoverGesture } from './hover/HoverTypes'; +import type { LongPressGesture } from './longPress/LongPressTypes'; +import type { ManualGesture } from './manual/ManualTypes'; +import type { NativeGesture } from './native/NativeTypes'; +import type { PanGesture } from './pan/PanTypes'; +import type { PinchGesture } from './pinch/PinchTypes'; +import type { RotationGesture } from './rotation/RotationTypes'; +import type { TapGesture } from './tap/TapTypes'; + +export type AnySingleGesture = + | TapGesture + | FlingGesture + | LongPressGesture + | PinchGesture + | RotationGesture + | HoverGesture + | ManualGesture + | NativeGesture + | PanGesture; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts index 4c1dfb1fbb..62e9b73ab6 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts @@ -3,6 +3,7 @@ import type { DiscreteSingleGesture, ExcludeInternalConfigProps, GestureEvent, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -96,5 +97,7 @@ export type TapGestureActiveEvent = TapGestureEvent; export type TapGesture = DiscreteSingleGesture< TapGestureInternalProperties, - TapHandlerData + TapHandlerData, + TapHandlerData, + SingleGestureName.Tap >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts index edd8c654e4..de2b8196f0 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts @@ -29,8 +29,10 @@ export function useTapGesture( TapGestureInternalProperties >(config, TapPropsMapping); - return useGesture( - SingleGestureName.Tap, - tapConfig - ); + return useGesture< + TapGestureInternalProperties, + TapHandlerData, + TapHandlerData, + SingleGestureName.Tap + >(SingleGestureName.Tap, tapConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts index ff55841506..3871cdebca 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -25,10 +25,11 @@ export function useGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, >( - type: SingleGestureName, + type: TType, config: BaseGestureConfig -): SingleGesture { +): SingleGesture { const handlerTag = useMemo(() => getNextHandlerTag(), []); const disableReanimated = useMemo(() => config.disableReanimated, []); diff --git a/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts b/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts index d78e7adc34..fb7284dba4 100644 --- a/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts @@ -39,9 +39,10 @@ export type SingleGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, > = { handlerTag: number; - type: SingleGestureName; + type: TType; config: BaseGestureConfig; detectorCallbacks: DetectorCallbacks; gestureRelations: GestureRelations; @@ -51,17 +52,19 @@ export type DiscreteSingleGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, > = { [K in keyof SingleGesture< TConfig, THandlerData, - TExtendedHandlerData + TExtendedHandlerData, + TType >]: K extends 'config' ? Omit< - SingleGesture[K], + SingleGesture[K], 'onUpdate' > - : SingleGesture[K]; + : SingleGesture[K]; }; export type ComposedGesture = { diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts index 406da74e56..5c9524cc5e 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts @@ -1,154 +1,85 @@ +import type { GestureRelationPolicy } from '../../handlers/gestureArbitration/GestureArbitrationTypes'; +import GestureArbitrator from '../../handlers/gestureArbitration/GestureArbitrator'; import { PointerType } from '../../PointerType'; -import { State } from '../../State'; +import type { State } from '../../State'; import type IGestureHandler from '../handlers/IGestureHandler'; import PointerTracker from './PointerTracker'; -export default class GestureHandlerOrchestrator { - private static _instance: GestureHandlerOrchestrator; - - private gestureHandlers: IGestureHandler[] = []; - private awaitingHandlers: IGestureHandler[] = []; - private awaitingHandlersTags: Set = new Set(); - - private handlingChangeSemaphore = 0; - private activationIndex = 0; - - // Private beacuse of Singleton - // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function - private constructor() {} - - private scheduleFinishedHandlersCleanup(): void { - if (this.handlingChangeSemaphore === 0) { - queueMicrotask(() => { - this.cleanupFinishedHandlers(); - }); - } - } +function checkOverlap( + handler: IGestureHandler, + otherHandler: IGestureHandler +): boolean { + // If handlers don't have common pointers, default return value is false. + // However, if at least on pointer overlaps with both handlers, we return true + // This solves issue in overlapping parents example - private cleanHandler(handler: IGestureHandler): void { - handler.reset(); - handler.active = false; - handler.awaiting = false; - handler.activationIndex = Number.MAX_VALUE; - } + // TODO: Find better way to handle that issue, for example by activation order and handler cancelling - public isHandlerRecorded(handler: IGestureHandler): boolean { - return this.gestureHandlers.includes(handler); - } - - public removeHandlerFromOrchestrator(handler: IGestureHandler): void { - const indexInGestureHandlers = this.gestureHandlers.indexOf(handler); - const indexInAwaitingHandlers = this.awaitingHandlers.indexOf(handler); - - if (indexInGestureHandlers >= 0) { - this.gestureHandlers.splice(indexInGestureHandlers, 1); - } + const isPointerWithinBothBounds = (pointer: number) => { + const point = handler.tracker.getLastAbsoluteCoords(pointer); - if (indexInAwaitingHandlers >= 0) { - this.awaitingHandlers.splice(indexInAwaitingHandlers, 1); - this.awaitingHandlersTags.delete(handler.handlerTag); - } - } - - private cleanupFinishedHandlers(): void { - const handlersToRemove = new Set(); - - for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { - const handler = this.gestureHandlers[i]; - - if (this.isFinished(handler.state) && !handler.awaiting) { - this.cleanHandler(handler); - handlersToRemove.add(handler); - } - } - - this.gestureHandlers = this.gestureHandlers.filter( - (handler) => !handlersToRemove.has(handler) + return ( + point && + handler.delegate.isPointerInBounds(point) && + otherHandler.delegate.isPointerInBounds(point) ); - } + }; - private hasOtherHandlerToWaitFor(handler: IGestureHandler): boolean { - const hasToWaitFor = (otherHandler: IGestureHandler) => { - return ( - !this.isFinished(otherHandler.state) && - this.shouldHandlerWaitForOther(handler, otherHandler) - ); - }; + return handler.getTrackedPointersID().some(isPointerWithinBothBounds); +} - return this.gestureHandlers.some(hasToWaitFor); - } +// Web-specific relation policy. Relation checks are delegated to the +// handlers (which consult the InteractionManager), while the default +// cancellation rule uses shared pointers and view overlap, which only exist +// on the web. +const webRelationPolicy: GestureRelationPolicy = { + shouldHandlerWaitForOther: (handler, otherHandler) => + handler.shouldWaitForHandlerFailure(otherHandler) || + otherHandler.shouldRequireToWaitForFailure(handler), - private shouldBeCancelledByFinishedHandler( - handler: IGestureHandler - ): boolean { - const shouldBeCancelled = (otherHandler: IGestureHandler) => { - return ( - this.shouldHandlerWaitForOther(handler, otherHandler) && - otherHandler.state === State.END - ); - }; + shouldRecognizeSimultaneously: (handler, otherHandler) => + handler.shouldRecognizeSimultaneously(otherHandler), - return this.gestureHandlers.some(shouldBeCancelled); - } + shouldBeCancelledByOther: (handler, otherHandler) => + handler.shouldBeCancelledByOther(otherHandler), - private tryActivate(handler: IGestureHandler): void { - if (this.shouldBeCancelledByFinishedHandler(handler)) { - handler.cancel(); - return; - } + shouldBeCancelledByDefault: (handler, otherHandler) => { + const handlerPointers: number[] = handler.getTrackedPointersID(); + const otherPointers: number[] = otherHandler.getTrackedPointersID(); - if (this.hasOtherHandlerToWaitFor(handler)) { - this.addAwaitingHandler(handler); - return; + if ( + !PointerTracker.shareCommonPointers(handlerPointers, otherPointers) && + handler.delegate.view !== otherHandler.delegate.view + ) { + return checkOverlap(handler, otherHandler); } - const handlerState = handler.state; - - if (handlerState === State.CANCELLED || handlerState === State.FAILED) { - return; - } + return true; + }, - if (this.shouldActivate(handler)) { - this.makeActive(handler); - return; - } + shouldBeginWithRecordedHandlers: (handler, recordedHandlers) => + handler.shouldBeginWithRecordedHandlers( + recordedHandlers as IGestureHandler[] + ), +}; - if (handlerState === State.ACTIVE) { - handler.fail(); - return; - } +export default class GestureHandlerOrchestrator { + private static _instance: GestureHandlerOrchestrator; - if (handlerState === State.BEGAN) { - handler.cancel(); - } - } + private arbitrator = new GestureArbitrator( + webRelationPolicy + ); - private shouldActivate(handler: IGestureHandler): boolean { - const shouldBeCancelledBy = (otherHandler: IGestureHandler) => { - return this.shouldHandlerBeCancelledBy(handler, otherHandler); - }; + // Private beacuse of Singleton + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} - return !this.gestureHandlers.some(shouldBeCancelledBy); + public isHandlerRecorded(handler: IGestureHandler): boolean { + return this.arbitrator.isHandlerRecorded(handler); } - private cleanupAwaitingHandlers(handler: IGestureHandler): void { - const shouldWait = (otherHandler: IGestureHandler) => { - return ( - !otherHandler.awaiting && - this.shouldHandlerWaitForOther(otherHandler, handler) - ); - }; - - for (const otherHandler of this.awaitingHandlers) { - if (shouldWait(otherHandler)) { - this.cleanHandler(otherHandler); - this.awaitingHandlersTags.delete(otherHandler.handlerTag); - } - } - - this.awaitingHandlers = this.awaitingHandlers.filter((otherHandler) => - this.awaitingHandlersTags.has(otherHandler.handlerTag) - ); + public removeHandlerFromOrchestrator(handler: IGestureHandler): void { + this.arbitrator.removeHandler(handler); } public onHandlerStateChange( @@ -157,208 +88,16 @@ export default class GestureHandlerOrchestrator { oldState: State, sendIfDisabled?: boolean ): void { - if (!handler.enabled && !sendIfDisabled) { - return; - } - - this.handlingChangeSemaphore += 1; - - if (this.isFinished(newState)) { - for (const otherHandler of this.awaitingHandlers) { - if ( - !this.shouldHandlerWaitForOther(otherHandler, handler) || - !this.awaitingHandlersTags.has(otherHandler.handlerTag) - ) { - continue; - } - - if (newState !== State.END) { - this.tryActivate(otherHandler); - continue; - } - - otherHandler.cancel(); - - if (otherHandler.state === State.END) { - // Handle edge case, where discrete gestures end immediately after activation thus - // their state is set to END and when the gesture they are waiting for activates they - // should be cancelled, however `cancel` was never sent as gestures were already in the END state. - // Send synthetic BEGAN -> CANCELLED to properly handle JS logic - otherHandler.sendEvent(State.CANCELLED, State.BEGAN); - } - - otherHandler.awaiting = false; - } - } - - if (newState === State.ACTIVE) { - this.tryActivate(handler); - } else if (oldState === State.ACTIVE || oldState === State.END) { - if (handler.active) { - handler.sendEvent(newState, oldState); - } else if ( - oldState === State.ACTIVE && - (newState === State.CANCELLED || newState === State.FAILED) - ) { - handler.sendEvent(newState, State.BEGAN); - } - } else if ( - oldState !== State.UNDETERMINED || - newState !== State.CANCELLED - ) { - handler.sendEvent(newState, oldState); - } - - this.handlingChangeSemaphore -= 1; - - this.scheduleFinishedHandlersCleanup(); - - if (!this.awaitingHandlers.includes(handler)) { - this.cleanupAwaitingHandlers(handler); - } - } - - private makeActive(handler: IGestureHandler): void { - const currentState = handler.state; - - handler.active = true; - handler.shouldResetProgress = true; - handler.activationIndex = this.activationIndex++; - - for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { - if (this.shouldHandlerBeCancelledBy(this.gestureHandlers[i], handler)) { - this.gestureHandlers[i].cancel(); - } - } - - for (const otherHandler of this.awaitingHandlers) { - if (this.shouldHandlerBeCancelledBy(otherHandler, handler)) { - otherHandler.awaiting = false; - } - } - - handler.sendEvent(State.ACTIVE, State.BEGAN); - - if (currentState !== State.ACTIVE) { - handler.sendEvent(State.END, State.ACTIVE); - if (currentState !== State.END) { - handler.sendEvent(State.UNDETERMINED, State.END); - } - } - - if (!handler.awaiting) { - return; - } - - handler.awaiting = false; - - this.awaitingHandlers = this.awaitingHandlers.filter( - (otherHandler) => otherHandler !== handler + this.arbitrator.onHandlerStateChange( + handler, + newState, + oldState, + sendIfDisabled ); } - private addAwaitingHandler(handler: IGestureHandler): void { - if (this.awaitingHandlers.includes(handler)) { - return; - } - - this.awaitingHandlers.push(handler); - this.awaitingHandlersTags.add(handler.handlerTag); - - handler.awaiting = true; - handler.activationIndex = this.activationIndex++; - } - public recordHandlerIfNotPresent(handler: IGestureHandler): void { - if (this.isHandlerRecorded(handler)) { - return; - } - - handler.active = false; - handler.awaiting = false; - handler.activationIndex = Number.MAX_SAFE_INTEGER; - - if (!handler.shouldBeginWithRecordedHandlers(this.gestureHandlers)) { - handler.cancel(); - } - - this.gestureHandlers.push(handler); - } - - private shouldHandlerWaitForOther( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - return ( - handler !== otherHandler && - (handler.shouldWaitForHandlerFailure(otherHandler) || - otherHandler.shouldRequireToWaitForFailure(handler)) - ); - } - - private canRunSimultaneously( - gh1: IGestureHandler, - gh2: IGestureHandler - ): boolean { - return ( - gh1 === gh2 || - gh1.shouldRecognizeSimultaneously(gh2) || - gh2.shouldRecognizeSimultaneously(gh1) - ); - } - - private shouldHandlerBeCancelledBy( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - if (this.canRunSimultaneously(handler, otherHandler)) { - return false; - } - - if (handler.awaiting || handler.state === State.ACTIVE) { - return handler.shouldBeCancelledByOther(otherHandler); - } - - const handlerPointers: number[] = handler.getTrackedPointersID(); - const otherPointers: number[] = otherHandler.getTrackedPointersID(); - - if ( - !PointerTracker.shareCommonPointers(handlerPointers, otherPointers) && - handler.delegate.view !== otherHandler.delegate.view - ) { - return this.checkOverlap(handler, otherHandler); - } - - return true; - } - - private checkOverlap( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - // If handlers don't have common pointers, default return value is false. - // However, if at least on pointer overlaps with both handlers, we return true - // This solves issue in overlapping parents example - - // TODO: Find better way to handle that issue, for example by activation order and handler cancelling - - const isPointerWithinBothBounds = (pointer: number) => { - const point = handler.tracker.getLastAbsoluteCoords(pointer); - - return ( - point && - handler.delegate.isPointerInBounds(point) && - otherHandler.delegate.isPointerInBounds(point) - ); - }; - - return handler.getTrackedPointersID().some(isPointerWithinBothBounds); - } - - private isFinished(state: State): boolean { - return ( - state === State.END || state === State.FAILED || state === State.CANCELLED - ); + this.arbitrator.recordHandlerIfNotPresent(handler); } // This function is called when handler receives touchdown event @@ -368,7 +107,7 @@ export default class GestureHandlerOrchestrator { // To handle this, when new touch event is received, we loop through active handlers and check which type of // pointer they're using. If there are any handler with mouse/pen as a pointer, we cancel them public cancelMouseAndPenGestures(currentHandler: IGestureHandler): void { - this.gestureHandlers.forEach((handler: IGestureHandler) => { + this.arbitrator.recordedHandlers.forEach((handler: IGestureHandler) => { if ( handler.pointerType !== PointerType.MOUSE && handler.pointerType !== PointerType.STYLUS