在做Wandroid項目時有一個搜索功能,要在搜索結(jié)果中將匹配到的關(guān)鍵詞高亮顯示。但是 玩安卓API并沒有提供顏色的高亮,只有字體斜體,效果看起來并不明顯,并且昵稱也參與了搜索,但并沒有增加HTML標(biāo)簽返回,這就有點美中不足了。因此我們自己動手來做一個。
API返回結(jié)果
{
...
"title": "微信在Github開源了Hard<em class='highlight'>coder</em>,對Android開發(fā)者有什么影響?",
...
}
預(yù)期效果

實現(xiàn)步驟
因為是支持多關(guān)鍵詞搜索(空格分割),所以需要將關(guān)鍵詞根據(jù)空格分割成一個至多個關(guān)鍵詞,只要實現(xiàn)了單關(guān)鍵詞的高亮,那么多關(guān)鍵詞的高亮自然就不是問題了。
先來看看單關(guān)鍵詞的實現(xiàn)步驟:
- 通過
indexOf定位搜索關(guān)鍵詞的索引,存在則返回關(guān)鍵詞首個字符在整個字符串中的位置,不存在則返回-1 - 找出索引后截取出包含的
關(guān)鍵詞以及關(guān)鍵詞之前的詞+關(guān)鍵詞拼接的字符串 - 將
關(guān)鍵詞以及關(guān)鍵詞之前的詞+關(guān)鍵詞拼接的字符串保存在MutableList<Pair<String, String>>中 - 遍歷集合,將匹配的詞使用帶有
HTML標(biāo)簽的詞替換 -
HtmlCompat.fromHtml讓TextView可識別HTML標(biāo)簽
看上面的實現(xiàn)步驟可能還是會有點懵,沒關(guān)系,下面來一個一個的梳理清楚。先來看看代碼
盡管是單關(guān)鍵詞,搜索結(jié)果中還是有可能會有包含多個關(guān)鍵詞的,比如Wandroid項目采用Kotlin語言編寫,kotlin語言真好用(此處的兩個kotlin的首字母大小寫不一致,是為了驗證后面要忽略大小寫)就包含2個Kotlin語言。
拿上面的字符串Wandroid項目采用Kotlin語言編寫,kotlin語言真好用來舉例,搜索單關(guān)鍵詞Kotlin語言,使其高亮顯示。
// CharSequenceExt.kt
const val emStart = "<em class='highlight'>" // 斜體
const val emEnd = "</em>"
const val fontStart = "<font color=\"red\">" // 字體紅色
const val fontEnd = "</font>"
fun CharSequence?.appendHtmlTags(key: String): CharSequence {
val str = "Wandroid項目采用Kotlin語言編寫,kotlin語言真好用"
val key = "Kotlin語言" // 需要將后面的"kotlin語言真好用"中的kotlin語言也匹配出來
// Pair<str的子字符串, str中包含的關(guān)鍵詞,字符串一致,大小寫不一定一致>
val textArr: MutableList<Pair<String, String>> = mutableListOf()
var searchIndex = 0 // 標(biāo)記已經(jīng)匹配過的位置,while循環(huán)中的indexOf需要從searchIndex開始,表示不重復(fù)匹配已經(jīng)匹配過的字符串
// 因為有2處可匹配到關(guān)鍵詞,所以這里用while循環(huán)遍歷
while(searchIndex < str.length) { // 匹配到最后一個字符就跳出循環(huán)
val index = str.indexOf(key, searchIndex, true) // true表示忽略大小寫
if(index != -1) {
// 匹配到了關(guān)鍵詞
val keyword = str.substring(index, index + key.length) // 和key一樣,只是大小寫不一定一樣
val text = str.substring(searchIndex, index + key.length)
searchIndex = index + key.length
textArr.add(text to keyword)
} else {
if(searchIndex != str.length) {
// 沒匹配到關(guān)鍵詞就把str和搜索關(guān)鍵詞添加到textArr集合中
textArr.add(str.subString(searchIndex) to key)
}
}
}
val builder = StringBuilder()
textArr.forEach {
builder.append(
if (!it.first.contains(it.second, true)) {
it.first
} else
it.first.replace(
it.second,
fontStart + emStart + it.second + emEnd + fontEnd
)
)
}
return builder.toString()
}
在while第一次循環(huán)中:
val str = "Wandroid項目采用Kotlin語言編寫,kotlin語言真好用" // length = 34
val key = "Kotlin語言"
val index = str.indexOf(key, 0, true) // index = 12
得到關(guān)鍵詞key在str的第12個位置開始,index不為-1,說明匹配到了關(guān)鍵詞,則進(jìn)入以下代碼
val keyword = str.substring(index, index + key.length) // str.substring(12, 12 + 8)
val text = str.substring(searchIndex, index + key.length) // str.substring(0, 12 + 8)
searchIndex = index + key.length // 12 + 8
textArr.add(text to keyword)
以上代碼得出:
keyword = Kotlin語言
text = Wandroid項目采用Kotlin語言
searchIndex = 20
textArr = [(Wandroid項目采用Kotlin語言, Kotlin語言)]
在while第二次循環(huán)中:
val str = "Wandroid項目采用Kotlin語言編寫,kotlin語言真好用"
val key = "Kotlin語言"
val index = str.indexOf(key, 20, true) // index = 23
這次定位索引是從字符串str的第二十的位置開始,得出index為23,則進(jìn)入以下代碼
val keyword = str.substring(index, index + key.length) // str.substring(23, 23 + 8)
val text = str.substring(searchIndex, index + key.length) // str.substring(20, 23 + 8)
searchIndex = index + key.length // 23 + 8
textArr.add(text to keyword)
以上代碼得出:
keyword = kotlin語言 // 小寫k
text = 編寫,kotlin語言
searchIndex = 31
textArr = [(Wandroid項目采用Kotlin語言, Kotlin語言), (編寫,kotlin語言, kotlin語言)]
目前searchIndex = 31,還沒有超出str.length = 34的范圍,所以還會進(jìn)入第三次循環(huán)
在while第三次循環(huán)中:
val str = "Wandroid項目采用Kotlin語言編寫,kotlin語言真好用"
val key = "Kotlin語言"
val index = str.indexOf(key, 31, true) // index = -1
因為str從第31位開始就只有真好用這三個字了,所以是匹配不到關(guān)鍵詞了,index返回-1,進(jìn)入了else的代碼塊中
if(searchIndex != str.length) { // 31 != 34 = true
// 沒匹配到關(guān)鍵詞就把str和搜索關(guān)鍵詞添加到textArr集合中
textArr.add(str.subString(searchIndex) to key) // textArr.add("真好用" to "Kotlin語言")
}
執(zhí)行完后到break處跳出循環(huán)。
到目前為止,textArr的值為:
[("Wandroid項目采用Kotlin語言", "Kotlin語言"), ("編寫,kotlin語言", "kotlin語言"), ("真好用", "Kotlin語言")]
從上面textArr的結(jié)果中可以看出分詞規(guī)則是這樣的:
從
searchIndex到index + key.length截取的字符串作為Pair.first,一個分詞片段從
index到index + key.length截取的字符串作為Pair.second,保存這個是為了要還原原字符串的大小寫,避免原字符串中是大寫,而搜索關(guān)鍵詞是小寫,造成最后replace的時候把原字符串中的大寫替換成了小寫。
既然得出了textArr,接下來就要遍歷textArr,然后依次給匹配的關(guān)鍵詞增加HTML標(biāo)簽
val builder = StringBuilder()
textArr.forEach {
builder.append(
// it是textArr的每一個元素,類型是Pair<String, String>
// 判斷每一個分詞片段是否包含關(guān)鍵詞
if (!it.first.contains(it.second, true)) {
// 不包含關(guān)鍵詞,直接將分詞片段返回拼接
it.first
} else
// 包含關(guān)鍵詞,將分詞片段中的關(guān)鍵詞用增加了標(biāo)簽的字符串替換,保持了原有字符串中的大小寫
it.first.replace(
it.second,
fontStart + emStart + it.second + emEnd + fontEnd
)
)
}
想要斜體+紅色字體效果,我們就要像以下格式在關(guān)鍵詞前后添加標(biāo)簽
<font color='red'><em class='highlight'>這是高亮文字</em></font>
再來捋一遍,textArr中有三個元素,遍歷會執(zhí)行三次forEach代碼塊中的代碼
第一次
it = ("Wandroid項目采用Kotlin語言", "Kotlin語言"),it.first = "Wandroid項目采用Kotlin語言"中包含it.second = "Kotlin語言",會執(zhí)行以下代碼
it.first.replace(it.second,fontStart + emStart + it.second + emEnd + fontEnd)
即,將Wandroid項目采用Kotlin語言替換成Wandroid項目采用<font color='red'><em class='highlight'>Kotlin語言</em></font>

此時的builder為Wandroid項目采用<font color='red'><em class='highlight'>Kotlin語言</em></font>;
第二次
it = ("編寫,kotlin語言", "kotlin語言"),it.first = "編寫,kotlin語言"中包含it.second = "kotlin語言",會執(zhí)行以下代碼
it.first.replace(it.second,fontStart + emStart + it.second + emEnd + fontEnd)
即,將編寫,kotlin語言替換成編寫,<font color='red'><em class='highlight'>kotlin語言</em></font>
與第一次得到的builder拼接,此時的builder為Wandroid項目采用<font color='red'><em class='highlight'>Kotlin語言</em></font>編寫,<font color='red'><em class='highlight'>kotlin語言</em></font>;
第三次
it = ("真好用", "Kotlin語言"),it.first = "真好用"中不包含it.second = "Kotlin語言",會直接將it.first返回與builder拼接
即,將真好用拼接在builder后面。
此時的builder為:
Wandroid項目采用<font color='red'><em class='highlight'>Kotlin語言</em></font>編寫,<font color='red'><em class='highlight'>kotlin語言</em></font>真好用
最后
textView.text = HtmlCompat.fromHtml(builder.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
就可以顯示出高亮效果了。
多關(guān)鍵詞高亮
到目前為止,顯示高亮效果的還只是單關(guān)鍵詞,那么如何實現(xiàn)多關(guān)鍵詞高亮效果呢?單關(guān)鍵詞的思路已經(jīng)有了,其實多關(guān)鍵詞高亮只需要將多關(guān)鍵詞分割成一個個的單關(guān)鍵詞就好了。
假設(shè)多關(guān)鍵詞用空格分開,我們就需要用key.split(" ")將一個關(guān)鍵詞字符串分割成多個關(guān)鍵詞,避免一些不規(guī)范的輸入,如前后有空格或者中間有不止一個空格,那就要消除這些不規(guī)范。
// 替換所有空格為一個空格
fun String?.replaceAllEmptyToOne(): String {
if (this.isNullOrEmpty()) return ""
val pattern = Pattern.compile("\\s+")
val matcher = pattern.matcher(this)
return matcher.replaceAll(" ")
}
val keys = key.trim() // 去除首尾空格
.toLowerCase(Locale.getDefault()) // 全部轉(zhuǎn)成小寫,忽略大小寫
.replaceAllEmptyToOne() // 將多個連續(xù)的空格替換成一個
.splt(" ") // 分割關(guān)鍵詞
.toSet() // 去除重復(fù)的關(guān)鍵詞
通過一系列的操作得到的keys是一個比較規(guī)范的關(guān)鍵詞組,通過遍歷依次對每個單關(guān)鍵詞進(jìn)行高亮操作即可。
// CharSequenceExt.kt
// 多關(guān)鍵詞高亮
fun CharSequence?.makeTextHighlightForMultiKeys(key: String): CharSequence {
if (this.isNullOrEmpty()) return ""
val keys = key.trim().toLowerCase(Locale.getDefault()).replaceAllEmptyToOne().split(" ").toSet()
var result: CharSequence = this
keys.forEach {
result = result.appendHtmlTags(it) // 對每個單關(guān)鍵詞添加HTML標(biāo)簽
}
return HtmlCompat.fromHtml(result.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
}
// MainActivity.kt
val text = "Wandroid項目采用Kotlin語言編寫,kotlin語言真好用"
val result = text.makeTextHighlightForMultiKeys("Wandroid Kotlin")
tvText.text = result

到此,已經(jīng)全部完成了,下面附上完整代碼
import androidx.core.text.HtmlCompat
import java.util.*
import java.util.regex.Pattern
const val emStart = "<em class='highlight'>"
const val emEnd = "</em>"
const val fontStart = "<font color='red'>"
const val fontEnd = "</font>"
// 多關(guān)鍵詞高亮
fun CharSequence?.makeTextHighlightForMultiKeys(key: String): CharSequence {
if (this.isNullOrEmpty()) return ""
val keys = key.trim().toLowerCase(Locale.getDefault()).replaceAllEmptyToOne().split(" ").toSet()
var result: CharSequence = this
keys.forEach {
result = result.appendHtmlTags(it)
}
return HtmlCompat.fromHtml(result.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
}
// 搜索到的標(biāo)題文本標(biāo)紅處理,返回的文本帶有<em>標(biāo)簽
fun String?.toSearchTitleColorString(): CharSequence {
return if (this.isNullOrEmpty()) "" else HtmlCompat.fromHtml(
if (this.contains(emStart)) {
this.replace(emStart, fontStart + emStart)
.replace(emEnd, emEnd + fontEnd)
} else {
this
},
HtmlCompat.FROM_HTML_MODE_LEGACY
)
}
fun CharSequence?.appendHtmlTags(key: String): CharSequence {
if (this.isNullOrEmpty()) return ""
if (!this.contains(key, true)) return this
// 解析出整個字符串中所有包含key的位置
val textArr: MutableList<Pair<String, String>> = mutableListOf()
var searchIndex = 0
while (searchIndex < this.length) {
val index = this.indexOf(key, searchIndex, true)
if (index != -1) {
// 能匹到
val keyword = this.substring(index, index + key.length) // 和key一樣,只是大小寫不一定一樣
val text = this.substring(searchIndex, index + key.length)
searchIndex = index + key.length
textArr.add(text to keyword)
} else {
if (searchIndex != length) {
// 還有字符串
textArr.add(substring(searchIndex) to key)
}
break
}
}
val builder = StringBuilder()
textArr.forEach {
builder.append(
if (!it.first.contains(it.second, true)) {
it.first
} else
it.first.replace(
it.second,
fontStart + emStart + it.second + emEnd + fontEnd
)
)
}
return builder.toString()
}
// 替換所有空格為一個空格
fun String?.replaceAllEmptyToOne(): String {
if (this.isNullOrEmpty()) return ""
val pattern = Pattern.compile("\\s+")
val matcher = pattern.matcher(this)
return matcher.replaceAll(" ")
}
it.first.replace(
it.second,
fontStart + emStart + it.second + emEnd + fontEnd
)
)
}
return builder.toString()
}
// 替換所有空格為一個空格
fun String?.replaceAllEmptyToOne(): String {
if (this.isNullOrEmpty()) return ""
val pattern = Pattern.compile("\\s+")
val matcher = pattern.matcher(this)
return matcher.replaceAll(" ")
}