Compose 没有 inputType 怎么过滤(限制)输入内容?这题我会!

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

前言

闲话

在我之前的文章 《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.png

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

比如参数 “导出图像质量” 我们需要将输入内容限制在 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.gif

而对于 情况 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.png

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

它提供了 6 个参数

  1. source 要插入的新字符
  2. start source 中要插入的字符位置起点
  3. end source 中要插入的字符位置终点
  4. dest 输入框中的原内容
  5. dstart dest 中要被 source 插入的位置的起点
  6. dend dest 中要被 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 输入内容过滤的方法并且提供了几种常用的过滤供大家使用。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6