From c9dbc4cf5a5550eefc41063b88487cc13f45f695 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 3 Jul 2026 14:27:41 +0200 Subject: [PATCH 1/2] preserve SingleGestureName in gesture types --- .../src/handlers/handlersRegistry.ts | 12 ++++++----- .../src/v3/hooks/gestures/fling/FlingTypes.ts | 5 ++++- .../src/v3/hooks/gestures/hover/HoverTypes.ts | 4 +++- .../src/v3/hooks/gestures/index.ts | 13 ++---------- .../gestures/longPress/LongPressTypes.ts | 5 ++++- .../gestures/longPress/useLongPressGesture.ts | 10 ++++++---- .../v3/hooks/gestures/manual/ManualTypes.ts | 5 ++++- .../v3/hooks/gestures/native/NativeTypes.ts | 5 ++++- .../src/v3/hooks/gestures/pan/PanTypes.ts | 4 +++- .../v3/hooks/gestures/pan/usePanGesture.ts | 3 ++- .../src/v3/hooks/gestures/pinch/PinchTypes.ts | 4 +++- .../hooks/gestures/rotation/RotationTypes.ts | 4 +++- .../v3/hooks/gestures/singleGestureUnion.ts | 20 +++++++++++++++++++ .../src/v3/hooks/gestures/tap/TapTypes.ts | 5 ++++- .../v3/hooks/gestures/tap/useTapGesture.ts | 10 ++++++---- .../src/v3/hooks/useGesture.ts | 5 +++-- .../src/v3/types/GestureTypes.ts | 11 ++++++---- 17 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 2c4426d974..b64549a3e5 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(); @@ -25,7 +25,9 @@ export function registerGesture< gesture: SingleGesture ) { if (isTestEnv() && gesture.config.testID) { - hookGestures.set(handlerTag, gesture); + // 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); testIDs.set(gesture.config.testID, handlerTag); } } 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 = { From b77d4c8f02c501ff9335f63ee012b31a4344136a Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 3 Jul 2026 14:53:36 +0200 Subject: [PATCH 2/2] Extract the platform-neutral arbitration core --- .../GestureHandlerOrchestrator.test.ts | 373 +++++++++++++++++ .../GestureArbitrationTypes.ts | 66 +++ .../gestureArbitration/GestureArbitrator.ts | 358 ++++++++++++++++ .../web/tools/GestureHandlerOrchestrator.ts | 393 +++--------------- 4 files changed, 863 insertions(+), 327 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts create mode 100644 packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts create mode 100644 packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts 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/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/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