I have a Jetpack compose form, created from this tutorial. It shows errors nicely by validating the form when you click the submit button. I want the button on my form to be dynamically enabled, using the same form state.
The issue is that state.requiredFieldsNotEmpty()
appears to reset the mutable state text variable whenever it is called. Meaning typing does nothing because it's instantly cleared. Why? All it does is read the state variable, no writing. Specifically calling this line field.text.length < it.length
triggers the bug and clears field.text.
Relevant code:
val state by remember {
mutableStateOf(FormState())
}
Column {
Form(
state = state,
fields = listOf(
Field(name = "pan",
placeholderHint = R.string.card_number,
validators = listOf(Required(), MinLength(length = Constants.MINIMUM_PAN_LENGTH))),
)
)
Row {
TextButton(
onClick = { if (state.validate()) submitData(state.getData()) },
enabled = state.requiredFieldsNotEmpty(),
) {
Text(
text = stringResource(id = R.string.confirm),
)
}
}
}
Form.kt:
@Composable
fun Form(
modifier: Modifier,
state: FormState,
fields: List<Field>) {
state.fields = fields
Column(modifier = modifier.padding(horizontal = 16.dp)) {
fields.forEach {
it.Content(modifier)
}
}
}
class FormState {
var fields: List<Field> = listOf()
set(value) {
field = value
}
fun validate(): Boolean {
var valid = true
for (field in fields) if (!field.validate()) {
valid = false
break
}
return valid
}
fun requiredFieldsNotEmpty(): Boolean {
for (field in fields){
if(field.validators.contains(Required()) && field.text.isBlank()){
return false
}
val minLength = field.validators.find { it is MinLength } as MinLength?
minLength?.let {
if(field.text.length < it.length){
return false
}
}
}
return true
}
fun getData(): Map<String, String> = fields.map { it.name to it.text }.toMap()
}
Validators.kt
sealed interface Validator
open class Email(var message: Int = emailMessage) : Validator
open class NotExpired(var message: Int = expiryMessage) : Validator
open class Required(var message: Int = requiredMessage) : Validator
open class MinLength(val length: Int, var message: Int = minLengthMessage): Validator
Field.kt
class Field(
val name: String,
val placeholderHint: Int = R.string.empty,
val error: Int = R.string.empty,
val singleLine: Boolean = true,
val keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
val validators: List<Validator>
) {
var text: String by mutableStateOf("")
var supportingError: Int by mutableStateOf(error)
var minLength: Int by mutableStateOf(-1)
var hasError: Boolean by mutableStateOf(false)
private fun showError(error: Int) {
hasError = true
supportingError = error
}
private fun showMinLengthError(error: Int, length: Int) {
hasError = true
minLength = length
supportingError = error
}
private fun hideError() {
supportingError = error
minLength = -1
hasError = false
}
@Composable
fun Content(
modifier: Modifier,
) {
OutlinedTextField(value = text, isError = hasError, supportingText = {
Text(text = stringResource(id = supportingError))
}, singleLine = singleLine, keyboardOptions = keyboardOptions, placeholder = {
Text(
text = stringResource(id = placeholderHint),
)
}, modifier = modifier
.fillMaxWidth()
.padding(10.dp), onValueChange = { value ->
hideError()
text = value
})
}
fun validate(): Boolean {
return validators.map {
when (it) {
is Email -> {
if (!Patterns.EMAIL_ADDRESS.matcher(text).matches()) {
showError(it.message)
return@map false
}
true
}
is Required -> {
if (text.isEmpty()) {
showError(it.message)
return@map false
}
true
}
is NotExpired -> {
val month = text.substring(0, 2).trimStart('0').toInt()
val year = text.substring(2, 4).trimStart('0').toInt()
val now = LocalDate.now()
if ((year < now.year) || (year == now.year && month < now.monthValue)) {
showError(it.message)
return@map false
}
true
}
is MinLength -> {
if (text.length < it.length) {
showMinLengthError(it.message, it.length)
return@map false
}
true
}
}
}.all { it }
}
}
I have found the following issues with the original code:
Required
class had reference semantics, so field.validators.contains(Required())
would never matchrequiredFieldsNotEmpty()
depended on state (due to the declaration var text: String by mutableStateOf("")
), but was not wrapped in derivedStateOf
The following adjustments:
sealed interface Validator
open class Email(var message: String = EMAIL_MESSAGE): Validator
data class Required(var message: String = REQUIRED_MESSAGE): Validator
open class Regex(var message: String, var regex: String = REGEX_MESSAGE): Validator
open class MinLength(val length: Int, var message: String = MIN_LENGTH_MESSAGE): Validator
@Preview
@Composable
fun Screen(){
val fields = remember {
listOf(
Field(name = "username", validators = listOf(Required(), MinLength(length = 3))),
Field(name = "email", validators = listOf(Required(), Email()))
)
}
val state by remember { mutableStateOf(FormState()) }
val buttonEnabled by remember { derivedStateOf { state.requiredFieldsNotEmpty() } }
Column {
Form(
state = state,
fields = fields
)
Button(
enabled = buttonEnabled,
onClick = { if (state.validate()) toast("Our form works!") }) {
Text("Submit")
}
}
}
fixed the issue.