跨度

尝试 Compose 方式
Jetpack Compose 是 Android 推荐的 UI 工具包。了解如何在 Compose 中使用文本。

跨度是强大的标记对象,您可以使用它们在字符或段落级别设置文本样式。通过将跨度附加到文本对象,您可以通过多种方式更改文本,包括添加颜色、使文本可点击、缩放文本大小以及以自定义方式绘制文本。跨度还可以更改 TextPaint 属性,在 Canvas 上绘制以及更改文本布局。

Android 提供了几种类型的跨度,涵盖各种常见的文本样式模式。您还可以创建自己的跨度以应用自定义样式。

创建和应用跨度

要创建跨度,您可以使用下表中列出的类之一。这些类根据文本本身是否可变、文本标记是否可变以及包含跨度数据的底层数据结构的不同而不同。

可变文本 可变标记 数据结构
SpannedString 线性数组
SpannableString 线性数组
SpannableStringBuilder 区间树

所有三个类都扩展了 Spanned 接口。SpannableStringSpannableStringBuilder 还扩展了 Spannable 接口。

以下是如何确定使用哪一个

  • 如果您在创建后不修改文本或标记,请使用 SpannedString
  • 如果您需要将少量跨度附加到单个文本对象,并且文本本身是只读的,请使用 SpannableString
  • 如果您需要在创建后修改文本,并且需要将跨度附加到文本,请使用 SpannableStringBuilder
  • 如果您需要将大量跨度附加到文本对象,无论文本本身是只读的还是可写的,请使用 SpannableStringBuilder

要应用跨度,请在 Spannable 对象上调用 setSpan(Object _what_, int _start_, int _end_, int _flags_)what 参数指的是您要应用于文本的跨度,startend 参数指示您要应用跨度的文本部分。

如果您在跨度边界内插入文本,则跨度会自动扩展以包含插入的文本。当在跨度边界插入文本时,即在startend 索引处,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
);
An image showing a grey text, partially red.
图 1. 使用 ForegroundColorSpan 设置文本样式。

由于跨度是使用 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)");
An image showing how the span include more text when SPAN_EXCLUSIVE_INCLUSIVE is used.
图 2. 使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE 时,跨度会扩展以包含附加文本。

您可以将多个跨度附加到同一文本。以下示例演示如何创建粗体红色文本

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
);
An image showing a text with multiple spans: `ForegroundColorSpan(Color.RED)` and `StyleSpan(BOLD)`
图 3. 带有多个跨度的文本:ForegroundColorSpan(Color.RED)StyleSpan(BOLD)

Android 跨度类型

Android 在 android.text.style 包中提供了 20 多种跨度类型。Android 以两种主要方式对跨度进行分类

  • 跨度如何影响文本:跨度可以影响文本外观或文本度量。
  • 跨度范围:一些跨度可以应用于单个字符,而另一些跨度必须应用于整个段落。
An image showing different span categories
图 4. 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);
An image showing how to underline text using an `UnderlineSpan`
图 5. 使用 UnderlineSpan 为文本添加下划线。

仅影响文本外观的跨度会触发文本的重新绘制,而不会触发布局的重新计算。这些跨度实现了 UpdateAppearance 并扩展了 CharacterStyleCharacterStyle 子类通过提供访问权限来更新 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);
An image showing the usage of RelativeSizeSpan
图 6. 使用 RelativeSizeSpan 使文本变大。

应用影响文本度量的跨度会导致观察对象重新测量文本,以便进行正确的布局和渲染,例如,更改文本大小可能会导致单词出现在不同的行上。应用前面的跨度会触发重新测量、文本布局的重新计算以及文本的重新绘制。

影响文本度量的跨度扩展了 MetricAffectingSpan 类,这是一个抽象类,它允许子类通过提供对 TextPaint 的访问权限来定义跨度如何影响文本测量。由于 MetricAffectingSpan 扩展了 CharacterSpan,因此子类会影响字符级别的文本外观。

影响段落的跨度

跨度还可以影响段落级别的文本,例如更改文本块的对齐方式或边距。影响整个段落的跨度实现了 ParagraphStyle。要使用这些跨度,您需要将它们附加到整个段落,不包括结束换行符。如果您尝试将段落跨度应用于除整个段落之外的任何内容,Android 根本不会应用该跨度。

图 8 显示了 Android 如何在文本中分隔段落。

图 7. 在 Android 中,段落以换行符 (\n) 结尾。

以下代码示例将 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);
An image showing an example of QuoteSpan
图 8. 应用于段落的 QuoteSpan

创建自定义跨度

如果您需要比现有 Android 跨度提供的更多功能,您可以实现自定义跨度。在实现自己的跨度时,请确定您的跨度是影响字符级别的文本还是段落级别的文本,以及它是否会影响文本的布局或外观。这将帮助您确定可以扩展哪些基类以及可能需要实现哪些接口。请参考下表

场景 类或接口
您的跨度会影响字符级别的文本。 CharacterStyle
您的跨度会影响文本外观。 UpdateAppearance
您的跨度会影响文本度量。 UpdateLayout
您的跨度会影响段落级别的文本。 ParagraphStyle

例如,如果您需要实现一个修改文本大小和颜色的自定义跨度,请扩展 RelativeSizeSpan。通过继承,RelativeSizeSpan 扩展了 CharacterStyle 并实现了两个 Update 接口。由于此类已经提供了 updateDrawStateupdateMeasureState 的回调,因此您可以覆盖这些回调来实现您的自定义行为。以下代码创建了一个自定义跨度,它扩展了 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);
    }
}

本示例演示如何创建自定义跨度。您也可以通过对文本应用 RelativeSizeSpanForegroundColorSpan 来实现相同的效果。

测试跨度用法

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 并多次设置文本。默认情况下,无论您是否设置了 BufferTypeTextView 都会创建 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 包的文档。