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: Intwithvar age: String- first one just feels more correct. - This view model is tightly coupled to the type of UI widget (
TextFieldin 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/Stringconversion 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,
),
)
}
Top comments (0)