969 lines
31 KiB
JavaScript
969 lines
31 KiB
JavaScript
|
/**
|
||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||
|
*
|
||
|
* This source code is licensed under the MIT license found in the
|
||
|
* LICENSE file in the root directory of this source tree.
|
||
|
*
|
||
|
* @flow strict-local
|
||
|
* @format
|
||
|
*/
|
||
|
|
||
|
import type {HostComponent} from '../Renderer/shims/ReactNativeTypes';
|
||
|
import type {
|
||
|
BlurEvent,
|
||
|
FocusEvent,
|
||
|
MouseEvent,
|
||
|
PressEvent,
|
||
|
} from '../Types/CoreEventTypes';
|
||
|
|
||
|
import SoundManager from '../Components/Sound/SoundManager';
|
||
|
import ReactNativeFeatureFlags from '../ReactNative/ReactNativeFeatureFlags';
|
||
|
import UIManager from '../ReactNative/UIManager';
|
||
|
import {type RectOrSize, normalizeRect} from '../StyleSheet/Rect';
|
||
|
import {type PointerEvent} from '../Types/CoreEventTypes';
|
||
|
import Platform from '../Utilities/Platform';
|
||
|
import {isHoverEnabled} from './HoverState';
|
||
|
import PressabilityPerformanceEventEmitter from './PressabilityPerformanceEventEmitter.js';
|
||
|
import {type PressabilityTouchSignal as TouchSignal} from './PressabilityTypes.js';
|
||
|
import invariant from 'invariant';
|
||
|
import * as React from 'react';
|
||
|
|
||
|
export type PressabilityConfig = $ReadOnly<{|
|
||
|
/**
|
||
|
* Whether a press gesture can be interrupted by a parent gesture such as a
|
||
|
* scroll event. Defaults to true.
|
||
|
*/
|
||
|
cancelable?: ?boolean,
|
||
|
|
||
|
/**
|
||
|
* Whether to disable initialization of the press gesture.
|
||
|
*/
|
||
|
disabled?: ?boolean,
|
||
|
|
||
|
/**
|
||
|
* Amount to extend the `VisualRect` by to create `HitRect`.
|
||
|
*/
|
||
|
hitSlop?: ?RectOrSize,
|
||
|
|
||
|
/**
|
||
|
* Amount to extend the `HitRect` by to create `PressRect`.
|
||
|
*/
|
||
|
pressRectOffset?: ?RectOrSize,
|
||
|
|
||
|
/**
|
||
|
* Whether to disable the systemm sound when `onPress` fires on Android.
|
||
|
**/
|
||
|
android_disableSound?: ?boolean,
|
||
|
|
||
|
/**
|
||
|
* Duration to wait after hover in before calling `onHoverIn`.
|
||
|
*/
|
||
|
delayHoverIn?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Duration to wait after hover out before calling `onHoverOut`.
|
||
|
*/
|
||
|
delayHoverOut?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Duration (in addition to `delayPressIn`) after which a press gesture is
|
||
|
* considered a long press gesture. Defaults to 500 (milliseconds).
|
||
|
*/
|
||
|
delayLongPress?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Duration to wait after press down before calling `onPressIn`.
|
||
|
*/
|
||
|
delayPressIn?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Duration to wait after letting up before calling `onPressOut`.
|
||
|
*/
|
||
|
delayPressOut?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Minimum duration to wait between calling `onPressIn` and `onPressOut`.
|
||
|
*/
|
||
|
minPressDuration?: ?number,
|
||
|
|
||
|
/**
|
||
|
* Called after the element loses focus.
|
||
|
*/
|
||
|
onBlur?: ?(event: BlurEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called after the element is focused.
|
||
|
*/
|
||
|
onFocus?: ?(event: FocusEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when the hover is activated to provide visual feedback.
|
||
|
*/
|
||
|
onHoverIn?: ?(event: MouseEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when the hover is deactivated to undo visual feedback.
|
||
|
*/
|
||
|
onHoverOut?: ?(event: MouseEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when a long press gesture has been triggered.
|
||
|
*/
|
||
|
onLongPress?: ?(event: PressEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when a press gesture has been triggered.
|
||
|
*/
|
||
|
onPress?: ?(event: PressEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when the press is activated to provide visual feedback.
|
||
|
*/
|
||
|
onPressIn?: ?(event: PressEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when the press location moves. (This should rarely be used.)
|
||
|
*/
|
||
|
onPressMove?: ?(event: PressEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Called when the press is deactivated to undo visual feedback.
|
||
|
*/
|
||
|
onPressOut?: ?(event: PressEvent) => mixed,
|
||
|
|
||
|
/**
|
||
|
* Returns whether a long press gesture should cancel the press gesture.
|
||
|
* Defaults to true.
|
||
|
*/
|
||
|
onLongPressShouldCancelPress_DEPRECATED?: ?() => boolean,
|
||
|
|
||
|
/**
|
||
|
* If `cancelable` is set, this will be ignored.
|
||
|
*
|
||
|
* Returns whether to yield to a lock termination request (e.g. if a native
|
||
|
* scroll gesture attempts to steal the responder lock).
|
||
|
*/
|
||
|
onResponderTerminationRequest_DEPRECATED?: ?() => boolean,
|
||
|
|
||
|
/**
|
||
|
* If `disabled` is set, this will be ignored.
|
||
|
*
|
||
|
* Returns whether to start a press gesture.
|
||
|
*
|
||
|
* @deprecated
|
||
|
*/
|
||
|
onStartShouldSetResponder_DEPRECATED?: ?() => boolean,
|
||
|
|}>;
|
||
|
|
||
|
export type EventHandlers = $ReadOnly<{|
|
||
|
onBlur: (event: BlurEvent) => void,
|
||
|
onClick: (event: PressEvent) => void,
|
||
|
onFocus: (event: FocusEvent) => void,
|
||
|
onMouseEnter?: (event: MouseEvent) => void,
|
||
|
onMouseLeave?: (event: MouseEvent) => void,
|
||
|
onPointerEnter?: (event: PointerEvent) => void,
|
||
|
onPointerLeave?: (event: PointerEvent) => void,
|
||
|
onResponderGrant: (event: PressEvent) => void,
|
||
|
onResponderMove: (event: PressEvent) => void,
|
||
|
onResponderRelease: (event: PressEvent) => void,
|
||
|
onResponderTerminate: (event: PressEvent) => void,
|
||
|
onResponderTerminationRequest: () => boolean,
|
||
|
onStartShouldSetResponder: () => boolean,
|
||
|
|}>;
|
||
|
|
||
|
type TouchState =
|
||
|
| 'NOT_RESPONDER'
|
||
|
| 'RESPONDER_INACTIVE_PRESS_IN'
|
||
|
| 'RESPONDER_INACTIVE_PRESS_OUT'
|
||
|
| 'RESPONDER_ACTIVE_PRESS_IN'
|
||
|
| 'RESPONDER_ACTIVE_PRESS_OUT'
|
||
|
| 'RESPONDER_ACTIVE_LONG_PRESS_IN'
|
||
|
| 'RESPONDER_ACTIVE_LONG_PRESS_OUT'
|
||
|
| 'ERROR';
|
||
|
|
||
|
const Transitions = Object.freeze({
|
||
|
NOT_RESPONDER: {
|
||
|
DELAY: 'ERROR',
|
||
|
RESPONDER_GRANT: 'RESPONDER_INACTIVE_PRESS_IN',
|
||
|
RESPONDER_RELEASE: 'ERROR',
|
||
|
RESPONDER_TERMINATED: 'ERROR',
|
||
|
ENTER_PRESS_RECT: 'ERROR',
|
||
|
LEAVE_PRESS_RECT: 'ERROR',
|
||
|
LONG_PRESS_DETECTED: 'ERROR',
|
||
|
},
|
||
|
RESPONDER_INACTIVE_PRESS_IN: {
|
||
|
DELAY: 'RESPONDER_ACTIVE_PRESS_IN',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'ERROR',
|
||
|
},
|
||
|
RESPONDER_INACTIVE_PRESS_OUT: {
|
||
|
DELAY: 'RESPONDER_ACTIVE_PRESS_OUT',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'ERROR',
|
||
|
},
|
||
|
RESPONDER_ACTIVE_PRESS_IN: {
|
||
|
DELAY: 'ERROR',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'RESPONDER_ACTIVE_LONG_PRESS_IN',
|
||
|
},
|
||
|
RESPONDER_ACTIVE_PRESS_OUT: {
|
||
|
DELAY: 'ERROR',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'ERROR',
|
||
|
},
|
||
|
RESPONDER_ACTIVE_LONG_PRESS_IN: {
|
||
|
DELAY: 'ERROR',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'RESPONDER_ACTIVE_LONG_PRESS_IN',
|
||
|
},
|
||
|
RESPONDER_ACTIVE_LONG_PRESS_OUT: {
|
||
|
DELAY: 'ERROR',
|
||
|
RESPONDER_GRANT: 'ERROR',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_IN',
|
||
|
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_OUT',
|
||
|
LONG_PRESS_DETECTED: 'ERROR',
|
||
|
},
|
||
|
ERROR: {
|
||
|
DELAY: 'NOT_RESPONDER',
|
||
|
RESPONDER_GRANT: 'RESPONDER_INACTIVE_PRESS_IN',
|
||
|
RESPONDER_RELEASE: 'NOT_RESPONDER',
|
||
|
RESPONDER_TERMINATED: 'NOT_RESPONDER',
|
||
|
ENTER_PRESS_RECT: 'NOT_RESPONDER',
|
||
|
LEAVE_PRESS_RECT: 'NOT_RESPONDER',
|
||
|
LONG_PRESS_DETECTED: 'NOT_RESPONDER',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
const isActiveSignal = (signal: TouchState) =>
|
||
|
signal === 'RESPONDER_ACTIVE_PRESS_IN' ||
|
||
|
signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN';
|
||
|
|
||
|
const isActivationSignal = (signal: TouchState) =>
|
||
|
signal === 'RESPONDER_ACTIVE_PRESS_OUT' ||
|
||
|
signal === 'RESPONDER_ACTIVE_PRESS_IN';
|
||
|
|
||
|
const isPressInSignal = (signal: TouchState) =>
|
||
|
signal === 'RESPONDER_INACTIVE_PRESS_IN' ||
|
||
|
signal === 'RESPONDER_ACTIVE_PRESS_IN' ||
|
||
|
signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN';
|
||
|
|
||
|
const isTerminalSignal = (signal: TouchSignal) =>
|
||
|
signal === 'RESPONDER_TERMINATED' || signal === 'RESPONDER_RELEASE';
|
||
|
|
||
|
const DEFAULT_LONG_PRESS_DELAY_MS = 500;
|
||
|
const DEFAULT_PRESS_RECT_OFFSETS = {
|
||
|
bottom: 30,
|
||
|
left: 20,
|
||
|
right: 20,
|
||
|
top: 20,
|
||
|
};
|
||
|
const DEFAULT_MIN_PRESS_DURATION = 130;
|
||
|
|
||
|
const DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE = 10;
|
||
|
let longPressDeactivationDistance = DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE;
|
||
|
/**
|
||
|
* Pressability implements press handling capabilities.
|
||
|
*
|
||
|
* =========================== Pressability Tutorial ===========================
|
||
|
*
|
||
|
* The `Pressability` class helps you create press interactions by analyzing the
|
||
|
* geometry of elements and observing when another responder (e.g. ScrollView)
|
||
|
* has stolen the touch lock. It offers hooks for your component to provide
|
||
|
* interaction feedback to the user:
|
||
|
*
|
||
|
* - When a press has activated (e.g. highlight an element)
|
||
|
* - When a press has deactivated (e.g. un-highlight an element)
|
||
|
* - When a press sould trigger an action, meaning it activated and deactivated
|
||
|
* while within the geometry of the element without the lock being stolen.
|
||
|
*
|
||
|
* A high quality interaction isn't as simple as you might think. There should
|
||
|
* be a slight delay before activation. Moving your finger beyond an element's
|
||
|
* bounds should trigger deactivation, but moving the same finger back within an
|
||
|
* element's bounds should trigger reactivation.
|
||
|
*
|
||
|
* In order to use `Pressability`, do the following:
|
||
|
*
|
||
|
* 1. Instantiate `Pressability` and store it on your component's state.
|
||
|
*
|
||
|
* state = {
|
||
|
* pressability: new Pressability({
|
||
|
* // ...
|
||
|
* }),
|
||
|
* };
|
||
|
*
|
||
|
* 2. Choose the rendered component who should collect the press events. On that
|
||
|
* element, spread `pressability.getEventHandlers()` into its props.
|
||
|
*
|
||
|
* return (
|
||
|
* <View {...this.state.pressability.getEventHandlers()} />
|
||
|
* );
|
||
|
*
|
||
|
* 3. Reset `Pressability` when your component unmounts.
|
||
|
*
|
||
|
* componentWillUnmount() {
|
||
|
* this.state.pressability.reset();
|
||
|
* }
|
||
|
*
|
||
|
* ==================== Pressability Implementation Details ====================
|
||
|
*
|
||
|
* `Pressability` only assumes that there exists a `HitRect` node. The `PressRect`
|
||
|
* is an abstract box that is extended beyond the `HitRect`.
|
||
|
*
|
||
|
* # Geometry
|
||
|
*
|
||
|
* ┌────────────────────────┐
|
||
|
* │ ┌──────────────────┐ │ - Presses start anywhere within `HitRect`, which
|
||
|
* │ │ ┌────────────┐ │ │ is expanded via the prop `hitSlop`.
|
||
|
* │ │ │ VisualRect │ │ │
|
||
|
* │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time
|
||
|
* │ │ HitRect │ │ before letting up, `VisualRect` activates for
|
||
|
* │ └──────────────────┘ │ as long as the press stays within `PressRect`.
|
||
|
* │ PressRect o │
|
||
|
* └────────────────────│───┘
|
||
|
* Out Region └────── `PressRect`, which is expanded via the prop
|
||
|
* `pressRectOffset`, allows presses to move
|
||
|
* beyond `HitRect` while maintaining activation
|
||
|
* and being eligible for a "press".
|
||
|
*
|
||
|
* # State Machine
|
||
|
*
|
||
|
* ┌───────────────┐ ◀──── RESPONDER_RELEASE
|
||
|
* │ NOT_RESPONDER │
|
||
|
* └───┬───────────┘ ◀──── RESPONDER_TERMINATED
|
||
|
* │
|
||
|
* │ RESPONDER_GRANT (HitRect)
|
||
|
* │
|
||
|
* ▼
|
||
|
* ┌─────────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||
|
* │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ T + DELAY │ RESPONDER_ACTIVE_ │
|
||
|
* │ PRESS_IN ├────────▶ │ PRESS_IN ├────────────▶ │ LONG_PRESS_IN │
|
||
|
* └─┬───────────────────┘ └─┬─────────────────┘ └─┬─────────────────┘
|
||
|
* │ ▲ │ ▲ │ ▲
|
||
|
* │LEAVE_ │ │LEAVE_ │ │LEAVE_ │
|
||
|
* │PRESS_RECT │ENTER_ │PRESS_RECT │ENTER_ │PRESS_RECT │ENTER_
|
||
|
* │ │PRESS_RECT │ │PRESS_RECT │ │PRESS_RECT
|
||
|
* ▼ │ ▼ │ ▼ │
|
||
|
* ┌─────────────┴───────┐ ┌─────────────┴─────┐ ┌─────────────┴─────┐
|
||
|
* │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ │ RESPONDER_ACTIVE_ │
|
||
|
* │ PRESS_OUT ├────────▶ │ PRESS_OUT │ │ LONG_PRESS_OUT │
|
||
|
* └─────────────────────┘ └───────────────────┘ └───────────────────┘
|
||
|
*
|
||
|
* T + DELAY => LONG_PRESS_DELAY + DELAY
|
||
|
*
|
||
|
* Not drawn are the side effects of each transition. The most important side
|
||
|
* effect is the invocation of `onPress` and `onLongPress` that occur when a
|
||
|
* responder is release while in the "press in" states.
|
||
|
*/
|
||
|
export default class Pressability {
|
||
|
_config: PressabilityConfig;
|
||
|
_eventHandlers: ?EventHandlers = null;
|
||
|
_hoverInDelayTimeout: ?TimeoutID = null;
|
||
|
_hoverOutDelayTimeout: ?TimeoutID = null;
|
||
|
_isHovered: boolean = false;
|
||
|
_longPressDelayTimeout: ?TimeoutID = null;
|
||
|
_pressDelayTimeout: ?TimeoutID = null;
|
||
|
_pressOutDelayTimeout: ?TimeoutID = null;
|
||
|
_responderID: ?number | React.ElementRef<HostComponent<mixed>> = null;
|
||
|
_responderRegion: ?$ReadOnly<{|
|
||
|
bottom: number,
|
||
|
left: number,
|
||
|
right: number,
|
||
|
top: number,
|
||
|
|}> = null;
|
||
|
_touchActivatePosition: ?$ReadOnly<{|
|
||
|
pageX: number,
|
||
|
pageY: number,
|
||
|
|}>;
|
||
|
_touchActivateTime: ?number;
|
||
|
_touchState: TouchState = 'NOT_RESPONDER';
|
||
|
|
||
|
constructor(config: PressabilityConfig) {
|
||
|
this.configure(config);
|
||
|
}
|
||
|
|
||
|
configure(config: PressabilityConfig): void {
|
||
|
this._config = config;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Resets any pending timers. This should be called on unmount.
|
||
|
*/
|
||
|
reset(): void {
|
||
|
this._cancelHoverInDelayTimeout();
|
||
|
this._cancelHoverOutDelayTimeout();
|
||
|
this._cancelLongPressDelayTimeout();
|
||
|
this._cancelPressDelayTimeout();
|
||
|
this._cancelPressOutDelayTimeout();
|
||
|
|
||
|
// Ensure that, if any async event handlers are fired after unmount
|
||
|
// due to a race, we don't call any configured callbacks.
|
||
|
this._config = Object.freeze({});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a set of props to spread into the interactive element.
|
||
|
*/
|
||
|
getEventHandlers(): EventHandlers {
|
||
|
if (this._eventHandlers == null) {
|
||
|
this._eventHandlers = this._createEventHandlers();
|
||
|
}
|
||
|
return this._eventHandlers;
|
||
|
}
|
||
|
|
||
|
static setLongPressDeactivationDistance(distance: number): void {
|
||
|
longPressDeactivationDistance = distance;
|
||
|
}
|
||
|
|
||
|
_createEventHandlers(): EventHandlers {
|
||
|
const focusEventHandlers = {
|
||
|
onBlur: (event: BlurEvent): void => {
|
||
|
const {onBlur} = this._config;
|
||
|
if (onBlur != null) {
|
||
|
onBlur(event);
|
||
|
}
|
||
|
},
|
||
|
onFocus: (event: FocusEvent): void => {
|
||
|
const {onFocus} = this._config;
|
||
|
if (onFocus != null) {
|
||
|
onFocus(event);
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
|
||
|
const responderEventHandlers = {
|
||
|
onStartShouldSetResponder: (): boolean => {
|
||
|
const {disabled} = this._config;
|
||
|
if (disabled == null) {
|
||
|
const {onStartShouldSetResponder_DEPRECATED} = this._config;
|
||
|
return onStartShouldSetResponder_DEPRECATED == null
|
||
|
? true
|
||
|
: onStartShouldSetResponder_DEPRECATED();
|
||
|
}
|
||
|
return !disabled;
|
||
|
},
|
||
|
|
||
|
onResponderGrant: (event: PressEvent): void => {
|
||
|
event.persist();
|
||
|
|
||
|
this._cancelPressOutDelayTimeout();
|
||
|
|
||
|
this._responderID = event.currentTarget;
|
||
|
this._touchState = 'NOT_RESPONDER';
|
||
|
this._receiveSignal('RESPONDER_GRANT', event);
|
||
|
|
||
|
const delayPressIn = normalizeDelay(this._config.delayPressIn);
|
||
|
if (delayPressIn > 0) {
|
||
|
this._pressDelayTimeout = setTimeout(() => {
|
||
|
this._receiveSignal('DELAY', event);
|
||
|
}, delayPressIn);
|
||
|
} else {
|
||
|
this._receiveSignal('DELAY', event);
|
||
|
}
|
||
|
|
||
|
const delayLongPress = normalizeDelay(
|
||
|
this._config.delayLongPress,
|
||
|
10,
|
||
|
DEFAULT_LONG_PRESS_DELAY_MS - delayPressIn,
|
||
|
);
|
||
|
this._longPressDelayTimeout = setTimeout(() => {
|
||
|
this._handleLongPress(event);
|
||
|
}, delayLongPress + delayPressIn);
|
||
|
},
|
||
|
|
||
|
onResponderMove: (event: PressEvent): void => {
|
||
|
const {onPressMove} = this._config;
|
||
|
if (onPressMove != null) {
|
||
|
onPressMove(event);
|
||
|
}
|
||
|
|
||
|
// Region may not have finished being measured, yet.
|
||
|
const responderRegion = this._responderRegion;
|
||
|
if (responderRegion == null) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const touch = getTouchFromPressEvent(event);
|
||
|
if (touch == null) {
|
||
|
this._cancelLongPressDelayTimeout();
|
||
|
this._receiveSignal('LEAVE_PRESS_RECT', event);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (this._touchActivatePosition != null) {
|
||
|
const deltaX = this._touchActivatePosition.pageX - touch.pageX;
|
||
|
const deltaY = this._touchActivatePosition.pageY - touch.pageY;
|
||
|
if (Math.hypot(deltaX, deltaY) > longPressDeactivationDistance) {
|
||
|
this._cancelLongPressDelayTimeout();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (this._isTouchWithinResponderRegion(touch, responderRegion)) {
|
||
|
this._receiveSignal('ENTER_PRESS_RECT', event);
|
||
|
} else {
|
||
|
this._cancelLongPressDelayTimeout();
|
||
|
this._receiveSignal('LEAVE_PRESS_RECT', event);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
onResponderRelease: (event: PressEvent): void => {
|
||
|
this._receiveSignal('RESPONDER_RELEASE', event);
|
||
|
},
|
||
|
|
||
|
onResponderTerminate: (event: PressEvent): void => {
|
||
|
this._receiveSignal('RESPONDER_TERMINATED', event);
|
||
|
},
|
||
|
|
||
|
onResponderTerminationRequest: (): boolean => {
|
||
|
const {cancelable} = this._config;
|
||
|
if (cancelable == null) {
|
||
|
const {onResponderTerminationRequest_DEPRECATED} = this._config;
|
||
|
return onResponderTerminationRequest_DEPRECATED == null
|
||
|
? true
|
||
|
: onResponderTerminationRequest_DEPRECATED();
|
||
|
}
|
||
|
return cancelable;
|
||
|
},
|
||
|
|
||
|
onClick: (event: PressEvent): void => {
|
||
|
const {onPress, disabled} = this._config;
|
||
|
if (onPress != null && disabled !== true) {
|
||
|
onPress(event);
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
|
||
|
if (process.env.NODE_ENV === 'test') {
|
||
|
// We are setting this in order to find this node in ReactNativeTestTools
|
||
|
// $FlowFixMe[prop-missing]
|
||
|
responderEventHandlers.onStartShouldSetResponder.testOnly_pressabilityConfig =
|
||
|
() => this._config;
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
ReactNativeFeatureFlags.shouldPressibilityUseW3CPointerEventsForHover()
|
||
|
) {
|
||
|
const hoverPointerEvents = {
|
||
|
onPointerEnter: (undefined: void | (PointerEvent => void)),
|
||
|
onPointerLeave: (undefined: void | (PointerEvent => void)),
|
||
|
};
|
||
|
const {onHoverIn, onHoverOut} = this._config;
|
||
|
if (onHoverIn != null) {
|
||
|
hoverPointerEvents.onPointerEnter = (event: PointerEvent) => {
|
||
|
this._isHovered = true;
|
||
|
this._cancelHoverOutDelayTimeout();
|
||
|
if (onHoverIn != null) {
|
||
|
const delayHoverIn = normalizeDelay(this._config.delayHoverIn);
|
||
|
if (delayHoverIn > 0) {
|
||
|
event.persist();
|
||
|
this._hoverInDelayTimeout = setTimeout(() => {
|
||
|
onHoverIn(convertPointerEventToMouseEvent(event));
|
||
|
}, delayHoverIn);
|
||
|
} else {
|
||
|
onHoverIn(convertPointerEventToMouseEvent(event));
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
if (onHoverOut != null) {
|
||
|
hoverPointerEvents.onPointerLeave = (event: PointerEvent) => {
|
||
|
if (this._isHovered) {
|
||
|
this._isHovered = false;
|
||
|
this._cancelHoverInDelayTimeout();
|
||
|
if (onHoverOut != null) {
|
||
|
const delayHoverOut = normalizeDelay(this._config.delayHoverOut);
|
||
|
if (delayHoverOut > 0) {
|
||
|
event.persist();
|
||
|
this._hoverOutDelayTimeout = setTimeout(() => {
|
||
|
onHoverOut(convertPointerEventToMouseEvent(event));
|
||
|
}, delayHoverOut);
|
||
|
} else {
|
||
|
onHoverOut(convertPointerEventToMouseEvent(event));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
return {
|
||
|
...focusEventHandlers,
|
||
|
...responderEventHandlers,
|
||
|
...hoverPointerEvents,
|
||
|
};
|
||
|
} else {
|
||
|
const mouseEventHandlers =
|
||
|
Platform.OS === 'ios' || Platform.OS === 'android'
|
||
|
? null
|
||
|
: {
|
||
|
onMouseEnter: (event: MouseEvent): void => {
|
||
|
if (isHoverEnabled()) {
|
||
|
this._isHovered = true;
|
||
|
this._cancelHoverOutDelayTimeout();
|
||
|
const {onHoverIn} = this._config;
|
||
|
if (onHoverIn != null) {
|
||
|
const delayHoverIn = normalizeDelay(
|
||
|
this._config.delayHoverIn,
|
||
|
);
|
||
|
if (delayHoverIn > 0) {
|
||
|
event.persist();
|
||
|
this._hoverInDelayTimeout = setTimeout(() => {
|
||
|
onHoverIn(event);
|
||
|
}, delayHoverIn);
|
||
|
} else {
|
||
|
onHoverIn(event);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
onMouseLeave: (event: MouseEvent): void => {
|
||
|
if (this._isHovered) {
|
||
|
this._isHovered = false;
|
||
|
this._cancelHoverInDelayTimeout();
|
||
|
const {onHoverOut} = this._config;
|
||
|
if (onHoverOut != null) {
|
||
|
const delayHoverOut = normalizeDelay(
|
||
|
this._config.delayHoverOut,
|
||
|
);
|
||
|
if (delayHoverOut > 0) {
|
||
|
event.persist();
|
||
|
this._hoverInDelayTimeout = setTimeout(() => {
|
||
|
onHoverOut(event);
|
||
|
}, delayHoverOut);
|
||
|
} else {
|
||
|
onHoverOut(event);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
return {
|
||
|
...focusEventHandlers,
|
||
|
...responderEventHandlers,
|
||
|
...mouseEventHandlers,
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Receives a state machine signal, performs side effects of the transition
|
||
|
* and stores the new state. Validates the transition as well.
|
||
|
*/
|
||
|
_receiveSignal(signal: TouchSignal, event: PressEvent): void {
|
||
|
// Especially on iOS, not all events have timestamps associated.
|
||
|
// For telemetry purposes, this doesn't matter too much, as long as *some* do.
|
||
|
// Since the native timestamp is integral for logging telemetry, just skip
|
||
|
// events if they don't have a timestamp attached.
|
||
|
if (event.nativeEvent.timestamp != null) {
|
||
|
PressabilityPerformanceEventEmitter.emitEvent(() => {
|
||
|
return {
|
||
|
signal,
|
||
|
nativeTimestamp: event.nativeEvent.timestamp,
|
||
|
};
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const prevState = this._touchState;
|
||
|
const nextState = Transitions[prevState]?.[signal];
|
||
|
if (this._responderID == null && signal === 'RESPONDER_RELEASE') {
|
||
|
return;
|
||
|
}
|
||
|
invariant(
|
||
|
nextState != null && nextState !== 'ERROR',
|
||
|
'Pressability: Invalid signal `%s` for state `%s` on responder: %s',
|
||
|
signal,
|
||
|
prevState,
|
||
|
typeof this._responderID === 'number'
|
||
|
? this._responderID
|
||
|
: '<<host component>>',
|
||
|
);
|
||
|
if (prevState !== nextState) {
|
||
|
this._performTransitionSideEffects(prevState, nextState, signal, event);
|
||
|
this._touchState = nextState;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Performs a transition between touchable states and identify any activations
|
||
|
* or deactivations (and callback invocations).
|
||
|
*/
|
||
|
_performTransitionSideEffects(
|
||
|
prevState: TouchState,
|
||
|
nextState: TouchState,
|
||
|
signal: TouchSignal,
|
||
|
event: PressEvent,
|
||
|
): void {
|
||
|
if (isTerminalSignal(signal)) {
|
||
|
this._touchActivatePosition = null;
|
||
|
this._cancelLongPressDelayTimeout();
|
||
|
}
|
||
|
|
||
|
const isInitialTransition =
|
||
|
prevState === 'NOT_RESPONDER' &&
|
||
|
nextState === 'RESPONDER_INACTIVE_PRESS_IN';
|
||
|
|
||
|
const isActivationTransition =
|
||
|
!isActivationSignal(prevState) && isActivationSignal(nextState);
|
||
|
|
||
|
if (isInitialTransition || isActivationTransition) {
|
||
|
this._measureResponderRegion();
|
||
|
}
|
||
|
|
||
|
if (isPressInSignal(prevState) && signal === 'LONG_PRESS_DETECTED') {
|
||
|
const {onLongPress} = this._config;
|
||
|
if (onLongPress != null) {
|
||
|
onLongPress(event);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const isPrevActive = isActiveSignal(prevState);
|
||
|
const isNextActive = isActiveSignal(nextState);
|
||
|
|
||
|
if (!isPrevActive && isNextActive) {
|
||
|
this._activate(event);
|
||
|
} else if (isPrevActive && !isNextActive) {
|
||
|
this._deactivate(event);
|
||
|
}
|
||
|
|
||
|
if (isPressInSignal(prevState) && signal === 'RESPONDER_RELEASE') {
|
||
|
// If we never activated (due to delays), activate and deactivate now.
|
||
|
if (!isNextActive && !isPrevActive) {
|
||
|
this._activate(event);
|
||
|
this._deactivate(event);
|
||
|
}
|
||
|
const {onLongPress, onPress, android_disableSound} = this._config;
|
||
|
if (onPress != null) {
|
||
|
const isPressCanceledByLongPress =
|
||
|
onLongPress != null &&
|
||
|
prevState === 'RESPONDER_ACTIVE_LONG_PRESS_IN' &&
|
||
|
this._shouldLongPressCancelPress();
|
||
|
if (!isPressCanceledByLongPress) {
|
||
|
if (Platform.OS === 'android' && android_disableSound !== true) {
|
||
|
SoundManager.playTouchSound();
|
||
|
}
|
||
|
onPress(event);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this._cancelPressDelayTimeout();
|
||
|
}
|
||
|
|
||
|
_activate(event: PressEvent): void {
|
||
|
const {onPressIn} = this._config;
|
||
|
const {pageX, pageY} = getTouchFromPressEvent(event);
|
||
|
this._touchActivatePosition = {pageX, pageY};
|
||
|
this._touchActivateTime = Date.now();
|
||
|
if (onPressIn != null) {
|
||
|
onPressIn(event);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_deactivate(event: PressEvent): void {
|
||
|
const {onPressOut} = this._config;
|
||
|
if (onPressOut != null) {
|
||
|
const minPressDuration = normalizeDelay(
|
||
|
this._config.minPressDuration,
|
||
|
0,
|
||
|
DEFAULT_MIN_PRESS_DURATION,
|
||
|
);
|
||
|
const pressDuration = Date.now() - (this._touchActivateTime ?? 0);
|
||
|
const delayPressOut = Math.max(
|
||
|
minPressDuration - pressDuration,
|
||
|
normalizeDelay(this._config.delayPressOut),
|
||
|
);
|
||
|
if (delayPressOut > 0) {
|
||
|
event.persist();
|
||
|
this._pressOutDelayTimeout = setTimeout(() => {
|
||
|
onPressOut(event);
|
||
|
}, delayPressOut);
|
||
|
} else {
|
||
|
onPressOut(event);
|
||
|
}
|
||
|
}
|
||
|
this._touchActivateTime = null;
|
||
|
}
|
||
|
|
||
|
_measureResponderRegion(): void {
|
||
|
if (this._responderID == null) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (typeof this._responderID === 'number') {
|
||
|
UIManager.measure(this._responderID, this._measureCallback);
|
||
|
} else {
|
||
|
this._responderID.measure(this._measureCallback);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_measureCallback = (
|
||
|
left: number,
|
||
|
top: number,
|
||
|
width: number,
|
||
|
height: number,
|
||
|
pageX: number,
|
||
|
pageY: number,
|
||
|
) => {
|
||
|
if (!left && !top && !width && !height && !pageX && !pageY) {
|
||
|
return;
|
||
|
}
|
||
|
this._responderRegion = {
|
||
|
bottom: pageY + height,
|
||
|
left: pageX,
|
||
|
right: pageX + width,
|
||
|
top: pageY,
|
||
|
};
|
||
|
};
|
||
|
|
||
|
_isTouchWithinResponderRegion(
|
||
|
touch: $PropertyType<PressEvent, 'nativeEvent'>,
|
||
|
responderRegion: $ReadOnly<{|
|
||
|
bottom: number,
|
||
|
left: number,
|
||
|
right: number,
|
||
|
top: number,
|
||
|
|}>,
|
||
|
): boolean {
|
||
|
const hitSlop = normalizeRect(this._config.hitSlop);
|
||
|
const pressRectOffset = normalizeRect(this._config.pressRectOffset);
|
||
|
|
||
|
let regionBottom = responderRegion.bottom;
|
||
|
let regionLeft = responderRegion.left;
|
||
|
let regionRight = responderRegion.right;
|
||
|
let regionTop = responderRegion.top;
|
||
|
|
||
|
if (hitSlop != null) {
|
||
|
if (hitSlop.bottom != null) {
|
||
|
regionBottom += hitSlop.bottom;
|
||
|
}
|
||
|
if (hitSlop.left != null) {
|
||
|
regionLeft -= hitSlop.left;
|
||
|
}
|
||
|
if (hitSlop.right != null) {
|
||
|
regionRight += hitSlop.right;
|
||
|
}
|
||
|
if (hitSlop.top != null) {
|
||
|
regionTop -= hitSlop.top;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
regionBottom +=
|
||
|
pressRectOffset?.bottom ?? DEFAULT_PRESS_RECT_OFFSETS.bottom;
|
||
|
regionLeft -= pressRectOffset?.left ?? DEFAULT_PRESS_RECT_OFFSETS.left;
|
||
|
regionRight += pressRectOffset?.right ?? DEFAULT_PRESS_RECT_OFFSETS.right;
|
||
|
regionTop -= pressRectOffset?.top ?? DEFAULT_PRESS_RECT_OFFSETS.top;
|
||
|
|
||
|
return (
|
||
|
touch.pageX > regionLeft &&
|
||
|
touch.pageX < regionRight &&
|
||
|
touch.pageY > regionTop &&
|
||
|
touch.pageY < regionBottom
|
||
|
);
|
||
|
}
|
||
|
|
||
|
_handleLongPress(event: PressEvent): void {
|
||
|
if (
|
||
|
this._touchState === 'RESPONDER_ACTIVE_PRESS_IN' ||
|
||
|
this._touchState === 'RESPONDER_ACTIVE_LONG_PRESS_IN'
|
||
|
) {
|
||
|
this._receiveSignal('LONG_PRESS_DETECTED', event);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_shouldLongPressCancelPress(): boolean {
|
||
|
return (
|
||
|
this._config.onLongPressShouldCancelPress_DEPRECATED == null ||
|
||
|
this._config.onLongPressShouldCancelPress_DEPRECATED()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
_cancelHoverInDelayTimeout(): void {
|
||
|
if (this._hoverInDelayTimeout != null) {
|
||
|
clearTimeout(this._hoverInDelayTimeout);
|
||
|
this._hoverInDelayTimeout = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_cancelHoverOutDelayTimeout(): void {
|
||
|
if (this._hoverOutDelayTimeout != null) {
|
||
|
clearTimeout(this._hoverOutDelayTimeout);
|
||
|
this._hoverOutDelayTimeout = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_cancelLongPressDelayTimeout(): void {
|
||
|
if (this._longPressDelayTimeout != null) {
|
||
|
clearTimeout(this._longPressDelayTimeout);
|
||
|
this._longPressDelayTimeout = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_cancelPressDelayTimeout(): void {
|
||
|
if (this._pressDelayTimeout != null) {
|
||
|
clearTimeout(this._pressDelayTimeout);
|
||
|
this._pressDelayTimeout = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_cancelPressOutDelayTimeout(): void {
|
||
|
if (this._pressOutDelayTimeout != null) {
|
||
|
clearTimeout(this._pressOutDelayTimeout);
|
||
|
this._pressOutDelayTimeout = null;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function normalizeDelay(
|
||
|
delay: ?number,
|
||
|
min: number = 0,
|
||
|
fallback: number = 0,
|
||
|
): number {
|
||
|
return Math.max(min, delay ?? fallback);
|
||
|
}
|
||
|
|
||
|
const getTouchFromPressEvent = (event: PressEvent) => {
|
||
|
const {changedTouches, touches} = event.nativeEvent;
|
||
|
|
||
|
if (touches != null && touches.length > 0) {
|
||
|
return touches[0];
|
||
|
}
|
||
|
if (changedTouches != null && changedTouches.length > 0) {
|
||
|
return changedTouches[0];
|
||
|
}
|
||
|
return event.nativeEvent;
|
||
|
};
|
||
|
|
||
|
function convertPointerEventToMouseEvent(input: PointerEvent): MouseEvent {
|
||
|
const {clientX, clientY} = input.nativeEvent;
|
||
|
return {
|
||
|
...input,
|
||
|
nativeEvent: {
|
||
|
clientX,
|
||
|
clientY,
|
||
|
pageX: clientX,
|
||
|
pageY: clientY,
|
||
|
timestamp: input.timeStamp,
|
||
|
},
|
||
|
};
|
||
|
}
|