From 39b4db73c3340ff955f67e4e5318159d19d1ab3a Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Wed, 25 Mar 2015 13:18:20 -0700 Subject: Add breakStrategy attribute to TextView This patch adds plumbing to TextView to control the strategy used for breaking paragraphs into lines. The default for TextView is "quality", while the default for EditText is "simple", largely to avoid too much re-layout when editing. StaticLayout now has a builder which provides access to more functionality and is also cleaner than the old mechanism of having lots of constructors with varying numbers of arguments. This patch changes TextView to use that builder, and also contains cleanups of the Builder within StaticLayout. Change-Id: Iee3cf3a05a3e51ba0834554e4a3ec606e9cabca5 --- api/current.txt | 6 ++ api/system-current.txt | 6 ++ core/java/android/text/DynamicLayout.java | 13 ++-- core/java/android/text/Layout.java | 28 ++++++++ core/java/android/text/StaticLayout.java | 91 ++++++++++++++--------- core/java/android/widget/TextView.java | 116 +++++++++++++++++++----------- core/res/res/values/attrs.xml | 9 +++ core/res/res/values/public.xml | 1 + core/res/res/values/styles.xml | 2 + 9 files changed, 191 insertions(+), 81 deletions(-) diff --git a/api/current.txt b/api/current.txt index b3d5fd0c014a..202139cc757b 100644 --- a/api/current.txt +++ b/api/current.txt @@ -354,6 +354,7 @@ package android { field public static final int bottomRightRadius = 16843180; // 0x10101ac field public static final int breadCrumbShortTitle = 16843524; // 0x1010304 field public static final int breadCrumbTitle = 16843523; // 0x1010303 + field public static final int breakStrategy = 16844011; // 0x10104eb field public static final int bufferType = 16843086; // 0x101014e field public static final int button = 16843015; // 0x1010107 field public static final int buttonBarButtonStyle = 16843567; // 0x101032f @@ -30920,6 +30921,9 @@ package android.text { method public final void increaseWidthTo(int); method public boolean isRtlCharAt(int); method protected final boolean isSpanned(); + field public static final int BREAK_STRATEGY_BALANCED = 2; // 0x2 + field public static final int BREAK_STRATEGY_HIGH_QUALITY = 1; // 0x1 + field public static final int BREAK_STRATEGY_SIMPLE = 0; // 0x0 field public static final int DIR_LEFT_TO_RIGHT = 1; // 0x1 field public static final int DIR_RIGHT_TO_LEFT = -1; // 0xffffffff } @@ -40082,6 +40086,7 @@ package android.widget { method public void endBatchEdit(); method public boolean extractText(android.view.inputmethod.ExtractedTextRequest, android.view.inputmethod.ExtractedText); method public final int getAutoLinkMask(); + method public int getBreakStrategy(); method public int getCompoundDrawablePadding(); method public android.content.res.ColorStateList getCompoundDrawableTintList(); method public android.graphics.PorterDuff.Mode getCompoundDrawableTintMode(); @@ -40183,6 +40188,7 @@ package android.widget { method public void removeTextChangedListener(android.text.TextWatcher); method public void setAllCaps(boolean); method public final void setAutoLinkMask(int); + method public void setBreakStrategy(int); method public void setCompoundDrawablePadding(int); method public void setCompoundDrawableTintList(android.content.res.ColorStateList); method public void setCompoundDrawableTintMode(android.graphics.PorterDuff.Mode); diff --git a/api/system-current.txt b/api/system-current.txt index 94620548bd69..c6c37d5672ee 100644 --- a/api/system-current.txt +++ b/api/system-current.txt @@ -426,6 +426,7 @@ package android { field public static final int bottomRightRadius = 16843180; // 0x10101ac field public static final int breadCrumbShortTitle = 16843524; // 0x1010304 field public static final int breadCrumbTitle = 16843523; // 0x1010303 + field public static final int breakStrategy = 16844011; // 0x10104eb field public static final int bufferType = 16843086; // 0x101014e field public static final int button = 16843015; // 0x1010107 field public static final int buttonBarButtonStyle = 16843567; // 0x101032f @@ -33462,6 +33463,9 @@ package android.text { method public final void increaseWidthTo(int); method public boolean isRtlCharAt(int); method protected final boolean isSpanned(); + field public static final int BREAK_STRATEGY_BALANCED = 2; // 0x2 + field public static final int BREAK_STRATEGY_HIGH_QUALITY = 1; // 0x1 + field public static final int BREAK_STRATEGY_SIMPLE = 0; // 0x0 field public static final int DIR_LEFT_TO_RIGHT = 1; // 0x1 field public static final int DIR_RIGHT_TO_LEFT = -1; // 0xffffffff } @@ -42925,6 +42929,7 @@ package android.widget { method public void endBatchEdit(); method public boolean extractText(android.view.inputmethod.ExtractedTextRequest, android.view.inputmethod.ExtractedText); method public final int getAutoLinkMask(); + method public int getBreakStrategy(); method public int getCompoundDrawablePadding(); method public android.content.res.ColorStateList getCompoundDrawableTintList(); method public android.graphics.PorterDuff.Mode getCompoundDrawableTintMode(); @@ -43026,6 +43031,7 @@ package android.widget { method public void removeTextChangedListener(android.text.TextWatcher); method public void setAllCaps(boolean); method public final void setAutoLinkMask(int); + method public void setBreakStrategy(int); method public void setCompoundDrawablePadding(int); method public void setCompoundDrawableTintList(android.content.res.ColorStateList); method public void setCompoundDrawableTintMode(android.graphics.PorterDuff.Mode); diff --git a/core/java/android/text/DynamicLayout.java b/core/java/android/text/DynamicLayout.java index 7d2e1ef757b5..239b3869c9d9 100644 --- a/core/java/android/text/DynamicLayout.java +++ b/core/java/android/text/DynamicLayout.java @@ -79,7 +79,8 @@ public class DynamicLayout extends Layout boolean includepad, TextUtils.TruncateAt ellipsize, int ellipsizedWidth) { this(base, display, paint, width, align, TextDirectionHeuristics.FIRSTSTRONG_LTR, - spacingmult, spacingadd, includepad, ellipsize, ellipsizedWidth); + spacingmult, spacingadd, includepad, StaticLayout.BREAK_STRATEGY_SIMPLE, + ellipsize, ellipsizedWidth); } /** @@ -95,7 +96,7 @@ public class DynamicLayout extends Layout TextPaint paint, int width, Alignment align, TextDirectionHeuristic textDir, float spacingmult, float spacingadd, - boolean includepad, + boolean includepad, int breakStrategy, TextUtils.TruncateAt ellipsize, int ellipsizedWidth) { super((ellipsize == null) ? display @@ -120,6 +121,7 @@ public class DynamicLayout extends Layout mObjects = new PackedObjectVector(1); mIncludePad = includepad; + mBreakStrategy = breakStrategy; /* * This is annoying, but we can't refer to the layout until @@ -279,10 +281,9 @@ public class DynamicLayout extends Layout sBuilder = null; } - // TODO: make sure reflowed is properly initialized if (reflowed == null) { reflowed = new StaticLayout(null); - b = StaticLayout.Builder.obtain(); + b = StaticLayout.Builder.obtain(text, where, where + after, getWidth()); } b.setText(text, where, where + after) @@ -292,7 +293,8 @@ public class DynamicLayout extends Layout .setSpacingMult(getSpacingMultiplier()) .setSpacingAdd(getSpacingAdd()) .setEllipsizedWidth(mEllipsizedWidth) - .setEllipsize(mEllipsizeAt); + .setEllipsize(mEllipsizeAt) + .setBreakStrategy(mBreakStrategy); reflowed.generate(b, false, true); int n = reflowed.getLineCount(); @@ -717,6 +719,7 @@ public class DynamicLayout extends Layout private boolean mEllipsize; private int mEllipsizedWidth; private TextUtils.TruncateAt mEllipsizeAt; + private int mBreakStrategy; private PackedIntVector mInts; private PackedObjectVector mObjects; diff --git a/core/java/android/text/Layout.java b/core/java/android/text/Layout.java index 22abb1864395..16ae5e238e0e 100644 --- a/core/java/android/text/Layout.java +++ b/core/java/android/text/Layout.java @@ -16,6 +16,7 @@ package android.text; +import android.annotation.IntDef; import android.emoji.EmojiFactory; import android.graphics.Canvas; import android.graphics.Paint; @@ -33,6 +34,8 @@ import android.text.style.TabStopSpan; import com.android.internal.util.ArrayUtils; import com.android.internal.util.GrowingArrayUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Arrays; /** @@ -43,6 +46,31 @@ import java.util.Arrays; * For text that will not change, use a {@link StaticLayout}. */ public abstract class Layout { + /** @hide */ + @IntDef({BREAK_STRATEGY_SIMPLE, BREAK_STRATEGY_HIGH_QUALITY, BREAK_STRATEGY_BALANCED}) + @Retention(RetentionPolicy.SOURCE) + public @interface BreakStrategy {} + + /** + * Value for break strategy indicating simple line breaking. Automatic hyphens are not added + * (though soft hyphens are respected), and modifying text generally doesn't affect the layout + * before it (which yields a more consistent user experience when editing), but layout may not + * be the highest quality. + */ + public static final int BREAK_STRATEGY_SIMPLE = 0; + + /** + * Value for break strategy indicating high quality line breaking, including automatic + * hyphenation and doing whole-paragraph optimization of line breaks. + */ + public static final int BREAK_STRATEGY_HIGH_QUALITY = 1; + + /** + * Value for break strategy indicating balanced line breaking. The breaks are chosen to + * make all lines as close to the same length as possible, including automatic hyphenation. + */ + public static final int BREAK_STRATEGY_BALANCED = 2; + private static final ParagraphStyle[] NO_PARA_SPANS = ArrayUtils.emptyArray(ParagraphStyle.class); diff --git a/core/java/android/text/StaticLayout.java b/core/java/android/text/StaticLayout.java index 4174df0f56c0..2bcb3529de92 100644 --- a/core/java/android/text/StaticLayout.java +++ b/core/java/android/text/StaticLayout.java @@ -23,6 +23,7 @@ import android.text.style.LineHeightSpan; import android.text.style.MetricAffectingSpan; import android.text.style.TabStopSpan; import android.util.Log; +import android.util.Pools.SynchronizedPool; import com.android.internal.util.ArrayUtils; import com.android.internal.util.GrowingArrayUtils; @@ -56,28 +57,23 @@ public class StaticLayout extends Layout { mNativePtr = nNewBuilder(); } - static Builder obtain() { - Builder b = null; - synchronized (sLock) { - for (int i = 0; i < sCached.length; i++) { - if (sCached[i] != null) { - b = sCached[i]; - sCached[i] = null; - break; - } - } - } + public static Builder obtain(CharSequence source, int start, int end, int width) { + Builder b = sPool.acquire(); if (b == null) { b = new Builder(); } // set default initial values - b.mWidth = 0; + b.mText = source; + b.mStart = start; + b.mEnd = end; + b.mWidth = width; + b.mAlignment = Alignment.ALIGN_NORMAL; b.mTextDir = TextDirectionHeuristics.FIRSTSTRONG_LTR; b.mSpacingMult = 1.0f; b.mSpacingAdd = 0.0f; b.mIncludePad = true; - b.mEllipsizedWidth = 0; + b.mEllipsizedWidth = width; b.mEllipsize = null; b.mMaxLines = Integer.MAX_VALUE; @@ -85,18 +81,11 @@ public class StaticLayout extends Layout { return b; } - static void recycle(Builder b) { + private static void recycle(Builder b) { b.mPaint = null; b.mText = null; MeasuredText.recycle(b.mMeasuredText); - synchronized (sLock) { - for (int i = 0; i < sCached.length; i++) { - if (sCached[i] == null) { - sCached[i] = b; - break; - } - } - } + sPool.release(b); } // release any expensive state @@ -129,6 +118,11 @@ public class StaticLayout extends Layout { return this; } + public Builder setAlignment(Alignment alignment) { + mAlignment = alignment; + return this; + } + public Builder setTextDir(TextDirectionHeuristic textDir) { mTextDir = textDir; return this; @@ -166,6 +160,11 @@ public class StaticLayout extends Layout { return this; } + public Builder setBreakStrategy(@BreakStrategy int breakStrategy) { + mBreakStrategy = breakStrategy; + return this; + } + /** * Measurement and break iteration is done in native code. The protocol for using * the native code is as follows. @@ -207,10 +206,8 @@ public class StaticLayout extends Layout { } public StaticLayout build() { - // TODO: can optimize based on whether ellipsis is needed - StaticLayout result = new StaticLayout(mText); - result.generate(this, this.mIncludePad, this.mIncludePad); - recycle(this); + StaticLayout result = new StaticLayout(this); + Builder.recycle(this); return result; } @@ -230,6 +227,7 @@ public class StaticLayout extends Layout { int mEnd; TextPaint mPaint; int mWidth; + Alignment mAlignment; TextDirectionHeuristic mTextDir; float mSpacingMult; float mSpacingAdd; @@ -237,6 +235,7 @@ public class StaticLayout extends Layout { int mEllipsizedWidth; TextUtils.TruncateAt mEllipsize; int mMaxLines; + int mBreakStrategy; Paint.FontMetricsInt mFontMetricsInt = new Paint.FontMetricsInt(); @@ -245,8 +244,7 @@ public class StaticLayout extends Layout { Locale mLocale; - private static final Object sLock = new Object(); - private static final Builder[] sCached = new Builder[3]; + private static final SynchronizedPool sPool = new SynchronizedPool(3); } public StaticLayout(CharSequence source, TextPaint paint, @@ -316,10 +314,9 @@ public class StaticLayout extends Layout { : new Ellipsizer(source), paint, outerwidth, align, textDir, spacingmult, spacingadd); - Builder b = Builder.obtain(); - b.setText(source, bufstart, bufend) + Builder b = Builder.obtain(source, bufstart, bufend, outerwidth) .setPaint(paint) - .setWidth(outerwidth) + .setAlignment(align) .setTextDir(textDir) .setSpacingMult(spacingmult) .setSpacingAdd(spacingadd) @@ -366,6 +363,35 @@ public class StaticLayout extends Layout { mLines = new int[mLineDirections.length]; } + private StaticLayout(Builder b) { + super((b.mEllipsize == null) + ? b.mText + : (b.mText instanceof Spanned) + ? new SpannedEllipsizer(b.mText) + : new Ellipsizer(b.mText), + b.mPaint, b.mWidth, b.mAlignment, b.mSpacingMult, b.mSpacingAdd); + + if (b.mEllipsize != null) { + Ellipsizer e = (Ellipsizer) getText(); + + e.mLayout = this; + e.mWidth = b.mEllipsizedWidth; + e.mMethod = b.mEllipsize; + mEllipsizedWidth = b.mEllipsizedWidth; + + mColumns = COLUMNS_ELLIPSIZE; + } else { + mColumns = COLUMNS_NORMAL; + mEllipsizedWidth = b.mWidth; + } + + mLineDirections = ArrayUtils.newUnpaddedArray(Directions.class, 2 * mColumns); + mLines = new int[mLineDirections.length]; + mMaximumVisibleLineCount = b.mMaxLines; + + generate(b, b.mIncludePad, b.mIncludePad); + } + /* package */ void generate(Builder b, boolean includepad, boolean trackpad) { CharSequence source = b.mText; int bufStart = b.mStart; @@ -477,10 +503,9 @@ public class StaticLayout extends Layout { } } - int breakStrategy = 0; // 0 = kBreakStrategy_Greedy nSetupParagraph(b.mNativePtr, chs, paraEnd - paraStart, firstWidth, firstWidthLineCount, restWidth, - variableTabStops, TAB_INCREMENT, breakStrategy); + variableTabStops, TAB_INCREMENT, b.mBreakStrategy); // measurement has to be done before performing line breaking // but we don't want to recompute fontmetrics or span ranges the diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java index 718ef932ae9a..2723080301e9 100644 --- a/core/java/android/widget/TextView.java +++ b/core/java/android/widget/TextView.java @@ -543,6 +543,8 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener private float mSpacingMult = 1.0f; private float mSpacingAdd = 0.0f; + private int mBreakStrategy; + private int mMaximum = Integer.MAX_VALUE; private int mMaxMode = LINES; private int mMinimum = 0; @@ -680,6 +682,7 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener boolean elegant = false; float letterSpacing = 0; String fontFeatureSettings = null; + mBreakStrategy = Layout.BREAK_STRATEGY_SIMPLE; final Resources.Theme theme = context.getTheme(); @@ -1133,6 +1136,9 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener case com.android.internal.R.styleable.TextView_fontFeatureSettings: fontFeatureSettings = a.getString(attr); break; + + case com.android.internal.R.styleable.TextView_breakStrategy: + mBreakStrategy = a.getInt(attr, Layout.BREAK_STRATEGY_SIMPLE); } } a.recycle(); @@ -2959,6 +2965,35 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener return mTextPaint.getFontFeatureSettings(); } + /** + * Sets the break strategy for breaking paragraphs into lines. The default value for + * TextView is {@link Layout#BREAK_STRATEGY_HIGH_QUALITY}, and the default value for + * EditText is {@link Layout#BREAK_STRATEGY_SIMPLE}, the latter to avoid the + * text "dancing" when being edited. + * + * @attr ref android.R.styleable#TextView_breakStrategy + * @see #getBreakStrategy() + */ + public void setBreakStrategy(@Layout.BreakStrategy int breakStrategy) { + mBreakStrategy = breakStrategy; + if (mLayout != null) { + nullLayouts(); + requestLayout(); + invalidate(); + } + } + + /** + * @return the currently set break strategy. + * + * @attr ref android.R.styleable#TextView_breakStrategy + * @see #setBreakStrategy(int) + */ + @Layout.BreakStrategy + public int getBreakStrategy() { + return mBreakStrategy; + } + /** * Sets font feature settings. The format is the same as the CSS * font-feature-settings attribute: @@ -6492,27 +6527,25 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener hintBoring, mIncludePad, mEllipsize, ellipsisWidth); } - } else if (shouldEllipsize) { - mHintLayout = new StaticLayout(mHint, - 0, mHint.length(), - mTextPaint, hintWidth, alignment, mTextDir, mSpacingMult, - mSpacingAdd, mIncludePad, mEllipsize, - ellipsisWidth, mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); - } else { - mHintLayout = new StaticLayout(mHint, mTextPaint, - hintWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, - mIncludePad); } - } else if (shouldEllipsize) { - mHintLayout = new StaticLayout(mHint, - 0, mHint.length(), - mTextPaint, hintWidth, alignment, mTextDir, mSpacingMult, - mSpacingAdd, mIncludePad, mEllipsize, - ellipsisWidth, mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); - } else { - mHintLayout = new StaticLayout(mHint, mTextPaint, - hintWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, - mIncludePad); + } + // TODO: code duplication with makeSingleLayout() + if (mHintLayout == null) { + StaticLayout.Builder builder = StaticLayout.Builder.obtain(mHint, 0, + mHint.length(), hintWidth) + .setPaint(mTextPaint) + .setAlignment(alignment) + .setTextDir(mTextDir) + .setSpacingMult(mSpacingMult) + .setSpacingAdd(mSpacingAdd) + .setIncludePad(mIncludePad) + .setBreakStrategy(mBreakStrategy); + if (shouldEllipsize) { + builder.setEllipsize(mEllipsize) + .setEllipsizedWidth(ellipsisWidth) + .setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); + } + mHintLayout = builder.build(); } } @@ -6544,9 +6577,8 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener Layout result = null; if (mText instanceof Spannable) { result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth, - alignment, mTextDir, mSpacingMult, - mSpacingAdd, mIncludePad, getKeyListener() == null ? effectiveEllipsize : null, - ellipsisWidth); + alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad, mBreakStrategy, + getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth); } else { if (boring == UNKNOWN_BORING) { boring = BoringLayout.isBoring(mTransformed, mTextPaint, mTextDir, mBoring); @@ -6583,29 +6615,27 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener boring, mIncludePad, effectiveEllipsize, ellipsisWidth); } - } else if (shouldEllipsize) { - result = new StaticLayout(mTransformed, - 0, mTransformed.length(), - mTextPaint, wantWidth, alignment, mTextDir, mSpacingMult, - mSpacingAdd, mIncludePad, effectiveEllipsize, - ellipsisWidth, mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); - } else { - result = new StaticLayout(mTransformed, mTextPaint, - wantWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, - mIncludePad); } - } else if (shouldEllipsize) { - result = new StaticLayout(mTransformed, - 0, mTransformed.length(), - mTextPaint, wantWidth, alignment, mTextDir, mSpacingMult, - mSpacingAdd, mIncludePad, effectiveEllipsize, - ellipsisWidth, mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); - } else { - result = new StaticLayout(mTransformed, mTextPaint, - wantWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, - mIncludePad); } } + if (result == null) { + StaticLayout.Builder builder = StaticLayout.Builder.obtain(mTransformed, + 0, mTransformed.length(), wantWidth) + .setPaint(mTextPaint) + .setAlignment(alignment) + .setTextDir(mTextDir) + .setSpacingMult(mSpacingMult) + .setSpacingAdd(mSpacingAdd) + .setIncludePad(mIncludePad) + .setBreakStrategy(mBreakStrategy); + if (shouldEllipsize) { + builder.setEllipsize(effectiveEllipsize) + .setEllipsizedWidth(ellipsisWidth) + .setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE); + } + // TODO: explore always setting maxLines + result = builder.build(); + } return result; } diff --git a/core/res/res/values/attrs.xml b/core/res/res/values/attrs.xml index b6d32b2dfac4..3945222d662f 100644 --- a/core/res/res/values/attrs.xml +++ b/core/res/res/values/attrs.xml @@ -4283,6 +4283,15 @@ + + + + + + + + + diff --git a/core/res/res/values/public.xml b/core/res/res/values/public.xml index 5c7daf2a28a9..f59a4d898c93 100644 --- a/core/res/res/values/public.xml +++ b/core/res/res/values/public.xml @@ -2656,5 +2656,6 @@ + diff --git a/core/res/res/values/styles.xml b/core/res/res/values/styles.xml index cc64b4314560..3c3d2861447c 100644 --- a/core/res/res/values/styles.xml +++ b/core/res/res/values/styles.xml @@ -497,6 +497,7 @@ please see styles_device_defaults.xml. ?attr/textEditSideNoPasteWindowLayout ?attr/textEditSuggestionItemLayout ?attr/textCursorDrawable + high_quality