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

前端 未结 13 1995
误落风尘
误落风尘 2020-12-12 10:25

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

What I wanted, after choosing multiple tabs one after another by

13条回答
  •  温柔的废话
    2020-12-12 11:27

    First, let me clarify how Youtube and Instagram handles fragment navigation.

    • When the user is on a detail fragment, back or up pop the stack once, with the state properly restaured. A second click on the already selected bottom bar item pop all the stack to the root, refreshing it
    • When the user is on a root fragment, back goes to the last menu selected on the bottom bar, displaying the last detail fragment, with the state properly restaured (JetPack doesn't)
    • When the user is on the start destination fragment, back finishes activity

    None of the other answers above solve all this problems using the jetpack navigation.

    JetPack navigation has no standard way to do this, the way that I found more simple is to dividing the navigation xml graph into one for each bottom navigation item, handling the back stack between the navigation items myself using the activity FragmentManager and use the JetPack NavController to handle the internal navigation between root and detail fragments (its implementation uses the childFragmentManager stack).

    Suppose you have in your navigation folder this 3 xmls:

    res/navigation/
        navigation_feed.xml
        navigation_explore.xml
        navigation_profile.xml
    

    Have your destinationIds inside the navigation xmls the same of your bottomNavigationBar menu ids. Also, to each xml set the app:startDestination to the fragment that you want as the root of the navigation item.

    Create a class BottomNavController.kt:

    class BottomNavController(
            val context: Context,
            @IdRes val containerId: Int,
            @IdRes val appStartDestinationId: Int
    ) {
        private val navigationBackStack = BackStack.of(appStartDestinationId)
        lateinit var activity: Activity
        lateinit var fragmentManager: FragmentManager
        private var listener: OnNavigationItemChanged? = null
        private var navGraphProvider: NavGraphProvider? = null
    
        interface OnNavigationItemChanged {
            fun onItemChanged(itemId: Int)
        }
    
        interface NavGraphProvider {
            @NavigationRes
            fun getNavGraphId(itemId: Int): Int
        }
    
        init {
            var ctx = context
            while (ctx is ContextWrapper) {
                if (ctx is Activity) {
                    activity = ctx
                    fragmentManager = (activity as FragmentActivity).supportFragmentManager
                    break
                }
                ctx = ctx.baseContext
            }
        }
    
        fun setOnItemNavigationChanged(listener: (itemId: Int) -> Unit) {
            this.listener = object : OnNavigationItemChanged {
                override fun onItemChanged(itemId: Int) {
                    listener.invoke(itemId)
                }
            }
        }
    
        fun setNavGraphProvider(provider: NavGraphProvider) {
            navGraphProvider = provider
        }
    
        fun onNavigationItemReselected(item: MenuItem) {
            // If the user press a second time the navigation button, we pop the back stack to the root
            activity.findNavController(containerId).popBackStack(item.itemId, false)
        }
    
        fun onNavigationItemSelected(itemId: Int = navigationBackStack.last()): Boolean {
    
            // Replace fragment representing a navigation item
            val fragment = fragmentManager.findFragmentByTag(itemId.toString())
                    ?: NavHostFragment.create(navGraphProvider?.getNavGraphId(itemId)
                            ?: throw RuntimeException("You need to set up a NavGraphProvider with " +
                                    "BottomNavController#setNavGraphProvider")
                    )
            fragmentManager.beginTransaction()
                    .setCustomAnimations(
                            R.anim.nav_default_enter_anim,
                            R.anim.nav_default_exit_anim,
                            R.anim.nav_default_pop_enter_anim,
                            R.anim.nav_default_pop_exit_anim
                    )
                    .replace(containerId, fragment, itemId.toString())
                    .addToBackStack(null)
                    .commit()
    
            // Add to back stack
            navigationBackStack.moveLast(itemId)
    
            listener?.onItemChanged(itemId)
    
            return true
        }
    
        fun onBackPressed() {
            val childFragmentManager = fragmentManager.findFragmentById(containerId)!!
                    .childFragmentManager
            when {
                // We should always try to go back on the child fragment manager stack before going to
                // the navigation stack. It's important to use the child fragment manager instead of the
                // NavController because if the user change tabs super fast commit of the
                // supportFragmentManager may mess up with the NavController child fragment manager back
                // stack
                childFragmentManager.popBackStackImmediate() -> {
                }
                // Fragment back stack is empty so try to go back on the navigation stack
                navigationBackStack.size > 1 -> {
                    // Remove last item from back stack
                    navigationBackStack.removeLast()
    
                    // Update the container with new fragment
                    onNavigationItemSelected()
                }
                // If the stack has only one and it's not the navigation home we should
                // ensure that the application always leave from startDestination
                navigationBackStack.last() != appStartDestinationId -> {
                    navigationBackStack.removeLast()
                    navigationBackStack.add(0, appStartDestinationId)
                    onNavigationItemSelected()
                }
                // Navigation stack is empty, so finish the activity
                else -> activity.finish()
            }
        }
    
        private class BackStack : ArrayList() {
            companion object {
                fun of(vararg elements: Int): BackStack {
                    val b = BackStack()
                    b.addAll(elements.toTypedArray())
                    return b
                }
            }
    
            fun removeLast() = removeAt(size - 1)
            fun moveLast(item: Int) {
                remove(item)
                add(item)
            }
        }
    }
    
    // Convenience extension to set up the navigation
    fun BottomNavigationView.setUpNavigation(bottomNavController: BottomNavController, onReselect: ((menuItem: MenuItem) -> Unit)? = null) {
        setOnNavigationItemSelectedListener {
            bottomNavController.onNavigationItemSelected(it.itemId)
        }
        setOnNavigationItemReselectedListener {
            bottomNavController.onNavigationItemReselected(it)
            onReselect?.invoke(it)
        }
        bottomNavController.setOnItemNavigationChanged { itemId ->
            menu.findItem(itemId).isChecked = true
        }
    }
    

    Do your layout main.xml like this:

    
    
        
    
        
    
    
    

    Use on your activity like this:

    class MainActivity : AppCompatActivity(),
            BottomNavController.NavGraphProvider  {
    
        private val navController by lazy(LazyThreadSafetyMode.NONE) {
            Navigation.findNavController(this, R.id.container)
        }
    
        private val bottomNavController by lazy(LazyThreadSafetyMode.NONE) {
            BottomNavController(this, R.id.container, R.id.navigation_feed)
        }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.main)
    
            bottomNavController.setNavGraphProvider(this)
            bottomNavigationView.setUpNavigation(bottomNavController)
            if (savedInstanceState == null) bottomNavController
                    .onNavigationItemSelected()
    
            // do your things...
        }
    
        override fun getNavGraphId(itemId: Int) = when (itemId) {
            R.id.navigation_feed -> R.navigation.navigation_feed
            R.id.navigation_explore -> R.navigation.navigation_explore
            R.id.navigation_profile -> R.navigation.navigation_profile
            else -> R.navigation.navigation_feed
        }
    
        override fun onSupportNavigateUp(): Boolean = navController
                .navigateUp()
    
        override fun onBackPressed() = bottomNavController.onBackPressed()
    }
    

提交回复
热议问题