UNPKG

react-native-gesture-handler

Version:

Declarative API exposing native platform touch and gesture system to React Native

514 lines (470 loc) 14.6 kB
import invariant from 'invariant'; import { DeviceEventEmitter } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; import { FlingGestureHandler, flingHandlerName, } from '../handlers/FlingGestureHandler'; import { ForceTouchGestureHandler, forceTouchHandlerName, } from '../handlers/ForceTouchGestureHandler'; import { BaseGestureHandlerProps, GestureEvent, HandlerStateChangeEvent, } from '../handlers/gestureHandlerCommon'; import { FlingGesture } from '../handlers/gestures/flingGesture'; import { ForceTouchGesture } from '../handlers/gestures/forceTouchGesture'; import { BaseGesture, GestureType } from '../handlers/gestures/gesture'; import { LongPressGesture } from '../handlers/gestures/longPressGesture'; import { NativeGesture } from '../handlers/gestures/nativeGesture'; import { PanGesture } from '../handlers/gestures/panGesture'; import { PinchGesture } from '../handlers/gestures/pinchGesture'; import { RotationGesture } from '../handlers/gestures/rotationGesture'; import { TapGesture } from '../handlers/gestures/tapGesture'; import { findHandlerByTestID } from '../handlers/handlersRegistry'; import { LongPressGestureHandler, longPressHandlerName, } from '../handlers/LongPressGestureHandler'; import type { FlingGestureHandlerEventPayload, ForceTouchGestureHandlerEventPayload, LongPressGestureHandlerEventPayload, NativeViewGestureHandlerPayload, PanGestureHandlerEventPayload, PinchGestureHandlerEventPayload, RotationGestureHandlerEventPayload, TapGestureHandlerEventPayload, } from '../handlers/GestureHandlerEventPayload'; import { NativeViewGestureHandler, nativeViewHandlerName, } from '../handlers/NativeViewGestureHandler'; import { PanGestureHandler, panHandlerName, } from '../handlers/PanGestureHandler'; import { PinchGestureHandler, pinchHandlerName, } from '../handlers/PinchGestureHandler'; import { RotationGestureHandler, rotationHandlerName, } from '../handlers/RotationGestureHandler'; import { TapGestureHandler, tapHandlerName, } from '../handlers/TapGestureHandler'; import { State } from '../State'; import { hasProperty, withPrevAndCurrent } from '../utils'; // Load fireEvent conditionally, so RNGH may be used in setups without testing-library let fireEvent = ( _element: ReactTestInstance, _name: string, ..._data: any[] ) => { // NOOP }; try { // eslint-disable-next-line @typescript-eslint/no-var-requires fireEvent = require('@testing-library/react-native').fireEvent; } catch (_e) { // Do nothing if not available } type GestureHandlerTestEvent< TEventPayload extends Record<string, unknown> = Record<string, unknown>, > = ( | GestureEvent<TEventPayload> | HandlerStateChangeEvent<TEventPayload> )['nativeEvent']; type HandlerNames = keyof DefaultEventsMapping; type WithNumberOfPointers<T> = { [P in keyof T]: T[P] & { numberOfPointers: number }; }; type DefaultEventsMapping = WithNumberOfPointers<{ [flingHandlerName]: FlingGestureHandlerEventPayload; [forceTouchHandlerName]: ForceTouchGestureHandlerEventPayload; [longPressHandlerName]: LongPressGestureHandlerEventPayload; [nativeViewHandlerName]: NativeViewGestureHandlerPayload; [panHandlerName]: PanGestureHandlerEventPayload; [pinchHandlerName]: PinchGestureHandlerEventPayload; [rotationHandlerName]: RotationGestureHandlerEventPayload; [tapHandlerName]: TapGestureHandlerEventPayload; }>; const handlersDefaultEvents: DefaultEventsMapping = { [flingHandlerName]: { x: 0, y: 0, absoluteX: 0, absoluteY: 0, numberOfPointers: 1, }, [forceTouchHandlerName]: { x: 0, y: 0, absoluteX: 0, absoluteY: 0, force: 1, numberOfPointers: 1, }, [longPressHandlerName]: { x: 0, y: 0, absoluteX: 0, absoluteY: 0, duration: 100, numberOfPointers: 1, }, [nativeViewHandlerName]: { pointerInside: true, numberOfPointers: 1, }, [panHandlerName]: { x: 0, y: 0, absoluteX: 0, absoluteY: 0, translationX: 100, translationY: 0, velocityX: 3, velocityY: 0, numberOfPointers: 1, stylusData: undefined, }, [pinchHandlerName]: { focalX: 0, focalY: 0, scale: 2, velocity: 1, numberOfPointers: 2, }, [rotationHandlerName]: { anchorX: 0, anchorY: 0, rotation: 3.14, velocity: 2, numberOfPointers: 2, }, [tapHandlerName]: { x: 0, y: 0, absoluteX: 0, absoluteY: 0, numberOfPointers: 1, }, }; function isGesture( componentOrGesture: ReactTestInstance | GestureType ): componentOrGesture is GestureType { return componentOrGesture instanceof BaseGesture; } interface WrappedGestureHandlerTestEvent { nativeEvent: GestureHandlerTestEvent; } function wrapWithNativeEvent( event: GestureHandlerTestEvent ): WrappedGestureHandlerTestEvent { return { nativeEvent: event }; } function fillOldStateChanges( previousEvent: GestureHandlerTestEvent | null, currentEvent: Omit<GestureHandlerTestEvent, 'oldState'> ): GestureHandlerTestEvent { const isFirstEvent = previousEvent === null; if (isFirstEvent) { return { oldState: State.UNDETERMINED, ...currentEvent, } as GestureHandlerTestEvent; } const isGestureStateEvent = previousEvent.state !== currentEvent.state; if (isGestureStateEvent) { return { oldState: previousEvent?.state, ...currentEvent, } as GestureHandlerTestEvent; } else { return currentEvent as GestureHandlerTestEvent; } } type EventWithStates = Partial< Pick<GestureHandlerTestEvent, 'oldState' | 'state'> >; function validateStateTransitions( previousEvent: EventWithStates | null, currentEvent: EventWithStates ) { function stringify(event: Record<string, unknown> | null) { return JSON.stringify(event, null, 2); } function errorMsgWithBothEvents(description: string) { return `${description}, invalid event: ${stringify( currentEvent )}, previous event: ${stringify(previousEvent)}`; } function errorMsgWithCurrentEvent(description: string) { return `${description}, invalid event: ${stringify(currentEvent)}`; } invariant( hasProperty(currentEvent, 'state'), errorMsgWithCurrentEvent('every event must have state') ); const isFirstEvent = previousEvent === null; if (isFirstEvent) { invariant( currentEvent.state === State.BEGAN, errorMsgWithCurrentEvent('first event must have BEGAN state') ); } if (previousEvent !== null) { if (previousEvent.state !== currentEvent.state) { invariant( hasProperty(currentEvent, 'oldState'), errorMsgWithCurrentEvent( 'when state changes, oldState field should be present' ) ); invariant( currentEvent.oldState === previousEvent.state, errorMsgWithBothEvents( "when state changes, oldState should be the same as previous event' state" ) ); } } return currentEvent; } type EventWithoutStates = Omit<GestureHandlerTestEvent, 'oldState' | 'state'>; interface HandlerInfo { handlerType: HandlerNames; handlerTag: number; } function fillMissingDefaultsFor({ handlerType, handlerTag, }: HandlerInfo): ( event: Partial<GestureHandlerTestEvent> ) => EventWithoutStates { return (event) => { return { ...handlersDefaultEvents[handlerType], ...event, handlerTag, }; }; } function isDiscreteHandler(handlerType: HandlerNames) { return ( handlerType === 'TapGestureHandler' || handlerType === 'LongPressGestureHandler' ); } function fillMissingStatesTransitions( events: EventWithoutStates[], isDiscreteHandler: boolean ): EventWithoutStates[] { type Event = EventWithoutStates | null; const _events = [...events]; const lastEvent = _events[_events.length - 1] ?? null; const firstEvent = _events[0] ?? null; const shouldDuplicateFirstEvent = !isDiscreteHandler && !hasState(State.BEGAN)(firstEvent); if (shouldDuplicateFirstEvent) { const duplicated = { ...firstEvent, state: State.BEGAN }; // @ts-ignore badly typed, property may exist and we don't want to copy it delete duplicated.oldState; _events.unshift(duplicated); } const shouldDuplicateLastEvent = !hasState(State.END)(lastEvent) || !hasState(State.FAILED)(lastEvent) || !hasState(State.CANCELLED)(lastEvent); if (shouldDuplicateLastEvent) { const duplicated = { ...lastEvent, state: State.END }; // @ts-ignore badly typed, property may exist and we don't want to copy it delete duplicated.oldState; _events.push(duplicated); } function isWithoutState(event: Event) { return event !== null && !hasProperty(event, 'state'); } function hasState(state: State) { return (event: Event) => event !== null && event.state === state; } function noEventsLeft(event: Event) { return event === null; } function trueFn() { return true; } interface Args { shouldConsumeEvent?: (event: Event) => boolean; shouldTransitionToNextState?: (nextEvent: Event) => boolean; } function fillEventsForCurrentState({ shouldConsumeEvent = trueFn, shouldTransitionToNextState = trueFn, }: Args) { function peekCurrentEvent(): Event { return _events[0] ?? null; } function peekNextEvent(): Event { return _events[1] ?? null; } function consumeCurrentEvent() { _events.shift(); } const currentEvent = peekCurrentEvent(); const nextEvent = peekNextEvent(); const currentRequiredState = REQUIRED_EVENTS[currentStateIdx]; let eventData = {}; const shouldUseEvent = shouldConsumeEvent(currentEvent); if (shouldUseEvent) { eventData = currentEvent!; consumeCurrentEvent(); } transformedEvents.push({ state: currentRequiredState, ...eventData }); if (shouldTransitionToNextState(nextEvent)) { currentStateIdx++; } } const REQUIRED_EVENTS = [State.BEGAN, State.ACTIVE, State.END]; let currentStateIdx = 0; const transformedEvents: EventWithoutStates[] = []; let hasAllStates; let iterations = 0; do { const nextRequiredState = REQUIRED_EVENTS[currentStateIdx]; if (nextRequiredState === State.BEGAN) { fillEventsForCurrentState({ shouldConsumeEvent: (e: Event) => isWithoutState(e) || hasState(State.BEGAN)(e), }); } else if (nextRequiredState === State.ACTIVE) { const shouldConsumeEvent = (e: Event) => isWithoutState(e) || hasState(State.ACTIVE)(e); const shouldTransitionToNextState = (nextEvent: Event) => noEventsLeft(nextEvent) || hasState(State.END)(nextEvent) || hasState(State.FAILED)(nextEvent) || hasState(State.CANCELLED)(nextEvent); fillEventsForCurrentState({ shouldConsumeEvent, shouldTransitionToNextState, }); } else if (nextRequiredState === State.END) { fillEventsForCurrentState({}); } hasAllStates = currentStateIdx === REQUIRED_EVENTS.length; invariant( iterations++ <= 500, 'exceeded max number of iterations, please report a bug in RNGH repository with your test case' ); } while (!hasAllStates); return transformedEvents; } type EventEmitter = ( eventName: string, args: { nativeEvent: GestureHandlerTestEvent } ) => void; interface HandlerData { emitEvent: EventEmitter; handlerType: HandlerNames; handlerTag: number; enabled: boolean | undefined; } function getHandlerData( componentOrGesture: ReactTestInstance | GestureType ): HandlerData { if (isGesture(componentOrGesture)) { const gesture = componentOrGesture; return { emitEvent: (eventName, args) => { DeviceEventEmitter.emit(eventName, args.nativeEvent); }, handlerType: gesture.handlerName as HandlerNames, handlerTag: gesture.handlerTag, enabled: gesture.config.enabled, }; } const gestureHandlerComponent = componentOrGesture; return { emitEvent: (eventName, args) => { fireEvent(gestureHandlerComponent, eventName, args); }, handlerType: gestureHandlerComponent.props.handlerType as HandlerNames, handlerTag: gestureHandlerComponent.props.handlerTag as number, enabled: gestureHandlerComponent.props.enabled, }; } type AllGestures = | TapGesture | PanGesture | LongPressGesture | RotationGesture | PinchGesture | FlingGesture | ForceTouchGesture | NativeGesture; type AllHandlers = | TapGestureHandler | PanGestureHandler | LongPressGestureHandler | RotationGestureHandler | PinchGestureHandler | FlingGestureHandler | ForceTouchGestureHandler | NativeViewGestureHandler; // prettier-ignore type ClassComponentConstructor<P> = new (props: P) => React.Component<P, any, any>; type ExtractPayloadFromProps<T> = T extends BaseGestureHandlerProps<infer TPayload> ? TPayload : never; type ExtractConfig<T> = T extends BaseGesture<infer TGesturePayload> ? TGesturePayload : T extends ClassComponentConstructor<infer THandlerProps> ? ExtractPayloadFromProps<THandlerProps> : Record<string, unknown>; export function fireGestureHandler<THandler extends AllGestures | AllHandlers>( componentOrGesture: ReactTestInstance | GestureType, eventList: Partial<GestureHandlerTestEvent<ExtractConfig<THandler>>>[] = [] ): void { const { emitEvent, handlerType, handlerTag, enabled } = getHandlerData(componentOrGesture); if (enabled === false) { return; } let _ = fillMissingStatesTransitions( eventList, isDiscreteHandler(handlerType) ); _ = _.map(fillMissingDefaultsFor({ handlerTag, handlerType })); _ = withPrevAndCurrent(_, fillOldStateChanges); _ = withPrevAndCurrent(_, validateStateTransitions); // @ts-ignore TODO _ = _.map(wrapWithNativeEvent); const events = _ as unknown as WrappedGestureHandlerTestEvent[]; const firstEvent = events.shift()!; emitEvent('onGestureHandlerStateChange', firstEvent); let lastSentEvent = firstEvent; for (const event of events) { const hasChangedState = lastSentEvent.nativeEvent.state !== event.nativeEvent.state; if (hasChangedState) { emitEvent('onGestureHandlerStateChange', event); } else { emitEvent('onGestureHandlerEvent', event); } lastSentEvent = event; } } export function getByGestureTestId(testID: string) { const handler = findHandlerByTestID(testID); if (handler === null) { throw new Error(`Handler with id: '${testID}' cannot be found`); } return handler; }