Android Native - Typesafe Navigation with Args

dimitrilc 1 Tallied Votes 235 Views Share

Introduction

When using Navigation Components, you might have wondered how to pass arguments between destinations. We will learn how to do just that in this tutorial. This tutorial also builds upon the Basic Navigation tutorial, so if you are not familiar with Navigation Components, I would recommend you to check it out.

There are two parts to this tutorial. First, we will learn how to perform navigation with type safety. Secondly, we will learn how to pass arguments with Safe Args.

Goals

At the end of the tutorial, you would have learned:

  1. How to use Navigation with Type Safety.
  2. How to pass arguments between destinations with Safe Args.

Tools Required

  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.

Prerequisite Knowledge

  1. Basic Android.
  2. Basic Navigation.

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Remove the default “Hello World!” TextView.

  3. In the Project-level build.gradle file, add the buildscript{} block below. Make sure that you place it above the existing plugins{} block.

     buildscript {
        repositories {
            google()
        }
        dependencies {
            def nav_version = "2.4.0"
            classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
        }
     }
  4. In the Module-level build.gradle file, add the dependencies below to the dependencies{} block.

     def nav_version = "2.4.0"
    
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
  5. Also in the Module-level build.gradle file, add the plugin below into the plugins{} block.

     id 'androidx.navigation.safeargs.kotlin'
  6. Because of this bug, you will have to downgrade your Android Gradle Plugin (AGP) to version 7.0.4. Gradle version 7.4 seems to work fine as of this tutorial. You can change your Project settings by going to File > Project Structure > Project.
    gradle_version.jpg

  7. Performs a Gradle sync.

  8. In the same package as MainActivity.kt, create two new fragments called BlankFragment1.kt, BlankFragment2.kt along with their respective layout files fragment_blank1.xml and fragment_blank2.xml.

  9. Create a new navigation graph called nav_graph.xml.

  10. Replace the content of activity_main.xml with the code below.

     <?xml version="1.0" encoding="utf-8"?>
     <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/nav_graph" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  11. Replace the content of nav_graph.xml with the code below.

     <?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/blankFragment1">
    
        <fragment
            android:id="@+id/blankFragment1"
            android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment1"
            android:label="fragment_blank1"
            tools:layout="@layout/fragment_blank1" >
            <action
                android:id="@+id/action_blankFragment1_to_blankFragment2"
                app:destination="@id/blankFragment2" />
        </fragment>
        <fragment
            android:id="@+id/blankFragment2"
            android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment2"
            android:label="fragment_blank2"
            tools:layout="@layout/fragment_blank2" />
     </navigation>
  12. Add the string resource below into strings.xml.

     <string name="second_screen">2nd Screen</string>
  13. Replace the content of fragment_blank1.xml with the code below.

     <?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:id="@+id/frameLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".BlankFragment1" >
    
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/second_screen"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>

Typesafe Navigation

Before diving into navigation type safety, we will first look at what navigation without type safety is like. Copy and paste the overridden version of onViewCreated() below into BlankFragment1.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   val navController = findNavController()

   val button = view.findViewById<Button>(R.id.button)

   button.setOnClickListener {
       //Will compile, but will crash at runtime. No Compiler checks (except for @IdRes).
       navController.navigate(R.id.button)

       //Will compile. Won't crash at runtime. No Compiler checks (except for @IdRes).
       //navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

       //Will compile. Won't crash at runtime. Has Compiler checks
       //val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2()
       //navController.navigate(direction)
   }
}

The code snippet above presents three different ways to navigate to a destination using a NavController. Without type safety, we normally can navigate using the android:id of the navigation <action>. When dissecting the method signature of the NavController#navigate(),

public open fun navigate(@IdRes resId: Int)

then we can see that it will take just about any Int argument. We can pass a useful Int value from R.id, or any number, really. There is an annotation @IdRes applied to the parameter which helps a bit due to the IDE’s annotation processor. If you were to provide a non-id number to navigate(), you will see some help from the IDE in the form of a compiler error, which can be suppressed.

navigate_9999.jpg

Right now, we are passing to navigate() a valid R.id.button,

navController.navigate(R.id.button)

but it is not a valid navigation <action>, so we will not see any compiler error. The code will compile, but it will crash at runtime when we press the Button.

java.lang.IllegalArgumentException: Navigation action/destination com.codelab.daniwebandroidtypesafenav:id/button cannot be found from the current destination 

NavCrash.gif

The second way to navigate using the <action> android:id is correct and will not crash, but it is still not typesafe.

navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

Because we have added the safe-args dependencies into our project, we can now use navigation with type safety. Type safety is provided by the interface NavDirections. The safe-args dependencies will generate a class implementing NavDirections at development time for each originating Destination of an <action>. This class will have the same name as the originating fragment, but with the word Directions suffixed to it. This new class will also have methods with the same name as the <action> android:id, converted to camel case after stripping out underscores (_).

compiler.jpg

You can now comment out the first two ways to navigate and uncomment the last way to navigate with NavDirections.

Pass data with Safe Args

In this section, we will learn how to pass arguments with Safe Args.

Replace the content of fragment_blank2.xml with the code below.

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

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="32sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       tools:text="Test Test Test" />

</androidx.constraintlayout.widget.ConstraintLayout>

To pass an argument to blankFragment2 when navigating to it from blankFragment1, we will have to do a couple of things.

  1. We have to add an <argument> element to the destination fragment (blankFragment2). This will trigger the compiler to add a parameter to the method actionBlankFragment1ToBlankFragment2(), so it will become actionBlankFragment1ToBlankFragment2(sampleArgs: String = “default value”) (if <argument> has the attribute android:defaultValue). In nav_graph.xml, inside of blankFragment2, add the <argument> below.

     <argument
        android:name="sampleArg"
        app:argType="string"
        android:defaultValue="blank" />
  2. You might have to rebuild the project for the compiler to generate the new class.

  3. Back in the Button’s onClickListener callback, we can replace the previous direction with the code below to pass an argument.

     val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2("Arg from fragment1 to fragment2")
  4. In order for BlankFragment2 to receive the arguments, we can use the navArgs() delegate from androidx.navigation.fragment to receive a type of BlankFragment2Args, which the compiler has generated for us as well. In BlankFragment2.kt, copy and paste the code below.

     val args: BlankFragment2Args by navArgs()
    
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    
        val textView = view.findViewById<TextView>(R.id.textView)
        textView.text = args.sampleArg
     }

And that is the last step in this tutorial.

Run the App

We are not ready to run the App. Your app should behave similarly to the Gif below.

NavSafeArgs.gif

Solution Code

BlankFragment1.kt

package com.codelab.daniwebandroidtypesafenav

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.navigation.fragment.findNavController

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment1.newInstance] factory method to
* create an instance of this fragment.
*/
class BlankFragment1 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank1, container, false)
   }

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)

       val navController = findNavController()

       val button = view.findViewById<Button>(R.id.button)

       button.setOnClickListener {
           //Will compile, but will crash at runtime. No Compiler checks (except for @IdRes).
           //navController.navigate(R.id.button)

           //Will compile. Won't crash at runtime. No Compiler checks (except for @IdRes).
           //navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

           //Will compile. Won't crash at runtime. Has Compiler checks
           //val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2()
           val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2("Arg from fragment1 to fragment2")
           navController.navigate(direction)
       }
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment1.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment1().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment2.kt

package com.codelab.daniwebandroidtypesafenav

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.navigation.fragment.navArgs

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment2.newInstance] factory method to
* create an instance of this fragment.
*/
class BlankFragment2 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank2, container, false)
   }

   val args: BlankFragment2Args by navArgs()

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)

       val textView = view.findViewById<TextView>(R.id.textView)
       textView.text = args.sampleArg
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment2.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment2().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/fragmentContainerView"
       android:name="androidx.navigation.fragment.NavHostFragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:defaultNavHost="true"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank1.xml

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

   <Button
       android:id="@+id/button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/second_screen"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank2.xml

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

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="32sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       tools:text="Test Test Test" />

</androidx.constraintlayout.widget.ConstraintLayout>

nav_graph.xml

<?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/blankFragment1">

   <fragment
       android:id="@+id/blankFragment1"
       android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment1"
       android:label="fragment_blank1"
       tools:layout="@layout/fragment_blank1" >
       <action
           android:id="@+id/action_blankFragment1_to_blankFragment2"
           app:destination="@id/blankFragment2" />
   </fragment>
   <fragment
       android:id="@+id/blankFragment2"
       android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment2"
       android:label="fragment_blank2"
       tools:layout="@layout/fragment_blank2" >
       <argument
           android:name="sampleArg"
           app:argType="string"
           android:defaultValue="blank" />
   </fragment>
</navigation>

strings.xml

<resources>
   <string name="app_name">Daniweb Android Typesafe Nav</string>
   <!-- TODO: Remove or change this placeholder text -->
   <string name="hello_blank_fragment">Hello blank fragment</string>
   <string name="second_screen">2nd Screen</string>
</resources>

Project build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
   repositories {
       google()
   }
   dependencies {
       def nav_version = "2.4.0"
       classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
   }
}

plugins {
   id 'com.android.application' version '7.0.4' apply false
   id 'com.android.library' version '7.0.4' apply false
   id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}

task clean(type: Delete) {
   delete rootProject.buildDir
}

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
   id 'androidx.navigation.safeargs.kotlin'
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.codelab.daniwebandroidtypesafenav"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
   }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
       }
   }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
   }
   kotlinOptions {
       jvmTarget = '1.8'
   }
}

dependencies {
   implementation 'androidx.legacy:legacy-support-v4:1.0.0'
   def nav_version = "2.4.0"

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

   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

Summary

We have learned how to navigate with type safety and pass destination arguments in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidTypesafeNav.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.