Android实现搜索关键词高亮显示-Kotlin

在做Wandroid项目时有一个搜索功能,要在搜索结果中将匹配到的关键词高亮显示。但是 玩安卓API并没有提供颜色的高亮,只有字体斜体,效果看起来并不明显,并且昵称也参与了搜索,但并没有增加HTML标签返回,这就有点美中不足了。因此我们自己动手来做一个。

API返回结果

{
    ...
    "title": "微信在Github开源了Hard<em class='highlight'>coder</em>,对Android开发者有什么影响?",
    ...
}

Wandroid项目源码地址

预期效果

实现步骤

因为是支持多关键词搜索(空格分割),所以需要将关键词根据空格分割成一个至多个关键词,只要实现了单关键词的高亮,那么多关键词的高亮自然就不是问题了。

先来看看单关键词的实现步骤:

  • 通过indexOf定位搜索关键词的索引,存在则返回关键词首个字符在整个字符串中的位置,不存在则返回-1
  • 找出索引后截取出包含的关键词以及关键词之前的词+关键词拼接的字符串
  • 关键词以及关键词之前的词+关键词拼接的字符串保存在MutableList<Pair<String, String>>
  • 遍历集合,将匹配的词使用带有HTML标签的词替换
  • HtmlCompat.fromHtmlTextView可识别HTML标签

看上面的实现步骤可能还是会有点懵,没关系,下面来一个一个的梳理清楚。先来看看代码

尽管是单关键词,搜索结果中还是有可能会有包含多个关键词的,比如Wandroid项目采用Kotlin语言编写,kotlin语言真好用(此处的两个kotlin的首字母大小写不一致,是为了验证后面要忽略大小写)就包含2个Kotlin语言

拿上面的字符串Wandroid项目采用Kotlin语言编写,kotlin语言真好用来举例,搜索单关键词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中包含的关键词,字符串一致,大小写不一定一致>
    val textArr: MutableList<Pair<String, String>> = mutableListOf()

    var searchIndex = 0 // 标记已经匹配过的位置,while循环中的indexOf需要从searchIndex开始,表示不重复匹配已经匹配过的字符串
    // 因为有2处可匹配到关键词,所以这里用while循环遍历
    while(searchIndex < str.length) { // 匹配到最后一个字符就跳出循环
        val index = str.indexOf(key, searchIndex, true) // true表示忽略大小写
        if(index != -1) {
            // 匹配到了关键词
            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) {
                // 没匹配到关键词就把str和搜索关键词添加到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第一次循环中:

val str = "Wandroid项目采用Kotlin语言编写,kotlin语言真好用" // length = 34
val key = "Kotlin语言"
val index = str.indexOf(key, 0, true) // index = 12

得到关键词keystr的第12个位置开始,index不为-1,说明匹配到了关键词,则进入以下代码

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第二次循环中:

val str = "Wandroid项目采用Kotlin语言编写,kotlin语言真好用"
val key = "Kotlin语言"
val index = str.indexOf(key, 20, true) // index = 23

这次定位索引是从字符串str的第二十的位置开始,得出index为23,则进入以下代码

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的范围,所以还会进入第三次循环

while第三次循环中:

val str = "Wandroid项目采用Kotlin语言编写,kotlin语言真好用"
val key = "Kotlin语言"
val index = str.indexOf(key, 31, true) // index = -1

因为str从第31位开始就只有真好用这三个字了,所以是匹配不到关键词了,index返回-1,进入了else的代码块中

if(searchIndex != str.length) { // 31 != 34 = true
    // 没匹配到关键词就把str和搜索关键词添加到textArr集合中
    textArr.add(str.subString(searchIndex) to key) // textArr.add("真好用" to "Kotlin语言")
}

执行完后到break处跳出循环。

到目前为止,textArr的值为:

[("Wandroid项目采用Kotlin语言", "Kotlin语言"), ("编写,kotlin语言", "kotlin语言"), ("真好用", "Kotlin语言")]

从上面textArr的结果中可以看出分词规则是这样的:

  • searchIndexindex + key.length截取的字符串作为Pair.first,一个分词片段

  • indexindex + key.length截取的字符串作为Pair.second,保存这个是为了要还原原字符串的大小写,避免原字符串中是大写,而搜索关键词是小写,造成最后replace的时候把原字符串中的大写替换成了小写。

在这里插入图片描述

既然得出了textArr,接下来就要遍历textArr,然后依次给匹配的关键词增加HTML标签

val builder = StringBuilder()

textArr.forEach {
    builder.append(
        // it是textArr的每一个元素,类型是Pair<String, String>
        // 判断每一个分词片段是否包含关键词
        if (!it.first.contains(it.second, true)) {
            // 不包含关键词,直接将分词片段返回拼接
            it.first
        } else
            // 包含关键词,将分词片段中的关键词用增加了标签的字符串替换,保持了原有字符串中的大小写
            it.first.replace(
                it.second,
                fontStart + emStart + it.second + emEnd + fontEnd
        )
    )
}

想要斜体+红色字体效果,我们就要像以下格式在关键词前后添加标签

<font color='red'><em class='highlight'>这是高亮文字</em></font>

再来捋一遍,textArr中有三个元素,遍历会执行三次forEach代码块中的代码

第一次

it = ("Wandroid项目采用Kotlin语言", "Kotlin语言")it.first = "Wandroid项目采用Kotlin语言"中包含it.second = "Kotlin语言",会执行以下代码

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语言",会执行以下代码

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)

就可以显示出高亮效果了。

多关键词高亮

到目前为止,显示高亮效果的还只是单关键词,那么如何实现多关键词高亮效果呢?单关键词的思路已经有了,其实多关键词高亮只需要将多关键词分割成一个个的单关键词就好了。

假设多关键词用空格分开,我们就需要用key.split(" ")将一个关键词字符串分割成多个关键词,避免一些不规范的输入,如前后有空格或者中间有不止一个空格,那就要消除这些不规范。

// 替换所有空格为一个空格
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()) // 全部转成小写,忽略大小写
    .replaceAllEmptyToOne() // 将多个连续的空格替换成一个
    .splt(" ") // 分割关键词
    .toSet() // 去除重复的关键词

通过一系列的操作得到的keys是一个比较规范的关键词组,通过遍历依次对每个单关键词进行高亮操作即可。

// CharSequenceExt.kt

// 多关键词高亮
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) // 对每个单关键词添加HTML标签
    }
    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

到此,已经全部完成了,下面附上完整代码

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>"

// 多关键词高亮
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)
}


// 搜索到的标题文本标红处理,返回的文本带有<em>标签
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(" ")
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259