Navigation in Feature Modules

Murat Yener
Android Developers
Published in
5 min readJun 2, 2021

--

Welcome to the another article in the second MAD Skills series on Navigation! If you prefer this content in video form, here is something to check out:

Navigation in Feature Modules video

Intro

In the previous article you learned how to use navigation in a multi module project. In this article we’ll take it a step further and convert the coffee module to a feature module. If you are not familiar with feature modules, you might want to check out this video first.

App Bundles

Feature modules are not downloaded at install time but rather only when the app requests them. This saves time and bandwidth on downloading and installing, as well as device storage.

So let’s save some bytes for the users! Let’s jump in and start coding!

Feature Modules

Since I already modularized the donut tracker app in the previous article, I’ll start with converting the existing coffee module to a feature module. If you want to follow along, you can check out the starter code from this repo.

First I replace the library plugin with the dynamic-feature plugin in coffee module’s build.gradle.

id 'com.android.dynamic-feature'

Next, I declare the coffee module as an on-demand module in androidmanifest.xml.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
package="com.android.samples.donuttracker.coffee">
<dist:module
dist:instant="false"
dist:title="@string/title_coffee">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>

</manifest>

Now that the coffee module is converted, I add this module as a dynamicFeature.

android {
//...

packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}

dynamicFeatures = [':coffee']


}

I also remove the coffee module from the list of dependencies in the app build.gradle and add the navigation-dynamic-features dependency.

implementation "androidx.navigation:navigation-dynamic-features-fragment:$navigationVersion"

Once the gradle sync is complete, it is time to update the navigation graph. I change the include tag to include-dynamic, add an id, graphResName and the moduleName which points to the feature module.

<include-dynamic
android:id="@+id/coffeeGraph"
app:moduleName="coffee"
app:graphResName="coffee_graph"/>

At this point, I can safely remove the id property from coffee_graph.xml since the Dynamic Navigator library ignores the id property in the root element of the included graph.

<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"
app:startDestination="@id/coffeeList">
<fragment
android:id="@+id/coffeeList"
android:name="com.android.samples.donuttracker.coffee.CoffeeList"
android:label="@string/coffee_list">
<action
android:id="@+id/action_coffeeList_to_coffeeEntryDialogFragment"
app:destination="@id/coffeeEntryDialogFragment" />
</fragment>
<dialog
android:id="@+id/coffeeEntryDialogFragment"
android:name="com.android.samples.donuttracker.coffee.CoffeeEntryDialogFragment"
android:label="CoffeeEntryDialogFragment">
<argument
android:name="itemId"
android:defaultValue="-1L"
app:argType="long" />
</dialog>
</navigation>

In activity_main layout, I change the name of the FragmentContainerView from NavHostFragment to DynamicNavHostFragment.

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

Similar to included graphs, to make dynamic-include work, the menu item id for coffee needs to match the graph name instead of the destination id.

<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@id/donutList"
android:icon="@drawable/donut_with_sprinkles"
android:title="@string/donut_name" />
<item
android:id="@id/coffeeGraph"
android:icon="@drawable/coffee_cup"
android:title="@string/coffee_name" />
</menu>

This is all I need to do to add dynamic-navigation. Now I’ll use bundletool to test the feature module. You can also use the Play Console to test feature modules. If you want to learn more on how to use bundletool and the Play Console to test feature module installation, you might want to check out the video below.

bundletool video

I also want to test what happens if the module can not be installed. To do that, I de-select donuttracker.coffee from the list of modules to deploy in run/debug configurations. Now when I run the app and navigate to coffeeList. A generic error message is displayed.

generic error message

Now that the feature module setup is complete, it’s time to polish the user experience. Wouldn’t it be nice to give the user customized feedback while the feature module is being downloaded or show a more meaningful error message instead of the generic one?

To do that, I can add a monitor to handle installation states, progress changes or errors while the user stays on the same screen. Alternatively, I can add a customized progress fragment to display the progress while the feature module is being downloaded.

Navigation has built in support for progress fragments. All I need to do is to create a new Fragment which extends AbstractProgressFragment.

class ProgressFragment : AbstractProgressFragment(R.layout.fragment_progress) {}

I add an ImageView, a TextView and a ProgressBar to show the download status.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="@dimen/default_margin"
android:paddingTop="@dimen/default_margin"
android:paddingRight="@dimen/default_margin"
android:paddingBottom="@dimen/default_margin">
<ImageView
android:id="@+id/progressImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="
@drawable/coffee_cup"
android:layout_marginBottom="
@dimen/default_margin"
android:layout_gravity="center"/>
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="
@string/installing_coffee_module"/>
<ProgressBar
android:id="@+id/progressBar"
style="
@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:progress="10" />
</LinearLayout>

Next, I override the onProgress() function to update the progressBar. I also override onFailed() and onCanceled() functions and update the textView to give the user some feedback.

override fun onProgress(status: Int, bytesDownloaded: Long, bytesTotal: Long) {
progressBar?.progress = (bytesDownloaded.toDouble() * 100 / bytesTotal).toInt()
}

override fun onFailed(errorCode: Int) {
message?.text = getString(R.string.install_failed)
}

override fun onCancelled() {
message?.text = getString(R.string.install_cancelled)
}

I need to add the progressFragment destination to the navigation graph. Finally, declare the progressFragment as the progressDestination of the navigation graph.

<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"
app:startDestination="@id/donutList"
app:progressDestination="@+id/progressFragment">
<fragment
android:id="@+id/donutList"
android:name="com.android.samples.donuttracker.donut.DonutList"
android:label="@string/donut_list" >
<action
android:id="@+id/action_donutList_to_donutEntryDialogFragment"
app:destination="@id/donutEntryDialogFragment" />
<action
android:id="@+id/action_donutList_to_selectionFragment"
app:destination="@id/selectionFragment" />
</fragment>
<dialog
android:id="@+id/donutEntryDialogFragment"
android:name="com.android.samples.donuttracker.donut.DonutEntryDialogFragment"
android:label="DonutEntryDialogFragment">
<deepLink app:uri="myapp://navdonutcreator.com/donutcreator" />
<argument
android:name="itemId"
app:argType="long"
android:defaultValue="-1L" />
</dialog>
<fragment
android:id="@+id/selectionFragment"
android:name="com.android.samples.donuttracker.setup.SelectionFragment"
android:label="@string/settings"
tools:layout="@layout/fragment_selection" >
<action
android:id="@+id/action_selectionFragment_to_donutList"
app:destination="@id/donutList" />
</fragment>
<fragment
android:id="@+id/progressFragment"
android:name="com.android.samples.donuttracker.ProgressFragment"
android:label="ProgressFragment" />

<include-dynamic
android:id="@+id/coffeeGraph"
app:moduleName="coffee"
app:graphResName="coffee_graph"/>
</navigation>

Now I run the app again with coffee de-selected and navigate to coffeeList. This time the app shows the customized progressFragment.

customized progressFragment

Similarly I can test the app with the bundle tool to see how the progress bar works as the coffee module being downloaded. You can check out the sample code from this repo.

Summary

That’s all! In this series, we re-visited Chet’s DonutTracker app and added coffee tracking functionality. Because… I like coffee.

With new functionality comes new responsibilities. To offer a better user experience, first I added NavigationUI to integrate UI components with navigation. Then I implemented a one time flow and conditional navigation. Later I used nested graphs and include tag to organize the navigation graph and modularized the app to save network and storage for the user. Now that we are done with the app, it is time to enjoy a nice cup of coffee and a donut!

--

--