From c0ea23cfb0782dab15abdcf1f6c08d539e033dc2 Mon Sep 17 00:00:00 2001 From: Adam Comella Date: Fri, 16 Dec 2016 01:19:16 -0800 Subject: [PATCH] Android: Expose textBreakStrategy on Text and TextInput Summary: Android has a text API called breakStrategy for controlling how paragraphs are broken up into lines. For example, some modes support automatically hyphenating words so a word can be split across lines while others do not. One source of complexity is that Android provides different defaults for `breakStrategy` for `TextView` vs `EditText`. `TextView`'s default is `BREAK_STRATEGY_HIGH_QUALITY` while `EditText`'s default is `BREAK_STRATEGY_SIMPLE`. In addition to exposing `textBreakStrategy`, this change also fixes a couple of rendering glitches with `Text` and `TextInput`. `TextView` and `EditText` have different default values for `breakStrategy` and `hyphenationFrequency` than `StaticLayout`. Consequently, we were using different parameters for measuring and rendering. Whenever measuring and rendering parameters are inconsistent, it can result in visual glitches such as the text taking up too much space or being clipped. This change fixes these inconsistencies by setting `breakStrategy` and `hyphenat Closes https://github.com/facebook/react-native/pull/11007 Differential Revision: D4227495 Pulled By: lacker fbshipit-source-id: c2d96bd0ddc7bd315fda016fb4f1b5108a2e35cf --- Libraries/Components/TextInput/TextInput.js | 7 ++ Libraries/Text/Text.js | 7 ++ .../facebook/react/uimanager/ViewProps.java | 1 + .../react/views/text/ReactTextShadowNode.java | 69 ++++++++++++++++--- .../react/views/text/ReactTextUpdate.java | 35 +++++++++- .../react/views/text/ReactTextView.java | 6 ++ .../react/views/textinput/ReactEditText.java | 6 ++ .../textinput/ReactTextInputShadowNode.java | 33 ++++++++- 8 files changed, 151 insertions(+), 13 deletions(-) diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 16e2ae8cb96..471315496cc 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -330,6 +330,12 @@ const TextInput = React.createClass({ * The default value is `false`. */ multiline: PropTypes.bool, + /** + * Set text break strategy on Android API Level 23+, possible values are `simple`, `highQuality`, `balanced` + * The default value is `simple`. + * @platform android + */ + textBreakStrategy: React.PropTypes.oneOf(['simple', 'highQuality', 'balanced']), /** * Callback that is called when the text input is blurred. */ @@ -724,6 +730,7 @@ const TextInput = React.createClass({ text={this._getText()} children={children} disableFullscreenUI={this.props.disableFullscreenUI} + textBreakStrategy={this.props.textBreakStrategy} />; return ( diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 8c990d0d5b4..11473276368 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -33,6 +33,7 @@ const viewConfig = { selectable: true, adjustsFontSizeToFit: true, minimumFontScale: true, + textBreakStrategy: true, }), uiViewClassName: 'RCTText', }; @@ -116,6 +117,12 @@ const Text = React.createClass({ * This prop is commonly used with `ellipsizeMode`. */ numberOfLines: React.PropTypes.number, + /** + * Set text break strategy on Android API Level 23+, possible values are `simple`, `highQuality`, `balanced` + * The default value is `highQuality`. + * @platform android + */ + textBreakStrategy: React.PropTypes.oneOf(['simple', 'highQuality', 'balanced']), /** * Invoked on mount and layout changes with * diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index f3faee5090f..5c4feaf02f9 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -82,6 +82,7 @@ public class ViewProps { public static final String TEXT_ALIGN = "textAlign"; public static final String TEXT_ALIGN_VERTICAL = "textAlignVertical"; public static final String TEXT_DECORATION_LINE = "textDecorationLine"; + public static final String TEXT_BREAK_STRATEGY = "textBreakStrategy"; public static final String ALLOW_FONT_SCALING = "allowFontScaling"; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java index 1a38a51f3f0..8df4adcbdd5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.List; import android.graphics.Typeface; +import android.os.Build; import android.text.BoringLayout; import android.text.Layout; import android.text.Spannable; @@ -248,14 +249,27 @@ public class ReactTextShadowNode extends LayoutShadowNode { (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) { // Is used when the width is not known and the text is not boring, ie. if it contains // unicode characters. - layout = new StaticLayout( + + int hintWidth = (int) Math.ceil(desiredWidth); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + layout = new StaticLayout( text, textPaint, - (int) Math.ceil(desiredWidth), + hintWidth, Layout.Alignment.ALIGN_NORMAL, 1.f, 0.f, true); + } else { + layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.f, 1.f) + .setIncludePad(true) + .setBreakStrategy(mTextBreakStrategy) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) + .build(); + } + } else if (boring != null && (unconstrainedWidth || boring.width <= width)) { // Is used for single-line, boring text when the width is either unknown or bigger // than the width of the text. @@ -270,14 +284,25 @@ public class ReactTextShadowNode extends LayoutShadowNode { true); } else { // Is used for multiline, boring text and the width is known. - layout = new StaticLayout( - text, - textPaint, - (int) width, - Layout.Alignment.ALIGN_NORMAL, - 1.f, - 0.f, - true); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + layout = new StaticLayout( + text, + textPaint, + (int) width, + Layout.Alignment.ALIGN_NORMAL, + 1.f, + 0.f, + true); + } else { + layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, (int) width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.f, 1.f) + .setIncludePad(true) + .setBreakStrategy(mTextBreakStrategy) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) + .build(); + } } if (mNumberOfLines != UNSET && @@ -317,6 +342,8 @@ public class ReactTextShadowNode extends LayoutShadowNode { protected float mFontSizeInput = UNSET; protected int mLineHeightInput = UNSET; protected int mTextAlign = Gravity.NO_GRAVITY; + protected int mTextBreakStrategy = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? + 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY; private float mTextShadowOffsetDx = 0; private float mTextShadowOffsetDy = 0; @@ -549,6 +576,25 @@ public class ReactTextShadowNode extends LayoutShadowNode { markUpdated(); } + @ReactProp(name = ViewProps.TEXT_BREAK_STRATEGY) + public void setTextBreakStrategy(@Nullable String textBreakStrategy) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + if (textBreakStrategy == null || "highQuality".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; + } else if ("simple".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_SIMPLE; + } else if ("balanced".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_BALANCED; + } else { + throw new JSApplicationIllegalArgumentException("Invalid textBreakStrategy: " + textBreakStrategy); + } + + markUpdated(); + } + @ReactProp(name = PROP_SHADOW_OFFSET) public void setTextShadowOffset(ReadableMap offsetMap) { mTextShadowOffsetDx = 0; @@ -607,7 +653,8 @@ public class ReactTextShadowNode extends LayoutShadowNode { getPadding(Spacing.TOP), getPadding(Spacing.END), getPadding(Spacing.BOTTOM), - getTextAlign() + getTextAlign(), + mTextBreakStrategy ); uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java index 237be1fe8d6..9f67aec6180 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java @@ -9,6 +9,7 @@ package com.facebook.react.views.text; +import android.text.Layout; import android.text.Spannable; /** @@ -26,6 +27,32 @@ public class ReactTextUpdate { private final float mPaddingRight; private final float mPaddingBottom; private final int mTextAlign; + private final int mTextBreakStrategy; + + /** + * @deprecated Use a non-deprecated constructor for ReactTextUpdate instead. This one remains + * because it's being used by a unit test that isn't currently open source. + */ + @Deprecated + public ReactTextUpdate( + Spannable text, + int jsEventCounter, + boolean containsImages, + float paddingStart, + float paddingTop, + float paddingEnd, + float paddingBottom, + int textAlign) { + this(text, + jsEventCounter, + containsImages, + paddingStart, + paddingTop, + paddingEnd, + paddingBottom, + textAlign, + Layout.BREAK_STRATEGY_HIGH_QUALITY); + } public ReactTextUpdate( Spannable text, @@ -35,7 +62,8 @@ public class ReactTextUpdate { float paddingTop, float paddingEnd, float paddingBottom, - int textAlign) { + int textAlign, + int textBreakStrategy) { mText = text; mJsEventCounter = jsEventCounter; mContainsImages = containsImages; @@ -44,6 +72,7 @@ public class ReactTextUpdate { mPaddingRight = paddingEnd; mPaddingBottom = paddingBottom; mTextAlign = textAlign; + mTextBreakStrategy = textBreakStrategy; } public Spannable getText() { @@ -77,4 +106,8 @@ public class ReactTextUpdate { public int getTextAlign() { return mTextAlign; } + + public int getTextBreakStrategy() { + return mTextBreakStrategy; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 50828a80ba6..3d5d9668bd8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -15,6 +15,7 @@ import android.content.Context; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; +import android.os.Build; import android.text.Layout; import android.text.Spanned; import android.text.TextUtils; @@ -69,6 +70,11 @@ public class ReactTextView extends TextView implements ReactCompoundView { mTextAlign = nextTextAlign; } setGravityHorizontal(mTextAlign); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (getBreakStrategy() != update.getTextBreakStrategy()) { + setBreakStrategy(update.getTextBreakStrategy()); + } + } } @Override 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 90af968f044..9e9fc6edcf2 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 @@ -19,6 +19,7 @@ import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; +import android.os.Build; import android.text.Editable; import android.text.InputType; import android.text.SpannableStringBuilder; @@ -340,6 +341,11 @@ public class ReactEditText extends EditText { mIsSettingTextFromJS = true; getText().replace(0, length(), spannableStringBuilder); mIsSettingTextFromJS = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (getBreakStrategy() != reactTextUpdate.getTextBreakStrategy()) { + setBreakStrategy(reactTextUpdate.getTextBreakStrategy()); + } + } } /** 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 3536f37707b..409cf13e4ec 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 @@ -10,7 +10,10 @@ package com.facebook.react.views.textinput; import javax.annotation.Nullable; +import javax.annotation.OverridingMethodsMustInvokeSuper; +import android.os.Build; +import android.text.Layout; import android.text.Spannable; import android.util.TypedValue; import android.view.ViewGroup; @@ -22,12 +25,14 @@ import com.facebook.yoga.YogaMeasureFunction; import com.facebook.yoga.YogaNodeAPI; import com.facebook.yoga.YogaMeasureOutput; 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.ViewProps; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.views.view.MeasureUtil; import com.facebook.react.views.text.ReactTextShadowNode; @@ -42,6 +47,8 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements private int mJsEventCount = UNSET; public ReactTextInputShadowNode() { + mTextBreakStrategy = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? + 0 : Layout.BREAK_STRATEGY_SIMPLE; setMeasureFunction(this); } @@ -100,6 +107,12 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements editText.setLines(mNumberOfLines); } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (editText.getBreakStrategy() != mTextBreakStrategy) { + editText.setBreakStrategy(mTextBreakStrategy); + } + } + editText.measure( MeasureUtil.getMeasureSpec(width, widthMode), MeasureUtil.getMeasureSpec(height, heightMode)); @@ -118,6 +131,23 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements mJsEventCount = mostRecentEventCount; } + @Override + public void setTextBreakStrategy(@Nullable String textBreakStrategy) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + if (textBreakStrategy == null || "simple".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_SIMPLE; + } else if ("highQuality".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; + } else if ("balanced".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_BALANCED; + } else { + throw new JSApplicationIllegalArgumentException("Invalid textBreakStrategy: " + textBreakStrategy); + } + } + @Override public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { super.onCollectExtraUpdates(uiViewOperationQueue); @@ -146,7 +176,8 @@ public class ReactTextInputShadowNode extends ReactTextShadowNode implements getPadding(Spacing.TOP), getPadding(Spacing.END), getPadding(Spacing.BOTTOM), - mTextAlign + mTextAlign, + mTextBreakStrategy ); uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate); }