
探索 Android 中的 Span
在 Android 中,使用 Span 定義文本的樣式. 通過 Span 改變幾個(gè)文字的顏色,讓它們可點(diǎn)擊,放縮文字的大小甚至是繪制自定義的項(xiàng)目符號點(diǎn)(bullet points,國外人名中名字之間的間隔符號 · ,HTML 中無序列表項(xiàng)的默認(rèn)符號)。Span 能夠改變 TextPaint 屬性,在 Canvas 上繪制,甚至是改變文本的布局和影響像行高這樣的元素。Span 是可以附加到文本或者從本文分離的標(biāo)記對象(markup objects);它們可以被應(yīng)用到部分或整段的文本中。
讓我們來看看Span如何使用、提供了哪些開箱即用的功能、怎樣簡單地創(chuàng)建我們自己的 Span 以及如何使用和測試它們。
Testing custom spans implementation
在 Android 上定義文本樣式
Android 提供了幾種定義文本樣式的方法:
- 單一樣式 —— 樣式應(yīng)用在 TextView 顯示的整個(gè)文本
- 多重樣式 —— 多種樣式應(yīng)用在字符或者段落級別的文本
單一樣式 使用 XML 屬性或者 樣式和主題 引入了 TextView 的所有內(nèi)容的樣式。這種方式實(shí)現(xiàn)簡單,通過 XML 即可實(shí)現(xiàn),但是并不能只定義部分內(nèi)容的樣式。舉個(gè)例子,通過設(shè)置 textStyle=”bold”,所有的文本都會變?yōu)楹隗w;你不能只定義特定的幾個(gè)字符為黑體。
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="32sp"
android:textStyle="bold"/>
多重樣式 引入了給一段文本添加多種樣式的功能。例如,一個(gè)單詞斜體而另一個(gè)粗體。多重樣式可以通過使用 HTML 標(biāo)簽、 Span 或者是在 Canvas 上處理自定義的文本繪制。

左圖:單一樣式文本。設(shè)置了 textSize=”32sp” 和 textStyle=”bold” 的 TextView 。右圖:多重樣式文本。設(shè)置了 ForegroundColorSpan, StyleSpan(ITALIC), ScaleXSpan(1.5f), StrikethroughSpan 的文本。
HTML 標(biāo)簽是解決簡單問題的簡單辦法,例如使文本加粗、斜體,甚至是顯示項(xiàng)目符號點(diǎn)。為了展示含有 HTML 標(biāo)簽的文本,使用 Html.fromHtml 方法。在內(nèi)部實(shí)現(xiàn)時(shí),HTML 標(biāo)簽被轉(zhuǎn)換成了 span 。但是請注意,Html 類并不支持完整的 HTML 標(biāo)簽和 CSS 樣式,例如將小黑點(diǎn)改為其他的顏色。
val text = "My text <ul><li>bullet one</li><li>bullet two</li></ul>"
myTextView.text = Html.fromHtml(text)
當(dāng)你有文本樣式的需求,但是 Android 平臺默認(rèn)不支持時(shí),你還可以手動地在 Canvas 上繪制文本,例如讓文字彎曲排布。
Span 允許你實(shí)現(xiàn)具有更細(xì)粒度自定義的多重樣式文本。舉個(gè)例子,通過 BulletSpan,你可以定義你的段落文本擁有項(xiàng)目符號點(diǎn)。你可以定制文本和點(diǎn)號之間的間距和點(diǎn)號的顏色。從 Android P 開始,你甚至可以 設(shè)置點(diǎn)號的半徑 。你也可以創(chuàng)建 span 的自定義實(shí)現(xiàn)。在文章中查看 “創(chuàng)建自定義 span” 部分可以找到如何實(shí)現(xiàn)。
val spannable = SpannableString("My text \nbullet one\nbullet two")
spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* 起始索引 */ 9, /* 終止索引 */ 18,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* 起始索引 */ 20, /* 終止索引 */ spannable.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
myTextView.text = spannable

左圖:使用 HTML 標(biāo)簽;中圖:使用 BulletSpan,默認(rèn)圓點(diǎn)大??;右圖:在 Android P 上使用 BulletSpan 或者自定義實(shí)現(xiàn)。
你可以組合使用單一樣式和多重樣式。你可以考慮將設(shè)置給 TextView 的樣式作為一種“基本”樣式,而 span 文本樣式是應(yīng)用在基本樣式“之上”并且會覆蓋基本樣式的樣式。例如,當(dāng)給一個(gè) TextView 設(shè)置了 textColor=”@color.blue” 屬性且給頭4個(gè)字符應(yīng)用了 ForegroundColorSpan(Color.PINK),則頭4個(gè)字符會使用 span 設(shè)置的粉色,而其他文本使用 TextView 屬性設(shè)置的顏色。
<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 屬性和 span 樣式
應(yīng)用 span
當(dāng)使用 span 時(shí),你會和以下類的其中之一打交道:SpannedString, SpannableString 或 SpannableStringBuilder。 它們之間的區(qū)別在于文本或標(biāo)記對象是可改變的還是不可改變的以及它們使用的內(nèi)部結(jié)構(gòu):SpannedString 和 SpannableString 使用線性數(shù)組記錄已添加的 span,而 SpannableStringBuilder 使用 區(qū)間樹。
下面是如何決定使用哪一個(gè)的方法:
- 僅 讀取而不設(shè)置 文本和 span? -->
SpannedString -
設(shè)置文本和 span? -->
SpannableStringBuilder - 設(shè)置 少量的 span (<~ 10)? -->
SpannableString - 設(shè)置 大量的 span (>~ 10)? -->
SpannableStringBuilder
舉個(gè)例子,你用到的文本并不會改變,但你想要附加 span 時(shí),你應(yīng)該使用 SpannableString。
║ 類 ║ 可變文本 ║ 可變標(biāo)記 ║
═════════════════════════════════════════════════════════
║ SpannedString ║ 否 ║ 否 ║
║ SpannableString ║ 否 ║ 是 ║
║ SpannableStringBuilder ║ 是 ║ 是 ║
上面所有的這些類都繼承自 Spanned 接口,但擁有可變標(biāo)記的類( SpannableString 和 SpannableStringBuilder) 同時(shí)也繼承自 Spannable。
Spanned --> 帶有不可變標(biāo)記的不可變文本
Spannable (繼承自 Spanned) --> 帶有可變標(biāo)記的不可變文本
通過 Spannable 對象調(diào)用 setSpan(Object what, int start, int end, int flags) 方法應(yīng)用 span。 What 對象是一個(gè)標(biāo)記,應(yīng)用于從開始到結(jié)束的索引之間的文本。falg 標(biāo)志位標(biāo)記了 span 是否應(yīng)該擴(kuò)展至包含插入文本的開始和結(jié)束的點(diǎn)。任何標(biāo)志位設(shè)置以后,只要插入文本的位置位于開始位置和結(jié)束位置之間,span 就會自動的擴(kuò)展。
舉個(gè)例子,設(shè)置 ForegroundColorSpan 可以像下面這樣完成:
val spannable = SpannableStringBuilder(“Text is spantastic!”)
spannable.setSpan(
ForegroundColorSpan(Color.RED),
8, 12,
Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
因?yàn)?span 設(shè)置時(shí)使用了 SPAN_EXCLUSIVE_INCLUSIVE 標(biāo)志位,在 span 的后面插入文本時(shí),新插入的文本也會自動地繼承此 span。
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 的文本;右圖:帶有 ForegroundColorSpan 和 Spannable.SPAN_EXCLUSIVE_INCLUSIVE 的文本。
如果 span 設(shè)置了 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE 標(biāo)志位,在 span 后面插入文本時(shí)則不會修改 span 的結(jié)束索引。
多個(gè) span 可以被組合且同時(shí)附加到同一段文本上。例如,粗體紅色的文本可以像這樣構(gòu)建:
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 的文本:ForegroundColorSpan(Color.RED) 和 StyleSpan(BOLD)
Framework span
Android framework 定義了幾個(gè)接口和抽象類,它們會在測量和渲染時(shí)被檢查。這些類有允許 span 訪問像 TextPaint 或 Canvas 對象的方法。
Android framework 在 android.text.style 包、主要接口的字類和抽象類中提供了20+的 span, 我們可以通過下面幾個(gè)方式對 span 進(jìn)行分類:
- 基于 span 是否只改變外形或者 text 的大小或布局
- 基于 是否會在字符或段落級別影響文本

span 類別:字符對比段落,外形對比大小
影響外形對比影響尺寸的 span
第一種類型以修改外形的方式在字符級別起作用:文本或背景顏色、下劃線、中橫線等等,它會觸發(fā)文本重新繪制但是并不會重新布局。這些 span 引入了 UpdateAppearance 且繼承自 CharacterStyle. CharacterStyle 字類通過提供更新 TextPaint 的訪問方法,定義了怎樣繪制文本。

影響外形的 span
影響尺寸的 span 更改了文本的尺寸和布局,因此觀察 span 的變化的對象會重新繪制文本以保證布局和渲染的正確。
舉個(gè)例子,影響文本字體大小的 span 要求重新測量和布局,也要求重新繪制。這種 span 通常繼承自 MetricAffectingSpan 類。這個(gè)抽象類通過提供對 TextPaint 的訪問,允許字類定義 span 如何影響文本測量,而 MetricAffectingSpan 繼承自 CharacterSpan,子類在字符級別影響文本的外形。

影響尺寸的 span
你可能會想要一直重新創(chuàng)建帶有文本和標(biāo)記的 CharSequence 并且調(diào)用 TextView.setText(CharSequence) 方法,但是這樣做很有可能一直觸發(fā)已經(jīng)創(chuàng)建好的布局和額外的對象的重新測量和重新繪制。為了減少性能損耗,將文本設(shè)置為 TextView.setText(Spannable, BufferType.SPANNABLE),然后當(dāng)你需要更改 span 的時(shí)候,通過將 TextView.getText() 轉(zhuǎn)換為 Spannable 從 TextView 獲得 Spannable 對象。我們會在未來的文章中詳細(xì)討論 TextView.setText 的實(shí)現(xiàn)原理和不同的性能優(yōu)化方式。
舉個(gè)例子,考慮通過這樣的方式設(shè)置和獲取 Spannable:
val spannableString = SpannableString(“Spantastic text”)
// 將文本設(shè)置為一個(gè) Spannable
textView.setText(spannableString, BufferType.SPANNABLE)
// 然后獲取 TextView 持有的 text 對象引用
// 這里之所以能轉(zhuǎn)換為 Spannable 是因?yàn)槲覀冎皩⑺O(shè)置為了 BufferType.SPANNABLE
val spannableText = textView.text as Spannable
現(xiàn)在,當(dāng)我們在 spannableText 上設(shè)置了 span 之后,我們就不需要再調(diào)用 textView.setText 了,因?yàn)槲覀冋谥苯有薷?TextView 持有的 CharSequence 對象的引用。
這是當(dāng)我們設(shè)置了不同的 span 之后會發(fā)生什么:
情形1: 影響外觀的 span
spannableText.setSpan(
ForegroundColorSpan(colorAccent),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
當(dāng)我們附加了一個(gè)影響外觀的 span 之后,TextView.onDraw 方法被調(diào)用但 TextView.onLayout 沒有。這是文本重繪,但寬和高保持原樣。
情形2: 影響尺寸的 span
spannableText.setSpan(
RelativeSizeSpan(2f),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
因?yàn)?RelativeSizeSpan 改變了文本的大小,文本的寬和高變化,文本的布局方式(舉個(gè)例子,在 TextView 的大小沒有變化的情況下,一個(gè)特定的單詞現(xiàn)在可能會換行)。TextView 需要計(jì)算新的大小所以 onMeasure 和 onLayout 均被調(diào)用。

左圖:ForegroundColorSpan——影響外觀的 span;右圖:RelativeSizeSpan——影響尺寸的 span
影響字符和影響段落的 span
一個(gè) span 對文本產(chǎn)生的影響既可以在字符級別,更新元素,如背景顏色、樣式或大小,也可以在段落級別,更改整個(gè)文本塊的對齊或者邊距。根據(jù)所需的樣式,span 既可以繼承自CharacterStyle,也可以引入 ParagraphStyle。 繼承自 ParagraphStyle 的 span 必須從第一個(gè)字符附加到單個(gè)段落的最后一個(gè)字符,否則 span 不會被顯示。在 Android 中,段落是基于換行符 (\n) 定義的。

在 Android 中,段落是基于換行符 (\n) 定義的。

影響段落的 span
舉個(gè)例子,像 BackgroundColorSpan 這樣的 CharacterStyle,可以被附加到文本中的任何字符上。這里,我們把它附加到第五到第八個(gè)字符上。
val spannable = SpannableString(“Text is\nspantastic”)
spannable.setSpan(
BackgroundColorSpan(color),
5, 8,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
ParagraphStyle 的 span,像 QuoteSpan,只能夠被附加到段落的開始,否則行和文本之間的邊距就不會出現(xiàn)。例如,“Text is\nspantastic” 在文本的第8個(gè)字符包含了一個(gè)換行符,所以我們可以給它附加一個(gè) QuoteSpan,段落從那里開始就會被添加樣式。如果我們在0或8之外的任何位置附加 span,text 就不會被添加樣式。
spannable.setSpan(
QuoteSpan(color),
8, text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

左圖:BackgroundColorSpan -- 影響字符的 span。右圖:QuoteSpan -- 影響段落的 span
創(chuàng)建自定義 span
在實(shí)現(xiàn)你自己的 span 時(shí),你需要確定你的 span 是否會影響字符或段落級別的文本,以及它是否也會影響文本的布局或外觀。但是,在從頭開始編寫自己的實(shí)現(xiàn)之前,檢查一下是否可以使用 framework 中提供的 span。
太長不看:
- 在 字符級別 影響文本 ->
CharacterStyle - 在 段落級別 影響文本 ->
ParagraphStyle - 影響 文本外觀 ->
UpdateAppearance - 影響 文本尺寸 ->
UpdateLayout
假設(shè)我們需要實(shí)現(xiàn)一個(gè) span,它允許以一定的比例增加文本的大小,比如 RelativeSizeSpan,并設(shè)置文本的顏色,比如 ForegroundColorSpan。為此,我們可以擴(kuò)展 RelativeSizeSpan,并且 RelativeSizeSpan 提供了 updateDrawState 和 updateMeasureState 回調(diào),我們可以復(fù)寫繪制狀態(tài)回調(diào)并設(shè)置 TextPaint 的顏色。
class RelativeSizeColorSpan(
@ColorInt private val color: Int,
size: Float
) : RelativeSizeSpan(size) {
override fun updateDrawState(textPaint: TextPaint?) {
super.updateDrawState(ds)
textPaint?.color = color
}
}
注意:同樣的效果可以通過在同一文本上同時(shí)應(yīng)用 RelativeSizeSpan 和 ForegroundColorSpan 實(shí)現(xiàn)。
測試自定義 span 的實(shí)現(xiàn)
測試 span 意味著檢查確實(shí)已對 TextPaint 進(jìn)行了預(yù)期的修改,或者是否已經(jīng)將正確的元素繪制到了 canvas 上。例如,假設(shè)一個(gè) span 的自定義實(shí)現(xiàn)為段落添加制定大小和顏色的項(xiàng)目符號點(diǎn),以及左邊距和項(xiàng)目符號點(diǎn)之間的間隙。在 android-text sample 查看具體實(shí)現(xiàn)。為了測試這個(gè)類,實(shí)現(xiàn)一個(gè) AndroidJUnit 類,確實(shí)檢查:
- 一個(gè)特定大小的圓被繪制在了 canvas 上
- 如果沒有被附加到文本上,則沒有任何繪制
- 基于構(gòu)造函數(shù)的參數(shù),設(shè)置了正確的邊距
測試 Canvas 的交互可以通過 mock canvas,給 drawLeadingMargin 方法傳 mock 過的引用并使用正確的參數(shù)驗(yàn)證是否已調(diào)用正確的方法來實(shí)現(xiàn)。
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
// 給定一個(gè)已經(jīng)設(shè)置到文本上的 span
val span = BulletPointSpan(GAP_WIDTH, color)
text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
// 當(dāng)前面的邊距已經(jīng)被繪制
span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom,
text, 0, 0, true, mock(Layout::class.java))
// 檢查確定的 canvas 和 paint 方法在確定的順序下被調(diào)用
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)
// 一個(gè)確定大小的圓在確定位置被繪制
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 查看其余的測試。
測試 span 的用法
Spanned 接口允許給文本設(shè)置 span 和從文本獲取 span 。通過實(shí)現(xiàn)一個(gè) Android JUnit 測試,檢查是否在正確的位置添加了正確的 span。在 android-text sample 我們把項(xiàng)目符號點(diǎn)標(biāo)記標(biāo)簽轉(zhuǎn)換為了項(xiàng)目符號點(diǎn)。這是通過給文本在正確的位置附加 BulletPointSpans。 下面展示了它是如何被測試的:
@Test fun textWithBulletPoints() {
val result = builder.markdownToSpans(“Points\n* one\n+ two”)
// 檢查標(biāo)記標(biāo)簽被移除
assertEquals(“Points\none\ntwo”, result.toString())
// 獲取所有附加到 SpannedString 上的 span
val spans = result.getSpans<Any>(0, result.length, Any::class.java)assertEquals(2, spans.size.toLong())
// 檢查 span 確實(shí)是 BulletPointSpan
val bulletSpan = spans[0] as BulletPointSpan
// 檢查開始和結(jié)束索引正是期望值
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 獲取更多測試示例。
注意,如果你需要在測試之外遍歷 span,使用
Spanned#nextSpanTransition而不是Spanned#getSpans,因?yàn)樗阅芨谩?/p>
span 是一個(gè)非常強(qiáng)大的概念,它深深的嵌入在文本渲染功能中。它們可以訪問 TextPaint 和 Canvas 等組件,這些組件允許在 Android 上使用高度可自定義的文本樣式。在 Android P 中,我們?yōu)?framework span 添加了大量文檔,所以,在實(shí)現(xiàn)你自己的 span 之前,查看那些能夠獲取到的內(nèi)容。
在以后的文章中,我們將向你詳細(xì)介紹 span 在底層是如何工作的以及怎樣高效地使用它們。例如,你需要使用 textView.setText(CharSequence, BufferType) 或者 Spannable.Factory。 有關(guān)原因的詳細(xì)信息,請保持關(guān)注。
非常感謝 Siyamed Sinir,Clara Bayarri 和 Nick Butcher.
本文原作者 Florina Muntenescu,Android Developer Advocate @Google . 原文地址:https://medium.com/google-developers/spantastic-text-styling-with-spans-17b0c16b4568. 本文由 TonnyL 翻譯,發(fā)表在: https://tonnyl.github.io/