JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
14.85k stars 1.08k forks source link

Support saving state for nested `NavHostController` #4735

Closed slikasgiedrius closed 2 weeks ago

slikasgiedrius commented 2 weeks ago

Describe the bug iOS doesn't save state when moving between bottom bar items

Affected platforms

Versions

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

MatkovIvan commented 2 weeks ago

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

slikasgiedrius commented 2 weeks ago

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?

slikasgiedrius commented 2 weeks ago

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,
                                    )
                                }
                            })
                    }
                }
            }
        }
    })
}
MatkovIvan commented 2 weeks ago

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")
                }
            }
        }
}
slikasgiedrius commented 2 weeks ago

There was a problem with my nested NavGraphs. Joining both into one helped. Thanks for your help!

slikasgiedrius commented 2 weeks ago

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

MatkovIvan commented 2 weeks ago

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

slikasgiedrius commented 2 weeks ago

I have made it a public repo - https://github.com/slikasgiedrius/Tob

MatkovIvan commented 2 weeks ago

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

MatkovIvan commented 2 weeks ago

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.

MatkovIvan commented 2 weeks ago

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

slikasgiedrius commented 2 weeks ago

I see some iOS build issues after the PR of the changes

Screenshot 2024-05-02 at 14 39 31
MatkovIvan commented 2 weeks ago

It looks like https://youtrack.jetbrains.com/issue/KT-61205 that should be already fixed. Looking why it's still here

slikasgiedrius commented 2 weeks ago

Yeah I have just discovered that it's related to K2 (Which I have enabled today) :D

slikasgiedrius commented 2 weeks ago

Everything works like a charm. Only K2 compiler issue left.

MatkovIvan commented 2 weeks ago

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

slikasgiedrius commented 2 weeks ago

Everything works perfectly now, THANK YOU SO MUCH for your massive help. I'm loving it! Closing this issue

MatkovIvan commented 2 weeks ago

org.jetbrains.compose.resources.MissingResourceException: Missing resource with path

It's tracked in #4720 What's a chain of unrelated bugs! 🫠