Fragments. It wasn’t too long ago that Google was advising people not to use them, and now they’re using them like crazy with their new UI paradigms. You’ve probably seem the BottomNavigationLayout that Google seems to be in love with now, after telling everyone it was an anti pattern in material design – I guess now it isn’t?
Where else have we seen this? iOS! iOS has been doing this paradigm of navigation for years, and have it basically mastered at this point. So what’s Google given us? A view. A single layout. That’s it. Now that might be fine – drag the BottomNavigationLayout into each activity, place it at the same position, easy! Except you can’t do that, because the BottomNavigationLayout view has an animation that goes along with swapping the view. So you need that BottomNavigationLayout to always be on top, and that means – fragments.
Now I’m not really a fan of the way Android handles Activities and Fragments already, and combining Fragments with the back stack is asking for trouble. What we’re gonna do today is steal the iOS way of doing this and implement it in Android, so we don’t all go crazy now that designers and implementing this navigation in every new app.
First let’s have a quick look at how iOS does it. iOS has two special things it uses – UITabBarController and UINavigationController. Now you don’t have to use them together, but together is when they work really well. Let me show you the hierarchy visually.
UITabBarController and UINavigationController are subclasses of UIViewController, but UIViewControllers can be added as child view controllers to any other UIViewController. Translated to Android, the UITabBarController would be an Activity, and everything underneath would be a Fragment. The UINavigationController has a stack of child UIViewControllers and it manages these.
Since this works really well, we’re gonna replicate this for Android. We’ll be making two things – a TabBarController, which will be an Activity, and a NavigationController, which will be a Fragment. The TabBarController will have multiple NavigationControllers, and each NavigationController will manage its own child fragments.
Step 1 – TabBarController
First, the TabBarController. This will be the thing that holds our BottomNavigationLayout and container. We’re gonna use a ViewPager for our container, since this works well. First the layout:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
xmlns:tools="http://schemas.android.com/tools" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent"> | |
<android.support.design.widget.BottomNavigationView | |
android:id="@+id/tab_bar" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:menu="@menu/tab_bar_items" /> | |
<com.stickerbox.tabbarcontroller.FixedViewPager | |
android:id="@+id/tab_bar_container" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintTop_toTopOf="parent" | |
app:layout_constraintBottom_toTopOf="@id/tab_bar"/> | |
</android.support.constraint.ConstraintLayout> |
The FixedViewPager you see there is a subclass of ViewPager which just disables swiping and touches.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class FixedViewPager @JvmOverloads constructor(context: Context, | |
attrs: AttributeSet? = null) : ViewPager(context, attrs) { | |
override fun onTouchEvent(ev: MotionEvent?) = false | |
override fun onInterceptTouchEvent(ev: MotionEvent?) = false | |
} |
Now make your TabBarController file, extending AppCompatActivity (or your base activity if you want). This will setup our BottomNavigationLayout with an adapter, which we’ll also define here, and setup listeners for when we select and reselect tabs. We’re also gonna override onBackPressed and push it down our children. You can also do the same for onActivityResult, etc.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class TabBarController : AppCompatActivity() { | |
private val adapter by lazy { TabAdapter(supportFragmentManager) } | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_tab_bar_controller) | |
tab_bar_container.adapter = adapter | |
tab_bar_container.offscreenPageLimit = adapter.count | |
tab_bar_container.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { | |
override fun onPageScrollStateChanged(state: Int) {} | |
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} | |
override fun onPageSelected(position: Int) { | |
invalidateOptionsMenu() | |
} | |
}) | |
tab_bar.setOnNavigationItemSelectedListener { | |
tabbar_container.currentItem = it.order | |
true | |
} | |
tab_bar.setOnNavigationItemReselectedListener { | |
adapter.getItem(tab_bar_container.currentItem).popToRoot() | |
} | |
} | |
override fun onBackPressed() { | |
if (!adapter.getItem(tab_bar_container.currentItem).onBackPressed()) { | |
super.onBackPressed() | |
} | |
} | |
class TabAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) { | |
private val fragments = mutableMapOf<Int, NavigationController>() | |
override fun getItem(position: Int): NavigationController { | |
if (fragments[position] == null) { | |
fragments[position] = when (position) { | |
0 -> NavigationController.newInstance(HomeFragment()) | |
1 -> NavigationController.newInstance(SearchFragment()) | |
else -> throw IllegalArgumentException("Unsupported position: $position") | |
} | |
} | |
return fragments[position]!! | |
} | |
override fun getCount() = 2 | |
} | |
} |
Obviously here you want to inflate your own menu and change the TabAdapter to return your own fragments. Don’t do that yet – we’ll get to it. Next we’re gonna look at the NavigationController.
Step 2 – NavigationController
Fragment Transactions typically take place with a FrameLayout, so to make this easier, we’re first gonna extend FrameLayout to provide some helper methods for us.
Here we’ve just got helper methods to push and pop fragments, and a private method to actually do the work for us. We’re also not allowing the transaction to be added to the back stack as it’s buggy as hell so we’re gonna handle this ourselves.
Now we need to make a NavigationController class that extends Fragment.
Each NavigationController has to be initialised with a root fragment. This is just a fragment that wraps calls to the NavigationView and has an onBackPressed function. This function is called when the back button is pressed and the message is sent from the TabBarController to the currently active NavigationController. With this implementation, if the NavigationController can pop it pops, otherwise says it can’t handle it. You can of course change this if your app design does different things with the back button.
Finally, the layout for the NavigationController fragment. It’s just a single root element of NavigationView with match parent for width and height and an id of navigation_view.
Step 3 – Putting it all together
Finally we’re ready. You’ll have some errors in your TabBarController – these are just the root fragments used to initialise each NavigationController. Change these to your own fragments that you want to be the root of each tab. Once you’ve done that you should be able to build and run and be able to click tabs and see the ViewPager change to the other fragment. Nice! So there’s one last step – how to we actually control navigation? Since we’re doing it anyway, let’s steal this from iOS too.
In iOS land, on any view controller you have an optional property of navigationController. From there you can tell it to pop, push a new view, or pop to the root. So let’s do that here.
Back in the NavigationController.kt file, at the very bottom, add this extension function.
This recursive function just walks up the parent fragments until it finds the NavigationController. If the fragment isn’t part of a NavigationController, it’ll return null. Finally we add another property extension to make this more Kotlin-like, and make it by lazy so we only need to walk the tree the first time we access it.
So now in any Fragment subclass, we can just do:
navigationController?.pop()
navigationController?.popToRoot()
navigationController?.push(FragmentToPush())
Finally, we’re done. We can now push and pop fragments with ease and not have to worry about the FragmentManager or doing transactions every time.
You can download a fully working example of this here