1. 简介
最近在开发新功能时,需要在用户在文字输入框中输入文字时,高亮显示用户名和 URL 等信息。这篇文章介绍总结下在 Jetpack Compose 中如何使用 TextField 的视觉转换实现在文本框中对特定内容高亮显示和格式化显示。
我们使用 TextField
可组合项来支持文本输入。它包含一个参数:visualTransformation
(视觉转换/视觉过滤)用于更改输入文本的视觉效果。
这个参数需要一个实现了 VisualTransformation
接口的对象,这个接口可用于更改输入字段中文本的视觉输出。例如,使用 Compose 自带的 androidx.compose.ui.text.input.PasswordVisualTransformation
用于密码输入的视觉过滤器,可以将输入内容转换为星号字符输出显示到 TextField
。
1.1 VisualTransformation
- 视觉转换
此接口包含一个过滤器函数,获取 TextField
的内容并返回 TransformedText
包含修改后的文本。
@Composable
fun HighlightText() {
var text by remember { mutableStateOf("ITmob.cn") }
TextField(
value = text,
onValueChange = { text = it },
visualTransformation = MyTransformation()
)
}
class MyTransformation() : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
TODO("Not yet implemented")
}
}
所以如果我们希望修改文本的视觉输出,首先创建一个 VisualTransformation
接口的实现,定义这个类并实现过滤器功能,返回一个 TransformedText
实例。
1.2 TransformedText
- 转换文本
跳转到 TransformedText
的源代码,可以看到该类需要两个属性:text
,offsetMapping
/**
* TransformedText 源码:
* 具有偏移映射的转换文本
*/
class TransformedText(
/**
* 转换后的文字
*/
val text: AnnotatedString,
/**
* 用于从原始文本到转换文本的双向偏移映射。
*/
val offsetMapping: OffsetMapping
) {
...
}
-
text
这是提供给过滤函数的文本的修改后的版本
-
offsetMapping
用于从原始文本到转换文本的双向偏移映射
1.3 OffsetMapping
偏移映射
提供原始文本和转换文本之间光标的双向偏移映射。如果视觉转换不会改变转换前后的字符数量,可以使用 OffsetMapping
在伴生对象中预定义的实例:OffsetMapping.Identity
如果需要在视觉转换中改变字符数量,需要定义一个类实现 OffsetMapping
提供转换前后文本间光标的映射关系。
如 OffsetMapping
接口源码所示,实现一个自定义的偏移映射需要实现两个方法:
originalToTransformed
将原始文本中光标的偏移量转为转换后文本中光标的偏移量transformedToOriginal
将转换后文本中光标的偏移量转为原始文本中的偏移量
OffsetMapping
中的这两个转换函数必须是单调/单向非递减函数。也就是说,如果光标在原始文本中前进,则转换文本中的光标必须前进或停留在那里。
关于 OffsetMapping 的用法见下文介绍:如何格式化显示文本
1.4 AnnotatedString
这里也简单再介绍下 AnnotatedString
,它是一种具有多种样式的文本的基本数据结构。类似于使用传统的 UI 控件 TextView
时实现文本样式时使用的 Span。
关于 AnnotatedString 的详细介绍可以参见其他文章,这里只简单介绍文档中推荐的使用构造器生成 AnnotatedString 实例的方法:
@Composable
fun TextWithAnnotatedString() {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold)) {
append("Hello ")
}
withStyle(SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) {
append("https://itmob.cn")
}
}
)
}
2. 密码输入框的实现
我们先来看看 Jetpack Compose 中的 PasswordVisualTransformation
是怎样实现的。
如下是 PasswordVisualTransformation
的源码:
/**
* 可用于密码输入文本框的视觉过滤器。 请注意,此视觉过滤器仅适用于 ASCII 字符。
*
* @param mask 代替原始文本使用的掩码字符
*/
class PasswordVisualTransformation(val mask: Char = '\u2022') : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
AnnotatedString(mask.toString().repeat(text.text.length)),
OffsetMapping.Identity
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PasswordVisualTransformation) return false
if (mask != other.mask) return false
return true
}
override fun hashCode(): Int {
return mask.hashCode()
}
}
可以看出:
PasswordVisualTransformation
是VisualTransformation
接口的实现,并实现了一个过滤器功能,返回一个TransformedText
实例,使用参数传入的字符作为掩码代替原始文本来显示。OffsetMapping.Identity
是OffsetMapping
接口的预定义实例,用于不更改字符数量的文本转换。
如下使用 Jetpack Compose 的 PasswordVisualTransformation
实现的密码输入框的例子:
@Composable
fun PasswordTextField() {
var text by remember { mutableStateOf("https://itmob.cn") }
var pswVisibility by remember { mutableStateOf(false) }
TextField(
value = text,
onValueChange = { text = it },
label = { Text(text = "密码") },
visualTransformation = if (pswVisibility) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconToggleButton(
checked = pswVisibility,
onCheckedChange = {
pswVisibility = !pswVisibility
}
) {
Icon(
imageVector = if (pswVisibility) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
}
)
}
3. 高亮显示 TextField 中的链接/URL
上面介绍了密码输入框的实现,这里再介绍下怎样实现输入框中输入文本时高亮显示 URL 并添加下划线。
- 实现文本高亮显示,添加下划线等操作会改变
TextField
中文字的视觉输出,所以也可以通过VisualTransformation
实现。 - 这个过程中不会增加或减少字符,所以视觉转换后返回的
TransformedText
不需要处理光标在转换前后的偏移问题,直接使用OffsetMapping.Identity
即可。 - 匹配文本中的 URL,使用
androidx.core.util.PatternsCompat
来实现的
如下是实现代码:
@Composable
fun UrlHighlightTextField() {
var text by remember { mutableStateOf("Hello itmob.cn") }
TextField(
value = text,
onValueChange = { text = it },
visualTransformation = UrlVisualTransformation(),
)
}
// 视觉过滤器
class UrlVisualTransformation() : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = buildAnnotatedString {
append(text)
val matcher = PatternsCompat.WEB_URL.matcher(text)
while (matcher.find()) {
addStyle(
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline,
),
start = matcher.start(),
end = matcher.end(),
)
}
},
offsetMapping = OffsetMapping.Identity,
)
}
}
4. 在 TextField 中格式化显示银行卡号码/信用卡号码(每隔4位插入连字符)
这里介绍怎样在 TextField 中实现银行卡/信用卡号码的视觉输出(每4位插入连字符),如:输入文本是:1234567890123456,输入时转换为:1234-5678-9012-3456
- 这个视觉转换过程中需要增加/删除字符,所以需要通过 OffsetMapping 处理视觉转换前后光标的偏移,返回正确的偏移映射关系。
这个实例在官方文档中也有提及:https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/VisualTransformation
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldSamples.kt;drc=ce27bd3be57a6a426ac048d6e57b7aaf1fcff2d6;l=125
如下时实现代码和效果:
@Composable
fun CreditCardTextField() {
var cardNum by remember { mutableStateOf("") }
TextField(
value = cardNum,
onValueChange = {input ->
if (input.length <= 16 && input.none { !it.isDigit() }) {
cardNum = input
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = CreditCardVisualTransformation(),
)
}
class CreditCardVisualTransformation() : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = buildAnnotatedString {
val digits = text.filter { it.isDigit() }
val trimmed = if (digits.length >= 16) digits.substring(0..15) else digits
var out = ""
// 遍历输入
for (i in trimmed.indices) {
out += trimmed[i]
if (i % 4 == 3 && i != 15) out += "-"
}
append(out)
},
offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset <= 3) return offset
if (offset <= 7) return offset + 1
if (offset <= 11) return offset + 2
if (offset <= 16) return offset + 3
return 19
}
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 4) return offset
if (offset <= 9) return offset - 1
if (offset <= 14) return offset - 2
if (offset <= 19) return offset - 3
return 16
}
},
)
}
}