TextView 图文混排,解决图片被截断

在 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
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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,875评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,569评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,475评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,459评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,537评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,563评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,580评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,326评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,773评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,086评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,252评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,921评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,566评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,190评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,435评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,129评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,125评论 2 352

推荐阅读更多精彩内容