Span 是十分強大的標記物件,可用來設定字元或段落層級的文字樣式。您可以將 Span 附加到文字物件中,以各種方式來改變文字呈現方式,包括新增顏色、使文字可點擊、調整文字大小,以及透過自訂的方式繪製文字。Span 也可以用來變更 TextPaint
屬性、在 Canvas
上繪圖,甚至變更文字版面配置。
Android 提供多種包含各式常見文字樣式模式的 Span。您也可以建立專屬 Span 以套用自訂樣式。
建立與套用 Span
您可以使用下表所列的任一類別來建立 Span。每個類別的差異在於文字本身是否可變動、文字標記是否可變動及包含 Span 資料的基礎資料結構:
類別 | 可變動文字 | 可變動標記 | 資料結構 |
---|---|---|---|
SpannedString |
否 | 否 | 線性陣列 |
SpannableString |
否 | 是 | 線性陣列 |
SpannableStringBuilder |
是 | 是 | 區間樹 |
以下說明如何決定要使用哪一種:
- 如果您在建立文字或標記後不會予以修改,請使用
SpannedString
。 - 如果您需要在單一文字物件中附加少量 Span,而文字本身是唯讀狀態,請使用
SpannableString
。 - 如果您需要在建立後修改文字,且需要將 Span 附加到文字,請使用
SpannableStringBuilder
。 - 如果您需要在文字物件中附加大量 Span (無論文字本身是否為唯讀狀態),請使用
SpannableStringBuilder
。
以上所有類別都會擴充 Spanned
介面。SpannableString
和 SpannableStringBuilder
也會擴充 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
。
因為使用 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
並擴充 CharacterStyle
。CharacterStyle
子類別會提供更新 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。只要將 RelativeSizeSpan
和 ForegroundColorSpan
套用至文字,就能達成同樣的效果。
測試 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
,並多次設定文字。根據預設,無論您是否設定 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
時建立額外的物件。
變更內部 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 套件的文件。