1. Introduction
Last Updated: 2022-07-25
What you'll need
- Latest Android Studio
- Knowledge of Kotlin and trailing lambdas
- Basic understanding of navigation and its terms, like back stack
- Basic understanding of Compose
- Consider taking the Jetpack Compose basics codelab before this
- Basic understanding of state management in Compose
- Consider taking the State in Jetpack Compose codelab before this
Navigating with Compose
Navigation is a Jetpack library that enables navigating from one destination within your app to another. The Navigation library also provides a specific artifact to enable consistent and idiomatic navigation with Jetpack Compose. This artifact (navigation-compose
) is the focal point of this codelab.
What you'll do
You're going to use the Rally Material study as the base for this codelab to implement the Jetpack Navigation component and enable navigation between composable Rally screens.
What you'll learn
- Basics of using Jetpack Navigation with Jetpack Compose
- Navigating between composables
- Integrating a custom tab bar composable into your navigation hierarchy
- Navigating with arguments
- Navigating using deep links
- Testing navigation
2. Setup
To follow along, clone the starting point (main
branch) for the codelab.
$ git clone https://github.com/android/codelab-android-compose.git
Alternatively, you can download two zip files:
Now that you've downloaded the code, open the NavigationCodelab project folder in Android Studio. You're now ready to get started.
3. Overview of the Rally app
As a first step, you should get familiar with the Rally app and its codebase. Run the app and explore it a bit.
Rally has three main screens as composables:
OverviewScreen
— overview of all financial transactions and alertsAccountsScreen
— insights into existing accountsBillsScreen
— scheduled expenses
At the very top of the screen, Rally is using a custom tab bar composable (RallyTabRow
) to navigate between these three screens. Tapping on each icon should expand the current selection and take you to its corresponding screen:
When navigating to these composable screens, you can also think of them as navigation destinations, as we want to land on each at a specific point. These destinations are predefined in the RallyDestinations.kt
file.
Inside, you will find all three main destinations defined as objects (Overview, Accounts
and Bills
) as well as a SingleAccount
, which will be added to the app later. Each object extends from the RallyDestination
interface and contains the necessary information on each destination for navigation purposes:
- An
icon
for the top bar - A String
route
(which is necessary for the Compose Navigation as a path that leads to that destination) - A
screen
representing the entire composable for this destination
When you run the app, you will notice that you can actually navigate between the destinations currently using the top bar. However, the app isn't in fact using Compose Navigation, but instead its current navigation mechanism is relying on some manual switching of composables and triggering recomposition to show the new content. Therefore, the goal of this codelab is to successfully migrate and implement Compose Navigation.
4. Migrating to Compose Navigation
The basic migration to Jetpack Compose follows several steps:
- Add the latest Compose Navigation dependency
- Set up the
NavController
- Add a
NavHost
and create the navigation graph - Prepare routes for navigating between different app destinations
- Replace the current navigation mechanism with Compose Navigation
Let's cover these steps one by one, in more detail.
Add the Navigation dependency
Open the app's build file, found at app/build.gradle
. In the dependencies section, add the navigation-compose
dependency.
dependencies {
implementation "androidx.navigation:navigation-compose:{latest_version}"
// ...
}
You can find the latest version of navigation-compose here.
Now, sync the project and you're ready to start using Navigation in Compose.
Set up the NavController
The NavController
is the central component when using Navigation in Compose. It keeps track of back stack composable entries, moves the stack forward, enables back stack manipulation, and navigates between destination states. Because NavController
is central to navigation, it has to be created as a first step in setting up Compose Navigation.
A NavController
is obtained by calling the rememberNavController()
function. This creates and remembers a NavController
which survives configuration changes (using rememberSaveable
).
You should always create and place the NavController
at the top level in your composable hierarchy, usually within your App
composable. Then, all composables that need to reference the NavController
have access to it. This follows the principles of state hoisting and ensures the NavController
is the main source of truth for navigating between composable screens and maintaining the back stack.
Open RallyActivity.kt
. Fetch the NavController
by using rememberNavController()
within RallyApp
, as it is the root composable and the entry point for the entire application:
import androidx.navigation.compose.rememberNavController
// ...
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
// ...
) {
// ...
}
}
Routes in Compose Navigation
As previously mentioned, Rally App has three main destinations and one additional to be added later (SingleAccount
). These are defined in RallyDestinations.kt
. and we mentioned that each destination has a defined icon
, route
and screen
:
The next step is to add these destinations to your navigation graph, with Overview
as the start destination when the app is launched.
When using Navigation within Compose, each composable destination in your navigation graph is associated with a route. Routes are represented as Strings that define the path to your composable and guide your navController
to land on the right place. You can think of it as an implicit deep link that leads to a specific destination. Each destination must have a unique route.
To accomplish this, we'll use the route
property of each RallyDestination
object. For example, Overview.route
is the route that will take you to the Overview
screen composable.
Calling the NavHost composable with the navigation graph
The next step is to add a NavHost
and create your navigation graph.
The 3 main parts of Navigation are the NavController
, NavGraph
, and NavHost
. The NavController
is always associated with a single NavHost
composable. The NavHost
acts as a container and is responsible for displaying the current destination of the graph. As you navigate between composables, the content of the NavHost
is automatically recomposed. It also links the NavController
with a navigation graph ( NavGraph
) that maps out the composable destinations to navigate between. It is essentially a collection of fetchable destinations.
Go back to the RallyApp
composable in RallyActivity.kt
. Replace the Box
composable inside the Scaffold
, which contains the current screen's contents for manual switching of the screens, with a new NavHost
that you can create by following the code example below.
Pass in the navController
we created in the previous step to hook it up to this NavHost
. As mentioned previously, each NavController
must be associated with a single NavHost
.
The NavHost
also needs a startDestination
route to know which destination to show when the app is launched, so set this to Overview.route
. Additionally, pass a Modifier
to accept the outer Scaffold
padding and apply it to the NavHost
.
The final parameter builder: NavGraphBuilder.() -> Unit
is responsible for defining and building the navigation graph. It uses the lambda syntax from the Navigation Kotlin DSL, so it can be passed as a trailing lambda inside the body of the function and pulled out of the parentheses:
import androidx.navigation.compose.NavHost
...
Scaffold(...) { innerPadding ->
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
// builder parameter will be defined here as the graph
}
}
Adding destinations to the NavGraph
Now, you can define your navigation graph and the destinations that the NavController
can navigate to. As mentioned, the builder
parameter expects a function, so Navigation Compose provides the NavGraphBuilder.composable
extension function to easily add individual composable destinations to the navigation graph and define the necessary navigation information.
The first destination will be Overview
, so you need to add it via the composable
extension function and set its unique String route
. This just adds the destination to your nav graph, so you also need to define the actual UI to be displayed when you navigate to this destination. This will also be done via a trailing lambda inside the body of the composable
function, a pattern that is frequently used in Compose:
import androidx.navigation.compose.composable
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
}
Following this pattern, we'll add all three main screen composables as three destinations:
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
composable(route = Accounts.route) {
Accounts.screen()
}
composable(route = Bills.route) {
Bills.screen()
}
}
Now run the app - you will see the Overview
as the start destination and its corresponding UI shown.
We mentioned before a custom top tab bar, RallyTabRow
composable, that previously handled the manual navigation between the screens. At this point, it's not yet connected with the new navigation, so you can verify that clicking on the tabs won't change the destination of the displayed screen composable. Let's fix that next!
5. Integrate RallyTabRow with navigation
In this step, you'll wire up the RallyTabRow
with the navController
and the navigation graph to enable it to navigate to the correct destinations.
To do this, you need to use your new navController
to define the correct navigation action for the RallyTabRow
's onTabSelected
callback. This callback defines what should happen when a specific tab icon is selected and performs the navigation action via navController.navigate(route)
.
Following this guidance, in RallyActivity
, find the RallyTabRow
composable and its callback parameter onTabSelected
.
Since we want the tab to navigate to a specific destination when tapped, you also need to know which exact tab icon was selected. Luckily, onTabSelected: (RallyDestination) -> Unit
parameter provides this already. You will use that information and the RallyDestination
route to guide your navController
and call navController.navigate(newScreen.route)
when a tab is selected:
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = rallyTabRowScreens,
// Pass the callback like this,
// defining the navigation action when a tab is selected:
onTabSelected = { newScreen ->
navController.navigate(newScreen.route)
},
currentScreen = currentScreen,
)
}
If you run the app now, you can verify that tapping on individual tabs in RallyTabRow
does indeed navigate to the correct composable destination. However, there are currently two issues you might have noticed:
- Retapping the same tab in a row launches the multiple copies of the same destination
- The tab's UI is not matching the correct destination shown - meaning, the expanding and collapsing of selected tabs isn't working as intended:
Let's fix both!
Launching a single copy of a destination
To fix the first issue and make sure there will be at most one copy of a given destination on the top of the back stack, Compose Navigation API provides a launchSingleTop
flag you can pass to your navController.navigate()
action, like this:
navController.navigate(route) { launchSingleTop = true }
Since you want this behavior across the app, for every destination, instead of copy pasting this flag to all of your .navigate(...)
calls, you can extract it into a helper extension at the bottom of your RallyActivity
:
import androidx.navigation.NavHostController
// ...
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) { launchSingleTop = true }
Now you can replace the navController.navigate(newScreen.route)
call with .navigateSingleTopTo(...)
. Rerun the app and verify you will now get only one copy of a single destination when clicking multiple times on its icon in the top bar:
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = rallyTabRowScreens,
onTabSelected = { newScreen ->
navController
.navigateSingleTopTo(newScreen.route)
},
currentScreen = currentScreen,
)
}
Controlling the navigation options and back stack state
Apart from launchSingleTop
, there are also other flags that you can use from the NavOptionsBuilder
to control and customize your navigation behavior even more. Since our RallyTabRow
acts similarly to a BottomNavigation
, you should also think about whether you want to save and restore a destination state when you navigate to and from it. For example, if you scroll to the bottom of Overview and then navigate to Accounts and back, do you want to keep the scroll position? Do you want to re-tap on the same destination in the RallyTabRow
to reload your screen state or not? These are all valid questions and should be determined by the requirements of your own app design.
We will cover some additional options that you can use within the same navigateSingleTopTo
extension function:
launchSingleTop = true
- as mentioned, this makes sure there will be at most one copy of a given destination on the top of the back stack- In Rally app, this would mean that re-tapping the same tab multiple times doesn't launch multiple copies of the same destination
popUpTo(startDestination) { saveState = true }
- pop up to the start destination of the graph to avoid building up a large stack of destinations on the back stack as you select tabs- In Rally, this would mean that pressing the back arrow from any destination would pop the entire back stack to Overview
restoreState = true
- determines whether this navigation action should restore any state previously saved byPopUpToBuilder.saveState
or thepopUpToSaveState
attribute. Note that, if no state was previously saved with the destination ID being navigated to, this has no effect- In Rally, this would mean that, re-tapping the same tab would keep the previous data and user state on the screen without reloading it again
You can add all of these options one by one to the code, run the app after each and verify the exact behavior after adding each flag. That way, you'll be able to see in practice how each flag changes the navigation and back stack state:
import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) {
popUpTo(
[email protected]().id
) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
Fixing the tab UI
At the very start of the codelab, while still using the manual navigation mechanism, RallyTabRow
was using the currentScreen
variable to determine whether to expand or collapse each tab.
However, after the changes you've made, currentScreen
will no longer be updated. This is why expanding and collapsing of selected tabs inside the RallyTabRow
doesn't work anymore.
To re-enable this behavior using Compose Navigation, you need to know at each point what is the current destination shown, or in navigation terms, what is the top of your current back stack entry, and then update your RallyTabRow
every time this changes.
To get real time updates on your current destination from the back stack in a form of State
, you can use navController.currentBackStackEntryAsState()
and then grab its current destination:
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...
@Composable
fun RallyApp() {
RallyTheme {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
// Fetch your currentDestination:
val currentDestination = currentBackStack?.destination
// ...
}
}
currentBackStack?.destination
returns NavDestination
.
To properly update the currentScreen
again, you need to find a way of matching the return NavDestination
with one of Rally's three main screen composables. You must determine which one is currently shown so that you can then pass this information to the RallyTabRow.
As mentioned previously, each destination has a unique route, so we can use this String route as an ID of sorts to do a verified comparison and find a unique match.
To update the currentScreen
, you need to iterate through the rallyTabRowScreens
list to find a matching route and then return the corresponding RallyDestination
. Kotlin provides a handy .find()
function for that:
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...
@Composable
fun RallyApp() {
RallyTheme {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStack?.destination
// Change the variable to this and use Overview as a backup screen if this returns null
val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
// ...
}
}
Since currentScreen
is already being passed to the RallyTabRow
, you can run the app and verify the tab bar UI is now being updated accordingly.
6. Extracting screen composables from RallyDestinations
Until now, for simplicity, we were using the screen
property from the RallyDestination
interface and the screen objects extending from it, to add the composable UI in the NavHost (RallyActivity.kt
):
import com.example.compose.rally.ui.overview.OverviewScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
// ...
}
However, the following steps in this codelab (such as click events) require passing additional information to your composable screens directly. In a production environment, there will certainly be even more data that would need to be passed.
The correct- and cleaner!- way of achieving this would be to add the composables directly in the NavHost
navigation graph and extract them from the RallyDestination
. After that, RallyDestination
and the screen objects would only hold navigation-specific information, like the icon
and route
, and would be decoupled from anything Compose UI related.
Open RallyDestinations.kt
. Extract each screen's composable from the screen
parameter of RallyDestination
objects and into the corresponding composable
functions in your NavHost
, replacing the previous .screen()
call, like this:
import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
OverviewScreen()
}
composable(route = Accounts.route) {
AccountsScreen()
}
composable(route = Bills.route) {
BillsScreen()
}
}
At this point you can safely remove the screen
parameter from RallyDestination
and its objects:
interface RallyDestination {
val icon: ImageVector
val route: String
}
/**
* Rally app navigation destinations
*/
object Overview : RallyDestination {
override val icon = Icons.Filled.PieChart
override val route = "overview"
}
// ...
Run the app again and verify that everything is still working as before. Now that you've completed this step, you'll be able to set up click events inside your composable screens.
Enable clicks on OverviewScreen
Currently, any click events in your OverviewScreen
are ignored. This means that the Accounts and Bills subsection "SEE ALL" buttons are clickable, but do not in fact take you anywhere. The goal of this step is to enable navigation for these click events.
OverviewScreen
composable can accept several functions as callbacks to set as click events, which, for this case, should be navigation actions taking you to AccountsScreen
or BillsScreen
. Let's pass these navigation callbacks to onClickSeeAllAccounts
and onClickSeeAllBills
to navigate to relevant destinations.
Open RallyActivity.kt
, find OverviewScreen
within NavHost
and pass navController.navigateSingleTopTo(...)
to both navigation callbacks with the corresponding routes:
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
}
)
The navController
will now have sufficient information, like the route of the exact destination,
to navigate to the right destination on a button click. If you look at the implementation of OverviewScreen
, you will see that these callbacks are already being set to the corresponding onClick
parameters:
@Composable
fun OverviewScreen(...) {
// ...
AccountsCard(
onClickSeeAll = onClickSeeAllAccounts,
onAccountClick = onAccountClick
)
// ...
BillsCard(
onClickSeeAll = onClickSeeAllBills
)
}
As mentioned previously, keeping the navController
at the top level of your navigation hierarchy and hoisted to the level of your App
composable (instead of passing it directly into, for example, OverviewScreen)
makes it easy to preview, reuse and test OverviewScreen
composable in isolation – without having to rely on an actual or mocked navController
instances. Passing callbacks instead also allows quick changes to your click events!
7. Navigating to SingleAccountScreen with arguments
Let's add some new functionality to our Accounts
and Overview
screens! Currently, these screens display a list of several different types of accounts - "Checking", "Home Savings" etc.
However, clicking on these account types doesn't do anything (yet!). Let's fix this! When we tap on each account type, we want to show a new screen with the full account details. To do so, we need to provide additional information to our navController
about which exact account type we're clicking on. This can be done via arguments.
Arguments are a very powerful tool that make navigation routing dynamic by passing one or more arguments to a route. It enables displaying different information based on the different arguments provided.
In RallyApp
, add a new destination SingleAccountScreen
, which will handle displaying these individual accounts, to the graph by adding a new composable
function to the existing NavHost:
import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
...
composable(route = SingleAccount.route) {
SingleAccountScreen()
}
}
Set up the SingleAccountScreen landing destination
When you land on SingleAccountScreen
, this destination would require additional information to know which exact account type it should display when opened. We can use arguments to pass this kind of information. You need to specify that its route additionally requires an argument {account_type}
. If you take a look at the RallyDestination
and its SingleAccount
object, you will notice that this argument has already been defined for you to use, as an accountTypeArg
String.
To pass the argument alongside your route when navigating, you need to append them together, following a pattern: "route/{argument}"
. In your case, that would look like this: "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
. Remember that $ sign is used to escape variables:
import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
SingleAccountScreen()
}
This will ensure that, when an action is triggered to navigate to SingleAccountScreen
, an accountTypeArg
argument must be passed as well, otherwise the navigation will be unsuccessful. Think of it as a signature or a contract that needs to be followed by other destinations that want to navigate to SingleAccountScreen
.
Second step to this is to make this composable
aware that it should accept arguments. You do that by defining its arguments
parameter. You could define as many arguments as you need, as the composable
function by default accepts a list of arguments. In your case, you just need to add a single one called accountTypeArg
and add some additional safety by specifying it as type String
. If you don't set a type explicitly, it will be inferred from the default value of this argument:
import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = listOf(
navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
)
) {
SingleAccountScreen()
}
This would work perfectly and you could choose to keep the code like this. However, since all of our destination specific information is in RallyDestinations.kt
and its objects, let's continue using the same approach (just as we did above for Overview
, Accounts,
and Bills
) and move this list of arguments into SingleAccount:
object SingleAccount : RallyDestination {
// ...
override val route = "single_account"
const val accountTypeArg = "account_type"
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
}
Replace the previous arguments with SingleAccount.arguments
now back into the NavHost corresponding composable
. This also ensures we keep the NavHost
as clean and readable as possible:
composable(
route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = SingleAccount.arguments
) {
SingleAccountScreen()
}
Now that you've defined your complete route with arguments for SingleAccountScreen
, the next step is to make sure this accountTypeArg
is passed down further to the SingleAccountScreen
composable, so that it knows which account type to display correctly. If you look at the implementation of the SingleAccountScreen
, you will see that it's already set up and waiting to accept an accountType
parameter:
fun SingleAccountScreen(
accountType: String? = UserData.accounts.first().name
) {
// ...
}
To recap, so far:
- You've made sure we define the route to request arguments, as a signal to its preceding destinations
- You made sure that the
composable
knows it needs to accept arguments
Our final step is to actually retrieve the passed argument value somehow.
In Compose Navigation, each NavHost
composable function has access to the current NavBackStackEntry
- a class which holds the information on the current route and passed arguments of an entry in the back stack. You can use this to get the required arguments
list from navBackStackEntry
and then search and retrieve the exact argument you need, to pass it down further to your composable screen.
In this case, you will request accountTypeArg
from the navBackStackEntry
. Then, you need to pass it down further to SingleAccountScreen'
s accountType
parameter.
You also could provide a default value for the argument, as a placeholder, in case it has not been provided and make your code ever safer by covering this edge case.
Your code should now look like this:
NavHost(...) {
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = SingleAccount.arguments
) { navBackStackEntry ->
// Retrieve the passed argument
val accountType =
navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
// Pass accountType to SingleAccountScreen
SingleAccountScreen(accountType)
}
}
Now your SingleAccountScreen
has the necessary information to display the correct account type when you navigate to it. If you look at the implementation of SingleAccountScreen,
you can see that it already does the matching of the passed accountType
to the UserData
source to fetch the corresponding account details.
Let's do one minor optimization task again and move the "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
route as well into RallyDestinations.kt
and its SingleAccount
object:
object SingleAccount : RallyDestination {
// ...
override val route = "single_account"
const val accountTypeArg = "account_type"
val routeWithArgs = "${route}/{${accountTypeArg}}"
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
}
And again, replace it in the corresponding NavHost composable:
// ...
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments
) {...}
Setup the Accounts and Overview starting destinations
Now that you've defined your SingleAccountScreen
route and the argument it requires and accepts to make a successful navigation to SingleAccountScreen
, you need to make sure that the same accountTypeArg
argument is being passed from the previous destination (meaning, whichever destination you're coming from).
As you can see, there are two sides to this - the starting destination that provides and passes an argument and the landing destination that accepts that argument and uses it to display the correct information. Both need to be defined explicitly.
As an example, when you're on the Accounts
destination and you tap on "Checking" account type, the Accounts destination needs to pass a "Checking" String as an argument, appended to the "single_account" String route, to successfully open the corresponding SingleAccountScreen
. Its String route would look like this: "single_account/Checking"
You would use this exact same route with the passed argument when using the navController.navigateSingleTopTo(...),
like this:
navController.navigateSingleTopTo("${SingleAccount.route}/$accountType")
.
Pass this navigation action callback to the onAccountClick
parameter of OverviewScreen
and AccountsScreen
. Note that these parameters are predefined as: onAccountClick: (String) -> Unit
, with String as input. This means that, when the user taps on a specific account type in Overview
and Account
, that account type String will already be available to you and can easily be passed as an nav argument:
OverviewScreen(
// ...
onAccountClick = { accountType ->
navController
.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
)
// ...
AccountsScreen(
// ...
onAccountClick = { accountType ->
navController
.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
)
To keep things readable, you could extract this navigation action into a private helper, extension function:
import androidx.navigation.NavHostController
// ...
OverviewScreen(
// ...
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
// ...
AccountsScreen(
// ...
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
// ...
private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
When you run the app at this point, you're able to click on each account type and will be taken to its corresponding SingleAccountScreen
, displaying data for the given account.
8. Enable deep link support
In addition to adding arguments, you can also add deep links to associate a specific URL, action, and/or mime type with a composable. In Android, a deep link is a link that takes you directly to a specific destination within an app. Navigation Compose supports implicit deep links. When an implicit deep link is invoked—for example, when a user clicks a link—Android can then open your app to the corresponding destination.
In this section, you'll add a new deep link for navigating to the SingleAccountScreen
composable with a corresponding account type and enable this deep link to be exposed to external apps as well. To refresh your memory, the route for this composable was "single_account/{account_type}"
and this is what you'll also use for the deep link, with some minor deep link related changes.
Since exposing deep links to external apps isn't enabled by default , you must also add <intent-filter>
elements to your app's manifest.xml
file, so this will be your first step.
Start by adding the deep link to the app's AndroidManifest.xml
. You need to create a new intent filter via <intent-filter>
inside of the <activity>
, with the action VIEW
and categories BROWSABLE
and DEFAULT
.
Then inside the filter, you need the data
tag to add a scheme
(rally
- name of your app) and host
(single_account
- route to your composable) to define your precise deep link. This will give you rally://single_account
as the deep link URL.
Note that you don't need to declare the account_type
argument in the AndroidManifest
. This will be appended later inside the NavHost
composable function.
<activity
android:name=".RallyActivity"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rally" android:host="single_account" />
</intent-filter>
</activity>
Trigger and verify the deep link
Now you can react to the incoming intents from within RallyActivity
.
The composable SingleAccountScreen
accepts arguments already, but now it also needs to accept the newly created deep link to launch this destination when its deep link is triggered.
Inside the composable function of SingleAccountScreen
, add one more parameter deepLinks
. Similarly to arguments,
it also accepts a list of navDeepLink
, as you could define multiple deep links leading to the same destination. Pass the uriPattern
, matching the one defined in intent-filter
in your manifest - rally://singleaccount
, but this time you'll also append its accountTypeArg
argument:
import androidx.navigation.navDeepLink
// ...
composable(
route = SingleAccount.routeWithArgs,
// ...
deepLinks = listOf(navDeepLink {
uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
})
)
You know what's next, right? Move this list into RallyDestinations SingleAccount:
object SingleAccount : RallyDestination {
// ...
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
val deepLinks = listOf(
navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
)
}
And again, replace it in the corresponding NavHost
composable:
// ...
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments,
deepLinks = SingleAccount.deepLinks
) {...}
Test the deep link using adb
Now your app and SingleAccountScreen
are ready to handle deep links. To test that it behaves correctly, do a fresh install of Rally on a connected emulator or device, open a command line and execute the following command, to simulate a deep link launch:
adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
This will take you directly into the "Checking" account, but you can also verify it works correctly for all other account types.
9. Extract the NavHost into RallyNavHost
Now your NavHost
is complete. However, to make it testable and to keep your RallyActivity
cleaner, you can extract your current NavHost
and its helper functions, like navigateToSingleAccount
, from the RallyApp
composable to its own composable function and name it RallyNavHost
.
RallyApp
is the one and only composable that should work directly with the navController
. As mentioned before, every other nested composable screen should only obtain navigation callbacks, not the navController
itself.
Therefore, the new RallyNavHost
will accept the navController
and modifier
as parameters from RallyApp
:
@Composable
fun RallyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = modifier
) {
composable(route = Overview.route) {
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
},
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
}
composable(route = Accounts.route) {
AccountsScreen(
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
}
composable(route = Bills.route) {
BillsScreen()
}
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments,
deepLinks = SingleAccount.deepLinks
) { navBackStackEntry ->
val accountType =
navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
SingleAccountScreen(accountType)
}
}
}
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) { launchSingleTop = true }
private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
Now add the new RallyNavHost
to your RallyApp
and rerun the app to verify everything works as previously:
fun RallyApp() {
RallyTheme {
...
Scaffold(
...
) { innerPadding ->
RallyNavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
}
}
10. Testing Compose Navigation
From the beginning of this codelab, you made sure not to pass the navController
directly into any composables (other than the high level app) and instead pass nav callbacks as parameters. This allows all your composables to be individually testable, as they do not require an instance of navController
in tests.
You should always test that the entire Compose Navigation mechanism works as intended in your app, by testing RallyNavHost
and navigation actions passed to your composables. These will be the main goals of this section. To test individual composable functions in isolation, make sure to check out the Testing in Jetpack Compose codelab.
To begin testing, we first need to add the necessary testing dependencies, so go back to your app's build file, found at app/build.gradle
. In the testing dependencies section, add the navigation-testing
dependency:
dependencies {
// ...
androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"
// ...
}
Prepare the NavigationTest class
Your RallyNavHost
can be tested in isolation from the Activity
itself.
As this test still will run on an Android device, you'll need to create your test directory /app/src/androidTest/java/com/example/compose/rally
, then create a new test file test class and name it NavigationTest
.
As a first step, to use the Compose testing APIs, as well as test and control composables and applications using Compose, add a Compose test rule:
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
}
Write your first test
Create a public rallyNavHost
test function and annotate it with @Test
. In that function, you first need to set the Compose content that you want to test. Do this, using composeTestRule
's setContent
. It takes a composable parameter as body and enables you to write Compose code and add composables in a test environment, as if you were in a regular, production environment app.
Inside the setContent,
you can set up your current test subject, RallyNavHost
and pass an instance of a new navController
instance to it. The Navigation testing artifact provides a handy TestNavHostController
to use. So let's add this step:
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Test
fun rallyNavHost() {
composeTestRule.setContent {
// Creates a TestNavHostController
navController =
TestNavHostController(LocalContext.current)
// Sets a ComposeNavigator to the navController so it can navigate through composables
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
fail()
}
}
If you copied above code, the fail()
call will ensure that your test fails until there is an actual assertion made. It serves as a reminder to finish implementing the test.
To verify that the correct screen composable is displayed, you can use its contentDescription
and assert that it is displayed. In this codelab, contentDescription
s for Accounts and Overview destinations have previously been set, so you can already use them for test verifications.
As a first verification, you should check that the Overview screen is displayed as the first destination when RallyNavHost
is initialized for the first time. You should also rename the test to reflect that - call it rallyNavHost_verifyOverviewStartDestination
. Do this by replacing the fail()
call with the following:
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Test
fun rallyNavHost_verifyOverviewStartDestination() {
composeTestRule.setContent {
navController =
TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
Run the test again, and verify that it passes.
Since you need to setup RallyNavHost
in the same way for each of the upcoming tests, you can extract its initialization into an annotated @Before
function to avoid unnecessary repetition and keep your tests more concise:
import org.junit.Before
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Before
fun setupRallyNavHost() {
composeTestRule.setContent {
navController =
TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
}
@Test
fun rallyNavHost_verifyOverviewStartDestination() {
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
Navigating in tests
You can test your navigation implementation in multiple ways, by performing clicks on the UI elements and then either verifying the displayed destination or by comparing the expected route against the current route.
Testing via UI clicks and screen contentDescription
As you want to test your concrete app's implementation, clicks on the UI are preferable. Next text can verify that, while in the Overview screen, clicking on the "SEE ALL" button in the Accounts subsection takes you to the Accounts destination:
You will again use the contentDescription
set on this specific button in the OverviewScreenCard
composable, simulating a click on it via performClick()
and verifying that the Accounts destination is then displayed:
import androidx.compose.ui.test.performClick
// ...
@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
composeTestRule
.onNodeWithContentDescription("All Accounts")
.performClick()
composeTestRule
.onNodeWithContentDescription("Accounts Screen")
.assertIsDisplayed()
}
You can follow this pattern to test all of the remaining click navigation actions in the app.
Testing via UI clicks and routes comparison
You also can use the navController
to check your assertions by comparing the current String routes to the expected one. To do this, perform a click on the UI, same as in the previous section, and then, compare the current route to the one you expect, using navController.currentBackStackEntry?.destination?.route
.
One additional step is to make sure you first scroll to the Bills subsection on your Overview screen, otherwise the test will fail as it wouldn't be able to find a node with contentDescription
"All Bills":
import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...
@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
composeTestRule.onNodeWithContentDescription("All Bills")
.performScrollTo()
.performClick()
val route = navController.currentBackStackEntry?.destination?.route
assertEquals(route, "bills")
}
Following these patterns, you can complete your test class by covering any additional navigation routes, destinations and click actions. Run the whole set of tests now to verify they are all passing.
11. Congratulations
Congratulations, you've successfully completed this codelab! You can find the solution code here and compare it with yours.
You added Jetpack Compose navigation to the Rally app and now are familiar with its key concepts. You learned how to set up a navigation graph of composable destinations, define your navigation routes and actions, pass additional information to routes via arguments, set up deep links and test your navigation.
For more topics and information, such as bottom nav bar integration, multi-module navigation and nested graphs, you can check out the Now in Android GitHub repository and see how it was implemented there.
What's next?
Check out these materials to continue your Jetpack Compose learning pathway :
More information on Jetpack Navigation: