ITmob-Ly
发布于 2023-03-04 / 266 阅读
0

Jetpack Compose TextField VisualTransformation 视觉转换详解-高亮显示URL-格式化显示银行卡号码

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 的源代码,可以看到该类需要两个属性:textoffsetMapping

/**
 * TransformedText 源码:
 * 具有偏移映射的转换文本
 */
class TransformedText(
    /**
     * 转换后的文字
     */
    val text: AnnotatedString,

    /**
     * 用于从原始文本到转换文本的双向偏移映射。
     */
    val offsetMapping: OffsetMapping
) {
...
}
  • text

    这是提供给过滤函数的文本的修改后的版本

  • offsetMapping

    用于从原始文本到转换文本的双向偏移映射

1.3 OffsetMapping 偏移映射

提供原始文本和转换文本之间光标的双向偏移映射。如果视觉转换不会改变转换前后的字符数量,可以使用 OffsetMapping 在伴生对象中预定义的实例:OffsetMapping.Identity

如果需要在视觉转换中改变字符数量,需要定义一个类实现 OffsetMapping 提供转换前后文本间光标的映射关系。

OffsetMapping 接口源码所示,实现一个自定义的偏移映射需要实现两个方法:

  1. originalToTransformed 将原始文本中光标的偏移量转为转换后文本中光标的偏移量
  2. 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")
            }
        }
    )
}

JetpackCompose AnnotatedString

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()
    }
}

可以看出:

  1. PasswordVisualTransformationVisualTransformation 接口的实现,并实现了一个过滤器功能,返回一个 TransformedText 实例,使用参数传入的字符作为掩码代替原始文本来显示。
  2. OffsetMapping.IdentityOffsetMapping 接口的预定义实例,用于不更改字符数量的文本转换。

如下使用 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
                )
            }
        }
    )
}

JetpackCompose TextField PasswordVisualTransformation

3. 高亮显示 TextField 中的链接/URL

上面介绍了密码输入框的实现,这里再介绍下怎样实现输入框中输入文本时高亮显示 URL 并添加下划线。

  1. 实现文本高亮显示,添加下划线等操作会改变 TextField 中文字的视觉输出,所以也可以通过 VisualTransformation 实现。
  2. 这个过程中不会增加或减少字符,所以视觉转换后返回的 TransformedText 不需要处理光标在转换前后的偏移问题,直接使用 OffsetMapping.Identity 即可。
  3. 匹配文本中的 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,
        )
    }
}

JetpackCompose TextField HighlightUrlVisualTransformation

4. 在 TextField 中格式化显示银行卡号码/信用卡号码(每隔4位插入连字符)

这里介绍怎样在 TextField 中实现银行卡/信用卡号码的视觉输出(每4位插入连字符),如:输入文本是:1234567890123456,输入时转换为:1234-5678-9012-3456

  1. 这个视觉转换过程中需要增加/删除字符,所以需要通过 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
                }
            },
        )
    }
}

JetpackCompose TextField CreditCardVirtualTransformation