Adetunji Dahunsi

Simplifying the FragmentManager API; Multiple Fragment backstacks on Android

A simple list — detail navigation flow

TJ Dahunsi

Oct 08 2019 · 10 min read

Categories:
android
kotlin

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:

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:

1interface Navigator { 2 @get:IdRes 3 val containerId: Int 4 5 val currentFragment: Fragment? 6 7 fun push(fragment: Fragment, tag: String): Boolean 8 9 fun pop(): Boolean 10 11 fun clear(upToTag: String? = null, includeMatch: Boolean = false) 12}

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*.

1class StackNavigator constructor( 2 internal val fragmentManager: FragmentManager, 3 @param:IdRes @field:IdRes @get:IdRes override val containerId: Int 4) : Navigator { 5 6 override val currentFragment: Fragment? 7 get() = fragmentManager.findFragmentById(containerId) 8 9 private val String.toEntry 10 get() = "$containerId-$this" 11 12 private val FragmentManager.BackStackEntry.inContainer: Boolean 13 get() = name?.split("-")?.firstOrNull() == containerId.toString() 14 15 private val FragmentManager.BackStackEntry.tag 16 get() = name?.run { this.removePrefix(split("-").first() + "-") } 17 18 private val baskStackEntries 19 get() = fragmentManager.run { (0 until backStackEntryCount).map(this::getBackStackEntryAt).filter { it.inContainer } } 20 21 private val fragmentTags 22 get() = baskStackEntries.map { it.tag } 23 24 init { 25 fragmentManager.registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() { 26 override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) = auditFragment(f) 27 }, false) 28 } 29 30 override fun push(fragment: Fragment, tag: String): Boolean { 31 val tags = fragmentTags 32 val currentFragmentTag = tags.lastOrNull() 33 if (currentFragmentTag != null && currentFragmentTag == tag) return false 34 35 val fragmentAlreadyExists = tags.contains(tag) 36 37 val fragmentShown = !fragmentAlreadyExists 38 39 val fragmentToShow = 40 (if (fragmentAlreadyExists) fragmentManager.findFragmentByTag(tag) 41 else fragment) ?: throw NullPointerException(MSG_DODGY_FRAGMENT) 42 43 fragmentManager.commit { 44 transactionModifier?.invoke(this, fragment) // 45 replace(containerId, fragmentToShow, tag) 46 addToBackStack(tag.toEntry) 47 } 48 49 return fragmentShown 50 } 51 52 override fun pop(): Boolean = fragmentTags.run { 53 if (size > 1) clear(last(), true).let { true } 54 else false 55 } 56 57 override fun clear(upToTag: String?, includeMatch: Boolean) { 58 // Empty string will be treated as a no-op internally 59 val tag = upToTag?.toEntry ?: baskStackEntries.firstOrNull()?.name ?: "" 60 fragmentManager.popBackStack(tag, if (includeMatch) FragmentManager.POP_BACK_STACK_INCLUSIVE else 0) 61 } 62}

***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 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 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. 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.

1class MultiStackNavigator( 2 stackCount: Int, 3 private val stateContainer: LifecycleSavedStateContainer, 4 private val fragmentManager: FragmentManager, 5 @IdRes override val containerId: Int, 6 private val rootFunction: (Int) -> Pair<Fragment, String>) : Navigator { 7 8 var stackSelectedListener: ((Int) -> Unit)? = null 9 10 var stackTransactionModifier: (FragmentTransaction.(Int) -> Unit)? = null 11 12 var transactionModifier: (FragmentTransaction.(Fragment) -> Unit)? = null 13 set(value) { 14 field = value 15 stackFragments 16 .filter { it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) } 17 .forEach { it.navigator.transactionModifier = value } 18 } 19 20 private val indices = 0 until stackCount 21 private val backStack: Stack<Int> = Stack() 22 private val stackFragments: List<StackFragment> 23 24 private val activeFragment: StackFragment 25 get() = stackFragments.run { firstOrNull(Fragment::isAttached) ?: first() } 26 27 val activeIndex 28 get() = activeFragment.index 29 30 val activeNavigator 31 get() = activeFragment.navigator 32 33 override val currentFragment: Fragment? 34 get() = activeNavigator.currentFragment 35 36 init { 37 fragmentManager.registerFragmentLifecycleCallbacks(StackLifecycleCallback(), false) 38 fragmentManager.addOnBackStackChangedListener { throw IllegalStateException("Fragments may not be added to the back stack of a FragmentManager managed by a MultiStackNavigator") } 39 40 val freshState = stateContainer.isFreshState 41 42 if (freshState) fragmentManager.commitNow { 43 indices.forEach { index -> add(containerId, StackFragment.newInstance(index), index.toString()) } 44 } 45 else fragmentManager.addedStackFragments(indices).forEach { stackFragment -> 46 backStack.push(stackFragment.index) 47 } 48 49 stateContainer.savedState.getIntArray(NAV_STACK_ORDER)?.apply { backStack.sortBy { indexOf(it) } } 50 stackFragments = fragmentManager.addedStackFragments(indices) 51 52 if (freshState) show(0) 53 } 54 55 fun show(index: Int) = showInternal(index, true) 56 57 override fun pop(): Boolean = when { 58 activeFragment.navigator.pop() -> true 59 backStack.run { remove(activeFragment.index); isEmpty() } -> false 60 else -> showInternal(backStack.peek(), false).let { true } 61 } 62 63 override fun clear(upToTag: String?, includeMatch: Boolean) = activeNavigator.clear(upToTag, includeMatch) 64 65 override fun push(fragment: Fragment, tag: String): Boolean = activeNavigator.push(fragment, tag) 66 67 private fun showInternal(index: Int, addTap: Boolean) = fragmentManager.commit { 68 val toShow = stackFragments[index] 69 if (addTap) track(toShow) 70 71 stackTransactionModifier?.invoke(this, index) 72 73 [email protected] for (fragment in stackFragments) when { 74 fragment.index == index && !fragment.isDetached -> continue@transactions 75 fragment.index == index && fragment.isDetached -> attach(fragment) 76 else -> if (!fragment.isDetached) detach(fragment) 77 } 78 79 runOnCommit { stackSelectedListener?.invoke(index) } 80 } 81 82 private fun track(tab: StackFragment) = tab.run { 83 if (backStack.contains(index)) backStack.remove(index) 84 backStack.push(index) 85 stateContainer.savedState.putIntArray(NAV_STACK_ORDER, backStack.toIntArray()) 86 } 87 88 private fun StackFragment.showRoot() = rootFunction(index).apply { navigator.push(first, second) } 89 90 private inner class StackLifecycleCallback : FragmentManager.FragmentLifecycleCallbacks() { 91 92 override fun onFragmentCreated(fm: FragmentManager, fragment: Fragment, savedInstanceState: Bundle?) = fragment.run { 93 if (id != this@MultiStackNavigator.containerId) return 94 check(this is StackFragment) { "Only Stack Fragments may be added to a container View managed by a MultiStackNavigator" } 95 96 if (!stateContainer.isFreshState) return 97 98 if (index != 0) fm.commit { detach(this@run) } 99 } 100 101 override fun onFragmentResumed(fm: FragmentManager, fragment: Fragment) = fragment.run { 102 if (id != this@MultiStackNavigator.containerId) return 103 check(this is StackFragment) { "Only Stack Fragments may be added to a container View managed by a MultiStackNavigator" } 104 105 navigator.transactionModifier = this@MultiStackNavigator.transactionModifier 106 if (hasNoRoot) showRoot() 107 } 108 } 109} 110 111class StackFragment : Fragment() { 112 113 internal lateinit var navigator: StackNavigator 114 115 internal var index: Int by args() 116 private var containerId: Int by args() 117 118 internal val hasNoRoot get() = navigator.currentFragment == null 119 120 override fun onCreate(savedInstanceState: Bundle?) { 121 super.onCreate(savedInstanceState) 122 123 val deferred: StackNavigator by childStackNavigationController(containerId) 124 navigator = deferred 125 } 126 127 override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = 128 FragmentContainerView(inflater.context).apply { id = containerId } 129 130 companion object { 131 internal fun newInstance(index: Int) = StackFragment().apply { this.index = index; containerId = View.generateViewId() } 132 } 133} 134 135const val NAV_STACK_ORDER = "navState" 136 137private val Fragment.isAttached get() = !isDetached 138 139private fun FragmentManager.addedStackFragments(indices: IntRange) = indices 140 .map(Int::toString) 141 .map(::findFragmentByTag) 142 .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 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:

1class MainActivity : AppCompatActivity(R.layout.activity_main) { 2 3 val multiStackNavigator: MultiStackNavigator by multiStackNavigationController( 4 tabs.size, 5 R.id.content_container 6 ) { index -> RouteFragment.newInstance(index).let { it to it.stableTag } } 7 8 public override fun onCreate(savedInstanceState: Bundle?) { 9 super.onCreate(savedInstanceState) 10 11 findViewById<BottomNavigationView>(R.id.bottom_navigation).apply { 12 multiStackNavigator.stackSelectedListener = { menu.findItem(tabs[it])?.isChecked = true } 13 multiStackNavigator.transactionModifier = { incomingFragment -> 14 val current = multiStackNavigator.currentFragment 15 if (current is Navigator.TransactionModifier) current.augmentTransaction(this, incomingFragment) 16 else crossFade() 17 } 18 multiStackNavigator.stackTransactionModifier = { crossFade() } 19 20 setOnApplyWindowInsetsListener { _: View?, windowInsets: WindowInsets? -> windowInsets } 21 setOnNavigationItemSelectedListener { multiStackNavigator.show(tabs.indexOf(it.itemId)).let { true } } 22 setOnNavigationItemReselectedListener { multiStackNavigator.activeNavigator.clear() } 23 } 24 25 onBackPressedDispatcher.addCallback(this) { if (!multiStackNavigator.pop()) finish() } 26 } 27 28 companion object { 29 val tabs = intArrayOf(R.id.menu_navigation, R.id.menu_recyclerview, R.id.menu_communications, R.id.menu_misc) 30 } 31 32}

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 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: