Search code examples
androidandroid-jetpack-composeandroid-jetpack-navigationnavigation-compose

Does NavOptionsBuilder.launchSingleTop work with nested navigation graphs in Jetpack Compose?


I'm using BottomNavigation in Jetpack Compose with navigation-compose:2.5.0-alpha04 and I want to encapsulate each tab's flow with nested navigation. To achieve this I created a single NavHost with different graphs inside:

    NavHost(
        navController = navController,
        startDestination = defaultTab.route
    ) {
        eventsGraph()

        employeesGraph()

        devicesGraph()

        feedbackGraph()
    }

Each graph contains its own composables inside a navigation extension function.

As docs suggest, when BottomNavigationItem is clicked, I'm configuring my NavOptions the following way:

onClick = {
    navController.navigate(tab.route) {
        popUpTo(navController.graph.findStartDestination().id) {
            saveState = true
        }
        launchSingleTop = true
        // We want to reset the graph if it is clicked while already selected
        restoreState = tab != currentTab
    }
    currentTab = tab
}

I'm expecting this code to ignore clicks on an already selected item and always having any selected tab as nav graph's root, i.e. quitting the app on back press if user is at the root destination of any given tab.

Instead, every click on a default tab at its start destination puts another instance of the same destination in the stack, and back press at a non-default tab puts me back to the defaultTab's destination.

I tried popping destinations inclusive, but NavController unsurprisingly just can't find saved Destination's ID to restore state for. I tried to saveState by the same tab != currentTab condition by which I restore it, but it appears to have no effect.

Is it possible to achieve desired behavior by the means of Navigation APIs or do I need to handle this by myself?

Update

I eventually came up with the following solution for resetting the current tab's graph. Basically, instead of navigating with popUpTo option we're just popping the stack to the start destination.

onClick = {
    if (tab == currentTab) {
        navController.popBackStack(
            route = tab.startDestination,
            inclusive = false
        )
        return@BottomNavigationItem
    }
    /* ... */
}

There is still an issue with defaultTab's graph being the root, which means pressing the back button on any non-defalut tab's start does not exit the app.


Solution

  • Short answer: yes, you need to write this logic by yourself. Fortunately, most of it is handled by the navigation library.
    What was required:

    1. Single tab at a time in a stack
    2. Tab's navigation state is saved on leave and restored on enter
    3. Selected tab's navigation state is reset on click
    4. Any selected tab is the root of the navigation graph

    Every one of those except 4 was achieved. As ianhanniballake pointed out, exiting the app on system back press must always happen in the start destination of the app, so I left the default behavior in place.

    As for items 1-3, here's complete onClick implementation for BottomNavigationItem:

    onClick = {
        // Handling resetting current tab's state
        if (tab == currentTab) {
            navController.popBackStack(
                route = tab.startDestination,
                inclusive = false
            )
            return@BottomNavigationItem
        }
        
        // Handling singleTop behavior with saving state
        navController.navigate(tab.route) {
            popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
            }
            launchSingleTop = true
            restoreState = tab != currentTab
        }
        appBarViewModel.setCurrentTab(tab)
    }
    

    What is tricky about this is currentTab from the previous implementation is not updated when defaultTab is shown after system back press, so I am updating it based on current nav back stack as a side effect:

    val navBackStackEntry by navController.currentBackStackEntryAsState()
    LaunchedEffect(navBackStackEntry) {
        // This condition is possible to be true if a system "Back" press was detected in a non-default tab,
        // navigating the user to app's start destination.
        // To correctly reflect that in bottom navigation, this code is needed
        if (navBackStackEntry?.destination?.route == appBarViewModel.defaultTab.startDestination)
            appBarViewModel.setCurrentTab(appBarViewModel.defaultTab)
    }