From c3a673f6041a2c956157f084bc7fb47cc56e7112 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Fri, 17 Nov 2017 09:13:11 -0500 Subject: [PATCH] fix(TextInput): Support auto-grow for TextInput The best way to support variable size TextInput is with the auto-grow property. This PR updates the JS for the TextInput component and adds the necessary changes to support auto-grow on UWP. Towards #272 --- .../Components/TextInput/TextInput.windows.js | 797 ++++++++++++------ .../TextInput/ReactPasswordBoxManager.cs | 11 +- .../Views/TextInput/ReactTextInputManager.cs | 9 +- .../ReactNative.Shared.projitems | 1 + .../UIManager/LayoutShadowNode.cs | 4 +- .../UIManager/UIManagerModule.Constants.cs | 7 + .../Views/TextInput/ReactTextBox.cs | 47 +- .../Views/TextInput/ReactTextChangedEvent.cs | 17 +- .../ReactTextInputContentSizeChangedEvent.cs | 45 + .../TextInput/ReactTextInputShadowNode.cs | 43 +- .../TextInput/ReactPasswordBoxManager.cs | 2 - .../Views/TextInput/ReactTextInputManager.cs | 62 +- 12 files changed, 744 insertions(+), 301 deletions(-) create mode 100644 ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputContentSizeChangedEvent.cs diff --git a/Libraries/Components/TextInput/TextInput.windows.js b/Libraries/Components/TextInput/TextInput.windows.js index 142a7299e53..7d758b5bd24 100644 --- a/Libraries/Components/TextInput/TextInput.windows.js +++ b/Libraries/Components/TextInput/TextInput.windows.js @@ -11,35 +11,52 @@ */ 'use strict'; -var DocumentSelectionState = require('DocumentSelectionState'); -var EventEmitter = require('EventEmitter'); -var NativeMethodsMixin = require('NativeMethodsMixin'); -var Platform = require('Platform'); -var PropTypes = require('prop-types'); -var React = require('React'); -var createReactClass = require('create-react-class'); -var ReactNative = require('ReactNative'); -var StyleSheet = require('StyleSheet'); -var Text = require('Text'); -var TextInputState = require('TextInputState'); -var TimerMixin = require('react-timer-mixin'); -var TouchableWithoutFeedback = require('TouchableWithoutFeedback'); -var UIManager = require('UIManager'); -var ViewPropTypes = require('ViewPropTypes'); - -var PasswordBoxWindows = require('react-native-windows').PasswordBoxWindows; -var emptyFunction = require('fbjs/lib/emptyFunction'); -var invariant = require('fbjs/lib/invariant'); - -var requireNativeComponent = require('requireNativeComponent'); - -var onlyMultiline = { - onTextInput: true, // not supported in Open Source yet +const ColorPropType = require('ColorPropType'); +const DocumentSelectionState = require('DocumentSelectionState'); +const EventEmitter = require('EventEmitter'); +const NativeMethodsMixin = require('NativeMethodsMixin'); +const Platform = require('Platform'); +const React = require('React'); +const createReactClass = require('create-react-class'); +const PropTypes = require('prop-types'); +const ReactNative = require('ReactNative'); +const StyleSheet = require('StyleSheet'); +const Text = require('Text'); +const TextInputState = require('TextInputState'); +/* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error + * found when Flow v0.54 was deployed. To see the error delete this comment and + * run Flow. */ +const TimerMixin = require('react-timer-mixin'); +const TouchableWithoutFeedback = require('TouchableWithoutFeedback'); +const UIManager = require('UIManager'); +const ViewPropTypes = require('ViewPropTypes'); + +const PasswordBoxWindows = require('react-native-windows').PasswordBoxWindows; + +/* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error + * found when Flow v0.54 was deployed. To see the error delete this comment and + * run Flow. */ +const emptyFunction = require('fbjs/lib/emptyFunction'); +const invariant = require('fbjs/lib/invariant'); +const requireNativeComponent = require('requireNativeComponent'); +/* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error + * found when Flow v0.54 was deployed. To see the error delete this comment and + * run Flow. */ +const warning = require('fbjs/lib/warning'); + +const onlyMultiline = { + onTextInput: true, children: true, }; -var notMultiline = { - // nothing yet +const notSecureTextEntry = { + autoCorrect: true, + autoCapitalize: true, + autoGrow: true, + multiline: true, + onContentSizeChange: true, + onSelectionChange: true, + onTextInput: true, }; if (Platform.OS === 'android') { @@ -52,10 +69,19 @@ if (Platform.OS === 'android') { } type Event = Object; +type Selection = { + start: number, + end?: number, +}; -function notSupported(prop) { - console.warn(`this.props.${prop} is not supported when secureTextEntry is set to true`); -} +const DataDetectorTypes = [ + 'phoneNumber', + 'link', + 'address', + 'calendarEvent', + 'none', + 'all', +]; /** * A foundational component for inputting text into the app via a @@ -68,13 +94,33 @@ function notSupported(prop) { * such as `onSubmitEditing` and `onFocus` that can be subscribed to. A simple * example: * + * ```ReactNativeWebPlayer + * import React, { Component } from 'react'; + * import { AppRegistry, TextInput } from 'react-native'; + * + * export default class UselessTextInput extends Component { + * constructor(props) { + * super(props); + * this.state = { text: 'Useless Placeholder' }; + * } + * + * render() { + * return ( + * this.setState({text})} + * value={this.state.text} + * /> + * ); + * } + * } + * + * // skip this line if using Create React Native App + * AppRegistry.registerComponent('AwesomeProject', () => UselessTextInput); * ``` - * this.setState({text})} - * value={this.state.text} - * /> - * ``` + * + * Two methods exposed via the native element are .focus() and .blur() that + * will focus or blur the TextInput programmatically. * * Note that some props are only available with `multiline={true/false}`. * Additionally, border styles that apply to only one side of the element @@ -82,15 +128,75 @@ function notSupported(prop) { * `multiline=false`. To achieve the same effect, you can wrap your `TextInput` * in a `View`: * + * ```ReactNativeWebPlayer + * import React, { Component } from 'react'; + * import { AppRegistry, View, TextInput } from 'react-native'; + * + * class UselessTextInput extends Component { + * render() { + * return ( + * + * ); + * } + * } + * + * export default class UselessTextInputMultiline extends Component { + * constructor(props) { + * super(props); + * this.state = { + * text: 'Useless Multiline Placeholder', + * }; + * } + * + * // If you type something in the text box that is a color, the background will change to that + * // color. + * render() { + * return ( + * + * this.setState({text})} + * value={this.state.text} + * /> + * + * ); + * } + * } + * + * // skip these lines if using Create React Native App + * AppRegistry.registerComponent( + * 'AwesomeProject', + * () => UselessTextInputMultiline + * ); * ``` - * - * - * - * ``` + * + * `TextInput` has by default a border at the bottom of its view. This border + * has its padding set by the background image provided by the system, and it + * cannot be changed. Solutions to avoid this is to either not set height + * explicitly, case in which the system will take care of displaying the border + * in the correct position, or to not display the border by setting + * `underlineColorAndroid` to transparent. + * + * Note that on Android performing text selection in input can change + * app's activity `windowSoftInputMode` param to `adjustResize`. + * This may cause issues with components that have position: 'absolute' + * while keyboard is active. To avoid this behavior either specify `windowSoftInputMode` + * in AndroidManifest.xml ( https://developer.android.com/guide/topics/manifest/activity-element.html ) + * or control this param programmatically with native code. + * */ -var TextInput = createReactClass({ - displayName: 'TextInput', +const TextInput = createReactClass({ + displayName: 'TextInput', statics: { /* TODO(brentvatne) docs are needed for this */ State: TextInputState, @@ -99,12 +205,12 @@ var TextInput = createReactClass({ propTypes: { ...ViewPropTypes, /** - * Can tell TextInput to automatically capitalize certain characters. + * Can tell `TextInput` to automatically capitalize certain characters. * - * - characters: all characters, - * - words: first letter of each word - * - sentences: first letter of each sentence (default) - * - none: don't auto capitalize anything + * - `characters`: all characters. + * - `words`: first letter of each word. + * - `sentences`: first letter of each sentence (*default*). + * - `none`: don't auto capitalize anything. */ autoCapitalize: PropTypes.oneOf([ 'none', @@ -113,16 +219,35 @@ var TextInput = createReactClass({ 'characters', ]), /** - * If false, disables auto-correct. The default value is true. + * If `false`, disables auto-correct. The default value is `true`. */ autoCorrect: PropTypes.bool, /** - * If true, focuses the input on componentDidMount. - * The default value is false. + * If `false`, disables spell-check style (i.e. red underlines). + * The default value is inherited from `autoCorrect`. + * @platform ios + */ + spellCheck: PropTypes.bool, + /** + * If `true`, focuses the input on `componentDidMount`. + * The default value is `false`. */ autoFocus: PropTypes.bool, /** - * If false, text is not editable. The default value is true. + * If true, will increase the height of the textbox if need be. If false, + * the textbox will become scrollable once the height is reached. The + * default value is false. + * @platform android + * @platform windows + */ + autoGrow: PropTypes.bool, + /** + * Specifies whether fonts should scale to respect Text Size accessibility settings. The + * default is `true`. + */ + allowFontScaling: PropTypes.bool, + /** + * If `false`, text is not editable. The default value is `true`. */ editable: PropTypes.bool, /** @@ -130,9 +255,29 @@ var TextInput = createReactClass({ * * The following values work across platforms: * - * - default - * - numeric - * - email-address + * - `default` + * - `numeric` + * - `email-address` + * - `phone-pad` + * + * *iOS Only* + * + * The following values work on iOS only: + * + * - `ascii-capable` + * - `numbers-and-punctuation` + * - `url` + * - `number-pad` + * - `name-phone-pad` + * - `decimal-pad` + * - `twitter` + * - `web-search` + * + * *Android Only* + * + * The following values work on Android only: + * + * - `visible-password` */ keyboardType: PropTypes.oneOf([ // Cross-platform @@ -140,15 +285,18 @@ var TextInput = createReactClass({ 'email-address', 'numeric', 'phone-pad', - // iOS-only - 'ascii-capable', - 'numbers-and-punctuation', + // iOS and Windows-only 'url', 'number-pad', 'name-phone-pad', 'decimal-pad', - 'twitter', 'web-search', + // iOS-only + 'ascii-capable', + 'numbers-and-punctuation', + 'twitter', + // Android-only + 'visible-password', ]), /** * Determines the color of the keyboard. @@ -163,27 +311,33 @@ var TextInput = createReactClass({ * Determines how the return key should look. On Android you can also use * `returnKeyLabel`. * + * *Cross platform* + * * The following values work across platforms: * - * - done - * - go - * - next - * - search - * - send + * - `done` + * - `go` + * - `next` + * - `search` + * - `send` + * + * *Android Only* * * The following values work on Android only: * - * - none - * - previous + * - `none` + * - `previous` + * + * *iOS Only* * * The following values work on iOS only: * - * - default - * - emergency-call - * - google - * - join - * - route - * - yahoo + * - `default` + * - `emergency-call` + * - `google` + * - `join` + * - `route` + * - `yahoo` */ returnKeyType: PropTypes.oneOf([ // Cross-platform @@ -207,35 +361,55 @@ var TextInput = createReactClass({ * Sets the return key to the label. Use it instead of `returnKeyType`. * @platform android */ - returnKeyLabel: PropTypes.string, + returnKeyLabel: PropTypes.string, /** * Limits the maximum number of characters that can be entered. Use this * instead of implementing the logic in JS to avoid flicker. */ maxLength: PropTypes.number, /** - * Sets the number of lines for a TextInput. Use it with multiline set to - * true to be able to fill the lines. + * If autogrow is `true`, limits the height that the TextInput box can grow + * to. Once it reaches this height, the TextInput becomes scrollable. + */ + maxHeight: PropTypes.number, + /** + * Sets the number of lines for a `TextInput`. Use it with multiline set to + * `true` to be able to fill the lines. * @platform android */ numberOfLines: PropTypes.number, /** - * If true, the keyboard disables the return key when there is no text and - * automatically enables it when there is text. The default value is false. + * When `false`, if there is a small amount of space available around a text input + * (e.g. landscape orientation on a phone), the OS may choose to have the user edit + * the text inside of a full screen text input mode. When `true`, this feature is + * disabled and users will always edit the text directly inside of the text input. + * Defaults to `false`. + * @platform android + */ + disableFullscreenUI: PropTypes.bool, + /** + * If `true`, the keyboard disables the return key when there is no text and + * automatically enables it when there is text. The default value is `false`. * @platform ios */ enablesReturnKeyAutomatically: PropTypes.bool, /** - * If true, the text input can be multiple lines. - * The default value is false. + * If `true`, the text input can be multiple lines. + * The default value is `false`. */ multiline: PropTypes.bool, /** - * Callback that is called when the text input is blurred + * Set text break strategy on Android API Level 23+, possible values are `simple`, `highQuality`, `balanced` + * The default value is `simple`. + * @platform android + */ + textBreakStrategy: PropTypes.oneOf(['simple', 'highQuality', 'balanced']), + /** + * Callback that is called when the text input is blurred. */ onBlur: PropTypes.func, /** - * Callback that is called when the text input is focused + * Callback that is called when the text input is focused. */ onFocus: PropTypes.func, /** @@ -247,23 +421,35 @@ var TextInput = createReactClass({ * Changed text is passed as an argument to the callback handler. */ onChangeText: PropTypes.func, + /** + * Callback that is called when the text input's content size changes. + * This will be called with + * `{ nativeEvent: { contentSize: { width, height } } }`. + * + * Only called for multiline text inputs. + */ + onContentSizeChange: PropTypes.func, /** * Callback that is called when text input ends. */ onEndEditing: PropTypes.func, /** - * Callback that is called when the text input selection is changed + * Callback that is called when the text input selection is changed. + * This will be called with + * `{ nativeEvent: { selection: { start, end } } }`. */ onSelectionChange: PropTypes.func, /** * Callback that is called when the text input's submit button is pressed. - * Invalid if multiline={true} is specified. + * Invalid if `multiline={true}` is specified. */ onSubmitEditing: PropTypes.func, /** * Callback that is called when a key is pressed. - * Pressed key value is passed as an argument to the callback handler. - * Fires before onChange callbacks. + * This will be called with `{ nativeEvent: { key: keyValue } }` + * where `keyValue` is `'Enter'` or `'Backspace'` for respective keys and + * the typed-in character otherwise including `' '` for space. + * Fires before `onChange` callbacks. * @platform ios */ onKeyPress: PropTypes.func, @@ -272,32 +458,56 @@ var TextInput = createReactClass({ */ onLayout: PropTypes.func, /** - * The string that will be rendered before text input has been entered + * Invoked on content scroll with `{ nativeEvent: { contentOffset: { x, y } } }`. + * May also contain other properties from ScrollEvent but on Android contentSize + * is not provided for performance reasons. + */ + onScroll: PropTypes.func, + /** + * The string that will be rendered before text input has been entered. */ placeholder: PropTypes.string, /** - * The text color of the placeholder string + * The text color of the placeholder string. */ - placeholderTextColor: PropTypes.string, + placeholderTextColor: ColorPropType, /** - * If true, the text input obscures the text entered so that sensitive text - * like passwords stay secure. The default value is false. + * If `true`, the text input obscures the text entered so that sensitive text + * like passwords stay secure. The default value is `false`. Does not work with 'multiline={true}'. */ secureTextEntry: PropTypes.bool, /** - * The highlight (and cursor on ios) color of the text input + * The highlight and cursor color of the text input. */ - selectionColor: PropTypes.string, + selectionColor: ColorPropType, /** - * See DocumentSelectionState.js, some state that is responsible for - * maintaining selection information for a document + * An instance of `DocumentSelectionState`, this is some state that is responsible for + * maintaining selection information for a document. + * + * Some functionality that can be performed with this instance is: + * + * - `blur()` + * - `focus()` + * - `update()` + * + * > You can reference `DocumentSelectionState` in + * > [`vendor/document/selection/DocumentSelectionState.js`](https://github.com/facebook/react-native/blob/master/Libraries/vendor/document/selection/DocumentSelectionState.js) + * * @platform ios */ selectionState: PropTypes.instanceOf(DocumentSelectionState), /** - * The value to show for the text input. TextInput is a controlled + * The start and end of the text input's selection. Set start and end to + * the same value to position the cursor. + */ + selection: PropTypes.shape({ + start: PropTypes.number.isRequired, + end: PropTypes.number, + }), + /** + * The value to show for the text input. `TextInput` is a controlled * component, which means the native value will be forced to match this - * value prop if provided. For most uses this works great, but in some + * value prop if provided. For most uses, this works great, but in some * cases this may cause flickering - one common cause is preventing edits * by keeping value the same. In addition to simply setting the same value, * either set `editable={false}`, or set/update `maxLength` to prevent @@ -306,12 +516,12 @@ var TextInput = createReactClass({ value: PropTypes.string, /** * Provides an initial value that will change when the user starts typing. - * Useful for simple use-cases where you don't want to deal with listening + * Useful for simple use-cases where you do not want to deal with listening * to events and updating the value prop to keep the controlled state in sync. */ defaultValue: PropTypes.string, /** - * When the clear button should appear on the right side of the text view + * When the clear button should appear on the right side of the text view. * @platform ios */ clearButtonMode: PropTypes.oneOf([ @@ -321,54 +531,113 @@ var TextInput = createReactClass({ 'always', ]), /** - * If true, clears the text field automatically when editing begins + * If `true`, clears the text field automatically when editing begins. * @platform ios */ clearTextOnFocus: PropTypes.bool, /** - * If true, all text will automatically be selected on focus + * If `true`, all text will automatically be selected on focus. */ selectTextOnFocus: PropTypes.bool, /** - * If true, the text field will blur when submitted. + * If `true`, the text field will blur when submitted. * The default value is true for single-line fields and false for - * multiline fields. Note that for multiline fields, setting blurOnSubmit - * to true means that pressing return will blur the field and trigger the - * onSubmitEditing event instead of inserting a newline into the field. + * multiline fields. Note that for multiline fields, setting `blurOnSubmit` + * to `true` means that pressing return will blur the field and trigger the + * `onSubmitEditing` event instead of inserting a newline into the field. */ blurOnSubmit: PropTypes.bool, /** - * Styles + * Note that not all Text styles are supported, an incomplete list of what is not supported includes: + * + * - `borderLeftWidth` + * - `borderTopWidth` + * - `borderRightWidth` + * - `borderBottomWidth` + * - `borderTopLeftRadius` + * - `borderTopRightRadius` + * - `borderBottomRightRadius` + * - `borderBottomLeftRadius` + * + * see [Issue#7070](https://github.com/facebook/react-native/issues/7070) + * for more detail. + * + * [Styles](docs/style.html) */ style: Text.propTypes.style, /** - * The color of the textInput underline. + * The color of the `TextInput` underline. * @platform android */ - underlineColorAndroid: PropTypes.string, - }, + underlineColorAndroid: ColorPropType, + + /** + * If defined, the provided image resource will be rendered on the left. + * The image resource must be inside `/android/app/src/main/res/drawable` and referenced + * like + * ``` + * + * ``` + * @platform android + */ + inlineImageLeft: PropTypes.string, + + /** + * Padding between the inline image, if any, and the text input itself. + * @platform android + */ + inlineImagePadding: PropTypes.number, + /** + * Determines the types of data converted to clickable URLs in the text input. + * Only valid if `multiline={true}` and `editable={false}`. + * By default no data types are detected. + * + * You can provide one type or an array of many types. + * + * Possible values for `dataDetectorTypes` are: + * + * - `'phoneNumber'` + * - `'link'` + * - `'address'` + * - `'calendarEvent'` + * - `'none'` + * - `'all'` + * + * @platform ios + */ + dataDetectorTypes: PropTypes.oneOfType([ + PropTypes.oneOf(DataDetectorTypes), + PropTypes.arrayOf(PropTypes.oneOf(DataDetectorTypes)), + ]), + /** + * If `true`, caret is hidden. The default value is `false`. + */ + caretHidden: PropTypes.bool, + }, + getDefaultProps(): Object { + return { + allowFontScaling: true, + }; + }, /** * `NativeMethodsMixin` will look for this when invoking `setNativeProps`. We * make `this` look like an actual native component class. */ mixins: [NativeMethodsMixin, TimerMixin], - viewConfig: - ((Platform.OS === 'ios' && RCTTextField ? - RCTTextField.viewConfig : - (Platform.OS === 'android' && AndroidTextInput ? - AndroidTextInput.viewConfig : - (Platform.OS === 'windows' && RCTTextBox ? - RCTTextBox.viewConfig : - {}))) : Object), + getInitialState: function() { + return {layoutHeight: this._layoutHeight}; + }, /** - * Returns if the input is currently focused. + * Returns `true` if the input is currently focused; `false` otherwise. */ isFocused: function(): boolean { return TextInputState.currentlyFocusedField() === - ReactNative.findNodeHandle(this.refs.input); + ReactNative.findNodeHandle(this._inputRef); }, contextTypes: { @@ -376,8 +645,11 @@ var TextInput = createReactClass({ focusEmitter: PropTypes.instanceOf(EventEmitter), }, + _inputRef: (undefined: any), _focusSubscription: (undefined: ?Function), _lastNativeText: (undefined: ?string), + _lastNativeSelection: (undefined: ?Selection), + _layoutHeight: (-1: number), componentDidMount: function() { this._lastNativeText = this.props.value; @@ -418,7 +690,7 @@ var TextInput = createReactClass({ }, /** - * Removes all text from the input. + * Removes all text from the `TextInput`. */ clear: function() { this.setNativeProps({text: ''}); @@ -437,53 +709,50 @@ var TextInput = createReactClass({ _getText: function(): ?string { return typeof this.props.value === 'string' ? this.props.value : - this.props.defaultValue; + ( + typeof this.props.defaultValue === 'string' ? + this.props.defaultValue : + '' + ); + }, + + _setNativeRef: function(ref: any) { + this._inputRef = ref; }, _renderIOS: function() { var textContainer; - var onSelectionChange; - if (this.props.selectionState || this.props.onSelectionChange) { - onSelectionChange = (event: Event) => { - if (this.props.selectionState) { - var selection = event.nativeEvent.selection; - this.props.selectionState.update(selection.start, selection.end); - } - this.props.onSelectionChange && this.props.onSelectionChange(event); - }; + var props = Object.assign({}, this.props); + props.style = [this.props.style]; + + if (props.selection && props.selection.end == null) { + props.selection = {start: props.selection.start, end: props.selection.start}; } - var props = Object.assign({}, this.props); - props.style = [styles.input, this.props.style]; if (!props.multiline) { - for (var propKey in onlyMultiline) { - if (props[propKey]) { - throw new Error( - 'TextInput prop `' + propKey + '` is only supported with multiline.' - ); + if (__DEV__) { + for (var propKey in onlyMultiline) { + if (props[propKey]) { + const error = new Error( + 'TextInput prop `' + propKey + '` is only supported with multiline.' + ); + warning(false, '%s', error.stack); + } } } textContainer = ; } else { - for (var propKey in notMultiline) { - if (props[propKey]) { - throw new Error( - 'TextInput prop `' + propKey + '` cannot be used with multiline.' - ); - } - } - var children = props.children; var childCount = 0; React.Children.forEach(children, () => ++childCount); @@ -492,33 +761,38 @@ var TextInput = createReactClass({ 'Cannot specify both value and children.' ); if (childCount >= 1) { - children = {children}; + children = {children}; } if (props.inputView) { children = [children, props.inputView]; } + props.style.unshift(styles.multilineInput); textContainer = ; } - return ( {textContainer} @@ -526,19 +800,18 @@ var TextInput = createReactClass({ }, _renderAndroid: function() { - var onSelectionChange; - if (this.props.selectionState || this.props.onSelectionChange) { - onSelectionChange = (event: Event) => { - if (this.props.selectionState) { - var selection = event.nativeEvent.selection; - this.props.selectionState.update(selection.start, selection.end); - } - this.props.onSelectionChange && this.props.onSelectionChange(event); - }; + const props = Object.assign({}, this.props); + props.style = this.props.style; + if (this.state.layoutHeight >= 0) { + props.style = [props.style, {height: this.state.layoutHeight}]; } - - var autoCapitalize = - UIManager.AndroidTextInput.Constants.AutoCapitalizationType[this.props.autoCapitalize]; + props.autoCapitalize = + UIManager.AndroidTextInput.Constants.AutoCapitalizationType[ + props.autoCapitalize || 'sentences' + ]; + /* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This comment + * suppresses an error when upgrading Flow's support for React. To see the + * error delete this comment and run Flow. */ var children = this.props.children; var childCount = 0; React.Children.forEach(children, () => ++childCount); @@ -549,46 +822,35 @@ var TextInput = createReactClass({ if (childCount > 1) { children = {children}; } - - var textContainer = + if (props.selection && props.selection.end == null) { + props.selection = {start: props.selection.start, end: props.selection.start}; + } + const textContainer = ; return ( {textContainer} @@ -596,38 +858,28 @@ var TextInput = createReactClass({ }, _renderWindows: function() { - - if (this.props.secureTextEntry) { - // Warn if using properties not supported during secureTextEntry - if (this.props.onSelectionChange) { - notSupported('onSelectionChange'); - } - - if (this.props.autoCorrect) { - notSupported('autoCorrect'); - } - - if (this.props.autoCapitalize) { - notSupported('autoCapitalize'); - } - - if (this.props.multiline) { - notSupported('multiline'); - } + + const props = Object.assign({}, this.props); + props.style = this.props.style; + if (this.state.layoutHeight >= 0) { + props.style = [props.style, {height: this.state.layoutHeight}]; } - var onSelectionChange; - if (this.props.selectionState || this.props.onSelectionChange) { - onSelectionChange = (event: Event) => { - if (this.props.selectionState) { - var selection = event.nativeEvent.selection; - this.props.selectionState.update(selection.start, selection.end); + if (props.secureTextEntry) { + // Warn if using properties not supported during secureTextEntry + if (__DEV__) { + for (var propKey in notSecureTextEntry) { + if (props[propKey]) { + const error = new Error( + 'TextInput prop `' + propKey + '` is not supported with secureTextEntry.' + ); + warning(false, '%s', error.stack); + } } - this.props.onSelectionChange && this.props.onSelectionChange(event); - }; + } } - var children = this.props.children; + var children = props.children; var childCount = 0; React.Children.forEach(children, () => ++childCount); invariant( @@ -635,62 +887,43 @@ var TextInput = createReactClass({ 'TextInput children are not supported on Windows.' ); - var textContainer; - if (this.props.secureTextEntry) { + let textContainer; + if (props.secureTextEntry) { textContainer = ; } else { textContainer = ; } return ( {textContainer} @@ -716,15 +949,17 @@ var TextInput = createReactClass({ _onChange: function(event: Event) { // Make sure to fire the mostRecentEventCount first so it is already set on // native when the text value is set. - this.refs.input.setNativeProps({ - mostRecentEventCount: event.nativeEvent.eventCount, - }); + if (this._inputRef) { + this._inputRef.setNativeProps({ + mostRecentEventCount: event.nativeEvent.eventCount, + }); + } var text = event.nativeEvent.text; this.props.onChange && this.props.onChange(event); this.props.onChangeText && this.props.onChangeText(text); - if (!this.refs.input) { + if (!this._inputRef) { // calling `this.props.onChange` or `this.props.onChangeText` // may clean up the input itself. Exits here. return; @@ -734,14 +969,67 @@ var TextInput = createReactClass({ this.forceUpdate(); }, + _onContentSizeChange: function(event: Event) { + let contentHeight = event.nativeEvent.contentSize.height; + if (this.props.autoGrow) { + if (this.props.maxHeight) { + contentHeight = Math.min(this.props.maxHeight, contentHeight); + } + this.setState({layoutHeight: Math.max(this._layoutHeight, contentHeight)}); + } + + this.props.onContentSizeChange && this.props.onContentSizeChange(event); + }, + + _onLayout: function(event: Event) { + const height = event.nativeEvent.layout.height; + if (height) { + this._layoutHeight = event.nativeEvent.layout.height; + } + this.props.onLayout && this.props.onLayout(event); + }, + + _onSelectionChange: function(event: Event) { + this.props.onSelectionChange && this.props.onSelectionChange(event); + + if (!this._inputRef) { + // calling `this.props.onSelectionChange` + // may clean up the input itself. Exits here. + return; + } + + this._lastNativeSelection = event.nativeEvent.selection; + + if (this.props.selection || this.props.selectionState) { + this.forceUpdate(); + } + }, + componentDidUpdate: function () { // 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 = {}; + if (this._lastNativeText !== this.props.value && typeof this.props.value === 'string') { - this.refs.input.setNativeProps({ - text: this.props.value, - }); + nativeProps.text = this.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)) { + nativeProps.selection = this.props.selection; + } + + if (Object.keys(nativeProps).length > 0 && this._inputRef) { + this._inputRef.setNativeProps(nativeProps); + } + + if (this.props.selectionState && selection) { + this.props.selectionState.update(selection.start, selection.end); } }, @@ -759,11 +1047,18 @@ var TextInput = createReactClass({ _onTextInput: function(event: Event) { this.props.onTextInput && this.props.onTextInput(event); }, + + _onScroll: function(event: Event) { + this.props.onScroll && this.props.onScroll(event); + }, }); var styles = StyleSheet.create({ - input: { - alignSelf: 'stretch', + multilineInput: { + // This default top inset makes RCTTextView seem as close as possible + // to single-line RCTTextField defaults, using the system defaults + // of font size 17 and a height of 31 points. + paddingTop: 5, }, }); diff --git a/ReactWindows/ReactNative.Net46/Views/TextInput/ReactPasswordBoxManager.cs b/ReactWindows/ReactNative.Net46/Views/TextInput/ReactPasswordBoxManager.cs index abb0250d256..cffa350075a 100644 --- a/ReactWindows/ReactNative.Net46/Views/TextInput/ReactPasswordBoxManager.cs +++ b/ReactWindows/ReactNative.Net46/Views/TextInput/ReactPasswordBoxManager.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using ReactNative.Reflection; using ReactNative.UIManager; using ReactNative.UIManager.Annotations; @@ -399,10 +399,9 @@ public override void OnDropViewInstance(ThemedReactContext reactContext, Passwor /// The output buffer. public override void SetDimensions(PasswordBox view, Dimensions dimensions) { - Canvas.SetLeft(view, dimensions.X); - Canvas.SetTop(view, dimensions.Y); - view.Width = dimensions.Width; - view.Height = dimensions.Height; + base.SetDimensions(view, dimensions); + view.MinWidth = dimensions.Width; + view.MinHeight = dimensions.Height; } private void OnPasswordChanged(object sender, RoutedEventArgs e) @@ -415,8 +414,6 @@ private void OnPasswordChanged(object sender, RoutedEventArgs e) new ReactTextChangedEvent( textBox.GetTag(), textBox.Password, - textBox.ActualWidth, - textBox.ActualHeight, 0)); } diff --git a/ReactWindows/ReactNative.Net46/Views/TextInput/ReactTextInputManager.cs b/ReactWindows/ReactNative.Net46/Views/TextInput/ReactTextInputManager.cs index b6f18409668..e8bbd51c326 100644 --- a/ReactWindows/ReactNative.Net46/Views/TextInput/ReactTextInputManager.cs +++ b/ReactWindows/ReactNative.Net46/Views/TextInput/ReactTextInputManager.cs @@ -475,10 +475,9 @@ public override void OnDropViewInstance(ThemedReactContext reactContext, ReactTe public override void SetDimensions(ReactTextBox view, Dimensions dimensions) { - Canvas.SetLeft(view, dimensions.X); - Canvas.SetTop(view, dimensions.Y); - view.Width = dimensions.Width; - view.Height = dimensions.Height; + base.SetDimensions(view, dimensions); + view.MinWidth = dimensions.Width; + view.MinHeight = dimensions.Height; } /// @@ -518,8 +517,6 @@ private void OnTextChanged(object sender, TextChangedEventArgs e) new ReactTextChangedEvent( textBox.GetTag(), textBox.Text, - textBox.ActualWidth, - textBox.ActualHeight, textBox.CurrentEventCount)); } diff --git a/ReactWindows/ReactNative.Shared/ReactNative.Shared.projitems b/ReactWindows/ReactNative.Shared/ReactNative.Shared.projitems index ee68c19fb91..106a1cc0244 100644 --- a/ReactWindows/ReactNative.Shared/ReactNative.Shared.projitems +++ b/ReactWindows/ReactNative.Shared/ReactNative.Shared.projitems @@ -223,6 +223,7 @@ + diff --git a/ReactWindows/ReactNative.Shared/UIManager/LayoutShadowNode.cs b/ReactWindows/ReactNative.Shared/UIManager/LayoutShadowNode.cs index 556ac521ad5..2cb02706f6f 100644 --- a/ReactWindows/ReactNative.Shared/UIManager/LayoutShadowNode.cs +++ b/ReactWindows/ReactNative.Shared/UIManager/LayoutShadowNode.cs @@ -1,4 +1,4 @@ -using Facebook.Yoga; +using Facebook.Yoga; using Newtonsoft.Json.Linq; using ReactNative.Reflection; using ReactNative.UIManager.Annotations; @@ -111,7 +111,7 @@ public void SetMinHeight(JValue minHeight) /// /// The maximum height. [ReactProp(ViewProps.MaxHeight, DefaultSingle = YogaConstants.Undefined)] - public void SetMaxHeight(JValue maxHeight) + public virtual void SetMaxHeight(JValue maxHeight) { if (IsVirtual) { diff --git a/ReactWindows/ReactNative.Shared/UIManager/UIManagerModule.Constants.cs b/ReactWindows/ReactNative.Shared/UIManager/UIManagerModule.Constants.cs index 460fff34526..7f2d5edc614 100644 --- a/ReactWindows/ReactNative.Shared/UIManager/UIManagerModule.Constants.cs +++ b/ReactWindows/ReactNative.Shared/UIManager/UIManagerModule.Constants.cs @@ -280,6 +280,13 @@ private static Dictionary GetDirectEventTypeConstants() { "registrationName", "onLoadingError" }, } }, + { + "topContentSizeChange", + new Map + { + { "registrationName", "onContentSizeChange" }, + } + }, { "topLayout", new Map diff --git a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextBox.cs b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextBox.cs index a544ec78a78..939510aafdc 100644 --- a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextBox.cs +++ b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextBox.cs @@ -14,6 +14,7 @@ class ReactTextBox : TextBox { private int _eventCount; private bool _selectionChangedSubscribed; + private bool _sizeChangedSubscribed; public int CurrentEventCount { @@ -48,16 +49,45 @@ public bool OnSelectionChange _selectionChangedSubscribed = value; if (_selectionChangedSubscribed) { - this.SelectionChanged += OnSelectionChanged; + SelectionChanged += OnSelectionChanged; } else { - this.SelectionChanged -= OnSelectionChanged; + SelectionChanged -= OnSelectionChanged; } } } } + public bool OnContentSizeChange + { + get + { + return _sizeChangedSubscribed; + } + set + { + if (value != _sizeChangedSubscribed) + { + _sizeChangedSubscribed = value; + if (_sizeChangedSubscribed) + { + SizeChanged += OnSizeChanged; + } + else + { + SizeChanged -= OnSizeChanged; + } + } + } + } + + public bool AutoGrow + { + get; + set; + } + public int IncrementEventCount() { return Interlocked.Increment(ref _eventCount); @@ -66,7 +96,6 @@ public int IncrementEventCount() protected override void OnGotFocus(RoutedEventArgs e) { base.OnGotFocus(e); - SizeChanged += OnSizeChanged; if (ClearTextOnFocus) { @@ -80,24 +109,16 @@ protected override void OnGotFocus(RoutedEventArgs e) } } - protected override void OnLostFocus(RoutedEventArgs e) - { - base.OnLostFocus(e); - SizeChanged -= OnSizeChanged; - } - private void OnSizeChanged(object sender, SizeChangedEventArgs e) { this.GetReactContext() .GetNativeModule() .EventDispatcher .DispatchEvent( - new ReactTextChangedEvent( + new ReactTextInputContentSizeChangedEvent( this.GetTag(), - Text, e.NewSize.Width, - e.NewSize.Height, - IncrementEventCount())); + e.NewSize.Height)); } private void OnSelectionChanged(object sender, RoutedEventArgs e) diff --git a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextChangedEvent.cs b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextChangedEvent.cs index 8587f80948d..805f9f95764 100644 --- a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextChangedEvent.cs +++ b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextChangedEvent.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using ReactNative.UIManager.Events; using System; @@ -11,8 +11,6 @@ namespace ReactNative.Views.TextInput class ReactTextChangedEvent : Event { private readonly string _text; - private readonly double _contextWidth; - private readonly double _contentHeight; private readonly int _eventCount; /// @@ -20,15 +18,11 @@ class ReactTextChangedEvent : Event /// /// The view tag. /// The text. - /// The content width. - /// The content height. /// The event count. - public ReactTextChangedEvent(int viewTag, string text, double contentWidth, double contentHeight, int eventCount) + public ReactTextChangedEvent(int viewTag, string text, int eventCount) : base(viewTag) { _text = text; - _contextWidth = contentWidth; - _contentHeight = contentHeight; _eventCount = eventCount; } @@ -63,16 +57,9 @@ public override bool CanCoalesce /// The event emitter. public override void Dispatch(RCTEventEmitter rctEventEmitter) { - var contentSize = new JObject - { - { "width", _contextWidth }, - { "height", _contentHeight }, - }; - var eventData = new JObject { { "text", _text }, - { "contentSize", contentSize }, { "eventCount", _eventCount }, { "target", ViewTag }, }; diff --git a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputContentSizeChangedEvent.cs b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputContentSizeChangedEvent.cs new file mode 100644 index 00000000000..58f27b891ad --- /dev/null +++ b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputContentSizeChangedEvent.cs @@ -0,0 +1,45 @@ +using Newtonsoft.Json.Linq; +using ReactNative.UIManager.Events; + +namespace ReactNative.Views.TextInput +{ + class ReactTextInputContentSizeChangedEvent : Event + { + private readonly double _width; + private readonly double _height; + + public ReactTextInputContentSizeChangedEvent( + int viewTag, + double width, + double height) + : base(viewTag) + { + _height = height; + _width = width; + } + + public override string EventName + { + get + { + return "topContentSizeChange"; + } + } + + public override void Dispatch(RCTEventEmitter eventEmitter) + { + eventEmitter.receiveEvent(ViewTag, EventName, new JObject + { + { "target", ViewTag }, + { + "contentSize", + new JObject + { + { "width", _width }, + { "height", _height }, + } + }, + }); + } + } +} diff --git a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputShadowNode.cs b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputShadowNode.cs index cffe5dc6a70..6aa7dc44305 100644 --- a/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputShadowNode.cs +++ b/ReactWindows/ReactNative.Shared/Views/TextInput/ReactTextInputShadowNode.cs @@ -41,11 +41,13 @@ public class ReactTextInputShadowNode : LayoutShadowNode private float[] _computedPadding; private bool _multiline; + private bool _autoGrow; private int _letterSpacing; private int _numberOfLines; private double _fontSize = Unset; private double _lineHeight; + private double? _maxHeight; private FontStyle? _fontStyle; private FontWeight? _fontWeight; @@ -170,7 +172,7 @@ public void SetLetterSpacing(int letterSpacing) /// /// The line height. [ReactProp(ViewProps.LineHeight)] - public virtual void SetLineHeight(double lineHeight) + public void SetLineHeight(double lineHeight) { if (_lineHeight != lineHeight) { @@ -179,12 +181,27 @@ public virtual void SetLineHeight(double lineHeight) } } + /// + /// Sets the max height. + /// + /// The max height. + [ReactProp(ViewProps.MaxHeight)] + public override void SetMaxHeight(JValue maxHeight) + { + var maxHeightValue = maxHeight.Value(); + if (_maxHeight != maxHeightValue) + { + _maxHeight = maxHeightValue; + MarkUpdated(); + } + } + /// /// Sets the maximum number of lines. /// /// Max number of lines. [ReactProp(ViewProps.NumberOfLines)] - public virtual void SetNumberOfLines(int numberOfLines) + public void SetNumberOfLines(int numberOfLines) { if (_numberOfLines != numberOfLines) { @@ -207,6 +224,23 @@ public void SetMultiline(bool multiline) } } + /// + /// Sets whether to enable auto-grow on the text input. + /// + /// The auto-grow flag. + [ReactProp("autoGrow")] + public void SetAutoGrow(bool autoGrow) + { + if (_autoGrow != autoGrow) + { + _autoGrow = autoGrow; + if (!_autoGrow) + { + MarkUpdated(); + } + } + } + /// /// Sets the text alignment. /// @@ -342,6 +376,11 @@ private static void ApplyStyles(ReactTextInputShadowNode textNode, TextBlock tex var fontFamily = new FontFamily(textNode._fontFamily); textBlock.FontFamily = fontFamily; } + + if (textNode._maxHeight.HasValue) + { + textBlock.MaxHeight = textNode._maxHeight.Value; + } } } } diff --git a/ReactWindows/ReactNative/Views/TextInput/ReactPasswordBoxManager.cs b/ReactWindows/ReactNative/Views/TextInput/ReactPasswordBoxManager.cs index fe744e3721c..27d25655d36 100644 --- a/ReactWindows/ReactNative/Views/TextInput/ReactPasswordBoxManager.cs +++ b/ReactWindows/ReactNative/Views/TextInput/ReactPasswordBoxManager.cs @@ -484,8 +484,6 @@ private void OnPasswordChanged(object sender, RoutedEventArgs e) new ReactTextChangedEvent( textBox.GetTag(), textBox.Password, - textBox.ActualWidth, - textBox.ActualHeight, 0)); } diff --git a/ReactWindows/ReactNative/Views/TextInput/ReactTextInputManager.cs b/ReactWindows/ReactNative/Views/TextInput/ReactTextInputManager.cs index 1b3468b6aa5..68363c506ee 100644 --- a/ReactWindows/ReactNative/Views/TextInput/ReactTextInputManager.cs +++ b/ReactWindows/ReactNative/Views/TextInput/ReactTextInputManager.cs @@ -226,6 +226,17 @@ public void SetOnSelectionChange(ReactTextBox view, bool onSelectionChange) view.OnSelectionChange = onSelectionChange; } + /// + /// Sets whether to track size changes on the . + /// + /// The view instance. + /// The indicator. + [ReactProp("onContentSizeChange", DefaultBoolean = false)] + public void setOnContentSizeChange(ReactTextBox view, bool onContentSizeChange) + { + view.OnContentSizeChange = onContentSizeChange; + } + /// /// Sets the default text placeholder property on the . /// @@ -390,6 +401,21 @@ public void SetMultiline(ReactTextBox view, bool multiline) view.TextWrapping = multiline ? TextWrapping.Wrap : TextWrapping.NoWrap; } + /// + /// Sets whether to enable the to autogrow. + /// + /// The view instance. + /// The auto-grow flag. + [ReactProp("autoGrow", DefaultBoolean = false)] + public void SetAutoGrow(ReactTextBox view, bool autoGrow) + { + view.AutoGrow = autoGrow; + if (autoGrow) + { + view.Height = double.NaN; + } + } + /// /// Sets the keyboard type on the . /// @@ -443,6 +469,17 @@ public void SetSelectTextOnFocus(ReactTextBox view, bool selectTextOnFocus) view.SelectTextOnFocus = selectTextOnFocus; } + /// + /// Sets the max height of the text box. + /// + /// The view instance. + /// The max height. + [ReactProp("maxHeight")] + public void SetMaxHeight(ReactTextBox view, double height) + { + view.MaxHeight = height; + } + /// /// Create the shadow node instance. /// @@ -556,9 +593,30 @@ public override void OnDropViewInstance(ThemedReactContext reactContext, ReactTe /// The dimensions. public override void SetDimensions(ReactTextBox view, Dimensions dimensions) { - base.SetDimensions(view, dimensions); + var removeContentSizeChange = view.OnContentSizeChange; + if (removeContentSizeChange) + { + view.OnContentSizeChange = false; + } + view.MinWidth = dimensions.Width; view.MinHeight = dimensions.Height; + + if (view.AutoGrow) + { + Canvas.SetLeft(view, dimensions.X); + Canvas.SetTop(view, dimensions.Y); + view.Width = dimensions.Width; + } + else + { + base.SetDimensions(view, dimensions); + } + + if (removeContentSizeChange) + { + view.OnContentSizeChange = true; + } } /// @@ -605,8 +663,6 @@ private void OnTextChanged(object sender, TextChangedEventArgs e) new ReactTextChangedEvent( textBox.GetTag(), textBox.Text, - textBox.ActualWidth, - textBox.ActualHeight, textBox.CurrentEventCount)); }