Weird behavior when filtering input in a text field in Jetpack Compose

1.3k Views Asked by At

I'm introducing Jetpack Compose 1.1.1 into my existing Android app by migrating an activity to it. In the XML layout for that activity, I have an EditText configured with android:inputType="numberDecimal".

In the Compose version I now have a view model that holds a TextFieldValue as part of a state object (which is observed in the composable through a StateFlow), and a method that is called in the TextField's onValueChange. This method checks that the input can be parsed as a decimal number and will apply the change to the state object if so, or preserve the previous value if not.

However, I noticed that sometimes the input I get in the onValueChange is a previous text that was rejected by the view model method (instead of the current value in the state object), which causes what would be valid input to be rejected. It's as if the TextField was keeping the previously typed text in some internal state, even if my state has not been updated.

Here's a distillation of the relevant code:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Screen()
            }
        }
    }
}

class MainViewModel: ViewModel() {

    data class State(
        val prompt: String,
        val input: TextFieldValue,
    )

    private val innerState = MutableStateFlow(State(prompt = "Prompt", input = TextFieldValue("")))
    val state = innerState.asStateFlow()

    private val decimalSeparator by lazy { DecimalFormatSymbols.getInstance().decimalSeparator }
    private val decimalMatcher by lazy {
        Pattern.compile("(0|[1-9]\\d*)(${"\\$decimalSeparator"}\\d*)?").matcher("")
    }

    fun setInput(input: TextFieldValue) {
        Log.i("MainViewModel", "Got input: $input")
        val newValue = when {
            input.text.isEmpty() -> {
                // Accept empty string
                input
            }
            decimalMatcher.reset(input.text).matches() -> {
                // Accept input
                input
            }
            else -> {
                // Reject input (apply previous value)
                state.value.input
            }
        }
        Log.i("MainViewModel", "Applying input: $newValue")
        innerState.value = innerState.value.copy(input = newValue)
    }
}

@Composable
fun Screen(viewModel: MainViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()
    Column {
        Text(text = state.prompt)
        TextField(value = state.input, onValueChange = { viewModel.setInput(it) })
    }
}

If I type the following sequence of characters ['2', '.', '.', '.', '5'] this is the log output I get:

I/MainViewModel: Got input: TextFieldValue(text='2', selection=TextRange(1, 1), composition=null)
I/MainViewModel: Applying input: TextFieldValue(text='2', selection=TextRange(1, 1), composition=null)
I/MainViewModel: Got input: TextFieldValue(text='2.', selection=TextRange(2, 2), composition=null)
I/MainViewModel: Applying input: TextFieldValue(text='2.', selection=TextRange(2, 2), composition=null)
I/MainViewModel: Got input: TextFieldValue(text='2..', selection=TextRange(3, 3), composition=null)
I/MainViewModel: Applying input: TextFieldValue(text='2.', selection=TextRange(2, 2), composition=null)
I/MainViewModel: Got input: TextFieldValue(text='2...', selection=TextRange(4, 4), composition=null)
I/MainViewModel: Applying input: TextFieldValue(text='2.', selection=TextRange(2, 2), composition=null)
I/MainViewModel: Got input: TextFieldValue(text='2...5', selection=TextRange(5, 5), composition=null)
I/MainViewModel: Applying input: TextFieldValue(text='2.', selection=TextRange(2, 2), composition=null)

What I expected instead is that the second and third . characters would be rejected and when the 5 was typed the text field would read 2.5, but instead the 5 is rejected as well, because the input I'm getting is 2...5 (even though it's still 2. in my state).

By the way, here are some variations of the sample code I've already tried:

  • replace the view model state with a var input by remember { mutableStateOf(TextFieldValue("")) } in MainScreen, do the filtering directly in the onValueChange lambda: behavior is exactly the same
  • use a simple String instead of TextFieldValue in my state object: sometimes I will get the expected input, but other times I'll get a repeated . as before. Even weirder, if I wait long enough and type again, then I'll get the expected input, which is not true of the TextFieldValue case

This looks like a bug to me, but I thought I'd ask here first in case I'm misusing the framework in some obvious way.

1

There are 1 best solutions below

1
On BEST ANSWER

I believe this is a known bug in Compose. See this issue in the issue Tracker. There was a commit on Jan 22, 2022 to fix it, but perhaps it has not made it into a release yet ...

https://issuetracker.google.com/issues/200577798

Also, this related issue, which was fixed on Feb. 9, 2022, may also not be in a release yet https://issuetracker.google.com/issues/206656075