为 Compose 的 TextField 添加类似 EditText inputType 的输入过滤

前言

闲话

在我之前的文章 《Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手》 中,我埋了一个坑,关于在 Compose 中如何过滤 TextField 的输入内容。时隔好几个月了,今天这篇文章就是来填这个坑的。

为什么需要添加过滤

在正式开始之前,我们先来回答一下标题这个问题,为什么需要过滤呢?

众所周知,在安卓的原生 View 体系中,输入框是 EditText 我们可以通过在 xml 布局文件中添加 inputType 属性来指定预设的几个允许的输入内容格式,例如:numbernumberDecimalnumberSigned 等属性,分别表示过滤输入结果仅允许输入数字、十进制数字、带符号数字。另外,我们也可以通过给 EditText 自定义继承自 InputFilter 的过滤方法(setFilters)来自定义我们自己的过滤规则。

但是在 Compose 中我们应该怎么去做呢?

在 Compose 中,输入框是 TextField,查遍 TextField 的参数列表,我们就会发现,并没有给我们提供任何类似于 EditText 的过滤参数。

最多只能找到一个 keyboardOptions: KeyboardOptions = KeyboardOptions.Default 参数,而这个参数也只是请求输入法展示我们要求的输入类型,例如只展示数字键盘,但是,这里只是请求,并不是要求,所以人家输入法还不一定理呢,哈哈哈。而且这个参数并不能限制输入框的内容,只是更改了默认弹出软键盘的类型而已。

其实想想也不难理解,毕竟 Compose 是声明式 UI ,如何处理输入内容确实应该由我们自己来实现。

但是如果官方能提供几个类似 EditText 的预设过滤参数就好了,可惜并没有。

所以我们需要自己实现对输入内容的过滤。

回到标题内容,为什么我们需要过滤输入内容?

这里我们将以上文中中提到的 时间水印助手 为例子讲解:

s1

在这个界面中,我们需要输入多个参数。

比如参数 “导出图像质量” ,我们需要将输入内容限制在 0.0 - 1.0 的浮点数。

当然,我们完全可以不在输入时限制,可以允许用户随意输入任意内容,然后在实际提交数据时再做校验,但是,显然,这样是不合理的,用户体验也不佳。

所以我们最好还是能直接在用户输入时就做好限制。

实现

先简单试一下

其实想做过滤也不是不行,想想好像还是挺简单的嘛,这里以简单的限制输入内容长度(类似 EditTExt 中的 maxLength 属性)为例子举例:

var inputValue by remember { mutableStateOf("") }

TextField(
    value = inputValue,
    onValueChange = { inputValue = it }
)

可能读者们会说,嗨,不就是限制输入长度嘛,这在声明式 UI 中都不叫事,看我直接这样就行了:

val maxLength = 8
var inputValue by remember { mutableStateOf("") }

TextField(
    value = inputValue,
    onValueChange = {
        if (it.length <= maxLength) inputValue = it
    }
)

我们在输入值改变时加一个判断,只有输入值的长度小于了定义的最大长度我们才改变 inputValue 的值。

咋一看,好想没有问题是吧?

但是,你再仔细想想。

真的这么简单吗?

你有没有想过以下两种情况:

  1. 我们在已经输入了 8 个字符后,把光标移动到中间位置,此时再输入内容,你猜会发生什么?
  2. 我们在输入了不足8个的字符(例如 5 个后),同时粘贴超过限制字符数的内容(例如 4 个),你猜会发生什么?

不卖关子了,其实对于 情况 1 ,会出现内容确实没有继续添加了,但是光标会往后走的情况:

s2

而对于 情况 2 ,相信不用我说,读者也能猜出来了,那就是粘贴后没有任何反应。

没错,显然因为我们在 onValueChange 中加了判断,如果当前输入的值 (it) 大于了限制的值(maxLength )那么我们将不会做任何响应。但是这个显然是不合理的,因为虽然我们粘贴的所有内容直接插入输入框的话确实会超出最大字符限制,但是并不是说输入框不能再输入内容了,很显然,输入框还可以接受再输入 3 个字符。所以我们应该做的处理是将新输入的内容截断,截取符合数量的内容插入输入框,多余的内容直接舍弃。

原生 View 的 EditText 也是这样的处理逻辑。

那么,现在我们应该怎么做呢?

实践一下,限制输入字符长度

经过上面的小试牛刀,相信大家也知道了,对于限制输入内容,不能简单的直接对输入的 String 做处理,而应该考虑到更多的情况,其中最需要关注的情况有两点:一是对输入框光标的控制;二是对选择多个字符和粘贴多个字符情况的处理(因为正常输入可以保证每次只输入(或删除)一个字符,但是粘贴或多选后不一定)。

很显然,如果想控制光标的话,我们不能直接使用 value 为 String 的 TextField 而应该改用使用 TextFieldValue

var inputValue by remember { mutableStateOf(TextFieldValue()) }

OutlinedTextField(
    value = inputValue,
    onValueChange = {  }
)

TextFieldValue 是一个封装了输入内容(text: String)、和选择以及光标状态(selection: TextRange)的类。

其中 TextRange 有两个参数 startend 分别表示选中文本时的开始和结束位置,如果两个值相等则表示没有选中任何文本,此时 TextRange 表示的是光标位置。

现在,我们已经具备了可以解决上面说的两点问题的前置条件,下面就是应该怎么去解决这个问题了。

其实对于问题 1 ,非常好解决,我们甚至都不需要过多的去变动代码,只需要把使用的 String 值 改成 TextFieldValue 即可:

val maxLength = 8
var inputValue by remember { mutableStateOf(TextFieldValue()) }

OutlinedTextField(
    value = inputValue,
    onValueChange = {
        if (it.text.length <= maxLength) inputValue = it
    }
)

原因也很简单,因为 TextFieldValue 中已经包含了光标信息,这里我们在输入内容超过限制长度时不做更改 inputValue 的值,实际上是连同光标信息一起不做更改了,而上面直接使用的是 String ,则只是不改变输入内容,但是光标位置还是会被改变。

而对于问题 2 ,需要我们做一些特殊的处理。

我们首先定义一个函数来处理输入内容的改动:

fun filterMaxLength(
    inputTextField: TextFieldValue,
    lastTextField: TextFieldValue,
    maxLength: Int
): TextFieldValue {
    // TODO
}

这个函数接收两个参数:inputTextFieldlastTextField 分别表示加上新输入的内容后的 TextFieldValue 和没有输入新内容时的 TextFieldValue

这里有个地方需要注意,就是在 TextFieldonChange 回调中,如果使用的是 TextFieldValue,那么不仅会在输入内容发生改变时才调用 onChange 回调,而是即使只有光标的移动或状态改变都会调用 onChange 回调。

然后,我们在这个函数中处理一下对于粘贴多个字符时的情况:

val inputCharCount = inputTextField.text.length - lastTextField.text.length
if (inputCharCount > 1) { // 同时粘贴了多个字符内容
    val allowCount = maxLength - lastTextField.text.length
    // 允许再输入字符已经为空,则直接返回原数据
    if (allowCount <= 0) return lastTextField

    // 还有允许输入的字符,则将其截断后插入
    val newString = StringBuffer()
    newString.append(lastTextField.text)
    val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
    newString.insert(lastTextField.selection.start, newChar)
    return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
}

这段代码其实很好理解,首先我们通过使用未更新前的字符长度减去本次输入的字符长度得到本次实际新增的字符长度,如果这个长度大于 1 则认为是同时粘贴了多个内容进输入框。(这里有个点需要注意,就是这样得到的值可能会等于 0,表示只是光标的变动,字符没有变;小于 0 ,表示是删除内容。)

然后再使用最大允许输入的字符长度减去未更新前的输入框字符长度,即可得到当前还允许再插入多少个字符 allowCount

如果尚还余有可输入的字符,则通过截取输入内容字符的符合长度的新增字段来获取。

截取的起点使用的是未更新前的光标起始位置(lastTextField.selection.start),截取长度就是还允许输入的字符长度。

需要注意的是,这里之所以使用 lastTextField.selection.start 作为截取起点,而不是 lastTextField.selection.end 是因为粘贴插入时也可能是因为之前已经选中了部分内容,然后再插入的,此时就应该以 未更新时的选中状态起点 作为插入的位置。而如果粘贴插入时并非选中状态,那么使用 startend 都可以,因为此时它俩的值是一样的。

拿到可以插入的字符后,接下里就是将其插入即可:newString.insert(lastTextField.selection.start, newChar)

最后返回时别忘了改一下光标的位置,这里其实也很简单,就是改到新字符插入的位置+实际插入的字符数量: TextRange(lastTextField.selection.start + newChar.length)

最后,完整的限制输入长度的过滤函数如下:

/**
 * 过滤输入内容长度
 *
 * @param maxLength 允许输入长度,如果 小于 0 则不做过滤,直接返回原数据
 * */
fun filterMaxLength(
    inputTextField: TextFieldValue,
    lastTextField: TextFieldValue,
    maxLength: Int
): TextFieldValue {
    if (maxLength < 0) return inputTextField // 错误的长度,不处理直接返回

    if (inputTextField.text.length <= maxLength) return inputTextField // 总计输入内容没有超出长度限制


    // 输入内容超出了长度限制
    // 这里要分两种情况:
    // 1. 直接输入的,则返回原数据即可
    // 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入

    val inputCharCount = inputTextField.text.length - lastTextField.text.length
    if (inputCharCount > 1) { // 同时粘贴了多个字符内容
        val allowCount = maxLength - lastTextField.text.length
        // 允许再输入字符已经为空,则直接返回原数据
        if (allowCount <= 0) return lastTextField

        // 还有允许输入的字符,则将其截断后插入
        val newString = StringBuffer()
        newString.append(lastTextField.text)
        val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
        newString.insert(lastTextField.selection.start, newChar)
        return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
    }
    else { // 正常输入
        return if (inputTextField.selection.collapsed) { // 如果当前不是选中状态,则使用上次输入的光标位置,如果使用本次的位置,光标位置会 +1
            lastTextField
        } else { // 如果当前是选中状态,则使用当前的光标位置
            lastTextField.copy(selection = inputTextField.selection)
        }
    }
}

其实这里的过滤函数还是有问题,不知道读者是否发现了?这里我就不指出也不改了,权当是留给读者们的一个思考题了,哈哈,毕竟不希望读者只是草草看完直接把代码粘贴走就完事了,哈哈哈哈。

我们在使用的时候只需要改一下 TextFieldonChange 回调即可:

val maxLength = 8
var inputValue by remember { mutableStateOf(TextFieldValue()) }
OutlinedTextField(
    value = inputValue,
    onValueChange = {
        inputValue = filterMaxLength(it, inputValue, maxLength)
    }
)

扩展一下,做一个通用的过滤

虽然上面我们已经实现了做一个自己的输入内容限制,但是似乎扩展性不怎么好啊。

我们有没有办法做一个通用的,方便扩展的过滤方法呢?

毕竟 View 中的过滤不仅是自己预设了很多好用的过滤,而且它还提供了一个通用的接口 InputFilter 可以让我们自己定义我们自己需要的过滤方法。

那么,说干就干,首先我们来看看 View 中的 InputFilter 是怎么写的:

s3

这么一看,其实也不是很复杂,就是一个 InputFilter 类,只有一个 filter 方法,这个方法返回一个 CharSequence 表示经过过滤处理后的新的字符。

它提供了 6 个参数:

  1. source :要插入的新字符
  2. startsource 中要插入的字符位置起点
  3. endsource 中要插入的字符位置终点
  4. dest : 输入框中的原内容
  5. dstartdest 中要被 source 插入的位置的起点
  6. denddest 中要被 source 插入的位置的终点

我们只需要使用这六个参数对字符进行过滤处理后返回新的字符就可以了。

但是 View 中的 InputFilter 显然只负责过滤字符,不负责处理更改光标的位置。

不管怎样,我们也照猫画虎做一个 Compose 版本的过滤基类 BaseFieldFilter

open class BaseFieldFilter {
    private var inputValue = mutableStateOf(TextFieldValue())

    protected open fun onFilter(inputTextFieldValue: TextFieldValue, lastTextFieldValue: TextFieldValue): TextFieldValue {
        return TextFieldValue()
    }

    protected open fun computePos(): Int {
        // TODO
        return 0
    }

    protecte fun getNewTextRange(
        lastTextFiled: TextFieldValue,
        inputTextFieldValue: TextFieldValue
    ): TextRange? {
        // TODO
        retutn null
    }

    protecte fun getNewText(
        lastTextFiled: TextFieldValue,
        inputTextFieldValue: TextFieldValue
    ): TextRange? {
        // TODO
        return null
    }

    fun getInputValue(): TextFieldValue {
        return inputValue.value
    }

    fun onValueChange(): (TextFieldValue) -> Unit {
        return {
            inputValue.value = onFilter(it, inputValue.value)
        }
    }
}

在这类中我们需要重点关注 onFilter 方法,我们的过滤内容主要在这个方法中编写。

然后在 TextField 中主要会使用到 getInputValueonValueChange 方法。

本来我还打算写几个基础的工具方法 getNewTextgetNewTextRangecomputePos 分别用于计算实际插入的新字符、实际插入字符的位置、新的索引位置。

但是后来发现似乎并不好写出一个很好用的通用方法,所以这里我就留空了。

这个基础类使用起来也很简单,我们只需要将我们自己的过滤方法继承这个基础类,然后重载 onFilter 方法即可,还是以限制输入长度为例,写一个类 FilterMaxLength

/**
 * 过滤输入内容长度
 *
 * @param maxLength 允许输入长度,如果 小于 0 则不做过滤,直接返回原数据
 * */
class FilterMaxLength(
    @androidx.annotation.IntRange(from = 0L)
    private val maxLength: Int
) : BaseFieldFilter() {
    override fun onFilter(
        inputTextFieldValue: TextFieldValue,
        lastTextFieldValue: TextFieldValue
    ): TextFieldValue {
        return filterMaxLength(inputTextFieldValue, lastTextFieldValue, maxLength)
    }

    private fun filterMaxLength(
        inputTextField: TextFieldValue,
        lastTextField: TextFieldValue,
        maxLength: Int
    ): TextFieldValue {
        if (maxLength < 0) return inputTextField // 错误的长度,不处理直接返回

        if (inputTextField.text.length <= maxLength) return inputTextField // 总计输入内容没有超出长度限制


        // 输入内容超出了长度限制
        // 这里要分两种情况:
        // 1. 直接输入的,则返回原数据即可
        // 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入

        val inputCharCount = inputTextField.text.length - lastTextField.text.length
        if (inputCharCount > 1) { // 同时粘贴了多个字符内容
            val allowCount = maxLength - lastTextField.text.length
            // 允许再输入字符已经为空,则直接返回原数据
            if (allowCount <= 0) return lastTextField

            // 还有允许输入的字符,则将其截断后插入
            val newString = StringBuffer()
            newString.append(lastTextField.text)
            val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
            newString.insert(lastTextField.selection.start, newChar)
            return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
        }
        else { // 正常输入
            return if (inputTextField.selection.collapsed) { // 如果当前不是选中状态,则使用上次输入的光标位置,如果使用本次的位置,光标位置会 +1
                lastTextField
            } else { // 如果当前是选中状态,则使用当前的光标位置
                lastTextField.copy(selection = inputTextField.selection)
            }
        }
    }
}

此时,我们在 TextField 中只需要这样即可调用:

val filter = remember { FilterMaxLength(8) }
    
OutlinedTextField(
    value = filter.getInputValue(),
    onValueChange = filter.onValueChange(),
)

怎么样,是不是非常的方便快捷?

我还想要更多!

当然,上面一直都是拿的限制输入长度举例子,那其他的过滤实现呢?你倒是端出来啊,别急,这就为各位奉上我项目中用到的几个过滤方法。

同样的,这些方法或多或少我都留有坑,各位千万不要不检查一下直接就用哦(坏笑)。

哈哈哈,开玩笑了,其实完整没坑的代码各位可以去前言中我提到的那个项目中找。

过滤数字

class FilterNumber(
    private val minValue: Double = -Double.MAX_VALUE,
    private val maxValue: Double = Double.MAX_VALUE,
    private val decimalNumber: Int = -1
) : BaseFieldFilter() {

    override fun onFilter(
        inputTextFieldValue: TextFieldValue,
        lastTextFieldValue: TextFieldValue
    ): TextFieldValue {
        return filterInputNumber(inputTextFieldValue, lastTextFieldValue, minValue, maxValue, decimalNumber)
    }

    private fun filterInputNumber(
        inputTextFieldValue: TextFieldValue,
        lastInputTextFieldValue: TextFieldValue,
        minValue: Double = -Double.MAX_VALUE,
        maxValue: Double = Double.MAX_VALUE,
        decimalNumber: Int = -1,
    ): TextFieldValue {
        val inputString = inputTextFieldValue.text
        val lastString = lastInputTextFieldValue.text

        val newString = StringBuffer()
        val supportNegative = minValue < 0
        var dotIndex = -1
        var isNegative = false

        if (supportNegative && inputString.isNotEmpty() && inputString.first() == '-') {
            isNegative = true
            newString.append('-')
        }

        for (c in inputString) {
            when (c) {
                '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> {
                    newString.append(c)
                    val tempValue = newString.toString().toDouble()
                    if (tempValue > maxValue) newString.deleteCharAt(newString.lastIndex)
                    if (tempValue < minValue) newString.deleteCharAt(newString.lastIndex) // TODO 需要改进 (例如限制最小值为 100000000,则将无法输入东西)

                    if (dotIndex != -1) {
                        if (decimalNumber != -1) {
                            val decimalCount = (newString.length - dotIndex - 1).coerceAtLeast(0)
                            if (decimalCount > decimalNumber) newString.deleteCharAt(newString.lastIndex)
                        }
                    }
                }
                '.' -> {
                    if (decimalNumber != 0) {
                        if (dotIndex == -1) {
                            if (newString.isEmpty()) {
                                if (abs(minValue) < 1) {
                                    newString.append("0.")
                                    dotIndex = newString.lastIndex
                                }
                            } else {
                                newString.append(c)
                                dotIndex = newString.lastIndex
                            }

                            if (newString.isNotEmpty() && newString.toString().toDouble() == maxValue) {
                                dotIndex = -1
                                newString.deleteCharAt(newString.lastIndex)
                            }
                        }
                    }
                }
            }
        }

        val textRange: TextRange
        if (inputTextFieldValue.selection.collapsed) { // 表示的是光标范围
            if (inputTextFieldValue.selection.end != inputTextFieldValue.text.length) { // 光标没有指向末尾
                var newPosition = inputTextFieldValue.selection.end + (newString.length - inputString.length)
                if (newPosition < 0) {
                    newPosition = inputTextFieldValue.selection.end
                }
                textRange = TextRange(newPosition)
            }
            else { // 光标指向了末尾
                textRange = TextRange(newString.length)
            }
        }
        else {
            textRange = TextRange(newString.length)
        }

        return lastInputTextFieldValue.copy(
            text = newString.toString(),
            selection = textRange
        )
    }
}

仅允许输入指定字符

class FilterOnlyChar() : BaseFieldFilter() {
    private var allowSet: Set<Char> = emptySet()

    constructor(allowSet: String) : this() {
        val tempSet = mutableSetOf<Char>()
        for (c in allowSet) {
            tempSet.add(c)
        }
        this.allowSet = tempSet
    }

    constructor(allowSet: Set<Char>) : this() {
        this.allowSet = allowSet
    }

    override fun onFilter(
        inputTextFieldValue: TextFieldValue,
        lastTextFieldValue: TextFieldValue
    ): TextFieldValue {
        return filterOnlyChar(
            inputTextFieldValue,
            lastTextFieldValue,
            allowChar = allowSet
        )
    }

    private fun filterOnlyChar(
        inputTextFiled: TextFieldValue,
        lastTextFiled: TextFieldValue,
        allowChar: Set<Char>
    ): TextFieldValue {
        if (allowChar.isEmpty()) return inputTextFiled // 如果允许列表为空则不过滤

        val newString = StringBuilder()

        var modifierEnd = 0

        for (c in inputTextFiled.text) {
            if (c in allowChar) {
                newString.append(c)
            }
            else modifierEnd--
        }

        return inputTextFiled.copy(text = newString.toString())
    }
}

过滤电子邮箱地址

class FilterStandardEmail(private val extraChar: String = "") : BaseFieldFilter() {
    private val allowChar: MutableSet<Char> = mutableSetOf('@', '.', '_', '-').apply {
        addAll('0'..'9')
        addAll('a'..'z')
        addAll('A'..'Z')
        addAll(extraChar.asIterable())
    }

    override fun onFilter(
        inputTextFieldValue: TextFieldValue,
        lastTextFieldValue: TextFieldValue
    ): TextFieldValue {
        return inputTextFieldValue.copy(text = filterStandardEmail(inputTextFieldValue.text, lastTextFieldValue.text))
    }

    private fun filterStandardEmail(
        inputString: String,
        lastString: String,
    ): String {
        val newString = StringBuffer()
        var flag = 0 // 0 -> None 1 -> "@" 2 -> "."

        for (c in inputString) {
            if (c !in allowChar) continue

            when (c) {
                '@' -> {
                    if (flag == 0) {
                        if (newString.isNotEmpty() && newString.last() != '.') {
                            if (newString.isNotEmpty()) {
                                newString.append(c)
                                flag++
                            }
                        }
                    }
                }
                '.' -> {
                    // if (flag >= 1) {
                        if (newString.isNotEmpty() && newString.last() != '@' && newString.last() != '.') {
                            newString.append(c)
                            // flag++
                        }
                    // }
                }
                else -> {
                    newString.append(c)
                }
            }
        }

        return newString.toString()
    }

}

过滤十六进制颜色

class FilterColorHex(
    private val includeAlpha: Boolean = true
) : BaseFieldFilter() {

    override fun onFilter(
        inputTextFieldValue: TextFieldValue,
        lastTextFieldValue: TextFieldValue
    ): TextFieldValue {
        return inputTextFieldValue.copy(filterInputColorHex(
            inputTextFieldValue.text,
            lastTextFieldValue.text,
            includeAlpha
        ))
    }

    private fun filterInputColorHex(
        inputValue: String,
        lastValue: String,
        includeAlpha: Boolean = true
    ): String {
        val maxIndex = if (includeAlpha) 8 else 6
        val newString = StringBuffer()
        var index = 0

        for (c in inputValue) {
            if (index > maxIndex) break

            if (index == 0) {
                if (c == '#') {
                    newString.append(c)
                    index++
                }
            }
            else {
                if (c in '0'..'9' || c.uppercase() in "A".."F" ) {
                    newString.append(c.uppercase())
                    index++
                }
            }
        }

        return newString.toString()
    }
}

总结

虽然在 Compsoe 中官方没有提供类似于 EditText 中的 inputType 的预设输入内容过滤,但是得益于 Compose 的声明式 UI,在 Compose 中过滤输入内容更加简单,都不需要太多繁琐的步骤,因为我们可以直接操作输入的内容。

本文就由浅入深的介绍了如何在 Compose 中快速实现类似于安卓原生 View 中的 inputType 输入内容过滤的方法,并且提供了几种常用的过滤供大家使用。