From f275514f275fdc404a853a1a2ab46620eea484f0 Mon Sep 17 00:00:00 2001 From: Tim Yung Date: Wed, 27 Jan 2021 16:17:07 -0800 Subject: [PATCH] RN: Modernize `Text` Component Summary: Rewrites the `Text` component using modern best practices. Notably, `Text` no longer depends on `Touchable` and now instead depends on `Pressability`. Changelog: [Internal] Reviewed By: mdvacca Differential Revision: D26106824 fbshipit-source-id: 0797e66075ae03c51dd5b4b3395b21ae92c39ba6 --- Libraries/Text/Text.js | 406 +++++++++++++++++------------------------ 1 file changed, 169 insertions(+), 237 deletions(-) diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 46aa822983569a..385164ea4ad116 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -4,257 +4,189 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ -'use strict'; - -import TextInjection from './TextInjection'; +import DeprecatedTextPropTypes from '../DeprecatedPropTypes/DeprecatedTextPropTypes'; +import * as PressabilityDebug from '../Pressability/PressabilityDebug'; +import usePressability from '../Pressability/usePressability'; +import StyleSheet from '../StyleSheet/StyleSheet'; +import processColor from '../StyleSheet/processColor'; +import TextAncestor from './TextAncestor'; import {NativeText, NativeVirtualText} from './TextNativeComponent'; - -const DeprecatedTextPropTypes = require('../DeprecatedPropTypes/DeprecatedTextPropTypes'); -const React = require('react'); -const TextAncestor = require('./TextAncestor'); -const Touchable = require('../Components/Touchable/Touchable'); - -const nullthrows = require('nullthrows'); -const processColor = require('../StyleSheet/processColor'); - -import type {PressEvent} from '../Types/CoreEventTypes'; -import type {PressRetentionOffset, TextProps} from './TextProps'; - -type ResponseHandlers = $ReadOnly<{| - onStartShouldSetResponder: () => boolean, - onResponderGrant: (event: PressEvent) => void, - onResponderMove: (event: PressEvent) => void, - onResponderRelease: (event: PressEvent) => void, - onResponderTerminate: (event: PressEvent) => void, - onResponderTerminationRequest: () => boolean, -|}>; - -type Props = $ReadOnly<{| - ...TextProps, - forwardedRef: ?React.Ref, -|}>; - -type State = {| - touchable: {| - touchState: ?string, - responderID: ?number, - |}, - isHighlighted: boolean, - createResponderHandlers: () => ResponseHandlers, - responseHandlers: ?ResponseHandlers, -|}; - -const PRESS_RECT_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; +import {type TextProps} from './TextProps'; +import * as React from 'react'; +import {useContext, useMemo, useState} from 'react'; /** - * A React component for displaying text. + * Text is the fundamental component for displaying text. * - * See https://reactnative.dev/docs/text.html + * @see https://reactnative.dev/docs/text.html */ -class TouchableText extends React.Component { - static defaultProps = { - accessible: true, - allowFontScaling: true, - ellipsizeMode: 'tail', - }; - - touchableGetPressRectOffset: ?() => PressRetentionOffset; - touchableHandleActivePressIn: ?() => void; - touchableHandleActivePressOut: ?() => void; - touchableHandleLongPress: ?(event: PressEvent) => void; - touchableHandlePress: ?(event: PressEvent) => void; - touchableHandleResponderGrant: ?(event: PressEvent) => void; - touchableHandleResponderMove: ?(event: PressEvent) => void; - touchableHandleResponderRelease: ?(event: PressEvent) => void; - touchableHandleResponderTerminate: ?(event: PressEvent) => void; - touchableHandleResponderTerminationRequest: ?() => boolean; - - state = { - ...Touchable.Mixin.touchableGetInitialState(), - isHighlighted: false, - createResponderHandlers: this._createResponseHandlers.bind(this), - responseHandlers: null, - }; - - static getDerivedStateFromProps( - nextProps: Props, - prevState: State, - ): $Shape | null { - return prevState.responseHandlers == null && isTouchable(nextProps) - ? { - responseHandlers: prevState.createResponderHandlers(), - } - : null; - } - - render(): React.Node { - let {forwardedRef, selectionColor, ...props} = this.props; - if (isTouchable(this.props)) { - props = { - ...props, - ...this.state.responseHandlers, - isHighlighted: this.state.isHighlighted, - }; - } - if (selectionColor != null) { - props = { - ...props, - selectionColor: processColor(selectionColor), - }; - } - if (__DEV__) { - if (Touchable.TOUCH_TARGET_DEBUG && props.onPress != null) { - props = { - ...props, - style: [props.style, {color: 'magenta'}], - }; - } - } - return ( - - {hasTextAncestor => - hasTextAncestor ? ( - // $FlowFixMe[prop-missing] For the `onClick` workaround. - - ) : ( - - - - ) - } - - ); - } - - _createResponseHandlers(): ResponseHandlers { - return { - onStartShouldSetResponder: (): boolean => { - const {onStartShouldSetResponder} = this.props; - const shouldSetResponder = - (onStartShouldSetResponder == null - ? false - : onStartShouldSetResponder()) || isTouchable(this.props); - - if (shouldSetResponder) { - this._attachTouchHandlers(); - } - return shouldSetResponder; - }, - onResponderGrant: (event: PressEvent): void => { - nullthrows(this.touchableHandleResponderGrant)(event); - if (this.props.onResponderGrant != null) { - this.props.onResponderGrant.call(this, event); - } - }, - onResponderMove: (event: PressEvent): void => { - nullthrows(this.touchableHandleResponderMove)(event); - if (this.props.onResponderMove != null) { - this.props.onResponderMove.call(this, event); - } - }, - onResponderRelease: (event: PressEvent): void => { - nullthrows(this.touchableHandleResponderRelease)(event); - if (this.props.onResponderRelease != null) { - this.props.onResponderRelease.call(this, event); - } - }, - onResponderTerminate: (event: PressEvent): void => { - nullthrows(this.touchableHandleResponderTerminate)(event); - if (this.props.onResponderTerminate != null) { - this.props.onResponderTerminate.call(this, event); - } - }, - onResponderTerminationRequest: (): boolean => { - const {onResponderTerminationRequest} = this.props; - if (!nullthrows(this.touchableHandleResponderTerminationRequest)()) { - return false; - } - if (onResponderTerminationRequest == null) { - return true; - } - return onResponderTerminationRequest(); - }, - }; - } - - /** - * Lazily attaches Touchable.Mixin handlers. - */ - _attachTouchHandlers(): void { - if (this.touchableGetPressRectOffset != null) { - return; - } - for (const key in Touchable.Mixin) { - if (typeof Touchable.Mixin[key] === 'function') { - (this: any)[key] = Touchable.Mixin[key].bind(this); - } +const Text: React.AbstractComponent< + TextProps, + React.ElementRef, +> = React.forwardRef((props: TextProps, forwardedRef) => { + const { + accessible, + allowFontScaling, + ellipsizeMode, + onLongPress, + onPress, + onResponderGrant, + onResponderMove, + onResponderRelease, + onResponderTerminate, + onResponderTerminationRequest, + onStartShouldSetResponder, + pressRetentionOffset, + suppressHighlighting, + ...restProps + } = props; + + const [isHighlighted, setHighlighted] = useState(false); + + const isPressable = + onPress != null || onLongPress != null || onStartShouldSetResponder != null; + + const initialized = useLazyInitialization(isPressable); + const config = useMemo( + () => + initialized + ? { + disabled: !isPressable, + pressRectOffset: pressRetentionOffset, + onLongPress, + onPress, + onPressIn(event) { + setHighlighted(!suppressHighlighting); + }, + onPressOut(event) { + setHighlighted(false); + }, + onResponderTerminationRequest_DEPRECATED: onResponderTerminationRequest, + onStartShouldSetResponder_DEPRECATED: onStartShouldSetResponder, + } + : null, + [ + initialized, + isPressable, + pressRetentionOffset, + onLongPress, + onPress, + onResponderTerminationRequest, + onStartShouldSetResponder, + suppressHighlighting, + ], + ); + + const eventHandlers = usePressability(config); + const eventHandlersForText = useMemo( + () => + eventHandlers == null + ? null + : { + onResponderGrant(event) { + eventHandlers.onResponderGrant(event); + if (onResponderGrant != null) { + onResponderGrant(event); + } + }, + onResponderMove(event) { + eventHandlers.onResponderMove(event); + if (onResponderMove != null) { + onResponderMove(event); + } + }, + onResponderRelease(event) { + eventHandlers.onResponderRelease(event); + if (onResponderRelease != null) { + onResponderRelease(event); + } + }, + onResponderTerminate(event) { + eventHandlers.onResponderTerminate(event); + if (onResponderTerminate != null) { + onResponderTerminate(event); + } + }, + onResponderTerminationRequest: + eventHandlers.onResponderTerminationRequest, + onStartShouldSetResponder: eventHandlers.onStartShouldSetResponder, + }, + [ + eventHandlers, + onResponderGrant, + onResponderMove, + onResponderRelease, + onResponderTerminate, + ], + ); + + // TODO: Move this processing to the view configuration. + const selectionColor = + restProps.selectionColor == null + ? null + : processColor(restProps.selectionColor); + + let style = restProps.style; + if (__DEV__) { + if (PressabilityDebug.isEnabled() && onPress != null) { + style = StyleSheet.compose(restProps.style, { + color: 'magenta', + }); } - this.touchableHandleActivePressIn = (): void => { - if (!this.props.suppressHighlighting && isTouchable(this.props)) { - this.setState({isHighlighted: true}); - } - }; - this.touchableHandleActivePressOut = (): void => { - if (!this.props.suppressHighlighting && isTouchable(this.props)) { - this.setState({isHighlighted: false}); - } - }; - this.touchableHandlePress = (event: PressEvent): void => { - if (this.props.onPress != null) { - this.props.onPress(event); - } - }; - this.touchableHandleLongPress = (event: PressEvent): void => { - if (this.props.onLongPress != null) { - this.props.onLongPress(event); - } - }; - this.touchableGetPressRectOffset = (): PressRetentionOffset => - this.props.pressRetentionOffset == null - ? PRESS_RECT_OFFSET - : this.props.pressRetentionOffset; } -} -const isTouchable = (props: Props): boolean => - props.onPress != null || - props.onLongPress != null || - props.onStartShouldSetResponder != null; + const hasTextAncestor = useContext(TextAncestor); + + return hasTextAncestor ? ( + + ) : ( + + + + ); +}); -const Text: React.AbstractComponent< - TextProps, - React.ElementRef, -> = React.forwardRef( - ( - props: TextProps, - forwardedRef: ?React.Ref, - ) => { - return ; - }, -); Text.displayName = 'Text'; -// TODO: Deprecate this. -/* $FlowFixMe(>=0.89.0 site=react_native_fb) This comment suppresses an error - * found when Flow v0.89 was deployed. To see the error, delete this comment - * and run Flow. */ +// TODO: Delete this. Text.propTypes = DeprecatedTextPropTypes; -const TextToExport: typeof Text & - $ReadOnly<{| - propTypes: typeof DeprecatedTextPropTypes, - |}> = - // $FlowFixMe[incompatible-type] - No good way to type a React.AbstractComponent with statics. - TextInjection.unstable_Text == null ? Text : TextInjection.unstable_Text; +/** + * Returns false until the first time `newValue` is true, after which this will + * always return true. This is necessary to lazily initialize `Pressability` so + * we do not eagerly create one for every pressable `Text` component. + */ +function useLazyInitialization(newValue: boolean): boolean { + const [oldValue, setValue] = useState(newValue); + if (!oldValue && newValue) { + setValue(newValue); + } + return oldValue; +} -module.exports = TextToExport; +// $FlowFixMe[incompatible-cast] - No good way to type a React.AbstractComponent with statics. +module.exports = (Text: typeof Text & + $ReadOnly<{ + propTypes: typeof DeprecatedTextPropTypes, + }>);