From ebf7d758164873169937321a4dccc3782359a0d3 Mon Sep 17 00:00:00 2001 From: Tim Yung Date: Wed, 27 Nov 2019 01:44:39 -0800 Subject: [PATCH] RN: New `TouchableWithoutFeedback` Summary: Launches a new implementation of `TouchableWithoutFeedback`. It is implemented using `Pressability` and extends `React.Component`. Notably, `propTypes` no longer exist. Changelog: [General] [Changed] - TouchableWithoutFeedback overhauled as a class without propTypes. Reviewed By: TheSavior Differential Revision: D18715852 fbshipit-source-id: f2eb28e3b8500bfcd8db44fc6bdbc0476193723a --- Libraries/Components/Touchable/TVTouchable.js | 54 +++ .../Touchable/TouchableWithoutFeedback.js | 438 ++++++------------ Libraries/Utilities/ReactNativeTestTools.js | 5 +- 3 files changed, 212 insertions(+), 285 deletions(-) create mode 100644 Libraries/Components/Touchable/TVTouchable.js diff --git a/Libraries/Components/Touchable/TVTouchable.js b/Libraries/Components/Touchable/TVTouchable.js new file mode 100644 index 00000000000000..5bf83f6ca3b3a3 --- /dev/null +++ b/Libraries/Components/Touchable/TVTouchable.js @@ -0,0 +1,54 @@ +/** + * 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 + * @format + */ + +'use strict'; + +import invariant from 'invariant'; +import ReactNative from '../../Renderer/shims/ReactNative.js'; +import type { + BlurEvent, + FocusEvent, + PressEvent, +} from '../../Types/CoreEventTypes'; +import {Platform, TVEventHandler} from 'react-native'; + +type TVTouchableConfig = $ReadOnly<{| + getDisabled: () => boolean, + onBlur: (event: BlurEvent) => mixed, + onFocus: (event: FocusEvent) => mixed, + onPress: (event: PressEvent) => mixed, +|}>; + +export default class TVTouchable { + _tvEventHandler: TVEventHandler; + + constructor(component: any, config: TVTouchableConfig) { + invariant(Platform.isTV, 'TVTouchable: Requires `Platform.isTV`.'); + this._tvEventHandler = new TVEventHandler(); + this._tvEventHandler.enable(component, (_, tvData) => { + tvData.dispatchConfig = {}; + if (ReactNative.findNodeHandle(component) === tvData.tag) { + if (tvData.eventType === 'focus') { + config.onFocus(tvData); + } else if (tvData.eventType === 'blur') { + config.onBlur(tvData); + } else if (tvData.eventType === 'select') { + if (!config.getDisabled()) { + config.onPress(tvData); + } + } + } + }); + } + + destroy(): void { + this._tvEventHandler.disable(); + } +} diff --git a/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 899833fbb96bfd..7db2a54020420c 100755 --- a/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -4,77 +4,44 @@ * 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 - * @flow */ 'use strict'; -import TouchableInjection from './TouchableInjection'; - -const DeprecatedEdgeInsetsPropType = require('../../DeprecatedPropTypes/DeprecatedEdgeInsetsPropType'); -const React = require('react'); -const PropTypes = require('prop-types'); -const Touchable = require('./Touchable'); -const View = require('../View/View'); - -const createReactClass = require('create-react-class'); -const ensurePositiveDelayProps = require('./ensurePositiveDelayProps'); - -const { - DeprecatedAccessibilityRoles, -} = require('../../DeprecatedPropTypes/DeprecatedViewAccessibility'); - +import Pressability from '../../Pressability/Pressability.js'; +import {PressabilityDebugView} from '../../Pressability/PressabilityDebug.js'; +import TVTouchable from './TVTouchable.js'; +import type { + AccessibilityActionEvent, + AccessibilityActionInfo, + AccessibilityRole, + AccessibilityState, + AccessibilityValue, +} from '../../Components/View/ViewAccessibility'; +import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; import type { BlurEvent, FocusEvent, LayoutEvent, PressEvent, } from '../../Types/CoreEventTypes'; -import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; -import type { - AccessibilityRole, - AccessibilityState, - AccessibilityActionInfo, - AccessibilityActionEvent, - AccessibilityValue, -} from '../View/ViewAccessibility'; - -const PRESS_RETENTION_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; - -const OVERRIDE_PROPS = [ - 'accessibilityLabel', - 'accessibilityHint', - 'accessibilityIgnoresInvertColors', - 'accessibilityRole', - 'accessibilityState', - 'accessibilityActions', - 'onAccessibilityAction', - 'accessibilityValue', - 'importantForAccessibility', - 'accessibilityLiveRegion', - 'accessibilityViewIsModal', - 'accessibilityElementsHidden', - 'hitSlop', - 'nativeID', - 'onBlur', - 'onFocus', - 'onLayout', - 'testID', -]; +import {Platform, View} from 'react-native'; +import * as React from 'react'; export type Props = $ReadOnly<{| accessibilityActions?: ?$ReadOnlyArray, + accessibilityElementsHidden?: ?boolean, accessibilityHint?: ?Stringish, accessibilityIgnoresInvertColors?: ?boolean, accessibilityLabel?: ?Stringish, + accessibilityLiveRegion?: ?('none' | 'polite' | 'assertive'), accessibilityRole?: ?AccessibilityRole, accessibilityState?: ?AccessibilityState, accessibilityValue?: ?AccessibilityValue, - accessible?: ?boolean, - accessibilityLiveRegion?: ?('none' | 'polite' | 'assertive'), accessibilityViewIsModal?: ?boolean, - accessibilityElementsHidden?: ?boolean, + accessible?: ?boolean, children?: ?React.Node, delayLongPress?: ?number, delayPressIn?: ?number, @@ -98,248 +65,151 @@ export type Props = $ReadOnly<{| touchSoundDisabled?: ?boolean, |}>; -/** - * Do not use unless you have a very good reason. All elements that - * respond to press should have a visual feedback when touched. - * - * TouchableWithoutFeedback supports only one child. - * If you wish to have several child components, wrap them in a View. - */ -const TouchableWithoutFeedbackImpl = ((createReactClass({ - displayName: 'TouchableWithoutFeedback', - mixins: [Touchable.Mixin], - - propTypes: { - accessible: PropTypes.bool, - accessibilityLabel: PropTypes.node, - accessibilityHint: PropTypes.string, - accessibilityIgnoresInvertColors: PropTypes.bool, - accessibilityRole: PropTypes.oneOf(DeprecatedAccessibilityRoles), - accessibilityState: PropTypes.object, - accessibilityActions: PropTypes.array, - onAccessibilityAction: PropTypes.func, - accessibilityValue: PropTypes.object, - /** - * Indicates to accessibility services whether the user should be notified - * when this view changes. Works for Android API >= 19 only. - * - * @platform android - * - * See http://facebook.github.io/react-native/docs/view.html#accessibilityliveregion - */ - accessibilityLiveRegion: (PropTypes.oneOf([ - 'none', - 'polite', - 'assertive', - ]): React$PropType$Primitive<'none' | 'polite' | 'assertive'>), - /** - * Controls how view is important for accessibility which is if it - * fires accessibility events and if it is reported to accessibility services - * that query the screen. Works for Android only. - * - * @platform android - * - * See http://facebook.github.io/react-native/docs/view.html#importantforaccessibility - */ - importantForAccessibility: (PropTypes.oneOf([ - 'auto', - 'yes', - 'no', - 'no-hide-descendants', - ]): React$PropType$Primitive< - 'auto' | 'yes' | 'no' | 'no-hide-descendants', - >), - /** - * A value indicating whether VoiceOver should ignore the elements - * within views that are siblings of the receiver. - * Default is `false`. - * - * @platform ios - * - * See http://facebook.github.io/react-native/docs/view.html#accessibilityviewismodal - */ - accessibilityViewIsModal: PropTypes.bool, - /** - * A value indicating whether the accessibility elements contained within - * this accessibility element are hidden. - * - * @platform ios - * - * See http://facebook.github.io/react-native/docs/view.html#accessibilityElementsHidden - */ - accessibilityElementsHidden: PropTypes.bool, - /** - * When `accessible` is true (which is the default) this may be called when - * the OS-specific concept of "focus" occurs. Some platforms may not have - * the concept of focus. - */ - onFocus: PropTypes.func, - /** - * When `accessible` is true (which is the default) this may be called when - * the OS-specific concept of "blur" occurs, meaning the element lost focus. - * Some platforms may not have the concept of blur. - */ - onBlur: PropTypes.func, - /** - * If true, disable all interactions for this component. - */ - disabled: PropTypes.bool, - /** - * Called when the touch is released, but not if cancelled (e.g. by a scroll - * that steals the responder lock). - */ - onPress: PropTypes.func, - /** - * Called as soon as the touchable element is pressed and invoked even before onPress. - * This can be useful when making network requests. - */ - onPressIn: PropTypes.func, - /** - * Called as soon as the touch is released even before onPress. - */ - onPressOut: PropTypes.func, - /** - * Invoked on mount and layout changes with - * - * `{nativeEvent: {layout: {x, y, width, height}}}` - */ - onLayout: PropTypes.func, - /** - * If true, doesn't play system sound on touch (Android Only) - **/ - touchSoundDisabled: PropTypes.bool, - - onLongPress: PropTypes.func, - - nativeID: PropTypes.string, - testID: PropTypes.string, - - /** - * Delay in ms, from the start of the touch, before onPressIn is called. - */ - delayPressIn: PropTypes.number, - /** - * Delay in ms, from the release of the touch, before onPressOut is called. - */ - delayPressOut: PropTypes.number, - /** - * Delay in ms, from onPressIn, before onLongPress is called. - */ - delayLongPress: PropTypes.number, - /** - * When the scroll view is disabled, this defines how far your touch may - * move off of the button, before deactivating the button. Once deactivated, - * try moving it back and you'll see that the button is once again - * reactivated! Move it back and forth several times while the scroll view - * is disabled. Ensure you pass in a constant to reduce memory allocations. - */ - pressRetentionOffset: DeprecatedEdgeInsetsPropType, - /** - * This defines how far your touch can start away from the button. This is - * added to `pressRetentionOffset` when moving off of the button. - * ** NOTE ** - * The touch area never extends past the parent view bounds and the Z-index - * of sibling views always takes precedence if a touch hits two overlapping - * views. - */ - hitSlop: DeprecatedEdgeInsetsPropType, - }, - - getInitialState: function() { - return this.touchableGetInitialState(); - }, - - componentDidMount: function() { - ensurePositiveDelayProps(this.props); - }, - - UNSAFE_componentWillReceiveProps: function(nextProps: Object) { - ensurePositiveDelayProps(nextProps); - }, - - /** - * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are - * defined on your component. - */ - touchableHandlePress: function(e: PressEvent) { - this.props.onPress && this.props.onPress(e); - }, - - touchableHandleActivePressIn: function(e: PressEvent) { - this.props.onPressIn && this.props.onPressIn(e); - }, - - touchableHandleActivePressOut: function(e: PressEvent) { - this.props.onPressOut && this.props.onPressOut(e); - }, - - touchableHandleLongPress: function(e: PressEvent) { - this.props.onLongPress && this.props.onLongPress(e); - }, - - touchableGetPressRectOffset: function(): typeof PRESS_RETENTION_OFFSET { - // $FlowFixMe Invalid prop usage - return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; - }, - - touchableGetHitSlop: function(): ?Object { - return this.props.hitSlop; - }, - - touchableGetHighlightDelayMS: function(): number { - return this.props.delayPressIn || 0; - }, - - touchableGetLongPressDelayMS: function(): number { - return this.props.delayLongPress === 0 - ? 0 - : this.props.delayLongPress || 500; - }, - - touchableGetPressOutDelayMS: function(): number { - return this.props.delayPressOut || 0; - }, +type State = $ReadOnly<{| + pressability: Pressability, +|}>; - render: function(): React.Element { - // Note(avik): remove dynamic typecast once Flow has been upgraded - // $FlowFixMe(>=0.41.0) - const child = React.Children.only(this.props.children); - let children = child.props.children; - if (Touchable.TOUCH_TARGET_DEBUG && child.type === View) { - children = React.Children.toArray(children); - children.push( - Touchable.renderDebugView({color: 'red', hitSlop: this.props.hitSlop}), - ); - } +const PASSTHROUGH_PROPS = [ + 'accessibilityActions', + 'accessibilityElementsHidden', + 'accessibilityHint', + 'accessibilityIgnoresInvertColors', + 'accessibilityLabel', + 'accessibilityLiveRegion', + 'accessibilityRole', + 'accessibilityState', + 'accessibilityValue', + 'accessibilityViewIsModal', + 'hitSlop', + 'importantForAccessibility', + 'nativeID', + 'onAccessibilityAction', + 'onBlur', + 'onFocus', + 'onLayout', + 'testID', +]; - const overrides = {}; - for (const prop of OVERRIDE_PROPS) { - if (this.props[prop] !== undefined) { - overrides[prop] = this.props[prop]; +class TouchableWithoutFeedback extends React.Component { + _tvTouchable: ?TVTouchable; + + state: State = { + pressability: new Pressability({ + getHitSlop: () => this.props.hitSlop, + getLongPressDelayMS: () => { + if (this.props.delayLongPress != null) { + const maybeNumber = this.props.delayLongPress; + if (typeof maybeNumber === 'number') { + return maybeNumber; + } + } + return 500; + }, + getPressDelayMS: () => this.props.delayPressIn, + getPressOutDelayMS: () => this.props.delayPressOut, + getPressRectOffset: () => this.props.pressRetentionOffset, + getTouchSoundDisabled: () => this.props.touchSoundDisabled, + onBlur: event => { + if (this.props.onBlur != null) { + this.props.onBlur(event); + } + }, + onFocus: event => { + if (this.props.onFocus != null) { + this.props.onFocus(event); + } + }, + onLongPress: event => { + if (this.props.onLongPress != null) { + this.props.onLongPress(event); + } + }, + onPress: event => { + if (this.props.onPress != null) { + this.props.onPress(event); + } + }, + onPressIn: event => { + if (this.props.onPressIn != null) { + this.props.onPressIn(event); + } + }, + onPressOut: event => { + if (this.props.onPressOut != null) { + this.props.onPressOut(event); + } + }, + onResponderTerminationRequest: () => + !this.props.rejectResponderTermination, + onStartShouldSetResponder: () => !this.props.disabled, + }), + }; + + render(): React.Node { + const element = React.Children.only(this.props.children); + const children = [element.props.children]; + if (__DEV__) { + if (element.type === View) { + children.push( + , + ); } } - return (React: any).cloneElement(child, { - ...overrides, + // BACKWARD-COMPATIBILITY: Focus and blur events were never supported before + // adopting `Pressability`, so preserve that behavior. + const { + onBlur, + onFocus, + ...eventHandlersWithoutBlurAndFocus + } = this.state.pressability.getEventHandlers(); + + const elementProps: {[string]: mixed, ...} = { + ...eventHandlersWithoutBlurAndFocus, accessible: this.props.accessible !== false, focusable: this.props.focusable !== false && this.props.onPress !== undefined, - onClick: this.touchableHandlePress, - onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, - onResponderTerminationRequest: this - .touchableHandleResponderTerminationRequest, - onResponderGrant: this.touchableHandleResponderGrant, - onResponderMove: this.touchableHandleResponderMove, - onResponderRelease: this.touchableHandleResponderRelease, - onResponderTerminate: this.touchableHandleResponderTerminate, - children, - }); - }, -}): any): React.ComponentType); + }; + for (const prop of PASSTHROUGH_PROPS) { + if (this.props[prop] !== undefined) { + elementProps[prop] = this.props[prop]; + } + } + + return React.cloneElement(element, elementProps, ...children); + } + + componentDidMount(): void { + if (Platform.isTV) { + this._tvTouchable = new TVTouchable(this, { + getDisabled: () => this.props.disabled === true, + onBlur: event => { + if (this.props.onBlur != null) { + this.props.onBlur(event); + } + }, + onFocus: event => { + if (this.props.onFocus != null) { + this.props.onFocus(event); + } + }, + onPress: event => { + if (this.props.onPress != null) { + this.props.onPress(event); + } + }, + }); + } + } -const TouchableWithoutFeedback: React.ComponentType = - TouchableInjection.unstable_TouchableWithoutFeedback == null - ? TouchableWithoutFeedbackImpl - : TouchableInjection.unstable_TouchableWithoutFeedback; + componentWillUnmount(): void { + if (Platform.isTV) { + if (this._tvTouchable != null) { + this._tvTouchable.destroy(); + } + } + this.state.pressability.reset(); + } +} module.exports = TouchableWithoutFeedback; diff --git a/Libraries/Utilities/ReactNativeTestTools.js b/Libraries/Utilities/ReactNativeTestTools.js index 14250ad93298f7..fbe4e094ca4aa4 100644 --- a/Libraries/Utilities/ReactNativeTestTools.js +++ b/Libraries/Utilities/ReactNativeTestTools.js @@ -36,6 +36,9 @@ function byClickable(): Predicate { typeof node.props.onPress === 'function') || // note: Special casing since it doesn't use touchable (node.type === Switch && node.props && node.props.disabled !== true) || + // HACK: Find components that use `Pressability`. + node.instance?.state?.pressability != null || + // TODO: Remove this after deleting `Touchable`. (node.instance && typeof node.instance.touchableHandlePress === 'function'), 'is clickable', @@ -177,7 +180,7 @@ function tap(instance: ReactTestInstance) { } else { // Only tap when props.disabled isn't set (or there aren't any props) if (!touchable.props || !touchable.props.disabled) { - touchable.instance.touchableHandlePress({nativeEvent: {}}); + touchable.props.onPress({nativeEvent: {}}); } } }