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,而 startend 參數會指出要套用 Span 的文字部分。

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

圖 1. 文字樣式包含 ForegroundColorSpan

因為使用 Spannable.SPAN_EXCLUSIVE_INCLUSIVE 設定 Span,因此 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)");

圖 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 可以套用到個別的半形字元,而有些則必須套用到整個段落上。

圖 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 類別。

以下程式碼範例會建立 RelativeSizeSppan,將文字加大 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 提供回呼,因此您可以覆寫這些回呼來實作自訂行為。以下程式碼會建立擴充 RelativeSizeSpan 的自訂 Span,並會覆寫 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。文字樣式範例包含會將 BulletPointSpan 附加至文字,使標記套用到項目符號的 Span。以下程式碼範例說明如何測試項目符號是否會正常顯示:

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() 方法,並使用正確的參數呼叫正確的方法,藉此測試 Canvas 互動,如以下範例所示:

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(CharSerial 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 或調整 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 時保留參照,即可避免經常重複呼叫 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 套件的文件。