react-native-gesture-handler
Version:
Declarative API exposing native platform touch and gesture system to React Native
514 lines (470 loc) • 14.6 kB
text/typescript
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;
}