https://kotlinlang.org logo
#compose
Title
# compose
s

Simon Stahl

06/16/2022, 1:50 AM
Hi. I'm still fiddling around with navigation, but have two questions that I just cannot find the answer to. I am basically using the default bottom nav example found here and adjusted it for my requirements. In my case, the bottom tabs are received from a server call at app start and are the only known navigation destinations. In each tab, I can navigate to sub-pages with an unknown name (with navigation destination
page/{pageName}
). Questions: 1.
currentDestination?.hierarchy
does not contain the root tab name after navigating to a sub page, so the tab does not stay selected 2. I would like to change the back behavior. By default, back first pops the current tabs backstack. Once it is empty, it moves to the
startDestination
(root page in the first tab with backstack empty). I would like to change that to: pop backstack of current tab. Once empty, switch to last used tab including backstack and start popping there. Once empty switch to the next last used tab etc. If there are no more backstack entries, close the app. For reference, please find my sample code in the comments
Copy code
private val bottomNavTabs = listOf("For You", "Profile", "Whatever")
fun getBottomNavTabs() = bottomNavTabs // in reality this would come from a server call

@Composable
fun NavigationTest() {
    val navController = rememberNavController()
    val bottomNavTabs = remember { getBottomNavTabs() }

    Scaffold(
        bottomBar = { SimpleBottomNav(navController, bottomNavTabs) }
    ) { innerPadding ->
        NavHost(
            navController,
            startDestination = bottomNavTabs[0],
            Modifier.padding(innerPadding)
        ) {
            val navigateToSubPage: (String) -> Unit = { pageName -> navController.navigate("page/$pageName") }
			// Known bottom nav tab destinations
            bottomNavTabs.forEach { pageName ->
                composable(pageName) {
                    PageContent(pageName, navigateToSubPage)
                }
            }
			// Unknows sub pages
            composable("page/{pageName}") {
                val pageName = it.arguments?.getString("pageName")
                pageName?.let { pn -> PageContent(pn, navigateToSubPage) }
            }
        }
    }
}

// Create some random page with a LazyList to test page state preservation
@Composable
fun PageContent(name: String, onNavigate: (pageName: String) -> Unit) {
    Column(modifier = Modifier.fillMaxSize()) {
        Text(text = "Page: $name")

        var text by rememberSaveable { mutableStateOf("") }
        TextField(value = text, onValueChange = { text = it })
        Button(onClick = { if (text.isNotEmpty()) onNavigate(text) }) {
            Text(text = "Open page $text")
        }

        LazyColumn {
            items(100) {
                Text(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(5.dp)
                        .background(Color.LightGray),
                    text = "Column $it"
                )
            }
        }
    }
}

@Composable
fun SimpleBottomNav(navController: NavHostController, tabNames: List<String>) {
    BottomNavigation {
        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val currentDestination = navBackStackEntry?.destination

        tabNames.forEach { pageName ->
            BottomNavigationItem(
                icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
                label = { Text(pageName) },
				// Hier currentDestination?.hierarchy does not contain the tab name once on a sub page
                selected = currentDestination?.hierarchy?.any { it.route == pageName } == true,
                onClick = {
                    navController.navigate(pageName) {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    }
}
i

Ian Lake

06/16/2022, 2:12 AM
So, if you don't want your selected tab to be based on the hierarchy (i.e., the position of your destination within the nested structure of your navigation graph)...just don't use that?
It is your code: if you want to track the last selected tab and use that as your source of truth (rather than using the NavController's current screen as the source of truth), then you can certainly do that. But then you are responsible for changing your tab source of truth - it won't change back to your first tab as you hit the system back button
And if you don't want to popUpTo the start destination when you select a tab, just leave out the popUpTo? Navigation is going to do exactly what you ask it to do. However, then you are taking the responsibility for reordering stacks as you tap between two tabs back and forth (i.e., if you from tab 1 to 2 to 3 and back to 2, what back stack do you want? 1 then 3 then 2? Then you'll write the popBackStack() + navigate() calls needed to reorder your back stack)
Of course, do your own UX studies with real users to see if they can figure out your logic for swapping bottom nav stacks. The Material team, along with us, actually did that UX study with both users familiar with Android and not, and specifically chose exactly the approach seen in the docs as the most predictable stacking - case after case, they were able to figure out the "rules" of how it works faster than any other structure - your proposed structure as well
💯 1
s

Simon Stahl

06/16/2022, 3:24 AM
yes yes, i am aware of the material design back behavior spec. i've already forwarded that information to our design team. unfortunately for now, your requirements are set 😞
I've tried to keep track of the selected tab myself, but just like you say, once the back button changes tab, the selection is not correct anymore. I haven't found a clean (or hacky for that matter) solution yet to always have the right tab selected
i've also played around with removing
popUpTo
, the issue i ran into then is that the compose states are not saved anymore and every time i switch tab etc, the composables are in their default state
btw, when you mention the doc with the default android behavior, i assume you mean the material design docs where it says "_*On Android:* the app navigates to a destination’s top-level screen. Any prior user interactions and temporary screen states are reset, such as scroll position, tab selection, and in-line search._". It doesn't mention what the back behavior is though. Just wondering if there is more information on that somewhere
i

Ian Lake

06/16/2022, 3:54 AM
You can't just "remove" popUpTo - that works for the first time that you create tab 2, yes, but not when reselecting a tab you've already created - that's what I was talking about when I said that you need to reorder the stacks when you select a tab a second time
👍 1
Write out your back stack before each click and what you want your final stack to be. That's the only way to figure out exactly what operations you need to run
👍 1
Say you are on A1, A2, B1, B2, C1, C2 (you selected tab A, then B, then C). If you select B again and want your stack to be A1, A2, C1, C2, B1, B2, then you need to popBackStack to C1 with saveState true, popBackStack to B1 with saveState true, then navigate to C1 with restoreState true, then navigate to B1 with restoreState true. Tada, stacks (with all of their state) swapped
👍 1
The problem is that you always need to exit from A1 - that is Principle of Navigation #1: https://developer.android.com/guide/navigation/navigation-principles
Breaking that really, really, really messes users up and is the #1 cause of accidentally leaving your app and getting booted to the launcher
👍 1
Which means the rules for reselecting your first tab can't follow the same rules as other tabs since you can't stack other tabs below it in the order without messing up the root of your back stack being that start screen
Which then means your UX is even more messy and impossible for users to actually reason about
And no, I don't mean what the Material guidelines say, since those were written before multiple back stacks and saving the state of each tab was possible. I mean exactly what the Navigation Compose docs do
👍 1
Each tab has its own stack, everything but the start destination (which must be at the root of your back stack) is popped when swapping tabs. Thus users can freely swap back and forth (easy to understand), don't build up an oppressively large back stack (it is easy for users to back out of the app in a minimal set of understandable steps), and they know for certain when system back will take them to the launcher (when they are on the start destination of your app)
💯 1
c

Colton Idle

06/16/2022, 3:17 PM
thanks ian for convinving me that i should never try to build my own nav library. 😅 For real though, theres a ton of choices that go in here. TIL to a lot of these. thanks for the detailed explanation and thought process.
s

Simon Stahl

06/16/2022, 5:57 PM
Thank you very much for the lengthly explanation. Just to make it clear, the expected default bottom nav behavior is that every tab maintains its own backstack. Switching between tabs will land you on the last visited detail page of that tab. Back button will pop from the current tab stack. Once the stack is empty the startDestination is shown and the next back click will exit the app
this is actually the exact behavior of the navigation with bottom nav sample compose code - which makes sense i guess 😉
implementing a different back behavior seems very tedious and difficult to have a consistent way out of the app. i get that, that is very confusing for the users. i will try to push back on that requirement
thanks again @Ian Lake, I appreciate it very much that you take the time to explain this all to me
1
7 Views