diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index db4b764330730c..9b6a0c513c88cd 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -32,6 +32,8 @@ import type {SyntheticEvent, ScrollEvent} from '../../Types/CoreEventTypes'; import type {PressEvent} from '../../Types/CoreEventTypes'; import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; +const {useEffect, useRef, useState} = React; + type ReactRefSetter = {current: null | T, ...} | ((ref: null | T) => mixed); let AndroidTextInput; @@ -679,12 +681,6 @@ export type Props = $ReadOnly<{| >, |}>; -type DefaultProps = $ReadOnly<{| - allowFontScaling: boolean, - rejectResponderTermination: boolean, - underlineColorAndroid: 'transparent', -|}>; - type ImperativeMethods = $ReadOnly<{| clear: () => void, isFocused: () => boolean, @@ -693,6 +689,31 @@ type ImperativeMethods = $ReadOnly<{| const emptyFunctionThatReturnsTrue = () => true; +function useFocusOnMount( + initialAutoFocus: ?boolean, + inputRef: {| + current: null | React.ElementRef>, + |}, +) { + const initialAutoFocusValue = useRef(initialAutoFocus); + + useEffect(() => { + // We only want to autofocus on initial mount. + // Since initialAutoFocusValue and inputRef will never change + // this should match the expected behavior + if (initialAutoFocusValue.current) { + const rafId = requestAnimationFrame(() => { + if (inputRef.current != null) { + inputRef.current.focus(); + } + }); + + return () => { + cancelAnimationFrame(rafId); + }; + } + }, [initialAutoFocusValue, inputRef]); +} /** * A foundational component for inputting text into the app via a * keyboard. Props provide configurability for several features, such as @@ -804,310 +825,279 @@ const emptyFunctionThatReturnsTrue = () => true; * or control this param programmatically with native code. * */ -class InternalTextInput extends React.Component { - static defaultProps: DefaultProps = { - allowFontScaling: true, - rejectResponderTermination: true, - underlineColorAndroid: 'transparent', - }; - - _inputRef: null | React.ElementRef> = null; - _lastNativeText: ?Stringish = null; - _lastNativeSelection: ?Selection = null; - _rafId: ?AnimationFrameID = null; - - componentDidMount() { - this._lastNativeText = this.props.value; - const tag = ReactNative.findNodeHandle(this._inputRef); - if (tag != null) { - // tag is null only in unit tests - TextInputState.registerInput(tag); - } - - if (this.props.autoFocus) { - this._rafId = requestAnimationFrame(() => { - if (this._inputRef) { - this._inputRef.focus(); - } - }); - } - } +function InternalTextInput(props: Props): React.Node { + const inputRef = useRef>>(null); + + const selection: ?Selection = + props.selection == null + ? null + : { + start: props.selection.start, + end: props.selection.end ?? props.selection.start, + }; + + const [lastNativeText, setLastNativeText] = useState(props.value); + const [lastNativeSelection, setLastNativeSelection] = useState( + selection, + ); - componentDidUpdate() { - // This is necessary in case native updates the text and JS decides - // that the update should be ignored and we should stick with the value - // that we have in JS. - const nativeProps = {}; + // This is necessary in case native updates the text and JS decides + // that the update should be ignored and we should stick with the value + // that we have in JS. + useEffect(() => { + const nativeUpdate = {}; - if ( - this._lastNativeText !== this.props.value && - typeof this.props.value === 'string' - ) { - nativeProps.text = this.props.value; + if (lastNativeText !== props.value && typeof props.value === 'string') { + nativeUpdate.text = props.value; + setLastNativeText(props.value); } - // Selection is also a controlled prop, if the native value doesn't match - // JS, update to the JS value. - const {selection} = this.props; if ( - this._lastNativeSelection && selection && - (this._lastNativeSelection.start !== selection.start || - this._lastNativeSelection.end !== selection.end) + lastNativeSelection && + (lastNativeSelection.start !== selection.start || + lastNativeSelection.end !== selection.end) ) { - nativeProps.selection = this.props.selection; + nativeUpdate.selection = selection; + setLastNativeSelection(selection); } - if ( - Object.keys(nativeProps).length > 0 && - this._inputRef && - this._inputRef.setNativeProps - ) { - this._inputRef.setNativeProps(nativeProps); + if (Object.keys(nativeUpdate).length > 0 && inputRef.current) { + inputRef.current.setNativeProps(nativeUpdate); } - } + }, [inputRef, props.value, lastNativeText, selection, lastNativeSelection]); - componentWillUnmount() { - if (this.isFocused()) { - nullthrows(this._inputRef).blur(); - } - const tag = ReactNative.findNodeHandle(this._inputRef); + useFocusOnMount(props.autoFocus, inputRef); + + useEffect(() => { + const tag = ReactNative.findNodeHandle(inputRef.current); if (tag != null) { - TextInputState.unregisterInput(tag); - } - if (this._rafId != null) { - cancelAnimationFrame(this._rafId); - } - } + TextInputState.registerInput(tag); - /** - * Removes all text from the `TextInput`. - */ - clear: () => void = () => { - if (this._inputRef != null) { - this._inputRef.setNativeProps({text: ''}); + return () => { + TextInputState.unregisterInput(tag); + }; } - }; - - /** - * Returns `true` if the input is currently focused; `false` otherwise. - */ - isFocused: () => boolean = () => { - return ( - TextInputState.currentlyFocusedField() === - ReactNative.findNodeHandle(this._inputRef) - ); - }; + }, [inputRef]); - getNativeRef: () => ?React.ElementRef> = () => { - return this._inputRef; - }; - - render(): React.Node { - let textInput = null; - let additionalTouchableProps: {| - rejectResponderTermination?: $PropertyType< - Props, - 'rejectResponderTermination', - >, - // This is a hack to let Flow know we want an exact object - |} = {...null}; - - const selection = - this.props.selection && this.props.selection.end == null - ? { - start: this.props.selection.start, - end: this.props.selection.start, - } - : null; - - if (Platform.OS === 'ios') { - const RCTTextInputView = this.props.multiline - ? RCTMultilineTextInputView - : RCTSinglelineTextInputView; - - const style = this.props.multiline - ? [styles.multilineInput, this.props.style] - : this.props.style; - - additionalTouchableProps.rejectResponderTermination = this.props.rejectResponderTermination; - - textInput = ( - - ); - } else if (Platform.OS === 'android') { - const style = [this.props.style]; - const autoCapitalize = this.props.autoCapitalize || 'sentences'; - let children = this.props.children; - let childCount = 0; - React.Children.forEach(children, () => ++childCount); - invariant( - !(this.props.value && childCount), - 'Cannot specify both value and children.', - ); - if (childCount > 1) { - children = {children}; + useEffect(() => { + // When unmounting we need to blur the input + return () => { + if (isFocused()) { + nullthrows(inputRef.current).blur(); } + }; + }, [inputRef]); - textInput = ( - /* $FlowFixMe the types for AndroidTextInput don't match up exactly with - the props for TextInput. This will need to get fixed */ - - ); + function clear(): void { + if (inputRef.current != null) { + inputRef.current.setNativeProps({text: ''}); } + } + + // TODO: Fix this returning true on null === null, when no input is focused + function isFocused(): boolean { return ( - - - {textInput} - - + TextInputState.currentlyFocusedField() === + ReactNative.findNodeHandle(inputRef.current) ); } - _getText(): ?string { - return typeof this.props.value === 'string' - ? this.props.value - : typeof this.props.defaultValue === 'string' - ? this.props.defaultValue + function getNativeRef(): ?React.ElementRef> { + return inputRef.current; + } + + function _getText(): ?string { + return typeof props.value === 'string' + ? props.value + : typeof props.defaultValue === 'string' + ? props.defaultValue : ''; } - _setNativeRef = setAndForwardRef({ - getForwardedRef: () => this.props.forwardedRef, + const _setNativeRef = setAndForwardRef({ + getForwardedRef: () => props.forwardedRef, setLocalRef: ref => { - this._inputRef = ref; + inputRef.current = ref; /* - Hi reader from the future. I'm sorry for this. - - This is a hack. Ideally we would forwardRef to the underlying - host component. However, since TextInput has it's own methods that can be - called as well, if we used the standard forwardRef then these - methods wouldn't be accessible and thus be a breaking change. - - We have a couple of options of how to handle this: - - Return a new ref with everything we methods from both. This is problematic - because we need React to also know it is a host component which requires - internals of the class implementation of the ref. - - Break the API and have some other way to call one set of the methods or - the other. This is our long term approach as we want to eventually - get the methods on host components off the ref. So instead of calling - ref.measure() you might call ReactNative.measure(ref). This would hopefully - let the ref for TextInput then have the methods like `.clear`. Or we do it - the other way and make it TextInput.clear(textInputRef) which would be fine - too. Either way though is a breaking change that is longer term. - - Mutate this ref. :( Gross, but accomplishes what we need in the meantime - before we can get to the long term breaking change. - */ + Hi reader from the future. I'm sorry for this. + + This is a hack. Ideally we would forwardRef to the underlying + host component. However, since TextInput has it's own methods that can be + called as well, if we used the standard forwardRef then these + methods wouldn't be accessible and thus be a breaking change. + + We have a couple of options of how to handle this: + - Return a new ref with everything we methods from both. This is problematic + because we need React to also know it is a host component which requires + internals of the class implementation of the ref. + - Break the API and have some other way to call one set of the methods or + the other. This is our long term approach as we want to eventually + get the methods on host components off the ref. So instead of calling + ref.measure() you might call ReactNative.measure(ref). This would hopefully + let the ref for TextInput then have the methods like `.clear`. Or we do it + the other way and make it TextInput.clear(textInputRef) which would be fine + too. Either way though is a breaking change that is longer term. + - Mutate this ref. :( Gross, but accomplishes what we need in the meantime + before we can get to the long term breaking change. + */ if (ref) { - ref.clear = this.clear; - ref.isFocused = this.isFocused; - ref.getNativeRef = this.getNativeRef; + ref.clear = clear; + ref.isFocused = isFocused; + ref.getNativeRef = getNativeRef; } }, }); - _onPress = (event: PressEvent) => { - if (this.props.editable || this.props.editable === undefined) { - nullthrows(this._inputRef).focus(); + const _onPress = (event: PressEvent) => { + if (props.editable || props.editable === undefined) { + nullthrows(inputRef.current).focus(); } }; - _onChange = (event: ChangeEvent) => { + const _onChange = (event: ChangeEvent) => { // Make sure to fire the mostRecentEventCount first so it is already set on // native when the text value is set. - if (this._inputRef && this._inputRef.setNativeProps) { - this._inputRef.setNativeProps({ + if (inputRef.current) { + inputRef.current.setNativeProps({ mostRecentEventCount: event.nativeEvent.eventCount, }); } const text = event.nativeEvent.text; - this.props.onChange && this.props.onChange(event); - this.props.onChangeText && this.props.onChangeText(text); + props.onChange && props.onChange(event); + props.onChangeText && props.onChangeText(text); - if (!this._inputRef) { - // calling `this.props.onChange` or `this.props.onChangeText` + if (!inputRef.current) { + // calling `props.onChange` or `props.onChangeText` // may clean up the input itself. Exits here. return; } - this._lastNativeText = text; - this.forceUpdate(); + setLastNativeText(text); }; - _onSelectionChange = (event: SelectionChangeEvent) => { - this.props.onSelectionChange && this.props.onSelectionChange(event); + const _onSelectionChange = (event: SelectionChangeEvent) => { + props.onSelectionChange && props.onSelectionChange(event); - if (!this._inputRef) { - // calling `this.props.onSelectionChange` + if (!inputRef.current) { + // calling `props.onSelectionChange` // may clean up the input itself. Exits here. return; } - this._lastNativeSelection = event.nativeEvent.selection; - - if (this.props.selection) { - this.forceUpdate(); - } + setLastNativeSelection(event.nativeEvent.selection); }; - _onFocus = (event: FocusEvent) => { - TextInputState.focusField(ReactNative.findNodeHandle(this._inputRef)); - if (this.props.onFocus) { - this.props.onFocus(event); + const _onFocus = (event: FocusEvent) => { + TextInputState.focusField(ReactNative.findNodeHandle(inputRef.current)); + if (props.onFocus) { + props.onFocus(event); } }; - _onBlur = (event: BlurEvent) => { - TextInputState.blurField(ReactNative.findNodeHandle(this._inputRef)); - if (this.props.onBlur) { - this.props.onBlur(event); + const _onBlur = (event: BlurEvent) => { + TextInputState.blurField(ReactNative.findNodeHandle(inputRef.current)); + if (props.onBlur) { + props.onBlur(event); } }; - _onScroll = (event: ScrollEvent) => { - this.props.onScroll && this.props.onScroll(event); + const _onScroll = (event: ScrollEvent) => { + props.onScroll && props.onScroll(event); }; + + let textInput = null; + let additionalTouchableProps: {| + rejectResponderTermination?: $PropertyType< + Props, + 'rejectResponderTermination', + >, + // This is a hack to let Flow know we want an exact object + |} = {...null}; + + if (Platform.OS === 'ios') { + const RCTTextInputView = props.multiline + ? RCTMultilineTextInputView + : RCTSinglelineTextInputView; + + const style = props.multiline + ? [styles.multilineInput, props.style] + : props.style; + + additionalTouchableProps.rejectResponderTermination = + props.rejectResponderTermination; + + textInput = ( + + ); + } else if (Platform.OS === 'android') { + const style = [props.style]; + const autoCapitalize = props.autoCapitalize || 'sentences'; + let children = props.children; + let childCount = 0; + React.Children.forEach(children, () => ++childCount); + invariant( + !(props.value && childCount), + 'Cannot specify both value and children.', + ); + if (childCount > 1) { + children = {children}; + } + + textInput = ( + /* $FlowFixMe the types for AndroidTextInput don't match up exactly with + the props for TextInput. This will need to get fixed */ + + ); + } + return ( + + + {textInput} + + + ); } const ExportedForwardRef: React.AbstractComponent< diff --git a/RNTester/js/examples/TextInput/TextInputExample.ios.js b/RNTester/js/examples/TextInput/TextInputExample.ios.js index 2454cbbbb2ce15..7854bb3c122f51 100644 --- a/RNTester/js/examples/TextInput/TextInputExample.ios.js +++ b/RNTester/js/examples/TextInput/TextInputExample.ios.js @@ -181,6 +181,43 @@ class RewriteExampleInvalidCharacters extends React.Component< } } +class RewriteInvalidCharactersAndClearExample extends React.Component< + $FlowFixMeProps, + any, +> { + inputRef: ?React.ElementRef = null; + + constructor(props) { + super(props); + this.state = {text: ''}; + } + render() { + return ( + + { + this.inputRef = ref; + }} + multiline={false} + onChangeText={text => { + this.setState({text: text.replace(/\s/g, '')}); + }} + style={styles.default} + value={this.state.text} + /> +