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.
Please check below code and I think this will help you. A similar implementation
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.