TextView的measure
一个简单的TextView,宽高都是wrap_content,那么宽高是如何确定的?
我们都知道,measure一下文本需要多宽、多高,然后定下来,我猜测高度与字号有关,而宽度与具体文字有关。
单行无span文本
我们先搞个简单的例子,写个TextView,里边文本为“电饭锅的双方各j电饭锅电/可爱饭锅电饭锅和鬼地方个很大方”,研究下他的onMeasure过程。
下边的代码是一个TextView的onMeasure的简单流程(单行且没有Span)。首先会调用 BoringLayout.isBoring去量一下这个textview里面的内容,得到一个返回值boring,boring是一个FontMetricsInt,内容是FontMetricsInt: top=-40 ascent=-34 descent=9 bottom=11 leading=0 width=986。这里不仅有常见的4信息(top,ascent,descent,bottom),还有width,width量出来是为了后边的分行以及Layout选择用。此时看L3,我们的width已经有了,还差个height,height怎么取?在mLayout确定之后去问mLayout要高度。getDesiredHeight是跟mLayout要高度,我们这里是BoringLayout,所以取的是layout.getLineTop(1).getLineTop是Layout的方法,是取某行的纵向坐标。对于BoringLayout来说就是返回bottom。这个bottom值,我看到是就是上边的FontMetricsInt里的bottom-top的值(51)。此时宽高都有了,在L10set进去就好了。
//TextView的onMeasure的简单流程
boring = BoringLayout.isBoring(mTransformed, mTextPaint, mTextDir, mBoring);
width = boring.width;
...
makeNewLayout(want, hintWant, boring, hintBoring,
width - getCompoundPaddingLeft() - getCompoundPaddingRight(), false);
//去问layout要高度值
height = getDesiredHeight();
...
setMeasuredDimension(width, height);
includeFontPadding作用
我们都知道includeFontPadding写成false,会让textview高度小一点,但是这是怎么做到的呢?我们看看BoringLayout的代码。
/* package */ void init(CharSequence source,
TextPaint paint, int outerwidth,
Alignment align,
float spacingmult, float spacingadd,
BoringLayout.Metrics metrics, boolean includepad,
boolean trustWidth) {
int spacing;
...
if (includepad) {
spacing = metrics.bottom - metrics.top;
} else {
spacing = metrics.descent - metrics.ascent;
}
mBottom = spacing;
if (includepad) {
mDesc = spacing + metrics.top;
} else {
mDesc = spacing + metrics.ascent;
}
...
if (includepad) {
mTopPadding = metrics.top - metrics.ascent;
mBottomPadding = metrics.bottom - metrics.descent;
}
}
从L10可以看到对于includepad为true的,那高度就是metrics.bottom - metrics.top,而对于includepad为false的,那高度就是metrics.descent - metrics.ascent。再看L26,includepad为true的会有mTopPadding和mBottomPadding,所以includeFontPadding写成false,会让textview高度小一点。
includeFontPadding的官方解释是Leave enough room for ascenders and descenders instead of using the font ascent and descent strictly. (Normally true).实际上就是留出top到ascent之间,descent到bottom之间的空间,top到ascent之间的空间是留给像法语的重音符号。我一般都把includeFontPadding设置为false,这样可以省点空间。
加入ImageSpan后的measure
我们现在把之前的textview改的稍微复杂一点,加入一个ImageSpan,看看有什么变化。把“电饭锅的双方各j电饭锅电/可爱饭锅电饭锅和鬼地方个很大方”,里面的”/可爱”用一个图片来代替,这个图片大小67*67。我们这里关心下这个textview的measuredHeight是在什么时候确定的。主要就看下边这一行代码,他会量出textview需要的高度。这行代码执行完后,boring值为FontMetricsInt: top=-67 ascent=-67 descent=9 bottom=11 leading=0 width=964 ,所以高度就是bottom-top=78。
我们再对比下,之前没加span的时候,FontMetricsInt: top=-40 ascent=-34 descent=9 bottom=11 leading=0,可以发现主要是top和ascent改了。
boring = BoringLayout.isBoring(mTransformed, mTextPaint, mTextDir, mBoring);
那么,谁改懂了boring的top和ascent呢?我们来看看ImageSpan的父类DynamicDrawableSpan 的代码,主要看getSize方法,可以看到在这里对fm进行了设置,ascent和top都改为-rect.bottom即-67,top和descent改为0.这里我们找到了top和ascent改动的痕迹,但是为什么bottom和descent没有修改呢?
//DynamicDrawableSpan
@Override
public int getSize(Paint paint, CharSequence text,
int start, int end,
Paint.FontMetricsInt fm) {
Drawable d = getCachedDrawable();
Rect rect = d.getBounds();
if (fm != null) {
fm.ascent = -rect.bottom;
fm.descent = 0;
fm.top = fm.ascent;
fm.bottom = 0;
}
return rect.right;
}
一切还是得看代码,调用关系如下,所以主要看TextLine的handlerRun方法。
isBoring->TextLine:metrics(fm)->measure->measureRun->handleRun
handlerRun会计算出“电饭锅的双方各j电饭锅电/可爱饭锅电饭锅和鬼地方个很大方”会占多少宽度,其实我们简单的分析下,这段字符串展示出来后会是12个字符+一个图片+13个字符,是这样的组成,要想知道宽度,得量一下12个字符占多少宽度,图片占多少宽度,13个字符占多少宽度,实际上handlerRun内部就是这么工作的。handlerRun除了计算宽度,还有计算metrics的边界的功能,textview的3部分都拥有独立的metrics,他会获取这些metrics的边界。
//TextLine::handlerRun
private float handleRun(int start, int measureLimit,
int limit, boolean runIsRtl, Canvas c, float x, int top, int y,
int bottom, FontMetricsInt fmi, boolean needWidth) {
// Case of an empty line, make sure we update fmi according to mPaint
if (start == measureLimit) {
TextPaint wp = mWorkPaint;
wp.set(mPaint);
if (fmi != null) {
expandMetricsFromPaint(fmi, wp);
}
return 0f;
}
if (mSpanned == null) {
TextPaint wp = mWorkPaint;
wp.set(mPaint);
final int mlimit = measureLimit;
return handleText(wp, start, limit, start, limit, runIsRtl, c, x, top,
y, bottom, fmi, needWidth || mlimit < measureLimit, mlimit);
}
mMetricAffectingSpanSpanSet.init(mSpanned, mStart + start, mStart + limit);
mCharacterStyleSpanSet.init(mSpanned, mStart + start, mStart + limit);
// Shaping needs to take into account context up to metric boundaries,
// but rendering needs to take into account character style boundaries.
// So we iterate through metric runs to get metric bounds,
// then within each metric run iterate through character style runs
// for the run bounds.
final float originalX = x;
for (int i = start, inext; i < measureLimit; i = inext) {
TextPaint wp = mWorkPaint;
wp.set(mPaint);
inext = mMetricAffectingSpanSpanSet.getNextTransition(mStart + i, mStart + limit) -
mStart;
int mlimit = Math.min(inext, measureLimit);
。。。
if (replacement != null) {
x += handleReplacement(replacement, wp, i, mlimit, runIsRtl, c, x, top, y,
bottom, fmi, needWidth || mlimit < measureLimit);
continue;
}
for (int j = i, jnext; j < mlimit; j = jnext) {
jnext = mCharacterStyleSpanSet.getNextTransition(mStart + j, mStart + inext) -
mStart;
int offset = Math.min(jnext, mlimit);
wp.set(mPaint);
for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
// Intentionally using >= and <= as explained above
if ((mCharacterStyleSpanSet.spanStarts[k] >= mStart + offset) ||
(mCharacterStyleSpanSet.spanEnds[k] <= mStart + j)) continue;
CharacterStyle span = mCharacterStyleSpanSet.spans[k];
span.updateDrawState(wp);
}
// Only draw hyphen on last run in line
if (jnext < mLen) {
wp.setHyphenEdit(0);
}
x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,
top, y, bottom, fmi, needWidth || jnext < measureLimit, offset);
}
}
return x - originalX;
}
handlerRun如上所示对于第一段和第三段,主要调L67的handleText,而对于第二段就是图片部分主要调L44 handleReplacement。handleText和handleReplacement是类似的,就是返回某部分的宽度。简单的文本就用handleText,而像ImageSpan就用handleReplacement,我们看看handleReplacement的代码。这里我们关注下L28,getSize这一行,这会调用DynamicDrawableSpan的getSize方法,在getSize方法内我们修改了fm的ascent,descent,top,bottom。然后会调用L31 updateMetrics。
private float handleReplacement(ReplacementSpan replacement, TextPaint wp,
int start, int limit, boolean runIsRtl, Canvas c,
float x, int top, int y, int bottom, FontMetricsInt fmi,
boolean needWidth) {
float ret = 0;
int textStart = mStart + start;
int textLimit = mStart + limit;
if (needWidth || (c != null && runIsRtl)) {
int previousTop = 0;
int previousAscent = 0;
int previousDescent = 0;
int previousBottom = 0;
int previousLeading = 0;
boolean needUpdateMetrics = (fmi != null);
if (needUpdateMetrics) {
previousTop = fmi.top;
previousAscent = fmi.ascent;
previousDescent = fmi.descent;
previousBottom = fmi.bottom;
previousLeading = fmi.leading;
}
ret = replacement.getSize(wp, mText, textStart, textLimit, fmi);
if (needUpdateMetrics) {
updateMetrics(fmi, previousTop, previousAscent, previousDescent, previousBottom,
previousLeading);
}
}
if (c != null) {
if (runIsRtl) {
x -= ret;
}
replacement.draw(c, mText, textStart, textLimit,
x, top, y, bottom, wp);
}
return runIsRtl ? -ret : ret;
}
我们再看看updateMetrics的代码,非常简单,其实就是对2个fmi进行边界整合,获取并集,取绝对值大的。所以我们此时就明白了为什么,我们改top和ascent有效,但是bottom和descent无效。有效的原因是他们的绝对值比previous大,所以才会生效,而bottom和descent我们设置的是0,所以绝对值比previous小,所以失效。
static void updateMetrics(FontMetricsInt fmi, int previousTop, int previousAscent,
int previousDescent, int previousBottom, int previousLeading) {
fmi.top = Math.min(fmi.top, previousTop);
fmi.ascent = Math.min(fmi.ascent, previousAscent);
fmi.descent = Math.max(fmi.descent, previousDescent);
fmi.bottom = Math.max(fmi.bottom, previousBottom);
fmi.leading = Math.max(fmi.leading, previousLeading);
}
在handleRun执行完毕之后,其实TextView的measure宽高,都定下来了。
垂直居中
默认的ImageSpan直提供了ALIGN_BOTTOM和ALIGN_BASELINE,但是我们经常需要图文居中,针对这个问题,我查了下资料,发现要想做到完全居中,没什么好的办法,但是有2种方案可以大体居中,都是写个自定义的Span,重写getSize和draw方法。
第一种方案
第一种方案来自stackoverflow
@Override
public int getSize(Paint paint, CharSequence text,
int start, int end,
Paint.FontMetricsInt fm) {
Drawable d = getCachedDrawable();
Rect rect = d.getBounds();
Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
if (fm != null) {
// Centers the text with the ImageSpan
if (rect.bottom - (fmPaint.descent - fmPaint.ascent) >= 0) {
// Stores the initial descent and computes the margin available
initialDescent = fmPaint.descent;
extraSpace = rect.bottom - (fmPaint.descent - fmPaint.ascent);
}
fm.descent = extraSpace / 2 + initialDescent;
fm.ascent = -rect.bottom + fm.descent;
fm.bottom = fm.descent;
fm.top = fm.ascent;
}
return rect.right;
}
rect.bottom代表的是ImageSpan里的drawable的高度。这里的代码其实也很简单,如果drawable大就定最终高度为drawable高度,差值就是rect.bottom - (fmPaint.descent - fmPaint.ascent)。然后descent那里分一半的距离,ascent分一半,就理论上居中了。这里主要利用descent变大,就使得整个文字的baseline往上抬起。这里我对原来的代码改了下,原代码用的是fm的数据,我这里改成fmPaint的数据,因为fm的数据是会变化的,从前文的分析,我们知道fm其实是不断update的,而我们这里想要的其实就是普通文字的高度,拿paint的FontMetricsInt比较合适。这种方法也只能做到理论上居中,实际看了下,descent抬高的效果没有想象的那么好,比如我设置了descent为10,而实际上测量的descent可能只有6或8,至于为什么,我也没明白。
第二种方案
其实也差不多,getSize的算法不太一样,这个算法就有点莫名其妙了。但是效果还可以。这里我多给了个draw方法,为了支持有行间距的textview,其实第一种方案也需要加上这个draw方法。
public int getSize(Paint paint, CharSequence text, int start, int end,
Paint.FontMetricsInt fm) {
Drawable d = getCachedDrawable();
Rect rect = d.getBounds();
if (fm != null) {
Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
int fontHeight = fmPaint.bottom - fmPaint.top;
int drHeight = rect.bottom - rect.top;
int top = drHeight / 2 - fontHeight / 4;
int bottom = drHeight / 2 + fontHeight / 4;
fm.ascent = -bottom;
fm.top = -bottom;
fm.bottom = top;
fm.descent = top;
}
return rect.right ;
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end,
float x, int top, int y, int bottom, Paint paint) {
Drawable b = getCachedDrawable();
// font metrics of text to be replaced
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
//y是baseline的坐标,y + fm.descent得到字体的descent线坐标,y + fm.ascent得到字体的ascent线坐标
//两者相加除以2就是两条线中线的坐标
//中线坐标减image高度的一半就是image顶部要绘制的目标位置
int transY = (y + fm.descent + y + fm.ascent) / 2
- b.getBounds().bottom / 2;
int transX = (int) (x );
canvas.save();
canvas.translate(transX, transY);
b.draw(canvas);
canvas.restore();
}
疑问
DynamicDrawableSpan的draw方法偏移
draw方法如下,可以看到默认偏移了bottom - b.getBounds().bottom,为什么要做这个偏移呢?因为不偏移的画会从顶部往下画,可以看后边效果图,下边那张就是不偏移的结果。
@Override
public void draw(Canvas canvas, CharSequence text,
int start, int end, float x,
int top, int y, int bottom, Paint paint) {
Drawable b = getCachedDrawable();
canvas.save();
int transY = bottom - b.getBounds().bottom;
if (mVerticalAlignment == ALIGN_BASELINE) {
transY -= paint.getFontMetricsInt().descent;
}
canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}

结论
- descent变大,就使得整个文字的baseline往上抬起
ref
http://www.cnblogs.com/withwind318/p/5541267.html
http://stackoverflow.com/questions/25628258/align-text-around-imagespan-center-vertical
http://blog.youkuaiyun.com/gaoyucindy/article/details/39473135
本文深入解析TextView的测量机制,包括单行无span文本的测量流程、包含ImageSpan的测量细节及垂直居中处理方案。通过代码解读,揭示TextView高度如何受字号、文字内容及ImageSpan的影响。
3689

被折叠的 条评论
为什么被折叠?



