
在 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
)
- 转换后的 text: AnnotatedString :这是原始文本在 UI 上经过视觉格式化后的呈现形式。比如,将原始状态的
"1234567890"格式化并显示为"123--456--8790"。 - 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 即可转换输出格式
梳理一下此时的文本状态:
- 原始文本 →
"a" - 转换后文本 →
"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。
以此类推,如果输入第三个字符,映射关系将是 3 → 6,第四个字符则是 4 → 8......
这个只需要小学知识就能知道:
转换后的光标偏移量,恰好是原始偏移量的两倍(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 → 3。0 依然是 0。
为了实现反向映射,我们只需要利用 Kotlin 中的整数除法特性(向下取整):
kotlin
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 0) return offset
return offset / 2
}
假如用户用手指点击了 a 和 - 中间,此时屏幕上的 offset 为 1。1 / 2 = 0,这意味着光标逻辑上会映射回原始数据开头。
如果用户点击了 - 后面,offset 为 2,2 / 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)
}
}
计算光标位置有点复杂,需要花点时间。
效果如下:

总结
- 当你需要自定义
TextField中文本的显示格式时,请首选VisualTransformation。 - 在
filter中根据你的业务需求,编写文本的排版与格式化逻辑。 - 创建并返回一个
TransformedText对象,传入格式化后的文本以及OffsetMapping规则。 - 千万不要忽略
OffsetMapping,确保"真实输入文本"和"视觉展示文本"之间的光标位置能够完美双向映射。
Compose 中的 VisualTransformation 是 Android 开发者提升表单输入体验的一件利器。
它最大的优势在于:只修改外观,不污染数据。这个特点让其成为构建优雅、无 bug 文本输入交互的无价之宝。
通过合理运用它,我们可以彻底告别在 onValueChange 里强行修改 String 而导致的光标乱跳噩梦。