From d905f4bf30de9527b1ef4a239ff243009561b7f3 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 17 May 2020 10:26:23 -0700 Subject: [PATCH 1/4] Implement Keybpoarding/Gamepad Activation for Pressability Fixes #4598 This change reenables touchables to be activated by keyboard/Gamepad. The implementation is mostly stolen from the previous that was used for Touchable. Like before, this doesn't participate in the input state machine, as its coupled to touch input. This means we don't get things like long-press detection, but we do still get feedback for key-in and key-out. We should do something better when we upstream keyboard behavior to do proper integration into the Pressability state machine, but this is invasive enough that we shouldn't fork this. Validated Spacebar and enter functionality with various touchables. --- .../Pressability/Pressability.windows.js | 868 ++++++++++++++++++ vnext/src/overrides.json | 8 + 2 files changed, 876 insertions(+) create mode 100644 vnext/src/Libraries/Pressability/Pressability.windows.js diff --git a/vnext/src/Libraries/Pressability/Pressability.windows.js b/vnext/src/Libraries/Pressability/Pressability.windows.js new file mode 100644 index 00000000000..394861bc9af --- /dev/null +++ b/vnext/src/Libraries/Pressability/Pressability.windows.js @@ -0,0 +1,868 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +import {isHoverEnabled} from './HoverState.js'; +import invariant from 'invariant'; +import SoundManager from '../Components/Sound/SoundManager.js'; +import type {EdgeInsetsProp} from '../StyleSheet/EdgeInsetsPropType.js'; +import type { + BlurEvent, + FocusEvent, + PressEvent, + MouseEvent, +} from '../Types/CoreEventTypes.js'; +import Platform from '../Utilities/Platform'; +import UIManager from '../ReactNative/UIManager'; +import type {HostComponent} from '../Renderer/shims/ReactNativeTypes'; +import * as React from 'react'; + +export type PressabilityConfig = $ReadOnly<{| + /** + * Returns the amount to extend the `VisualRect` by to create `HitRect`. + */ + getHitSlop?: ?() => ?EdgeInsetsProp, + + /** + * Returns the duration to wait after hover in before activation. + */ + getHoverInDelayMS?: ?() => ?number, + + /** + * Returns the duration to wait after hover out before deactivation. + */ + getHoverOutDelayMS?: ?() => ?number, + + /** + * Returns the duration (in addition to the value from `getPressDelayMS`) + * after which a press gesture becomes a long press gesture. + */ + getLongPressDelayMS?: ?() => ?number, + + /** + * Returns the duration to wait after press down before activation. + */ + getPressDelayMS?: ?() => ?number, + + /** + * Returns the duration to wait after letting up before deactivation. + */ + getPressOutDelayMS?: ?() => ?number, + + /** + * Returns the amount to extend the `HitRect` by to create `PressRect`. + */ + getPressRectOffset?: ?() => ?EdgeInsetsProp, + + /** + * Returns true to disable playing system sound on touch (Android Only) + **/ + getTouchSoundDisabled?: ?() => ?boolean, + + /** + * Called after the element loses focus. + */ + onBlur?: ?(event: BlurEvent) => void, + + /** + * Called after the element is focused. + */ + onFocus?: ?(event: FocusEvent) => void, + + /** + * Called when the hover is activated to provide visual feedback. + */ + onHoverIn?: ?(event: MouseEvent) => void, + + /** + * Called when the hover is deactivated to undo visual feedback. + */ + onHoverOut?: ?(event: MouseEvent) => void, + + /** + * Called when a long press gesture has been triggered. + */ + onLongPress?: ?(event: PressEvent) => void, + + /** + * Returns whether a long press gesture should cancel the press gesture. + * Defaults to true. + */ + onLongPressShouldCancelPress?: ?() => boolean, + + /** + * Called when a press gestute has been triggered. + */ + onPress?: ?(event: PressEvent) => void, + + /** + * Called when the press is activated to provide visual feedback. + */ + onPressIn?: ?(event: PressEvent) => void, + + /** + * Called when the press location moves. (This should rarely be used.) + */ + onPressMove?: ?(event: PressEvent) => void, + + /** + * Called when the press is deactivated to undo visual feedback. + */ + onPressOut?: ?(event: PressEvent) => void, + + /** + * Returns whether to yield to a lock termination request (e.g. if a native + * scroll gesture attempts to steal the responder lock). + */ + onResponderTerminationRequest?: ?() => boolean, + + /** + * Returns whether to start a press gesture. + */ + onStartShouldSetResponder?: ?() => boolean, +|}>; + +type EventHandlers = $ReadOnly<{| + onBlur: (event: BlurEvent) => void, + onClick: (event: PressEvent) => void, + onFocus: (event: FocusEvent) => void, + onMouseEnter?: (event: MouseEvent) => void, + onMouseLeave?: (event: MouseEvent) => void, + onResponderGrant: (event: PressEvent) => void, + onResponderMove: (event: PressEvent) => void, + onResponderRelease: (event: PressEvent) => void, + onResponderTerminate: (event: PressEvent) => void, + onResponderTerminationRequest: () => boolean, + onStartShouldSetResponder: () => boolean, + // [Windows + onKeyUp: (event: KeyEvent) => void, + onKeyDown: (event: KeyEvent) => void, + // Windows] +|}>; + +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'; + +type TouchSignal = + | 'DELAY' + | 'RESPONDER_GRANT' + | 'RESPONDER_RELEASE' + | 'RESPONDER_TERMINATED' + | 'ENTER_PRESS_RECT' + | 'LEAVE_PRESS_RECT' + | 'LONG_PRESS_DETECTED'; + +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 => + signal === 'RESPONDER_ACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN'; + +const isActivationSignal = signal => + signal === 'RESPONDER_ACTIVE_PRESS_OUT' || + signal === 'RESPONDER_ACTIVE_PRESS_IN'; + +const isPressInSignal = signal => + signal === 'RESPONDER_INACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN'; + +const isTerminalSignal = signal => + signal === 'RESPONDER_TERMINATED' || signal === 'RESPONDER_RELEASE'; + +const DEFAULT_LONG_PRESS_DELAY_MS = 500; +const DEFAULT_PRESS_DELAY_MS = 0; +const DEFAULT_PRESS_RECT_OFFSETS = { + bottom: 30, + left: 20, + right: 20, + top: 20, +}; + +/** + * 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 ( + * + * ); + * + * 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 `getHitSlop`. + * │ │ │ 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 + * `getPressRectOffset`, 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> = null; + _responderRegion: ?$ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}> = null; + _touchActivatePosition: ?$ReadOnly<{| + pageX: number, + pageY: number, + |}>; + _touchState: TouchState = 'NOT_RESPONDER'; + + constructor(config: PressabilityConfig) { + 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(); + } + + /** + * Returns a set of props to spread into the interactive element. + */ + getEventHandlers(): EventHandlers { + if (this._eventHandlers == null) { + this._eventHandlers = this._createEventHandlers(); + } + return this._eventHandlers; + } + + _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: () => { + const {onStartShouldSetResponder} = this._config; + return onStartShouldSetResponder == null + ? true + : onStartShouldSetResponder(); + }, + + onResponderGrant: (event: PressEvent): void => { + event.persist(); + + this._cancelPressOutDelayTimeout(); + + this._responderID = event.currentTarget; + this._touchState = 'NOT_RESPONDER'; + this._receiveSignal('RESPONDER_GRANT', event); + + const {getLongPressDelayMS, getPressDelayMS} = this._config; + + const pressDelay = getDelayMS( + getPressDelayMS, + 0, + DEFAULT_PRESS_DELAY_MS, + ); + if (pressDelay > 0) { + this._pressDelayTimeout = setTimeout(() => { + this._receiveSignal('DELAY', event); + }, pressDelay); + } else { + this._receiveSignal('DELAY', event); + } + + const longPressDelay = getDelayMS( + getLongPressDelayMS, + 10, + DEFAULT_LONG_PRESS_DELAY_MS, + ); + this._longPressDelayTimeout = setTimeout(() => { + this._handleLongPress(event); + }, longPressDelay + pressDelay); + }, + + onResponderMove: (event: PressEvent): void => { + if (this._config.onPressMove != null) { + this._config.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) > 10) { + 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 {onResponderTerminationRequest} = this._config; + return onResponderTerminationRequest == null + ? true + : onResponderTerminationRequest(); + }, + + onClick: (event: PressEvent) => { + const {onPress} = this._config; + if (onPress != null) { + onPress(event); + } + }, + }; + + const mouseEventHandlers = + Platform.OS === 'ios' || Platform.OS === 'android' + ? null + : { + onMouseEnter: (event: MouseEvent): void => { + if (isHoverEnabled()) { + this._isHovered = true; + this._cancelHoverOutDelayTimeout(); + const {onHoverIn, getHoverInDelayMS} = this._config; + if (onHoverIn != null) { + const delay = getDelayMS(getHoverInDelayMS); + if (delay > 0) { + this._hoverInDelayTimeout = setTimeout(() => { + onHoverIn(event); + }, delay); + } else { + onHoverIn(event); + } + } + } + }, + + onMouseLeave: (event: MouseEvent): void => { + if (this._isHovered) { + this._isHovered = false; + this._cancelHoverInDelayTimeout(); + const {onHoverOut, getHoverOutDelayMS} = this._config; + if (onHoverOut != null) { + const delay = getDelayMS(getHoverOutDelayMS); + if (delay > 0) { + this._hoverInDelayTimeout = setTimeout(() => { + onHoverOut(event); + }, delay); + } else { + onHoverOut(event); + } + } + } + }, + }; + + // [Windows + const keyboardEventHandlers = { + onKeyUp: (event: KeyEvent): void => { + if ( + event.nativeEvent.code === 'Space' || + event.nativeEvent.code === 'Enter' || + event.nativeEvent.code === 'GamepadA' + ) { + const {onPressOut, onPress} = this._config; + onPressOut && onPressOut(event); + onPress && onPress(event); + } + }, + onKeyDown: (event: KeyEvent): void => { + if ( + event.nativeEvent.code === 'Space' || + event.nativeEvent.code === 'Enter' || + event.nativeEvent.code === 'GamepadA' + ) { + const {onPressIn} = this._config; + onPressIn && onPressIn(event); + } + }, + }; + // Windows] + + return { + ...focusEventHandlers, + ...responderEventHandlers, + ...mouseEventHandlers, + ...keyboardEventHandlers, // [Windows] + }; + } + + /** + * 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 { + 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 + : '<>', + ); + 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 isActivationTransiton = + !isActivationSignal(prevState) && isActivationSignal(nextState); + + if (isInitialTransition || isActivationTransiton) { + 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') { + const {onLongPress, onPress, getTouchSoundDisabled} = this._config; + if (onPress != null) { + const isPressCanceledByLongPress = + onLongPress != null && + prevState === 'RESPONDER_ACTIVE_LONG_PRESS_IN' && + this._shouldLongPressCancelPress(); + if (!isPressCanceledByLongPress) { + // If we never activated (due to delays), activate and deactivate now. + if (!isNextActive && !isPrevActive) { + this._activate(event); + this._deactivate(event); + } + const isTouchSoundDisabled = + (getTouchSoundDisabled == null ? null : getTouchSoundDisabled()) ?? + false; + if (Platform.OS === 'android' && !isTouchSoundDisabled) { + SoundManager.playTouchSound(); + } + onPress(event); + } + } + } + + this._cancelPressDelayTimeout(); + } + + _activate(event: PressEvent): void { + const {onPressIn} = this._config; + const touch = getTouchFromPressEvent(event); + this._touchActivatePosition = { + pageX: touch.pageX, + pageY: touch.pageY, + }; + if (onPressIn != null) { + onPressIn(event); + } + } + + _deactivate(event: PressEvent): void { + const {onPressOut, getPressOutDelayMS} = this._config; + if (onPressOut != null) { + const delay = getDelayMS(getPressOutDelayMS); + if (delay > 0) { + this._pressOutDelayTimeout = setTimeout(() => { + onPressOut(event); + }, delay); + } else { + onPressOut(event); + } + } + } + + _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, top, width, height, pageX, pageY) => { + if (!left && !top && !width && !height && !pageX && !pageY) { + return; + } + this._responderRegion = { + bottom: pageY + height, + left: pageX, + right: pageX + width, + top: pageY, + }; + }; + + _isTouchWithinResponderRegion( + touch: $PropertyType, + responderRegion: $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, + ): boolean { + const {getHitSlop, getPressRectOffset} = this._config; + + let regionBottom = responderRegion.bottom; + let regionLeft = responderRegion.left; + let regionRight = responderRegion.right; + let regionTop = responderRegion.top; + + const hitSlop = getHitSlop == null ? null : getHitSlop(); + 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; + } + } + + const rectOffset = getPressRectOffset == null ? null : getPressRectOffset(); + regionBottom += + rectOffset == null || rectOffset.bottom == null + ? DEFAULT_PRESS_RECT_OFFSETS.bottom + : rectOffset.bottom; + regionLeft -= + rectOffset == null || rectOffset.left == null + ? DEFAULT_PRESS_RECT_OFFSETS.left + : rectOffset.left; + regionRight += + rectOffset == null || rectOffset.right == null + ? DEFAULT_PRESS_RECT_OFFSETS.right + : rectOffset.right; + regionTop -= + rectOffset == null || rectOffset.top == null + ? DEFAULT_PRESS_RECT_OFFSETS.top + : rectOffset.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 == null || + this._config.onLongPressShouldCancelPress() + ); + } + + _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; + } + } +} + +const getDelayMS = (getDelay: ?() => ?number, min = 0, fallback = 0) => { + return Math.max(min, (getDelay == null ? null : getDelay()) ?? 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; +}; diff --git a/vnext/src/overrides.json b/vnext/src/overrides.json index ff537dfc57b..c02cfcdb585 100644 --- a/vnext/src/overrides.json +++ b/vnext/src/overrides.json @@ -588,6 +588,14 @@ "baseHash": "39326801da6c9ce8c350aa8ba971be4a386499bc", "issue": "LEGACY_FIXME" }, + { + "type": "patch", + "file": "Libraries\\Pressability\\Pressability.windows.js", + "baseFile": "Libraries\\Pressability\\Pressability.js", + "baseVersion": "0.62.2", + "baseHash": "acf1c23e6f39d8fb201a06bca850760efe295057", + "issue": 4379 + }, { "type": "patch", "file": "Libraries\\Pressability\\PressabilityDebug.windows.js", From 7930c5c59ce2e6ce8c2a82e1d4e5f0b78796aeae Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 17 May 2020 10:26:45 -0700 Subject: [PATCH 2/4] Change files --- ...tive-windows-2020-05-17-10-26-45-pressability-kbd.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 change/react-native-windows-2020-05-17-10-26-45-pressability-kbd.json diff --git a/change/react-native-windows-2020-05-17-10-26-45-pressability-kbd.json b/change/react-native-windows-2020-05-17-10-26-45-pressability-kbd.json new file mode 100644 index 00000000000..24845722c81 --- /dev/null +++ b/change/react-native-windows-2020-05-17-10-26-45-pressability-kbd.json @@ -0,0 +1,8 @@ +{ + "type": "prerelease", + "comment": "Implement Keybpoarding/Gamepad Activation for Pressability", + "packageName": "react-native-windows", + "email": "ngerlem@microsoft.com", + "dependentChangeType": "patch", + "date": "2020-05-17T17:26:45.850Z" +} From 2996ff12c905024839f8a4a5f28ca600f4285ad7 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 17 May 2020 10:53:21 -0700 Subject: [PATCH 3/4] Flow fixes --- vnext/src/Libraries/Pressability/Pressability.windows.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vnext/src/Libraries/Pressability/Pressability.windows.js b/vnext/src/Libraries/Pressability/Pressability.windows.js index 394861bc9af..316513fba43 100644 --- a/vnext/src/Libraries/Pressability/Pressability.windows.js +++ b/vnext/src/Libraries/Pressability/Pressability.windows.js @@ -19,6 +19,7 @@ import type { FocusEvent, PressEvent, MouseEvent, + KeyEvent, // [Windows] } from '../Types/CoreEventTypes.js'; import Platform from '../Utilities/Platform'; import UIManager from '../ReactNative/UIManager'; @@ -572,7 +573,10 @@ export default class Pressability { event.nativeEvent.code === 'GamepadA' ) { const {onPressOut, onPress} = this._config; + + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead onPressOut && onPressOut(event); + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead onPress && onPress(event); } }, @@ -583,6 +587,8 @@ export default class Pressability { event.nativeEvent.code === 'GamepadA' ) { const {onPressIn} = this._config; + + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead onPressIn && onPressIn(event); } }, From 471c8874177017db4146d5575f548694746bd5e3 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 17 May 2020 11:01:05 -0700 Subject: [PATCH 4/4] Fix typo --- vnext/src/Libraries/Pressability/Pressability.windows.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vnext/src/Libraries/Pressability/Pressability.windows.js b/vnext/src/Libraries/Pressability/Pressability.windows.js index 316513fba43..31883d6d352 100644 --- a/vnext/src/Libraries/Pressability/Pressability.windows.js +++ b/vnext/src/Libraries/Pressability/Pressability.windows.js @@ -574,9 +574,9 @@ export default class Pressability { ) { const {onPressOut, onPress} = this._config; - // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead onPressOut && onPressOut(event); - // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead onPress && onPress(event); } }, @@ -588,7 +588,7 @@ export default class Pressability { ) { const {onPressIn} = this._config; - // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of pasing KeyEvents instead + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead onPressIn && onPressIn(event); } },