From c550f27a4e82402b966567d7b79a8b1f1d1491a5 Mon Sep 17 00:00:00 2001 From: Valentin Shergin Date: Mon, 2 Oct 2017 16:29:44 -0700 Subject: [PATCH] Intrinsic content size for ReactTextInput (aka autoexpandable on Android) Summary: After this diff the intrinsic content size of reflects the size of text inside EditText, it means that if there is no additional style constraints, will grow with containing text. If you want to constraint minimum or maximum height, just do it via Yoga styling. Reviewed By: achen1 Differential Revision: D5828366 fbshipit-source-id: eccd0cb4ccf724c7096c947332a64a0a1e402673 --- RNTester/js/TextInputExample.android.js | 96 +++++++++++++------ .../react/views/textinput/ReactEditText.java | 27 ++++-- .../textinput/ReactTextInputLocalData.java | 51 ++++++++++ .../textinput/ReactTextInputShadowNode.java | 86 ++++++++++------- 4 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java diff --git a/RNTester/js/TextInputExample.android.js b/RNTester/js/TextInputExample.android.js index b90424dc378..bf3afaf39e6 100644 --- a/RNTester/js/TextInputExample.android.js +++ b/RNTester/js/TextInputExample.android.js @@ -18,6 +18,8 @@ var { TextInput, View, StyleSheet, + Slider, + Switch, } = ReactNative; class TextEventsExample extends React.Component<{}, $FlowFixMeState> { @@ -70,27 +72,6 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> { } } -class AutoExpandingTextInput extends React.Component<$FlowFixMeProps, $FlowFixMeState> { - constructor(props) { - super(props); - this.state = { - height: 0, - }; - } - render() { - return ( - { - this.setState({height: event.nativeEvent.contentSize.height}); - }} - style={[styles.default, {height: Math.min(200, Math.max(35, this.state.height))}]} - /> - ); - } -} - class RewriteExample extends React.Component<$FlowFixMeProps, $FlowFixMeState> { constructor(props) { super(props); @@ -330,12 +311,59 @@ class SelectionExample extends React.Component<$FlowFixMeProps, SelectionExample } } +class AutogrowingTextInputExample extends React.Component<{}> { + + constructor(props) { + super(props); + + this.state = { + width: 100, + multiline: true, + text: '', + }; + } + + componentDidReceiveProps(props) { + this.setState({ + multiline: props.multiline, + }); + } + + render() { + var {style, multiline, ...props} = this.props; + return ( + + Width: + this.setState({width: value})} + /> + Multiline: + this.setState({multiline: value})} + /> + TextInput: + this.setState({text: value})} + {...props} + /> + Plain text value representation: + {this.state.text} + + ); + } +} + var styles = StyleSheet.create({ multiline: { height: 60, fontSize: 16, - padding: 4, - marginBottom: 10, }, eventLabel: { margin: 3, @@ -343,7 +371,6 @@ var styles = StyleSheet.create({ }, singleLine: { fontSize: 16, - padding: 4, }, singleLineWithHeightTextInput: { height: 30, @@ -363,7 +390,8 @@ exports.examples = [ return ( ); @@ -613,12 +641,22 @@ exports.examples = [ render: function() { return ( - + multiline={true} + style={{maxHeight: 400, minHeight: 20, backgroundColor: '#eeeeee'}} + > + generic generic generic + + small small small small small small + + regular regular + + huge huge huge huge huge + + generic generic generic + ); } 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 19aa1d589b6..7b06e8897d1 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 @@ -33,6 +33,8 @@ import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.UIManagerModule; import com.facebook.react.views.text.CustomStyleSpan; import com.facebook.react.views.text.ReactTagSpan; import com.facebook.react.views.text.ReactTextUpdate; @@ -129,9 +131,7 @@ public class ReactEditText extends EditText { @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - if (mContentSizeWatcher != null) { - mContentSizeWatcher.onLayout(); - } + onContentSizeChange(); } @Override @@ -366,7 +366,9 @@ public class ReactEditText extends EditText { manageSpans(spannableStringBuilder); mContainsImages = reactTextUpdate.containsImages(); mIsSettingTextFromJS = true; + getText().replace(0, length(), spannableStringBuilder); + mIsSettingTextFromJS = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (getBreakStrategy() != reactTextUpdate.getTextBreakStrategy()) { @@ -446,6 +448,21 @@ public class ReactEditText extends EditText { return (getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0; } + private void onContentSizeChange() { + if (mContentSizeWatcher != null) { + mContentSizeWatcher.onLayout(); + } + + setIntrinsicContentSize(); + } + + private void setIntrinsicContentSize() { + ReactContext reactContext = (ReactContext) getContext(); + UIManagerModule uiManager = reactContext.getNativeModule(UIManagerModule.class); + final ReactTextInputLocalData localData = new ReactTextInputLocalData(this); + uiManager.setViewLocalData(getId(), localData); + } + /* package */ void setGravityHorizontal(int gravityHorizontal) { if (gravityHorizontal == 0) { gravityHorizontal = mDefaultGravityHorizontal; @@ -621,9 +638,7 @@ public class ReactEditText extends EditText { } } - if (mContentSizeWatcher != null) { - mContentSizeWatcher.onLayout(); - } + onContentSizeChange(); } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java new file mode 100644 index 00000000000..1740a68004b --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java @@ -0,0 +1,51 @@ +/** + * 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 android.os.Build; +import android.text.SpannableString; +import android.util.TypedValue; +import android.widget.EditText; + +/** Local state bearer for EditText instance. */ +public final class ReactTextInputLocalData { + + private final SpannableString mText; + private final float mTextSize; + private final int mMinLines; + private final int mMaxLines; + private final int mInputType; + private final int mBreakStrategy; + + public ReactTextInputLocalData(EditText editText) { + mText = new SpannableString(editText.getText()); + mTextSize = editText.getTextSize(); + mMinLines = editText.getMinLines(); + mMaxLines = editText.getMaxLines(); + mInputType = editText.getInputType(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mBreakStrategy = editText.getBreakStrategy(); + } else { + mBreakStrategy = 0; + } + } + + public void apply(EditText editText) { + editText.setText(mText); + editText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + editText.setMinLines(mMinLines); + editText.setMaxLines(mMaxLines); + editText.setInputType(mInputType); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + editText.setBreakStrategy(mBreakStrategy); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java index 9fb09471e1f..3fac6104ec0 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java @@ -11,19 +11,16 @@ package com.facebook.react.views.textinput; import android.os.Build; import android.text.Layout; -import android.util.TypedValue; import android.view.ViewGroup; import android.widget.EditText; import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.common.annotations.VisibleForTesting; -import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.Spacing; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.UIViewOperationQueue; -import com.facebook.react.uimanager.ViewDefaults; import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.views.text.ReactTextShadowNode; +import com.facebook.react.views.text.ReactBaseTextShadowNode; import com.facebook.react.views.text.ReactTextUpdate; import com.facebook.react.views.view.MeasureUtil; import com.facebook.yoga.YogaMeasureFunction; @@ -33,19 +30,22 @@ import com.facebook.yoga.YogaNode; import javax.annotation.Nullable; @VisibleForTesting -public class ReactTextInputShadowNode extends ReactTextShadowNode implements - YogaMeasureFunction { +public class ReactTextInputShadowNode extends ReactBaseTextShadowNode + implements YogaMeasureFunction { - private @Nullable EditText mEditText; private int mMostRecentEventCount = UNSET; + private @Nullable EditText mDummyEditText; + private @Nullable ReactTextInputLocalData mLocalData; @VisibleForTesting public static final String PROP_TEXT = "text"; + // Represents the {@code text} property only, not possible nested content. private @Nullable String mText = null; public ReactTextInputShadowNode() { mTextBreakStrategy = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_SIMPLE; + setMeasureFunction(this); } @@ -53,20 +53,30 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements public void setThemedContext(ThemedReactContext themedContext) { super.setThemedContext(themedContext); - // TODO #7120264: cache this stuff better - mEditText = new EditText(getThemedContext()); + // {@code EditText} has by default a border at the bottom of its view + // called "underline". To have a native look and feel of the TextEdit + // we have to preserve it at least by default. + // The border (underline) has its padding set by the background image + // provided by the system (which vary a lot among versions and vendors + // of Android), and it cannot be changed. + // So, we have to enforce it as a default padding. + // TODO #7120264: Cache this stuff better. + EditText editText = new EditText(getThemedContext()); + setDefaultPadding(Spacing.START, editText.getPaddingStart()); + setDefaultPadding(Spacing.TOP, editText.getPaddingTop()); + setDefaultPadding(Spacing.END, editText.getPaddingEnd()); + setDefaultPadding(Spacing.BOTTOM, editText.getPaddingBottom()); + + mDummyEditText = editText; + + // We must measure the EditText without paddings, so we have to reset them. + mDummyEditText.setPadding(0, 0, 0, 0); + // This is needed to fix an android bug since 4.4.3 which will throw an NPE in measure, // setting the layoutParams fixes it: https://code.google.com/p/android/issues/detail?id=75877 - mEditText.setLayoutParams( + mDummyEditText.setLayoutParams( new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT)); - - setDefaultPadding(Spacing.START, mEditText.getPaddingStart()); - setDefaultPadding(Spacing.TOP, mEditText.getPaddingTop()); - setDefaultPadding(Spacing.END, mEditText.getPaddingEnd()); - setDefaultPadding(Spacing.BOTTOM, mEditText.getPaddingBottom()); - mEditText.setPadding(0, 0, 0, 0); + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } @Override @@ -77,22 +87,14 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements float height, YogaMeasureMode heightMode) { // measure() should never be called before setThemedContext() - EditText editText = Assertions.assertNotNull(mEditText); + EditText editText = Assertions.assertNotNull(mDummyEditText); - editText.setTextSize( - TypedValue.COMPLEX_UNIT_PX, - mFontSize == UNSET ? - (int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP)) : mFontSize); - - if (mNumberOfLines != UNSET) { - editText.setLines(mNumberOfLines); + if (mLocalData == null) { + // No local data, no intrinsic size. + return YogaMeasureOutput.make(0, 0); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (editText.getBreakStrategy() != mTextBreakStrategy) { - editText.setBreakStrategy(mTextBreakStrategy); - } - } + mLocalData.apply(editText); editText.measure( MeasureUtil.getMeasureSpec(width, widthMode), @@ -102,9 +104,25 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements } @Override - public void onBeforeLayout() { - // We don't have to measure the text within the text input. - return; + public boolean isVirtualAnchor() { + return true; + } + + @Override + public boolean isYogaLeafNode() { + return true; + } + + @Override + public void setLocalData(Object data) { + Assertions.assertCondition(data instanceof ReactTextInputLocalData); + mLocalData = (ReactTextInputLocalData) data; + + // Telling to Yoga that the node should be remeasured on next layout pass. + dirty(); + + // Note: We should NOT mark the node updated (by calling {@code markUpdated}) here + // because the state remains the same. } @ReactProp(name = "mostRecentEventCount")