Reputation: 16228
I implemented bottom tabs with multi backstack in my app using this code:
NavigationBar {
bottomTabs.forEachIndexed { _, bottomTab ->
NavigationBarItem(
alwaysShowLabel = true,
icon = { Icon(bottomTab.icon!!, contentDescription = bottomTab.title) },
label = { Text(bottomTab.title) },
selected = bottomTab.rootRoute.name == currentRouteName,
onClick = {
currentRouteName = bottomTab.rootRoute.name
navController.navigate(bottomTab.rootRoute.name) {
navController.graph.startDestinationRoute?.let { startRoute ->
popUpTo(startRoute) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
This gives me the desired behavior when switching tabs using the bottom bar. It also works OK when using back navigation gesture within the same tab.
However, if I use back navigation gesture (or back button in the top bar) to transition between tabs, the state is lost.
For example, imagine I have tab A and B, and screens A1, A2 and B1. If I navigate: A1 -> A2 -> B1 and then press back, I'd expect the tab to switch to A and land on screen A2, but, instead, I land on A1.
That's how back navigation is implemented:
navigationIcon = {
if (!isRootRoute) {
IconButton(
onClick = {
navController.popBackStack()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
tint = Color.White,
contentDescription = "back"
)
}
}
}
My intuition tells me that if I could specify restoreState = true
while popping the backstack, similarly to how the bottom tabs navigation works, then it would work. However, there is no popBackStack(restoreState)
method available.
So, how can I integrate back gesture and back button with bottom tabs and multi backstack?
Upvotes: 5
Views: 1800
Reputation: 476
Based on this response https://stackoverflow.com/a/75514499/7848330, I created an example of how to implement the desired behaviour. Here is a video of how it works https://vimeo.com/manage/videos/1034031580.
I used Android Studio Ladybug | 2024.2.1 Patch 2.
I put everything into one file for clarity:
@Serializable
object HomeRoute
@Serializable
object FavoriteRoute
@Serializable
object A1Route
@Serializable
object A2Route
@Serializable
object B1Route
@Serializable
object B2Route
data class NavigationBarDataItem<T : Any>(
val name: String, val route: T, val icon: ImageVector, val selectedIcon: ImageVector
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Navigation2024Theme { // Auto generated based on the project name
val navController = rememberNavController()
Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = {
NavigationBar {
val navigationBarDataItems = listOf(
NavigationBarDataItem(
name = "Home",
route = HomeRoute,
icon = Icons.Outlined.Home,
selectedIcon = Icons.Filled.Home
), NavigationBarDataItem(
name = "Favorite",
route = FavoriteRoute,
icon = Icons.Outlined.FavoriteBorder,
selectedIcon = Icons.Filled.Favorite
)
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
navigationBarDataItems.forEach { item ->
val selected =
currentDestination?.hierarchy?.any { it.route == item.route::class.qualifiedName } == true
NavigationBarItem(icon = {
Icon(
imageVector = if (selected) item.selectedIcon else item.icon,
contentDescription = "${item.name} icon"
)
}, label = { Text(item.name) }, selected = selected, onClick = {
navController.navigate(item.route) {
popUpTo(navigationBarDataItems[0].route /*HomeRoute*/) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
})
}
}
}) { innerPadding ->
Content(innerPadding, navController)
}
}
}
}
@Composable
private fun Content(innerPadding: PaddingValues, navController: NavHostController) {
val navGraph = navController.createGraph(HomeRoute) {
composable<HomeRoute> { HomeTabContent() }
composable<FavoriteRoute> { FavoriteTabContent() }
}
NavHost(navController, navGraph, modifier = Modifier.padding(innerPadding))
}
@Composable
private fun HomeTabContent() {
val navController = rememberNavController()
val navGraph = navController.createGraph(startDestination = A1Route) {
composable<A1Route> {
TabScreen("A1", color = Color.Red) {
navController.navigate(
A2Route
)
}
}
composable<A2Route> { TabScreen("A2", color = Color.Red) }
}
NavHost(navController, navGraph)
}
@Composable
private fun FavoriteTabContent() {
val navController = rememberNavController()
val navGraph = navController.createGraph(startDestination = B1Route) {
composable<B1Route> {
TabScreen("B1", color = Color.Blue) {
navController.navigate(B2Route)
}
}
composable<B2Route> { TabScreen("B2", color = Color.Blue) }
}
NavHost(navController, navGraph)
}
@Composable
private fun TabScreen(
text: String,
color: Color,
onClick: (() -> Unit)? = null
) {
Column(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text,
style = TextStyle(fontSize = 64.sp, color = color),
textAlign = TextAlign.Center
)
Button(
onClick = { if (onClick != null) onClick() },
enabled = onClick != null
) {
Text("Next screen")
}
}
}
}
Upvotes: 1
Reputation: 1
I'm not sure if I can fully help here, but I've had this problem as well, the "culprit" of the issue seems to be
navController.graph.startDestinationRoute?.let { startRoute ->
popUpTo(startRoute) {
saveState = true
}
}
When you do popUpTo(startRoute)
, it clears everything from the backstack except the start route.
So in your example, before you navigate to B1, your stack is [A1, A2], once you navigate to B1 in the other tab (assuming A1 is start destination), A2 will be gone from the back stack, your backstack is [A1, B1]. If you press back button, you'll get If you open the bottom navigation tab of A again, you'll get [A1, A2] again. If you log the backstack entries you'll see that behaviour.
If you remove that snippet, all your composables will be in the backstack, so if you do A1 -> A2 -> B1, and press back button, you'll go back to A2. This causes another problem though. You can easily create an endless backstack, if you do A1 -> B1 -> A1 -> B1 -> A1 -> B1 -> A1 -> A2 -> B1 -> B2, etc. you'll have repeated destinations in the backstack. And when you navigate with back button, you'll go back through each of those repeated destinations. So what needs to be done here is to control where you want to pop back to. Youtube app avoids this issue, not fully sure how they do it, but either they don't use Jetpack Navigation (HA), or probably they have some custom behaviour for NavHost to manipulate the back stack.
Maybe this can achieve it - https://vedraj360.medium.com/youtube-like-backstack-in-jetpack-navigation-component-android-2537b446668d (need to modify it to use compose navigation instead of fragments, but the core logic might help)
I'll tinker around with it and update my response if I find anything worthwhile.
Upvotes: 0
Reputation: 171
Try this
fun handleBackNavigation(navController: NavController) {
if (!navController.navigateUp()) {
val currentTab =
bottomTabs.indexOfFirst { it.rootRoute.name == navController.currentDestination?.route }
val previousTab = if (currentTab > 0) currentTab - 1 else bottomTabs.size - 1
val previousTabRoute = bottomTabs[previousTab].rootRoute.name
navController.navigate(previousTabRoute) {
navController.graph.findNode(previousTabRoute)?.route?.let { startRoute ->
popUpTo(startRoute) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
}
navigationIcon = {
if (!isRootRoute) {
IconButton(
onClick = {
handleBackNavigation(navController)
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
tint = Color.White,
contentDescription = "back"
)
}
}
}
Upvotes: 1