自定义Span
前面的文章介绍了一些常见 Span
的使用场景及其使用示例,本文继续来学习自定义Span
。那么,系统已经提供很多种类的Span了,为什么还要自定义?
- 自定义
Span
可以根据具体需求实现更多样化的文本样式、交互效果和文本布局。 - 当内置的
Span
类无法满足需求时,或者希望实现更定制化的效果时,可以考虑自定义Span
。
既然要自定义Span
,就要考虑父类用哪个合适。
- 在
字符级别
影响文本 -> CharacterStyle - 在
段落级别
影响文本 -> ParagraphStyle - 影响
文本外观
-> UpdateAppearance - 影响
文本测量尺寸
-> UpdateLayout
其中
CharacterStyle
中的updateDrawState(TextPaint tp)
本质上是改变画笔TextPaint
的属性;ReplacementSpan
及其扩展类DynamicDrawableSpan
不满足于只修改TextPaint
画笔属性,而是使用圈定Rect
绘制区域,然后自行绘制这个区域的效果。
不过大部分场景下,我们不用继承到这么深的父类,选择合适的已有Span
进行扩展是一个不错的选择。
题外话:其实可以类比下自定义View
,我们不用每次都去继承 View
或者 ViewGroup
,选择一个合适已有的父类(如横向排版时可以考虑LinearLayout
作为基类)会让整个过程变得简单。
如果系统提供的Span
基本符合需求,只需要轻微调整,此时父类就选择已有的Span
即可;如果系统提供的Span
不符合需要,可以考虑通过继承ReplacementSpan
来实现。
下面对两种场景分别给出详细示例。
FontMetricsInt 必知必会
在学习自定义 Span
之前,有必要再复习下FontMetrics
,该类描述了 Text
文本的关键指标信息:
public static class FontMetrics {
public float top;
public float ascent;
public float descent;
public float bottom;
public float leading;
}
FontMetricsInt
跟 FontMetrics
含义相同,只是将类中成员变量都改为int
修饰了:
public static class FontMetricsInt {
public int top;
public int ascent;
public int descent;
public int bottom;
public int leading;
}
我们来看下类中的成员变量都是什么含义。
Baseline
是基线,在Android
中,文字的绘制都是从Baseline
处开始的,Baseline
往上至字符“最高处”的距离我们称之为ascent
(上坡度),Baseline
往下至字符“最低处”的距离我们称之为descent
(下坡度);leading
(行间距)则表示上一行字符的descent
到该行字符的ascent
之间的距离;top
和bottom
文档描述地很模糊,其实这里我们可以借鉴一下TextView
对文本的绘制,TextView
在绘制文本的时候总会在文本的最外层留出一些内边距,为什么要这样做?因为TextView
在绘制文本的时候考虑到了类似读音符号,下图中的A
上面的符号就是一个拉丁文的类似读音符号的东西:
top
的意思其实就是除了Baseline
到字符顶端的距离外还应该包含这些符号的高度,bottom
的意思也是一样。
一般情况下我们极少使用到类似的符号所以往往会忽略掉这些符号的存在,但是Android
依然会在绘制文本的时候在文本外层留出一定的边距,这就是为什么top
和bottom
总会比ascent
和descent
大一点的原因。而在TextView
中我们可以通过xml
设置其属性android:includeFontPadding="false"
去掉一定的边距值但是不能完全去掉。
详细参见:Android深入理解文字绘制:FontMetrics字体测量及其TextPaint介绍
示例一: 文本环绕图片
分析上面 UI
效果:段落的前几行进行缩进,余下行不再缩进,在缩进的空间里绘制一张图。看到这里,基本能想到用哪个父类了,没错,就是LeadingMarginSpan2
。
/**
* @param lineCount 行数
* @param mFirst 段落前N行margin 单位dp
* @param mRest 段落剩余行margin 单位dp
*/
class TextAroundSpan(
private var imgInfo: ImgInfo,
private val lineCount: Int,
private val mFirst: Int,
private val mRest: Int = 0,
) : LeadingMarginSpan2 {
/**
* 段落缩进的行数
*/
override fun getLeadingMarginLineCount(): Int = lineCount
/**
* @param first true作用于段落中前N行(N为getLeadingMarginLineCount()中返回的值),否则作用于段落剩余行
*/
override fun getLeadingMargin(first: Boolean): Int =
if (first) mFirst.dp2px() else mRest.dp2px()
/**
* 绘制页边距(leading margin)。在{@link #getLeadingMargin(boolean)}返回值调整页边距之前调用。
*/
override fun drawLeadingMargin(
canvas: Canvas?,
paint: Paint?,
x: Int,
dir: Int,
top: Int,
baseline: Int,
bottom: Int,
text: CharSequence?,
start: Int,
end: Int,
first: Boolean,
layout: Layout?,
) {
if (canvas == null || paint == null) return
val drawable: Drawable = imgInfo.drawable
canvas.save()
drawable.setBounds(0, 0, imgInfo.width, imgInfo.height)
canvas.translate(imgInfo.dx, imgInfo.dy)
drawable.draw(canvas)
canvas.restore()
}
data class ImgInfo(
val drawable: Drawable,
val width: Int,
val height: Int,
val dx: Float = 1.dp2px().toFloat(),
val dy: Float = 2.dp2px().toFloat(),
)
}
- getLeadingMarginLineCount:段落缩进的行数;
- getLeadingMargin(boolean):
true
作用于段落中前N行(N为getLeadingMarginLineCount()
中返回的值),否则作用于段落剩余行; - drawLeadingMargin:绘制页边距(
leading margin
),用于绘制空出来的margin
,在getLeadingMargin(boolean)
返回值调整页边距之前调用。
使用它:
private const val SPAN_STR =
"悯农锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。"
private fun processTagSpan() {
val imgDrawable = ResourcesCompat.getDrawable(resources, R.drawable.icon_flower, null)
val builder = SpannableStringBuilder(SPAN_STR)
builder.setSpan(TextAroundSpan(TextAroundSpan.ImgInfo(imgDrawable!!, 90.dp2px(), 90.dp2px()), 4, 100), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSpan.text = builder
}
执行结果就是上面的效果图,可以看到通过继承已有的Span
实现了我们想要的效果。
示例二: 文本内部打Tag标签
现有的 Span
无法满足需求,考虑使用ReplacementSpan
来实现上述效果,ReplacementSpan
本身是个抽象类,需要实现内部的 getSize
和 draw
两个方法:
- getSize:返回当前
Span
需要的宽度。子类可以通过更新Paint.FontMetricsInt
的属性来设置Span
的高度。如果Span
覆盖了整个文本,并且高度没有设置,那么draw
方法将不会调用。 - draw:将
Span
绘制到Canvas
中,有了Canvas
和Paint
后,就可以绘制我们想要的效果了。
下面是具体实现代码:
/**
* 自定义Tag Span
*
* @property tagColor tag外框颜色
* @property tagRadius tag圆角半径
* @property tagStrokeWidth tag外框宽度
* @property tagMarginLeft tag外框左侧的margin
* @property tagMarginRight tag外框右侧的margin
* @property tagPadding tag内侧文字padding
* @property txtSize 文字大小
* @property txtColor 文字颜色
*/
class TagSpan(
private val tagColor: Int = Color.RED,
private val tagRadius: Float = 2.dp2px().toFloat(),
private val tagStrokeWidth: Float = 1.dp2px().toFloat(),
private val tagMarginLeft: Float = 0.dp2px().toFloat(),
private val tagMarginRight: Float = 5.dp2px().toFloat(),
private val tagPadding: Float = 2.dp2px().toFloat(),
private val txtSize: Float = 14.sp2px().toFloat(),
private val txtColor: Int = Color.RED,
) : ReplacementSpan() {
private var mSpanWidth = 0 //包含了Span文字左右间距在内的宽度
/**
* 返回Span的宽度。子类可以通过更新Paint.FontMetricsInt的属性来设置Span的高度。
* 如果Span覆盖了整个文本,并且高度没有设置,那么draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)方法将不会调用。
*
* @param paint Paint画笔
* @param text 当前文本
* @param start Span开始索引
* @param end Span结束索引
* @param fm Paint.FontMetricsInt,可能是空
* @return 返回Span的宽度
*/
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?,
): Int {
if (text.isNullOrEmpty()) return 0
paint.textSize = txtSize
//测量包含了Span文字左右间距在内的宽度
mSpanWidth = (paint.measureText(text, start, end) + getTxtLeftW() + getTxtRightW()).toInt()
return mSpanWidth
}
/**
* 将Span绘制到Canvas中
*
* @param canvas Canvas画布
* @param text 当前文本
* @param start Span开始索引
* @param end Span结束索引
* @param x Edge of the replacement closest to the leading margin.
* @param top 行文字显示区域的Top
* @param y Baseline基线
* @param bottom 行文字显示区域的Bottom 当在XML中设置lineSpacingExtra时,这里也会受影响
* @param paint Paint画笔
*/
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint,
) {
if (text.isNullOrEmpty()) return
paint.run {
color = tagColor
isAntiAlias = true
isDither = true
style = Paint.Style.STROKE
strokeWidth = tagStrokeWidth
}
//文字高度
val txtHeight = paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent
//1、绘制标签
val tagRect = RectF(
x + getTagLeft(), top.toFloat(),
x + mSpanWidth - tagMarginRight, (top + txtHeight).toFloat()
)
canvas.drawRoundRect(tagRect, tagRadius, tagRadius, paint)
//2、绘制文字
paint.run {
color = txtColor
style = Paint.Style.FILL
}
// 计算Baseline绘制的Y坐标 ,计算方式:画布高度的一半 - 文字总高度的一半
val baseY = tagRect.height() / 2 - (paint.descent() + paint.ascent()) / 2
//绘制标签内文字
canvas.drawText(text, start, end, x + getTxtLeftW(), baseY, paint)
}
private fun getTagLeft(): Float {
return tagMarginLeft + tagStrokeWidth
}
/**
* Span文字左侧所有的间距
*/
private fun getTxtLeftW(): Float {
return tagPadding + tagMarginLeft + tagStrokeWidth
}
/**
* Span文字右侧所有的间距
*/
private fun getTxtRightW(): Float {
return tagPadding + tagMarginRight + tagStrokeWidth
}
}
主要思路:
- 首先在
getSize
中通过paint.measureText()
来获取Span
文字的宽度,注意还要加上Span
文字左右的padding
和margin
; - 接着在
draw
中将Span
绘制到Canvas
中,通过文字的宽度和左右padding
来确定Tag
边框的范围并绘制出来。确定了Tag
边框的范围,找到边框中心然后继续绘制文字即可。
Tips:在使用组合Span
时,如果在ReplacementSpan
中改变了Span
的宽度,需要最先设置 ReplacementSpan
,再设置其它Span
,避免出现位置错乱问题。