react-native-gesture-handler
Version:
Declarative API exposing native platform touch and gesture system to React Native
600 lines (524 loc) • 17.6 kB
text/typescript
/* eslint-disable eslint-comments/no-unlimited-disable */
/* eslint-disable */
import Hammer from '@egjs/hammerjs';
import { findNodeHandle } from 'react-native';
import { State } from '../State';
import { EventMap } from './constants';
import * as NodeManager from './NodeManager';
import { ghQueueMicrotask } from '../ghQueueMicrotask';
// TODO(TS) Replace with HammerInput if https://github.com/DefinitelyTyped/DefinitelyTyped/pull/50438/files is merged
export type HammerInputExt = Omit<HammerInput, 'destroy' | 'handler' | 'init'>;
export type Config = Partial<{
enabled: boolean;
minPointers: number;
maxPointers: number;
minDist: number;
minDistSq: number;
minVelocity: number;
minVelocitySq: number;
maxDist: number;
maxDistSq: number;
failOffsetXStart: number;
failOffsetYStart: number;
failOffsetXEnd: number;
failOffsetYEnd: number;
activeOffsetXStart: number;
activeOffsetXEnd: number;
activeOffsetYStart: number;
activeOffsetYEnd: number;
waitFor: any[] | null;
simultaneousHandlers: any[] | null;
}>;
type NativeEvent = ReturnType<GestureHandler['transformEventData']>;
let gestureInstances = 0;
abstract class GestureHandler {
public handlerTag: any;
public isGestureRunning = false;
public view: number | null = null;
protected hasCustomActivationCriteria: boolean;
protected hasGestureFailed = false;
protected hammer: HammerManager | null = null;
protected initialRotation: number | null = null;
protected __initialX: any;
protected __initialY: any;
protected config: Config = {};
protected previousState: State = State.UNDETERMINED;
private pendingGestures: Record<string, this> = {};
private oldState: State = State.UNDETERMINED;
private lastSentState: State | null = null;
private gestureInstance: number;
private _stillWaiting: any;
private propsRef: any;
private ref: any;
abstract get name(): string;
get id() {
return `${this.name}${this.gestureInstance}`;
}
// a simple way to check if GestureHandler is NativeViewGestureHandler, since importing it
// here to use instanceof would cause import cycle
get isNative() {
return false;
}
get isDiscrete() {
return false;
}
get shouldEnableGestureOnSetup(): boolean {
throw new Error('Must override GestureHandler.shouldEnableGestureOnSetup');
}
constructor() {
this.gestureInstance = gestureInstances++;
this.hasCustomActivationCriteria = false;
}
getConfig() {
return this.config;
}
onWaitingEnded(_gesture: this) {}
removePendingGesture(id: string) {
delete this.pendingGestures[id];
}
addPendingGesture(gesture: this) {
this.pendingGestures[gesture.id] = gesture;
}
isGestureEnabledForEvent(
_config: any,
_recognizer: any,
_event: any
): { failed?: boolean; success?: boolean } {
return { success: true };
}
get NativeGestureClass(): RecognizerStatic {
throw new Error('Must override GestureHandler.NativeGestureClass');
}
updateHasCustomActivationCriteria(_config: Config) {
return true;
}
clearSelfAsPending = () => {
if (Array.isArray(this.config.waitFor)) {
for (const gesture of this.config.waitFor) {
gesture.removePendingGesture(this.id);
}
}
};
updateGestureConfig({ enabled = true, ...props }) {
this.clearSelfAsPending();
this.config = this.ensureConfig({ enabled, ...props });
this.hasCustomActivationCriteria = this.updateHasCustomActivationCriteria(
this.config
);
if (Array.isArray(this.config.waitFor)) {
for (const gesture of this.config.waitFor) {
gesture.addPendingGesture(this);
}
}
if (this.hammer) {
this.sync();
}
return this.config;
}
destroy = () => {
this.clearSelfAsPending();
if (this.hammer) {
this.hammer.stop(false);
this.hammer.destroy();
}
this.hammer = null;
};
isPointInView = ({ x, y }: { x: number; y: number }) => {
// @ts-ignore FIXME(TS)
const rect = this.view!.getBoundingClientRect();
const pointerInside =
x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
return pointerInside;
};
getState(type: keyof typeof EventMap): State {
// @ts-ignore TODO(TS) check if this is needed
if (type == 0) {
return 0;
}
return EventMap[type];
}
transformEventData(event: HammerInputExt) {
const { eventType, maxPointers: numberOfPointers } = event;
// const direction = DirectionMap[ev.direction];
const changedTouch = event.changedPointers[0];
const pointerInside = this.isPointInView({
x: changedTouch.clientX,
y: changedTouch.clientY,
});
// TODO(TS) Remove cast after https://github.com/DefinitelyTyped/DefinitelyTyped/pull/50966 is merged.
const state = this.getState(eventType as 1 | 2 | 4 | 8);
if (state !== this.previousState) {
this.oldState = this.previousState;
this.previousState = state;
}
return {
nativeEvent: {
numberOfPointers,
state,
pointerInside,
...this.transformNativeEvent(event),
// onHandlerStateChange only
handlerTag: this.handlerTag,
target: this.ref,
// send oldState only when the state was changed, or is different than ACTIVE
// GestureDetector relies on the presence of `oldState` to differentiate between
// update events and state change events
oldState:
state !== this.previousState || state != 4
? this.oldState
: undefined,
},
timeStamp: Date.now(),
};
}
transformNativeEvent(_event: HammerInputExt) {
return {};
}
sendEvent = (nativeEvent: HammerInputExt) => {
const { onGestureHandlerEvent, onGestureHandlerStateChange } =
this.propsRef.current;
const event = this.transformEventData(nativeEvent);
invokeNullableMethod(onGestureHandlerEvent, event);
if (this.lastSentState !== event.nativeEvent.state) {
this.lastSentState = event.nativeEvent.state as State;
invokeNullableMethod(onGestureHandlerStateChange, event);
}
};
cancelPendingGestures(event: HammerInputExt) {
for (const gesture of Object.values(this.pendingGestures)) {
if (gesture && gesture.isGestureRunning) {
gesture.hasGestureFailed = true;
gesture.cancelEvent(event);
}
}
}
notifyPendingGestures() {
for (const gesture of Object.values(this.pendingGestures)) {
if (gesture) {
gesture.onWaitingEnded(this);
}
}
}
// FIXME event is undefined in runtime when firstly invoked (see Draggable example), check other functions taking event as input
onGestureEnded(event: HammerInputExt) {
this.isGestureRunning = false;
this.cancelPendingGestures(event);
}
forceInvalidate(event: HammerInputExt) {
if (this.isGestureRunning) {
this.hasGestureFailed = true;
this.cancelEvent(event);
}
}
cancelEvent(event: HammerInputExt) {
this.notifyPendingGestures();
this.sendEvent({
...event,
eventType: Hammer.INPUT_CANCEL,
isFinal: true,
});
this.onGestureEnded(event);
}
onRawEvent({ isFirst }: HammerInputExt) {
if (isFirst) {
this.hasGestureFailed = false;
}
}
shouldUseTouchEvents(config: Config) {
return (
config.simultaneousHandlers?.some((handler) => handler.isNative) ?? false
);
}
setView(ref: Parameters<typeof findNodeHandle>['0'], propsRef: any) {
if (ref == null) {
this.destroy();
this.view = null;
return;
}
// @ts-ignore window doesn't exist on global type as we don't want to use Node types
const SUPPORTS_TOUCH = 'ontouchstart' in window;
this.propsRef = propsRef;
this.ref = ref;
this.view = findNodeHandle(ref);
// When the browser starts handling the gesture (e.g. scrolling), it sends a pointercancel event and stops
// sending additional pointer events. This is not the case with touch events, so if the gesture is simultaneous
// with a NativeGestureHandler, we need to check if touch events are supported and use them if possible.
this.hammer =
SUPPORTS_TOUCH && this.shouldUseTouchEvents(this.config)
? new Hammer.Manager(this.view as any, {
inputClass: Hammer.TouchInput,
})
: new Hammer.Manager(this.view as any);
this.oldState = State.UNDETERMINED;
this.previousState = State.UNDETERMINED;
this.lastSentState = null;
const { NativeGestureClass } = this;
// @ts-ignore TODO(TS)
const gesture = new NativeGestureClass(this.getHammerConfig());
this.hammer.add(gesture);
this.hammer.on('hammer.input', (ev: HammerInput) => {
if (!this.config.enabled) {
this.hasGestureFailed = false;
this.isGestureRunning = false;
return;
}
this.onRawEvent(ev as unknown as HammerInputExt);
// TODO: Bacon: Check against something other than null
// The isFirst value is not called when the first rotation is calculated.
if (this.initialRotation === null && ev.rotation !== 0) {
this.initialRotation = ev.rotation;
}
if (ev.isFinal) {
// in favor of a willFail otherwise the last frame of the gesture will be captured.
setTimeout(() => {
this.initialRotation = null;
this.hasGestureFailed = false;
});
}
});
this.setupEvents();
this.sync();
}
setupEvents() {
// TODO(TS) Hammer types aren't exactly that what we get in runtime
if (!this.isDiscrete) {
this.hammer!.on(`${this.name}start`, (event: HammerInput) =>
this.onStart(event as unknown as HammerInputExt)
);
this.hammer!.on(
`${this.name}end ${this.name}cancel`,
(event: HammerInput) => {
this.onGestureEnded(event as unknown as HammerInputExt);
}
);
}
this.hammer!.on(this.name, (ev: HammerInput) =>
this.onGestureActivated(ev as unknown as HammerInputExt)
); // TODO(TS) remove cast after https://github.com/DefinitelyTyped/DefinitelyTyped/pull/50438 is merged
}
onStart({ deltaX, deltaY, rotation }: HammerInputExt) {
// Reset the state for the next gesture
this.oldState = State.UNDETERMINED;
this.previousState = State.UNDETERMINED;
this.lastSentState = null;
this.isGestureRunning = true;
this.__initialX = deltaX;
this.__initialY = deltaY;
this.initialRotation = rotation;
}
onGestureActivated(ev: HammerInputExt) {
this.sendEvent(ev);
}
onSuccess() {}
_getPendingGestures() {
if (Array.isArray(this.config.waitFor) && this.config.waitFor.length) {
// Get the list of gestures that this gesture is still waiting for.
// Use `=== false` in case a ref that isn't a gesture handler is used.
const stillWaiting = this.config.waitFor.filter(
({ hasGestureFailed }) => hasGestureFailed === false
);
return stillWaiting;
}
return [];
}
getHammerConfig() {
const pointers =
this.config.minPointers === this.config.maxPointers
? this.config.minPointers
: 0;
return {
pointers,
};
}
sync = () => {
const gesture = this.hammer!.get(this.name);
if (!gesture) return;
const enable = (recognizer: any, inputData: any) => {
if (!this.config.enabled) {
this.isGestureRunning = false;
this.hasGestureFailed = false;
return false;
}
// Prevent events before the system is ready.
if (
!inputData ||
!recognizer.options ||
typeof inputData.maxPointers === 'undefined'
) {
return this.shouldEnableGestureOnSetup;
}
if (this.hasGestureFailed) {
return false;
}
if (!this.isDiscrete) {
if (this.isGestureRunning) {
return true;
}
// The built-in hammer.js "waitFor" doesn't work across multiple views.
// Only process if there are views to wait for.
this._stillWaiting = this._getPendingGestures();
// This gesture should continue waiting.
if (this._stillWaiting.length) {
// Check to see if one of the gestures you're waiting for has started.
// If it has then the gesture should fail.
for (const gesture of this._stillWaiting) {
// When the target gesture has started, this gesture must force fail.
if (!gesture.isDiscrete && gesture.isGestureRunning) {
this.hasGestureFailed = true;
this.isGestureRunning = false;
return false;
}
}
// This gesture shouldn't start until the others have finished.
return false;
}
}
// Use default behaviour
if (!this.hasCustomActivationCriteria) {
return true;
}
const deltaRotation =
this.initialRotation == null
? 0
: inputData.rotation - this.initialRotation;
// @ts-ignore FIXME(TS)
const { success, failed } = this.isGestureEnabledForEvent(
this.getConfig(),
recognizer,
{
...inputData,
deltaRotation,
}
);
if (failed) {
this.simulateCancelEvent(inputData);
this.hasGestureFailed = true;
}
return success;
};
const params = this.getHammerConfig();
// @ts-ignore FIXME(TS)
gesture.set({ ...params, enable });
};
simulateCancelEvent(_inputData: any) {}
// Validate the props
ensureConfig(config: Config): Required<Config> {
const props = { ...config };
// TODO(TS) We use ! to assert that if property is present then value is not empty (null, undefined)
if ('minDist' in config) {
props.minDist = config.minDist;
props.minDistSq = props.minDist! * props.minDist!;
}
if ('minVelocity' in config) {
props.minVelocity = config.minVelocity;
props.minVelocitySq = props.minVelocity! * props.minVelocity!;
}
if ('maxDist' in config) {
props.maxDist = config.maxDist;
props.maxDistSq = config.maxDist! * config.maxDist!;
}
if ('waitFor' in config) {
props.waitFor = asArray(config.waitFor)
.map(({ handlerTag }: { handlerTag: number }) =>
NodeManager.getHandler(handlerTag)
)
.filter((v) => v);
} else {
props.waitFor = null;
}
if ('simultaneousHandlers' in config) {
const shouldUseTouchEvents = this.shouldUseTouchEvents(this.config);
props.simultaneousHandlers = asArray(config.simultaneousHandlers)
.map((handler: number | GestureHandler) => {
if (typeof handler === 'number') {
return NodeManager.getHandler(handler);
} else {
return NodeManager.getHandler(handler.handlerTag);
}
})
.filter((v) => v);
if (shouldUseTouchEvents !== this.shouldUseTouchEvents(props)) {
ghQueueMicrotask(() => {
// if the undelying event API needs to be changed, we need to unmount and mount
// the hammer instance again.
this.destroy();
this.setView(this.ref, this.propsRef);
});
}
} else {
props.simultaneousHandlers = null;
}
const configProps = [
'minPointers',
'maxPointers',
'minDist',
'maxDist',
'maxDistSq',
'minVelocitySq',
'minDistSq',
'minVelocity',
'failOffsetXStart',
'failOffsetYStart',
'failOffsetXEnd',
'failOffsetYEnd',
'activeOffsetXStart',
'activeOffsetXEnd',
'activeOffsetYStart',
'activeOffsetYEnd',
] as const;
configProps.forEach((prop: (typeof configProps)[number]) => {
if (typeof props[prop] === 'undefined') {
props[prop] = Number.NaN;
}
});
return props as Required<Config>; // TODO(TS) how to convince TS that props are filled?
}
}
// TODO(TS) investigate this method
// Used for sending data to a callback or AnimatedEvent
function invokeNullableMethod(
method:
| ((event: NativeEvent) => void)
| { __getHandler: () => (event: NativeEvent) => void }
| { __nodeConfig: { argMapping: any } },
event: NativeEvent
) {
if (method) {
if (typeof method === 'function') {
method(event);
} else {
// For use with reanimated's AnimatedEvent
if (
'__getHandler' in method &&
typeof method.__getHandler === 'function'
) {
const handler = method.__getHandler();
invokeNullableMethod(handler, event);
} else {
if ('__nodeConfig' in method) {
const { argMapping } = method.__nodeConfig;
if (Array.isArray(argMapping)) {
for (const [index, [key, value]] of argMapping.entries()) {
if (key in event.nativeEvent) {
// @ts-ignore fix method type
const nativeValue = event.nativeEvent[key];
if (value && value.setValue) {
// Reanimated API
value.setValue(nativeValue);
} else {
// RN Animated API
method.__nodeConfig.argMapping[index] = [key, nativeValue];
}
}
}
}
}
}
}
}
}
function asArray<T>(value: T | T[]) {
// TODO(TS) use config.waitFor type
return value == null ? [] : Array.isArray(value) ? value : [value];
}
export default GestureHandler;