import * as React from 'react'; import { Component } from 'react'; import { Animated, Platform, StyleProp, ViewStyle, TouchableWithoutFeedbackProps, Insets, } from 'react-native'; import { State } from '../../State'; import { BaseButton } from '../GestureButtons'; import { GestureEvent, HandlerStateChangeEvent, } from '../../handlers/gestureHandlerCommon'; import { NativeViewGestureHandlerPayload } from '../../handlers/NativeViewGestureHandler'; import { TouchableNativeFeedbackExtraProps } from './TouchableNativeFeedback.android'; /** * Each touchable is a states' machine which preforms transitions. * On very beginning (and on the very end or recognition) touchable is * UNDETERMINED. Then it moves to BEGAN. If touchable recognizes that finger * travel outside it transits to special MOVED_OUTSIDE state. Gesture recognition * finishes in UNDETERMINED state. */ export const TOUCHABLE_STATE = { UNDETERMINED: 0, BEGAN: 1, MOVED_OUTSIDE: 2, } as const; type TouchableState = typeof TOUCHABLE_STATE[keyof typeof TOUCHABLE_STATE]; export interface GenericTouchableProps extends Omit { // Decided to drop not used fields from RN's implementation. // e.g. onBlur and onFocus as well as deprecated props. - TODO: this comment may be unuseful in this moment // TODO: in RN these events get native event parameter, which prolly could be used in our implementation too onPress?: () => void; onPressIn?: () => void; onPressOut?: () => void; onLongPress?: () => void; nativeID?: string; shouldActivateOnStart?: boolean; disallowInterruption?: boolean; containerStyle?: StyleProp; hitSlop?: Insets | number; } interface InternalProps { extraButtonProps: TouchableNativeFeedbackExtraProps; onStateChange?: (oldState: TouchableState, newState: TouchableState) => void; } // TODO: maybe can be better // TODO: all clearTimeout have ! added, maybe they shouldn't ? type Timeout = ReturnType | null | undefined; /** * GenericTouchable is not intented to be used as it is. * Should be treated as a source for the rest of touchables */ export default class GenericTouchable extends Component< GenericTouchableProps & InternalProps > { static defaultProps = { delayLongPress: 600, extraButtonProps: { rippleColor: 'transparent', exclusive: true, }, }; // timeout handlers pressInTimeout: Timeout; pressOutTimeout: Timeout; longPressTimeout: Timeout; // This flag is required since recognition of longPress implies not-invoking onPress longPressDetected = false; pointerInside = true; // State of touchable STATE: TouchableState = TOUCHABLE_STATE.UNDETERMINED; // handlePressIn in called on first touch on traveling inside component. // Handles state transition with delay. handlePressIn() { if (this.props.delayPressIn) { this.pressInTimeout = setTimeout(() => { this.moveToState(TOUCHABLE_STATE.BEGAN); this.pressInTimeout = null; }, this.props.delayPressIn); } else { this.moveToState(TOUCHABLE_STATE.BEGAN); } if (this.props.onLongPress) { const time = (this.props.delayPressIn || 0) + (this.props.delayLongPress || 0); this.longPressTimeout = setTimeout(this.onLongPressDetected, time); } } // handleMoveOutside in called on traveling outside component. // Handles state transition with delay. handleMoveOutside() { if (this.props.delayPressOut) { this.pressOutTimeout = this.pressOutTimeout || setTimeout(() => { this.moveToState(TOUCHABLE_STATE.MOVED_OUTSIDE); this.pressOutTimeout = null; }, this.props.delayPressOut); } else { this.moveToState(TOUCHABLE_STATE.MOVED_OUTSIDE); } } // handleGoToUndetermined transits to UNDETERMINED state with proper delay handleGoToUndetermined() { clearTimeout(this.pressOutTimeout!); // TODO: maybe it can be undefined if (this.props.delayPressOut) { this.pressOutTimeout = setTimeout(() => { if (this.STATE === TOUCHABLE_STATE.UNDETERMINED) { this.moveToState(TOUCHABLE_STATE.BEGAN); } this.moveToState(TOUCHABLE_STATE.UNDETERMINED); this.pressOutTimeout = null; }, this.props.delayPressOut); } else { if (this.STATE === TOUCHABLE_STATE.UNDETERMINED) { this.moveToState(TOUCHABLE_STATE.BEGAN); } this.moveToState(TOUCHABLE_STATE.UNDETERMINED); } } componentDidMount() { this.reset(); } // reset timeout to prevent memory leaks. reset() { this.longPressDetected = false; this.pointerInside = true; clearTimeout(this.pressInTimeout!); clearTimeout(this.pressOutTimeout!); clearTimeout(this.longPressTimeout!); this.pressOutTimeout = null; this.longPressTimeout = null; this.pressInTimeout = null; } // All states' transitions are defined here. moveToState(newState: TouchableState) { if (newState === this.STATE) { // Ignore dummy transitions return; } if (newState === TOUCHABLE_STATE.BEGAN) { // First touch and moving inside this.props.onPressIn?.(); } else if (newState === TOUCHABLE_STATE.MOVED_OUTSIDE) { // Moving outside this.props.onPressOut?.(); } else if (newState === TOUCHABLE_STATE.UNDETERMINED) { // Need to reset each time on transition to UNDETERMINED this.reset(); if (this.STATE === TOUCHABLE_STATE.BEGAN) { // ... and if it happens inside button. this.props.onPressOut?.(); } } // Finally call lister (used by subclasses) this.props.onStateChange?.(this.STATE, newState); // ... and make transition. this.STATE = newState; } onGestureEvent = ({ nativeEvent: { pointerInside }, }: GestureEvent) => { if (this.pointerInside !== pointerInside) { if (pointerInside) { this.onMoveIn(); } else { this.onMoveOut(); } } this.pointerInside = pointerInside; }; onHandlerStateChange = ({ nativeEvent, }: HandlerStateChangeEvent) => { const { state } = nativeEvent; if (state === State.CANCELLED || state === State.FAILED) { // Need to handle case with external cancellation (e.g. by ScrollView) this.moveToState(TOUCHABLE_STATE.UNDETERMINED); } else if ( // This platform check is an implication of slightly different behavior of handlers on different platform. // And Android "Active" state is achieving on first move of a finger, not on press in. // On iOS event on "Began" is not delivered. state === (Platform.OS !== 'android' ? State.ACTIVE : State.BEGAN) && this.STATE === TOUCHABLE_STATE.UNDETERMINED ) { // Moving inside requires this.handlePressIn(); } else if (state === State.END) { const shouldCallOnPress = !this.longPressDetected && this.STATE !== TOUCHABLE_STATE.MOVED_OUTSIDE && this.pressOutTimeout === null; this.handleGoToUndetermined(); if (shouldCallOnPress) { // Calls only inside component whether no long press was called previously this.props.onPress?.(); } } }; onLongPressDetected = () => { this.longPressDetected = true; // checked for in the caller of `onLongPressDetected`, but better to check twice this.props.onLongPress?.(); }; componentWillUnmount() { // to prevent memory leaks this.reset(); } onMoveIn() { if (this.STATE === TOUCHABLE_STATE.MOVED_OUTSIDE) { // This call is not throttled with delays (like in RN's implementation). this.moveToState(TOUCHABLE_STATE.BEGAN); } } onMoveOut() { // long press should no longer be detected clearTimeout(this.longPressTimeout!); this.longPressTimeout = null; if (this.STATE === TOUCHABLE_STATE.BEGAN) { this.handleMoveOutside(); } } render() { const hitSlop = (typeof this.props.hitSlop === 'number' ? { top: this.props.hitSlop, left: this.props.hitSlop, bottom: this.props.hitSlop, right: this.props.hitSlop, } : this.props.hitSlop) ?? undefined; const coreProps = { accessible: this.props.accessible !== false, accessibilityLabel: this.props.accessibilityLabel, accessibilityHint: this.props.accessibilityHint, accessibilityRole: this.props.accessibilityRole, // TODO: check if changed to no 's' correctly, also removed 2 props that are no longer available: `accessibilityComponentType` and `accessibilityTraits`, // would be good to check if it is ok for sure, see: https://github.com/facebook/react-native/issues/24016 accessibilityState: this.props.accessibilityState, accessibilityActions: this.props.accessibilityActions, onAccessibilityAction: this.props.onAccessibilityAction, nativeID: this.props.nativeID, onLayout: this.props.onLayout, }; return ( {this.props.children} ); } }