kotlinandroid-jetpack-composeandroid-compose-textfieldandroid-jetpack-compose-button

Jetpack compose text field cleared when non-compose state is read


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 }
    }
}

Solution

  • I have found the following issues with the original code:

    1. The form fields themselves were never remembered (and so the nested state properties would be replaced with new ones on every render)
    2. The Required class had reference semantics, so field.validators.contains(Required()) would never match
    3. The computation of requiredFieldsNotEmpty() 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.