Span 是一种强大的标记对象,可用于在字符或段落级别设置文本样式。通过将 span 附加到文本对象,您可以以各种方式更改文本,包括添加颜色、使文本可点击、缩放文本大小以及以自定义方式绘制文本。Span 还可以更改 TextPaint
属性,在 Canvas
上绘制,以及更改文本布局。
Android 提供了几种类型的 span,涵盖了各种常见的文本样式模式。您还可以创建自己的 span 来应用自定义样式。
创建并应用 span
要创建 span,您可以使用下表中列出的类之一。这些类的区别在于文本本身是否可变、文本标记是否可变以及包含 span 数据的底层数据结构。
类 | 可变文本 | 可变标记 | 数据结构 |
---|---|---|---|
SpannedString |
否 | 否 | 线性数组 |
SpannableString |
否 | 是 | 线性数组 |
SpannableStringBuilder |
是 | 是 | 区间树 |
这三个类都扩展了 Spanned
接口。SpannableString
和 SpannableStringBuilder
还扩展了 Spannable
接口。
以下是决定使用哪个类的建议
- 如果您在创建后不修改文本或标记,请使用
SpannedString
。 - 如果您需要将少量 span 附加到单个文本对象且文本本身是只读的,请使用
SpannableString
。 - 如果您需要在创建后修改文本并需要将 span 附加到文本,请使用
SpannableStringBuilder
。 - 无论文本本身是否只读,如果您需要将大量 span 附加到文本对象,请使用
SpannableStringBuilder
。
要应用 span,请在 Spannable
对象上调用 setSpan(Object _what_, int _start_, int _end_, int _flags_)
。what 参数是指您要应用于文本的 span,而 start 和 end 参数表示您要将 span 应用于文本的哪一部分。
如果您在 span 的边界内插入文本,span 会自动扩展以包含插入的文本。在 span 边界处(即在 start 或 end 索引处)插入文本时,flags 参数决定 span 是否扩展以包含插入的文本。使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE
标志来包含插入的文本,使用 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
标志来排除插入的文本。
以下示例展示了如何将 ForegroundColorSpan
附加到字符串
Kotlin
val spannable = SpannableStringBuilder("Text is spantastic!") spannable.setSpan( ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE )
Java
SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE );

ForegroundColorSpan
设置样式的文本。由于 span 是使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE
设置的,因此 span 会扩展以包含在 span 边界处插入的文本,如下例所示
Kotlin
val spannable = SpannableStringBuilder("Text is spantastic!") spannable.setSpan( ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE ) spannable.insert(12, "(& fon)")
Java
SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE ); spannable.insert(12, "(& fon)");

Spannable.SPAN_EXCLUSIVE_INCLUSIVE
时,span 会扩展以包含附加文本。您可以将多个 span 附加到同一文本。以下示例展示了如何创建加粗和红色的文本
Kotlin
val spannable = SpannableString("Text is spantastic!") spannable.setSpan(ForegroundColorSpan(Color.RED), 8, 12, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan( StyleSpan(Typeface.BOLD), 8, spannable.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE )
Java
SpannableString spannable = new SpannableString("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, 12, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ); spannable.setSpan( new StyleSpan(Typeface.BOLD), 8, spannable.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE );

ForegroundColorSpan(Color.RED)
和 StyleSpan(BOLD)
。Android span 类型
Android 在 android.text.style 包中提供了 20 多种 span 类型。Android 主要通过以下两种方式对 span 进行分类
- span 如何影响文本:span 可以影响文本外观或文本指标。
- span 范围:有些 span 可以应用于单个字符,而其他 span 必须应用于整个段落。

以下各部分将更详细地介绍这些类别。
影响文本外观的 span
一些应用于字符级别的 span 会影响文本外观,例如更改文本或背景颜色以及添加下划线或删除线。这些 span 扩展了 CharacterStyle
类。
以下代码示例展示了如何应用 UnderlineSpan
来为文本添加下划线
Kotlin
val string = SpannableString("Text with underline span") string.setSpan(UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
SpannableString string = new SpannableString("Text with underline span"); string.setSpan(new UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

UnderlineSpan
添加下划线的文本。仅影响文本外观的 span 会触发文本重绘,而不会触发布局的重新计算。这些 span 实现了 UpdateAppearance
并扩展了 CharacterStyle
。CharacterStyle
子类通过提供更新 TextPaint
的访问权限来定义如何绘制文本。
影响文本指标的 span
其他应用于字符级别的 span 会影响文本指标,例如行高和文本大小。这些 span 扩展了 MetricAffectingSpan
类。
以下代码示例创建了一个 RelativeSizeSpan
,可将文本大小增加 50%
Kotlin
val string = SpannableString("Text with relative size span") string.setSpan(RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
SpannableString string = new SpannableString("Text with relative size span"); string.setSpan(new RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

RelativeSizeSpan
使文本变大。应用影响文本指标的 span 会导致观察对象重新测量文本以实现正确的布局和渲染,例如,更改文本大小可能会导致单词出现在不同的行上。应用前面的 span 会触发重新测量、文本布局的重新计算和文本的重绘。
影响文本指标的 span 扩展了 MetricAffectingSpan
类,这是一个抽象类,允许子类通过提供对 TextPaint
的访问来定义 span 如何影响文本测量。由于 MetricAffectingSpan
扩展了 CharacterStyle
,因此子类会影响文本在字符级别上的外观。
影响段落的 span
span 还可以影响文本在段落级别上的样式,例如更改文本块的对齐方式或边距。影响整个段落的 span 实现了 ParagraphStyle
。要使用这些 span,请将它们附加到整个段落,不包括末尾的换行符。如果您尝试将段落 span 应用于整个段落以外的内容,Android 将根本不应用该 span。
图 8 显示了 Android 如何在文本中分隔段落。

\n
) 结尾。以下代码示例将 QuoteSpan
应用于段落。请注意,如果您将 span 附加到段落开头或结尾之外的任何位置,Android 将根本不应用该样式。
Kotlin
spannable.setSpan(QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
spannable.setSpan(new QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

QuoteSpan
。创建自定义 span
如果您需要的功能超出了现有 Android span 的范围,则可以实现自定义 span。在实现自己的 span 时,请确定您的 span 是影响字符级别的文本还是段落级别的文本,以及它是否影响文本的布局或外观。这有助于您确定可以扩展哪些基类以及可能需要实现哪些接口。请参考下表
场景 | 类或接口 |
---|---|
您的 span 影响字符级别的文本。 | CharacterStyle |
您的 span 影响文本外观。 | UpdateAppearance |
您的 span 影响文本指标。 | UpdateLayout |
您的 span 影响段落级别的文本。 | ParagraphStyle |
例如,如果您需要实现一个修改文本大小和颜色的自定义 span,请扩展 RelativeSizeSpan
。通过继承,RelativeSizeSpan
扩展了 CharacterStyle
并实现了两个 Update
接口。由于此类已提供 updateDrawState
和 updateMeasureState
的回调,您可以重写这些回调来实现您的自定义行为。以下代码创建了一个自定义 span,该 span 扩展了 RelativeSizeSpan
并重写了 updateDrawState
回调以设置 TextPaint
的颜色
Kotlin
class RelativeSizeColorSpan( size: Float, @ColorInt private val color: Int ) : RelativeSizeSpan(size) { override fun updateDrawState(textPaint: TextPaint) { super.updateDrawState(textPaint) textPaint.color = color } }
Java
public class RelativeSizeColorSpan extends RelativeSizeSpan { private int color; public RelativeSizeColorSpan(float spanSize, int spanColor) { super(spanSize); color = spanColor; } @Override public void updateDrawState(TextPaint textPaint) { super.updateDrawState(textPaint); textPaint.setColor(color); } }
此示例说明了如何创建自定义 span。通过将 RelativeSizeSpan
和 ForegroundColorSpan
应用于文本,可以实现相同的效果。
测试 span 用法
Spanned
接口既允许您设置 span,也允许您从文本中检索 span。测试时,请实现 Android JUnit 测试以验证是否在正确的位置添加了正确的 span。文本样式示例应用包含一个通过将 BulletPointSpan
附加到文本来对项目符号应用标记的 span。以下代码示例展示了如何测试项目符号是否按预期显示
Kotlin
@Test fun textWithBulletPoints() { val result = builder.markdownToSpans("Points\n* one\n+ two") // Check whether the markup tags are removed. assertEquals("Points\none\ntwo", result.toString()) // Get all the spans attached to the SpannedString. val spans = result.getSpans<Any>(0, result.length, Any::class.java) // Check whether the correct number of spans are created. assertEquals(2, spans.size.toLong()) // Check whether the spans are instances of BulletPointSpan. val bulletSpan1 = spans[0] as BulletPointSpan val bulletSpan2 = spans[1] as BulletPointSpan // Check whether the start and end indices are the expected ones. assertEquals(7, result.getSpanStart(bulletSpan1).toLong()) assertEquals(11, result.getSpanEnd(bulletSpan1).toLong()) assertEquals(11, result.getSpanStart(bulletSpan2).toLong()) assertEquals(14, result.getSpanEnd(bulletSpan2).toLong()) }
Java
@Test public void textWithBulletPoints() { SpannedString result = builder.markdownToSpans("Points\n* one\n+ two"); // Check whether the markup tags are removed. assertEquals("Points\none\ntwo", result.toString()); // Get all the spans attached to the SpannedString. Object[] spans = result.getSpans(0, result.length(), Object.class); // Check whether the correct number of spans are created. assertEquals(2, spans.length); // Check whether the spans are instances of BulletPointSpan. BulletPointSpan bulletSpan1 = (BulletPointSpan) spans[0]; BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1]; // Check whether the start and end indices are the expected ones. assertEquals(7, result.getSpanStart(bulletSpan1)); assertEquals(11, result.getSpanEnd(bulletSpan1)); assertEquals(11, result.getSpanStart(bulletSpan2)); assertEquals(14, result.getSpanEnd(bulletSpan2)); }
有关更多测试示例,请参阅 GitHub 上的 MarkdownBuilderTest。
测试自定义 span
测试 span 时,请验证 TextPaint
是否包含预期的修改,以及正确的元素是否显示在您的 Canvas
上。例如,考虑一个在某些文本前添加项目符号的自定义 span 实现。该项目符号具有指定的大小和颜色,并且可绘制区域的左边距与项目符号之间存在间隙。
您可以通过实现 AndroidJUnit 测试来测试此类的行为,检查以下内容
- 如果您正确应用 span,则指定大小和颜色的项目符号会出现在画布上,并且左边距和项目符号之间存在适当的间距。
- 如果您不应用 span,则不会出现任何自定义行为。
您可以在 GitHub 上的 TextStyling 示例中查看这些测试的实现。
您可以通过模拟 canvas,将模拟对象传递给 drawLeadingMargin()
方法,并验证是否使用正确的参数调用了正确的方法来测试 Canvas 交互。
您可以在 BulletPointSpanTest 中找到更多 span 测试示例。
使用 span 的最佳实践
根据您的需求,有几种内存高效的方法可以在 TextView
中设置文本。
在不更改底层文本的情况下附加或分离 span
TextView.setText()
包含多个重载,它们以不同方式处理 span。例如,您可以使用以下代码设置 Spannable
文本对象
Kotlin
textView.setText(spannableObject)
Java
textView.setText(spannableObject);
调用此 setText()
重载时,TextView
会将您的 Spannable
复制为 SpannedString
,并将其作为 CharSequence
保存在内存中。这意味着您的文本和 span 是不可变的,因此当您需要更新文本或 span 时,请创建一个新的 Spannable
对象并再次调用 setText()
,这也会触发布局的重新测量和重新绘制。
要表明 span 必须是可变的,您可以使用 setText(CharSequence text, TextView.BufferType type)
,如下例所示
Kotlin
textView.setText(spannable, BufferType.SPANNABLE) val spannableText = textView.text as Spannable spannableText.setSpan( ForegroundColorSpan(color), 8, spannableText.length, SPAN_INCLUSIVE_INCLUSIVE )
Java
textView.setText(spannable, BufferType.SPANNABLE); Spannable spannableText = (Spannable) textView.getText(); spannableText.setSpan( new ForegroundColorSpan(color), 8, spannableText.getLength(), SPAN_INCLUSIVE_INCLUSIVE);
在此示例中,BufferType.SPANNABLE
参数会导致 TextView
创建一个 SpannableString
,并且 TextView
保留的 CharSequence
对象现在具有可变的标记和不可变的文本。要更新 span,请将文本检索为 Spannable
,然后根据需要更新 span。
当您附加、分离或重新定位 span 时,TextView
会自动更新以反映文本的变化。如果您更改现有 span 的内部属性,请调用 invalidate()
进行与外观相关的更改,或调用 requestLayout()
进行与指标相关的更改。
在 TextView 中多次设置文本
在某些情况下,例如使用 RecyclerView.ViewHolder
时,您可能希望重用 TextView
并多次设置文本。默认情况下,无论您是否设置 BufferType
,TextView
都会创建 CharSequence
对象的副本并将其保存在内存中。这使得所有 TextView
更新都是故意的——您无法通过更新原始 CharSequence
对象来更新文本。这意味着每次设置新文本时,TextView
都会创建一个新对象。
如果您想对此过程进行更多控制并避免额外的对象创建,则可以实现自己的 Spannable.Factory
并重写 newSpannable()
。您不必创建新的文本对象,而是可以将现有的 CharSequence
强制转换并返回为 Spannable
,如下例所示
Kotlin
val spannableFactory = object : Spannable.Factory() { override fun newSpannable(source: CharSequence?): Spannable { return source as Spannable } }
Java
Spannable.Factory spannableFactory = new Spannable.Factory(){ @Override public Spannable newSpannable(CharSequence source) { return (Spannable) source; } };
设置文本时必须使用 textView.setText(spannableObject, BufferType.SPANNABLE)
。否则,源 CharSequence
会作为 Spanned
实例创建,并且无法强制转换为 Spannable
,导致 newSpannable()
抛出 ClassCastException
。
重写 newSpannable()
后,告诉 TextView
使用新的 Factory
Kotlin
textView.setSpannableFactory(spannableFactory)
Java
textView.setSpannableFactory(spannableFactory);
获取对 TextView
的引用后,立即设置 Spannable.Factory
对象一次。如果您正在使用 RecyclerView
,请在首次膨胀视图时设置 Factory
对象。这样可以避免在 RecyclerView
将新项目绑定到 ViewHolder
时创建额外的对象。
更改内部 span 属性
如果您只需要更改可变 span 的内部属性,例如自定义项目符号 span 中的项目符号颜色,则可以通过在创建 span 时保留对 span 的引用来避免多次调用 setText()
带来的开销。当您需要修改 span 时,可以修改引用,然后根据您更改的属性类型,在 TextView
上调用 invalidate()
或 requestLayout()
。
在以下代码示例中,自定义项目符号实现具有默认的红色,当点击按钮时会变为灰色
Kotlin
class MainActivity : AppCompatActivity() { // Keeping the span as a field. val bulletSpan = BulletPointSpan(color = Color.RED) override fun onCreate(savedInstanceState: Bundle?) { ... val spannable = SpannableString("Text is spantastic") // Setting the span to the bulletSpan field. spannable.setSpan( bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) styledText.setText(spannable) button.setOnClickListener { // Change the color of the mutable span. bulletSpan.color = Color.GRAY // Color doesn't change until invalidate is called. styledText.invalidate() } } }
Java
public class MainActivity extends AppCompatActivity { private BulletPointSpan bulletSpan = new BulletPointSpan(Color.RED); @Override protected void onCreate(Bundle savedInstanceState) { ... SpannableString spannable = new SpannableString("Text is spantastic"); // Setting the span to the bulletSpan field. spannable.setSpan(bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE); styledText.setText(spannable); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { // Change the color of the mutable span. bulletSpan.setColor(Color.GRAY); // Color doesn't change until invalidate is called. styledText.invalidate(); } }); } }
使用 Android KTX 扩展函数
Android KTX 还包含使处理 span 更轻松的扩展函数。要了解详情,请参阅 androidx.core.text 包的文档。