彻底解决android图文混排时,图片与文字对齐的问题

本文测试环境为api30

前言

从本文可以学习到什么?

  • TextView#setIncludeFontPadding如何影响布局
  • 图文混排如何随意对齐图片和文字

相信很多android小伙伴接触过图片混排的需求。通常如果不是很注意的话,使用ImageSpan这个类也就对付过去了。但如果TextView的情况稍微复杂些,你就会发现这个类非常鸡肋。
在详细了解图文混排之前,我们先来看看android的文字有哪些基准线。这就涉及到了FontMetricsInt类。该类是纯数据类型,定义了5个整数top ascent descent bottom leading,再加上lineToplineBottom,这个“七线谱”定义了文字如何绘制。

绘制文字的七线谱

关于leading的含义,源码注释不是很理解,网上说的貌似也不准确,测试结果一直为0。
本文把这个值理解为baseline,其他四个值以这个值为参照物。
如有其他见解,欢迎交流

在后面的图解中,上面5个值的描述如下:

描述
top textTop
ascent textAscent
leading textBaseline
descent textDescent
bottom textBottom

除了上面5个值,我们以lineTop lineBottom表示行的顶部和底部,共7个值。

下面我们画出一段文字的这7个值的位置。行线标为红色,FontMetricsInt线标为蓝色。

TextView的设置很简单,为了明确文本区域,设置了背景色:

    <TextView
            android:id="@+id/text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="100dp"
            android:background="#eeeeee"/>

先来看看行线:


行线

文本线为:


文本线

此时的值如下:


  • lineTop=0
  • lineBottom=450

baselineY即为leading在TextView中的绝对值,所以:

  • textTop=baselineY+top=0
  • textAscent=baselineY+ascent=44
  • textBaseline=baselineY+leading=358
  • textDescent=baselineY+descent=441
  • textBottom=baselineY+bottom=450

多行文本时:


多行文本

可以看到下一行的lineTop,就是上一行的lineBottom

添加lineSpace的情况

针对多行文本,在有lineSpance时,如下:


有间距时

可以看到,这种情况下,行高变了,但蓝线和lineTop的距离没变。

includeFontPadding

我们偶尔会使用setIncludeFontPadding来调整文本的高度,那这个设置修改的是什么呢?
这个设置影响的是第一行的top,和最后一行的bottom。

单行

图文混排的对齐问题

掌握了以上七条线,我们看看图文混排遇到的问题

ImageSpan

通过ImageSpan设置图文混排是最基本的手段。
下面看看在设置中心对齐的ImageSpan后,存在什么问题:

  1. 调整行高:


    行高
  2. 调整文字大小:

文字大小
  1. 无法动态设置图片大小和左右边距

以上三个问题,导致ImageSpan很鸡肋

如何自定义ImageSpan

ImageSpan中有两个重要的方法:

getSize

public abstract int getSize(@NonNull Paint paint, 
                    CharSequence text,@IntRange(from = 0) int start, @IntRange(from = 0) int end,
                    @Nullable Paint.FontMetricsInt fm);

该方法有两个注意的点

  1. 通过返回值,确认ImageSpan所占的宽度
  2. 通过设置fm,影响行高和文本的baseline位置

储备知识:TextView依赖Layout显示文本。sdk提供了三种类型的Layout:
BoringLayout、StaticLayout、DynamicLayout
通常情况下,单行文本使用BoringLayout显示
后续对ImageSpan的getSize、draw的讨论均是基于BoringLayout。其他两种layout类似

首先看下getSize的调用路径:

9TmnKtwDtVBSSIyx.png

可以看到,getSize方法是在TextLine的handleReplacement中调用的,该方法如下:

    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;

            //onMeasure这条调用链中,fmi是有值的。值由paint.getFontMetricsInt进行初始化。fmi是BoringLayout$Metrics类型
            //TextView的onDraw也会调用到这里,不过fmi为null
            boolean needUpdateMetrics = (fmi != null);

            if (needUpdateMetrics) {
                //保存旧值
                previousTop     = fmi.top;
                previousAscent  = fmi.ascent;
                previousDescent = fmi.descent;
                previousBottom  = fmi.bottom;
                previousLeading = fmi.leading;
            }
            //调用ImageSpan的getSize方法
            ret = replacement.getSize(wp, mText, textStart, textLimit, fmi);

            if (needUpdateMetrics) {
                //注意这个方法,getSize中修改后的FontMetricsInt,在这里会做矫正
                updateMetrics(fmi, previousTop, previousAscent, previousDescent, previousBottom,
                        previousLeading);
            }
        }

        if (c != null) {
            if (runIsRtl) {
                x -= ret;
            }
            //这里调用ImageSpan的draw方法,不过onMeasure这条调用链中,canvas为null,所以不会执行
            replacement.draw(c, mText, textStart, textLimit,
                    x, top, y, bottom, wp);
        }

        return runIsRtl ? -ret : ret;
    }

    static void updateMetrics(FontMetricsInt fmi, int previousTop, int previousAscent,
            int previousDescent, int previousBottom, int previousLeading) {
        //新的top、ascent不能比旧值大
        fmi.top     = Math.min(fmi.top,     previousTop);
        fmi.ascent  = Math.min(fmi.ascent,  previousAscent);

        //新的leading、descent、bottom不能比旧值小
        fmi.descent = Math.max(fmi.descent, previousDescent);
        fmi.bottom  = Math.max(fmi.bottom,  previousBottom);
        fmi.leading = Math.max(fmi.leading, previousLeading);
    }

需要注意两点:

  1. getSize中对参数fm的修改,不会影响Paint
  2. fm修改后的top、ascent不能大于旧值;leading、descent、bottom不能小于旧值。否则会被重设为旧值

修改后的fm在如下onMeasure后续调用中起作用:

fm

BoringLayout#init方法:

    //参数metrics就是我们修改后的FontMetricsInt
    /* package */ void init(CharSequence source, TextPaint paint, Alignment align,
            BoringLayout.Metrics metrics, boolean includePad, boolean trustWidth) {
        int spacing;

        if (source instanceof String && align == Layout.Alignment.ALIGN_NORMAL) {
            mDirect = source.toString();
        } else {
            mDirect = null;
        }

        mPaint = paint;

        //includePad就是TextView#setIncludeFontPadding对应的标志位
        //includePad为true,则行高为bottom-top;为false则行高为descent-ascent。和上面的动图表现一致
        if (includePad) {
            spacing = metrics.bottom - metrics.top;
            mDesc = metrics.bottom;
        } else {
            spacing = metrics.descent - metrics.ascent;
            mDesc = metrics.descent;
        }

        mBottom = spacing;
        ...
    }

getSize方法设置的metrics中,top==ascent,bottom==descent,则最终展现不受includeFontPadding影响

先留意下这里的mBottommDesc两个值。
下面看draw流程

draw

public void draw(@NonNull Canvas canvas, 
            CharSequence text, @IntRange(from = 0) int start, @IntRange(from = 0) int end, 
            float baselineX,int top, int baselineY, int bottom, 
            @NonNull Paint paint) {

调用路径为:

调用路径

看下drawText的部分逻辑:

int ltop = previousLineBottom;//lineTop值,因为只有一行,此时previousLineBottom为0
int lbottom = getLineTop(lineNum + 1);//lineBottom值,因为只有一行,此时lineNum为0
previousLineBottom = lbottom;
int lbaseline = lbottom - getLineDescent(lineNum);//baselineY值,也即textBaseline这条线

BoringLayout#getLineTop

public int getLineTop(int line) {
        if (line == 0)
            return 0;
        else
            //line不为0,则返回mBottom
            return mBottom;
    }

getLineDescent:

    public int getLineDescent(int line) {
        return mDesc;
    }

所以baselineY = mBottom - mDesc
以上就是在 getSize中设置的fm,对draw参数的影响。具体应该怎么设置,相信你也有了自己的想法

这里要注意的是,确定图片相对于文字的位置的时候,一定要使用baselineY这个值,否则就会被行高或其他因素影响

小结一下:

  • getSize
    • 返回值是该ImageSpan所占用的文本宽度
    • 对于参数FontMetricsInt的修改:
      • 修改不影响paint中的参数,只影响行高和文本基线位置
      • top、ascent不能大于原值;leading、descent、bottom不能小于原值;不符合的会被矫正成原值
      • 最终的行高受includeFontPadding影响:
        • 该值为true: lineBottom=FontMetricsInt.bottom-FontMetricsInt.top;文本基线=lineBottom-FontMetricsInt.bottom
        • 该值为false:lineBottom=FontMetricsInt.descent-FontMetricsInt.ascent;文本基线=lineBottom-FontMetricsInt.descent
  • draw
    • baselineY便是上面的textBaseline线
    • 绘制图片时,应该以baselineY为参照

最终解决方案

了解了上述的计算,我们来看看如何准确覆写getSizedraw
同样使用ImageSpan的三种对齐方式ALIGN_BASELINE ALIGN_BOTTOM ALIGN_CENTER,同时添加一种ALIGN_TOP方式。

定义如下变量:

  • oldFm——修改前的FontMetricsInt
  • newFm——修改后的FontMetricsInt
  • paintFm——Paint使用的FontMetricsInt,即绘制文字时使用的FontMetricsInt。里面的值和oldFm是一样的
  • drawableHeight——图片高度

同时以lineTop lineBottom textTop textAscent textBaseline textDescent textBottom表示上面的七条线
先讨论includeFontPadding为false的情况,此时不用考虑newFm的top和bottom,只关注ascent和descent即可
则七条线表示如下:

lineTop = 0;
lineBottom = newFm.descent-newFm.ascent;

textBaseline = lineBottom - newFm.descent = -newFm.ascent;//这个很重要

textTop = textBaseline + paintFm.top = paintFm.top - newFm.ascent;
textAscent = textBaseline + paintFm.ascent = paintFm.ascent - newFm.ascent;
textDescent = textBaseline + paintFm.descent = paintFm.descent - newFm.ascent;
textBottom = textBaseline+paintFm.bottom = paintFm.bottom - newFm.ascent;

ALIGN_BASELINE

基线对齐,即要求图片的底部和textBaseline对齐,并且图片需要完全展示出来:

textBaseline>= drawableHeight;
//即:
-newFm.ascent  >= drawableHeight;

取临界值newFm.ascent = -drawableHeight

关键代码:

public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            fm.ascent = -drawableHeight;
        }
}

public void draw(@NonNull Canvas canvas,
        CharSequence text, int start, int end,
        float baselineX, int lineTop, int baselineY, int lineBottom,
        @NonNull Paint paint) {
            int transY = baselineY - drawableHeight;
}

ALIGN_BOTTOM

这里取图片底部和Descent对齐。

textDescent >= drawableHeight;
//即:
paintFm.descent - newFm.ascent>=drawableHeight;

取临界值newFm.ascent = paintFm.descent - drawableHeight

关键代码:

public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            fm.ascent = fm.descent - drawableHeight;
        }
}

public void draw(@NonNull Canvas canvas,
        CharSequence text, int start, int end,
        float baselineX, int lineTop, int baselineY, int lineBottom,
        @NonNull Paint paint) {
            int transY = baselineY - drawableHeight + fontMetricsInt.descent;
}

ALIGN_CENTER

中心对齐,即图片的中心等于(textDescent-textAscent)/2,稍微复杂一些。有两个等式:

lineBottom - lineTop >= drawableHeight;  //行高不小于图片高度
//取临界值左右两边相等,则还有一个等式
textAscent - lineTop = lineBottom - textDescent; //文字在行中间

求解:

newFm.ascent = (paintFm.ascent + paintFm.descent - drawableHeight) / 2;
newFm.descent = (paintFm.ascent + paintFm.descent + drawableHeight) / 2

关键代码:

public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            int textAreaHeight = fm.descent - fm.ascent;
            //只有当文字区域比图片高度小的时候才对fm进行修改(这个判断加不加都行)
            if (textAreaHeight < drawableHeight) {
                //这个计算是为了保证文字居中
                int oldSumOfAscentAndDescent = fm.ascent + fm.descent;
                fm.ascent = oldSumOfAscentAndDescent - drawableHeight >> 1;
                fm.descent = oldSumOfAscentAndDescent + drawableHeight >> 1;
            }
        }
}

public void draw(@NonNull Canvas canvas,
        CharSequence text, int start, int end,
        float baselineX, int lineTop, int baselineY, int lineBottom,
        @NonNull Paint paint) {
            int transY = baselineY - drawableHeight +  fontMetricsInt.descent;//先底部对齐
            int fontHeight = fontMetricsInt.descent - fontMetricsInt.ascent;
            transY += (drawableHeight - fontHeight) >> 1;
}

ALIGN_TOP

顶部对齐,取图片顶部和textAscent对齐。则有:

lineBottom - textAscent >= drawableHeight;

取临界值求解:newFm.descent = paint.fm.ascent + drawableHeight;

关键代码:

public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            fm.descent = drawableHeight + fm.ascent;
        }
}

public void draw(@NonNull Canvas canvas,
        CharSequence text, int start, int end,
        float baselineX, int lineTop, int baselineY, int lineBottom,
        @NonNull Paint paint) {
            int transY = baselineY + fontMetricsInt.ascent;
}

处理includeFontPadding

对于includeFontPadding为true的情况,有两种处理方式供参考:

  1. 忽略:
public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            ....//计算新的ascent、descent值
            fm.top = fm.ascent;
            fm.bottom = fm.descent;
        }
}

这样就不受includeFontPadding的影响。

  1. 保持原有尺寸设置:
public int getSize(@NonNull Paint paint, CharSequence text,
        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
        @Nullable Paint.FontMetricsInt fm) {
        if (fm != null) {
            int deltaTop = fm.top - fm.ascent;
            int deltaBottom = fm.bottom - fm.descent;
            ....//计算新的ascent、descent值
            fm.top = fm.ascent + deltaTop;
            fm.bottom = fm.descent + deltaBottom;
        }
}

到此,我们已经掌握了随意处理图文混排的方法。


以上整理出了EnhancedImageSpan类,除了可以设置四种对齐方式外,还可以调整图片大小,或者让图片随文字大小动态变化,设置图片边距

最终效果

最后附上工程地址:ImageSpanDemo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
禁止转载,如需转载请通过简信或评论联系作者。

推荐阅读更多精彩内容