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