I've made a form in which I can create or edit cards. I'm using Kotlin + Jetpack Compose + Dagger Hilt + Room. It works OK, except for one thing. When I turn the screen without saving the record, I lose the modified data. Example: if I'm editing the card with title "X", and I change its title to "XYZ", and I turn the screen, the title goes back to "X".
I'm using sealed classes for the state.
This is the state of the screen:
@Parcelize
sealed class CardUiState: Parcelable {
@Parcelize
data object Loading: CardUiState()
@Parcelize
data class Creation(
val titlePrefix: String = "",
val title: String = "",
val titleSuffix: String = "",
val mainCategories: List<Category> = emptyList(),
val selectedMainCategories: List<Category> = emptyList(),
val timeCategories: List<Category> = emptyList(),
val selectedTimeCategories: List<Category> = emptyList(),
val error: Boolean = false,
val errorMessageResId: Int? = null,
) : CardUiState()
@Parcelize
data class Edition(
val card: Card,
val titlePrefix: String = card.titlePrefix,
val title: String = card.title,
val titleSuffix: String = card.titleSuffix,
val mainCategories: List<Category> = emptyList(),
val selectedMainCategories: List<Category> = emptyList(),
val timeCategories: List<Category> = emptyList(),
val selectedTimeCategories: List<Category> = emptyList(),
val error: Boolean = false,
val errorMessageResId: Int? = null,
): CardUiState()
@Parcelize
data class Error(
val message: String
): CardUiState()
}
This is the view model (it's much bigger but I'm only pasting the relevant code):
@HiltViewModel
class CardViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val cardRepository: CardRepository,
private val categoryRepository: CategoryRepository,
private val cardCategoryRelRepository: CardCategoryRelRepository,
@ApplicationContext private val context: Context
): ViewModel() {
private val _uiState = MutableStateFlow<CardUiState>(CardUiState.Loading)
val uiState: StateFlow<CardUiState> = _uiState.asStateFlow()
init {
val savedState = savedStateHandle.get<CardUiState>("uiState")
if (savedState != null) {
_uiState.value = savedState
}
}
private fun setState(uiState: CardUiState) {
_uiState.value = uiState
savedStateHandle["uiState"] = uiState
}
fun getCard(cardId: Int?) {
viewModelScope.launch {
try {
setState(CardUiState.Loading)
val categories = categoryRepository.getCategories().first()
if (cardId != null) {
val cardWithCategories = cardRepository.getCardWithCategories(cardId).first()
setState(CardUiState.Edition(
card = cardWithCategories.card,
selectedMainCategories = cardWithCategories.categories.filter { it.type == "main" },
mainCategories = categories.filter { it.type == "main" },
selectedTimeCategories = cardWithCategories.categories.filter { it.type == "time" },
timeCategories = categories.filter { it.type == "time" },
))
} else {
setState(CardUiState.Creation(
mainCategories = categories.filter { it.type == "main" },
timeCategories = categories.filter { it.type == "time" },
))
}
} catch (e: Exception) {
setState(CardUiState.Error(
e.message ?: context.getString(R.string.msg_unknown_error)
))
}
}
}
fun onChangeTitle(title: String) {
val currentState = _uiState.value
if (currentState is CardUiState.Creation) {
setState(currentState.copy(
title = title,
error = false,
errorMessageResId = null,
))
} else if (currentState is CardUiState.Edition) {
setState(currentState.copy(
title = title,
error = false,
errorMessageResId = null,
))
}
}
...
This is the screen (it's much bigger but I'm only pasting peaces of the relevant code):
@Composable
fun CardScreen(
modifier: Modifier = Modifier,
titleResId: Int,
viewModel: CardViewModel = hiltViewModel(),
canNavigateBack: Boolean = false,
navigateUp: () -> Unit,
cardId: Int? = null,
) {
LaunchedEffect(cardId) {
viewModel.getCard(cardId)
}
val uiState by viewModel.uiState.collectAsState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
CardScreenContent(
modifier = modifier,
titleResId = titleResId,
uiState = uiState,
scrollBehavior = scrollBehavior,
canNavigateBack = canNavigateBack,
navigateUp = navigateUp,
onTitleChange = { viewModel.onChangeTitle(it) },
onTitlePrefixChange = { viewModel.onChangeTitlePrefix(it) },
onTitleSuffixChange = { viewModel.onChangeTitleSuffix(it) },
onCategoryAdd = { category ->
viewModel.addCategoryToSelection(category)
},
onCategoryRemove = { category ->
viewModel.removeCategoryFromSelection(category)
},
onSave = {
if (uiState is CardUiState.Creation) {
val selectedCategories = (
uiState as CardUiState.Creation
).selectedMainCategories.plus((
uiState as CardUiState.Creation
).selectedTimeCategories)
viewModel.save(
title = (uiState as CardUiState.Creation).title,
titlePrefix = (uiState as CardUiState.Creation).titlePrefix,
titleSuffix = (uiState as CardUiState.Creation).titleSuffix,
selectedCategories = selectedCategories,
)
} else if (uiState is CardUiState.Edition) {
val selectedCategories = (
uiState as CardUiState.Edition
).selectedMainCategories.plus((
uiState as CardUiState.Edition
).selectedTimeCategories)
viewModel.save(
title = (uiState as CardUiState.Edition).title,
titlePrefix = (uiState as CardUiState.Edition).titlePrefix,
titleSuffix = (uiState as CardUiState.Edition).titleSuffix,
selectedCategories = selectedCategories,
)
}
},
)
}
...
when (uiState) {
is CardUiState.Loading -> CircularProgressIndicator()
is CardUiState.Error -> ErrorDialog(
text = uiState.message,
onDismiss = navigateUp
)
is CardUiState.Creation -> CardForm(
title = uiState.title,
titlePrefix = uiState.titlePrefix,
titleSuffix = uiState.titleSuffix,
mainCategories = uiState.mainCategories,
selectedMainCategories = uiState.selectedMainCategories,
timeCategories = uiState.timeCategories,
selectedTimeCategories = uiState.selectedTimeCategories,
onTitleChange = onTitleChange,
onTitlePrefixChange = onTitlePrefixChange,
onTitleSuffixChange = onTitleSuffixChange,
onCategoryAdd = onCategoryAdd,
onCategoryRemove = onCategoryRemove,
error = uiState.error,
errorMessageResId = uiState.errorMessageResId,
)
is CardUiState.Edition -> CardForm(
title = uiState.title,
titlePrefix = uiState.titlePrefix,
titleSuffix = uiState.titleSuffix,
mainCategories = uiState.mainCategories,
selectedMainCategories = uiState.selectedMainCategories,
timeCategories = uiState.timeCategories,
selectedTimeCategories = uiState.selectedTimeCategories,
onTitleChange = onTitleChange,
onTitlePrefixChange = onTitlePrefixChange,
onTitleSuffixChange = onTitleSuffixChange,
onCategoryAdd = onCategoryAdd,
onCategoryRemove = onCategoryRemove,
error = uiState.error,
errorMessageResId = uiState.errorMessageResId,
)
}
...
@Composable
fun CardForm(
title: String = "",
titlePrefix: String = "",
titleSuffix: String = "",
mainCategories: List<Category> = emptyList(),
selectedMainCategories: List<Category> = emptyList(),
timeCategories: List<Category> = emptyList(),
selectedTimeCategories: List<Category> = emptyList(),
onTitleChange: (String) -> Unit = {},
onTitlePrefixChange: (String) -> Unit = {},
onTitleSuffixChange: (String) -> Unit = {},
onCategoryAdd: (Category) -> Unit = {},
onCategoryRemove: (Category) -> Unit = {},
error: Boolean = false,
errorMessageResId: Int? = null,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.BottomCenter
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = titlePrefix,
onValueChange = onTitlePrefixChange,
label = { Text(stringResource(id = R.string.text_field_title_prefix)) },
singleLine = true,
)
...
EDIT
These are the elements of the NavHost which call the list of cards screen (Cards) and the card form to create/edit (CardDetail):
composable(route = NavScreen.Cards.route) {
CardsScreen(
titleResId = NavScreen.Cards.titleResId,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() },
onCardUpdateButtonClicked = { cardId ->
navController.navigate(
NavScreen.CardDetail.createRoute(cardId.toString())
)
},
onCardInsertButtonClicked = { navController.navigate(NavScreen.CardDetail.route) },
)
}
composable(
route = NavScreen.CardDetail.route,
arguments = NavScreen.CardDetail.navArguments
) { backStackEntry ->
val cardId = backStackEntry.arguments?.getString("cardId")
CardScreen(
titleResId = NavScreen.CardDetail.titleResId,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() },
cardId = cardId?.toIntOrNull(),
)
}
So, as we already established in the comments, the problem is calling viewModel.getCard
from LaunchedEffect
, which is invoked when you rotate screen and will reload card data from repository and replace the data you have in SavedStateHandle
.
Since you use androidx navigation, you can get the navigation arguments from the SavedStateHandle
inside of your viewmodel, see the documentation. Also note that you can conveniently getStateFlow
directly from the SavedStateHandle
, so you don't have to update two places separately. The resulting code can look like this:
class CardViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val uiState = savedStateHandle.getStateFlow<CardUiState>("uiState", CardUiState.Loading)
init {
if (uiState.value == CardUiState.Loading) {
// CardUiState was not saved in savedStateHandle, you have to load data
viewModelScope.launch {
// get the id from you navigation arguments
val cardId = savedStateHandle.toRoute<YourRoute>.cardId
// load data from repository
setState(getCard(cardId))
}
}
}
private suspend fun getCard(cardId: Int?): CardUiState {
// ...
}
private fun setState(uiState: CardUiState) {
savedStateHandle["uiState"] = uiState
}
// the rest stays the same:
fun onChangeTitle(title: String) {
val currentState = uiState.value
val newState = ...
setState(newState)
}
}
From the compose code, you will just observe uiState
and update data with methods like onChangeTitle
. Loading data (getCard) is responsibility of ViewModel
.