Span

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

Android 提供多种类型的 Span,涵盖各种常见的文本样式格式。您也可以创建自己的 Span,以应用自定义样式。

创建并应用 Span

如需创建 Span,您可以使用下表列出的类之一。根据文本本身是否可变、文本标记是否可变以及包含 Span 数据的具体底层数据结构,每个类都有所不同:

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

下面介绍了如何决定使用哪个类:

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

所有这些类都会扩展 Spanned 接口。SpannableStringSpannableStringBuilder 还会扩展 Spannable 接口。

如需应用 Span,请对 Spannable 对象调用 setSpan(Object _what_, int _start_, int _end_, int _flags_)。what 参数指的是要应用于文本的 Span,而 start 和 end 参数表示要对其应用该 Span 的文本部分。

应用 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
);

图 1. 使用 ForegroundColorSpan 样式的文本。

由于该 Span 是使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE 设置的,因此它会扩展以包含在 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)");

图 2. 使用 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 = 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
);

图 3. 具有多个 Span 的文本:ForegroundColorSpan(Color.RED)StyleSpan(BOLD)

Android Span 类型

Android 在 android.text.style 软件包中提供了超过 20 种 Span 类型。Android 主要以两种方式对 Span 进行分类:

  • Span 如何影响文本:Span 可以影响文本外观或文本指标。
  • Span 范围:一些 Span 可应用于单个字符,而其他 Span 则必须应用于整个段落。

图 4. 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);

图 5. 使用 UnderlineSpan 为文本添加下划线

仅影响文本外观的 Span 会触发重新绘制文本,而不会触发重新计算布局。这些 Span 会实现 UpdateAppearance 并扩展 CharacterStyleCharacterStyle 子类通过提供更新 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);

图 6. 使用 RelativeSizeSpan 设置文本大小

应用影响文本指标的 Span 会导致观察对象重新测量文本,以实现正确的布局和渲染。例如,更改文本大小可能会导致字词显示在不同的行上。应用上述 Span 会触发重新测量、重新计算文本布局,以及重新绘制文本。这些 Span 通常会扩展 MetricAffectingSpan 类,该类为抽象类,它允许子类通过提供对 TextPaint 的访问权限来定义 Span 影响文本测量的方式。由于 MetricAffectingSpan 会扩展 CharacterSpan,因此子类会影响字符级别的文本外观。

影响单个字符的 Span

Span 可能会影响字符级别的文本。例如,您可以更新背景颜色、样式或大小等字符元素。影响单个字符的 Span 会扩展 CharacterStyle 类。

以下代码示例将 BackgroundColorSpan 附加到文本中的部分字符:

Kotlin

val string = SpannableString("Text with a background color span")
string.setSpan(BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Java

SpannableString string = new SpannableString("Text with a background color span");
string.setSpan(new BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

图 7. 对文本应用 BackgroundColorSpan

影响段落的 Span

Span 还可能影响段落级别的文本,例如更改整个文本块的对齐方式或边距。影响整个段落的 Span 会实现 ParagraphStyle。使用这些 Span 时,您必须将其附加到整个段落,不包括末尾换行符。如果您尝试将段落 Span 应用于除整个段落以外的其他内容,Android 根本不会应用该 Span。

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

图 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);

图 9. 将 QuoteSpan 应用到段落

创建自定义 Span

除了现有 Android Span 中提供的功能之外,如果您还需要其他功能,可以实现自定义 Span。实现自己的 Span 时,您需要确定您的 Span 会在字符级别还是段落级别影响文本,以及它是影响文本的布局还是外观。这有助于您确定可以扩展的基类以及可能需要实现的接口。请参考下表:

场景 类或接口
您的 Span 会在字符级别影响文本。 CharacterStyle
您的 Span 会在段落级别影响文本。 ParagraphStyle
您的 Span 会影响文本外观。 UpdateAppearance
您的 Span 会影响文本指标。 UpdateLayout

例如,如果您需要实现可用于修改文本大小和颜色的自定义 Span,您可以扩展 RelativeSizeSpan。由于该类已经为 updateDrawState 和 updateMeasureState 提供了回调,因此您可以替换这些回调以实现自定义行为。以下代码创建了一个自定义 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。您可以通过对文本应用 RelativeSizeSpanForegroundColorSpan 来实现同样的效果。

测试 Span 用法

Spanned 接口支持设置 Span 和从文本中检索 Span。进行测试时,您应该实现 Android JUnit 测试,以确认是否在正确的位置添加了正确的 Span。文本样式示例包含的 Span 通过将 BulletPointSpan 附加到文本来将标记应用于项目符号。以下代码示例展示了如何测试项目符号是否按预期的方式显示:

Kotlin

@Test fun textWithBulletPoints() {
   val result = builder.markdownToSpans(“Points\n* one\n+ two”)

   // check that the markup tags were 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 that the correct number of spans were created
   assertEquals(2, spans.size.toLong())

   // check that the spans are instances of BulletPointSpan
   val bulletSpan1 = spans[0] as BulletPointSpan
   val bulletSpan2 = spans[1] as BulletPointSpan

   // check that 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 that the markup tags were 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 that the correct number of spans were created
    assertEquals(2, spans.length);

    // check that the spans are instances of BulletPointSpan
    BulletPointSpan bulletSpan1 = (BulletPointSpan) spans[0];
    BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1];

    // check that 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));
}

如需查看更多测试示例,请参阅 MarkdownBuilderTest

测试自定义 Span 实现

测试 Span 时,您应确认 TextPaint 包含预期的修改,并且您的 Canvas 上显示了正确的元素。假设有一个自定义 Span 实现,它在一些文本前面添加了项目符号,该项目符号具有指定的大小和颜色,并且在可绘制区域的左边距和项目符号之间存在着间隙。

您可以通过实现 AndroidJUnit 测试并检查以下内容来测试此类的行为:

  • 如果正确应用 Span,画布上会出现指定尺寸和颜色的项目符号,并且左边距和项目符号之间也会存在适当的间距
  • 如果您未应用 Span,就不会出现任何自定义行为

请参阅 TextStylingKotlin 示例中这些测试的实现。

您可以通过以下方法来测试画布互动:模拟画布,将模拟的对象传递给 drawLeadingMargin() 方法,并验证是否使用正确的参数调用了正确的方法,具体如下例所示:

Kotlin

val GAP_WIDTH = 5
val canvas = mock(Canvas::class.java)
val paint = mock(Paint::class.java)
val text = SpannableString("text")

@Test fun drawLeadingMargin() {
    val x = 10
    val dir = 15
    val top = 5
    val bottom = 7
    val color = Color.RED

    // Given a span that is set on a text
    val span = BulletPointSpan(GAP_WIDTH, color)
    text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    // When the leading margin is drawn
    span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom,
            text, 0, 0, true, mock(Layout::class.java))

    // Check that the correct canvas and paint methods are called,
    // in the correct order
    val inOrder = inOrder(canvas, paint)
    // bullet point paint color is the one we set
    inOrder.verify(paint).color = color
    inOrder.verify(paint).style = eq<Paint.Style>(Paint.Style.FILL)
    // a circle with the correct size is drawn
    // at the correct location
    val xCoordinate = GAP_WIDTH.toFloat() + x.toFloat()
        + dir * BulletPointSpan.DEFAULT_BULLET_RADIUS
    val yCoordinate = (top + bottom) / 2f
    inOrder.verify(canvas)
           .drawCircle(
                eq(xCoordinate),
                eq(yCoordinate),
                eq(BulletPointSpan.DEFAULT_BULLET_RADIUS),
                eq(paint)
            )
    verify(canvas, never()).save()
    verify(canvas, never()).translate(
               eq(xCoordinate),
               eq(yCoordinate)
    )
}

Java

private int GAP_WIDTH = 5;
private Canvas canvas = mock(Canvas.class);
private Paint paint = mock(Paint.class);
private SpannableString text = new SpannableString("text");

@Test
public void drawLeadingMargin() {
    int x = 10;
    int dir = 15;
    int top = 5;
    int bottom = 7;
    int color = Color.RED;

    // Given a span that is set on a text
    BulletPointSpan span = new BulletPointSpan(GAP_WIDTH, color);
    text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

    // When the leading margin is drawn
    span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom, text, 0, 0, true, mock
            (Layout.class));

    // Check that the correct canvas and paint methods are called, in the correct order
    InOrder inOrder = inOrder(canvas, paint);
    inOrder.verify(paint).setColor(color);
    inOrder.verify(paint).setStyle(eq(Paint.Style.FILL));
    // a circle with the correct size is drawn
    // at the correct location
    int xCoordinate = (float)GAP_WIDTH + (float)x
        + dir * BulletPointSpan.BULLET_RADIUS;
    int yCoordinate = (top + bottom) / 2f;
    inOrder.verify(canvas)
           .drawCircle(
                eq(xCoordinate),
                eq(yCoordinate),
                eq(BulletPointSpan.BULLET_RADIUS),
                eq(paint));
    verify(canvas, never()).save();
    verify(canvas, never()).translate(
            eq(xCoordinate),
            eq(yCoordinate);
}

您可以在 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 并多次设置文本。默认情况下,无论您是否设置 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 时创建额外的对象。

更改内部 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 our mutable span
            bulletSpan.color = Color.GRAY
            // color won’t be changed 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 our mutable span
                bulletSpan.setColor(Color.GRAY);
                // color won’t be changed until invalidate is called
                styledText.invalidate();
            }
        });
    }
}

使用 Android KTX 扩展功能

Android KTX 还包含扩展功能,可简化 Span 的使用。如需了解详情,请参阅有关 androidx.core.text 软件包的文档。