背景
前段时间在写一个TextView的属性的时候,需要设置最大字数,然后超出部分省略号显示。这个功能其实是非常简单的,于是我不假思索的就写下了这段功能。(下面用测试代码代替)
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="这是一段测试的文本"
android:maxEms="7"
android:ellipsize="end"
android:maxLines="1"
android:lineSpacingMultiplier="1.5"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
稀疏平常的一段代码,run一下看下效果:有点奇怪了,文本并没有在第七个字开始变成省略号。
一开始以为是我记错了属性,把maxEms改成maxLength,但是似乎并没有效果。Google了一下找到的都是TextView文本多行导致的ellipsize失效,但是我们这里使用的就是单行,不存在多行情况,所以问题变得特别奇怪了。
后来尝试着将android:lineSpacingMultiplier属性去掉以后,看了下效果:
发现竟然解决了问题!于是比较疑惑了,为啥行间距设置会影响ellipsize属性。当然稀里糊涂的解决问题并不是我的风格,所以决定深入了解下为什么会产生这个问题。
当然在了解这个问题之前,首先先来看下,TextView正常情况下是如何设置Ellipsize属性的。
TextView在绘制的时候会借助Layout类,而Layout只是个抽象类,所以根据不同的情况,TextView会创建不同的Layout子类来赋值自己绘制文字。所以我们需要从onMeasure看起:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mLayout == null) {
makeNewLayout(want, hintWant, boring, hintBoring,
width - getCompoundPaddingLeft() - getCompoundPaddingRight(), false);
}
}
onMeasure里面会先判断Layout是否存在,不存在的时候执行makeNewLayout创建对应的Layout。
@VisibleForTesting
@UnsupportedAppUsage
public 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);
}
makeNewLayout接下来会调用makeSingleLayout方法。
/**
* @hide
*/
protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,
Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,
boolean useSaved) {
Layout result = null;
...代码省略...
if (result == null) {
StaticLayout.Builder builder = StaticLayout.Builder.obtain(mTransformed,
0, mTransformed.length(), mTextPaint, wantWidth)
.setAlignment(alignment)
.setTextDirection(mTextDir)
.setLineSpacing(mSpacingAdd, mSpacingMult)
.setIncludePad(mIncludePad)
.setUseLineSpacingFromFallbacks(mUseFallbackLineSpacing)
.setBreakStrategy(mBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency)
.setJustificationMode(mJustificationMode)
.setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE);
if (shouldEllipsize) {
builder.setEllipsize(effectiveEllipsize)
.setEllipsizedWidth(ellipsisWidth);
}
result = builder.build();
}
return result;
}
正常情况下,设置了maxEms的TextView会创建StaticLayout方法,然后设置对应的Ellipsize属性。接下来TextView会在onDraw的时候调用Layout的draw方法进行绘制
public void draw(Canvas canvas, Path highlight, Paint highlightPaint,
int cursorOffsetVertical) {
final long lineRange = getLineRangeForDraw(canvas);
int firstLine = TextUtils.unpackRangeStartFromLong(lineRange);
int lastLine = TextUtils.unpackRangeEndFromLong(lineRange);
if (lastLine < 0) return;
drawBackground(canvas, highlight, highlightPaint, cursorOffsetVertical,
firstLine, lastLine);
drawText(canvas, firstLine, lastLine);
}
此处执行drawText方法,下面直接给出调用链:
Layout#drawText()
TextLine#set()
TextUtils#getChars()
Ellipsizer#getChars()
Layout#ellipsize()
TextUtils#getEllipsisString()
最后通过调用textUtils的getEllipsisString方法获取到省略号,然后拼接到字符串当中去。
那么问题来了:为什么设置了lineSpacingMultiplier以后Ellipsize就失效了呢。这就要在onMeasure里面找原因了
if (mMaxWidthMode == EMS) {
width = Math.min(width, mMaxWidth * getLineHeight());
} else {
width = Math.min(width, mMaxWidth);
}
onMeasure方法里面有上面这段代码,当设置了maxEms的时候,width也就是控件的宽度就是去当前width和mMaxWidth * getLineHeight()的最小值,width就是当前测量的android:text所包含的文案的总宽度,而mMaxWidth就是maxEms属性值即7。那么再来看下getLineHeight获取的是什么?
public int getLineHeight() {
return FastMath.round(mTextPaint.getFontMetricsInt(null) * mSpacingMult + mSpacingAdd);
}
getLineHeight其实获取的就是行高,mSpacingMult就是lineSpacingMultiplier的属性,mSpacingAdd则是lineSpacingExtra属性,总的来说就是设置最后的行高。
那么width和mMaxWidth * getLineHeight就目前来看是谁大呢,我们不妨算一下:
width=当前文字总数即 9 * 文字本身宽度
mMaxWidth * getLineHeight() = 7 * 文字本身宽度 * 1.5
很明显width更小,所以最后设置的width就是当前文字总数即 9 * 文字本身宽度了。
width什么作用呢?在获取到width以后,接下来就是makeNewLayout方法了,然后会在makeSingleLayout里面创建对应的Layout实例。
protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,
Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,
boolean useSaved) {
Layout result = null;
if (useDynamicLayout()) {
...代码省略
} else {
if (boring == UNKNOWN_BORING) {
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, mTextPaint,
wantWidth, 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)
.setUseLineSpacingFromFallbacks(mUseFallbackLineSpacing)
.setBreakStrategy(mBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency)
.setJustificationMode(mJustificationMode)
.setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE);
if (shouldEllipsize) {
builder.setEllipsize(effectiveEllipsize)
.setEllipsizedWidth(ellipsisWidth);
}
result = builder.build();
}
return result;
}
我们可以看到只有在result为空的情况下才会创建StaticLayout,而我们此时传入的wantWidth就是当前文字总数的宽度,boring.width获取到也是当前文字总数的宽度。所以最后会创建BoringLayout。
而BoringLayout重写了Layout的draw方法,也就是说当TextView在调用mLayout.draw的时候最后其实进到BoringLayout的draw方法中:
@Override
public void draw(Canvas c, Path highlight, Paint highlightpaint,
int cursorOffset) {
if (mDirect != null && highlight == null) {
c.drawText(mDirect, 0, mBottom - mDesc, mPaint);
} else {
super.draw(c, highlight, highlightpaint, cursorOffset);
}
}
BoringLayout的draw方法很简单的就是调用了canvas的drawText,所以Ellipsize就会失效了。
总结
如果需求需要设置lineSpacingMultiplier或者是lineSpacingExtra,那么似乎并没有什么特别好的解决方案可以防止Ellipsize失效。
PS:其实Ellipsize并不是真正的失效,而是此时最小宽度与boring的width一致了。如果想要实现Ellipsize为End的效果那么可以设置maxEms为5(以上面给出的demo为例)
另外就是对于width的获取操作比较费解,不懂为啥在获取宽度的时候要用行高作为乘数。