Dev Journal #12 - AndroidX Navigation



One of my favourite Android Jetpack libraries is the Navigation framework. Before the introduction of this library, you’d typically use intents or fragment transactions to switch between activities and fragments. With the new navigation component, you have a centralized place to define all the possible navigation paths between fragments and activities. It also takes care of backstack and type-safe argument passing (with gradle plugin). I’m mindblown to see how much the support library has progressed in the last couple of years 🤯

Dependencies

You will need the following dependencies included in your project to use the Navigation component.

// Project dependency

dependencies {
    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.4"
}
// Module dependency

dependencies {
    implementation "androidx.navigation:navigation-fragment-ktx:2.3.4"
    implementation "androidx.navigation:navigation-ui-ktx:2.3.4"
}

Single Activity Architecture

I would highly encourage you to watch this talk given by Ian Lake: Single activity: Why, when and how

Why do we even have activities? They’re useful as an entry point but beyond that, you’re in your realm.

- Ian Lake, Android Dev Summit 2018

Here are the key takeaways on why single activity architectures are recommended:

Problem with multiple activities
Possible solution

Following this advice, I adopted the single activity architecture in my app by having just one* launcher activity acting as the entry point. Once the app has launched, every view within the app is controlled by fragments.

*Technically, I have two activities. The very first activity is a SplashActivity that acts as a splash screen to render background drawable for the duration of the cold boot. This activity immediately creates intent to start the “real” single activity which houses the rest of my views.

My app has two levels of navigation graphs. The outer navigation graph handles the flow from the cold boot to authentication fragments: [login, registration and forgot-password]

The inner navigation graph handles the post-authentication navigation. This second navigation is implemented with the bottom navigation bar where users can swap between multiple fragments by selecting a menu item on the bar.

Nesting graphs can be achieved by placing the <include> tag under the parent navigation graph. In my graph, I have defined the entry point with app:startDestination property as SplashFragment. This fragment shares the same LoginViewModel as my LoginFragment. I’ll be focusing more on the ViewModels in a separate upcoming post. But for now, you can think of it as a shared scope between the two fragments.

<?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/nav_graph"
    app:startDestination="@id/destination_splash">
    <include app:graph="@navigation/bottom_nav_graph" />
    <fragment
        android:id="@+id/destination_login"
        android:name="com.leoybkim.findingfitness.login.LoginFragment"
        android:label="LoginFragment"
        tools:layout="@layout/fragment_login">
        <action
            android:id="@+id/action_loginFragment_to_registerFragment"
            app:destination="@+id/destination_register" />
        <action
            android:id="@+id/action_loginFragment_to_forgotPasswordFragment"
            app:destination="@id/forgotPasswordFragment" />
        <action
            android:id="@+id/action_loginFragment_to_bottomNavigation"
            app:popUpTo="@+id/destination_login"
            app:popUpToInclusive="true"
            app:destination="@id/bottom_nav_graph" />
    </fragment>
    <fragment
        android:id="@+id/destination_register"
        android:name="com.leoybkim.findingfitness.register.RegisterFragment"
        android:label="RegisterFragment"
        tools:layout="@layout/fragment_register" />
    <fragment
        android:id="@+id/destination_forgot_password"
        android:name="com.leoybkim.findingfitness.forgotpassword.ForgotPasswordFragment"
        android:label="ForgotPasswordFragment"
        tools:layout="@layout/fragment_forgot_password" />
    <fragment
        android:id="@+id/destination_splash"
        android:name="com.leoybkim.findingfitness.login.SplashFragment"
        android:label="SplashFragment">
        <action
            android:id="@+id/action_splashFragment_to_loginFragment"
            app:destination="@id/destination_login"
            app:popUpTo="@+id/destination_splash"
            app:popUpToInclusive="true" />
        <action
            android:id="@+id/action_splashFragment_to_bottom_nav_graph"
            app:destination="@id/bottom_nav_graph" />
    </fragment>
</navigation>

The SplashFragment will observe the authentication state mutated by the LoginViewModel. If the user is not authenticated, it will navigate them to the LoginFragment. If the user is authenticated, it will navigate them to the nested bottom navigation graph. This child graph also has its own entry point defined with app:startDestination property. So pre-authenticated users will be directed to the ProfileFragment upon launching the app.

<?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"
    android:id="@+id/bottom_nav_graph"
    app:startDestination="@id/destination_profile">

    <fragment
        android:id="@+id/destination_profile"
        android:name="com.leoybkim.findingfitness.profile.ProfileFragment"
        android:label="@string/profile">
        <action
            android:id="@+id/action_profileFragment_to_loginFragment"
            app:destination="@id/destination_login" />
    </fragment>
    <fragment
        android:id="@+id/destination_achievements"
        android:name="com.leoybkim.findingfitness.fragments.AchievementsFragment"
        android:label="@string/achievements" />
    <fragment
        android:id="@+id/destination_logs"
        android:name="com.leoybkim.findingfitness.fragments.LogsFragment"
        android:label="@string/logs" />
    <fragment
        android:id="@+id/destination_settings"
        android:name="com.leoybkim.findingfitness.settings.SettingsFragment"
        android:label="@string/settings">
        <action
            android:id="@+id/action_settingFragment_to_loginFragment"
            app:destination="@id/destination_login" />
    </fragment>
    <fragment
        android:id="@+id/destination_edit_profile"
        android:name="com.leoybkim.findingfitness.editprofile.EditProfileFragment"
        android:label="@string/edit_profile">
        <action
            android:id="@+id/action_editProfileFragment_to_loginFragment"
            app:destination="@id/destination_login" />
        <action
            android:id="@+id/action_editProfileFragment_to_profileFragment"
            app:destination="@id/destination_profile" />
    </fragment>
</navigation>

Navigating between fragments can be accomplished by providing the NavController with an action ID or a destination ID. Actions are the possible navigation paths defined inside the graph. Destinations are referring to the fragment resource. The Android developer’s guide recommends using the action id where possible because actions can provide additional compile-time safety and offer animations between transitions.

private lateinit var navController: NavController

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    navController = this.findNavController()

    // ...

    navController.navigate(LoginFragmentDirections.actionLoginFragmentToRegisterFragment())
}

There are two views in the layout of my single activity:

  1. FragmentContainerView

    • NavHostFragment widget that provides an area for self-contained navigation to occur.
  2. BottomNavigationView

<layout 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">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorMintCream"
        android:orientation="vertical"
        tools:context=".FindingFitnessActivity">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottom_nav"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:menu="@menu/menu_navigation" />

    </LinearLayout>
</layout>

The blank area labeled as the nav_host_fragment is where different views can be swapped in and out whilst the bottom_nav is fixed in position the whole time.

Setting up the bottom navigation can be handled by the NavigationUI class. NavigationUI class is included as part of the navigation component. It can also be used to manage the top app bar and the navigation drawer.

You will need to first define the menu items to be displayed on the bottom navigation bar:

<!-- menu_navigation.xml -->
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/destination_profile"
        android:icon="@drawable/ic_person_black_24dp"
        android:title="@string/profile" />

    <item
        android:id="@+id/destination_logs"
        android:icon="@drawable/ic_fitness_center_black_24dp"
        android:title="@string/logs" />

    <item
        android:id="@+id/destination_achievements"
        android:icon="@drawable/ic_star_black_24dp"
        android:title="@string/achievements" />
</menu>

Pre-authentication fragments like the login screen and registration screen need to hide the bottom navigation visibility. This can be done by adding the OnDestinationChangedListener to the NavController inside the onCreate of the single activity, and by setting the visibility based on the destination.

private lateinit var binding: ActivityFindingFitnessBinding
private lateinit var navController: NavController

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityFindingFitnessBinding.inflate(layoutInflater)
    setContentView(view)
    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    navController = navHostFragment.navController


    // Setup bottom navigation
    NavigationUI.setupWithNavController(binding.bottomNav, navController)

    // Setup navigation listener
    navController.addOnDestinationChangedListener { _, destination, _ ->
        when (destination.id) {
            R.id.destination_splash,
            R.id.destination_login,
            R.id.destination_register,
            R.id.destination_forgot_password,
            -> {
                binding.bottomNav.visibility = View.GONE
            }
            else -> {
                binding.bottomNav.visibility = View.VISIBLE
            }
        }
    }
}
Previous Post Next Post