Search code examples
androidandroid-architecture-componentsbottomnavigationviewandroid-navigationandroid-architecture-navigation

Android Jetpack Navigation, BottomNavigationView with Youtube or Instagram like proper back navigation (fragment back stack)?


Android Jetpack Navigation, BottomNavigationView with auto fragment back stack on back button click?

What I wanted, after choosing multiple tabs one after another by user and user click on back button app must redirect to the last page he/she opened.

I achieved the same using Android ViewPager, by saving the currently selected item in an ArrayList. Is there any auto back stack after Android Jetpack Navigation Release? I want to achieve it using navigation graph

activity_main.xml

<?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:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".main.MainActivity">

    <fragment
        android:id="@+id/my_nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@+id/navigation"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />

    <android.support.design.widget.BottomNavigationView
        android:id="@+id/navigation"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/navigation" />

</android.support.constraint.ConstraintLayout>

navigation.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_people"
        android:icon="@drawable/ic_group"
        android:title="@string/title_people" />

    <item
        android:id="@+id/navigation_organization"
        android:icon="@drawable/ic_organization"
        android:title="@string/title_organization" />

    <item
        android:id="@+id/navigation_business"
        android:icon="@drawable/ic_business"
        android:title="@string/title_business" />

    <item
        android:id="@+id/navigation_tasks"
        android:icon="@drawable/ic_dashboard"
        android:title="@string/title_tasks" />

</menu>

also added

bottomNavigation.setupWithNavController(Navigation.findNavController(this, R.id.my_nav_host_fragment))

I got one answer from Levi Moreira, as follows

navigation.setOnNavigationItemSelectedListener {item ->

            onNavDestinationSelected(item, Navigation.findNavController(this, R.id.my_nav_host_fragment))

        }

But by doing this only happening is that last opened fragment's instance creating again.

Providing proper Back Navigation for BottomNavigationView


Solution

  • Since Navigation version 2.4.0, BottomNavigationView with NavHostFragment supports a separate back stack for each tab, so Elyeante answer is 50% correct. But it doesn't support back stack for primary tabs. For example, if we have 4 main fragments (tabs) A, B, C, and D, the startDestination is A. D has child fragments D1, D2, and D3. If user navigates like A -> B -> C ->D -> D1 -> D2-> D3, if the user clicks the back button with the official library the navigation will be D3 -> D2-> D1-> D followed by A. That means primary tabs B and C will not be in the back stack.

    To support the primary tab back stack, I created a stack with a primary tab navigation reference. On the user's back click, I updated the selected item of BottomNavigationView based on the stack created.

    I have created this Github repo to show what I did. I reached this answer with the following medium articles.

    Steps to implement

    Add the latest navigation library to Gradle and follow Official repo for supporting back stack for child fragments.

    Instead of creating single nav_graph, we have to create separate navigation graphs for each bottom bar item and this three graph should add to one main graph as follows

    <navigation
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/nav_graph"
        app:startDestination="@+id/home">
    
        <include app:graph="@navigation/home"/>
        <include app:graph="@navigation/list"/>
        <include app:graph="@navigation/form"/>
    
    </navigation>
    

    And link bottom navigation view and nav host fragment with setupWithNavController

    Now the app will support back stack for child fragments. For supporting the main back navigation, we need to add more lines.

    private var addToBackStack: Boolean = true
    private lateinit var fragmentBackStack: Stack<Int>
    

    The fragmentBackStack will help us to save all the visited destinations in the stack & addToBackStack is a checker which will help to determine if we want to add the current destination into the stack or not.

    navHostFragment.findNavController().addOnDestinationChangedListener { _, destination, _ ->
        val bottomBarId = findBottomBarIdFromFragment(destination.id)
        if (!::fragmentBackStack.isInitialized){
            fragmentBackStack = Stack()
        }
        if (needToAddToBackStack && bottomBarId!=null) {
            if (!fragmentBackStack.contains(bottomBarId)) {
                fragmentBackStack.add(bottomBarId)
            } else if (fragmentBackStack.contains(bottomBarId)) {
                if (bottomBarId == R.id.home) {
                    val homeCount =
                        Collections.frequency(fragmentBackStack, R.id.home)
                    if (homeCount < 2) {
                        fragmentBackStack.push(bottomBarId)
                    } else {
                        fragmentBackStack.asReversed().remove(bottomBarId)
                        fragmentBackStack.push(bottomBarId)
                    }
                } else {
                    fragmentBackStack.remove(bottomBarId)
                    fragmentBackStack.push(bottomBarId)
                }
            }
    
        }
        needToAddToBackStack = true
    }
    

    When navHostFragment changes the fragment we get a callback to addOnDestinationChangedListener and we check whether the fragment is already existing in the Stack or not. If not we will add to the top of the Stack, if yes we will swap the position to the Stack's top. As we are now using separate graph for each tab the id in the addOnDestinationChangedListener and BottomNavigationView will be different, so we use findBottomBarIdFromFragment to find BottomNavigationView item id from destination fragment.

    private fun findBottomBarIdFromFragment(fragmentId:Int?):Int?{
        if (fragmentId!=null){
            val bottomBarId = when(fragmentId){
                R.id.register ->{
                    R.id.form
                }
                R.id.leaderboard -> {
                    R.id.list
                }
                R.id.titleScreen ->{
                    R.id.home
                }
                else -> {
                    null
                }
            }
            return bottomBarId
        } else {
            return null
        }
    }
    

    And when the user clicks back we override the activity's onBackPressed method(NB:onBackPressed is deprecated I will update the answer once I find a replacement for super.onBackPressed() inside override fun onBackPressed())

    override fun onBackPressed() {
        val bottomBarId = if (::navController.isInitialized){
            findBottomBarIdFromFragment(navController.currentDestination?.id)
        } else {
            null
        }
        if (bottomBarId!=null) {
            if (::fragmentBackStack.isInitialized && fragmentBackStack.size > 1) {
                if (fragmentBackStack.size == 2 && fragmentBackStack.lastElement() == fragmentBackStack.firstElement()){
                    finish()
                } else {
                    fragmentBackStack.pop()
                    val fragmentId = fragmentBackStack.lastElement()
                    needToAddToBackStack = false
                    bottomNavigationView.selectedItemId = fragmentId
                }
            } else {
                if (::fragmentBackStack.isInitialized && fragmentBackStack.size == 1) {
                    finish()
                } else {
                    super.onBackPressed()
                }
            }
        } else super.onBackPressed()
    }
    

    When the user clicks back we will pop the last fragment from Stack and set the selected item id in the bottom navigation view.

    Medium Link