I am using Jetpack Compose, and it has occurred to me that I may be doing this incorrectly.

Suppose we have a screen that allows us to edit a form's data, which has been saved locally using Room. Currently, I follow this rough outline:

In my ViewModel's init block, call repository methods to query local Room Db and collect the results as a flow. Upon the flow change, update the ui state (which is a mutableStateOf inside of the viewModel and observed in the UI).

Now, I am following MVVM and my compose ui pattern is as follows: NavHost -> MyComposableScreen -> MyComposablePage. So we have:

@Composable
fun EditFormScreen(
    viewModel: EditFormScreenViewModel,
    onBackClick: () -> Unit,
    onDoneClick: () -> Unit,
) {
    val uiState = viewModel.uiState

    LaunchedEffect(key1 = uiState) {
        when (uiState.validationEvent) {
            is FormValidationEvent.Initial -> {
                // do nothing
            }
            is FormValidationEvent.Success -> {
                onDoneClick()
            }
        }
    }

    Scaffold(
        topBar = {
            AppBar(
                title = {
                    Text(
                        text = if (viewModel.id == null) {
                            stringResource(id = R.string.add_new_title)
                        } else {
                            stringResource(id = R.string.edit_existing_title)
                        },
                    )
                },
                onBackPressed = onBackClick,
            )
        }
    ) {
        EditFormPage(
            uiState = uiState,
            onEvent = viewModel::onEvent,
        )
    }
}
fun EditFormPage(
    uiState: EditFormPageUiState,
    onEvent: (EditFormUiEvent) -> Unit = {},
) {
    Column(
        modifier = Modifier
            ...
    ) {
        Column(
            modifier = Modifier
                ...
        ) {
            when(uiState.formLoadedState) {
                FormLoadedState.Initial -> {
                    OutlinedInput(
                        label = stringResource(id = R.string.first_name),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnFirstNameChanged(it)
                            )
                        },
                        isError = uiState.isFirstNameError,
                        onNext = { focusManager.moveFocus(FocusDirection.Down) },
                        onDone = {},
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.last_name),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnLastNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.password),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnPasswordChanged(it)
                            )
                        },
                        ...
                    )
                }
                FormLoadedState.Loading -> {
                    LoadingScreen()
                }
                is FormLoadedState.Success -> {
                    OutlinedInput(
                        label = stringResource(id = R.string.first_name),
                        initialValue = uiState.formLoadedState.user.firstName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnFirstNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.last_name),
                        initialValue = uiState.formLoadedState.user.lastName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnLastNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.password),
                        initialValue = uiState.formLoadedState.user.password,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnPasswordChanged(it)
                            )
                        },
                        ...
                    )
                }
            }
        }
        MainButton(
            label = stringResource(id = R.string.main_button_done),
            onClick = {
                focusManager.clearFocus()
                onEvent(EditFormUiEvent.OnDoneClick)
            }
        )
    }
}

My OutlinedInput composable is just a wrapper around OutlinedTextField, and is as follows:

@Composable
fun OutlinedInput(
    modifier: ...,
    label: String,
    initialValue: String? = null,
    textStyle: ...,
    onTextChanged: (String) -> Unit,
    isError: Boolean = false,
    ...
) {
    var text by rememberSaveable { mutableStateOf(initialValue ?: "") }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = it
            onTextChanged(it)
        },
        isError = isError,
        keyboardOptions = keyboardOptions,
        keyboardActions = KeyboardActions(
            onNext = onNext,
            onDone = onDone,
        ),
        textStyle = textStyle,
        label = {
            Text(
                text = label
            )
        },
    )
}

And finally my viewmodel class:

class EditFormScreenViewModel(
    application: Application,
    val id: Int? = null,
    private val userRepository: UserRepository,
    private val coroutineContextProvider: CoroutineContextProvider,
) : AndroidViewModel(application) {
    var uiState: EditFormPageUiState by mutableStateOf(
        EditFormPageUiState()
    )

    init {
        if (id == null) {
            // we are creating a new user
            uiState = uiState.copy(
                user = User(
                    ...
                )
            )
        } else {
            // collect user flow to pre-populate UI fields
            viewModelScope.launch {
                uiState = uiState
                    .copy(
                        formLoadedState = FormLoadedState.Loading
                    )
                withContext(coroutineContextProvider.IO) {
                    collectGetUserByIdFlow(id)
                }
            }
        }
    }

    private suspend fun collectGetUserByIdFlow(id: Int) {
        userRepository.getUserById(id = id)
            .stateIn(viewModelScope)
            .collectLatest(::onGetUserByIdUpdate)
    }

    private suspend fun onGetUserByIdUpdate(user: User) {
        withContext(coroutineContextProvider.Main) {
            uiState = uiState.copy(
                formLoadedState = FormLoadedState.Success(
                    user = user
                )
            )
        }
    }

    /**
     * Manages user form input event & validation
     */
    fun onEvent(uiEvent: EditFormUiEvent) {
        when (uiEvent) {
            is EditFormUiEvent.Initial -> {
                // do nothing
            }
            is EditFormUiEvent.OnFirstNameChanged -> {
                ...
            }
            ...
            is EditFormUiEvent.OnDoneClick -> {
                validateInputs()
            }
        }
    }

    private fun validateInputs() {
        ...

            val hasError = listOf(
                firstNameResult,
                lastNameResult,
                passwordResult,
            ).any { !it.status }
            if(!hasError) {
                viewModelScope.launch {
                    upsertUser(user)
                }
            }
        }
    }

    private suspend fun upsertUser(user: User) {
        userRepository.upsertUser(user = user)
        withContext(coroutineContextProvider.Main) {
            uiState = uiState.copy(
                validationEvent = EditFormUiEvent.Success
            )
        }
    }
}

The above works completely as expected: Arrive at screen -> init view model loads data -> while data is loading shows a progress bar -> when data is done loading, ui state is updated to success and the data is preloaded into the form.

However, I can't help but feel like I am missing a simpler way to achieve this and avoid the repetition in the EditFormPage composable, specifically, referring to this part:

            when(uiState.formLoadedState) {
                FormLoadedState.Initial -> {
                    OutlinedInput(
                        label = stringResource(id = R.string.first_name),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnFirstNameChanged(it)
                            )
                        },
                        isError = uiState.isFirstNameError,
                        onNext = { focusManager.moveFocus(FocusDirection.Down) },
                        onDone = {},
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.last_name),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnLastNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.password),
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnPasswordChanged(it)
                            )
                        },
                        ...
                    )
                }
                FormLoadedState.Loading -> {
                    LoadingScreen()
                }
                is FormLoadedState.Success -> {
                    OutlinedInput(
                        label = stringResource(id = R.string.first_name),
                        initialValue = uiState.formLoadedState.user.firstName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnFirstNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.last_name),
                        initialValue = uiState.formLoadedState.user.lastName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnLastNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.password),
                        initialValue = uiState.formLoadedState.user.password,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnPasswordChanged(it)
                            )
                        },
                        ...
                    )
                }
            }
        }
...

How can I, taking my current structure into account, achieve something where my edit form page instead looks like this? (i.e.: no initial/loading/success states):

                    OutlinedInput(
                        label = stringResource(id = R.string.first_name),
                        initialValue = uiState.user.firstName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnFirstNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.last_name),
                        initialValue = uiState.user.lastName,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnLastNameChanged(it)
                            )
                        },
                        ...
                    )
                    OutlinedInput(
                        label = stringResource(id = R.string.password),
                        initialValue = uiState.user.password,
                        onTextChanged = {
                            onEvent(
                                EditFormUiEvent.OnPasswordChanged(it)
                            )
                        },
                        ...
                    )
              

I would expect the above to work, since initial value in the OutlinedInput can use something uiState.user.firstName, and I would think that once I do this in the viewmodel:

    private suspend fun onGetUserByIdUpdate(user: User) {
        withContext(coroutineContextProvider.Main) {
            uiState = uiState.copy(
                user = user
            )
        }
    }

The OutlinedInput would recompose, and display the updated uiState's user's data. However, this doesn't happen.

1

There are 1 best solutions below

0
On

Please check below code and I think this will help you. A similar implementation

val fullNameText = remember { mutableStateOf("") }

fullNameText.value = state.user.fullName

TextField(                
            label = stringResource(id = R.string.fullname),
            textValue = fullNameText.value,
            onValueChange = { newText ->
                fullNameText.value = newText.trim()
                onEvent(ProfileEditEvent.SetFullName( newText.trim()))
            }
        )

Here we are assaigning this into a mutableState rememberable then set state to it and then reassign. I think somebody will suggest you a better option.