diff --git a/Examples/UIExplorer/TextInputExample.android.js b/Examples/UIExplorer/TextInputExample.android.js index 2238111cd07294..7dff92b8e3a6c7 100644 --- a/Examples/UIExplorer/TextInputExample.android.js +++ b/Examples/UIExplorer/TextInputExample.android.js @@ -76,18 +76,21 @@ var TextEventsExample = React.createClass({ class AutoExpandingTextInput extends React.Component { constructor(props) { super(props); - this.state = {text: '', height: 0}; + this.state = { + text: 'React Native enables you to build world-class application experiences on native platforms using a consistent developer experience based on JavaScript and React. The focus of React Native is on developer efficiency across all the platforms you care about — learn once, write anywhere. Facebook uses React Native in multiple production apps and will continue investing in React Native.', + height: 0, + }; } render() { return ( { - this.setState({ - text: event.nativeEvent.text, - height: event.nativeEvent.contentSize.height, - }); + onContentSizeChange={(event) => { + this.setState({height: event.nativeEvent.contentSize.height}); + }} + onChangeText={(text) => { + this.setState({text}); }} style={[styles.default, {height: Math.max(35, this.state.height)}]} value={this.state.text} @@ -412,19 +415,19 @@ exports.examples = [ render: function() { return ( - - - - diff --git a/Examples/UIExplorer/TextInputExample.ios.js b/Examples/UIExplorer/TextInputExample.ios.js index a3bae1d4629641..bebce92032d962 100644 --- a/Examples/UIExplorer/TextInputExample.ios.js +++ b/Examples/UIExplorer/TextInputExample.ios.js @@ -102,18 +102,21 @@ class AutoExpandingTextInput extends React.Component { constructor(props) { super(props); - this.state = {text: '', height: 0}; + this.state = { + text: 'React Native enables you to build world-class application experiences on native platforms using a consistent developer experience based on JavaScript and React. The focus of React Native is on developer efficiency across all the platforms you care about — learn once, write anywhere. Facebook uses React Native in multiple production apps and will continue investing in React Native.', + height: 0, + }; } render() { return ( { - this.setState({ - text: event.nativeEvent.text, - height: event.nativeEvent.contentSize.height, - }); + onChangeText={(text) => { + this.setState({text}); + }} + onContentSizeChange={(event) => { + this.setState({height: event.nativeEvent.contentSize.height}); }} style={[styles.default, {height: Math.max(35, this.state.height)}]} value={this.state.text} diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 505fdea088a8a6..81e39d0167c454 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -270,7 +270,7 @@ const TextInput = React.createClass({ * 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. @@ -310,6 +310,14 @@ const TextInput = React.createClass({ * 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. */ @@ -566,6 +574,7 @@ const TextInput = React.createClass({ onFocus={this._onFocus} onBlur={this._onBlur} onChange={this._onChange} + onContentSizeChange={this.props.onContentSizeChange} onSelectionChange={onSelectionChange} onTextInput={this._onTextInput} onSelectionChangeShouldSetResponder={emptyFunction.thatReturnsTrue} @@ -626,6 +635,7 @@ const TextInput = React.createClass({ onFocus={this._onFocus} onBlur={this._onBlur} onChange={this._onChange} + onContentSizeChange={this.props.onContentSizeChange} onSelectionChange={onSelectionChange} onTextInput={this._onTextInput} onEndEditing={this.props.onEndEditing} diff --git a/Libraries/Text/RCTTextView.h b/Libraries/Text/RCTTextView.h index 8268e7a53ddea9..49e499fedccdf5 100644 --- a/Libraries/Text/RCTTextView.h +++ b/Libraries/Text/RCTTextView.h @@ -29,6 +29,7 @@ @property (nonatomic, strong) NSNumber *maxLength; @property (nonatomic, copy) RCTDirectEventBlock onChange; +@property (nonatomic, copy) RCTDirectEventBlock onContentSizeChange; @property (nonatomic, copy) RCTDirectEventBlock onSelectionChange; @property (nonatomic, copy) RCTDirectEventBlock onTextInput; diff --git a/Libraries/Text/RCTTextView.m b/Libraries/Text/RCTTextView.m index 94e99af4cc6001..4c377a58bcc11b 100644 --- a/Libraries/Text/RCTTextView.m +++ b/Libraries/Text/RCTTextView.m @@ -77,6 +77,9 @@ @implementation RCTTextView BOOL _blockTextShouldChange; BOOL _nativeUpdatesInFlight; NSInteger _nativeEventCount; + + CGSize _previousContentSize; + BOOL _viewDidCompleteInitialLayout; } - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher @@ -261,6 +264,17 @@ - (void)updateContentSize size.height = [_textView sizeThatFits:size].height; _scrollView.contentSize = size; _textView.frame = (CGRect){CGPointZero, size}; + + if (_viewDidCompleteInitialLayout && _onContentSizeChange && !CGSizeEqualToSize(_previousContentSize, size)) { + _previousContentSize = size; + _onContentSizeChange(@{ + @"contentSize": @{ + @"height": @(size.height), + @"width": @(size.width), + }, + @"target": self.reactTag, + }); + } } - (void)updatePlaceholder @@ -633,6 +647,11 @@ - (BOOL)resignFirstResponder - (void)layoutSubviews { [super layoutSubviews]; + + // Start sending content size updates only after the view has been laid out + // otherwise we send multiple events with bad dimensions on initial render. + _viewDidCompleteInitialLayout = YES; + [self updateFrames]; } diff --git a/Libraries/Text/RCTTextViewManager.m b/Libraries/Text/RCTTextViewManager.m index 97fbe5e351c5d4..cbbd60a38fa1eb 100644 --- a/Libraries/Text/RCTTextViewManager.m +++ b/Libraries/Text/RCTTextViewManager.m @@ -35,6 +35,7 @@ - (UIView *)view RCT_REMAP_VIEW_PROPERTY(keyboardAppearance, textView.keyboardAppearance, UIKeyboardAppearance) RCT_EXPORT_VIEW_PROPERTY(maxLength, NSNumber) RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onContentSizeChange, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString) diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java index d3bdab1be52a73..7b5528bf20daee 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java @@ -71,11 +71,12 @@ /* package */ static Map getDirectEventTypeConstants() { return MapBuilder.builder() - .put("topSelectionChange", MapBuilder.of("registrationName", "onSelectionChange")) - .put("topLoadingStart", MapBuilder.of("registrationName", "onLoadingStart")) - .put("topLoadingFinish", MapBuilder.of("registrationName", "onLoadingFinish")) - .put("topLoadingError", MapBuilder.of("registrationName", "onLoadingError")) + .put("topContentSizeChange", MapBuilder.of("registrationName", "onContentSizeChange")) .put("topLayout", MapBuilder.of("registrationName", "onLayout")) + .put("topLoadingError", MapBuilder.of("registrationName", "onLoadingError")) + .put("topLoadingFinish", MapBuilder.of("registrationName", "onLoadingFinish")) + .put("topLoadingStart", MapBuilder.of("registrationName", "onLoadingStart")) + .put("topSelectionChange", MapBuilder.of("registrationName", "onSelectionChange")) .build(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/recyclerview/RecyclerViewBackedScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/recyclerview/RecyclerViewBackedScrollViewManager.java index dfac0da4e10585..b82c03f74734e8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/recyclerview/RecyclerViewBackedScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/recyclerview/RecyclerViewBackedScrollViewManager.java @@ -85,9 +85,6 @@ public void scrollTo( Map getExportedCustomDirectEventTypeConstants() { return MapBuilder.builder() .put(ScrollEventType.SCROLL.getJSEventName(), MapBuilder.of("registrationName", "onScroll")) - .put( - ContentSizeChangeEvent.EVENT_NAME, - MapBuilder.of("registrationName", "onContentSizeChange")) .build(); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ContentSizeWatcher.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ContentSizeWatcher.java new file mode 100644 index 00000000000000..fab6bbc1e34d1a --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ContentSizeWatcher.java @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.textinput; + +public interface ContentSizeWatcher { + public void onLayout(); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactContentSizeChangedEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactContentSizeChangedEvent.java new file mode 100644 index 00000000000000..a5d0ef21936f32 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactContentSizeChangedEvent.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.textinput; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted by EditText native view when content size changes. + */ +public class ReactContentSizeChangedEvent extends Event { + + public static final String EVENT_NAME = "topContentSizeChange"; + + private int mContentWidth; + private int mContentHeight; + + public ReactContentSizeChangedEvent( + int viewId, + long timestampMs, + int contentSizeWidth, + int contentSizeHeight) { + super(viewId, timestampMs); + mContentWidth = contentSizeWidth; + mContentHeight = contentSizeHeight; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), serializeEventData()); + } + + private WritableMap serializeEventData() { + WritableMap eventData = Arguments.createMap(); + + WritableMap contentSize = Arguments.createMap(); + contentSize.putDouble("width", mContentWidth); + contentSize.putDouble("height", mContentHeight); + eventData.putMap("contentSize", contentSize); + + eventData.putInt("target", getViewTag()); + return eventData; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 7f9a13d7d27b7a..d5f9c604229a8a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -71,6 +71,7 @@ public class ReactEditText extends EditText { private boolean mContainsImages; private boolean mBlurOnSubmit; private @Nullable SelectionWatcher mSelectionWatcher; + private @Nullable ContentSizeWatcher mContentSizeWatcher; private final InternalKeyListener mKeyListener; private static final KeyListener sKeyListener = QwertyKeyListener.getInstanceForFullKeyboard(); @@ -102,15 +103,30 @@ public ReactEditText(Context context) { // TODO: t6408636 verify if we should schedule a layout after a View does a requestLayout() @Override public boolean isLayoutRequested() { - return false; + // If we are watching and updating container height based on content size + // then we don't want to scroll right away. This isn't perfect -- you might + // want to limit the height the text input can grow to. Possible solution + // is to add another prop that determines whether we should scroll to end + // of text. + if (mContentSizeWatcher != null) { + return isMultiline(); + } else { + return false; + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (mContentSizeWatcher != null) { + mContentSizeWatcher.onLayout(); + } } // Consume 'Enter' key events: TextView tries to give focus to the next TextInput, but it can't // since we only allow JS to change focus, which in turn causes TextView to crash. @Override public boolean onKeyUp(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_ENTER && - ((getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) == 0 )) { + if (keyCode == KeyEvent.KEYCODE_ENTER && !isMultiline()) { hideSoftKeyboard(); return true; } @@ -162,6 +178,10 @@ public void removeTextChangedListener(TextWatcher watcher) { } } + public void setContentSizeWatcher(ContentSizeWatcher contentSizeWatcher) { + mContentSizeWatcher = contentSizeWatcher; + } + @Override protected void onSelectionChanged(int selStart, int selEnd) { super.onSelectionChanged(selStart, selEnd); @@ -212,7 +232,7 @@ public void setInputType(int type) { mStagedInputType = type; // Input type password defaults to monospace font, so we need to re-apply the font super.setTypeface(tf); - + // We override the KeyListener so that all keys on the soft input keyboard as well as hardware // keyboards work. Some KeyListeners like DigitsKeyListener will display the keyboard but not // accept all input from it @@ -329,6 +349,10 @@ private TextWatcherDelegator getTextWatcherDelegator() { return mTextWatcherDelegator; } + private boolean isMultiline() { + return (getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0; + } + /* package */ void setGravityHorizontal(int gravityHorizontal) { if (gravityHorizontal == 0) { gravityHorizontal = mDefaultGravityHorizontal; @@ -447,7 +471,7 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { @Override public void afterTextChanged(Editable s) { if (!mIsSettingTextFromJS && mListeners != null) { - for (android.text.TextWatcher listener : mListeners) { + for (TextWatcher listener : mListeners) { listener.afterTextChanged(s); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index d8d835dab33b81..951f1c6f6190ca 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -66,7 +66,7 @@ public class ReactTextInputManager extends BaseViewManager