I'm developing an Android app where I have a bottom bar. I am using Jetpack Compose, hilt and ViewModel. I have a ViewModel (ShopListHomeViewModel) that manages the state of a screen (ShopListHomeScreen). However, when I switch tabs and return to the screen, the ViewModel's state resets, causing me to lose data and screen goes to loading again.
Can you guys help me what is missing and wrong? I want to keep the data even tho user switch tabs because I don't want to go for loading everytime, I will do that when I only refresh by pull and creating new list. I am adding code and video. Thanks
Codes:
data class ShopListHomeState(
val isLoading: Boolean = true,
val name: String = "Test",
val surname: String = "User"
)
@HiltViewModel
internal class ShopListHomeViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow(ShopListHomeState())
val state: StateFlow<ShopListHomeState> = _state.asStateFlow()
var counter: Int = 0
init {
viewModelScope.launch {
delay(3000)
_state.update { it.copy(isLoading = false) }
}
}
fun handleEvent(event: ShopListHomeEvents) {
viewModelScope.launch {
when (event) {
is ShopListHomeEvents.CreateNewListClicked -> {
_state.update { it.copy(name = counter++.toString()) }
}
}
}
}
}
and screen itself:
@Composable
internal fun ShopListHomeScreen() {
val viewModel: ShopListHomeViewModel = hiltViewModel()
val state = viewModel.state.collectAsState()
if (state.value.isLoading) {
ScLoadingScreen()
} else {
ShopListHomeScreenContent(state.value, viewModel)
}
}
@Composable
internal fun ShopListHomeScreenContent(
state: ShopListHomeState,
viewModel: ShopListHomeViewModel
) {
Column(modifier = Modifier.padding(16.dp)) {
ScCardItem(
modifier = Modifier.fillMaxWidth(),
onClick = {}
) {
Box(
modifier = Modifier
.padding(32.dp)
.fillMaxWidth(),
contentAlignment = Alignment.TopStart,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement
.spacedBy(
space = 16.dp,
alignment = Alignment.Start
),
) {
Text(text = "Family List", style = ScTheme.typography.titleMedium)
Text(text = "Created by ${state.name}", style = ScTheme.typography.bodySmall)
}
}
}
Spacer(modifier = Modifier.weight(1f))
ScPrimaryButton(text = "Create New List", onClick = {
viewModel.handleEvent(ShopListHomeEvents.CreateNewListClicked)
})
}
}
Other Codes about navigation and settings:
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
startDestination: String = NavigationItem.Home.route,
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination,
enterTransition = {
EnterTransition.None
},
exitTransition = {
ExitTransition.None
}
) {
composable(NavigationItem.Home.route) {
ShopListHomeScreen()
}
composable(NavigationItem.Settings.route) {
SettingsHomeScreen()
}
composable(NavigationItem.CreateListScreen.route) {
ShopListCreateScreen(navController)
}
}
}
@Composable
internal fun SettingsHomeScreen() {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Setting List", style = ScTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
}
}
enum class Screen {
Home,
Settings,
CreateListScreen
}
sealed class NavigationItem(val route: String) {
object Home : NavigationItem(Screen.Home.name)
object Settings : NavigationItem(Screen.Settings.name)
object CreateListScreen : NavigationItem(Screen.CreateListScreen.name)
}
@Composable
fun TabView(tabBarItems: List<TabBarItem>, navController: NavController) {
var selectedTabIndex by rememberSaveable {
mutableStateOf(0)
}
NavigationBar {
tabBarItems.forEachIndexed { index, tabBarItem ->
NavigationBarItem(
colors = NavigationBarItemColors(
selectedIconColor= ScTheme.colors.neutrals.black200TextPrimary,
selectedTextColor = ScTheme.colors.neutrals.black200TextPrimary,
selectedIndicatorColor = Color.Transparent,
unselectedIconColor = ScTheme.colors.neutrals.grey200TitleSecondary,
unselectedTextColor = ScTheme.colors.neutrals.grey200TitleSecondary,
disabledIconColor = ScTheme.colors.neutrals.grey200TitleSecondary,
disabledTextColor = ScTheme.colors.neutrals.grey200TitleSecondary,
),
selected = selectedTabIndex == index,
onClick = {
selectedTabIndex = index
navController.navigate(tabBarItem.title)
},
icon = {
TabBarIconView(
isSelected = selectedTabIndex == index,
selectedIcon = tabBarItem.selectedIcon,
unselectedIcon = tabBarItem.unselectedIcon,
title = tabBarItem.title,
)
},
label = { Text(tabBarItem.title) })
}
}
}
@Composable
fun TabBarIconView(
isSelected: Boolean,
selectedIcon: ImageVector,
unselectedIcon: ImageVector,
title: String,
badgeAmount: Int? = null
) {
BadgedBox(badge = { TabBarBadgeView(badgeAmount) }) {
Icon(
imageVector = if (isSelected) {
selectedIcon
} else {
unselectedIcon
},
contentDescription = title
)
}
}
So after researching for hours I found a way I don't know if it is the perfect solution but it works. Here it is:
I found two ways, First one is this:
At my navhost, I created a variable
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
and when navigating to screens I passed my viewmodel from here like this:
composable(NavigationItem.Home.route) {
val viewModel = hiltViewModel<ShopListHomeViewModel>(viewModelStoreOwner = viewModelStoreOwner)
ShopListHomeScreen(viewModel)
}
composable(NavigationItem.Settings.route) {
SettingsHomeScreen()
}
composable(NavigationItem.CreateListScreen.route) {
ShopListCreateScreen(navController)
}
Second solution came from @ianhanniballake He showed that doc was explaining this problem in here
Only addition needed was to handle states in my BottomNavigationBar.
I added these to on click of bottom navigation items and fixed my issue
onClick = {
selectedTabIndex = index
navController.navigate(tabBarItem.title){
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},