Spans

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

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

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

创建和应用 span

要创建 span,您可以使用下表中列出的类之一。这些类的区别在于文本本身是否可变、文本标记是否可变以及包含 span 数据的底层数据结构。

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

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

以下是关于如何决定使用哪个类的说明

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

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

如果您在 span 的边界内插入文本,则 span 会自动扩展以包含插入的文本。当在 span 边界(即 startend 索引)处插入文本时,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
);
An image showing a grey text, partially red.
图 1. 使用 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)");
An image showing how the span include more text when SPAN_EXCLUSIVE_INCLUSIVE is used.
图 2. 使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE 时,span 会扩展以包含附加文本。

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

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. 带有多个 spans 的文本:ForegroundColorSpan(Color.RED)StyleSpan(BOLD)

Android span 类型

Android 在 android.text.style 包中提供了 20 多种 span 类型。Android 主要通过两种方式对 spans 进行分类

  • span 如何影响文本:span 可以影响文本外观或文本度量。
  • span 范围:某些 spans 可以应用于单个字符,而其他 spans 必须应用于整个段落。
An image showing different span categories
图 4. Android spans 的类别。

以下部分将更详细地描述这些类别。

影响文本外观的 Spans

一些在字符级别应用的 spans 会影响文本外观,例如更改文本或背景颜色以及添加下划线或删除线。这些 spans 扩展了 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 划线的文本。

仅影响文本外观的 spans 会触发文本重绘,而不会触发布局的重新计算。这些 spans 实现 UpdateAppearance 并扩展 CharacterStyleCharacterStyle 子类通过提供访问权限来更新 TextPaint 来定义如何绘制文本。

影响文本度量的 Spans

其他在字符级别应用的 spans 会影响文本度量,例如行高和文本大小。这些 spans 扩展了 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 放大的文本。

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

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

影响段落的 Spans

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

图 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 包的文档。