Implement TextInput onContentSizeChange

Summary:
This adds proper support for tracking a TextInput content size as discussed in #6552 by adding a new callback that is called every time the content size changes including when first rendering the view.

Some points that are up for discussion are what do we want to do with the onChange callback as I don't see any use left for it now that we can track text change in onChangeText and size changes in onContentSizeChange. Also a bit off topic but should we consider renaming onChangeText to onTextChange to keep the naming more consistent (see [this naming justification](https://twitter.com/notbrent/status/709445076850597888)).

This is split in 2 commits for easier review, one for iOS and one for android.

The iOS implementation simply checks if the content size has changed everytime we update it and fire the callback, the only small issue was that the content size had several different values on initial render so I added a check to not fire events before the layoutSubviews where at this point the value is g
Closes https://github.com/facebook/react-native/pull/8457

Differential Revision: D3528202

Pulled By: dmmiller

fbshipit-source-id: fefe83f10cc5bfde1f5937c48c88b10408e58d9d
This commit is contained in:
Janic Duplessis 2016-07-07 08:44:59 -07:00 committed by Facebook Github Bot 1
parent be0abd17e5
commit 2537157d99
12 changed files with 209 additions and 28 deletions

View File

@ -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 (
<TextInput
{...this.props}
multiline={true}
onChange={(event) => {
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}

View File

@ -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 (
<TextInput
{...this.props}
multiline={true}
onChange={(event) => {
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}

View File

@ -271,7 +271,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.
@ -311,6 +311,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.
*/
@ -581,6 +589,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}
@ -641,6 +650,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}

View File

@ -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;

View File

@ -77,6 +77,9 @@
BOOL _blockTextShouldChange;
BOOL _nativeUpdatesInFlight;
NSInteger _nativeEventCount;
CGSize _previousContentSize;
BOOL _viewDidCompleteInitialLayout;
}
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
@ -261,6 +264,17 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string)
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 @@ static BOOL findMismatch(NSString *first, NSString *second, NSRange *firstRange,
- (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];
}

View File

@ -35,6 +35,7 @@ RCT_REMAP_VIEW_PROPERTY(keyboardType, textView.keyboardType, UIKeyboardType)
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)

View File

@ -71,11 +71,12 @@ import com.facebook.react.uimanager.events.TouchEventType;
/* 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();
}

View File

@ -85,9 +85,6 @@ public class RecyclerViewBackedScrollViewManager extends
Map getExportedCustomDirectEventTypeConstants() {
return MapBuilder.builder()
.put(ScrollEventType.SCROLL.getJSEventName(), MapBuilder.of("registrationName", "onScroll"))
.put(
ContentSizeChangeEvent.EVENT_NAME,
MapBuilder.of("registrationName", "onContentSizeChange"))
.build();
}
}

View File

@ -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();
}

View File

@ -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<ReactTextChangedEvent> {
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;
}
}

View File

@ -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 class ReactEditText extends EditText {
// 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 class ReactEditText extends EditText {
}
}
public void setContentSizeWatcher(ContentSizeWatcher contentSizeWatcher) {
mContentSizeWatcher = contentSizeWatcher;
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
@ -212,7 +232,7 @@ public class ReactEditText extends EditText {
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 @@ public class ReactEditText extends EditText {
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 class ReactEditText extends EditText {
@Override
public void afterTextChanged(Editable s) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (android.text.TextWatcher listener : mListeners) {
for (TextWatcher listener : mListeners) {
listener.afterTextChanged(s);
}
}

View File

@ -67,7 +67,7 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
private static final String KEYBOARD_TYPE_PHONE_PAD = "phone-pad";
private static final InputFilter[] EMPTY_FILTERS = new InputFilter[0];
private static final int UNSET = -1;
@Override
public String getName() {
return REACT_CLASS;
@ -245,6 +245,15 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
view.setBlurOnSubmit(blurOnSubmit);
}
@ReactProp(name = "onContentSizeChange", defaultBoolean = false)
public void setOnContentSizeChange(final ReactEditText view, boolean onContentSizeChange) {
if (onContentSizeChange) {
view.setContentSizeWatcher(new ReactContentSizeWatcher(view));
} else {
view.setContentSizeWatcher(null);
}
}
@ReactProp(name = "placeholder")
public void setPlaceholder(ReactEditText view, @Nullable String placeholder) {
view.setHint(placeholder);
@ -551,15 +560,17 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
if (count == before && newText.equals(oldText)) {
return;
}
// TODO: remove contentSize from onTextChanged entirely now that onChangeContentSize exists?
int contentWidth = mEditText.getWidth();
int contentHeight = mEditText.getHeight();
// Use instead size of text content within EditText when available
if (mEditText.getLayout() != null) {
contentWidth = mEditText.getCompoundPaddingLeft() + mEditText.getLayout().getWidth() +
mEditText.getCompoundPaddingRight();
mEditText.getCompoundPaddingRight();
contentHeight = mEditText.getCompoundPaddingTop() + mEditText.getLayout().getHeight() +
mEditText.getCompoundPaddingTop();
mEditText.getCompoundPaddingTop();
}
// The event that contains the event counter and updates it must be sent first.
@ -645,6 +656,45 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
});
}
private class ReactContentSizeWatcher implements ContentSizeWatcher {
private ReactEditText mEditText;
private EventDispatcher mEventDispatcher;
private int mPreviousContentWidth = 0;
private int mPreviousContentHeight = 0;
public ReactContentSizeWatcher(ReactEditText editText) {
mEditText = editText;
ReactContext reactContext = (ReactContext) editText.getContext();
mEventDispatcher = reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher();
}
@Override
public void onLayout() {
int contentWidth = mEditText.getWidth();
int contentHeight = mEditText.getHeight();
// Use instead size of text content within EditText when available
if (mEditText.getLayout() != null) {
contentWidth = mEditText.getCompoundPaddingLeft() + mEditText.getLayout().getWidth() +
mEditText.getCompoundPaddingRight();
contentHeight = mEditText.getCompoundPaddingTop() + mEditText.getLayout().getHeight() +
mEditText.getCompoundPaddingTop();
}
if (contentWidth != mPreviousContentWidth || contentHeight != mPreviousContentHeight) {
mPreviousContentHeight = contentHeight;
mPreviousContentWidth = contentWidth;
mEventDispatcher.dispatchEvent(
new ReactContentSizeChangedEvent(
mEditText.getId(),
SystemClock.nanoTime(),
(int) PixelUtil.toDIPFromPixel(contentWidth),
(int) PixelUtil.toDIPFromPixel(contentHeight)));
}
}
}
private class ReactSelectionWatcher implements SelectionWatcher {
private ReactEditText mReactEditText;