UNPKG

create-expo-cljs-app

Version:

Create a react native application with Expo and Shadow-CLJS!

600 lines (542 loc) 21 kB
/** * 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'; type ClickEvent = any; type KeyboardEvent = any; type ResponderEvent = any; export type PressResponderConfig = $ReadOnly<{| // The gesture can be interrupted by a parent gesture, e.g., scroll. // Defaults to true. cancelable?: ?boolean, // Whether to disable initialization of the press gesture. disabled?: ?boolean, // Duration (in addition to `delayPressStart`) 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 `onPressStart`. delayPressStart?: ?number, // Duration to wait after letting up before calling `onPressEnd`. delayPressEnd?: ?number, // Called when a long press gesture has been triggered. onLongPress?: ?(event: ResponderEvent) => void, // Called when a press gestute has been triggered. onPress?: ?(event: ClickEvent) => void, // Called when the press is activated to provide visual feedback. onPressChange?: ?(event: ResponderEvent) => void, // Called when the press is activated to provide visual feedback. onPressStart?: ?(event: ResponderEvent) => void, // Called when the press location moves. (This should rarely be used.) onPressMove?: ?(event: ResponderEvent) => void, // Called when the press is deactivated to undo visual feedback. onPressEnd?: ?(event: ResponderEvent) => void |}>; export type EventHandlers = $ReadOnly<{| onClick: (event: ClickEvent) => void, onContextMenu: (event: ClickEvent) => void, onKeyDown: (event: KeyboardEvent) => void, onResponderGrant: (event: ResponderEvent) => void, onResponderMove: (event: ResponderEvent) => void, onResponderRelease: (event: ResponderEvent) => void, onResponderTerminate: (event: ResponderEvent) => void, onResponderTerminationRequest: (event: ResponderEvent) => boolean, onStartShouldSetResponder: (event: ResponderEvent) => boolean |}>; type TouchState = | 'NOT_RESPONDER' | 'RESPONDER_INACTIVE_PRESS_START' | 'RESPONDER_ACTIVE_PRESS_START' | 'RESPONDER_ACTIVE_LONG_PRESS_START' | 'ERROR'; type TouchSignal = | 'DELAY' | 'RESPONDER_GRANT' | 'RESPONDER_RELEASE' | 'RESPONDER_TERMINATED' | 'LONG_PRESS_DETECTED'; const DELAY = 'DELAY'; const ERROR = 'ERROR'; const LONG_PRESS_DETECTED = 'LONG_PRESS_DETECTED'; const NOT_RESPONDER = 'NOT_RESPONDER'; const RESPONDER_ACTIVE_LONG_PRESS_START = 'RESPONDER_ACTIVE_LONG_PRESS_START'; const RESPONDER_ACTIVE_PRESS_START = 'RESPONDER_ACTIVE_PRESS_START'; const RESPONDER_INACTIVE_PRESS_START = 'RESPONDER_INACTIVE_PRESS_START'; const RESPONDER_GRANT = 'RESPONDER_GRANT'; const RESPONDER_RELEASE = 'RESPONDER_RELEASE'; const RESPONDER_TERMINATED = 'RESPONDER_TERMINATED'; const Transitions = Object.freeze({ NOT_RESPONDER: { DELAY: ERROR, RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, RESPONDER_RELEASE: ERROR, RESPONDER_TERMINATED: ERROR, LONG_PRESS_DETECTED: ERROR }, RESPONDER_INACTIVE_PRESS_START: { DELAY: RESPONDER_ACTIVE_PRESS_START, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: ERROR }, RESPONDER_ACTIVE_PRESS_START: { DELAY: ERROR, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START }, RESPONDER_ACTIVE_LONG_PRESS_START: { DELAY: ERROR, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START }, ERROR: { DELAY: NOT_RESPONDER, RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: NOT_RESPONDER } }); const isActiveSignal = (signal) => signal === RESPONDER_ACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_LONG_PRESS_START; const isButtonRole = (element) => element.getAttribute('role') === 'button'; const isPressStartSignal = (signal) => signal === RESPONDER_INACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_LONG_PRESS_START; const isTerminalSignal = (signal) => signal === RESPONDER_TERMINATED || signal === RESPONDER_RELEASE; const isValidKeyPress = (event) => { const { key, target } = event; const role = target.getAttribute('role'); const isSpacebar = key === ' ' || key === 'Spacebar'; return key === 'Enter' || (isSpacebar && role === 'button'); }; const DEFAULT_LONG_PRESS_DELAY_MS = 450; // 500 - 50 const DEFAULT_PRESS_DELAY_MS = 50; /** * =========================== PressResponder Tutorial =========================== * * The `PressResponder` 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 `PressResponder`, do the following: * * const pressResponder = new PressResponder(config); * * 2. Choose the rendered component who should collect the press events. On that * element, spread `pressability.getEventHandlers()` into its props. * * return ( * <View {...this.state.pressResponder.getEventHandlers()} /> * ); * * 3. Reset `PressResponder` when your component unmounts. * * componentWillUnmount() { * this.state.pressResponder.reset(); * } * * ==================== Implementation Details ==================== * * `PressResponder` 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`. * │ │ ┌────────────┐ │ │ * │ │ │ VisualRect │ │ │ * │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time * │ │ HitRect │ │ before letting up, `VisualRect` activates. * │ └──────────────────┘ │ * │ Out Region o │ * └────────────────────│───┘ * └────── When the press is released outside the `HitRect`, * the responder is NOT 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_START ├────────▶ │ PRESS_START ├────────────▶ │ LONG_PRESS_START │ * └─────────────────────┘ └───────────────────┘ └───────────────────┘ * * T + DELAY => LONG_PRESS_DELAY + DELAY * * Not drawn are the side effects of each transition. The most important side * effect is the invocation of `onLongPress`. Only when the browser produces a * `click` event is `onPress` invoked. */ export default class PressResponder { _config: PressResponderConfig; _eventHandlers: ?EventHandlers = null; _isPointerTouch: ?boolean = false; _longPressDelayTimeout: ?TimeoutID = null; _longPressDispatched: ?boolean = false; _pressDelayTimeout: ?TimeoutID = null; _pressOutDelayTimeout: ?TimeoutID = null; _selectionTerminated: ?boolean; _touchActivatePosition: ?$ReadOnly<{| pageX: number, pageY: number |}>; _touchState: TouchState = NOT_RESPONDER; constructor(config: PressResponderConfig) { this.configure(config); } configure(config: PressResponderConfig): void { this._config = config; } /** * Resets any pending timers. This should be called on unmount. */ reset(): void { 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 start = (event: ResponderEvent, shouldDelay?: boolean): void => { event.persist(); this._cancelPressOutDelayTimeout(); this._longPressDispatched = false; this._selectionTerminated = false; this._touchState = NOT_RESPONDER; this._isPointerTouch = event.nativeEvent.type === 'touchstart'; this._receiveSignal(RESPONDER_GRANT, event); const delayPressStart = normalizeDelay( this._config.delayPressStart, 0, DEFAULT_PRESS_DELAY_MS ); if (shouldDelay !== false && delayPressStart > 0) { this._pressDelayTimeout = setTimeout(() => { this._receiveSignal(DELAY, event); }, delayPressStart); } else { this._receiveSignal(DELAY, event); } const delayLongPress = normalizeDelay( this._config.delayLongPress, 10, DEFAULT_LONG_PRESS_DELAY_MS ); this._longPressDelayTimeout = setTimeout(() => { this._handleLongPress(event); }, delayLongPress + delayPressStart); }; const end = (event: ResponderEvent): void => { this._receiveSignal(RESPONDER_RELEASE, event); }; const keyupHandler = (event: KeyboardEvent) => { const { onPress } = this._config; const { target } = event; if (this._touchState !== NOT_RESPONDER && isValidKeyPress(event)) { end(event); document.removeEventListener('keyup', keyupHandler); const role = target.getAttribute('role'); const elementType = target.tagName.toLowerCase(); const isNativeInteractiveElement = role === 'link' || elementType === 'a' || elementType === 'button' || elementType === 'input' || elementType === 'select' || elementType === 'textarea'; if (onPress != null && !isNativeInteractiveElement) { onPress(event); } } }; return { onStartShouldSetResponder: (event): boolean => { const { disabled } = this._config; if (disabled && isButtonRole(event.currentTarget)) { event.stopPropagation(); } if (disabled == null) { return true; } return !disabled; }, onKeyDown: (event) => { const { disabled } = this._config; const { key, target } = event; if (!disabled && isValidKeyPress(event)) { if (this._touchState === NOT_RESPONDER) { start(event, false); // Listen to 'keyup' on document to account for situations where // focus is moved to another element during 'keydown'. document.addEventListener('keyup', keyupHandler); } const role = target.getAttribute('role'); const isSpacebarKey = key === ' ' || key === 'Spacebar'; const isButtonRole = role === 'button' || role === 'menuitem'; if (isSpacebarKey && isButtonRole) { // Prevent spacebar scrolling the window event.preventDefault(); } event.stopPropagation(); } }, onResponderGrant: (event) => start(event), onResponderMove: (event) => { if (this._config.onPressMove != null) { this._config.onPressMove(event); } const touch = getTouchFromResponderEvent(event); 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(); } } }, onResponderRelease: (event) => end(event), onResponderTerminate: (event) => { if (event.nativeEvent.type === 'selectionchange') { this._selectionTerminated = true; } this._receiveSignal(RESPONDER_TERMINATED, event); }, onResponderTerminationRequest: (event): boolean => { const { cancelable, disabled, onLongPress } = this._config; // If `onLongPress` is provided, don't terminate on `contextmenu` as default // behavior will be prevented for non-mouse pointers. if ( !disabled && onLongPress != null && this._isPointerTouch && event.nativeEvent.type === 'contextmenu' ) { return false; } if (cancelable == null) { return true; } return cancelable; }, // NOTE: this diverges from react-native in 3 significant ways: // * The `onPress` callback is not connected to the responder system (the native // `click` event must be used but is dispatched in many scenarios where no pointers // are on the screen.) Therefore, it's possible for `onPress` to be called without // `onPress{Start,End}` being called first. // * The `onPress` callback is only be called on the first ancestor of the native // `click` target that is using the PressResponder. // * The event's `nativeEvent` is a `MouseEvent` not a `TouchEvent`. onClick: (event: any): void => { const { disabled, onPress } = this._config; if (!disabled) { // If long press dispatched, cancel default click behavior. // If the responder terminated because text was selected during the gesture, // cancel the default click behavior. event.stopPropagation(); if (this._longPressDispatched || this._selectionTerminated) { event.preventDefault(); } else if (onPress != null && event.altKey === false) { onPress(event); } } else { if (isButtonRole(event.currentTarget)) { event.stopPropagation(); } } }, // If `onLongPress` is provided and a touch pointer is being used, prevent the // default context menu from opening. onContextMenu: (event: any): void => { const { disabled, onLongPress } = this._config; if (!disabled) { if (onLongPress != null && this._isPointerTouch && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); } } else { if (isButtonRole(event.currentTarget)) { event.stopPropagation(); } } } }; } /** * 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: ResponderEvent): void { const prevState = this._touchState; let nextState = null; if (Transitions[prevState] != null) { nextState = Transitions[prevState][signal]; } if (this._touchState === NOT_RESPONDER && signal === RESPONDER_RELEASE) { return; } if (nextState == null || nextState === ERROR) { console.error(`PressResponder: Invalid signal ${signal} for state ${prevState} on responder`); } else 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: ResponderEvent ): void { if (isTerminalSignal(signal)) { this._isPointerTouch = false; this._touchActivatePosition = null; this._cancelLongPressDelayTimeout(); } if (isPressStartSignal(prevState) && signal === LONG_PRESS_DETECTED) { const { onLongPress } = this._config; // Long press is not supported for keyboards because 'click' can be dispatched // immediately (and multiple times) after 'keydown'. if (onLongPress != null && event.nativeEvent.key == null) { onLongPress(event); this._longPressDispatched = true; } } const isPrevActive = isActiveSignal(prevState); const isNextActive = isActiveSignal(nextState); if (!isPrevActive && isNextActive) { this._activate(event); } else if (isPrevActive && !isNextActive) { this._deactivate(event); } if (isPressStartSignal(prevState) && signal === RESPONDER_RELEASE) { const { onLongPress, onPress } = this._config; if (onPress != null) { const isPressCanceledByLongPress = onLongPress != null && prevState === RESPONDER_ACTIVE_LONG_PRESS_START; if (!isPressCanceledByLongPress) { // If we never activated (due to delays), activate and deactivate now. if (!isNextActive && !isPrevActive) { this._activate(event); this._deactivate(event); } } } } this._cancelPressDelayTimeout(); } _activate(event: ResponderEvent): void { const { onPressChange, onPressStart } = this._config; const touch = getTouchFromResponderEvent(event); this._touchActivatePosition = { pageX: touch.pageX, pageY: touch.pageY }; if (onPressStart != null) { onPressStart(event); } if (onPressChange != null) { onPressChange(true); } } _deactivate(event: ResponderEvent): void { const { onPressChange, onPressEnd } = this._config; function end() { if (onPressEnd != null) { onPressEnd(event); } if (onPressChange != null) { onPressChange(false); } } const delayPressEnd = normalizeDelay(this._config.delayPressEnd); if (delayPressEnd > 0) { this._pressOutDelayTimeout = setTimeout(() => { end(); }, delayPressEnd); } else { end(); } } _handleLongPress(event: ResponderEvent): void { if ( this._touchState === RESPONDER_ACTIVE_PRESS_START || this._touchState === RESPONDER_ACTIVE_LONG_PRESS_START ) { this._receiveSignal(LONG_PRESS_DETECTED, event); } } _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 = 0, fallback = 0): number { return Math.max(min, delay ?? fallback); } function getTouchFromResponderEvent(event: ResponderEvent) { 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; }