Multiple back stacks

A deep dive into what actually went into this feature

Ian Lake
Ian Lake
Jun 7 · 10 min read

If a ‘back stack’ is a set of screens that you can navigate back through via the system back button, ‘multiple back stacks’ is just a bunch of those, right? Well, that’s exactly what we’ve done with the multiple back stack support added in Navigation 2.4.0-alpha01 and Fragment 1.4.0-alpha01!

The joys of the system back button

Whether you’re using Android’s new gesture navigation system or the traditional navigation bar, the ability for users to go ‘back’ is a key part to the user experience on Android and doing that right is an important part to making your app feel like a natural part of the ecosystem.

In the simplest cases, the system back button just finishes your activity. While in the past you might have been tempted to override the onBackPressed() method of your activity to customize this behavior, it is 2021 and that is totally unnecessary. Instead, there are APIs for custom back navigation in the OnBackPressedDispatcher. This is actually the same API that FragmentManager and NavController already plug into.

That means when you use either Fragments or Navigation, they use the OnBackPressedDispatcher to ensure that if you’re using their back stack APIs, the system back button works to reverse each of the screens that you’ve pushed onto the back stack.

Multiple back stacks doesn’t change these fundamentals. The system back button is still a one directional command — ‘go back’. This has a profound effect on how the multiple back stack APIs work.

Multiple back stacks in Fragments

At the surface level, the support for multiple back stacks is deceptively straightforward, but requires a bit of an explanation of what actually is the ‘fragment back stack’. The FragmentManager’s back stack isn’t made up of fragments, but instead is made up of fragment transactions. Specifically, the ones that have used the addToBackStack(String name) API.

This means when you commit() a fragment transaction with addToBackStack(), the FragmentManager is going to execute the transaction by going through and executing each of the operations (the replace, etc.) that you specified on the transaction, thus moving each fragment through to its expected state. FragmentManager then holds onto that transaction as part of its back stack.

When you call popBackStack() (either directly or via FragmentManager’s integration with the system back button), the topmost transaction on the fragment back stack is reversed — an added fragment is removed, a hidden fragment is shown, etc. This puts the FragmentManager back into the same state that it was before the fragment transaction was initially committed.

Note: I cannot stress this enough, but you absolutely should never interleave transactions with addToBackStack() and transactions without in the same FragmentManager: transactions on your back stack are blissfully unaware of non-back stack changing fragment transactions — swapping things out from underneath those transactions makes that reversal when you pop a much more dicey proposition.

This means that popBackStack() is a destructive operation: any added fragment will have its state destroyed when that transaction is popped. This means you lose your view state, any saved instance state, and any ViewModel instances you’ve attached to that fragment are cleared. This is the main difference between that API and the new saveBackStack(). saveBackStack() does the same reversal that popping the transaction does, but it ensures that the view state, saved instance state, and ViewModel instances are all saved from destruction. This is how the restoreBackStack() API can later recreate those transactions and their fragments from the saved state and effectively ‘redo’ everything that was saved. Magic!

This didn’t come without paying down a lot of technical debt though.

Paying down our technical debts in Fragments

While fragments have always saved the Fragment’s view state, the only time that a fragment’s onSaveInstanceState() would be called would be when the Activity’s onSaveInstanceState() was called. To ensure that the saved instance state is saved when calling saveBackStack(), we need to also inject a call to onSaveInstanceState() at the right point in the fragment lifecycle transitions. We can’t call it too soon (your fragment should never have its state saved while it is still STARTED), but not too late (you want to save the state before the fragment is destroyed).

This requirement kicked off a process to fix how FragmentManager moves to state to make sure there’s one place that manages moving a fragment to its expected state and handles re-entrant behavior and all the state transitions that go into fragments.

35 changes and 6 months into that restructuring of fragments, it turned out that postponed fragments were seriously broken, leading to a world where postponed transactions were left floating in limbo — not actually committed and not actually not committed. Over 65 changes and another 5 months later, and we had completely rewritten most of the internals of how FragmentManager manages state, postponed transitions, and animations. That effort is covered in more detail in my previous blog post:

What to expect in Fragments

With the technical debt paid down (and a much more reliable and understandable FragmentManager), the tip of the iceberg APIs of saveBackStack() and restoreBackStack() were added.

If you don’t use these new APIs, nothing changes: the single FragmentManager back stack works as before. The existing addToBackStack() API remains unchanged — you can use a null name or any name you want. However, that name takes on a new importance when you start looking at multiple back stacks: it is that name that is the unique key for that fragment transaction that you’d use with saveBackStack() and later with restoreBackStack().

This might be easier to see in an example. Let’s say that you have added an initial fragment to your activity, then done two transactions, each with a single replace operation:

// This is the initial fragment the user sees
fragmentManager.commit {
setReorderingAllowed(true)
replace<HomeFragment>(R.id.fragment_container)
}
// Later, in response to user actions, we’ve added two more
// transactions to the back stack
fragmentManager.commit {
setReorderingAllowed(true)
replace<ProfileFragment>(R.id.fragment_container)
addToBackStack(“profile”)
}
fragmentManager.commit {
setReorderingAllowed(true)
replace<EditProfileFragment>(R.id.fragment_container)
addToBackStack(“edit_profile”)
}

This means that our FragmentManager looks like:

FragmentManager state after three commits

Let’s say that we want to swap out our profile back stack and swap to the notifications fragment. We’d call saveBackStack() followed by a new transaction:

fragmentManager.saveBackStack("profile")fragmentManager.commit {
setReorderingAllowed(true)
replace<NotificationsFragment>(R.id.fragment_container)
addToBackStack("notifications")
}

Now our transaction that added the ProfileFragment and the transaction that added the EditProfileFragment has been saved under the "profile"
key. Those fragments have had their state saved completely and FragmentManager is holding onto their state alongside the transaction state. Importantly: those fragment instances no longer exist in memory or in the FragmentManager — it is just the state (and any non config state in the form of ViewModel instances):

FragmentManager state after we’ve saved the profile back stack and added one more commit

Swapping back is simple enough: we can do the same saveBackStack() operation on our "notifications" transaction and then restoreBackStack():

fragmentManager.saveBackStack(“notifications”)fragmentManager.restoreBackStack(“profile”)

The two stacks have effectively swapped positions:

FragmentManager state after swapping the two stacks

This style of maintaining a single active back stack and swapping transactions onto it ensures that the FragmentManager and the rest of the system always has a consistent view of what actually is supposed to happen when the system back button is tapped. In fact, that logic remained entirely unchanged: it still just pops the last transaction off of the fragment back stack like before.

These APIs are purposefully minimal, despite their underlying effects. This makes it possible to build your own structure on top of these building blocks while avoiding any hacks to save Fragment view state, saved instance state, and non config state.

Of course, if you don’t want to build your own structure on top of these APIs, you can also use the one we provide.

Bringing multiple back stacks to any screen type with Navigation

The Navigation Component was built from the beginning as a generic runtime that knows nothing about Views, Fragments, Composables, or any other type of screen or ‘destination’ you might implement within your activity. Instead, it is the responsibility of an implementation of the NavHost interface to add one or more Navigator instances that do know how to interact with a particular type of destination.

This meant that the logic for interacting with fragments was entirely encapsulated in the navigation-fragment artifact and its FragmentNavigator and DialogFragmentNavigator. Similarly the logic for interacting with Composables is in the completely independent navigation-compose artifact and its ComposeNavigator. That abstraction means that if you want to build your app solely with Composables, you are not forced to pull in any dependency on fragments when you use Navigation Compose.

This level of separation means that there are really two layers to multiple back stacks in Navigation:

  • Saving the state of the individual NavBackStackEntry instances that make up the NavController back stack. This is the responsibility of the NavController.
  • Saving any Navigator specific state associated with each NavBackStackEntry (e.g., the fragment associated with a FragmentNavigator destination). This is the responsibility of the Navigator.

Special attention was given to the cases where the Navigator has not been updated to support saving its state. While the underlying Navigator API was entirely rewritten to support saving state (with new overloads of its navigate() and popBackStack() APIs that you should override instead of the previous versions), NavController will save the NavBackStackEntry state even if the Navigator has not been updated (backward compatibility is a big deal in the Jetpack world!).

PS: this new Navigator API also makes it way easier to test your own custom Navigator in isolation by attaching a TestNavigatorState that acts as a mini-NavController.

If you’re just using Navigation in your app, the Navigator level is more of an implementation detail than something you’ll ever need to interact with directly. Suffice it to say, we’ve already done the work required to get the FragmentNavigator and the ComposeNavigator over to the new Navigator APIs so that they correctly save and restore their state; there’s no work you need to do at that level.

Enabling multiple back stacks in Navigation

If you’re using NavigationUI, our set of opinionated helpers for connecting your NavController to Material view components, you’ll find that multiple back stacks is enabled by default for menu items, BottomNavigationView (and now NavigationRailView!), and NavigationView. This means that the common combination of using navigation-fragment and navigation-ui will just work.

The NavigationUI APIs are purposefully built on top of the other public APIs available in Navigation, ensuring that you can build your own versions for precisely your set of custom components you want. The APIs to enable saving and restoring a back stack are no exception to this, with new APIs on NavOptions, the navOptions Kotlin DSL, in the Navigation XML, and in an overload for popBackStack() that let you specify that you want a pop operation to save state or you want a navigate operation to restore some previously saved state.

For example, in Compose, any global navigation pattern (whether it is a bottom navigation bar, navigation rail, drawer, or anything you can dream up) can all use the same technique as we show for integrating with BottomNavigation and call navigate() with the saveState and restoreState attributes:

onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}

// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}

Save your state, save your users

One of the most frustrating things for a user is losing their state. That’s one of the reasons why fragments have a whole page on saving state and one of the many reasons why I am so glad to get each layer updated to support multiple back stacks:

  • Fragments (i.e., without using the Navigation Component at all): this is an opt-in change by using the new FragmentManager APIs of saveBackStack and restoreBackStack.
  • The core Navigation Runtime: adds opt-in new NavOptions methods for restoreState and saveState and a new overload of popBackStack() that also accepts a saveState boolean (defaults to false).
  • Navigation with Fragments: the FragmentNavigator now utilizes the new Navigator APIs to properly translate the Navigation Runtime APIs into the Fragment APIs by using the Navigation Runtime APIs.
  • NavigationUI: The onNavDestinationSelected(), NavigationBarView.setupWithNavController(), and NavigationView.setupWithNavController() now use the new restoreState and saveState NavOptions by default whenever they would pop the back stack. This means that every app using those NavigationUI APIs will get multiple back stacks without any code changes on their part after upgrading the Navigation 2.4.0-alpha01 or higher.

If you’d like to look at some more examples that use this API, take a look at the NavigationAdvancedSample (newly updated without any of the NavigationExtensions code it used to require to support multiple back stacks):

And for Navigation Compose, consider looking at Tivi:

If you do run into any issues, please make sure to use the official issue tracker to file bugs against Fragments or Navigation and we’ll be sure to take a look at them!

Android Developers

The official Android Developers publication on Medium

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store