前言
我们现在在 Android App 中几乎天天都能见到 ImageSpan,比如 App 自定义的 emoji 表情和文本中带的一些小图标等。
既然都有 emoji 了,为啥还要自定义呢,输入法里的 emoji 和 ImageSpan 的有啥不一样呢?。要做这玩意儿肯定还是产品提的需求啦,想自己原创一些比较 贱 的表情吧。现在好多输入法都自带的 emoji 只是一串字符,TextView 在渲染的时候会用系统自带的表情把它们画出来,而ImageSpan的话就需要自己手动画对应的图片。我在使用
ImageSpan的过程中也遇到了一些坑,在这里就做下总结,分享出来,有什么不对的地方,也请各路大佬批评指正。
ImageSpan
1、ImageSpan 来自 ReplacementSpan
ReplacementSpan中空实现了updateMeasureState和updateDrawState
/**
* This method does nothing, since ReplacementSpans are measured
* explicitly instead of affecting Paint properties.
*/
public void updateMeasureState(TextPaint p) { }
/**
* This method does nothing, since ReplacementSpans are drawn
* explicitly instead of affecting Paint properties.
*/
public void updateDrawState(TextPaint ds) { }
而新增了getSize和draw来进行图片位置的确定以及图片内容的绘制
/**
* Returns the width of the span. Extending classes can set the height of the span by updating
* attributes of {@link android.graphics.Paint.FontMetricsInt}. If the span covers the whole
* text, and the height is not set,
* {@link #draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} will not be
* called for the span.
*
* @param paint Paint instance.
* @param text Current text.
* @param start Start character index for span.
* @param end End character index for span.
* @param fm Font metrics, can be null.
* @return Width of the span.
*/
public abstract int getSize(@NonNull Paint paint, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end,
@Nullable Paint.FontMetricsInt fm);
/**
* Draws the span into the canvas.
*
* @param canvas Canvas into which the span should be rendered.
* @param text Current text.
* @param start Start character index for span.
* @param end End character index for span.
* @param x Edge of the replacement closest to the leading margin.
* @param top Top of the line.
* @param y Baseline.
* @param bottom Bottom of the line.
* @param paint Paint instance.
*/
public abstract void draw(@NonNull Canvas canvas, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
int top, int y, int bottom, @NonNull Paint paint);
getSize中的各个参数注释中已经解释的很清楚啦,但是这个FontMetricsInt是干啥的,注释中是我们可以通过它改变高度。其实它还会改变绘制图片时候的基线baseline和 TextView 的布局,布局的问题稍后再说,这个baseline又是啥呢,这个网上资料就太多了,简单的说就是文字的绘制都是通过这个baseline对齐的,除此之外还有top、ascent、descent、bottom和leading,可以参考下图,详细的就需要上网查资料吧。顺便附上扔物线大神的HenCoder教程: 自定义 View 1-3 drawText() 文字的绘制
draw方法就比较直白一点,就是画图嘛(当然需要一定的自定义绘制基础,不懂的可以参考上面的HenCoder教程),其中的y就是刚才说的baseline,整个一行的文本(或图片)都是以他对齐的,draw方法需要注意的是在使用ellipsize时要自己处理(你说的你行你上呀,画布画笔都给你了,省略号也自己画吧)。而继承它的
DynamicDrawableSpan只是简单的实现了ALIGN_BOTTOM和ALIGN_BASELINE两种对齐方式,同时给了public abstract Drawable getDrawable();让其子类实现获得图片资源的方法,而继承DynamicDrawableSpan的ImageSpan则实现了该方法,提供了通过drawable、resouce id和uri等方式获得图片的方法。由此可见,
DynamicDrawableSpan和ImageSpan其实没有干太多的事情,真正要用好ImageSpan还是需要熟悉getSize和draw两个方法,接下来就稍微深入地讨论下这两个方法使用过程中的坑。
2、ImageSpan 的 getSize 和 draw 都干了啥
其实这两个方法的实现都在DynamicDrawableSpan中,getSize简单粗暴的设置了FontMetricsInt,fm.top = fm.ascent = -drawable高度; fm.bottom = fm.descent = 0;,并返回drawable的宽度,乍一看没问题,但是这一块是有坑的,稍后详细说。
draw方法中处理了对齐方式,底部对齐的就设置transY = bottom - b.getBounds().bottom;,然后把画布挪过去再进行绘制。基线对齐的还要transY -= paint.getFontMetricsInt().descent;,原因是baseline在bottom上面距descent的地方,所以底部对齐后再减去descent就与baseline对齐了。
注:这里的“底部对齐”指的是整行文本的底部,而不是文字的底部
接下来就来试一下不同对齐的效果吧。
ALIGN_BOTTOM,第二个是ALIGN_BASELINE,用了三种字号来展示效果,图中红线大致就是baseline的位置。接下来重写下
draw方法,搞一个顶部对齐的ImageSpan,draw的时候把画布移动到顶部canvas.translate(x, top);,效果如下图所示,第三个就是顶部对齐的。
3、居中对齐的 ImageSpan 怎么搞
当然,我们最想看的还是文字居中对齐,看起来更舒服一点。既然要与文字对齐,那就需要有与文字对齐的参考系,上面用的bottom和top注释中也都写了,是Bottom of the line和Top of the line,所以它们都是整行的,而不是文字的,要想与文字对齐,只能用baseline,因为绘制文字时也是以它对齐,然后再算出图片需要相对偏移多少就行了。
那么问题就来了,图片的baseline在哪里呢?这就要用到之前提到过的getSize中的FontMetricsInt,它设置了图片的ascent和descent等东西,其实也就设置了baseline的相对位置,这里注意的是,ascent和descent都是参考baseline的,以baseline = 0,ascent < 0在上面,descent > 0在下面。从DynamicDrawableSpan的getSize实现中可以知道图片的baseline是与descent重合的,要与文字居中就是文字的ascent和descent要和图片的居中,这样我们知道了图片的baseline,就可以确定图片与文字居中对齐时的位置了。
ascent,红线是baseline,蓝线是文字的descent,黄线是图片的ascent或top。设transY为画布最终要偏移的纵坐标,y为baseline坐标,fm为文字的FontMetrics,textHeight = fm.descent - fm.ascent为文字高度,drawableHeight为图片高度。首先图片是与文字
baseline对齐的,我们要先把图片底部与文字的descent对齐,transY = y - fm.descent
offsetHeight = textHeight - drawableHeight,图片在往上移动一半的高度差,transY -= offsetHeight / 2得到图片底部坐标,再减去图片的高度就得到最终的纵坐标偏移量,trasnY -= drawableHeight。化简下就是
transY = y + (fm.descent + fm.ascent) / 2 - drawableHeight / 2。y + (fm.descent + fm.ascent) / 2得到文字中间的纵坐标,在减去drawableHeight / 2得到图片顶部的位置。最终绘制出来的效果如下图,从左数第四个笑脸就是
居中对齐的:当我们还沉浸在“居中对齐”的美好中时,QA 同学突然提 Bug 了:“你这个图片底部怎么被截了一部分?”,“。。。啥?”
和谐和自由间的笑脸,之前为了显示清楚,也为了扩大TextView的画布范围,我给它们都加了padding,现在把padding都设为0就出现了这种情况。究其原因,居中对齐相对其他对齐方式都往下移了一小部分,导致ImageSpan绘制的图片超出了TextView的范围,所以就画不出来了。要解决这类问题就要用到最开始说的能够改变
baseline和布局的getSize。大致浏览下TextView的源码也能看到,TextView在onMeasure时会通过它的Layout去计算高度,在StaticLayout中会有Span高度计算之类的代码,TextView的Layout通过计算一行文本或Span的高度生成一些LineHeightSpan用于表示每行的高度,这里就不再深入源码了,免得无法自拔。
bottom,绿框表示文字计算出的区域,蓝色是图片计算的区域。图片为了居中对齐往下挪了一点,导致上面留出了空白,而下面缺画不出来了。我们可以通过重写getSize来改变图片计算的绘制区域,从而改变TextView测量时得到的绘制范围,图片就能正常绘制出来了。这部分代码比较简单,原理也跟
draw中居中对齐的处理类似,就直接贴代码了。
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
// return super.getSize(paint, text, start, end, fm);
Drawable d = getDrawable();
Rect rect = d.getBounds();
float drawableHeight = rect.height();
Paint.FontMetrics paintFm = paint.getFontMetrics();
float textHeight = paintFm.descent - paintFm.ascent;
if (fm != null) {
float textCenter = (paintFm.descent + paintFm.ascent) / 2;
fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
fm.descent = fm.bottom = (int) (drawableHeight + fm.ascent);
}
return rect.right;
}
居中对齐的ImageSpan,倒数第二个是没有重写getSize的,它也由于扩宽了显示范围而显示完整了,布局大致如下图所示。TextView的这一整行都被顶高一点,这在TexView上倒是没啥影响,但在EditText上就不一样了。你想呀,本来只有文字的时候是这么高,加了图片(表情)之后这一行变高了,就会抖动一下,删掉时也会抖一下。大家可以拿微信试一下,在纯文本加第一个表情,或者纯表情加第一个文本的时候,都会轻微地抖一下,也都是这个原因。这种抖动的问题也不是不能解决,其实在
getSize的时候把图片高度设置成和文字高度一样,再手动把图片的Bitmap调整到对应大小,重写getDrawable自己做下图片缓存,就没问题了。但是这样也带了性能损耗和内存浪费,有点得不偿失,怎么选还是看需求吧。
4、Ellipsize 的省略号被“吃”掉了
平字上加一个ImageSpan,第二行在平和等各加一个,第三行把后面的、也加上ImageSpan。可以明显看出省略号被ImageSpan给吃掉了。要解决这个问题就要知道啥时候是倒末尾了,不能在往后画了。其实
TextView的Layout中有getEllipsisStart和getEllipsisCount可以知道哪里被省略了,但draw里面并没有TextView的引用,而且你拿到的text里的内容虽然变了,但它的长度还是折叠之前的长度(WTF),那怎么办呢?text被折叠了,但它的长度又没变,那省略号后面的是啥东西呢。
'.',而是一个字符'…'
'\uFEFF',上网一查,原来是 BOM(Byte Order Mark),Big-Endian 时为'\uFEFF',Little-Endian时为'\uFFFE',这些字符不影响显示,但影响长度判断,需要去掉。在draw的前面加上
text = text.toString().replace("\uFEFF", "").replace("\uFFFE", "");
if (start >= text.length()) {
return;
}
这里为了安全起见,把'\uFFFE'也给替换掉,这样超过省略号的部分也就不会再绘制了。
text是不是要画'…',是的话就画'…',不是的话就画原来的图片。
if (end > text.length()) {
end = text.length();
}
String subText = text.subSequence(start, end).toString();
if ("…".equals(subText)) {
canvas.drawText(text, start, end, x, y, paint);
return;
}
这样就 OK 啦!
结束
罗嗦了这么多,也算是把使用ImageSpan时遇到的问题说清楚了,由于没有详细研究TextView及其Layout的源码,那里讲的有问题还请各位大佬批评指正。下面附上做实验用的源码,github.com/funnywolfda…。下一篇会讲一下ClickableSpan的坑和我的解决方法。