關于TextView的使用,作為一個Android開發者來說,想必大家已經用到想吐了。但是這個TextView可以說是最簡單也是最復雜的View之一,首先說它簡單,因為在界面上它卻是非常簡單,就一個破文本,沒啥值得炫耀的。然后說它復雜,是因為。。。不說了,自己打開TextView的源碼瞅瞅吧!(1個類一萬多行代碼,你跟我說不復雜試試~_~)
坑是這樣的
廢話不多說了,最近碰到一個很坑爹的問題,因為開發過程中lint靜態掃描代碼老提示singleLine已經廢棄,讓我用maxLines="1"替換。我就想著,這么簡單,換就換唄,萬萬沒想到,越是這么簡單越讓人掉坑。咱先上圖:
效果很簡單,一行字符串單行顯示,末尾用省略號顯示,沒毛??!咱看看布局文件是怎么寫的:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="200dp"
android:layout_height="wrap_content"
android:background="#adddcc"
android:singleLine="true"
android:text="父視圖在對子視圖進行measure操作的過程中,使用變量mTotalLength保存已經measure過的child所占用的高度,該變量剛開始時是0。在for循環中調用measureChildBeforeLayout" />
</LinearLayout>
布局也是簡單的不要不要的,然而AndroidStudio確給我這樣的提示。
看著挺簡單的嘛,不就是用maxLines="1"替換singleLine="true",好,咱們就用maxLines來替換來看下效果,修改后的xml如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#adddcc"
android:maxLines="1"
android:text="父視圖在對子視圖進行measure操作的過程中,使用變量mTotalLength保存已經measure過的child所占用的高度,該變量剛開始時是0。在for循環中調用measureChildBeforeLayout" />
</LinearLayout>
嗯,很好,運行下試試。
尼瑪,這效果有點坑爹啊,為啥后面會留有一坨空白的,而且省略號也沒有了?
真的是嚇得我趕緊看看API來壓壓驚:
singleLine主要是約束文本顯示在水平的一行,而maxLines則負責TextView高度最多只有一行高。看來maxLine并不關心省略號怎么顯示啊,這里可能很多人都知道要結合ellipsize屬性使用才能看到省略號效果。好,我們來試試:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#adddcc"
android:maxLines="1"
android:ellipsize="end"
android:text="父視圖在對子視圖進行measure操作的過程中,使用變量mTotalLength保存已經measure過的child所占用的高度,該變量剛開始時是0。在for循環中調用measureChildBeforeLayout" />
</LinearLayout>
效果還是很不錯的嘛!
是的,但是回到之前沒有設置ellipsize屬性的時候,為什么后面會留出一坨空白?我們現在就這個問題來分析分析。咱們首先該下布局文件如下,
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#adddcc"
android:maxLines="3"
android:ellipsize="end"
android:text="父視圖在對子視圖進行measure操作的過程中,使用變量mTotalLength保存已經measure過的child所占用的高度,該變量剛開始時是0。在for循環中調用measureChildBeforeLayout" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="#adddcc"
android:maxLines="3"
android:ellipsize="end"
android:text="adfadfadgfadsfgfsdhfhdfghdfghdfga dfagsdfgsfghfghdfghsdfghsfgfghdfghsdfghs/fgfghdfghsdfghsfgghsfgfghdfghsdfghsfgghsfgfghdfghsdfghsfgghsfgfghdfghsdfghsfg" />
</LinearLayout>
再來看看效果吧
看到上面這效果,我的眼淚都快掉了,為啥無緣無故給我亂折行?第一個TextView中有中文字符和英文字符混搭,結果文本繪制就有問題了。第二個是全英文字符,然后和一些特殊字符如空格、斜杠、問號等混搭,也出現亂折行的問題。而且還跟特殊字符的位置有關,我們這里把第二個TextView中文本的斜杠放到空格后兩個試試效果:
尼瑪,現在第二個TextView又不折行了,真的是想死的心都有了。。。
你以為坑就到這了,太天真了,來來來,我們在上面的基礎上再修改下文本,在后面加個斜杠試試:
哎哎哎,哥們,陽臺在哪里,別攔著我。。。
翻翻源碼吧
好吧好吧,咱們來看看源碼這是咋回事,打開那個TextView,看著一萬多行代碼,從哪里開始看呢。問題在哪里咱們就從哪里開始,由于文本最終是通過onDraw方法繪制出來的,咱就先看下這個方法,看看text文本是怎么繪制出來的。然而并沒有發現canvas.drawText等類似方法,但是有幾句代碼還是比較可疑
@Overrideprotected void onDraw(Canvas canvas) {
...
if (mLayout == null) {
assumeLayout(); //1
}
Layout layout = mLayout;
if (mEditor != null) {
mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);
} else {
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical); //2
}
}
因為mEditor為空,所以咱們只要看這個layout,這個layout是個啥東西呢,看1的方法assumeLayout()。
/** * Make a new Layout based on the already-measured size of the view,
* on the assumption that it was measured correctly at some point. */
private void assumeLayout() {
int width = mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight();
if (width < 1) {
width = 0;
}
int physicalWidth = width;
if (mHorizontallyScrolling) {
width = VERY_WIDE;
}
makeNewLayout(width, physicalWidth, UNKNOWN_BORING, UNKNOWN_BORING, physicalWidth, false);
}
繼續看makeNewLayout這個方法,
/** * The width passed in is now the desired layout width, * not the full view width with padding. * {@hide} */
protected void makeNewLayout(int wantWidth, int hintWidth, BoringLayout.Metrics boring, BoringLayout.Metrics hintBoring, int ellipsisWidth, boolean bringIntoView) {
...
mLayout = makeSingleLayout(wantWidth, boring, ellipsisWidth, alignment, shouldEllipsize, effectiveEllipsize, effectiveEllipsize == mEllipsize);
...
}
我們主要看這個layout的初始化,所以進入makeSingleLayout方法,
private Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth, Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize, boolean useSaved) {
Layout result = null;
if (mText instanceof Spannable) { //1
result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad, mBreakStrategy, mHyphenationFrequency, getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);
} else {
if (boring == UNKNOWN_BORING) { //2、
boring = BoringLayout.isBoring(mTransformed, mTextPaint, mTextDir, mBoring);
if (boring != null) {
mBoring = boring;
}
}
if (boring != null) {
if (boring.width <= wantWidth && (effectiveEllipsize == null || boring.width <= ellipsisWidth)) {
if (useSaved && mSavedLayout != null) {
result = mSavedLayout.replaceOrMake(mTransformed, mTextPaiwantWidth, alignment, mSpacingMult, mSpacingAdd, boring, mIncludePad);
} else {
result = BoringLayout.make(mTransformed, mTextPaint, wantWidth, alignment, mSpacingMult, mSpacingAdd, boring, mIncludePad);
}
if (useSaved) {
mSavedLayout = (BoringLayout) result;
}
} else if (shouldEllipsize && boring.width <= wantWidth) {
if (useSaved && mSavedLayout != null) {
result = mSavedLayout.replaceOrMake(mTransformed, mTextPaint,wantWidth, alignment, mSpacingMult, mSpacingAdd, boring, mIncludePad, effectiveEllipsize, ellipsisWidth);
} else {
result = BoringLayout.make(mTransformed, mTextPaint, wantWidth, alignment, mSpacingMult, mSpacingAdd, boring, mIncludePad, effectiveEllipsize, ellipsisWidth);
}
}
}
if (result == null) {
StaticLayout.Builder builder = StaticLayout.Builder.obtain(mTransformed, 0, mTransformed.length(), mTextPaint, wantWidth)
.setAlignment(alignment).setTextDirection(mTextDir)
.setLineSpacing(mSpacingAdd, mSpacingMult)
.setIncludePad(mIncludePad).setBreakStrategy(mBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency);
if (shouldEllipsize) {
builder.setEllipsize(effectiveEllipsize)
.setEllipsizedWidth(ellipsisWidth)
.setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE);
}
// TODO: explore always setting maxLines
result = builder.build(); //3、
}
return result;
}
看到上面的3個注釋,都是xxxLayout,結合onDraw方法里的layout.draw(...),我們便能推測出TextView繪制文本的過程應都是交給這個Layout去完成的。于是趕緊去安利下這個Layout,這個layout主要有3個子類
1、BoringLayout 主要負責顯示單行文本,并提供了isBoring方法來判斷是否滿足單行文本的條件。
2、DynamicLayout 當文本為Spannable的時候,TextView就會使用它來負責文本的顯示,在內部設置了SpanWatcher,當檢測到span改變的時候,會進行reflow,重新計算布局。
3、StaticLayout 當文本為非單行文本,且非Spannable的時候,就會使用StaticLayout,內部并不會監聽span的變化,因此效率上會比DynamicLayout高,只需一次布局的創建即可,但其實內部也能顯示SpannableString,只是不能在span變化之后重新進行布局而已。
到了這里,大概能知道上面那個坑出在哪塊了,由于之前給定的text為非單行文本,而且非Spannable,所以我們具體看看這個StaticLayout。根據UI界面顯示的效果和問題所在,可以斷定出問題的應該是在折行處理這一塊。然后debug,發現generate方法的這句代碼:
void generate(Builder b, boolean includepad, boolean trackpad) {
...
int breakCount = nComputeLineBreaks(b.mNativePtr, lineBreaks, lineBreaks.breaks, lineBreaks.widths, lineBreaks.flags, lineBreaks.breaks.length);
...
}
這個方法的作用是處理文本折行的問題,無奈是個JNI方法,然后我看了一短時間并沒有搞懂google的折行機制,不過發現這個折行跟很多特殊字符有關(! $ - + 空格 } | / ? 等等)。。。
不過textview的折行包含以下規律:
1、半角字符與全角字符混亂所致:這種情況一般就是漢字與數字、英文字母混用。
2、TextView在顯示中文的時候標點符號不能顯示在一行的行首和行尾,如果一個標點符號剛好在一行的行尾,該標點符號就會連同前一個字符跳到下一行顯示。
3、一個英文單詞不能被顯示在兩行中( TextView在顯示英文時,標點符號是可以放在行尾的,但英文單詞也不能分開 )。
至于解決方案,網上有一種http://niufc.iteye.com/blog/1729792, 但是效果不佳,git上也有一個行文本左右對齊的自定義TextViewhttps://github.com/ufo22940268/android-justifiedtextview, 但是效果也不是非常好。
這里還有個問題值得思考,為什么maxLine="1"加上ellipsize="end"屬性后,就能正常出現省略號還不留白?
這是因為,StaticLayout中針對ellipsize屬性,對文本內容單獨進行了處理(之前的折行處理效果在這里就不管用了),然后Layout.onDraw的時候只會繪制處理完后的text。具體方法在out中
private int out(CharSequence text, int start, int end, int above, int below, int top, int bottom, int v, float spacingmult, float spacingadd,
LineHeightSpan[] chooseHt, int[] chooseHtv, Paint.FontMetricsInt fm, int flags, boolean needMultiply, byte[] chdirs, int dir,
boolean easy, int bufEnd, boolean includePad,boolean trackPad, char[] chs,
float[] widths, int widthStart, TextUtils.TruncateAt ellipsize, float ellipsisWidth, float textWidth, TextPaint paint, boolean moreChars) {
...
if (ellipsize != null) {
// If there is only one line, then do any type of ellipsis except when it is MARQUEE
// if there are multiple lines, just allow END ellipsis on the last line
boolean forceEllipsis = moreChars && (mLineCount + 1 == mMaximumVisibleLineCount);
boolean doEllipsis = (((mMaximumVisibleLineCount == 1 && moreChars) || (firstLine && !moreChars)) && ellipsize != TextUtils.TruncateAt.MARQUEE) || (!firstLine && (currentLineIsTheLastVisibleOne || !moreChars) && ellipsize == TextUtils.TruncateAt.END);
if (doEllipsis) {
calculateEllipsis(start, end, widths, widthStart, ellipsisWidth, ellipsize, j, textWidth, paint, forceEllipsis);
}
}
mLineCount++;
return v;
}
大家感興趣繼續看calculateEllipsis這個方法吧。。。
說了那么多,其實最主要的坑還是google的折行算法有問題,