手把手带你用Compose的BasicTextField实现代码高亮编辑器
序言
先上效果图:

最近准备用 Jetpack Compose
写个小东西,内容涉及到类似于代码编辑器的部分,在Android中这类的开源库也有不少(Github上简单搜了一下)。
一开始准备是使用 Compose
套 AndroidView(factory = { })
的,但是想了一下,可以是试试 Compose
的 TextField
家族。
因为在过程中,我发现了 TextField
有一个 TextFieldValue
的参数。

而这个 TextFieldValue
类,就支持 AnnotatedString
,

这个东西就是 Compose
中的 Spanned
,于是......
实现思路
这里用 JavaScript
的代码做高亮逻辑解释。
首先,对于一般的代码高亮逻辑来说 只有被触发到关键字
才做变色(高亮)处理,如下图的两处红框里的 function
、//TODO
:

而要在一个文本文件或者说一段文字内找到这些关键字唯一的方法就是: 正则表达式
。
通过正则表达式将每一个出现的关键字信息都记录下来,其中要包括以下内容:
- 被匹配到的文本内容 (需要高亮的内容)
- 该内容的头下标 (高亮范围的开始)
- 该内容的尾下标 (高亮范围的结束)
如上图的 function
,对应下来就是:
- function
- 0
- 7
于是,自然而然的,我们可以封装一个类,以此来存放每一个应该被高亮内容:
kotlin
data class CodeStyle(
var code: String,
var range: IntRange, //IntRange(first:Int, last:Int)
var color: Color,
)
然后就是循环监听输入的内容了,如下图所示:

我们需要对每一个输入的字符做监听,当它包含或等于某一个关键字时,就对其做相应的高亮处理,于是就有了以下的方法:
kotlin
fun findKeyword(value: String): List<CodeStyle> {
//存放所有的代码高亮样式列表
val codeStyles = mutableListOf<CodeStyle>()
//查找文本内容中包含 function 文本的所有内容, \b在正则表达式中表示单词边界
val findAll = Regex("\bfunction\b").findAll(value)
//循环所有找到的 function 文本, 为其添加高亮颜色并存入列表
for (matchResult in findAll) {
codeStyles.add(
CodeStyle(
code = matchResult.value,
range = matchResult.range,
color = Color.Red, //红色
)
)
}
return codeStyles
}
到了这一步,我们只需要在指定位置调用一下 findKeyword
方法,就可以获取到到所有的高亮关键字和它的首尾坐标以及高亮颜色。
接下来就是对文本内容做 截取和替换
,删除原文中所有匹配到的高亮关键词并在该位置填入高亮后的关键字,来看看下面的代码:
kotlin
fun buildCodeStyle(value: String): AnnotatedString {
val styles = findKeyword(value).sortedBy { it.range.first } //找到所有的高亮关键字,并按照起始位置排序(坑点)
return buildAnnotatedString { //Kotlin的DSL语法特性,可不要太好用
var startIndex = 0
for (style in styles) {
if (style.isEmpty()) continue //为CodeStyle类增加的自定义方法,后文中可查看方法内容
if (style.range.first < startIndex) continue //如果该关键字已经被高亮过, 这里就涉及到上面的排序坑点。
append(value.substring(startIndex, style.range.first)) //截取并添加前置文本
withStyle(SpanStyle(color = style.color)) { append(style.code) } //为该关键字设置颜色并添加
startIndex = style.range.last + 1 //向后继续替换
}
if (startIndex < value.length) {
append(value.substring(startIndex)) //截取并添加后续文本
}
}
}
可以体会以下上面的代码,请记一下这里的排序坑点,后文在多个匹配逻辑时会提到为什么要排序。
到这里,代码高亮的基本逻辑已经成型,而这里只是用了 function
关键字来举例,我们可以多添加几个关键字来看看效果。
修改正则表达式:
kotlin
val findAll = Regex("\b(function|const|let)\b").findAll(value)
这时候来看,效果图:

会发现,正则表达式中出现的关键字都已经被高亮表示,而应该被高亮的 class
因为没有在规则中,所以它被排除在外了。
完整代码
好了,至此思路以及实现逻辑已经完成,我们整理一下代码并实现文章开头动图的效果:
以下是代码的结构图,每一个方法中都有对应的正则表达式逻辑:

具体代码:
kotlin
data class CodeStyle(
var code: String,
var range: IntRange,
var color: Color,
) {
fun isEmpty(): Boolean {
return this == empty
}
companion object {
fun build(value: String): AnnotatedString {
val styles = findCode(value).sortedBy { it.range.first }
return buildAnnotatedString {
var startIndex = 0
for (style in styles) {
if (style.isEmpty()) continue
if (style.range.first < startIndex) continue
append(value.substring(startIndex, style.range.first))
withStyle(SpanStyle(color = style.color)) { append(style.code) }
startIndex = style.range.last + 1
}
if (startIndex < value.length) {
append(value.substring(startIndex))
}
}
}
private fun findCode(value: String): List<CodeStyle> {
val codeStyles = mutableListOf<CodeStyle>()
codeStyles.addAll(findReservedWord(value))
//findFunction() 应该放在 findProperty() 的上方, 因为它们的区别在大多数情况下只是多了一个括号;
//而根据 build 方法的逻辑 startIndex 是累加的, 因此它会跳过已经被高亮的代码
codeStyles.addAll(findFunction(value))
codeStyles.addAll(findProperty(value))
codeStyles.addAll(findStringConstant(value))
codeStyles.addAll(findOtherConstant(value))
codeStyles.addAll(findAnnotation(value))
return codeStyles
}
// 注释
private fun findAnnotation(value: String): List<CodeStyle> {
val regex = Regex("//.*|(?s)/\*.*?\*/")
return regex.findAll(value)
.map { buildCodeStyle(it, Color(0XFF8C8C8C)) }
.toList()
}
// 保留字
private fun findReservedWord(value: String): List<CodeStyle> {
val regex =
Regex("\b(break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|function|if|implements|import|in|instanceof|interface|let|new|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield|window)\b")
return regex.findAll(value)
.map { buildCodeStyle(it, /*Color(0XFF0033B3)*/Color.Red) }
.toList()
}
// 方法(函数)
private fun findFunction(value: String): List<CodeStyle> {
val regex = Regex("(\w+)\s*?\(")
return regex.findAll(value)
.map {
//group[0] == all match
val first = it.groups[1] ?: return@map CodeStyle.empty
CodeStyle(
code = first.value,
range = first.range,
color = Color(0XFF7A7A43)
)
}
.toList()
}
// 属性(字段)
private fun findProperty(value: String): List<CodeStyle> {
val regex = Regex("\.\s*?(\w+)")
return regex.findAll(value)
.map {
//group[0] == all match
val first = it.groups[1] ?: return@map CodeStyle.empty
CodeStyle(
code = first.value,
range = first.range,
color = Color(0XFF871094)
)
}
.toList()
}
// 字符串字面量
private fun findStringConstant(value: String): List<CodeStyle> {
val regex = Regex("('.*?')|(".*?")")
return regex.findAll(value)
.map { buildCodeStyle(it, Color(0XFF067D17)) }
.toList()
}
// 其他字面量
private fun findOtherConstant(value: String): List<CodeStyle> {
val regex = Regex("(?<=\s|\b)\d+(\.\d+)?\b|(?i)(\btrue\b|\bfalse\b|\bnull\b)")
return regex.findAll(value)
.map { buildCodeStyle(it, Color(0XFF005CC5)) }
.toList()
}
private fun buildCodeStyle(result: MatchResult, color: Color): CodeStyle {
return CodeStyle(
code = result.value,
range = result.range,
color = color
)
}
val empty: CodeStyle
get() = CodeStyle("", IntRange.EMPTY, Color.Transparent)
}
}
填坑
前文让记住了一个 排序
的坑点,这里在完整代码贴出来之后可以说明一下:
在上述代码逻辑中,因为不同关键字的高亮颜色不一样,位置也不一样,如果不升序排序并做过滤处理(跳过已经高亮的代码),则会照成下标异常(越界)的出现。
使用
kotlin
// 简单封装一下 CodeEditor
@Composable
private fun CodeEditor(
modifier: Modifier,
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodySmall.copy(lineHeight = 1.3.em),
modifier = modifier
) {
if (value.text.isEmpty()) {
Text(
text = "请在这里书写代码片段",
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.typography.bodySmall.color.copy(0.5f)
)
)
}
it.invoke()
}
}
//调用
var codePartValue by remember { mutableStateOf(TextFieldValue("")) }
CodeEditor(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(12.dp),
value = codePartValue,
onValueChange = {
//只需要在每次文本发生改变时处理一下就行了
codePartValue = it.copy(annotatedString = CodeStyle.build(it.text))
}
)
最后,该逻辑是有优化的余地的,这里表示:能跑就行!
。
再见~