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

0 阅读5分钟

0.jpg

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

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

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

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

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

视觉效果示例如下:

1.gif

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

初体验

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

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

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

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

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

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

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

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

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

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

@Immutable
fun interface VisualTransformation {
    fun filter(text: AnnotatedString): TransformedText
}

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

1. text: AnnotatedString

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

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

2. TransformedText

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

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" 作为输入。

03-fig2.png

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

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

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 的接口定义:

interface OffsetMapping {
    fun originalToTransformed(offset: Int): Int

    fun transformedToOriginal(offset: Int): Int
}

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

1. originalToTransformed

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

04-fig3.png

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

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

05-fig4.png

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

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

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

06-fig5a.png

图 5a:原始文本“as”

07-fig5b.png

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

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

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

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

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

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

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 中的整数除法特性(向下取整):

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

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

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

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

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

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 显示格式与光标行为!

2.gif

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

电话号码

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

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

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

效果如下:

3.gif

总结

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

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

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

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