How does Compose filter (restrict) input content without inputType? I will answer this question!

foreword

gossip

In my previous article " Compose For Desktop Practice: Using Compose-jb as a time watermark assistant ", I buried a pit about how to filter the input content of TextField in Compose. After several months, today’s article is to fill in this pit.

Why do you need to add filtering

Before officially starting, let's answer the question of the title first, why do we need to filter?

inputTypeAs we all know, in Android's native View system, the input box is EditText. We can specify several preset allowable input content formats by adding attributes in the xml layout file , such as: number, numberDecimal, numberSignedand other attributes, which respectively indicate that the filtered input results are only Numbers, decimal numbers, and signed numbers are allowed. In addition, we can also customize our own filtering rules by customizing InputFilterthe filtering method ( ) inherited from EditText .setFilters

But how should we do it in Compose?

In Compose, the input box is TextField, search through TextFieldthe parameter list, we will find that it does not provide us with any filtering parameters similar to EditText.

Only one keyboardOptions: KeyboardOptions = KeyboardOptions.Defaultparameter can be found at most, and this parameter is only a request for the input method to display the input type we require, such as only displaying the numeric keypad, but this is just a request, not a requirement, so the input method is not necessarily reasonable, hahaha . And this parameter does not limit the content of the input box, but only changes the type of soft keyboard that pops up by default.

In fact, it is not difficult to understand after thinking about it. After all, Compose is a declarative UI, and how to process input content should be implemented by ourselves.

But it would be great if the official can provide several preset filter parameters similar to EditText, but unfortunately not.

So we need to implement the filtering of the input content ourselves.

Going back to the title content, why do we need to filter the input content?

Here we will explain the time watermark assistant mentioned above as an example:

s1.png

In this interface, we need to enter multiple parameters.

For the parameter "export image quality", we need to limit the input content to a floating point number from 0.0 - 1.0.

Of course, we can not restrict the input at all, and allow users to input any content at will, and then perform verification when actually submitting the data, but obviously, this is unreasonable and the user experience is not good.

So we'd better be able to limit it directly when the user enters it.

accomplish

just try it first

In fact, it is not impossible to do filtering. Thinking about it, it seems to be quite simple. Here is an example of simply limiting the length of the input content (similar to the maxLength attribute in EditTExt):

var inputValue by remember {
    
     mutableStateOf("") }

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

Maybe readers will say, hey, isn’t it just to limit the input length? This is not a problem in a declarative UI. Let me just do this:

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

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

We add a judgment when the input value changes, and we change the value only if the length of the input value is less than the defined maximum length inputValue.

At first glance, I think there is no problem, right?

But, think again.

Is it really that simple?

Have you ever thought about the following two situations:

  1. After we have entered 8 characters, move the cursor to the middle position, and then enter the content at this time, guess what will happen?
  2. We enter less than 8 characters (for example, after 5), and at the same time paste more than the limited number of characters (for example, 4), guess what will happen?

It’s not a secret anymore. In fact, for case 1, there will be a situation where the content is indeed not added, but the cursor will go backward:

s2.gif

As for situation 2, I believe readers can guess it without me saying it, that is, there is no response after pasting.

That's right, obviously because we onValueChange have added a judgment in , if the current input value ( it) is greater than the limit value ( maxLength ), then we will not make any response. But this is obviously unreasonable, because although all the content we pasted directly into the input box will indeed exceed the maximum character limit, it does not mean that the input box can no longer enter content. Obviously, the input box can also accept another input of 3 characters. So what we should do is to truncate the newly entered content, insert the content that meets the required amount into the input box, and discard the excess content directly.

The EditText of the native View also has such processing logic.

So what should we do now?

Practice it, limit the length of input characters

After the small test above, I believe everyone knows that for restricting the input content, you can’t simply process the input String directly, but you should consider more situations. Among them, there are two situations that need the most attention: one is The control of the cursor in the input box; the second is the processing of selecting and pasting multiple characters (because normal input can ensure that only one character is entered (or deleted) at a time, but not necessarily after pasting or multi-selection).

Obviously, if we want to control the cursor, we can't directly use it valueas a String TextFieldbut should use instead TextFieldValue:

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

OutlinedTextField(
    value = inputValue,
    onValueChange = {
    
      }
)

TextFieldValueis a class that encapsulates input content ( text: String), and selection and cursor state ( ).selection: TextRange

There TextRangeare two parameters start, endwhich represent the start and end positions of the selected text respectively. If the two values ​​are equal, it means that no text is selected, and at this time TextRangerepresents the cursor position.

Now, we already have the preconditions that can solve the two problems mentioned above, the following is how to solve this problem.

In fact, for problem 1, it is very easy to solve, we don't even need to change the code too much, just change the String value used to TextFieldValue:

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

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

The reason is also very simple, because TextFieldValuealready contains the cursor information, here we do not change the inputValuevalue when the input content exceeds the limit length, in fact, the cursor information is not changed together, and the above directly uses String, it is just The input content is not changed, but the cursor position will still be changed.

As for question 2, we need to do some special processing.

We first define a function to handle changes to the input:

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

This function receives two parameters: inputTextField, lastTextFieldwhich respectively represent after adding new input content TextFieldValueand when no new content is input TextFieldValue.

There is one thing to note here, that is, in the callback TextFieldof onChange, if you use TextFieldValue, the callback will not only be called when the input content changes onChange, but the callback will be called even if only the cursor moves or the state changes onChange.

Then, we handle the situation when pasting multiple characters in this function:

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

This code is actually easy to understand. First, we subtract the character length entered this time by using the character length before the update to get the actual new character length. If the length is greater than 1, it is considered that multiple contents are pasted at the same time. into the input box. (There is a point to note here, that is, the value obtained in this way may be equal to 0, which means that only the cursor changes, and the characters do not change; if it is less than 0, it means that the content is deleted.)

Then use the maximum allowable input character length minus the character length of the input box before updating to get how many more characters are currently allowed to be inserted allowCount .

If there are still characters that can be entered, it will be obtained by intercepting the characters of the input content in a new field that meets the length.

The starting point of the interception is the initial position of the cursor before updating ( lastTextField.selection.start), and the interception length is the character length that is still allowed to be input.

It should be noted that the reason why it is used lastTextField.selection.startas the starting point of interception here is not lastTextField.selection.endbecause when pasting and inserting, it may also be because some content has been selected before and then inserted. At this time, the starting point of the selected state when it is not updated should be used as the insertion s position. And if it is not in the selected state when pasting and inserting, you can use both startand endbecause their values ​​are the same at this time.

After getting the characters that can be inserted, the next step is to insert them: newString.insert(lastTextField.selection.start, newChar).

Don’t forget to change the position of the cursor when you return at the end. It’s actually very simple here, just change to the position where the new character is inserted + the number of characters actually inserted: TextRange(lastTextField.selection.start + newChar.length).

Finally, the complete filter function to limit the input length is as follows:

/**
 * 过滤输入内容长度
 *
 * @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)
        }
    }
}

In fact, there is still a problem with the filter function here. I don’t know if the reader has discovered it? I will not point out or change it here. It is a question for readers to think about. After all, I don’t want readers to just read the code and paste it away. Hahahaha.

TextFieldWe only need to change the callback of when we use it onChange:

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

Expand it and do a general filter

Although above we have achieved to make our own input content limit, but it seems that the scalability is not very good.

Is there a way for us to make a general-purpose, easy-to-extend filtering method?

After all, the filtering in View not only presets a lot of useful filtering, but also provides a general interface that InputFilterallows us to define the filtering methods we need.

InputFilterSo, let's do it, first let's take a look at how is written in View :

s3.png

Looking at it this way, it is actually not very complicated. It is a InputFilterclass with only one filtermethod. This method returns a CharSequenceto represent the new character after filtering.

It provides 6 parameters:

  1. source: new character to insert
  2. start: sourceThe starting point of the character position to be inserted in
  3. end: sourceThe end point of the character position to be inserted in
  4. dest: the original content in the input box
  5. dstart: the starting point of the position destto be inserted insource
  6. dend: the end point of the position destto be inserted insource

We only need to use these six parameters to filter the characters and return new characters.

But the in View InputFilteris obviously only responsible for filtering characters, not for changing the position of the cursor.

Anyway, let's do a Compose version of the filter base class as well 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)
        }
    }
}

In this category we need to focus on onFilterthe method, and our filtering content is mainly written in this method.

Then the and methods TextFieldare mainly used in .getInputValueonValueChange

Originally, I also planned to write several basic tool methods getNewText, getNewTextRange, computePoswhich are used to calculate the actual inserted new character, the position of the actual inserted character, and the new index position.

But later I found that it seems that it is not easy to write a common method that is easy to use, so I will leave it blank here.

This base class is also very simple to use, we only need to inherit our own filtering method from this base class, and then overload the onFiltermethod, let’s take limiting the input length as an example, write a class 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)
            }
        }
    }
}

At this point, we TextFieldjust need to call it like this in :

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

How about it, isn't it very convenient and fast?

I want more!

Of course, the above has always been an example of limiting the input length, what about other filtering implementations? You bring it out, don't worry, here are a few filtering methods used in my project.

In the same way, I have more or less pitfalls in these methods, so please don't use them without checking them (smirk).

Hahaha, just kidding, in fact, you can find the complete code without pitfalls in the project I mentioned in the preface.

filter numbers

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

Only allow specified characters

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

Filter email addresses

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

}

filter hex color

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

Summarize

Although Compose does not officially provide preset input content filtering similar to inputType in EditText, thanks to Compose's declarative UI, it is easier to filter input content in Compose without too many cumbersome steps, because we You can directly manipulate the input content.

This article introduces from the shallower to the deeper how to quickly implement a method of filtering input content in Compose similar to the inputType in Android's native View, and provides several common filters for everyone to use.

Guess you like

Origin blog.csdn.net/sinat_17133389/article/details/130894376