Compose 官方 API 搞定文本输入格式

在 Android 开发中,处理用户输入往往不仅仅是把用户的输入展现出来那么简单。

例如手机号码格式,传真格式,以及其他用户想要文本展现格式。

这时,开发者就会面临一个挑战:动态地格式化输入内容。挑战在于需要在用户输入时自动添加特定的字符(如连字符、空格或货币符号)。

这种格式化对于提升用户体验、确保数据以易读和标准的格式呈现至关重要。

本文会先通过一个"在用户输入的每个字符后自动插入连字符(-)"的示例,一起探索 Compose 中的 VisualTransformation 接口如何优雅地解决这一问题。

视觉效果示例如下:

图 1:用户输入字符后自动跟随一个连字符"-"

初体验

很多开发的第一反应可能是:使用 TextFiled 直接在 onValueChange 回调中对文本进行格式化,并把改好的字符串存回另一个状态变量 formatedString 中。

如果用户要获取原始字符串,那么就好需要一个状态变量 originString 去存储原始的输入。

虽然听起来较为麻烦,但是这种方法确实可行:如果需要原始的字符串,使用变量 originString,而展示格式化后的字符串,使用变量 formatedString

你可能没有想到,这个方法会带来一个棘手的副作用------光标位置失位

onValueChange 中直接修改文本内容,很容易导致光标在 TextField 中出现不可预测的跳动,从而严重影响用户的输入体验。

同时,如果用户去选择光标移动,那么光标又会停留在一些格式化后字符串的中间位置,这样同样会导致输入问题!

为了解决这个问题,VisualTransformation 闪亮登场!

VisualTransformation 可以彻底剥离视图展现和底层数据,极大地减少光标错位问题,同时,他不需要两个变量来记录原始值和格式化后的值,因为 VisualTransformation 只是影响屏幕上的输出,并不会影响原始值。

初次接触 VisualTransformation 可能会觉得有点抽象,没关系,我们花点时间,理解一下它的需要的几个组件。

我们先来看一下 VisualTransformation 的接口定义:

kotlin 复制代码
@Immutable
fun interface VisualTransformation {
    fun filter(text: AnnotatedString): TransformedText
}

该接口只包含一个 filter 函数,它接收一个 AnnotatedString 类型的 text 参数,并要求返回一个 TransformedText 对象。让我们逐一拆解:

1. text: AnnotatedString

它代表用户实际输入到 TextField 中的原始字符(即真实的数据):

kotlin 复制代码
// TextField 的 onValueChange 回调
onValueChange = { newValue ->
    currentValue = newValue
    // 这里的 newValue 就会原封不动地作为 text 参数,传递给 filter 函数
}

2. TransformedText

这是一个处理 TextField 视觉转换结果的包装类。它由两个核心组件构成,都需要通过构造函数传入:

kotlin 复制代码
class TransformedText(
    /**
     * 转换后的文本(即展现在屏幕上的样子)
     */
    val text: AnnotatedString,

    /**
     * 用于在"原始文本"和"转换后文本"之间进行双向偏移量(光标位置)映射的映射器
     */
    val offsetMapping: OffsetMapping
)
  1. 转换后的 text: AnnotatedString :这是原始文本在 UI 上经过视觉格式化后的呈现形式。比如,将原始状态的 "1234567890" 格式化并显示为 "123--456--8790"
  2. offsetMapping: OffsetMapping :它负责将原始文本的偏移量(索引/光标位置)映射到转换后文本的偏移量上,同时也要支持反向映射。OffsetMapping 维护了真实数据与显示数据之间的桥梁,从而保证光标定位和文本拖拽选中的完全正确。这个属性看起来好像你可以忽略,不过我们继续看,你就知道这个有多重要了。

实战

接下来,我们通过前面的需求例子来把这些概念串起来:在每个输入的字符后自动插入一个连字符 -

当用户在 TextField 中输入字符 a 时,filter 函数的 text 参数就会接收到 "a" 作为输入。

图 2:用户输入字符"a",filter 函数以"a"作为输入参数

filter 函数并不会自动帮你应用任何格式,你需要根据业务需求自行编写逻辑。

kotlin 复制代码
class MyVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        // 这是我们的格式化逻辑,我们在每个字符后添加一个连字符
        // 提示:你可以根据需求在这里编写任意的字符串转换代码
        val transformedText = text.text
            .map { c -> "$c-" }
            .joinToString("")
        // 此时 transformedText 的值为 "a-"
        
        // 我们必须返回一个 TransformedText 对象
        // 传入格式化后的文本 AnnotatedString,以及自定义的光标映射 offsetMapping
        // (关于 MyOffsetMapping 类的具体实现,下面会详细解释)
        return TransformedText(
            text = AnnotatedString(text = transformedText), 
            offsetMapping = MyOffsetMapping()
        )
    }
}

调用 text.text.map 即可转换输出格式

梳理一下此时的文本状态:

  1. 原始文本"a"
  2. 转换后文本"a-"

搞定 OffsetMapping

最关键的一步是将 OffsetMapping 应用于 TransformedText。我们先来看看 OffsetMapping 的接口定义:

kotlin 复制代码
interface OffsetMapping {
    fun originalToTransformed(offset: Int): Int

    fun transformedToOriginal(offset: Int): Int
}

它包含两个强制重写的映射函数:

1. originalToTransformed

该函数负责将原始文本中的偏移量(光标索引)正向转换 为转换后文本中的偏移量。 如果我们的原始文本是 "a",字符串长度为 1,此时光标的默认最终位置也是 1。在下图中,| 代表光标的位置。

图 3:原始文本的光标位置

当这段文本被视觉转换为 "a-" 时,字符串长度变长了,光标在屏幕上的预期位置也应该随之向后推。在转换后的文本中,逻辑光标应该处于索引 2 的位置。

图 4:格式化后的光标位置

也就是说:光标在原始文本中的索引为 1 时,在转换后的文本中映射到了位置 2

如果我们继续输入第二个字符 "s",情况如下:

图 5a:原始文本"as"

图 5b:格式化后的文本"a-s-"

此时,当原始文本中的光标索引处于 2 时,它在转换后的文本中偏移到了位置 4

以此类推,如果输入第三个字符,映射关系将是 36,第四个字符则是 48......

这个只需要小学知识就能知道:

转换后的光标偏移量,恰好是原始偏移量的两倍(offset * 2)。

另外需要考虑一下边界情况:当 TextField 为空时,偏移量是 0。将这些考虑进去后,代码如下:

kotlin 复制代码
class MyOffsetMapping : OffsetMapping {
    // @param: offset -> 原始文本的偏移量(光标位置)
    override fun originalToTransformed(offset: Int): Int {
        if (offset <= 0) return offset
        // @returns -> 转换后文本对应的光标位置
        return offset * 2
    }

    override fun transformedToOriginal(offset: Int): Int { ... }
}

2. transformedToOriginal

这个函数的作用与上一个完全相反。我们需要将转换后文本的偏移量,反向映射回真实的原始文本。

当用户在 TextField 中用手指点击某处、或者拖拽选中一段文本时,系统就会调用这个函数来确定对应真实数据的位置。

回顾前面的正向映射规律:0 → 0, 1 → 2, 2 → 4, 3 → 6

现在我们需要反着来:2 → 1, 4 → 2, 6 → 30 依然是 0

为了实现反向映射,我们只需要利用 Kotlin 中的整数除法特性(向下取整):

kotlin 复制代码
    override fun transformedToOriginal(offset: Int): Int {
        if (offset <= 0) return offset
        return offset / 2
    }

假如用户用手指点击了 a- 中间,此时屏幕上的 offset11 / 2 = 0,这意味着光标逻辑上会映射回原始数据开头。

如果用户点击了 - 后面,offset22 / 2 = 1,光标映射到原始字符 a 后面。

这样的映射能完美避免光标卡在生成出来的无用字符上!

将两者组合起来,完整的类如下所示:

kotlin 复制代码
class MyOffsetMapping : OffsetMapping {
    override fun originalToTransformed(offset: Int): Int {
        if (offset <= 0) return offset
        return offset * 2
    }

    override fun transformedToOriginal(offset: Int): Int {
        if (offset <= 0) return offset
        return offset / 2
    }
}

就这样,我们成功地在完全不篡改底层真实输入数据的前提下,完美自定义了 TextField 的 UI 显示格式与光标行为!

你会发现,当我们移动光标的时,光标一定不会处在数字和 - 的中间位置上,一定会在 - 之后。

电话号码

如果上面你看明白了,那么格式化电话号码,自然不在话下了:

Kotlin 复制代码
var phoneNumber by remember { mutableStateOf("") }

TextField(
    value = phoneNumber,
    onValueChange = { 
        if (it.length <= 11) { // 最长输入 11 个字符,也就是电话号码长度
            phoneNumber = it
        }
    },
    visualTransformation = PhoneNumberVisualTransformation(),
    modifier = Modifier
        .fillMaxWidth()
        .padding(start = 16.dp, top = 4.dp, end = 16.dp)
)
kotlin 复制代码
class PhoneNumberVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val originalText = text.text
        val originalLength = originalText.length

        val formattedNumber = StringBuilder()
        for (i in originalText.indices) {
            if (i == 3 || i == 7) {
                formattedNumber.append(" ")
            }
            formattedNumber.append(originalText[i])
        }

        val transformedText = "+86 $formattedNumber" // 自动插入 +86

        return TransformedText(
            text = AnnotatedString(transformedText),
            offsetMapping = PhoneNumberOffsetMapping(originalLength)
        )
    }
}
Kotlin 复制代码
class PhoneNumberOffsetMapping(private val originalLength: Int) : OffsetMapping {
    override fun originalToTransformed(offset: Int): Int {
        var transformedOffset = 4 + offset
        if (offset > 3) transformedOffset += 1
        if (offset > 7) transformedOffset += 1
        
        val maxTransformedLength = 4 + originalLength + (if (originalLength > 3) 1 else 0) + (if (originalLength > 7) 1 else 0)
        return transformedOffset.coerceIn(0, maxTransformedLength)
    }

    override fun transformedToOriginal(offset: Int): Int {
        if (offset <= 4) return 0
        
        var originalOffset = offset - 4
        if (originalOffset > 4) originalOffset -= 1
        if (originalOffset > 9) originalOffset -= 1

        return originalOffset.coerceIn(0, originalLength)
    }
}

计算光标位置有点复杂,需要花点时间。

效果如下:

总结

  1. 当你需要自定义 TextField 中文本的显示格式时,请首选 VisualTransformation
  2. filter 中根据你的业务需求,编写文本的排版与格式化逻辑。
  3. 创建并返回一个 TransformedText 对象,传入格式化后的文本以及 OffsetMapping 规则。
  4. 千万不要忽略 OffsetMapping,确保"真实输入文本"和"视觉展示文本"之间的光标位置能够完美双向映射。

Compose 中的 VisualTransformation 是 Android 开发者提升表单输入体验的一件利器。

它最大的优势在于:只修改外观,不污染数据。这个特点让其成为构建优雅、无 bug 文本输入交互的无价之宝。

通过合理运用它,我们可以彻底告别在 onValueChange 里强行修改 String 而导致的光标乱跳噩梦。

相关推荐
松仔log33 分钟前
Java中级——组合和继承
android·java·开发语言
我命由我123454 小时前
Jetpack Compose - MaterialExpressiveTheme 与 MaterialTheme、ColorScheme
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime
深海呐4 小时前
仓颉语言是ArkTs的上层语言吗?就像kotlin和Java
java·开发语言·kotlin·仓颉
lilian2335 小时前
Harmony os 技术实战|拼豆制图06:收藏 ID、生成记录与重启恢复怎么不打架
android·java·数据库·harmonyos
2601_955759625 小时前
ClaudeAPI成本中心与业务标签设计指南
android·java·数据库
余衫马5 小时前
2026最新版!Android Studio 极速安装与配置指南
android·ide·android studio
AFinalStone5 小时前
Android 7系统异常问题排查(五)Framework层(下)—System Server崩溃
android·tombstone·系统异常
AFinalStone5 小时前
Android 7系统异常问题排查(八)系统追踪—Trace机制与性能诊断
android·系统异常
极客猴子9 小时前
Android录音转写频繁卡顿?多款APP长时间会议场景稳定性实测
android·人工智能·智能手机·飞书
Kapaseker9 小时前
Boolean 变量到底该怎么命名?
android·kotlin