Hi everyone. I'm stuck with a problem with TextFields for about 5 months, but have not found a solut...
m
Hi everyone. I'm stuck with a problem with TextFields for about 5 months, but have not found a solution for now. When I write two letters very fast, the first one is never displayed in the textField. Note that I' have use a viewModel and I'm using a Flow to combine all the sources to create the view state. The code is quite simple (this is an example with a screen with a random int generated every second and a TextField):
Copy code
data class AppState(val text: String, val number: Int){
    companion object{
        val Empty = AppState("", 0)
    }
}

class AppViewModel(viewModelScope: CoroutineScope) {
    private val randomNumber = flow{
        while(true) {
            emit(Random.nextInt())
            delay(1000)
        }
    }
    private val textField = MutableStateFlow("")

    val state: StateFlow<AppState> = combine(
        textField,
        randomNumber,
        ::AppState,
    ).stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        initialValue = AppState.Empty,
    )

    fun updateText(text: String){
        textField.value = text
    }
}

@Composable
fun App() {
    val viewModelScope = rememberCoroutineScope()
    val viewModel = remember{AppViewModel(viewModelScope)}
    val viewState by viewModel.state.collectAsState(Dispatchers.Main.immediate)
    Column {
        TextField(viewState.text, onValueChange = viewModel::updateText)
        Text(viewState.number.toString())
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}
If you play the example and press two letters very (very) fast, the first one is omitted. I think that the issue is described in the following post: https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5 For short, there is a race condition and before the TextField is modified after the combine, the second letter is pressed, so the onchange does not contain the first letter. I've tried to look in many examples moving in internet, but most of them do not contain textFields. The only example that I've found that can apply is Tivi from @cb . In this line https://github.com/chrisbanes/tivi/blob/dffc1e9036cad936bc78236ce9e017bed21e7e7b/ui/search/src/main/java/app/tivi/home/search/Search.kt#L142 he uses a secondary state to store the textfield Input, and the textfield value is not updated with the main state. This is the only solution? We need to create a separate state for the textfields? As you can see I'm allready using collectAsState(Dispatchers.Main.immediate) that is one of the solutions that I've found in internet, but does not seem to help at all. Thanks!
z
It’s best to avoid asynchrony altogether with text fields. We’re working on a redesign of how text field state works to make it easier to avoid the pitfalls.
c
tbh I never really understood how/why people used any sort of async operations. I just always used mutableStateOf without issue. But yeah, it seems like enough people misuse it that rethinking it makes sense. I'm pretty excited about that! I ultimately have added debouncing for searching and did fall into a similar question, but still never had issues of chars not showing.
m
@Zach Klippenstein (he/him) [MOD] Great! So the solution for now is using a separated "asynch state" for textFileds? (I've done an example of this and pasted it in the end) @Colton Idle I'm struggling with this when I do, for example an edit form that first load the data of the fields form the DB, then are displayed in the text fields. Moreover the edition of this fields may trigger DB access to do validations. I've tried to fix the example that I posted before using separate sync and async states. Just and edit form that simulates DB access for initial values, plus DB access for validation. If anyone has a better example please share!
Copy code
/**
 * Returns current time milis every second
 */
fun currentTime() = flow{
    while(currentCoroutineContext().isActive) {
        emit(System.currentTimeMillis())
        delay(1000)
    }
}

/**
 * Simulates obtaining user profile from BD
 */
fun getBdUserProfile() = flow{
    delay(2000) //simulate slow DB access
    emit(UserProfile("John Doe", "<mailto:john@example.com|john@example.com>"))
}

/**
 * Simulates cheking if email is allready present in DB
 */
fun alreadyUsedEmail(email :String) = flow{
    delay(300) //simulate slow DB access
    emit(email.startsWith("a"))
}

fun saveUser(userProfile: UserProfile){
    println("Saving: $userProfile")
}

val notDigits = "\\D*".toRegex()

sealed interface EditUserProfileState{
    object LoadingState : EditUserProfileState
    data class UserProfileFormState(val userProfile: UserProfile, val currentMilis: Long, val invalidNameError : Boolean = false, val alreadyUsedEmail: Boolean = false):EditUserProfileState
}

data class UserProfile(val name: String ="", val email: String ="")

class EditUserProfileViewModel(viewModelScope: CoroutineScope) {
    private val currentTime = currentTime()
    private val storedUserProfileFlow = getBdUserProfile()
    private val userProfile = MutableStateFlow<UserProfile?>(null)
    private val alreadyUsedEmail = userProfile.debounce(500).flatMapLatest {
        if(it==null) flow{true}
        else alreadyUsedEmail(it.email)
    }

    init{
        viewModelScope.launch {
            val storedUserProfile = storedUserProfileFlow.first()
            immediateState.value = storedUserProfile
            userProfile.value = storedUserProfile
        }
    }

    val immediateState = mutableStateOf(UserProfile())

    val state: StateFlow<EditUserProfileState> = combine(
        userProfile,
        currentTime,
        alreadyUsedEmail
    ){ userProfile, currentTime, alreadyUsedEmail ->
        if(userProfile==null)
            EditUserProfileState.LoadingState
        else {
            val invalidNameError = !notDigits.matches(userProfile.name)
            EditUserProfileState.UserProfileFormState(userProfile, currentTime, invalidNameError, alreadyUsedEmail)
        }

    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        initialValue = EditUserProfileState.LoadingState,
    )

    fun updateName(name: String){
        immediateState.value = immediateState.value.copy(name = name)
        userProfile.value = immediateState.value
    }

    fun updateEmail(email: String){
        immediateState.value = immediateState.value.copy(email = email)
        userProfile.value = immediateState.value
    }

    fun save(){
        saveUser(immediateState.value)
    }
}

@Composable
@Preview
fun App() {
    val viewModelScope = rememberCoroutineScope()
    val viewModel = remember{EditUserProfileViewModel(viewModelScope)}
    val viewState by viewModel.state.collectAsState(Dispatchers.Main.immediate)
    val userProfileForm by viewModel.immediateState
    val currentUserProfileForm = userProfileForm

    when(val currentViewState = viewState){
        EditUserProfileState.LoadingState -> Text("Loading")
        is EditUserProfileState.UserProfileFormState -> {
            Column {
                if(currentViewState.invalidNameError)
                    Text("Name can't contain numbers")
                TextField(currentUserProfileForm.name, onValueChange = viewModel::updateName)
                if(currentViewState.alreadyUsedEmail)
                    Text("Already used email")
                TextField(currentUserProfileForm.email, onValueChange = viewModel::updateEmail)
                Text(currentViewState.currentMilis.toString())
                Button(onClick = viewModel::save) {
                    Text("Save")
                }
            }
        }
    }

}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}
m
For now till is not fixed i created this workaround and seems to be ok for me:
Copy code
@Composable
fun TextFieldStateful(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    readOnly: Boolean = false,
    textStyle: TextStyle = LocalTextStyle.current,
    label: @Composable (() -> Unit)? = null,
    placeholder: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    isError: Boolean = false,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    keyboardActions: KeyboardActions = KeyboardActions(),
    singleLine: Boolean = false,
    maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
    minLines: Int = 1,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    shape: Shape =
        MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
    colors: TextFieldColors = TextFieldDefaults.textFieldColors()
) {

    var state by remember(value) { mutableStateOf(value) }


    snapshotFlow { state }
        .mapLatest {
            onValueChange(it)
        }
        .stateIn(
            scope = rememberCoroutineScope(),
            started = SharingStarted.Lazily,
            initialValue = state
       ).collectAsState()

    TextField(
        value = state,
        onValueChange = { newValue ->
            state = newValue
        },
        modifier = modifier,
        enabled = enabled,
        readOnly = readOnly,
        textStyle = textStyle,
        label = label,
        placeholder = placeholder,
        leadingIcon = leadingIcon,
        trailingIcon = trailingIcon,
        isError = isError,
        visualTransformation = visualTransformation,
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
        singleLine = singleLine,
        maxLines = maxLines,
        minLines = minLines,
        interactionSource = interactionSource,
        shape = shape,
        colors = colors
    )
}
z
Copy code
collectAsState(Dispatchers.Main.immediate)
You shouldn’t override the dispatcher this sort of thing. Compose’s coroutine scope (both
rememberCoroutineScope
and
LaunchedEffect
) run on the compose dispatcher, which always runs on the main thread and additionally will ensure all continuations are drained before starting recomposition.
123 Views