Jetpack Compose TextField which accepts and emits value other than String
Tags: android, jetpackcompose, compose, kotlin
Published: 2021-11-14 20:28:24

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:

```kotlin
@Composable
private fun AgeTextField(
    age: Int,
    onAgeChange: (Int) -> Unit,
)
```

## Let's code
To start, let's create the screen with age input and a button:
```kotlin
@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,
        ),
    )
}
```
For now `AgeTextField` works on the `String` type and all conversion logic is inside view model:
```kotlin
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
    }
}
```
## 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`.
```kotlin
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
    }
}
```
Now, we have to update `AgeTextField` signature by replacing `String` with `Int`:
```kotlin
@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,
        ),
    )
}
```
Of course it doesn't compile because of type mismatch in the following lines:
```kotlin
value = age,
onValueChange = onAgeChange,
```
Here starts the tricky part. When I faced this problem, my first naive solution was to add conversion logic inside `AgeTextField`:
```kotlin
value = age.toString(),
onValueChange = { raw ->
    val parsed = raw.toIntOrNull()
    if (parsed != null) onAgeChange(parsed)
},
```
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:
```kotlin
value = age.toString(),
onValueChange = { raw ->
    val parsed = raw.toIntOrNull() ?: 0
    onAgeChange(parsed)
},
```
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:
```kotlin
@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,
        ),
    )
}
```
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:
```kotlin
Button(onClick = model::onAgeIncrement) {
    Text("Increment by one")
}
```
and a new method in `MyViewModel`:
```kotlin
fun onAgeIncrement() {
    age++
}
```
Age in the text field was not changed after clicking on the new button.

This was quite easy to fix:
```kotlin
var text by remember(age) { mutableStateOf(age.toString()) }
```
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:
```kotlin
@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,
        ),
    )
}
```