Android實現(xiàn)搜索關(guān)鍵詞高亮顯示-Kotlin

在做Wandroid項目時有一個搜索功能,要在搜索結(jié)果中將匹配到的關(guān)鍵詞高亮顯示。但是 玩安卓API并沒有提供顏色的高亮,只有字體斜體,效果看起來并不明顯,并且昵稱也參與了搜索,但并沒有增加HTML標(biāo)簽返回,這就有點美中不足了。因此我們自己動手來做一個。

API返回結(jié)果

{
    ...
    "title": "微信在Github開源了Hard<em class='highlight'>coder</em>,對Android開發(fā)者有什么影響?",
    ...
}

Wandroid項目源碼地址

預(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.fromHtmlTextView可識別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)鍵詞keystr的第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ī)則是這樣的:

  • searchIndexindex + key.length截取的字符串作為Pair.first,一個分詞片段

  • indexindex + 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>

此時的builderWandroid項目采用<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拼接,此時的builderWandroid項目采用<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(" ")
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容