UNPKG

react-native-gesture-handler

Version:

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

600 lines (524 loc) 17.6 kB
/* 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;