[譯] 使用 Span 來修改文字樣式的優質體驗

LeviDing發表於2018-06-16

如果要在 Android 中設定文字的樣式,請使用 spans!使用 span 改變一些字元的顏色,使它們可以被點選、縮放文字的大小、甚至是繪製自定義的 bullet points。Spans 可以改變 TextPaint 屬性、在 Canvas 上繪製,甚至改變文字佈局並影響線高等元素。Span 是可以附加到文字和從文字分離的標記物件,它們可以應用於整個段落或部分文字。

讓我們來學習如何使用 spans,有哪些 spans 供我們選擇,如何簡單建立屬於你的 spans 以及如何測試它們。

在 Android 中設定文字樣式

Android 提供了幾種方法用於文字樣式的設定:

  • 單一樣式 —— 樣式是用於由 TextView 顯示的整個文字
  • 多樣式 —— 可以將多種不同的樣式分別應用於文字、字元或者段落

單一樣式 意味著使用 XML 屬性或者樣式和主題對 TextView 的整個內容進行樣式的修改。使用 XML 的方法是一種比較簡單的解決方案,但是這種方法無法修改文字中間的樣式。例如,通過設定 textStyle=”bold”,整個文字將變成粗體,您不能只將特定字元定義為粗體。

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textSize="32sp"
    android:textStyle="bold"/>
複製程式碼

多樣式意味著在同一文字中新增多種樣式。例如,將一個單詞設定為斜體,另一個單詞設定為粗體。多樣式模式可以使用 HTML 標籤,在畫布上使用 spans 或者通過處理自定義文字繪製來進行文字樣式的應用。

[譯] 使用 Span 來修改文字樣式的優質體驗

左圖:單一樣式的文字。TextView 設定 textSize=”32sp”textStyle=”bold”。右圖:多樣式的文字。文字設定 ForegroundColorSpanStyleSpan(ITALIC)ScaleXSpan(1.5f)StrikethroughSpan

HTML 標籤是一種處理簡單樣式問題的解決方案,如使文字變粗體、斜體甚至是標識 bullet points。要設定包含 HTML 標籤的文字,請呼叫 Html.fromHtml 方法。在 HTML 引擎中,HTML 格式被轉換成 spans。請注意,Html 類並不支援所有 HTML 標籤和 css 樣式,例如使 bullet points 變成另一種顏色。

val text = "My text <ul><li>bullet one</li><li>bullet two</li></ul>"
myTextView.text = Html.fromHtml(text)
複製程式碼

當您發現有平臺不支援的樣式需求時,您可以手動在畫布上繪製文字,例如需要寫一個彎曲的文字。

Spans 允許您使用更精細的方法來自定義實現多樣式文字。例如,您可以通過使用 BulletSpan 來定義 bullet point。您也可以自定義目標文字邊距和顏色。從 Android P 開始,您甚至可以設定 bullet point 的半徑

val spannable = SpannableString("My text \nbullet one\nbullet two")

spannable.setSpan(
    BulletPointSpan(gapWidthPx, accentColor),
    /* start index */ 9, /* end index */ 18,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

spannable.setSpan(
     BulletPointSpan(gapWidthPx, accentColor),
     /* start index */ 20, /* end index */ spannable.length,
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

myTextView.text = spannable
複製程式碼

[譯] 使用 Span 來修改文字樣式的優質體驗

左圖:使用 HTML 標籤。中圖:使用 BulletSpan 設定預設 bullet 大小。右圖:使用 BulletSpan 在 Android P 或者自定義實現。

您可以結合單一樣式和多樣式。您可以將您設定 TextView 的樣式視為“基礎”樣式。spans 的文字樣式應用於基礎樣式的“頂部”,並且會覆蓋基礎樣式。例如,當將 textColor=”@color.blue” 屬性設定為 TextView 並對文字的前4個字元設定 ForegroundColorSpan(Color.PINK) 時,前 4 個字元將使用粉紅色,是由 span 來進行控制,剩下的部分有 TextView 屬性來進行設定。

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/blue"/>

val spannable = SpannableString(“Text styling”)
spannable.setSpan(
    ForegroundColorSpan(Color.PINK), 
    0, 4, 
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

myTextView.text = spannable
複製程式碼

[譯] 使用 Span 來修改文字樣式的優質體驗

將 TextView 使用 XML 和文字結合的方式來使用 spans。

應用中的 Spans

當使用 spans 時,您將使用以下類之一:SpannedStringSpannableString 或者 SpannableStringBuilder。他們之間的區別在於文字或者標記的物件是否可變以及他們使用內部結構:SpannedStringSpannableString 是使用線性的方式來儲存新增 spans 的記錄。而 SpannableStringBuilder 使用區間樹來實現。

以下是怎麼確定要使用哪一個 Spans:

  • 僅僅讀取而不是設定文字或者 spans? -> SpannableString
  • 設定文字和 spans ?-> SpannableStringBuilder
  • 設定一個 spans 很少數量的文字(<~10)? -> SpannableString
  • 設定一個 spans 很大數量的文字(>~10)? -> SpannableStringBuilder

例如,如果您使用的文字不會改變,但要將其附加到 spans 的文字中,應該使用 SpannableString

╔════════════════════════╦══════════════════╦════════════════════╗
║ **Class**              ║ **Mutable Text** ║ **Mutable Markup** ║
╠════════════════════════╬══════════════════╬════════════════════╣
║ SpannedString          ║       no         ║       no           ║
║ SpannableString        ║       no         ║       yes          ║
║ SpannableStringBuilder ║       yes        ║       yes          ║
╚════════════════════════╩══════════════════╩════════════════════╝
複製程式碼

所有這些類都繼承 Spanned 的介面,但是具有可變標記(SpannableStringSpannableStringBuilder)也是繼承與Spannable

Spanned -> 帶有不可變標記的不可變文字

Spannable(繼承 Spanned)-> 具有可變標記的不可變文字

通過 Spannable 物件呼叫 setSpan(Object what, int start, int end, int flags)what物件是將從文字中的開始到結束索引的標記。這個標誌代表了這個 span 是否應在其擴充套件到包含起點或者終點的位置處插入文字。無論在那個位置進行標記,只要文字插入的位置大於起點小於終點位置,span 將自動擴大。

舉個例子,設定一個 ForegroundColorSpan 可以像這麼做:

val spannable = SpannableStringBuilder(“Text is spantastic!”)

spannable.setSpan(
     ForegroundColorSpan(Color.RED), 
     8, 12, 
     Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
複製程式碼

由於 span 是使用 SPAN_EXCLUSIVE_INCLUSIVE 標誌,因此在文字末插入文字時,它將會擴充套件到包含新的文字。

val spannable = SpannableStringBuilder(“Text is spantastic!”)

spannable.setSpan(
     ForegroundColorSpan(Color.RED), 
     /* start index */ 8, /* end index */ 12, 
     Spannable.SPAN_EXCLUSIVE_INCLUSIVE)

spannable.insert(12, “(& fon)”)
複製程式碼

[譯] 使用 Span 來修改文字樣式的優質體驗

左圖:文字使用 ForegroundColorSpan。右圖:文字使用 ForegroundColorSpanSpannable.SPAN_EXCLUSIVE_INCLUSIVE

如果 span 設定為 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE 標誌,則在 span 末尾插入的文字將不會修改 span 的結束標記。

多 spans 可以組成並且附加到相同的文字段。舉個例子,粗體和紅色的文字都可以這樣構造:

val spannable = SpannableString(“Text is spantastic!”)

spannable.setSpan(
     ForegroundColorSpan(Color.RED), 
     8, 12, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

spannable.setSpan(
     StyleSpan(BOLD), 
     8, spannable.length, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
複製程式碼

[譯] 使用 Span 來修改文字樣式的優質體驗

文字使用多 spans:ForegroundColorSpan(Color.RED)StyleSpan(BOLD)

spans 的框架

Android 框架定義了在度量和渲染圖形時檢查的幾個介面和抽象類。這些類具有允許 span 訪問 TextPaint 或者 Canvas 物件的方法。

Android 框架在 android.text.style 包中提供了20多個 span,對主要的介面和抽象類進行了子類化。我們可以用幾種方法進行分類:

  • 根據 span 是僅僅更改外觀還是更改文字的度量/佈局
  • 根據它們是否影響文字在字元或者段落中的級別

[譯] 使用 Span 來修改文字樣式的優質體驗

Span 型別:字元與段落,外觀與度量。

外觀與度量分別對 span 的影響

第一組分類影響字元級文字可以修改它們的外觀:文字或背景顏色、下劃線、刪除線等,會重新繪製而不會導致文字重新佈局。這些 span 實現了 UpdateAppearance 並且繼承 CharacterStyleCharacterStyle 子類定義瞭如何通過提供更新 TextPaint 來訪問文字。

[譯] 使用 Span 來修改文字樣式的優質體驗

影響外觀的 span。

度量影響 spans 修改文字度量和佈局,因此觀察 span 的物件將會從新測量文字以便於正確的佈局和渲染。

舉個例子,影響文字大小的 span 將需要從新測量、佈局以及繪製。這些 spans 通常會去繼承 MetricAffectingSpan 類。這個抽象類允許子類通過對 TextPaint 的訪問來決定如何去測量文字。由於 MetricAffectingSpan 繼承 CharacterSpan,因此子類會影響字元級別的文字外觀。

[譯] 使用 Span 來修改文字樣式的優質體驗

影響度量的 span。

您可能總是想去重新建立帶有文字和標記的 CharSequence,並呼叫 TextView.setText(CharSequence)。 但是這將會導致每次重新測量、重新繪製佈局以及建立額外物件。為了降低效能消耗,請使用 TextView.setText(Spannable, BufferType.SPANNABLE) 然後,當你需要修改 span 時,通過將 TextView.getText() 強制轉換成 Spannable 來從 TextView 中檢索 Spannable 物件。我們將在後面詳細介紹 TextView.setText 背後的原理,以及不同的效能優化

舉個例子,思考以下 Spannable 物件並像這樣檢索:

val spannableString = SpannableString(“Spantastic text”)

// setting the text as a Spannable
textView.setText(spannableString, BufferType.SPANNABLE)

// later getting the instance of the text object held 
// by the TextView
// this can can be cast to Spannable only because we set it as a
// BufferType.SPANNABLE before
val spannableText = textView.text as Spannable
複製程式碼

現在,當我們在 spannableText 中設定 span 時,我們不需要再次呼叫 textView.setText,因為我們直接修改由 TextView 持有的 CharSequence 物件例項。

以下是我們設定不同 span 時發生的情況:

情況 1:影響外觀的 span

spannableText.setSpan(
     ForegroundColorSpan(colorAccent), 
     0, 4, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
複製程式碼

由於我們附加了一種影響外觀的 span,因此呼叫了 TextView.onDraw,而不是 TextView.onLayout。文字進行重繪,但寬度和高度將會相同。

情況 2:影響度量的 span

spannableText.setSpan(
     RelativeSizeSpan(2f), 
     0, 4, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
複製程式碼

因為 RelativeSizeSpan 可以改變文字的大小、寬度和高度(舉個例子,一個特定的單詞可能會出現在下一行,但是 TextView 的大小不會被修改)。TextView 需要計算新的大小,所以 onMeasureonLayout 會被呼叫。

[譯] 使用 Span 來修改文字樣式的優質體驗

左圖:ForegroundColorSpan — 影響外觀的 span。右圖:RelativeSizeSpan — 影響度量的 span。

影響字元和段落的 spans

span 不但可以改變字元級別的文字,更新元素如背景顏色、樣式或者大小,而且可以改變段落級別的文字,更改整個文字塊的對齊或者邊距。根據所需的樣式,spans 繼承 CharacterStyle 或者實現 ParagraphStyle。繼承 ParagraphStyle 的 Spans 必須從第一個字元附加到單個段落的最後一個字元,否則 span 將不會被顯示出來。在 Android 上,段落是根據(\n)字元定義的。

[譯] 使用 Span 來修改文字樣式的優質體驗

在 Android 上,段落是根據(\n)字元定義的。

[譯] 使用 Span 來修改文字樣式的優質體驗

影響段落的 spans。

舉個例子,像是 BackgroundColorSpanCharacterStyle span,可以附加到文字中的任何字元。這裡我們將其新增至第5到第8個字元中:

val spannable = SpannableString(“Text is\nspantastic”)

spannable.setSpan(
    BackgroundColorSpan(color),
    5, 8,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
複製程式碼

QuoteSpan 一樣的 ParagraphStyle span 只能從段落開頭附加,否則文字的邊距並不會生效。舉個例子,“Text is\nspantastic” 在文字的第8個字元中包含了換行,因此我們可以將 QuoteSpan 附加到它上面,並且只是從那裡開始的段落將被格式化。如果我們將 span 附加到除了 0 或 8 以外的其他任何位置,則文字不會被設定目標樣式。

spannable.setSpan(
    QuoteSpan(color), 
    8, text.length, 
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
複製程式碼

[譯] 使用 Span 來修改文字樣式的優質體驗

左圖:BackgroundColorSpan — 影響外觀的 span。右圖:QuoteSpan — 影響段落的 span。

建立自定義的 spans

當需要實現自己的 span 時,您需要確定 span 是否需要影響字元或者段落文字,以及它是否影響佈局或者文字的外觀。但是從頭開始編寫自己的實現之前,請檢查您是否可以使用 span 框架中提供的功能。

TL;DR:

  • 字元級別修改文字 -> CharacterStyle
  • 段落級別修改文字 -> ParagraphStyle
  • 修改文字外觀 -> UpdateAppearance
  • 修改文字度量 -> UpdateLayout

假如我們需要實現一個 span,允許一定比例的增加文字的大小,就像是 RelativeSizeSpan,並設定文字的顏色,像是 ForegroundColorSpan。為此,我們可以繼承 RelativeSizeSpan,並且由於它提供了 updateDrawStateupdateMeasureState 回撥函式,我們可以重寫繪製狀態的回撥並且設定 TextPaint 的顏色。

class RelativeSizeColorSpan(
    @ColorInt private val color: Int,
    size: Float
) : RelativeSizeSpan(size) {

    override fun updateDrawState(textPaint: TextPaint?) {
         super.updateDrawState(ds)
         textPaint?.color = color
    }
}
複製程式碼

提示:通過將 RelativeSizeSpanForegroundColorSpan 設定在相同的文字可以獲得同樣的效果。

測試您實現自定義的 spans

測試 spans 意味著檢查是否確實對 TextPaint 進行了預期的修改或者 Canvas 上繪製了正確的元素。舉個例子,考慮 span 的自定義實現,該 span 向段落中新增具有大小和顏色的 bullet point 以及左邊距和 bullet point 之間的間隙。請參考 android-text sample。為了測試這個類而實現了一個 AndroidJUnit 測試類來檢查是否滿足預期效果:

  • 在畫布上繪製一個特定尺寸的圓
  • 如果 span 未附加到文字,則不繪製任何內容
  • 根據建構函式的引數值設定正確的頁邊距

測試 Canvas 互動可以通過模擬一個畫布,將模擬出來的物件傳遞給 drawLeadingMargin 方法,並驗證呼叫的含有正確引數的方法。

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 yCoord = (top + bottom) / 2f

    inOrder.verify(canvas)
           .drawCircle(
                eq(xCoordinate),
                eq(yCoord), 
                eq(BulletPointSpan.DEFAULT_BULLET_RADIUS), 
                eq(paint))
    verify(canvas, never()).save()
    verify(canvas, never()).translate(
               eq(xCoordinate), 
               eq(yCoordinate))
}
複製程式碼

檢視其餘的測試在 BulletPointSpanTest

測試 spans 的用法

Spanned 介面允許從文字中設定和檢索 span。通過實現 Android JUnit 測試,來檢查是否在正確的位置新增了正確的 span。在 android-text sample 中,我們 bullet point 標記標籤轉換成 bullet points。這是通過 在正確的位置附加 BulletPointSpans 來完成的。以下是可以被測試的方式:

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

// check that 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)assertEquals(2, spans.size.toLong())

// check that the span is indeed a BulletPointSpan
val bulletSpan = spans[0] as BulletPointSpan

// check that the start and end indexes are the expected ones
assertEquals(7, result.getSpanStart(bulletSpan).toLong())
assertEquals(11, result.getSpanEnd(bulletSpan).toLong())

val bulletSpan2 = spans[1] as BulletPointSpan
assertEquals(11, result.getSpanStart(bulletSpan2).toLong())
assertEquals(14, result.getSpanEnd(bulletSpan2).toLong())
}
複製程式碼

檢視 MarkdownBuilderTest 以獲得更多測試示例。

提示:如果你需要遍歷測試外的 spans,使用 Spanned#nextSpanTransition 而不是 Spanned#getSpans,因為它更高效。


Spans 是一個很強大的概念,文字渲染功能中有強大的功能。他們允許訪問像 TextPaintCanvas 這樣的元件,這些元件可以在 Android 上進行高度可定製的樣式文字。在 Android P 中,我們為 spans 框架新增了大量文件,因此在您需要實現自己的 span 的時候,請先檢視是否有您需要的功能。

在以後的文章中,我們將更詳細地介紹 span 如何在引擎下以高效的方式使用它們。例如,您需要使用 textView.setText(CharSequence, BufferType)。有關詳情,敬請關注!

非常感謝 Siyamed Sinir, Clara Bayarri 和 Nick Butcher


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章