UNPKG

react-native-gesture-handler

Version:

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

871 lines (725 loc) 23.9 kB
/* eslint-disable @typescript-eslint/no-empty-function */ import { State } from '../../State'; import { Config, AdaptedEvent, PropsRef, ResultEvent, PointerData, ResultTouchEvent, TouchEventType, EventTypes, } from '../interfaces'; import EventManager from '../tools/EventManager'; import GestureHandlerOrchestrator from '../tools/GestureHandlerOrchestrator'; import InteractionManager from '../tools/InteractionManager'; import PointerTracker, { TrackerElement } from '../tools/PointerTracker'; import IGestureHandler from './IGestureHandler'; import { MouseButton } from '../../handlers/gestureHandlerCommon'; import { PointerType } from '../../PointerType'; import { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; export default abstract class GestureHandler implements IGestureHandler { private lastSentState: State | null = null; private _state: State = State.UNDETERMINED; private _shouldCancelWhenOutside = false; protected hasCustomActivationCriteria = false; private _enabled = false; private viewRef!: number; private propsRef!: React.RefObject<unknown>; private _handlerTag!: number; private _config: Config = { enabled: false }; private _tracker: PointerTracker = new PointerTracker(); // Orchestrator properties private _activationIndex = 0; private _awaiting = false; private _active = false; private _shouldResetProgress = false; private _pointerType: PointerType = PointerType.MOUSE; private _delegate: GestureHandlerDelegate<unknown, IGestureHandler>; public constructor( delegate: GestureHandlerDelegate<unknown, IGestureHandler> ) { this._delegate = delegate; } // // Initializing handler // protected init(viewRef: number, propsRef: React.RefObject<unknown>) { this.propsRef = propsRef; this.viewRef = viewRef; this.state = State.UNDETERMINED; this.delegate.init(viewRef, this); } public attachEventManager(manager: EventManager<unknown>): void { manager.setOnPointerDown(this.onPointerDown.bind(this)); manager.setOnPointerAdd(this.onPointerAdd.bind(this)); manager.setOnPointerUp(this.onPointerUp.bind(this)); manager.setOnPointerRemove(this.onPointerRemove.bind(this)); manager.setOnPointerMove(this.onPointerMove.bind(this)); manager.setOnPointerEnter(this.onPointerEnter.bind(this)); manager.setOnPointerLeave(this.onPointerLeave.bind(this)); manager.setOnPointerCancel(this.onPointerCancel.bind(this)); manager.setOnPointerOutOfBounds(this.onPointerOutOfBounds.bind(this)); manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this)); manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); manager.setOnWheel(this.onWheel.bind(this)); manager.registerListeners(); } // // Resetting handler // protected onCancel(): void {} protected onReset(): void {} protected resetProgress(): void {} public reset(): void { this.tracker.resetTracker(); this.onReset(); this.resetProgress(); this.delegate.reset(); this.state = State.UNDETERMINED; } // // State logic // public moveToState(newState: State, sendIfDisabled?: boolean) { if (this.state === newState) { return; } const oldState = this.state; this.state = newState; if ( this.tracker.trackedPointersCount > 0 && this.config.needsPointerData && this.isFinished() ) { this.cancelTouches(); } GestureHandlerOrchestrator.instance.onHandlerStateChange( this, newState, oldState, sendIfDisabled ); this.onStateChange(newState, oldState); if (!this.enabled && this.isFinished()) { this.state = State.UNDETERMINED; } } protected onStateChange(_newState: State, _oldState: State): void {} public begin(): void { if (!this.checkHitSlop()) { return; } if (this.state === State.UNDETERMINED) { this.moveToState(State.BEGAN); } } /** * @param {boolean} sendIfDisabled - Used when handler becomes disabled. With this flag orchestrator will be forced to send fail event */ public fail(sendIfDisabled?: boolean): void { if (this.state === State.ACTIVE || this.state === State.BEGAN) { // Here the order of calling the delegate and moveToState is important. // At this point we can use currentState as previuos state, because immediately after changing cursor we call moveToState method. this.delegate.onFail(); this.moveToState(State.FAILED, sendIfDisabled); } this.resetProgress(); } /** * @param {boolean} sendIfDisabled - Used when handler becomes disabled. With this flag orchestrator will be forced to send cancel event */ public cancel(sendIfDisabled?: boolean): void { if ( this.state === State.ACTIVE || this.state === State.UNDETERMINED || this.state === State.BEGAN ) { this.onCancel(); // Same as above - order matters this.delegate.onCancel(); this.moveToState(State.CANCELLED, sendIfDisabled); } } public activate(force = false) { if ( (this.config.manualActivation !== true || force) && (this.state === State.UNDETERMINED || this.state === State.BEGAN) ) { this.delegate.onActivate(); this.moveToState(State.ACTIVE); } } public end() { if (this.state === State.BEGAN || this.state === State.ACTIVE) { // Same as above - order matters this.delegate.onEnd(); this.moveToState(State.END); } this.resetProgress(); } // // Methods for orchestrator // public getShouldResetProgress(): boolean { return this.shouldResetProgress; } public setShouldResetProgress(value: boolean): void { this.shouldResetProgress = value; } public shouldWaitForHandlerFailure(handler: IGestureHandler): boolean { if (handler === this) { return false; } return InteractionManager.instance.shouldWaitForHandlerFailure( this, handler ); } public shouldRequireToWaitForFailure(handler: IGestureHandler): boolean { if (handler === this) { return false; } return InteractionManager.instance.shouldRequireHandlerToWaitForFailure( this, handler ); } public shouldRecognizeSimultaneously(handler: IGestureHandler): boolean { if (handler === this) { return true; } return InteractionManager.instance.shouldRecognizeSimultaneously( this, handler ); } public shouldBeCancelledByOther(handler: IGestureHandler): boolean { if (handler === this) { return false; } return InteractionManager.instance.shouldHandlerBeCancelledBy( this, handler ); } // // Event actions // protected onPointerDown(event: AdaptedEvent): void { GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(this); this.pointerType = event.pointerType; if (this.pointerType === PointerType.TOUCH) { GestureHandlerOrchestrator.instance.cancelMouseAndPenGestures(this); } // TODO: Bring back touch events along with introducing `handleDown` method that will handle handler specific stuff } // Adding another pointer to existing ones protected onPointerAdd(event: AdaptedEvent): void { this.tryToSendTouchEvent(event); } protected onPointerUp(event: AdaptedEvent): void { this.tryToSendTouchEvent(event); } // Removing pointer, when there is more than one pointers protected onPointerRemove(event: AdaptedEvent): void { this.tryToSendTouchEvent(event); } protected onPointerMove(event: AdaptedEvent): void { this.tryToSendMoveEvent(false, event); } protected onPointerLeave(event: AdaptedEvent): void { if (this.shouldCancelWhenOutside) { switch (this.state) { case State.ACTIVE: this.cancel(); break; case State.BEGAN: this.fail(); break; } return; } this.tryToSendTouchEvent(event); } protected onPointerEnter(event: AdaptedEvent): void { this.tryToSendTouchEvent(event); } protected onPointerCancel(event: AdaptedEvent): void { this.tryToSendTouchEvent(event); this.cancel(); this.reset(); } protected onPointerOutOfBounds(event: AdaptedEvent): void { this.tryToSendMoveEvent(true, event); } protected onPointerMoveOver(_event: AdaptedEvent): void { // Used only by hover gesture handler atm } protected onPointerMoveOut(_event: AdaptedEvent): void { // Used only by hover gesture handler atm } protected onWheel(_event: AdaptedEvent): void { // Used only by pan gesture handler } protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void { if ((out && this.shouldCancelWhenOutside) || !this.enabled) { return; } if (this.active) { this.sendEvent(this.state, this.state); } this.tryToSendTouchEvent(event); } protected tryToSendTouchEvent(event: AdaptedEvent): void { if (this.config.needsPointerData) { this.sendTouchEvent(event); } } public sendTouchEvent(event: AdaptedEvent): void { if (!this.enabled) { return; } const { onGestureHandlerEvent }: PropsRef = this.propsRef .current as PropsRef; const touchEvent: ResultTouchEvent | undefined = this.transformTouchEvent(event); if (touchEvent) { invokeNullableMethod(onGestureHandlerEvent, touchEvent); } } // // Events Sending // public sendEvent = (newState: State, oldState: State): void => { const { onGestureHandlerEvent, onGestureHandlerStateChange }: PropsRef = this.propsRef.current as PropsRef; const resultEvent: ResultEvent = this.transformEventData( newState, oldState ); // In the new API oldState field has to be undefined, unless we send event state changed // Here the order is flipped to avoid workarounds such as making backup of the state and setting it to undefined first, then changing it back // Flipping order with setting oldState to undefined solves issue, when events were being sent twice instead of once // However, this may cause trouble in the future (but for now we don't know that) if (this.lastSentState !== newState) { this.lastSentState = newState; invokeNullableMethod(onGestureHandlerStateChange, resultEvent); } if (this.state === State.ACTIVE) { resultEvent.nativeEvent.oldState = undefined; invokeNullableMethod(onGestureHandlerEvent, resultEvent); } }; private transformEventData(newState: State, oldState: State): ResultEvent { return { nativeEvent: { numberOfPointers: this.tracker.trackedPointersCount, state: newState, pointerInside: this.delegate.isPointerInBounds( this.tracker.getAbsoluteCoordsAverage() ), ...this.transformNativeEvent(), handlerTag: this.handlerTag, target: this.viewRef, oldState: newState !== oldState ? oldState : undefined, pointerType: this.pointerType, }, timeStamp: Date.now(), }; } private transformTouchEvent( event: AdaptedEvent ): ResultTouchEvent | undefined { const rect = this.delegate.measureView(); const all: PointerData[] = []; const changed: PointerData[] = []; const trackerData = this.tracker.trackedPointers; // This if handles edge case where all pointers have been cancelled // When pointercancel is triggered, reset method is called. This means that tracker will be reset after first pointer being cancelled // The problem is, that handler will receive another pointercancel event from the rest of the pointers // To avoid crashing, we don't send event if tracker tracks no pointers, i.e. has been reset if (trackerData.size === 0 || !trackerData.has(event.pointerId)) { return; } trackerData.forEach((element: TrackerElement, key: number): void => { const id: number = this.tracker.getMappedTouchEventId(key); all.push({ id: id, x: element.abosoluteCoords.x - rect.pageX, y: element.abosoluteCoords.y - rect.pageY, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); }); // Each pointer sends its own event, so we want changed touches to contain only the pointer that has changed. // However, if the event is cancel, we want to cancel all pointers to avoid crashes if (event.eventType !== EventTypes.CANCEL) { changed.push({ id: this.tracker.getMappedTouchEventId(event.pointerId), x: event.x - rect.pageX, y: event.y - rect.pageY, absoluteX: event.x, absoluteY: event.y, }); } else { trackerData.forEach((element: TrackerElement, key: number): void => { const id: number = this.tracker.getMappedTouchEventId(key); changed.push({ id: id, x: element.abosoluteCoords.x - rect.pageX, y: element.abosoluteCoords.y - rect.pageY, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); }); } let eventType: TouchEventType = TouchEventType.UNDETERMINED; switch (event.eventType) { case EventTypes.DOWN: case EventTypes.ADDITIONAL_POINTER_DOWN: eventType = TouchEventType.DOWN; break; case EventTypes.UP: case EventTypes.ADDITIONAL_POINTER_UP: eventType = TouchEventType.UP; break; case EventTypes.MOVE: eventType = TouchEventType.MOVE; break; case EventTypes.CANCEL: eventType = TouchEventType.CANCELLED; break; } // Here, when we receive up event, we want to decrease number of touches // That's because we want handler to send information that there's one pointer less // However, we still want this pointer to be present in allTouches array, so that its data can be accessed let numberOfTouches: number = all.length; if ( event.eventType === EventTypes.UP || event.eventType === EventTypes.ADDITIONAL_POINTER_UP ) { --numberOfTouches; } return { nativeEvent: { handlerTag: this.handlerTag, state: this.state, eventType: eventType, changedTouches: changed, allTouches: all, numberOfTouches: numberOfTouches, pointerType: this.pointerType, }, timeStamp: Date.now(), }; } private cancelTouches(): void { const rect = this.delegate.measureView(); const all: PointerData[] = []; const changed: PointerData[] = []; const trackerData = this.tracker.trackedPointers; if (trackerData.size === 0) { return; } trackerData.forEach((element: TrackerElement, key: number): void => { const id: number = this.tracker.getMappedTouchEventId(key); all.push({ id: id, x: element.abosoluteCoords.x - rect.pageX, y: element.abosoluteCoords.y - rect.pageY, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); changed.push({ id: id, x: element.abosoluteCoords.x - rect.pageX, y: element.abosoluteCoords.y - rect.pageY, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); }); const cancelEvent: ResultTouchEvent = { nativeEvent: { handlerTag: this.handlerTag, state: this.state, eventType: TouchEventType.CANCELLED, changedTouches: changed, allTouches: all, numberOfTouches: all.length, pointerType: this.pointerType, }, timeStamp: Date.now(), }; const { onGestureHandlerEvent }: PropsRef = this.propsRef .current as PropsRef; invokeNullableMethod(onGestureHandlerEvent, cancelEvent); } protected transformNativeEvent(): Record<string, unknown> { // Those properties are shared by most handlers and if not this method will be overriden const lastCoords = this.tracker.getAbsoluteCoordsAverage(); const lastRelativeCoords = this.tracker.getRelativeCoordsAverage(); return { x: lastRelativeCoords.x, y: lastRelativeCoords.y, absoluteX: lastCoords.x, absoluteY: lastCoords.y, }; } // // Handling config // public updateGestureConfig({ enabled = true, ...props }: Config): void { this._config = { enabled: enabled, ...props }; this.enabled = enabled; this.delegate.onEnabledChange(enabled); if (this.config.shouldCancelWhenOutside !== undefined) { this.shouldCancelWhenOutside = this.config.shouldCancelWhenOutside; } this.validateHitSlops(); if (this.enabled) { return; } switch (this.state) { case State.ACTIVE: this.fail(true); break; case State.UNDETERMINED: GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator(this); break; default: this.cancel(true); break; } } protected checkCustomActivationCriteria(criterias: string[]): void { for (const key in this.config) { if (criterias.indexOf(key) >= 0) { this.hasCustomActivationCriteria = true; } } } private validateHitSlops(): void { if (!this.config.hitSlop) { return; } if ( this.config.hitSlop.left !== undefined && this.config.hitSlop.right !== undefined && this.config.hitSlop.width !== undefined ) { throw new Error( 'HitSlop Error: Cannot define left, right and width at the same time' ); } if ( this.config.hitSlop.width !== undefined && this.config.hitSlop.left === undefined && this.config.hitSlop.right === undefined ) { throw new Error( 'HitSlop Error: When width is defined, either left or right has to be defined' ); } if ( this.config.hitSlop.height !== undefined && this.config.hitSlop.top !== undefined && this.config.hitSlop.bottom !== undefined ) { throw new Error( 'HitSlop Error: Cannot define top, bottom and height at the same time' ); } if ( this.config.hitSlop.height !== undefined && this.config.hitSlop.top === undefined && this.config.hitSlop.bottom === undefined ) { throw new Error( 'HitSlop Error: When height is defined, either top or bottom has to be defined' ); } } private checkHitSlop(): boolean { if (!this.config.hitSlop) { return true; } const { width, height } = this.delegate.measureView(); let left = 0; let top = 0; let right: number = width; let bottom: number = height; if (this.config.hitSlop.horizontal !== undefined) { left -= this.config.hitSlop.horizontal; right += this.config.hitSlop.horizontal; } if (this.config.hitSlop.vertical !== undefined) { top -= this.config.hitSlop.vertical; bottom += this.config.hitSlop.vertical; } if (this.config.hitSlop.left !== undefined) { left = -this.config.hitSlop.left; } if (this.config.hitSlop.right !== undefined) { right = width + this.config.hitSlop.right; } if (this.config.hitSlop.top !== undefined) { top = -this.config.hitSlop.top; } if (this.config.hitSlop.bottom !== undefined) { bottom = width + this.config.hitSlop.bottom; } if (this.config.hitSlop.width !== undefined) { if (this.config.hitSlop.left !== undefined) { right = left + this.config.hitSlop.width; } else if (this.config.hitSlop.right !== undefined) { left = right - this.config.hitSlop.width; } } if (this.config.hitSlop.height !== undefined) { if (this.config.hitSlop.top !== undefined) { bottom = top + this.config.hitSlop.height; } else if (this.config.hitSlop.bottom !== undefined) { top = bottom - this.config.hitSlop.height; } } const rect = this.delegate.measureView(); const { x, y } = this.tracker.getLastAbsoluteCoords(); const offsetX: number = x - rect.pageX; const offsetY: number = y - rect.pageY; return ( offsetX >= left && offsetX <= right && offsetY >= top && offsetY <= bottom ); } public isButtonInConfig(mouseButton: MouseButton | undefined) { return ( !mouseButton || (!this.config.mouseButton && mouseButton === MouseButton.LEFT) || (this.config.mouseButton && mouseButton & this.config.mouseButton) ); } protected resetConfig(): void {} public onDestroy(): void { this.delegate.destroy(this.config); } // // Getters and setters // public get handlerTag() { return this._handlerTag; } public set handlerTag(value: number) { this._handlerTag = value; } public get config(): Config { return this._config; } public get delegate() { return this._delegate; } public get tracker() { return this._tracker; } public get state(): State { return this._state; } protected set state(value: State) { this._state = value; } public get shouldCancelWhenOutside() { return this._shouldCancelWhenOutside; } protected set shouldCancelWhenOutside(value) { this._shouldCancelWhenOutside = value; } public get enabled() { return this._enabled; } protected set enabled(value) { this._enabled = value; } public get pointerType(): PointerType { return this._pointerType; } protected set pointerType(value: PointerType) { this._pointerType = value; } public get active() { return this._active; } protected set active(value) { this._active = value; } public get awaiting() { return this._awaiting; } protected set awaiting(value) { this._awaiting = value; } public get activationIndex() { return this._activationIndex; } protected set activationIndex(value) { this._activationIndex = value; } public get shouldResetProgress() { return this._shouldResetProgress; } protected set shouldResetProgress(value) { this._shouldResetProgress = value; } public getTrackedPointersID(): number[] { return this.tracker.trackedPointersIDs; } private isFinished(): boolean { return ( this.state === State.END || this.state === State.FAILED || this.state === State.CANCELLED ); } } function invokeNullableMethod( method: | ((event: ResultEvent | ResultTouchEvent) => void) | { __getHandler: () => (event: ResultEvent | ResultTouchEvent) => void } | { __nodeConfig: { argMapping: unknown[] } }, event: ResultEvent | ResultTouchEvent ): void { if (!method) { return; } if (typeof method === 'function') { method(event); return; } if ('__getHandler' in method && typeof method.__getHandler === 'function') { const handler = method.__getHandler(); invokeNullableMethod(handler, event); return; } if (!('__nodeConfig' in method)) { return; } const { argMapping }: { argMapping: unknown } = method.__nodeConfig; if (!Array.isArray(argMapping)) { return; } for (const [index, [key, value]] of argMapping.entries()) { if (!(key in event.nativeEvent)) { continue; } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const nativeValue = event.nativeEvent[key]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (value?.setValue) { // Reanimated API // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call value.setValue(nativeValue); } else { // RN Animated API method.__nodeConfig.argMapping[index] = [key, nativeValue]; } } return; }