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

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

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

在 Android 中設置文字樣式

Android 提供了幾種方法用於文本樣式的設置:前端

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

單同樣式 意味着使用 XML 屬性或者樣式和主題對 TextView 的整個內容進行樣式的修改。使用 XML 的方法是一種比較簡單的解決方案,可是這種方法沒法修改文本中間的樣式。例如,經過設置 textStyle=」bold」,整個文本將變成粗體,您不能只將特定字符定義爲粗體。java

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

多樣式意味着在同一文本中添加多種樣式。例如,將一個單詞設置爲斜體,另外一個單詞設置爲粗體。多樣式模式可使用 HTML 標籤,在畫布上使用 spans 或者經過處理自定義文本繪製來進行文本樣式的應用。android

左圖:單同樣式的文本。TextView 設置 textSize=」32sp」textStyle=」bold」。右圖:多樣式的文本。文本設置 ForegroundColorSpanStyleSpan(ITALIC)ScaleXSpan(1.5f)StrikethroughSpanios

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

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

當您發現有平臺不支持的樣式需求時,您能夠手動在畫布上繪製文本,例如須要寫一個彎曲的文本。github

Spans 容許您使用更精細的方法來自定義實現多樣式文本。例如,您能夠經過使用 BulletSpan 來定義 bullet point。您也能夠自定義目標文本邊距和顏色。從 Android P 開始,您甚至能夠設置 bullet point 的半徑canvas

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
複製代碼

左圖:使用 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
複製代碼

將 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)」)
複製代碼

左圖:文本使用 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)
複製代碼

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

spans 的框架

Android 框架定義了在度量和渲染圖形時檢查的幾個接口和抽象類。這些類具備容許 span 訪問 TextPaint 或者 Canvas 對象的方法。

Android 框架在 android.text.style 包中提供了20多個 span,對主要的接口和抽象類進行了子類化。咱們能夠用幾種方法進行分類:

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

Span 類型:字符與段落,外觀與度量。

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

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

影響外觀的 span。

度量影響 spans 修改文本度量和佈局,所以觀察 span 的對象將會重新測量文本以便於正確的佈局和渲染。

舉個例子,影響文本大小的 span 將須要重新測量、佈局以及繪製。這些 spans 一般會去繼承 MetricAffectingSpan 類。這個抽象類容許子類經過對 TextPaint 的訪問來決定如何去測量文本。因爲 MetricAffectingSpan 繼承 CharacterSpan,所以子類會影響字符級別的文本外觀。

影響度量的 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 會被調用。

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

影響字符和段落的 spans

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

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

影響段落的 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)
複製代碼

左圖: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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索