androidkotlinandroid-jetpack-composeandroid-viewmodel

How to implement bidirectional syncing of text field input between a view model and a composable?


I am using the same view model/composable pair for a creation form and an edit form. In the creation form, the text input starts as empty, but in the edit form, the initial text input comes from the database. In the view model, I am combining input that comes from the database with the current text input to show different states to the user. I created a TextFieldState in the composable to handle the text field input on the UI end and a StateFlow in the view model to handle the input on that side. I'm having trouble syncing the data bidirectionally. The latest value needs to come from the UI but the initial value needs to come from the database. Here's a simplified example:

data class FormUiState(val name: String, val changed: Boolean)

class FormViewModel : ViewModel() {
    private val name = MutableStateFlow("")

    val uiState = combine(name, getExistingNameFromDb()) { name, existingName ->
        FormUiState(
            name = name,
            changed = name != existingName,
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000L),
        FormUiState(name = "", changed = false)
    )

    init {
        viewModelScope.launch {
            name.value = getExistingNameFromDb().first()
        }
    }

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

    private fun getExistingNameFromDb() = flowOf("foo")
}

@Composable
fun FormScreen(modifier: Modifier = Modifier, viewModel: FormViewModel = viewModel()) {
    val name = rememberTextFieldState()
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(name) {
        snapshotFlow { name.text.toString() }.collect { viewModel.updateName(it) }
    }

    LaunchedEffect(uiState.name) {
        if (uiState.name != name.text) {
            name.setTextAndPlaceCursorAtEnd(uiState.name)
        }
    }

    Column(modifier = modifier) {
        OutlinedTextField(state = name, label = { Text("Name") })
        Text(text = "Changed: ${uiState.changed}")
    }
}

With what I have implemented now, I can only get the value to go one way from the UI to the view model. I also tried using a value-based text field, but it causes the updates to the text input in the UI to skip some values. I also tried moving the TextFieldState to the view model, but then I can't run the view model tests.

How do I bidirectionally sync text field input between a view model and a composable?


Solution

  • There is nothing wrong with keeping parts of the state in the composable itself. You do not need to notify the view model about each textfield change, you only need to do that if the view model should actually do something with it.

    Let's assume you want to save whatever was entered into the database, but with a one second delay to prevent unnecessary database writes. Then your view model can be simplified to this:

    class FormViewModel : ViewModel() {
        val uiState: StateFlow<FormUiState?> = getExistingNameFromDb()
            .map {
                FormUiState(initialName = it)
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5.seconds),
                initialValue = null,
            )
    
        fun saveName(name: String) {
            saveNameToDatabase(name)
        }
    }
    

    As you can see, the database value is now only used for the FormUiState's initialName. The name property, as well as changed are removed:

    data class FormUiState(
        val initialName: String,
    )
    

    The composable would then look something like this:

    @Composable
    fun FormScreen(modifier: Modifier = Modifier, viewModel: FormViewModel = viewModel()) {
        val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
    
        if (uiState == null) CircularProgressIndicator()
        else {
            val name = rememberTextFieldState(uiState.initialName)
            val changed = name.text != uiState.initialName
    
            LaunchedEffect(name) {
                snapshotFlow { name.text.toString() }
                    .debounce(1.seconds)
                    .collect {
                        if (name.text != uiState.initialName)
                            viewModel.saveName(it)
                    }
            }
    
            Column(modifier = modifier) {
                OutlinedTextField(state = name, label = { Text("Name") })
                Text(text = "Changed: $changed")
            }
        }
    }
    

    Since displaying the textfield only makes sense when the initial name was loaded from the database (otherwise the user may already enter something which would then be overwritten), a CircularProgressIndicator is displayed in the meantime.

    The initial name is only used once, as the initial value when the TextFieldState is created. changed can be stored in a dedicated variable here, or simply calculated where it is needed. The only reason you need a LaunchedEffect now is to tell the view model that the content of the textfield should be saved to the database. You could replace it with a save button if you want, but the above example automatically saves the content one second after the last change in the textfield (by calling debounce on the flow).

    That's it, the composable now handles the text field changes itself, the view model is only involved when the value should be persisted in the database. This way each component has clear responsibilities, with the composable being the Single Source of Truth for the current text field content, and the database being th SSOT of the initial value.