跨度是强大的标记对象,您可以使用它们在字符或段落级别设置文本样式。通过将跨度附加到文本对象,您可以通过多种方式更改文本,包括添加颜色、使文本可点击、缩放文本大小以及以自定义方式绘制文本。跨度还可以更改 TextPaint
属性,在 Canvas
上绘制以及更改文本布局。
Android 提供了几种类型的跨度,涵盖各种常见的文本样式模式。您还可以创建自己的跨度以应用自定义样式。
创建和应用跨度
要创建跨度,您可以使用下表中列出的类之一。这些类根据文本本身是否可变、文本标记是否可变以及包含跨度数据的底层数据结构的不同而不同。
类 | 可变文本 | 可变标记 | 数据结构 |
---|---|---|---|
SpannedString |
否 | 否 | 线性数组 |
SpannableString |
否 | 是 | 线性数组 |
SpannableStringBuilder |
是 | 是 | 区间树 |
所有三个类都扩展了 Spanned
接口。SpannableString
和 SpannableStringBuilder
还扩展了 Spannable
接口。
以下是如何确定使用哪一个
- 如果您在创建后不修改文本或标记,请使用
SpannedString
。 - 如果您需要将少量跨度附加到单个文本对象,并且文本本身是只读的,请使用
SpannableString
。 - 如果您需要在创建后修改文本,并且需要将跨度附加到文本,请使用
SpannableStringBuilder
。 - 如果您需要将大量跨度附加到文本对象,无论文本本身是只读的还是可写的,请使用
SpannableStringBuilder
。
要应用跨度,请在 Spannable
对象上调用 setSpan(Object _what_, int _start_, int _end_, int _flags_)
。what 参数指的是您要应用于文本的跨度,start 和 end 参数指示您要应用跨度的文本部分。
如果您在跨度边界内插入文本,则跨度会自动扩展以包含插入的文本。当在跨度边界处插入文本时,即在start 或 end 索引处,flags 参数决定跨度是否扩展以包含插入的文本。使用 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 );
由于跨度是使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE
设置的,因此跨度会扩展以包含在跨度边界处插入的文本,如下例所示
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)");
您可以将多个跨度附加到同一文本。以下示例演示如何创建粗体红色文本
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 );
Android 跨度类型
Android 在 android.text.style 包中提供了 20 多种跨度类型。Android 以两种主要方式对跨度进行分类
- 跨度如何影响文本:跨度可以影响文本外观或文本度量。
- 跨度范围:一些跨度可以应用于单个字符,而另一些跨度必须应用于整个段落。
以下部分将更详细地描述这些类别。
影响文本外观的跨度
一些在字符级别应用的跨度会影响文本外观,例如更改文本或背景颜色,以及添加下划线或删除线。这些跨度扩展了 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);
仅影响文本外观的跨度会触发文本的重新绘制,而不会触发布局的重新计算。这些跨度实现了 UpdateAppearance
并扩展了 CharacterStyle
。CharacterStyle
子类通过提供访问权限来更新 TextPaint
来定义如何绘制文本。
影响文本度量的跨度
其他在字符级别应用的跨度会影响文本度量,例如行高和文本大小。这些跨度扩展了 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);
应用影响文本度量的跨度会导致观察对象重新测量文本,以便进行正确的布局和渲染,例如,更改文本大小可能会导致单词出现在不同的行上。应用前面的跨度会触发重新测量、文本布局的重新计算以及文本的重新绘制。
影响文本度量的跨度扩展了 MetricAffectingSpan
类,这是一个抽象类,它允许子类通过提供对 TextPaint
的访问权限来定义跨度如何影响文本测量。由于 MetricAffectingSpan
扩展了 CharacterSpan
,因此子类会影响字符级别的文本外观。
影响段落的跨度
跨度还可以影响段落级别的文本,例如更改文本块的对齐方式或边距。影响整个段落的跨度实现了 ParagraphStyle
。要使用这些跨度,您需要将它们附加到整个段落,不包括结束换行符。如果您尝试将段落跨度应用于除整个段落之外的任何内容,Android 根本不会应用该跨度。
图 8 显示了 Android 如何在文本中分隔段落。
以下代码示例将 QuoteSpan
应用于段落。请注意,如果您将跨度附加到除段落开头或结尾以外的任何位置,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);
创建自定义跨度
如果您需要比现有 Android 跨度提供的更多功能,您可以实现自定义跨度。在实现自己的跨度时,请确定您的跨度是影响字符级别的文本还是段落级别的文本,以及它是否会影响文本的布局或外观。这将帮助您确定可以扩展哪些基类以及可能需要实现哪些接口。请参考下表
场景 | 类或接口 |
---|---|
您的跨度会影响字符级别的文本。 | CharacterStyle |
您的跨度会影响文本外观。 | UpdateAppearance |
您的跨度会影响文本度量。 | UpdateLayout |
您的跨度会影响段落级别的文本。 | ParagraphStyle |
例如,如果您需要实现一个修改文本大小和颜色的自定义跨度,请扩展 RelativeSizeSpan
。通过继承,RelativeSizeSpan
扩展了 CharacterStyle
并实现了两个 Update
接口。由于此类已经提供了 updateDrawState
和 updateMeasureState
的回调,因此您可以覆盖这些回调来实现您的自定义行为。以下代码创建了一个自定义跨度,它扩展了 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); } }
本示例演示如何创建自定义跨度。您也可以通过对文本应用 RelativeSizeSpan
和 ForegroundColorSpan
来实现相同的效果。
测试跨度用法
Spanned
接口允许您设置跨度并从文本中检索跨度。在测试时,实现一个 Android JUnit 测试 来验证是否在正确的位置添加了正确的跨度。文本样式示例应用程序 包含一个跨度,通过将 BulletPointSpan
附加到文本,对项目符号进行标记。以下代码示例展示了如何测试项目符号是否按预期显示
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。
测试自定义跨度
在测试跨度时,请验证 TextPaint
是否包含预期的修改,以及 Canvas
上是否出现了正确的元素。例如,考虑一个自定义跨度实现,它在某些文本前面加上一个项目符号。该项目符号具有指定的尺寸和颜色,并且在可绘制区域的左边缘和项目符号之间存在一个间隙。
您可以通过实现一个 AndroidJUnit 测试来测试此类的行为,并检查以下内容
- 如果您正确地应用了跨度,则画布上会出现指定尺寸和颜色的项目符号,并且在左边缘和项目符号之间存在适当的空间。
- 如果您没有应用跨度,则不会出现任何自定义行为。
您可以在 GitHub 上的 文本样式示例 中看到这些测试的实现。
您可以通过模拟画布来测试画布交互,将模拟对象传递给 drawLeadingMargin()
方法,并验证是否使用正确的参数调用了正确的方法。
您可以在 BulletPointSpanTest 中找到更多跨度测试示例。
使用跨度的最佳实践
根据您的需要,有几种内存高效的方式在 TextView
中设置文本。
附加或分离跨度而无需更改底层文本
TextView.setText()
包含多个处理跨度不同的重载。例如,您可以使用以下代码设置 Spannable
文本对象
Kotlin
textView.setText(spannableObject)
Java
textView.setText(spannableObject);
在调用此 setText()
重载时,TextView
会将您的 Spannable
复制为 SpannedString
,并在内存中将其保留为 CharSequence
。这意味着您的文本和跨度是不可变的,因此当您需要更新文本或跨度时,请创建一个新的 Spannable
对象并再次调用 setText()
,这也会触发布局的重新测量和重新绘制。
为了表明跨度必须是可变的,您可以使用 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
对象现在具有可变标记和不可变文本。要更新跨度,请将文本检索为 Spannable
,然后根据需要更新跨度。
当您附加、分离或重新定位跨度时,TextView
会自动更新以反映对文本的更改。如果您更改了现有跨度的内部属性,请调用 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
时出现额外的对象创建。
更改内部跨度属性
如果您只需要更改可变跨度的内部属性,例如自定义项目符号跨度中的项目符号颜色,您可以通过保留创建跨度的引用来避免多次调用 setText()
带来的开销。当您需要修改跨度时,您可以修改引用,然后根据您更改的属性类型,在 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 还包含扩展函数,使使用跨度变得更容易。要了解更多信息,请参阅 androidx.core.text 包的文档。