Android Week: Fixing Google’s mistake

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.

New Project.png

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:


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


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.


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.


class NavigationView @JvmOverloads constructor(context: Context,
attrs: AttributeSet? = null) : FrameLayout(context, attrs) {
private enum class Operation {
FORWARD, BACKWARD, ROOT
}
lateinit var rootFragment: Fragment
var fragmentManager: FragmentManager? = null
private var activeFragment: Fragment? = null
private val fragments = mutableListOf<Fragment>()
val hasFragmentsVisible: Boolean
get() = fragments.isNotEmpty()
val canPop: Boolean
get() = fragments.size > 1
fun push(fragment: Fragment) {
fragments.add(fragment)
updateUi(Operation.FORWARD)
}
fun pop() {
if (!canPop) return
fragments.removeAt(fragments.size 1)
updateUi(Operation.BACKWARD)
}
fun popToRoot() {
updateUi(Operation.ROOT)
}
private fun updateUi(op: Operation) {
if (fragmentManager == null) throw IllegalStateException("Fragment manager has not be set")
val transaction = fragmentManager!!.beginTransaction()
if (activeFragment != null) {
transaction.detach(activeFragment)
}
when (op) {
Operation.FORWARD -> {
activeFragment = fragments.last()
transaction.add(id, activeFragment)
}
Operation.BACKWARD -> {
transaction.remove(activeFragment)
activeFragment = fragments.last()
transaction.attach(activeFragment)
}
Operation.ROOT -> {
fragments.forEach {
transaction.remove(it)
}
fragments.clear()
activeFragment = rootFragment
fragments.add(activeFragment!!)
transaction.add(id, activeFragment)
}
}
transaction.setCustomAnimations(android.R.anim.fade_in, android.R.anim.fade_out, android.R.anim.fade_in, android.R.anim.fade_out)
transaction.disallowAddToBackStack()
transaction.commit()
}
}

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.


class NavigationController : Fragment() {
private lateinit var navigationView: NavigationView
lateinit var rootFragment: Fragment
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_navigation_controller, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navigationView = navigation_view
navigationView.rootFragment = rootFragment
navigationView.fragmentManager = childFragmentManager
navigationView.popToRoot()
}
fun pop() {
navigationView.pop()
}
fun popToRoot() {
navigationView.popToRoot()
}
fun push(fragment: Fragment) {
navigationView.push(fragment)
}
fun onBackPressed() : Boolean {
return if (!navigationView.canPop) {
false
} else {
navigationView.pop()
true
}
}
companion object {
fun newInstance(root: Fragment) : NavigationController {
val instance = NavigationController()
instance.rootFragment = root
return instance
}
}
}

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.


<?xml version="1.0" encoding="utf-8"?>
<com.stickerbox.tabbarcontroller.NavigationView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/navigation_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

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.


private fun Fragment.getNavigationController(parent: Fragment? = null) : NavigationController? {
if (parentFragment == null) return null
if (parent == null) return getNavigationController(parentFragment)
if (parentFragment!! is NavigationController) {
return parentFragment as NavigationController
}
return getNavigationController(parentFragment)
}
val Fragment.navigationController : NavigationController? by lazy {
getNavigationController(parentFragment)
}

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

 

 

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s