浅谈 Android 中的富文本之强大的 SpannableString

文章目录
  1. 1. 简要介绍
  2. 2. Demo 用法
  3. 3. 深入解读
  4. 4. 参考文献

如果你昨天的成绩了不起,说明你今天做得还不够好。

至这一篇文章,我个人搭建的博客成功运营两年了,每两周更新一篇想写的冰山一角。两年,不长,好多都物是人非,莫忘初衷,就继续下去吧,Move on。好,开始正题。

Android 中,显示文本都是基于 TextView 控件,往往样式没什么额外惊喜。若我们想自定义个性的颜色、样式,甚至带超链接等,就不得不请出将要谈到的 SpannableString。

简要介绍

SpannableString 是 Android 内置的专门处理富文本的类,先简单看看其常用的方法:

类型 方法 描述
final char charAt(int i) 和 String 一样,可以返回指定位置的字符值
boolean equals(Object o) 和 String 一样,指示是否有别的对象和当前对象相等
final void getChars (int start, int end, char[] dest, int off) 和 String 一样,从字符序列中取出 start ~ end - 1 的字符,在偏移为 off 处,复制到 dest 数组中
int getSpanEnd(Object what) 返回指定标记对象依附文本范围的末端,若未依附则返回 -1
int getSpanFlags(Object what) 当 setSpan(Object, int, int, int) 用来依附指定的标记对象时,返回指定的 flags,若未依附则返回 0
int getSpanStart(Object what) 返回指定标记对象依附文本范围的开端,若未依附则返回 -1
T[] getSpans(int queryStart, int queryEnd, Class kind) 返回依附指定序列块标记对象的数组,其类型是指定的类型,或者是子类
int hashCode() 返回对象的哈希值
final int length() 返回该字符序列的长度

接下面,

类型 方法 描述
int nextSpanTransition(int start, int limit, Class kind) 在标记类对象类型开始或结束的地方,返回比 start 大的第一个偏移,或者是 limit,条件是没有 starts 或 ends 比 start 大,且比 limit
void removeSpan(Object what) 移除 Span
void setSpan(Object what, int start, int end, int flags) 设置 Span
final CharSequence subSequence(int start, int end) 返回当前序列截取后的子序列
final String toString() 返回当前对象转化成的 String
static SpannableString valueOf(CharSequence source) 生成 SpannableString 类型的值

以上总结来自官网 SpannableString,若实现可拼接,见 SpannableStringBuilder

Demo 用法

Demo 地址:SpannableStringDemo

显示如下:

点击“现在我仍然……都要努力”,响应点击事件;点击下方的“电话……进入地图”,分别跳转至相应的 URL 地址页面。

代码如下,先看普通文本部分:

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
private SpannableString getNormalString() {
SpannableString ssNormal = new SpannableString("Hi,我是吉尔伯特·阿里纳斯,这是我的故事。 " +
"职业生涯的前 40 场,我是在板凳上度过的。 他们认为我打不上比赛, 我想,他们根本没看到我的天赋。 " +
"他们觉得我就是个 0,一无是处。 但是我并没有坐在那里怨天尤人,而是不断的训练,训练。 " +
"在没有人相信你的时候,你的任何努力都会为自己加分。 这已经不是我能否打好篮球的问题了, 而是我要证明他们是错误的。 " +
"现在我仍然穿着 0 号球衣,因为我要告诫自己每天都要努力。");

// 设置字体: default, default-bold, monospace, serif, sans-serif
ssNormal.setSpan(new TypefaceSpan("default"), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new TypefaceSpan("default-bold"), 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new TypefaceSpan("monospace"), 4, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new TypefaceSpan("serif"), 6, 8, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new TypefaceSpan("sans-serif"), 8, 10, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置字体大小
// 绝对值, 20 为返回的物理像素
ssNormal.setSpan(new AbsoluteSizeSpan(20), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 绝对值, 为 true, 则返回独立像素
ssNormal.setSpan(new AbsoluteSizeSpan(20, true), 12, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 相对值, 0.5f 表示为默认字体大小的一半
ssNormal.setSpan(new RelativeSizeSpan(0.5f), 14, 16, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置字体前景色
ssNormal.setSpan(new ForegroundColorSpan(Color.RED), 16, 18, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置字体背景色
ssNormal.setSpan(new BackgroundColorSpan(Color.GREEN), 18, 20, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置字体样式: NORMAL, BOLD, ITALIC, BOLD_ITALIC
ssNormal.setSpan(new StyleSpan(Typeface.NORMAL), 20, 21, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new StyleSpan(Typeface.BOLD), 21, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new StyleSpan(Typeface.ITALIC), 22, 23, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 23, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置下划线
ssNormal.setSpan(new UnderlineSpan(), 24, 26, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置删除线
ssNormal.setSpan(new StrikethroughSpan(), 26, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置上下标
ssNormal.setSpan(new SubscriptSpan(), 28, 30, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssNormal.setSpan(new SuperscriptSpan(), 30, 32, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置字体, 2.0f 为默认字体宽度的两倍, X 轴方向放大, 高度不变
ssNormal.setSpan(new ScaleXSpan(2.0f), 32, 34, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置项目符号
ssNormal.setSpan(new BulletSpan(BulletSpan.STANDARD_GAP_WIDTH, Color.GREEN), 0, ssNormal.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置图片
Drawable drawable = getResources().getDrawable(R.drawable.ic_launcher_foreground);
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
ssNormal.setSpan(new ImageSpan(drawable), 44, 46, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// 设置点击事件
ClickableSpan clickableSpan = new ClickableSpan() {
@Override
public void onClick(View widget) {
Toast.makeText(MainActivity.this, "我是可以点击的喔", Toast.LENGTH_SHORT).show();
}
};
ssNormal.setSpan(clickableSpan, ssNormal.length() - 29, ssNormal.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

return ssNormal;
}

再看超链接部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private SpannableString getLinkString() {
SpannableString ssLink = new SpannableString("电话 邮件 百度一下 短信 彩信 进入地图");

// 电话
ssLink.setSpan(new URLSpan("tel:8008820"), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 邮件
ssLink.setSpan(new URLSpan("mailto:[email protected]"), 4, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 网络
ssLink.setSpan(new URLSpan("http://www.baidu.com"), 8, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 短信
ssLink.setSpan(new URLSpan("sms:10086"), 14, 16, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 彩信
ssLink.setSpan(new URLSpan("mms:10086"), 18, 20, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 地图
ssLink.setSpan(new URLSpan("geo:32.123456,-17.123456"), 22, 26, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

return ssLink;
}

深入解读

通过以上简单的使用,我们可以实现常规文本的定制化,进一步,来看看 Android 内部 TextView 富文本的实现。UML 图如下:

一目了然,先看 Demo 中用的最多的 setSpan() 方法,其走入的源码如下:

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
private void setSpan(Object what, int start, int end, int flags
, boolean enforceParagraph)
{

// ...

for (int i = 0; i < count; i++) {
if (spans[i] == what) {
int ostart = data[i * COLUMNS + START];
int oend = data[i * COLUMNS + END];

data[i * COLUMNS + START] = start;
data[i * COLUMNS + END] = end;
data[i * COLUMNS + FLAGS] = flags;

sendSpanChanged(what, ostart, oend, nstart, nend);
return;
}
}

// ...

mSpans[mSpanCount] = what;
mSpanData[mSpanCount * COLUMNS + START] = start;
mSpanData[mSpanCount * COLUMNS + END] = end;
mSpanData[mSpanCount * COLUMNS + FLAGS] = flags;
mSpanCount++;

if (this instanceof Spannable)
sendSpanAdded(what, nstart, nend);
}

该方法内部主要改变了 mSpans,mSpanData 和 mSpanCount。其中,mSpanData 表示样式的首尾索引和 flags,mSpans 表示对应的样式。从源码中我们可以看到,mSpanData 将 start、end 和 flags 打包存在一起,根据对应的偏移地址取值。然而,SpannableStringBuilder 直接将四个相应的变量存在四个数组里。进一步,来看看富文本的绘制,走到 TextView 的 onDraw() 里:

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
protected void onDraw(Canvas canvas) {
// ... 一些局部变量的声明

if (mLayout == null) {
// 基于已测量好尺寸的 View 上生成新的布局
assumeLayout();
}

Layout layout = mLayout;

// ...

final int cursorOffsetVertical = voffsetCursor - voffsetText;

Path highlight = getUpdatedHighlightPath();
// mEditor 是 TextView 处理可编辑 Text 的帮助类对象
if (mEditor != null) {
mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);
} else {
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}

// ...

// 恢复 Canvas 之前保存的状态, 防止 save 后, 对 Canvas 执行的操作影响后续的绘制
canvas.restore();
}

TextView 的绘制细节交给 mLayout,其由 makeSingleLayout() 赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,
Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,
boolean useSaved)
{

Layout result = null;
if (mText instanceof Spannable) {
result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth,
alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad,
mBreakStrategy, mHyphenationFrequency, mJustificationMode,
getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);
} else {
// ...
}

// ...

return result;
}

接着,走到 layout.draw() 的 draw() 方法里,在指定的 Canvas 上绘制布局,其背景和文本之间有着高亮的路径:

1
2
3
4
5
6
7
8
9
10
11
public void draw(Canvas canvas, Path highlight, Paint highlightPaint,
int cursorOffsetVertical)
{

final long lineRange = getLineRangeForDraw(canvas);
int firstLine = TextUtils.unpackRangeStartFromLong(lineRange);
int lastLine = TextUtils.unpackRangeEndFromLong(lineRange);
if (lastLine < 0) return;

drawBackground(canvas, highlight, highlightPaint, cursorOffsetVertical,
firstLine, lastLine);
drawText(canvas, firstLine, lastLine);
}

再走到绘制文本的方法 drawText() 里,Layout 计算好每一行的段落格式,如前面空出多少、居中还是靠右等。

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
public void drawText(Canvas canvas, int firstLine, int lastLine) {
// ... 一些变量的声明

TextLine tl = TextLine.obtain();

// 一次绘制一根线, 基线是以下行的顶部减去当前行的下降部分
for (int lineNum = firstLine; lineNum <= lastLine; lineNum++) {
// ... 一些变量的初始化

// ... 一些段落格式的计算

Alignment align = paraAlign;
if (align == Alignment.ALIGN_LEFT) {
align = (dir == DIR_LEFT_TO_RIGHT) ?
Alignment.ALIGN_NORMAL : Alignment.ALIGN_OPPOSITE;
} else if (align == Alignment.ALIGN_RIGHT) {
align = (dir == DIR_LEFT_TO_RIGHT) ?
Alignment.ALIGN_OPPOSITE : Alignment.ALIGN_NORMAL;
}

int x;
final int indentWidth;
if (align == Alignment.ALIGN_NORMAL) {
if (dir == DIR_LEFT_TO_RIGHT) {
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
x = left + indentWidth;
} else {
indentWidth = -getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
x = right - indentWidth;
}
} else {
int max = (int)getLineExtent(lineNum, tabStops, false);
if (align == Alignment.ALIGN_OPPOSITE) {
if (dir == DIR_LEFT_TO_RIGHT) {
indentWidth = -getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
x = right - max - indentWidth;
} else {
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
x = left - max + indentWidth;
}
} else { // Alignment.ALIGN_CENTER
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_CENTER);
max = max & ~1;
x = ((right + left - max) >> 1) + indentWidth;
}
}

paint.setHyphenEdit(getHyphen(lineNum));
Directions directions = getLineDirections(lineNum);
if (directions == DIRS_ALL_LEFT_TO_RIGHT && !mSpannedText && !hasTab && !justify) {
// XXX: assumes there's nothing additional to be done
canvas.drawText(buf, start, end, x, lbaseline, paint);
} else {
tl.set(paint, buf, start, end, dir, directions, hasTab, tabStops);
if (justify) {
tl.justify(right - left - indentWidth);
}
tl.draw(canvas, x, ltop, lbaseline, lbottom);
}
paint.setHyphenEdit(0);
}

TextLine.recycle(tl);
}

再走到 TextLine 中,其负责文本绘制的文字显示样式,重点看 handleText() 方法:

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
private float handleText(TextPaint wp, int start, int end,
int contextStart, int contextEnd, boolean runIsRtl,
Canvas c, float x, int top, int y, int bottom,
FontMetricsInt fmi, boolean needWidth, int offset)
{

// 设置间距
wp.setWordSpacing(mAddedWidth);

// ...

if (c != null) {
if (runIsRtl) {
x -= ret;
}

// 背景
if (wp.bgColor != 0) {
int previousColor = wp.getColor();
Paint.Style previousStyle = wp.getStyle();

wp.setColor(wp.bgColor);
wp.setStyle(Paint.Style.FILL);
c.drawRect(x, top, x + ret, bottom, wp);

wp.setStyle(previousStyle);
wp.setColor(previousColor);
}

// 下划线
if (wp.underlineColor != 0) {
// kStdUnderline_Offset = 1/9, defined in SkTextFormatParams.h
float underlineTop = y + wp.baselineShift + (1.0f / 9.0f) * wp.getTextSize();

int previousColor = wp.getColor();
Paint.Style previousStyle = wp.getStyle();
boolean previousAntiAlias = wp.isAntiAlias();

wp.setStyle(Paint.Style.FILL);
wp.setAntiAlias(true);

wp.setColor(wp.underlineColor);
c.drawRect(x, underlineTop, x + ret, underlineTop + wp.underlineThickness, wp);

wp.setStyle(previousStyle);
wp.setColor(previousColor);
wp.setAntiAlias(previousAntiAlias);
}

// 调用 Canvas 的 drawTextRun()
drawTextRun(c, wp, start, end, contextStart, contextEnd, runIsRtl,
x, y + wp.baselineShift);
}

return runIsRtl ? -ret : ret;
}

其在 handleRun() 里被执行:

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
private float handleRun(int start, int measureLimit,
int limit, boolean runIsRtl, Canvas c, float x, int top, int y,
int bottom, FontMetricsInt fmi, boolean needWidth)
{


// ...

final float originalX = x;
for (int i = start, inext; i < measureLimit; i = inext) {
TextPaint wp = mWorkPaint;
wp.set(mPaint);

inext = mMetricAffectingSpanSpanSet.getNextTransition(mStart + i, mStart + limit) -
mStart;
int mlimit = Math.min(inext, measureLimit);

ReplacementSpan replacement = null;

for (int j = 0; j < mMetricAffectingSpanSpanSet.numberOfSpans; j++) {
// Both intervals [spanStarts..spanEnds] and [mStart + i..mStart + mlimit] are NOT
// empty by construction. This special case in getSpans() explains the >= & <= tests
if ((mMetricAffectingSpanSpanSet.spanStarts[j] >= mStart + mlimit) ||
(mMetricAffectingSpanSpanSet.spanEnds[j] <= mStart + i)) continue;
MetricAffectingSpan span = mMetricAffectingSpanSpanSet.spans[j];
if (span instanceof ReplacementSpan) {
replacement = (ReplacementSpan)span;
} else {
// 使用绘制状态有一个替代, 否则测量状态就足够了
span.updateDrawState(wp);
}
}

for (int j = i, jnext; j < mlimit; j = jnext) {
// ...
CharacterStyle span = mCharacterStyleSpanSet.spans[k];
// 直接设置 TextPaint 的属性
span.updateDrawState(wp);
}

wp.setHyphenEdit(adjustHyphenEdit(j, jnext, wp.getHyphenEdit()));

x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,
top, y, bottom, fmi, needWidth || jnext < measureLimit, offset);
}
}

return x - originalX;
}

大体流程如此。至此,浅谈 Android 中的富文本之强大的 SpannableString 到此完毕,更多细节还需要在使用中体会。

本人才疏学浅,如有疏漏错误之处,望读者中有识之士不吝赐教,谢谢。

1
Email: [email protected] / WeChat: Wolverine623

您也可以关注我个人的微信公众号码农六哥第一时间获得博客的更新通知,或后台留言与我交流

参考文献

1.https://developer.android.com/reference/android/text/SpannableString

2.https://blog.csdn.net/lukejunandroid/article/details/25892737

3.https://www.jianshu.com/p/aa53ee98d954