3

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?

0

2 Answers 2

4

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.

Sign up to request clarification or add additional context in comments.

2 Comments

I do really need the name input from the UI saved in the view model in the real app. The real app supports creating a new entity, which I don't want to do until the save button is pressed, so I can't save the inputs in the database temporarily. The user might close the form without saving it at all. That adds complex undo logic.
I think you misunderstood, the LaunchedEffect is just an example of how you can involve the view model. If you don't want it, remove it, everything still works perfectly fine without it: "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 [...]." -- Have you tried that - replacing the LaunchedEffect with a Button? Isn't that exactly what you want? There is no complexity and no undo logic necessary. I don't see how it can be any simpler than that.
0

After thinking about it some more, I realized I could split the single input into 2 states: 1 for the state from the database and 1 for the state from the UI.

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

class FormViewModel : ViewModel() {
    // state from the database
    private val initialName = MutableStateFlow("")

    // state from the UI
    private val name = MutableStateFlow("")

    val uiState =
        combine(initialName, name, getExistingNameFromDb()) { initialName, name, existingName ->
            FormUiState(
                // send database state to the UI
                initialName = initialName,
                // use UI state in calculations
                changed = name != existingName,
            )
        }.stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000L),
            FormUiState(initialName = "", changed = false)
        )

    init {
        viewModelScope.launch {
            // Need to update both flows
            val existingName = getExistingNameFromDb().first()
            initialName.value = existingName
            name.value = existingName
        }
    }

    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.initialName) {
        if (uiState.initialName != name.text) {
            name.setTextAndPlaceCursorAtEnd(uiState.initialName)
        }
    }

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

Since the logic in the view model for the real app is much more complicated and uses more database data and more text field inputs than the simplified example, I think it's beneficial to have the latest text field input stored in the view model alongside the data pulled from the database.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.