本文测试环境为api30
前言
从本文可以学习到什么?
- TextView#setIncludeFontPadding如何影响布局
- 图文混排如何随意对齐图片和文字
相信很多android小伙伴接触过图片混排的需求。通常如果不是很注意的话,使用ImageSpan
这个类也就对付过去了。但如果TextView的情况稍微复杂些,你就会发现这个类非常鸡肋。
在详细了解图文混排之前,我们先来看看android的文字有哪些基准线。这就涉及到了FontMetricsInt
类。该类是纯数据类型,定义了5个整数top ascent descent bottom leading
,再加上lineTop
,lineBottom
,这个“七线谱”定义了文字如何绘制。
绘制文字的七线谱
关于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后,存在什么问题:
-
调整行高:
行高 调整文字大小:
- 无法动态设置图片大小和左右边距
以上三个问题,导致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);
该方法有两个注意的点
- 通过返回值,确认ImageSpan所占的宽度
- 通过设置fm,影响行高和文本的baseline位置
储备知识:TextView依赖Layout显示文本。sdk提供了三种类型的Layout:
BoringLayout、StaticLayout、DynamicLayout
通常情况下,单行文本使用BoringLayout显示
后续对ImageSpan的getSize、draw的讨论均是基于BoringLayout。其他两种layout类似
首先看下getSize
的调用路径:
可以看到,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);
}
需要注意两点:
- getSize中对参数fm的修改,不会影响Paint
- fm修改后的top、ascent不能大于旧值;leading、descent、bottom不能小于旧值。否则会被重设为旧值
修改后的fm在如下onMeasure后续调用中起作用:
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影响
先留意下这里的mBottom
和mDesc
两个值。
下面看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为参照
最终解决方案
了解了上述的计算,我们来看看如何准确覆写getSize
和draw
,
同样使用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的情况,有两种处理方式供参考:
- 忽略:
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的影响。
- 保持原有尺寸设置:
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