在 Android 中做图文混排,一般都是选择用 TextView 来实现

TextView 的图文混排,是使用 ImageSpan 来显示图片,但是一般情况,效果不理想,可能会上截断,或者下截断,如下图
截断
也看过了 ImageSpan 的源码,根据源码调距离,怎么调也不准确,遂去啃了一下 TextView 的部分源码,主要是画的部分,并且找到了画 ImageSpan 相关的代码,是在 TextLine.java 中的 handleReplacement() 函数,ImageSpan 是继承自 ReplacementSpan 的

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) { // 更新 Metrics
            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;
}
// 取两端最大值
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);
}

到这里,我们不得不介绍一下 top 、ascent 、descent 、bottom 以及 leading 的含义和作用,我们来看一下图
Matics
在 TextView 中每一行都有一条基线,叫 BaseLine ,文本的绘制是从这里开始的,这是以当前行为坐标系,y 方向为 0 的一条线,也就是说,BaseLine 以上是负数,以下是正数,我们清楚了正负数关系之后,再说其他几个概念
ascent 是从 BaseLine 向上到字符的最高处
descent 是从 BaseLine 向下到字符的最低处
leading 是表示 上一行的 descent 到当前行的 ascent 之间的距离
在说 top 和 bottom 之前,需要知道,世界上很多国家,文字书写也是不相同的,有些文字可能带有读音符之类的上标或者下标,比如上图中的 A ,它上面的波浪线就是类似于读音符(具体是啥,我也不知道),Android 为了更好的画出这些上标或者下标,特意在每一行的 ascent 和 descent 外都预留了一点距离,即 top 是 ascent 加上上面预留出来的距离所表示的坐标,bottom 也是一样的

根据上面的概念,就可以写一个自己的 AlignImageSpan ,它继承自 ImageSpan

public class AlignImageSpan extends ImageSpan {
    /**
     * 顶部对齐
     */
    public static final int ALIGN_TOP = 3;
    /**
     * 垂直居中
     */
    public static final int ALIGN_CENTER = 4;
    @IntDef({ALIGN_BOTTOM, ALIGN_BASELINE, ALIGN_TOP, ALIGN_CENTER})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Alignment {
    }
    public AlignImageSpan(Drawable d) {
        this(d, ALIGN_CENTER);
    }
    public AlignImageSpan(Drawable d, @Alignment int verticalAlignment) {
        super(d, verticalAlignment);
    }
    @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) {
            Paint.FontMetrics fmPaint = paint.getFontMetrics();
            // 顶部 leading
            float topLeading = fmPaint.top - fmPaint.ascent;
            // 底部 leading
            float bottomLeading = fmPaint.bottom - fmPaint.descent;
            // 当前行 的高度
            float fontHeight = fmPaint.descent - fmPaint.ascent;
            // drawable 的高度
            int drHeight = rect.height();
            switch (mVerticalAlignment) {
                case ALIGN_CENTER: { // drawable 的中间与 行中间对齐
                    // 整行的 y方向上的中间 y 坐标
                    float center = fmPaint.descent - fontHeight / 2;
                    // 算出 ascent 和 descent
                    float ascent = center - drHeight / 2;
                    float descent = center + drHeight / 2;
                    fm.ascent = (int) ascent;
                    fm.top = (int) (ascent + topLeading);
                    fm.descent = (int) descent;
                    fm.bottom = (int) (descent + bottomLeading);
                    break;
                }
                case ALIGN_BASELINE: { // drawable 的底部与 baseline 对齐
                    // 所以 ascent 的值就是 负的 drawable 的高度
                    float ascent = -drHeight;
                    fm.ascent = -drHeight;
                    fm.top = (int) (ascent + topLeading);
                    break;
                }
                case ALIGN_TOP: { // drawable 的顶部与 行的顶部 对齐
                    // 算出 descent
                    float descent = drHeight + fmPaint.ascent;
                    fm.descent = (int) descent;
                    fm.bottom = (int) (descent + bottomLeading);
                    break;
                }
                case ALIGN_BOTTOM: // drawable 的底部与 行的底部 对齐
                default: {
                    // 算出 ascent
                    float ascent = fmPaint.descent - drHeight;
                    fm.ascent = (int) ascent;
                    fm.top = (int) (ascent + topLeading);
                }
            }
        }
        return rect.right;
    }
    /**
     * 这里的 x, y, top 以及 bottom 都是基于整个 TextView 的坐标系的坐标
     *
     * @param x      drawable 绘制的起始 x 坐标
     * @param top    当前行最高处,在 TextView 中的 y 坐标
     * @param y      当前行的 BaseLine 在 TextView 中的 y 坐标
     * @param bottom 当前行最低处,在 TextView 中的 y 坐标
     */
    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
        Drawable drawable = getDrawable();
        Rect rect = drawable.getBounds();
        float transY;
        switch (mVerticalAlignment) {
            case ALIGN_BASELINE:
                transY = y - rect.height();
                break;
            case ALIGN_CENTER:
                transY = ((bottom - top) - rect.height()) / 2 + top;
                break;
            case ALIGN_TOP:
                transY = top;
                break;
            case ALIGN_BOTTOM:
            default:
                transY = bottom - rect.height();
        }
        canvas.save();
        // 这里如果不移动画布,drawable 就会在 Textview 的左上角出现
        canvas.translate(x, transY);
        drawable.draw(canvas);
        canvas.restore();
    }
    private Drawable getCachedDrawable() {
        WeakReference<Drawable> wr = mDrawableRef;
        Drawable d = null;
        if (wr != null)
            d = wr.get();
        if (d == null) {
            d = getDrawable();
            mDrawableRef = new WeakReference<>(d);
        }
        return d;
    }
    private WeakReference<Drawable> mDrawableRef;
}

效果如图
效果图
代码不多,大部分地方我都写了注释了,其中 getSize() 函数是在不同对齐方式下,对 FontMetricsInt 里面的各种成员赋值,之后,TextLine 的 updateMetrics() 函数会取最大的值保留为新的属性,这样就实现了当前行高的扩大,避免了 drawable 被截断的问题
只要理解了上面说的各种概念,这个就十分简单了,ImageSpan 中只提供了 ALIGN_BOTTOM 和 ALIGN_BASELINE 两个对齐方式,在 AlignImageSpan 我增加了 ALIGN_TOP 和 ALIGN_CENTER 更灵活一些
这个类并不一定适用所有的情况,我是用来显示自定义表情图片的,可能再大的图就不适用了,授人以鱼不如授人以渔,在这里,我把渔和鱼都放出来了,相信对大家的学习是有一定的帮助的

demo 已经上传到 github

参考链接
Android ImageSpan使TextView的图文居中对齐
自定义控件其实很简单1/4