Simplifying the FragmentManager API; Multiple Fragment backstacks on Android
A simple list — detail navigation flow

TJ Dahunsi
Oct 08 2019 · 10 mins
Fragments
in Android are many things to different people. View controllers, state containers, callback hooks to system events like permissions, life cycle aware components and so on, so it comes as no surprise that a sizable amount of Android Developers find them rather polarizing. According to this tweet, the Square engineering blog’s most read article remains this particular one. It’s 2019 however, do fragments still deserve this reputation?
The TL;DR is no, in fact both the Fragment
and its associated FragmentManager
API are more robust than ever. However, most robust tools tend to come with a steep learning curve, along with increasing complexity as your use case falls outside the most easy/common scenarios; see RxJava.
TL;WR? You can watch a presentation of this post below:
Simplifying the FragmentManager Video
In my experience, the best way to work with such fully featured and complex APIs and not shoot yourself in the foot, is to outline the MVP of whatever it is you are trying to achieve, and take the least complicated path to it. This usually takes the form of using only the simplest APIs you have in your toolchain, only using the more esoteric and flamboyant ones when you can’t do it any other way. This is because complex APIs while attractive and can potentially save you a lot of effort upfront, can have a lot of subtleties that can cause really, really hard to debug issues down the road. I’m looking at you Observable.zip
, Observable.combineLatest
, Observable.connect
and so on.
This is also true for the FragmentManager
API. It is extremely raw; a non exhaustive list of operations you can perform on a Fragment in a FragmentTransaction
include add
, replace
, show
, hide
, attach
, detach
. You may also opt to perform these operations synchronously or asynchronously, or add a tag to them. You may also even decide to add them to a back stack. For these reasons, the FragmentManager
should not be the API surface you use to implement navigation in your app; it needs an abstraction that delegates to it.
Let’s assume our MVP is to have an app where navigation consists of various destinations with a simple back stack. We can define our navigation API as:
interface Navigator { @get:IdRes val containerId: Int val currentFragment: Fragment? fun push(fragment: Fragment, tag: String): Boolean fun pop(): Boolean fun clear(upToTag: String? = null, includeMatch: Boolean = false) }
An interface for a simple stack based Navigator
Implementing this with FragmentManger
fortunately, is rather straightforward. When performing a FragmentTransaction
, we can opt to add the Fragment
to the FragmentManager’s
back stack, so rather than trying to manage the stack yourself, and restore it across process death, you can delegate it all to the FragmentManager
. Furthermore, to make the solution more robust, the back stack entry name can be encoded with the container the Fragment
is shown in within it, allowing for a single FragmentManger
to have multiple back stacks*.
class StackNavigator constructor( internal val fragmentManager: FragmentManager, @param:IdRes @field:IdRes @get:IdRes override val containerId: Int ) : Navigator { override val currentFragment: Fragment? get() = fragmentManager.findFragmentById(containerId) private val String.toEntry get() = "$containerId-$this" private val FragmentManager.BackStackEntry.inContainer: Boolean get() = name?.split("-")?.firstOrNull() == containerId.toString() private val FragmentManager.BackStackEntry.tag get() = name?.run { this.removePrefix(split("-").first() + "-") } private val baskStackEntries get() = fragmentManager.run { (0 until backStackEntryCount).map(this::getBackStackEntryAt).filter { it.inContainer } } private val fragmentTags get() = baskStackEntries.map { it.tag } init { fragmentManager.registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() { override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) = auditFragment(f) }, false) } override fun push(fragment: Fragment, tag: String): Boolean { val tags = fragmentTags val currentFragmentTag = tags.lastOrNull() if (currentFragmentTag != null && currentFragmentTag == tag) return false val fragmentAlreadyExists = tags.contains(tag) val fragmentShown = !fragmentAlreadyExists val fragmentToShow = (if (fragmentAlreadyExists) fragmentManager.findFragmentByTag(tag) else fragment) ?: throw NullPointerException(MSG_DODGY_FRAGMENT) fragmentManager.commit { transactionModifier?.invoke(this, fragment) // replace(containerId, fragmentToShow, tag) addToBackStack(tag.toEntry) } return fragmentShown } override fun pop(): Boolean = fragmentTags.run { if (size > 1) clear(last(), true).let { true } else false } override fun clear(upToTag: String?, includeMatch: Boolean) { // Empty string will be treated as a no-op internally val tag = upToTag?.toEntry ?: baskStackEntries.firstOrNull()?.name ?: "" fragmentManager.popBackStack(tag, if (includeMatch) FragmentManager.POP_BACK_STACK_INCLUSIVE else 0) } }
***An implementation of a simple stack based Navigator: StackNavigator
The above implementation makes it possible to achieve the following rather easily:
Multiple Fragment Stacks with a StackNavigator powered by a single FragmentManager
In the above, each unique stack associated with a different FragmentManager can be manipulated without affecting the others, despite sharing the same FragmentManager, provided Fragments are only added on top of the stack. Should a fragment be popped however, if any of the stacks had a push
that interleaved with another stack, it would also be popped, which isn’t a desired effect.
Stack 2 interleaves with stack 1, so popping from stack 2 inadvertently pops from stack 1
Going further, the popular settled upon navigation pattern for apps today, is the bottom navigation bar with tabs. One of the best apps implementing this pattern that I’ve seen is the Instagram Android app: Each tab has its own back stack, and each stack is independent of the other. You can leave the home feed tab, visit the explore tab, push and pop destinations to your hearts content, and return to the home feed intact, just as you left it. It’s remarkable, and I think it’s one of the reasons people can lose themselves in Instagram; people trust its navigation and have no qualms engrossing themselves in more, and more content.
Navigation in Instagram: reliable, effective and robust.
So how can we replicate this with the FragmentManager
? The StackNavigator
implementation above is only a little off from what is required. If a single FragmentManager
cannot support multiple independent stacks, then the solution is to have multiple FragmentMangers
; we’re about to go deeper.
A Fragment
instance has access to a child FragmentManager
, so by creating multiple Fragments
within a parent Fragment
, each child Fragment
can host a StackNavigator
within it and therefore host independent tabs. Since only one Fragment
stack will be visible at any one time, it stands to reason that the other fragments that host StackNavigators
, should not have their UI attached to the layout hierarchy when not visible, lest resources be wasted. To allow for this, the FragmentManager
API lets us arbitrarily detach and reattach fragments from their host container view. When a user selects a tab, we attach that tab’s fragment stack and detach the others, and when a user returns to the previous tab, that tab’s Fragment
and its associated stack is reattached without any loss of state. We can call the navigator that allows us to switch between multiple independent stacks, a MultiStackNavigator
.
class MultiStackNavigator( stackCount: Int, private val stateContainer: LifecycleSavedStateContainer, private val fragmentManager: FragmentManager, @IdRes override val containerId: Int, private val rootFunction: (Int) -> Pair<Fragment, String>) : Navigator { var stackSelectedListener: ((Int) -> Unit)? = null var stackTransactionModifier: (FragmentTransaction.(Int) -> Unit)? = null var transactionModifier: (FragmentTransaction.(Fragment) -> Unit)? = null set(value) { field = value stackFragments .filter { it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) } .forEach { it.navigator.transactionModifier = value } } private val indices = 0 until stackCount private val backStack: Stack<Int> = Stack() private val stackFragments: List<StackFragment> private val activeFragment: StackFragment get() = stackFragments.run { firstOrNull(Fragment::isAttached) ?: first() } val activeIndex get() = activeFragment.index val activeNavigator get() = activeFragment.navigator override val currentFragment: Fragment? get() = activeNavigator.currentFragment init { fragmentManager.registerFragmentLifecycleCallbacks(StackLifecycleCallback(), false) fragmentManager.addOnBackStackChangedListener { throw IllegalStateException("Fragments may not be added to the back stack of a FragmentManager managed by a MultiStackNavigator") } val freshState = stateContainer.isFreshState if (freshState) fragmentManager.commitNow { indices.forEach { index -> add(containerId, StackFragment.newInstance(index), index.toString()) } } else fragmentManager.addedStackFragments(indices).forEach { stackFragment -> backStack.push(stackFragment.index) } stateContainer.savedState.getIntArray(NAV_STACK_ORDER)?.apply { backStack.sortBy { indexOf(it) } } stackFragments = fragmentManager.addedStackFragments(indices) if (freshState) show(0) } fun show(index: Int) = showInternal(index, true) override fun pop(): Boolean = when { activeFragment.navigator.pop() -> true backStack.run { remove(activeFragment.index); isEmpty() } -> false else -> showInternal(backStack.peek(), false).let { true } } override fun clear(upToTag: String?, includeMatch: Boolean) = activeNavigator.clear(upToTag, includeMatch) override fun push(fragment: Fragment, tag: String): Boolean = activeNavigator.push(fragment, tag) private fun showInternal(index: Int, addTap: Boolean) = fragmentManager.commit { val toShow = stackFragments[index] if (addTap) track(toShow) stackTransactionModifier?.invoke(this, index) transactions@ for (fragment in stackFragments) when { fragment.index == index && !fragment.isDetached -> continue@transactions fragment.index == index && fragment.isDetached -> attach(fragment) else -> if (!fragment.isDetached) detach(fragment) } runOnCommit { stackSelectedListener?.invoke(index) } } private fun track(tab: StackFragment) = tab.run { if (backStack.contains(index)) backStack.remove(index) backStack.push(index) stateContainer.savedState.putIntArray(NAV_STACK_ORDER, backStack.toIntArray()) } private fun StackFragment.showRoot() = rootFunction(index).apply { navigator.push(first, second) } private inner class StackLifecycleCallback : FragmentManager.FragmentLifecycleCallbacks() { override fun onFragmentCreated(fm: FragmentManager, fragment: Fragment, savedInstanceState: Bundle?) = fragment.run { if (id != this@MultiStackNavigator.containerId) return check(this is StackFragment) { "Only Stack Fragments may be added to a container View managed by a MultiStackNavigator" } if (!stateContainer.isFreshState) return if (index != 0) fm.commit { detach(this@run) } } override fun onFragmentResumed(fm: FragmentManager, fragment: Fragment) = fragment.run { if (id != this@MultiStackNavigator.containerId) return check(this is StackFragment) { "Only Stack Fragments may be added to a container View managed by a MultiStackNavigator" } navigator.transactionModifier = this@MultiStackNavigator.transactionModifier if (hasNoRoot) showRoot() } } } class StackFragment : Fragment() { internal lateinit var navigator: StackNavigator internal var index: Int by args() private var containerId: Int by args() internal val hasNoRoot get() = navigator.currentFragment == null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val deferred: StackNavigator by childStackNavigationController(containerId) navigator = deferred } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = FragmentContainerView(inflater.context).apply { id = containerId } companion object { internal fun newInstance(index: Int) = StackFragment().apply { this.index = index; containerId = View.generateViewId() } } } const val NAV_STACK_ORDER = "navState" private val Fragment.isAttached get() = !isDetached private fun FragmentManager.addedStackFragments(indices: IntRange) = indices .map(Int::toString) .map(::findFragmentByTag) .filterIsInstance(StackFragment::class.java)
Full implementation for a MultiStackNavigator
The FragmentManager sometimes has different set of APIs for similar functions, where one set is asynchronous, and the other performed immediately. When the MultiStackNavigator
is created, it’s important that all the StackFragments
are available immediately, so it uses the FragmentManager.commitNow
method to synchronously add all its child StackFragments
to the FragmentManager
. However, since only one stack can be visible at anytime, it uses an implementation of the FragmentManager.FragmentLifecycleCallbacks
class called StackLifecycleCallback
to detach all StackFragments
but the first after it’s been created. When the MultiStackNavigator
is resumed after process death however, all its StackFragments
are already preserved in the FragmentManager
, so it only needs to retrieve them.
The MultiStackNavigator
also maintains an internal stack of the visitation order for each stack, so if a stack is at its root, and the navigator is popped, it switches to the previous stack.
With the above, making an app that has a navigation pattern similar to Instagram becomes easy.
MultiStackNavigator Demo
In the above, the app:
Is on the Nav tab first, and pushes the
DoggoListFragment
unto its stack.The Lists tab is selected, and DoggoRankFragment is pushed unto the Lists stack
The Nav and Lists tab are then alternated between to show that their independent stack states are preserved.
The Lists tab is delved into further, by pushing the detail
AdoptDoggoFragment
unto its stack.The back button is pressed multiple times until the Lists tab is popped to its root
Fragment
.The back button is pressed again, and the MultiStackNavigator switches to the last visited Stack before the Lists tab: the Nav tab.
The back button keeps getting pressed till the Nav tab is eventually popped to its root
Fragment
.
The implementation in the root activity is very succinct:
class MainActivity : AppCompatActivity(R.layout.activity_main) { val multiStackNavigator: MultiStackNavigator by multiStackNavigationController( tabs.size, R.id.content_container ) { index -> RouteFragment.newInstance(index).let { it to it.stableTag } } public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) findViewById<BottomNavigationView>(R.id.bottom_navigation).apply { multiStackNavigator.stackSelectedListener = { menu.findItem(tabs[it])?.isChecked = true } multiStackNavigator.transactionModifier = { incomingFragment -> val current = multiStackNavigator.currentFragment if (current is Navigator.TransactionModifier) current.augmentTransaction(this, incomingFragment) else crossFade() } multiStackNavigator.stackTransactionModifier = { crossFade() } setOnApplyWindowInsetsListener { _: View?, windowInsets: WindowInsets? -> windowInsets } setOnNavigationItemSelectedListener { multiStackNavigator.show(tabs.indexOf(it.itemId)).let { true } } setOnNavigationItemReselectedListener { multiStackNavigator.activeNavigator.clear() } } onBackPressedDispatcher.addCallback(this) { if (!multiStackNavigator.pop()) finish() } } companion object { val tabs = intArrayOf(R.id.menu_navigation, R.id.menu_recyclerview, R.id.menu_communications, R.id.menu_misc) } }
The great thing about this is, if there was a Fragment
that was pushed on any of the tab’s stacks that needed to have multiple tabs internally, that Fragment can use a MultiStackNavigator
with its own child FragmentManager
just fine as seen in the gif below:
Multiple stacks within multiple stacks
With the above, the FragmentManager’s
robustness is clear to see. It has an incredible feature filled API that unfortunately has an undeserved poor reputation, at least in 2019. It also keeps improving at a rapid pace, for the latest on Fragments and a bunch of other core Androidx library updates, give Ian Lake’s Twitter a follow. In the Android Dev Summit last week, Fragments had their own talk, so they are not going away any time soon.
Furthermore, if you like the navigation patterns demonstrated above and would like to use the StackNavigator
, MultiStackNavigator
or both in your next project, it’s available at the Maven coordinates:
`implementation 'com.tunjid.androidx:navigation:1.0.0-rc03'`
It is production ready, the only reason it is not a full blown 1.0.0
release is because it depends on the androidx.fragment:fragment:1.2.0-rc01
Androidx Jetpack module. As soon as that hits stable, the library will be as well.
For the full source for the StackNavigator
, MultiStackNavigator
, demo, gifs, and other Android extensions that can make Android development a lot easier, check out this repository:
Tests for MultiStackNavigator can also be found here, if you happen to really like TDD.
For a more featured example, the full source for an app that uses it for complex flows like login, and shared element transactions across screens can be seen below: