对于富文本展示,比如字体变化、背景色、前景色等,一般都需要使用SpannableString配合各种系统自带的Span(比如AbsoluteSizeSpan、BackgroundColorSpan等等)来实现。
创建ReplacementSpan的子类,可以自定义实现更丰富的样式,本文通过一个常见小需求来介绍ReplacementSpan的使用方法
** 文章需要读者对Canvas使用有基础了解即可
参考文档:
Span | Android 开发者 | Android Developers ReplacementSpan | Android Developers
利用SpannableString富文本方式设置圆角标签背景
Android Textview获取文本高度及drawable 居中
需求
下面是视觉稿,在文本前面展示一个标签,标签字体比文本小,颜色为白色,背景为带有圆角的矩形。
(可以直接跳过中间部分,使用最后一页中的代码实现)

1.修改标签大小
如果只是前两个字为小字体会很容易实现:
直接使用AbsoluteSizeSpan,修改字体大小

ini
val spannable1 = SpannableString("散件" + goodsName)
spannable1.setSpan(
AbsoluteSizeSpan(20), 0, 2,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
binding.tvGoodsName1.text = spannable1
2.绘制背景色和文本
ReplacementSpan是一个抽象类,有两个抽象方法:getSize 返回span的宽度,draw绘制span。
draw方法的参数比较多,可以看下,其中x、top、bottom、y是我们绘制过程中最重要的坐标参数:
less
/**
* 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. span文本其实下标
* @param end End character index for span. span文本结束下标
* @param x Edge of the replacement closest to the leading margin. span边缘接近leading margin处,可以认为是span最左边坐标。
* @param top Top of the line. span顶部
* @param y Baseline. span绘制文本的基线
* @param bottom Bottom of the line. span底部
* @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);
先实现一个自定义ReplacementSpan,绘制圆角矩形和文本(先不管他们的位置是否居中):
kotlin
class RoundBackgroundColorSpan(
private val bgColor: Int,
private val textColor: Int,
private val padding: Int,
private val radio: Int,
) :
ReplacementSpan() {
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
// 设置宽度 为文字左右添加 padding
return paint.measureText(text, start, end).toInt() + (padding * 2)
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int, // span顶部
y: Int,
bottom: Int, // span底部
paint: Paint
) {
// 保存原始颜色
val originalColor = paint.color
// 修改背景色
paint.color = bgColor
// 绘制圆角矩形背景
canvas.drawRoundRect(
RectF(
x,
top.toFloat(),
x + (paint.measureText(text, start, end).toInt() + (padding * 2)),
bottom.toFloat()
),
radio.toFloat(),
radio.toFloat(),
paint
)
paint.color = textColor
// 画文字,增加 padding
canvas.drawText(text, start, end, x + padding, y.toFloat(), paint)
// 将paint颜色复原
paint.color = originalColor
}
}
解释一下代码的内容:
RoundBackgroundColorSpan构造方法中有四个参数:
bgColor - 背景色
textColor - 文本颜色
padding - 文本的边距(使标签文本左右两边留一定留白间距)
radio - 背景矩圆角矩形的圆角大小
paint.measureText 方法可以测量文本宽度,加上两个padding就是文字整体占用的宽度
canvas.drawRoundRect 方法用来绘制圆角矩形
canvas.drawText 绘制文本
将我们创建的span设置应用:
less
val spannable1 = SpannableString("散件" + goodsName)
spannable1.setSpan(
AbsoluteSizeSpan(20), 0, 2,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
spannable1.setSpan(
RoundBackgroundColorSpan(
Color.parseColor("#108EE9"),
Color.WHITE,
padding,
radio
),
0,
2,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
binding.tvGoodsName1.text = spannable1
跑代码看看效果:

效果有了,但是还差点意思:背景矩形太高,标签文字也没有居中。
【如果设置标签文字和后面的文字大小一样(不使用AbsoluteSizeSpan),效果如下图:
】
3.使标签文字居中
绘制文本的方法是canvas.drawText,这是这部分的重点
下面是drawText的源码
less
/**
* Draw the specified range of text, specified by start/end, with its origin at (x,y), in the
* specified Paint. The origin is interpreted based on the Align setting in the Paint.
*
* @param text The text to be drawn
* @param start The index of the first character in text to draw
* @param end (end - 1) is the index of the last character in text to draw
* @param x The x-coordinate of origin for where to draw the text
* @param y The y-coordinate of origin for where to draw the text
* @param paint The paint used for the text (e.g. color, size, style)
*/
public void drawText(@NonNull CharSequence text, int start, int end, float x, float y,
@NonNull Paint paint) {
super.drawText(text, start, end, x, y, paint);
}
绘制文本时,传入坐标x,y。这个坐标并不是文本的左上角或者右下角,而是文本的基线起始位置。下图中左侧标注了基线(baseline)的位置。

(图取自文章Android Textview获取文本高度及drawable 居中)
Paint有个公共方法叫做getFontMetrics,这个方法可以获得字体相关的一个对象FontMetrics,这个对象中有上图字体的关键数据:
arduino
/**
* Class that describes the various metrics for a font at a given text size.
* Remember, Y values increase going down, so those values will be positive,
* and values that measure distances going up will be negative. This class
* is returned by getFontMetrics().
*/
public static class FontMetrics {
/**
* The maximum distance above the baseline for the tallest glyph in
* the font at a given text size.
*/
public float top;
/**
* The recommended distance above the baseline for singled spaced text.
*/
public float ascent;
/**
* The recommended distance below the baseline for singled spaced text.
*/
public float descent;
/**
* The maximum distance below the baseline for the lowest glyph in
* the font at a given text size.
*/
public float bottom;
/**
* The recommended additional space to add between lines of text.
*/
public float leading;
}
FontMetrics 类描述了给定文本大小的字体的各种度量标准。该类由 getFontMetrics() 方法返回。Y 值随着向下而增加,这些描述的值以基线baseline为基点,即baseline的值为0来进行描述,top 和 ascent 的值为负值,descent 和 bottom 的值为正值。比如,baseline到字体顶部的距离就是 -top(top为负值,那么-top就是正值)。
通过fontMetrics获取文本高度(参考Android Textview获取文本高度及drawable 居中)
ini
val fm = paint.fontMetrics
val fontHeight = fm.top - fm.bottom
有了以上数据,我们就可以来计算,baseline是是多少时,可以让文字居中展示。
参考下图开始计算baseline:
下图标注了要计算的baseline的位置,外边框矩形为原字体高度区域。
1.计算center坐标:top和bottom为draw方法的参数,那么center = (top + bottom) / 2。
2.计算标签的高度:tagFontHeight = fm.bottom - fm.top(fm为paint获取到的fontMetrics)
3.计算tag top 坐标:tagTop = center - tagFontHeight/2 (Android Canvas坐标系是Y轴向下)
4.计算baseline坐标:baseline = tagTop + (-fm.top) = tagTop - fm.top (fm.top为负值)

继续计算标签背后有颜色的圆角矩形的顶部和底部坐标
5.计算标签背后圆角矩形的top = tag top
6.计算标签背后圆角矩形的bottom = tag top + tagFontHeight
如上计算代码如下:
kotlin
class RoundBackgroundColorSpan(
private val bgColor: Int,
private val textColor: Int,
private val padding: Int,
private val radio: Int,
) :
ReplacementSpan() {
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
//设置宽度为文字宽度加 padding
return paint.measureText(text, start, end).toInt() + (padding * 2)
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val originalColor = paint.color
val fm = paint.fontMetrics
paint.color = bgColor
val center = (top + bottom) / 2
val tagFontHeight = fm.bottom - fm.top
val tagTop = center - tagFontHeight / 2
val baseline = tagTop - fm.top
//画圆角矩形背景
canvas.drawRoundRect(
RectF(
x,
tagTop,
x + (paint.measureText(text, start, end).toInt() + (padding * 2)),
tagTop + tagFontHeight
),
radio.toFloat(),
radio.toFloat(),
paint
)
paint.color = textColor
//画文字,两边各增加8dp
canvas.drawText(text, start, end, x + padding, baseline, paint)
//将paint复原
paint.color = originalColor
}
}
最后效果就出来啦:

总结
不论是ReplacementSpan还是自定义View,Android已经封装好我们重写draw方法就可以进行绘制实现。
我们在绘制前,需要对canvas和paint的功能有个大概了解。
绘制步骤
1.思路捋清楚(要画哪些东西,怎么画)
2.查canvas的绘制的api及用到的参数(drawText、drawXXX等等)
3.用已有数据计算坐标