TextView的measure

本文深入解析TextView的测量机制,包括单行无span文本的测量流程、包含ImageSpan的测量细节及垂直居中处理方案。通过代码解读,揭示TextView高度如何受字号、文字内容及ImageSpan的影响。

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值