A Navigation-ized To-Do List

The FragmentNav sample module in the Sampler and SamplerJ projects mostly is a clone of last chapter’s FragmentManual module. In this edition of the app, though, we use the Navigation component to get between the ListFragment and the DisplayFragment. We also switch to using a Toolbar here for our app bar, so we can get the up button added for us when we are on the DisplayFragment.

The Dependencies

The Navigation component is relatively complex in terms of dependencies, as we have variations for Java and Kotlin plus additional setup for the Safe Args feature.

The Basics

There are two main dependencies that most apps will use to employ the Navigation component:

  1. androidx.navigation:navigation-fragment provides the core support for navigation, particularly using fragments
  2. androidx.navigation:navigation-ui offers the integration with the Toolbar and other forms of app bar

These dependencies are closely coupled and generally will need to have the same version. So, the overall Sampler/SamplerJ projects define a nav_version constant that contains the version to use:

buildscript {
    ext.nav_version = "2.3.5"

    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:7.0.2'
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}
(from build.gradle)

That way, when we add the dependencies in our FragmentNav module, we can reference that nav_version constant and ensure that everything stays synchronized:

  implementation "androidx.navigation:navigation-fragment:$nav_version"
  implementation "androidx.navigation:navigation-ui:$nav_version"

Groovy — the language used for these Gradle scripts — supports string interpolation, using the same basic syntax as does Kotlin ($name to add the value of name to the string). As a result, values like androidx.navigation:navigation-fragment:$nav_version get the $nav_version portion replaced by whatever the value of nav_version is.

The KTX Bits

The dependencies closure shown above is from the SamplerJ project. Both Java and Kotlin projects are welcome to use those dependencies. Kotlin projects, though, are more likely to use the Android KTX variants of the dependencies:

  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

These, like the rest of the Android KTX libraries, add some extension functions and similar Kotlin features that make using the Navigation component easier in Kotlin.

The Safe Args Code Generator

For basic use of the Navigation component, those are all that you need. If you want to use the Safe Args feature for type-safe data exchange as part of navigation, you have some additional setup to perform.

Let’s review one of the earlier code snippets, where we defined nav_version:

buildscript {
    ext.nav_version = "2.3.5"

    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:7.0.2'
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}
(from build.gradle)

If you look closely, this also adds another entry to the buildscript set of dependencies. Specifically, this adds a Gradle plugin for Safe Args. dependencies in a module usually refer to code added to that module for runtime use; dependencies in the buildscript closure usually refer to Gradle plugins that will be used by modules.

Simply having the classpath entry is insufficient, though. Modules need to opt into specific plugins offered by the library indicated by the classpath entry. So, our Java modules’ build.gradle files have two apply plugin statements at the top: one for Android apps and one for Safe Args:

apply plugin: 'com.android.application'
apply plugin: 'androidx.navigation.safeargs'

Kotlin modules will have additional plugins for Kotlin support, along with a Kotlin-specific version of the Safe Args plugin:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'kotlin-kapt'

The Navigation Resource

The module has a res/navigation/ directory for navigation resources. In there, there is a main_nav_graph.xml resource that represents our tiny navigation graph:

<?xml version="1.0" encoding="utf-8"?>
<navigation android:id="@+id/main_nav_graph"
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  app:startDestination="@id/listFragment">

  <fragment
    android:id="@+id/listFragment"
    android:name="com.commonsware.jetpack.sampler.nav.ListFragment"
    android:label="ToDo Items">
    <action
      android:id="@+id/displayModel"
      app:destination="@id/displayFragment" />
  </fragment>
  <fragment
    android:id="@+id/displayFragment"
    android:name="com.commonsware.jetpack.sampler.nav.DisplayFragment"
    android:label="ToDo Item">
    <argument
      android:name="modelId"
      app:argType="string" />
  </fragment>
</navigation>

listFragment

There are two <fragment> elements set up as destinations in this resource. One is listFragment, pointing to our ListFragment class:

Android Studio Navigation Editor, Showing listFragment
Android Studio Navigation Editor, Showing listFragment

It contains an <action> child element, identifying that this destination can navigate to the displayFragment destination. The action’s ID is displayModel, and that is what we can use in our Java/Kotlin code to request to perform this action.

Clicking the action’s arrow in the graphic designer shows the details of that action in the “Attributes” pane:

Android Studio Navigation Editor, Showing displayModel
Android Studio Navigation Editor, Showing displayModel

The app:startDestination attribute in the root <navigation> element points to this listFragment destination, meaning that when we first use this navigation graph, the ListFragment is what should be displayed.

displayFragment

The other <fragment> element is for displayFragment:

Android Studio Navigation Editor, Showing displayFragment
Android Studio Navigation Editor, Showing displayFragment

This contains an <argument> child element, stating that this destination expects to receive a modelId String value.

The Activity Layout

In the FragmentManual sample, we did not need an activity layout. We just used the existing android.R.id.content container as a target for our FragmentTransaction requests.

With the Navigation component, usually you will need a layout resource, particularly one where you place the NavHostFragment to say where the navigation graph’s <fragment> destinations should appear. So, FragmentNav has an activity_main layout resource that does just that:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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"
  tools:context=".MainActivity">

  <androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:background="?attr/colorPrimary"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:theme="?attr/actionBarPopupTheme" />

  <androidx.fragment.app.FragmentContainerView
    android:id="@+id/nav_host"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:defaultNavHost="true"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/toolbar"
    app:navGraph="@navigation/main_nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

The NavHostFragment is added using a <FragmentContainerView> element in the layout XML. This is the modern way to add “static fragments”, ones that will be used all the time and cannot themselves be removed. The android:name attribute identifies the particular Fragment subclass to use, using the fully-qualified class name. In this case, we are not using one of our fragments, but instead are using NavHostFragment (or, more formally, androidx.navigation.fragment.NavHostFragment).

The NavHostFragment takes two main custom attributes in addition to the standard ones for sizing and positioning the fragment. app:navGraph supplies a reference to the navigation graph resource that this host will be using for its contents. app:defaultNavHost basically says “have the system’s BACK button route through this fragment’s navigation graph”, so BACK presses navigate backwards through the graph.

The layout also has a Toolbar to use as our app bar, with the NavHostFragment taking up the rest of the available space. As a result, this app uses Theme.AppCompat.Light.NoActionBar as a basis for its AppTheme style resource, to suppress the default action bar implementation, as we saw back in the chapter on the app bar.

MainActivity

Most of the FragmentNav app’s code is the same as its FragmentManual counterpart. We still have two fragments, each with its own viewmodel, with ToDoModel objects coming from a ToDoRepository. We also still have the same RecyclerView pieces for populating the list.

What differs is how our activity sets things up, how we navigate from the ListFragment to the DisplayFragment, and how we pass the ToDoModel ID between those fragments.

The FragmentManual edition of MainActivity conditionally executed a FragmentTransaction to bring up the ListFragment. The Navigation-enhanced MainActivity instead needs to initialize the Navigation component.

The Android KTX extensions for Kotlin cause the Java and Kotlin code to look somewhat different, even though in the end they do the same work:

Java

package com.commonsware.jetpack.samplerj.nav;

import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;

public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    NavController nav =
      ((NavHostFragment)getSupportFragmentManager()
        .findFragmentById(R.id.nav_host))
        .getNavController();
    AppBarConfiguration appBarCfg =
      new AppBarConfiguration.Builder(nav.getGraph()).build();

    NavigationUI.setupWithNavController(findViewById(R.id.toolbar), nav,
      appBarCfg);
  }
}

In Java, to obtain the NavController, we:

After that, we:

Now, the Navigation component will fill in the Toolbar title from our destination label and handle the up button, as we navigate through the graph that we have set up for this NavHostFragment.

Kotlin

The Kotlin code does the same work, with less actual code:

package com.commonsware.jetpack.sampler.nav

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.commonsware.jetpack.sampler.nav.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val binding = ActivityMainBinding.inflate(layoutInflater)

    setContentView(binding.root)

    supportFragmentManager.findFragmentById(R.id.nav_host)?.findNavController()?.let { nav ->
      binding.toolbar.setupWithNavController(nav, AppBarConfiguration(nav.graph))
    }
  }
}

Android KTX gives us:

ListFragment

The change to ListFragment is in the navTo() function, as now we use the Navigation component instead of a FragmentTransaction to bring up the DetailFragment.

This app uses Safe Args, courtesy of the <argument> element in the navigation graph and the androidx.navigation.safeargs Gradle plugin. Hence, we get a ListFragmentDirections class code-generated for us. This class is named based on the initial destination (ListFragment) and has static methods for each of our actions that collect the arguments that we need to pass to the destination that we want to navigate to. Our action has displayModel as its ID, so ListFragmentDirections has a displayModel() method that we can call, either from Java:

  private void navTo(ToDoModel model) {
    NavHostFragment.findNavController(this)
      .navigate(ListFragmentDirections.displayModel(model.id));
  }

…or from Kotlin:

  private fun navTo(model: ToDoModel) {
    findNavController().navigate(ListFragmentDirections.displayModel(model.id))
  }

To navigate to a destination, we:

The Navigation component will then do whatever is necessary to take us to that destination, updating the Toolbar as needed.

DisplayFragment

The only change in DisplayFragment is how we get our ToDoModel ID. In FragmentManual, we used the arguments Bundle directly. In FragmentNav, we are using Safe Args, which gives us a code-generated DisplayFragmentArgs class. This class is named after our destination (DisplayFragment), knows how to obtain its arguments, and supplies them to us in type-safe functions, such as getModelId() for our modelId argument.

In Java, we get a DisplayFragmentArgs by calling the static fromBundle() method, providing it our arguments Bundle:

    String modelId = DisplayFragmentArgs.fromBundle(getArguments()).getModelId();

From there, a call to getModelId() returns the ToDoModel ID that we supplied to ListFragmentDirections.displayModel().

In Kotlin, while you could do the same thing, you also have the navArgs() property delegate from Android KTX:

  private val args: DisplayFragmentArgs by navArgs()

That knows how to get our arguments Bundle and set up our DisplayFragmentArgs instance, so we can just refer to its modelId property to get the ToDoModel ID:

    val model = vm.getModel(args.modelId)

Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.