在 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 的含义和作用,我们来看一下图
在 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 wr = mDrawableRef;
Drawable d = null;
if (wr != null)
d = wr.get();
if (d == null) {
d = getDrawable();
mDrawableRef = new WeakReference<>(d);
}
return d;
}
private WeakReference mDrawableRef;
}
效果如图
代码不多,大部分地方我都写了注释了,其中 getSize() 函数是在不同对齐方式下,对 FontMetricsInt 里面的各种成员赋值,之后,TextLine 的 updateMetrics() 函数会取最大的值保留为新的属性,这样就实现了当前行高的扩大,避免了 drawable 被截断的问题
只要理解了上面说的各种概念,这个就十分简单了,ImageSpan 中只提供了 ALIGN_BOTTOM 和 ALIGN_BASELINE 两个对齐方式,在 AlignImageSpan 我增加了 ALIGN_TOP 和 ALIGN_CENTER 更灵活一些
这个类并不一定适用所有的情况,我是用来显示自定义表情图片的,可能再大的图就不适用了,授人以鱼不如授人以渔,在这里,我把渔和鱼都放出来了,相信对大家的学习是有一定的帮助的
demo 已经上传到 github
参考链接
Android ImageSpan使TextView的图文居中对齐
自定义控件其实很简单1/4