Jetpack Compose TextField which accepts and emits value other than String
Piotr Chmielowski
Posted on November 14, 2021
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,
)
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,
),
)
}
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
}
}
The problem
age
variable is of type String
which has the following implications:
- Code is obscured. Compare
var age: Int
withvar age: String
- first one just feels more correct. - 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. - 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
}
}
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,
),
)
}
Of course it doesn't compile because of type mismatch in the following lines:
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
:
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:
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:
@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:
Button(onClick = model::onAgeIncrement) {
Text("Increment by one")
}
and a new method in MyViewModel
:
fun onAgeIncrement() {
age++
}
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()) }
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,
),
)
}
Posted on November 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.