Search code examples
androidandroid-fragmentsandroid-jetpack-navigation

Android Navigation Component: Hitting back results in NavController being out of sync


I'm in the process of migrating an app from manual backstack manipulation via FragmentManager to using the new navigation library. The app consists of a MainActivity that has a DrawerLayout with 10-12 items in the drawer. When tapped some fragment manipulation happens and a new fragment is shown.

There's enough code in this production application that it's not feasible to simply rip out the old navigation structure and move to the navigation library whole cloth.

Instead, what I'm doing is migrating drawer item by drawer item. For example - There's a feature called check deposit which is accessed via an item in the nav drawer. When you tap that item, you're shown a new fragment and you can progress through a check deposit wizard-like flow. So far what I've done is created a new RootCheckDeposit fragment whose View consists of a single FragmentContainerView that houses a nav graph. Then the whole "flow" of that check deposit wizard is navigated through in the nav graph that's ultimately contained in the RootcheckDeposit.

Here's the layout for that RootCheckDeposit fragment:

<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/check_deposit_graph" />

That's working mostly ok, though the integration with the toolbar is still a bit wonky.

But the main problem I'm having is that as you progress through this wizard, hitting back can cause the NavController to get into a state where it seems to think it's on a different fragment than it actually is. This is reliably happening in the following flow within the check deposit wizard:

Loading screen -> Terms screen -> Check deposit landing screen.

Once you get to the check deposit landing screen, if you hit back nothing happens - which is fine. What's not fine is if you then click on a button in that landing screen that would progress you through the wizard the app crashes with an IllegalArgumentException, claiming that we're trying to use an action that's not available from Loading Screen. Which is weird because we're not on the Loading Screen, we're on the Check Deposit Landing Screen.

Here's my nav graph file for reference:

<?xml version="1.0" encoding="utf-8"?>
<navigation 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/check_deposit_graph"
    app:startDestination="@id/loadingFragment">

    <fragment
        android:id="@+id/loadingFragment"
        android:name="module.checkdeposit.loading.TermsLoadingFragment"
        android:label="@string/check_deposit_title"
        tools:layout="@layout/terms_loading_fragment" >
        <action
            android:id="@+id/action_loadingFragment_to_termsAndConditionsFragment"
            app:destination="@id/termsAndConditionsFragment"
            app:popUpTo="@id/loadingFragment"
            app:popUpToInclusive="true" />
        <action
            android:id="@+id/action_loadingFragment_to_depositOptionsFragment"
            app:destination="@id/depositLandingFragment"
            app:popUpTo="@id/loadingFragment"
            app:popUpToInclusive="true" />
    </fragment>

    <fragment
        android:id="@+id/termsAndConditionsFragment"
        android:name="module.checkdeposit.terms.TermsAndConditionsFragment"
        android:label="@string/check_deposit_terms_and_conditions"
        tools:layout="@layout/terms_and_conditions_fragment" >
        <action
            android:id="@+id/action_termsAndConditionsFragment_to_depositLandingFragment"
            app:destination="@id/depositLandingFragment"
            app:popUpTo="@id/termsAndConditionsFragment"
            app:popUpToInclusive="true" />
    </fragment>

    <fragment
        android:id="@+id/depositLandingFragment"
        android:name="module.checkdeposit.depositlanding.DepositLandingFragment"
        android:label="@string/check_deposit_title"
        tools:layout="@layout/deposit_landing_fragment" >
        <action
            android:id="@+id/action_depositLandingFragment_to_depositHistoryFragment"
            app:destination="@id/depositHistoryFragment" />
        <action
            android:id="@+id/action_depositLandingFragment_to_scanCheckFragment"
            app:destination="@id/scanCheckFragment" />
    </fragment>

    <fragment
        android:id="@+id/depositHistoryFragment"
        android:name="module.checkdeposit.deposithistory.DepositHistoryFragment"
        android:label="@string/check_deposit_deposit_history"
        tools:layout="@layout/deposit_history_fragment" />

    <fragment
        android:id="@+id/scanCheckFragment"
        android:name="module.checkdeposit.scancheck.ScanCheckFragment"
        android:label=""
        tools:layout="@layout/scan_check_fragment" />
</navigation>

Of note - when we go from Loading screen to Terms screen to Landing Screen we're popping each item because I don't want the user to be able to go "back" to a loading screen or a terms screen.

Things I've tried:

  • Changing the popUpTo logic in the nav graph to point to the actual nav graph instead of the destination
  • Check to see if the result of navController.popBackStack is true or false and take some action accordingly

I've also combed through the actual NavController code, and what seems to be happening is that the NavController is popping its internal backstack when back is clicked but not changing destinations because there's really no other destination to go to. It just seems like the NavController is then in a weird inconsistent state where it and the currently displayed Fragment are out of whack/not pointing to the same thing.

Has anyone else done this sort of migration with any success?

EDIT: I've created a repo that has a minimal project to recreate the crash I'm running into: https://github.com/alexsullivan114/ExampleNavigationCrashRepo. This repo kind of mimics the structure of the production app I'm working on. If you click into the slideshow drawer item then continue clicking on the text in the screens until you get to Next Example Fragment then hit back a few times and click the text again you'll see the crash.

I'm sure the issue is in how I've setup the navigation library, I'm just not sure what the right way is without rewriting the rest of the apps navigation


Solution

  • As per the considerations for child and sibling fragments guide:

    Only one FragmentManager is allowed to control the fragment back stack at any given time. If your app shows multiple sibling fragments on the screen at the same time, or if your app uses child fragments, then one FragmentManager must be designated to handle your app's primary navigation.

    To define the primary navigation fragment inside of a fragment transaction, call the setPrimaryNavigationFragment() method on the transaction, passing in the instance of the fragment whose childFragmentManager should have primary control.

    As explained in the Interact programmatically with Navigation, the app:defaultNavHost="true" is calling setPrimaryNavigationFragment() for you on the NavHostFragment itself, but you aren't calling setPrimaryNavigationFragment() on the parent fragment - your RootSlideshowFragment. This means that none of the logic to automatically handle the back button correctly is being called - you've essentially broke the chain from the activity to the parent fragment to the NavHostFragment. This causes you to intercept the back button even when there's no back stack (Navigation doesn't actually remove the last fragment when you pop it as otherwise it would not be present when your activity's exit animation is running).

    Therefore to fix this, you need to:

    1. Remove your OnBackPressedCallback from RootSlideshowFragment. It is not needed.
    2. When you swap between fragments in your MainActivity, call setPrimaryNavigationFragment() on the new Fragment:
    navView.setNavigationItemSelectedListener { menuitem ->
        val newFragment = when (menuitem.itemId) {
            R.id.nav_home -> HomeFragment()
            R.id.nav_gallery-> GalleryFragment()
            else -> RootSlideshowFragment()
        }
        supportFragmentManager.beginTransaction()
            .replace(R.id.container, newFragment)
            .setPrimaryNavigationFragment(newFragment)
            .commit()
        true
    }