DEV Community

Piotr Chmielowski
Piotr Chmielowski

Posted on

Jetpack Compose TextField which accepts and emits value other than String

How to create a TextField composable which - as opposed to the default one - works not on String but on other types, such as Int.

In this post, we will build a composable for entering user's age with the following signature:

@Composable
private fun AgeTextField(
    age: Int,
    onAgeChange: (Int) -> Unit,
)
Enter fullscreen mode Exit fullscreen mode

Let's code

To start, let's create the screen with age input and a button:

@Composable
fun AppContent(model: MyViewModel = viewModel()) {
    Column {
        AgeTextField(
            age = model.age,
            onAgeChange = model::onAgeChange,
        )
        Button(onClick = model::onUpdateClick) {
            Text("Update")
        }
    }
}
@Composable
private fun AgeTextField(
    age: String,
    onAgeChange: (String) -> Unit,
) {
    TextField(
        value = age,
        onValueChange = onAgeChange,
        label = { Text("Enter age") },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
        ),
    )
}
Enter fullscreen mode Exit fullscreen mode

For now AgeTextField works on the String type and all conversion logic is inside view model:

class MyViewModel : ViewModel() {

    var age: String by mutableStateOf(readAgeFromDatabase().toString())
        private set

    fun onAgeChange(text: String) {
        age = text
    }

    fun onUpdateClick() {
        val parsed = age.toIntOrNull()
        if (parsed != null) {
            updateAgeInDatabase(parsed)
        }
    }

    private fun readAgeFromDatabase(): Int {
        // TODO: Real implementation
        return 0
    }

    private fun updateAgeInDatabase(age: Int) {
        // TODO: Real implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

The problem

age variable is of type String which has the following implications:

  1. Code is obscured. Compare var age: Int with var age: String - first one just feels more correct.
  2. This view model is tightly coupled to the type of UI widget (TextField in this case). Changing the UI widget to e.g. dropdown or slider could possibly force to us to modify view model.
  3. Imagine a form with a few numeric inputs: age, height, number of pets etc. - the Int/String conversion logic will be duplicated all over view model

Solution

First, let's change age field in the view model to Int.

class MyViewModel : ViewModel() {

    var age: Int by mutableStateOf(readAgeFromDatabase())
        private set

    fun onAgeChange(newAge: Int) {
        age = newAge
    }

    fun onUpdateClick() {
        updateAgeInDatabase(age)
    }

    private fun readAgeFromDatabase(): Int {
        // TODO: Real implementation
        return 0
    }

    private fun updateAgeInDatabase(age: Int) {
        // TODO: Real implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we have to update AgeTextField signature by replacing String with Int:

@Composable
private fun AgeTextField(
    age: Int,
    onAgeChange: (Int) -> Unit,
) {
    TextField(
        value = age, // Error here
        onValueChange = onAgeChange, // Error here
        label = { Text("Enter age") },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
        ),
    )
}
Enter fullscreen mode Exit fullscreen mode

Of course it doesn't compile because of type mismatch in the following lines:

value = age,
onValueChange = onAgeChange,
Enter fullscreen mode Exit fullscreen mode

Here starts the tricky part. When I faced this problem, my first naive solution was to add conversion logic inside AgeTextField:

value = age.toString(),
onValueChange = { raw ->
    val parsed = raw.toIntOrNull()
    if (parsed != null) onAgeChange(parsed)
},
Enter fullscreen mode Exit fullscreen mode

But this was wrong: when user tries to clear the input with Backspace, toIntOrNull() returns null so onAgeChange is not called. As a consequence, the text field value stays unchanged.

I've tried also the following logic:

value = age.toString(),
onValueChange = { raw ->
    val parsed = raw.toIntOrNull() ?: 0
    onAgeChange(parsed)
},
Enter fullscreen mode Exit fullscreen mode

Also wrong: when user tries to clear the input, it isn't cleared but its value changes to "0" - still strange and simply incorrect user experience.

At this point I've realized that there is a need to keep raw user input as a String internally, independent from the value from view model:

@Composable
private fun AgeTextField(
    age: Int,
    onAgeChange: (Int) -> Unit,
) {
    var text by remember { mutableStateOf(age.toString()) }
    TextField(
        value = text,
        onValueChange = { raw ->
            text = raw
            val parsed = raw.toIntOrNull() ?: 0
            onAgeChange(parsed)
        },
        label = { Text("Enter age") },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
        ),
    )
}
Enter fullscreen mode Exit fullscreen mode

Turned out I was 50% there.

There was one more problem to solve: the text field is not updated if value in view model is changed from another source.

To test it, I've added a new button to the screen:

Button(onClick = model::onAgeIncrement) {
    Text("Increment by one")
}
Enter fullscreen mode Exit fullscreen mode

and a new method in MyViewModel:

fun onAgeIncrement() {
    age++
}
Enter fullscreen mode Exit fullscreen mode

Age in the text field was not changed after clicking on the new button.

This was quite easy to fix:

var text by remember(age) { mutableStateOf(age.toString()) }
Enter fullscreen mode Exit fullscreen mode

The age argument added to remember method forces the { mutableStateOf(age.toString()) } lambda to be executed each time age in the view model changes.

Conclusion

Here is the final version of our composable:

@Composable
private fun AgeTextField(
    age: Int,
    onAgeChange: (Int) -> Unit,
) {
    var text by remember(age) { mutableStateOf(age.toString()) }
    TextField(
        value = text,
        onValueChange = { raw ->
            text = raw
            val parsed = raw.toIntOrNull() ?: 0
            onAgeChange(parsed)
        },
        label = { Text("Enter age") },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
        ),
    )
}
Enter fullscreen mode Exit fullscreen mode

Discussion (0)