compose-multiplatform
                                
                                 compose-multiplatform copied to clipboard
                                
                                    compose-multiplatform copied to clipboard
                            
                            
                            
                        Support saving state for nested `NavHostController`
Describe the bug iOS doesn't save state when moving between bottom bar items
Affected platforms
- iOS
Versions
- Kotlin version*: 1.9.23
- Compose Multiplatform version*: 1.6.10-dev1593
Video https://github.com/JetBrains/compose-multiplatform/assets/9390550/08ae3ca8-f302-4fe8-8029-bce68ea07696
Some code for reference
Scaffold(
    bottomBar = {
        BottomNavigation {
            BottomNavigationItem(
                icon = {
                    Icon(
                        Icons.Default.Home,
                        contentDescription = BottomNavigationItems.HOME.name
                    )
                },
                label = { Text(BottomNavigationItems.HOME.name) },
                selected = BottomNavigationItems.HOME == selectedScreen,
                onClick = {
                    selectedScreen = BottomNavigationItems.HOME
                    navController.navigate(
                        route = BottomNavigationItems.HOME.name
                    ) {
                        navController.graph.startDestinationRoute?.let {
                            popUpTo(it) {
                                saveState = true
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    }
                },
                modifier = Modifier.padding(0.dp)
            )
            BottomNavigationItem(
                icon = {
                    Icon(
                        Icons.Default.AccountCircle,
                        contentDescription = BottomNavigationItems.PROFILE.name
                    )
                },
                label = { Text(BottomNavigationItems.PROFILE.name) },
                selected = BottomNavigationItems.PROFILE == selectedScreen,
                onClick = {
                    selectedScreen = BottomNavigationItems.PROFILE
                    navController.navigate(
                        route = BottomNavigationItems.PROFILE.name
                    ) {
                        navController.graph.startDestinationRoute?.let {
                            popUpTo(it) {
                                saveState = true
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    }
                },
                modifier = Modifier.padding(0.dp)
            )
        }
    },
    content = {
        NavHost(
            navController = navController,
            startDestination = BottomNavigationItems.HOME.name
        ) {
            composable(BottomNavigationItems.HOME.name) {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    ClickNavigation()
                }
            }
            composable(BottomNavigationItems.PROFILE.name) {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text("PROFILE")
                }
            }
        }
    }
)
Additional context I am quite new with Multiplatform, but I am trying to do everything in the common code without ever touching the platform based implementations
It should work just fine:
https://github.com/JetBrains/compose-multiplatform/assets/1836384/a279376c-2edf-4ad4-9487-566d2fc1fc88
Tested with: Compose 1.6.10-beta03, Navigation 2.7.0-alpha03 I don't see a version of the navigation library in the description, so the recommendation is quite common: use the latest version instead of early dev. There were a few related changes, but I'm not entirely sure which one exactly fixed this case.
Closing as it works on the latest version
I am using
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha02") and compose = "1.6.10-beta03"
But I get the same result. @MatkovIvan Can you share your codebase for this working example so I can take a look?
I've noticed another issue which is probably related. Opening a screen of the first tab and then switching tabs results to screen state and the whole screen stays the same while iOS is being set to the default screen and state:
https://github.com/JetBrains/compose-multiplatform/assets/9390550/7f7a07e7-7549-4d68-bdc5-65d033e0b88b
commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
            //Moko mvvm
            api("dev.icerock.moko:mvvm-core:0.16.1")
            api("dev.icerock.moko:mvvm-compose:0.16.1")
            //Kamel
            implementation("media.kamel:kamel-image:0.9.4")
            //Ktor
            implementation("io.ktor:ktor-client-core:2.3.10")
            implementation("io.ktor:ktor-client-content-negotiation:2.3.10")
            implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.10")
            //Kotlinx serialization
            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
            //Koin
            implementation("io.insert-koin:koin-core:3.5.6")
            implementation("io.insert-koin:koin-compose:1.1.5")
            //Navigation
            implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha02")
        }
[versions]
agp = "8.2.0"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
androidx-activityCompose = "1.9.0"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.13.0"
androidx-espresso-core = "3.5.1"
androidx-material = "1.11.0"
androidx-test-junit = "1.1.5"
compose = "1.6.10-beta03"
compose-plugin = "1.6.2"
junit = "4.13.2"
kotlin = "1.9.23"
kotlinxDatetime = "0.5.0"
@Composable
fun App() {
    TobTheme {
        BottomNavigation()
    }
}
private enum class BottomNavigationItems {
    HOME,
    PROFILE,
}
@Composable
fun BottomNavigation() {
    val navController = rememberNavController()
    var selectedScreen by remember { mutableStateOf(BottomNavigationItems.HOME) }
    Scaffold(
        bottomBar = {
            BottomNavigation {
                BottomNavigationItem(
                    icon = {
                        Icon(
                            Icons.Default.Home,
                            contentDescription = BottomNavigationItems.HOME.name
                        )
                    },
                    label = { Text(BottomNavigationItems.HOME.name) },
                    selected = BottomNavigationItems.HOME == selectedScreen,
                    onClick = {
                        selectedScreen = BottomNavigationItems.HOME
                        navController.navigate(
                            route = BottomNavigationItems.HOME.name
                        ) {
                            navController.graph.startDestinationRoute?.let {
                                popUpTo(it) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    },
                    modifier = Modifier.padding(0.dp)
                )
                BottomNavigationItem(
                    icon = {
                        Icon(
                            Icons.Default.AccountCircle,
                            contentDescription = BottomNavigationItems.PROFILE.name
                        )
                    },
                    label = { Text(BottomNavigationItems.PROFILE.name) },
                    selected = BottomNavigationItems.PROFILE == selectedScreen,
                    onClick = {
                        selectedScreen = BottomNavigationItems.PROFILE
                        navController.navigate(
                            route = BottomNavigationItems.PROFILE.name
                        ) {
                            navController.graph.startDestinationRoute?.let {
                                popUpTo(it) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    },
                    modifier = Modifier.padding(0.dp)
                )
            }
        },
        content = {
            NavHost(
                navController = navController,
                startDestination = BottomNavigationItems.HOME.name
            ) {
                composable(BottomNavigationItems.HOME.name) {
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        ClickNavigation()
                    }
                }
                composable(BottomNavigationItems.PROFILE.name) {
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text("PROFILE")
                    }
                }
            }
        }
    )
}
@Composable
fun ClickNavigation() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = MainNavHostDestinations.Routes.HOME
    ) {
        composable(
            route = MainNavHostDestinations.Routes.HOME
        ) {
            HomeScreen(
                onArticleClicked = {
                    navController.openArticle(articleTitle = it)
                }
            )
        }
        composable(
            route = MainNavHostDestinations.Routes.ARTICLE,
            arguments = listOf(
                navArgument(name = MainNavHostDestinations.ArticleArgs.TITLE) {
                    type = NavType.StringType
                }
            )
        ) {
            DetailedArticle(
                title = it.arguments?.getString(MainNavHostDestinations.ArticleArgs.TITLE),
                onNavigateBack = {
                    navController.navigateBack()
                }
            )
        }
    }
}
@OptIn(ExperimentalResourceApi::class, ExperimentalMaterialApi::class)
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = koinInject(),
    onArticleClicked: (String) -> Unit,
) {
    val uiState by viewModel.uiState.collectAsState()
    LazyColumn(content = {
        items(uiState.articles) {
            Card(backgroundColor = Color.LightGray,
                modifier = Modifier.padding(all = 4.dp).fillMaxWidth().height(300.dp),
                onClick = {
                    onArticleClicked(it.title)
                }) {
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(it.title)
                    if (it.urlToImage.isNullOrEmpty()) {
                        Image(
                            painter = painterResource(Res.drawable.compose_multiplatform),
                            null,
                        )
                    } else {
                        KamelImage(resource = asyncPainterResource(it.urlToImage),
                            contentDescription = "",
                            contentScale = ContentScale.FillWidth,
                            onLoading = { CircularProgressIndicator(it) },
                            onFailure = {
                                Column {
                                    Text(
                                        text = "Failed to load",
                                        fontWeight = FontWeight.Bold,
                                    )
                                }
                            })
                    }
                }
            }
        }
    })
}
2.8.0-alpha02
It was explicitly reverted to 2.7 branch to avoid compatibility issues - we're using the original Google's binary on Android and 2.8 introduces dependency on Compose 1.7. It causes switching to Compose 1.7 alpha on Android and mismatching with common code. Please use 2.7 until Compose Multiplatform 1.7. It's not "older"
My testing code is based on yours:
private enum class BottomNavigationItems {
    HOME,
    PROFILE,
}
@Composable
fun App() {
    var selectedScreen by remember { mutableStateOf(BottomNavigationItems.HOME) }
    val navController = rememberNavController()
    Scaffold(
        bottomBar = {
            BottomNavigation {
                BottomNavigationItem(
                    icon = {
                        Icon(
                            Icons.Default.Home,
                            contentDescription = BottomNavigationItems.HOME.name
                        )
                    },
                    label = { Text(BottomNavigationItems.HOME.name) },
                    selected = BottomNavigationItems.HOME == selectedScreen,
                    onClick = {
                        selectedScreen = BottomNavigationItems.HOME
                        navController.navigate(
                            route = BottomNavigationItems.HOME.name
                        ) {
                            navController.graph.startDestinationRoute?.let {
                                popUpTo(it) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    },
                    modifier = Modifier.padding(0.dp)
                )
                BottomNavigationItem(
                    icon = {
                        Icon(
                            Icons.Default.AccountCircle,
                            contentDescription = BottomNavigationItems.PROFILE.name
                        )
                    },
                    label = { Text(BottomNavigationItems.PROFILE.name) },
                    selected = BottomNavigationItems.PROFILE == selectedScreen,
                    onClick = {
                        selectedScreen = BottomNavigationItems.PROFILE
                        navController.navigate(
                            route = BottomNavigationItems.PROFILE.name
                        ) {
                            navController.graph.startDestinationRoute?.let {
                                popUpTo(it) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    },
                    modifier = Modifier.padding(0.dp)
                )
            }
        },
        content = {
            NavHost(
                navController = navController,
                startDestination = BottomNavigationItems.HOME.name
            ) {
                composable(BottomNavigationItems.HOME.name) {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center,
                    ) {
                        ClickNavigation()
                    }
                }
                composable(BottomNavigationItems.PROFILE.name) {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center,
                    ) {
                        Text("PROFILE")
                    }
                }
            }
        }
    )
}
@Composable
private fun ClickNavigation() {
        val lazyListState = rememberLazyListState()
        LazyColumn(state = lazyListState) {
            items(99) {
                val color = when (it % 7) {
                    0 -> Color.Red
                    1 -> Color.Blue
                    2 -> Color.Green
                    3 -> Color.Yellow
                    4 -> Color.Magenta
                    5 -> Color.Gray
                    6 -> Color.Cyan
                    else -> Color.Transparent
                }
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Box(modifier = Modifier.size(40.dp).background(color))
                    Spacer(modifier = Modifier.width(12.dp))
                    BasicText("Item number $it")
                }
            }
        }
}
There was a problem with my nested NavGraphs. Joining both into one helped. Thanks for your help!
I am receiving errors when running android on Navigation 2.7.0-alpha03 :
Caused by: org.gradle.api.internal.artifacts.ivyservice.DefaultLenientConfiguration$ArtifactResolveException: Could not resolve all files for configuration ':composeApp:debugRuntimeClasspath'.
This issue still persists though:
https://github.com/JetBrains/compose-multiplatform/assets/9390550/979f386c-e6ad-4f23-a094-c3599bcc3600
Looking at nested NavHost case (btw nested graphs don't require multiple NavHost/NavHostControllers)
Caused by: org.gradle.api.internal.artifacts.ivyservice.DefaultLenientConfiguration$ArtifactResolveException: Could not resolve all files for configuration ':composeApp:debugRuntimeClasspath'.
Cannot say anything without reproduction, but it doesn't look related to navigation
I have made it a public repo - https://github.com/slikasgiedrius/Tob
Investigation regarding state: save/restoring state works, but keys that are based on composition-key-hash are different. For now, I'm not sure that there is a guarantee that it will be the same even on Android. Trying to find why they are changed
Ok, It's about restoring the state of nested NavHostControllers - it's the limitation for these first alpha releases. Sorry, I didn't match the case with that unimplemented TODO.
It's under TODO for future versions because it requires serialization of structures based on Android's Parcelable that isn't ported to multiplatform yet.
Keeping this issue open to track this
Workaround 1: Use single NavHostController and define nested graphs via NavGraphBuilder.navigation() function. See documentation
Workaround 2: Move second rememberNavController() call out of wiped composition.
Regarding versions: I've prepared the fix https://github.com/slikasgiedrius/Tob/pull/1
It was a misusage of the version constants. The issue for renaming these constants in the template is tracked here: https://youtrack.jetbrains.com/issue/KT-66613
I see some iOS build issues after the PR of the changes
It looks like https://youtrack.jetbrains.com/issue/KT-61205 that should be already fixed. Looking why it's still here
Yeah I have just discovered that it's related to K2 (Which I have enabled today) :D
Everything works like a charm. Only K2 compiler issue left.
Ok, it was because K2 experimental mode in Kotlin 1.9. It's already fixed, I've updated Kotlin to 2.0.0-RC2 in your project - https://github.com/slikasgiedrius/Tob/pull/2
Everything works perfectly now, THANK YOU SO MUCH for your massive help. I'm loving it! Closing this issue
org.jetbrains.compose.resources.MissingResourceException: Missing resource with path
It's tracked in #4720 What's a chain of unrelated bugs! 🫠
Hey! Sorry for commenting on a closed thread, I have the same situation with slikasgiedrius, from the second part (the one where you renamed the thread to "support saving state for nested NavController")
On Android it works perfectly, but on IOS it does not. I tried using the workarounds you provided, but I still can't get it to work.
here is my code: navigation.kt
object BaseRoutes {
    const val home = "home"
    const val callCenter = "callcenter"
    const val account = "account"
}
object HomeRoutes {
    const val homeRoot = "${BaseRoutes.home}/root"
    const val home_2 = "${BaseRoutes.home}/home_2"
    const val home_3 = "${BaseRoutes.home}/home_3"
}
object CallCenterRoutes {
    const val callCenterRoot = "${BaseRoutes.callCenter}/root"
    const val callcenter_02 = "${BaseRoutes.callCenter}/callcenter_2"
    const val callcenter_03 = "${BaseRoutes.callCenter}/callcenter_3"
}
object AccountRoutes {
    const val accountRoot = "${BaseRoutes.account}/root"
}
fun NavGraphBuilder.buildNavGraph(
    navController: NavController,
    accountViewModel: AccountScreenViewModel
) {
    home(navController = navController)
    account(accountViewModel)
    callCenter(navController = navController)
}
fun NavGraphBuilder.home(navController: NavController) {
    navigation(
        route = BaseRoutes.home,
        startDestination = HomeRoutes.homeRoot
    ) {
        composable(route = HomeRoutes.homeRoot) {
            ShowHomeUI(navController = navController)
        }
        composable(route = HomeRoutes.home_2) {
            GenericScreen(route = HomeRoutes.home_3, navController = navController, text = "home 2")
        }
        composable(route = HomeRoutes.home_3) {
            GenericScreen(route = route ?: "", navController = navController, text = "home 3")
        }
    }
}
// Tab 2
fun NavGraphBuilder.callCenter(navController: NavController) {
    navigation(
        route = BaseRoutes.callCenter,
        startDestination = CallCenterRoutes.callCenterRoot
    ) {
        composable(route = CallCenterRoutes.callCenterRoot) {
            ShowCallCenterUI(navController = navController)
        }
        composable(route = CallCenterRoutes.callcenter_02) {
            GenericScreen(
                route = CallCenterRoutes.callcenter_03,
                navController = navController,
                text = "Callcenter 2"
            )
        }
        composable(route = CallCenterRoutes.callcenter_03) {
            GenericScreen(route = route ?: "", navController = navController, text = "Callcenter 3")
        }
    }
}
// Tab 3
fun NavGraphBuilder.account(accountViewModel: AccountScreenViewModel) {
    navigation(
        route = BaseRoutes.account,
        startDestination = AccountRoutes.accountRoot
    ) {
        composable(route = AccountRoutes.accountRoot) {
            AccountScreen(
                viewModel = accountViewModel,
                modifier = Modifier.Companion
            )
        }
    }
}
App.kt (only the relevant part)
`           Scaffold(
                bottomBar = {
                    BottomNavigationBar(rootNavController)
                },
            ) {
                NavHost(
                    modifier = Modifier.padding(it),
                    navController = rootNavController,
                    startDestination = BaseRoutes.home
                ) {
                    buildNavGraph(navController = rootNavController, viewModelAccountScreen)
                }
            }
            
BottomNavBar.kt
sealed class BottomNavigationItem(val name: String, val icon: DrawableResource, val route: String) {
    data object Home : BottomNavigationItem(
        name = "home",
        icon = Res.drawable.ic_home,
        route = BaseRoutes.home
    )
    data object CallCenter : BottomNavigationItem(
        name = "callcenter",
        icon = Res.drawable.ic_callcenter,
        route = BaseRoutes.callCenter
    )
    data object Account : BottomNavigationItem(
        name = "account",
        icon = Res.drawable.ic_person,
        route = BaseRoutes.account
    )
}
@Composable
fun BottomNavigationBar(navController: NavController) {
    val screens = listOf(
        BottomNavigationItem.Home,
        BottomNavigationItem.CallCenter,
        BottomNavigationItem.Account
    )
    BottomNavigation(
// TODO check content height (above tabbar)
        backgroundColor = Color(0xFF, 0x00, 0x00, 0x66),
        windowInsets = BottomNavigationDefaults.windowInsets
    ) {
        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val currentRoute = navBackStackEntry?.destination?.route
        screens.forEach { screen ->
            val selected = currentRoute?.split("/")?.first() == screen.route
            BottomNavigationItem(
                modifier = Modifier
                    .background(md_theme_dark_onSecondaryContainer),
                alwaysShowLabel = false,
                icon = {
                    Icon(
                        modifier = Modifier
                            .size(25.dp, 25.dp),
                        tint = Color.Black,
                        painter = painterResource(screen.icon),
                        contentDescription = null
                    )
                },
                label = {
                    Text(
                        color = Color.Black,
                        text = screen.name
                    )
                },
                selected = selected,
                onClick = {
                    navController.navigate(screen.route) {
                        popUpTo(navController.graph.findStartDestination().route.toString()) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    }
}
@Stancescu-Andrei It's not super clear from your message what exactly doesn't work for you, I hope I've got it right.
On Android it works perfectly, but on IOS it does not
Saving state for nested NavHostController is still not supported, I'll reopen this issue to track it. However nested graphs that I suggested as workaround works the same as on Android as far as I see.
Regarding your sample with switching tabs. The code should be adopted for the change from nested host to nested graphs a bit. This should work in your case:
val currentGraphRoute = navBackStackEntry?.destination?.parent?.route
navController.navigate(screen.route) {
    popUpTo(currentGraphRoute!!) {
        inclusive = true
        saveState = true
    }
    launchSingleTop = true
    restoreState = true
}
Thanks! Indeed, this is what I was missing, now everything works great, thanks again!
@MatkovIvan Hello! Could you please tell when you are going to support saving state for nested NavHosts on iOS?
@Too-Many-Bytes in a TODO list, but no ETA because there is an ongoing discussion with Google that can affect the it. Also, I want to emphasize that I'm not aware of scenarios where the nested graphs approach doesn't work (see https://github.com/JetBrains/compose-multiplatform/issues/4735#issuecomment-2090235055), so it shouldn't be a blocker for anyone.
@MatkovIvan I've studied the documentation, but I'm not sure if this approach is good in my case. If possible, please tell me how you would solve the problem with a nested graph.
My case: I have two top-level screens: Registration and Main, so there is one root fullscreen NavHost (1) which holds them. Also, inside MainScreen there is a bottom navigation bar and the second NavHost (2) which is not fullscreen (because the navigation bar with tabs takes some place). Between tabs in MainScreen saving state is working fine. But the problem occurs in such scenario: One of the tabs is the messenger with chats, so it is in MainScreen NavHost (2). However, a particular chat i want to open in fullscreen without bottom navigation bar. So I need to navigate to the particular chat screen within the root level NavHost (1). As a result, MainScreen goes into the backstack when i navigate to the particular chat and after returning to the messenger in MainScreen I get no restored state in MainScreen NavHost (2).
If you can give me advice on how to solve the problem effectively, I would appreciate it
Thanks for explaining the use case, indeed for non-fullscreen sub navigation multiple NavHosts is the best fit.
I beleive that second mentioned workaround is easy to implement:
Workaround 2: Move second
rememberNavController()call out of wiped composition.
It will work because the state of controller matters here, not NavHost itself.
PS I'll try to resolve this without changes in the dependencies (that currently blocked), but still no promises regarding ETA.
Workaround helped without serious efforts. Thanks a lot!
Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.
Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.
Hi everyone. Someone can help me in this problem? iOS save and restore state not working, android working fine, I tested all versions of compose-plugin and compose-multiplatform navigation. Please help me. Thanks
https://youtrack.jetbrains.com/issue/CMP-6691/Compose-navigation-restore-state-not-working