Android 富文本.md

**Android富文本的实现的几种方式
**在Android开发过程中,最常见的富文本场景一般都是变色,点击跳转,或者局部变大,而我们实现的方式通常分为两种。
一种是Html的方式定义在string中,通过html标签变色,变大,通过占位符填充数据。一般常用于有国际化的需求。
另一种是CharSequence的setSpan设置自定义Span。功能更强大,细读也更细,便于精准操作。一般用于没有国际化需求的地方。
为什么有国际化相关的要求,是因为一般setSpan的方式都是添加或者根据索引替换对应的文本,如果国际化之后中英马等语言的顺序都变了,自然效果就不同了。当然也可以通过判断语言进行不同的操作。这是后话了。

一,Html的方式实现

1.1 占位符的处理

先看看string xml中如何处理占位符 %N代表第N个参数,如%3代表的是第三个参数; $是结束符;

1
<string name="string_test_1">学号:%1$d ;姓名:%2$s ;成绩:%3$.2f</string>

使用的时候:

1
2
3
4
String testStr = getResources().getString(R.string.string_test_1);
String result = String.format(testStr,1001,"张三",9.235);
System.out.println(result);

1.2 Html的占位符

和上面的差不多:

1
2
3
<string name="purchase_points"><![CDATA[ <font color="#767676">Purchase with</font> 
<font color="#FF5E75">%s</font><font color="#767676"> points?</font>]]></string>

使用:

1
2
3
String formatPoints = PointFormatUtils.formatPoints(points);
String result = String.format(getResources().getString(R.string.purchase_points),formatPoints);
tv_message.setText(Html.fromHtml(result));

注:Html.fromHtml还分Android N的兼容处理,需要传入Model,不同的Model展示的效果有所不同,这里不做展开。其实效果大差不差。
实现效果:
111
结论:
能实现变色,简单的变大等简单功能,由于TextView不能解析更多的Html标签,由此还出现了一些库,让TextView支持更多标签,但是我们Android实现富文本本身就是小功能,还得依赖库支持更多标签也都用不上,得不偿失啊。
如果有一些自定义的需求,我们可以使用自定义标签+自定义标签的功能,例如Html中的自定义字体

1.2 自定义Html标签

先定义自定义字体的Span类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 系统原生的TypefaceSpan只能使用原生的默认字体
* 如果使用自定义的字体,通过这个来实现
*/
public class MyTypefaceSpan extends MetricAffectingSpan {

private final Typeface typeface;

public MyTypefaceSpan(final Typeface typeface) {
this.typeface = typeface;
}

@Override
public void updateDrawState(final TextPaint drawState) {
apply(drawState);
}

@Override
public void updateMeasureState(final TextPaint paint) {
apply(paint);
}

private void apply(final Paint paint) {
final Typeface oldTypeface = paint.getTypeface();
final int oldStyle = oldTypeface != null ? oldTypeface.getStyle() : 0;
int fakeStyle = oldStyle & ~typeface.getStyle();
if ((fakeStyle & Typeface.BOLD) != 0) {
paint.setFakeBoldText(true);
}
if ((fakeStyle & Typeface.ITALIC) != 0) {
paint.setTextSkewX(-0.25f);
}
paint.setTypeface(typeface);
}

}

自定义标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* Html的TextView标签解释
* <face></face>
*/
public class TypeFaceLabel implements Html.TagHandler {
private Typeface typeface;
private int startIndex = 0;
private int stopIndex = 0;

public TypeFaceLabel(Typeface typeface) {
this.typeface = typeface;
}

@Override
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
if (tag.toLowerCase().equals("face")) {
if (opening) {
startIndex = output.length();
} else {
stopIndex = output.length();
//使用的是自定义的字体来实现
output.setSpan(new MyTypefaceSpan(typeface), startIndex, stopIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}

}

定义Xml并使用,注意自定义face标签

1
2
3
4
5
6
7
8
9
String content = "<font color=\"#000000\">HR from </font>" +
"<face><font color=\"#0689FB\">" + item.employer_name + "</font></face>" +
"<font color=\"#000000\"> has viewed your resume.</font>";

if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
tv_resume_log_content.setText(Html.fromHtml(content, Html.FROM_HTML_MODE_LEGACY, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
} else {
tv_resume_log_content.setText(Html.fromHtml(content, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
}

效果如下:
111

如果想实现其他的变大 下划线 中划线等Span效果,都可以通过自定义的Html标签+自定义Span实现相应的效果。

二,Span的几种实现方式

虽然通过Html的方式可以实现各种效果,但是定义的时候也太过复杂,各种定义Span 定义标签之类的,有没有更简单和直接的?
有,我们直接封装Span就行了。

2.1 java - SpanUtil

在Java中我们可以封装工具类一个如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/**
* String字符串通过区间来改变颜色,大小,字体,下划线等
*/
public class SpanUtils {

private static final SpanUtils ourInstance = new SpanUtils();

public static SpanUtils getInstance() {
return ourInstance;
}

private SpanUtils() {
}

/**
* 变大变小
*/
public CharSequence toSizeSpan(CharSequence charSequence, int start, int end, float scale) {

SpannableString spannableString = new SpannableString(charSequence);

spannableString.setSpan(
new RelativeSizeSpan(scale),
start,
end,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

return spannableString;
}

/**
* 变色
*/
public CharSequence toColorSpan(CharSequence charSequence, int start, int end, int color) {

SpannableString spannableString = new SpannableString(charSequence);

spannableString.setSpan(
new ForegroundColorSpan(color),
start,
end,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

return spannableString;
}

/**
* 变背景色
*/
public CharSequence toBackgroundColorSpan(CharSequence charSequence, int start, int end, int color) {

SpannableString spannableString = new SpannableString(charSequence);

spannableString.setSpan(
new BackgroundColorSpan(color),
start,
end,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

return spannableString;
}

private long mLastClickTime = 0;
public static final int TIME_INTERVAL = 1000;

/**
* 可点击-带下划线
*/
public CharSequence toClickSpan(CharSequence charSequence, int start, int end, int color, boolean needUnderLine, OnSpanClickListener listener) {

SpannableString spannableString = new SpannableString(charSequence);

ClickableSpan clickableSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
if (listener != null) {
//防止重复点击
if (System.currentTimeMillis() - mLastClickTime >= TIME_INTERVAL) {
//to do
listener.onClick(charSequence.subSequence(start, end));

mLastClickTime = System.currentTimeMillis();
}

}
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
ds.setColor(color);
ds.setUnderlineText(needUnderLine);
}
};

spannableString.setSpan(
clickableSpan,
start,
end,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

return spannableString;
}

public interface OnSpanClickListener {
void onClick(CharSequence charSequence);
}


/**
* 变成自定义的字体
*/
public CharSequence toCustomTypeFaceSpan(CharSequence charSequence, int start, int end, Typeface typeface) {

SpannableString spannableString = new SpannableString(charSequence);

spannableString.setSpan(
new MyTypefaceSpan(typeface),
start,
end,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

return spannableString;
}

}

2.2 kotlin扩展
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* 将一段文字中指定range的文字改变大小
* @param range 要改变大小的文字的范围
* @param scale 缩放值,大于1,则比其他文字大;小于1,则比其他文字小;默认是1.5
*/
fun CharSequence.toSizeSpan(range: IntRange, scale: Float = 1.5f): CharSequence {
return SpannableString(this).apply {
setSpan(
RelativeSizeSpan(scale),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

/**
* 将一段文字中指定range的文字改变前景色
* @param range 要改变前景色的文字的范围
* @param color 要改变的颜色,默认是红色
*/
fun CharSequence.toColorSpan(range: IntRange, color: Int = Color.RED): CharSequence {
return SpannableString(this).apply {
setSpan(
ForegroundColorSpan(color),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

/**
* 将一段文字中指定range的文字改变背景色
* @param range 要改变背景色的文字的范围
* @param color 要改变的颜色,默认是红色
*/
fun CharSequence.toBackgroundColorSpan(range: IntRange, color: Int = Color.RED): CharSequence {
return SpannableString(this).apply {
setSpan(
BackgroundColorSpan(color),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

/**
* 将一段文字中指定range的文字添加删除线
* @param range 要添加删除线的文字的范围
*/
fun CharSequence.toStrikeThrougthSpan(range: IntRange): CharSequence {
return SpannableString(this).apply {
setSpan(
StrikethroughSpan(),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

/**
* 将一段文字中指定range的文字添加颜色和点击事件
* @param range 目标文字的范围
*/
fun CharSequence.toClickSpan(
range: IntRange,
color: Int = Color.RED,
isUnderlineText: Boolean = false,
clickAction: (() -> Unit)?
): CharSequence {
return SpannableString(this).apply {
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
clickAction?.invoke()
}

override fun updateDrawState(ds: TextPaint) {
ds.color = color
ds.isUnderlineText = isUnderlineText
}
}
setSpan(clickableSpan, range.start, range.endInclusive, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
}
}

/**
* 将一段文字中指定range的文字添加style效果
* @param range 要添加删除线的文字的范围
*/
fun CharSequence.toStyleSpan(style: Int = Typeface.BOLD, range: IntRange): CharSequence {
return SpannableString(this).apply {
setSpan(
StyleSpan(style),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

/**
* 将一段文字中指定range的文字添加自定义效果
* @param range 要添加删除线的文字的范围
*/
fun CharSequence.toCustomTypeFaceSpan(typeface: Typeface, range: IntRange): CharSequence {
return SpannableString(this).apply {
setSpan(
CustomTypefaceSpan(typeface),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}


/**
* 将一段文字中指定range的文字添加自定义效果,可以设置对齐方式,可以设置margin
* @param range
*/
fun CharSequence.toImageSpan(
imageRes: Int,
range: IntRange,
verticalAlignment: Int = 0, //默认底部 4是垂直居中
maginLeft: Int = 0,
marginRight: Int = 0,
width: Int = 0,
height: Int = 0
): CharSequence {
return SpannableString(this).apply {
setSpan(
MiddleIMarginImageSpan(
CommUtils.getDrawable(imageRes)
.apply {
setBounds(0, 0, if (width == 0) getIntrinsicWidth() else width, if (height == 0) getIntrinsicHeight() else height)
},
verticalAlignment,
maginLeft,
marginRight
),
range.start,
range.endInclusive,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

扩展方法的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mBinding.tvTextSpan1.text = "演示一下appendXX方法的用法\n"
mBinding.tvTextSpan1.appendSizeSpan("变大变大", 1.5f)
.appendColorSpan("我要变色", color = Color.parseColor("#f0aafc"))
.appendBackgroundColorSpan("我是有底色的", color = Color.parseColor("#cacee0"))
.appendStrikeThrougthSpan("添加删除线哦哦哦哦")
.appendClickSpan("来点我一下试试啊", isUnderlineText = true, clickAction = {
toast("哎呀,您点到我了呢,嘿嘿")
})
.appendImageSpan(R.mipmap.ic_launcher) //默认的大图什么都不加 默认在底部对齐
.appendStyleSpan("我是粗体的") //可以是默认粗体 斜体等
.appendImageSpan(R.mipmap.ic_launcher_round, 4, width = dp2px(35f), height = dp2px(35f))//4是居中的,限制Drawable
.appendCustomTypeFaceSpan("Xiao mi Hua wei", TypefaceUtil.getSFFlower(mActivity)) //自定义字体文件
//默认底部对齐,加左右margin
.appendImageSpan(R.mipmap.iv_me_red_packet, maginLeft = dp2px(10f), marginRight = dp2px(10f))
//添加删除线
.appendStrikeThrougthSpan("添加删除线哦哦哦哦添加删除线哦哦哦哦")

效果:
111

2.3 kotlin DSL方式

如果是使用Kotlin的语言开发,那么还有更简单的DSL封装方式:
第一层的DSL接口

1
2
3
4
5
6
7
8
interface DslSpannableStringBuilder {
//增加一段文字
fun addText(text: String, method: (DslSpanBuilder.() -> Unit)? = null)

//添加一个图标
fun addImage(imageRes: Int, verticalAlignment: Int = 0, maginLeft: Int = 0, marginRight: Int = 0, width: Int = 0, height: Int = 0)
}

第一层的DSL接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class DslSpannableStringBuilderImpl : DslSpannableStringBuilder {

private val builder = SpannableStringBuilder()

//添加文本
override fun addText(text: String, method: (DslSpanBuilder.() -> Unit)?) {

val spanBuilder = DslSpanBuilderImpl()
method?.let { spanBuilder.it() }

var charSeq: CharSequence = text

spanBuilder.apply {
if (issetColor) {
charSeq = charSeq.toColorSpan(0..text.length, textColor)
}
if (issetBackground) {
charSeq = charSeq.toBackgroundColorSpan(0..text.length, textBackgroundColor)
}
if (issetScale) {
charSeq = charSeq.toSizeSpan(0..text.length, scaleSize)
}
if (isonClick) {
charSeq = charSeq.toClickSpan(0..text.length, textColor, isuseUnderLine, onClick)
}
if (issetTypeface) {
charSeq = charSeq.toCustomTypeFaceSpan(typefaces, 0..text.length)
}
if (issetStrikethrough) {
charSeq = charSeq.toStrikeThrougthSpan(0..text.length)
}

builder.append(charSeq)
}
}

//添加图标
override fun addImage(imageRes: Int, verticalAlignment: Int, maginLeft: Int, marginRight: Int, width: Int, height: Int) {
var charSeq: CharSequence = "1"
charSeq = charSeq.toImageSpan(imageRes, 0..1, verticalAlignment, maginLeft, marginRight, width, height)
builder.append(charSeq)
}

fun build(): SpannableStringBuilder {
return builder
}

}

第二层Text的DSL接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface DslSpanBuilder {
//设置文字颜色
fun setColor(color: Int = 0)

//设置点击事件
fun setClick(useUnderLine: Boolean = true, onClick: (() -> Unit)?)

//设置缩放大小
fun setScale(scale: Float = 1.0f)

//设置自定义字体
fun setTypeface(typeface: Typeface)

//是否需要中划线
fun setStrikethrough(isStrikethrough: Boolean = false)

//设置背景
fun setBackground(color: Int = Color.TRANSPARENT)

}

第二层Text的DSL接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class DslSpanBuilderImpl : DslSpanBuilder {
var issetColor = false
var textColor: Int = Color.BLACK

var isonClick = false
var isuseUnderLine = false
var onClick: (() -> Unit)? = null

var issetScale = false
var scaleSize = 1.0f

var issetTypeface = false
var typefaces: Typeface = Typeface.DEFAULT

var issetStrikethrough = false

var issetBackground = false
var textBackgroundColor = 0

override fun setColor(color: Int) {
issetColor = true
textColor = color
}

override fun setClick(useUnderLine: Boolean, onClick: (() -> Unit)?) {
isonClick = true
isuseUnderLine = useUnderLine
this.onClick = onClick
}

override fun setScale(scale: Float) {
issetScale = true
scaleSize = scale
}

override fun setTypeface(typeface: Typeface) {
issetTypeface = true
typefaces = typeface
}

override fun setStrikethrough(isStrikethrough: Boolean) {
issetStrikethrough = isStrikethrough
}

override fun setBackground(color: Int) {
issetBackground = true
textBackgroundColor = color
}

}

创建TextVuew的扩展入口

1
2
3
4
5
6
7
8
9
//为 TextView 创建扩展函数,其参数为接口的扩展函数
fun TextView.buildSpannableString(init: DslSpannableStringBuilder.() -> Unit) {
//具体实现类
val spanStringBuilderImpl = DslSpannableStringBuilderImpl()
spanStringBuilderImpl.init()
movementMethod = LinkMovementMethod.getInstance()
//通过实现类返回SpannableStringBuilder
text = spanStringBuilderImpl.build()
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mBinding.tvTextSpan4.buildSpannableString {
addText("我已详细阅读并同意")
addText("测试红色的文字颜色") {
setColor(Color.RED)
}
addText("测试白色文字加上灰色背景") {
setColor(Color.WHITE)
setBackground(Color.GRAY)
}
addText("测试文本变大了") {
setColor(Color.DKGRAY)
setScale(1.5f)
}
addImage(R.mipmap.ic_launcher)
addText("测试可以点击的文本") {
setClick(true) {
toast("点击文本拉啦啦")
}
}
addImage(R.mipmap.ic_launcher_round, 5, dp2px(10f), dp2px(10f), dp2px(35f), dp2px(35f))
addText("Test Custom Typeface Font is't Success?") {
setTypeface(TypefaceUtil.getSFFlower(mActivity))
}
addText("测试中划线是否生效") {
setStrikethrough(true)
}
}

效果:
111

总结

如果是顺序固定,效果复杂,那么可以用Span的方式。
如果顺序不固定(如国际化)那么可以使用Html的方式。
总的来说,两种方式都不算太难,都是些固定的代码。如果需求可以看源码