Navigation in Jetpack compose. Full guide Beginner to Advanced.

Kaushal Vasava
12 min readSep 19, 2023

--

What is Navigation in Android?

Navigation helps you in understanding how your app moves across different components in your Application.

Android JetPack Navigation helps in implementing your high-level navigation in a go easy approach.

The Navigation Component is made up of three major parts:

  1. Navigation Graph: This is a resource that collects all navigation-related data in one place. This includes all of the locations in your app, referred to as destinations, as well as the possible paths a user could take through your app. It’s like a big book that has all the places you can go in an app and how you can move between them. Think of it as a map and a guide combined.
  2. NavHost: This is a unique composable that you can include in your layout. It shows various destinations from your Navigation Graph. The NavHost links the NavController with a navigation graph that specifies the composable destinations that you should be able to navigate between. As you navigate between composables, the content of the NavHost is automatically recomposed. Each composable destination in your navigation graph is associated with a route.
  3. NavController: The NavController is the central API for the Navigation component. It is stateful and keeps track of the back stack of composables that make up the screens in your app and the state of each screen.

Navigation in Jetpack Compose

The Navigation component provides support for Jetpack Compose applications. You can navigate between composables while taking advantage of the Navigation component’s infrastructure and features.

To get started with navigation in Jetpack Compose, you need to include the required dependencies in your project’s build.gradle file:

implementation "androidx.navigation:navigation-compose:2.7.1"

Basic concept about navigation in Jetpack compose.

NavController:

The NavController is the central API for the Navigation component. It is stateful and keeps track of the back stack of composables that make up the screens in your app and the state of each screen.

You can create a NavController by using the rememberNavController() method in your composable:

val navController = rememberNavController()

You should create the NavController in the place in your composable hierarchy where all composables that need to reference it have access to it. This follows the principles of state hoisting and allows you to use the NavController and the state it provides via currentBackStackEntryAsState() to be used as the source of truth for updating composables outside of your screens. See Integration with the bottom navbar for an example of this functionality.

Note: If you’re using the Navigation component for fragments, you don’t have to define new navigation graphs in Compose or use NavHost composables. See Interoperability for more information.

NavHost:

Each NavController must be associated with a single NavHost composable. The NavHost links the NavController with a navigation graph that specifies the composable destinations that you should be able to navigate between. As you navigate between composables, the content of the NavHost is automatically recomposed. Each composable destination in your navigation graph is associated with a route.

Key Term: Route is a String that defines the path to your composable. You can think of it as an implicit deep link that leads to a specific destination. Each destination should have a unique route.

Creating the NavHost requires the NavController previously created via rememberNavController() and the route of the starting destination of your graph. NavHost creation uses the lambda syntax from the Navigation Kotlin DSL to construct your navigation graph. You can add to your navigation structure by using the composable() method. This method requires that you provide a route and the composable that should be linked to the destination:

NavHost(navController = navController, startDestination = "profile") {
composable("profile") { Profile(/*...*/) }
composable("friendslist") { FriendsList(/*...*/) }
/*...*/
}

Note: the Navigation Component requires that you follow the Principles of Navigation and use a fixed starting destination. You should not use a composable value for the startDestination route.

Example: How to setup navigation graph, navhost and navigationitem.

Step 1: Define screens name and routes for Navigation in one file. ie. AppNavigation.kt

enum class Screen {
HOME,
LOGIN,
}
sealed class NavigationItem(val route: String) {
object Home : NavigationItem(Screen.HOME.name)
object Login : NavigationItem(Screen.LOGIN.name)
}

Step 2: Define NavHost with your screens. ie. AppNavHost.kt

@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
startDestination: String = NavigationItem.Splash.route,
... // other parameters
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(NavigationItem.Splash.route) {
SplashScreen(navController)
}
composable(NavigationItem.Login.route) {
LoginScreen(navController)
}
}

Step 3: Call AppNavHostinside your MainActivity.kt file.

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AutoPartsAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavHost(navController = rememberNavController())
}
}
}
}
}

Navigation argument:

Navigation Compose also supports passing arguments between composable destinations. In order to do this, you need to add argument placeholders to your route, similar to how you add arguments to a deep link when using the base navigation library:

Use cases:

  1. Without Arguments
  2. With simple arguments like with predefined data type (i.e. Int, String, etc.)

3. With complex arguments like User defined data type

4. Optional arguments

5. Navigate back with result

Without Argument:

NavHost(navController = navController, startDestination = "profile") {
composable("profile") { Profile(/*...*/) }
composable("friendslist") { FriendsList(/*...*/) }
/*...*/
}

With Simple Arguments:

By default, all arguments are parsed as strings. The arguments parameter of composable() accepts a list of NamedNavArguments. You can quickly create a NamedNavArgument using the navArgument method and then specify its exact type:

NavHost(startDestination = "profile/{userId}") {
...
composable("profile/{userId}") {...}
}

By default, all arguments are parsed as strings. The arguments parameter of composable() accepts a list of NamedNavArguments. You can quickly create a NamedNavArgument using the navArgument method and then specify its exact type:

NavHost(startDestination = "profile/{userId}") {
...
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId"){
type = NavType.StringType
})
) {...}
}

You should extract the arguments from the NavBackStackEntry that is available in the lambda of the composable() function.

composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
// here you have to fetch user data
Profile(
navController,
//pass fetch user data ie. UserInfo
)
}

To pass the argument to the destination, you need to add append it to the route when you make the navigate call:

navController.navigate("profile/user1234")

For a list of supported types, see Pass data between destinations.

With Complex or User defined Arguments:

It is strongly advised not to pass around complex data objects when navigating, but instead pass the minimum necessary information, such as a unique identifier or other form of ID, as arguments when performing navigation actions:

// Pass only the user ID when navigating to a new destination as argument
navController.navigate("profile/user1234")

Complex objects should be stored as data in a single source of truth, such as the data layer. Once you land on your destination after navigating, you can then load the required information from the single source of truth by using the passed ID. To retrieve the arguments in your ViewModel that’s responsible for accessing the data layer, you can use the ViewModel’s SavedStateHandle:

class UserViewModel(
savedStateHandle: SavedStateHandle,
private val userInfoRepository: UserInfoRepository
) : ViewModel() {

private val userId: String = checkNotNull(savedStateHandle["userId"])

// Fetch the relevant user information from the data layer,
// ie. userInfoRepository, based on the passed userId argument
private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

--------------- OR -----------------

// fetch data from network or database
private val _dataFlow =
MutableStateFlow<UserInfo>(userInfoRepository.getUserInfo(userId))
val dataFlow get() = _dataFlow.asStateFlow()
}

Composable function

//Navhost 
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
// here you have to fetch user data
val userInfo by taskViewModel.dataFlow.collectAsState()
Profile(
navController,
userInfo
)
}

// Profile screen
@Composable
fun Profile(navController: NavController, userInfo:UserInfo){
// do you work here
}

Note: Place viewmodel outside of composable screen because preview will not work and also best practices to avoid coupling between composable and viewmodel.

Refer: Jetpack Compose navigation architecture with ViewModels | by Tom Seifert | ProAndroidDev

This approach helps prevent data loss during configuration changes and any inconsistencies when the object in question is being updated or mutated.

For a more in-depth explanation on why you should avoid passing complex data as arguments, as well as a list of supported argument types, see Pass data between destinations.

Adding optional Arguments

Navigation Compose also supports optional navigation arguments. Optional arguments differ from required arguments in two ways:

  • They must be included using query parameter syntax ("?argName={argName}")
  • They must have a defaultValue set, or have nullable = true (which implicitly sets the default value to null)

This means that all optional arguments must be explicitly added to the composable() function as a list:

composable(
"profile?userId={userId}/{isMember}",
arguments = listOf(
navArgument("userId") {
type = NavType.StringType
defaultValue = "user1234"
// OR
type = NavType.StringType
nullable = true
},
navArgument("isNewTask") {
type = NavType.BoolType
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
val isMember = backStackEntry.arguments?.getBoolean("isMember")?:false
Profile(navController, userId, isMember)
}

Now, even if there is no argument passed to the destination, the defaultValue = "user1234", is used instead.

The structure of handling the arguments through the routes means that your composables remain completely independent of Navigation and makes them much more testable.

Navigating back with Result

Navigate back with result is most common task. ie. When you open filter dialog and select filter and then navigate back with selected filter to apply those filters in your screen.

There are two screens. 1. FirstScreen and 2. SecondScreen. We need data from SecondScreen to our FirstScreen.

NavHost.kt : Setup navigation graph.

 val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "firstscreen"
) {
composable("firstscreen") {
FirstScreen(navController)
}
composable("secondscreen") {
SecondScreen(navController)
}
}

FirstScreen.kt: Retrieve data after navigating back from SecondScreen with the help of savedStateHandle of current back-stack entry of NavController .

@Composable
fun FirstScreen(navController: NavController) {
// Retrieve data from next screen
val msg =
navController.currentBackStackEntry?.savedStateHandle?.get<String>("msg")
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { navController.navigate("secondscreen") }) {
Text("Go to next screen")
}
Spacer(modifier = Modifier.height(8.dp))
msg?.let {
Text(it)
}
}
}

SecondScreen.kt: Put data inside savedStateHandle of previous back-stack entry of NavController .

@Composable
fun SecondScreen(navController: NavController) {
var text by remember {
mutableStateOf("")
}
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
value = text, onValueChange = { text = it },
placeholder = {
Text("Enter text", color = Color.Gray)
}
)
Spacer(Modifier.height(8.dp))
Button(onClick = {

// Put data into savedStateHandle to retrive data on the previous screen

navController.previousBackStackEntry?.savedStateHandle?.set("msg", text)
navController.popBackStack()
}) {
Text(text = "Submit")
}
}
}

Video:

https://github.com/KaushalVasava/JetPackCompose_Basic/assets/49050597/1d96d44f-66e1-4f3b-bba1-2844ab6553cc

GitHub repo: https://github.com/KaushalVasava/JetPackCompose_Basic/tree/navigate-back-with-result

Deep links

Navigation Compose supports implicit deep links that can be defined as part of the composable() function as well. Its deepLinks parameter accepts a list of NavDeepLinks which can be quickly created using the navDeepLink method:

val uri = "https://www.example.com"
composable(
"profile?id={id}",
deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("id"))
}

These deep links let you associate a specific URL, action or mime type with a composable. By default, these deep links are not exposed to external apps. To make these deep links externally available you must add the appropriate <intent-filter> elements to your app’s manifest.xml file. To enable the deep link above, you should add the following inside of the <activity> element of the manifest:

<activity …>
<intent-filter>
...
<data android:scheme="https" android:host="www.example.com" />
</intent-filter>
</activity>

Navigation automatically deep links into that composable when the deep link is triggered by another app.

These same deep links can also be used to build a PendingIntent with the appropriate deep link from a composable:

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
Intent.ACTION_VIEW,
"https://www.example.com/$id".toUri(),
context,
MyActivity::class.java
)
val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)

val flag = if(Build.VERSION.SDK_INT > Build.VERSION_CODES.S){
PendingIntent.FLAG_IMMUTABLE
}
else
PendingIntent.FLAG_UPDATE_CURRENT
getPendingIntent(0, flag)
}

You can then use this deepLinkPendingIntent like any other PendingIntent to open your app at the deep link destination.

Nested Navigation

Destinations can be grouped into a nested graph to modularize a particular flow in your app’s UI. An example of this could be a self-contained login flow.

A nested graph groups its destinations, just like the main graph, and it requires a designated start destination for its associated route, which is where you’ll go when you access that nested graph’s route.

To add a nested graph to your NavHost, you can use the navigation extension function:

NavHost(navController, startDestination = "home") {
...
// Navigating to the graph via its route ('login') automatically
// navigates to the graph's start destination - 'username'
// therefore encapsulating the graph's internal routing logic
navigation(startDestination = "username", route = "login") {
composable("username") { ... }
composable("password") { ... }
composable("registration") { ... }
}
...
}

It is strongly recommended that you split your navigation graph into multiple methods as the graph grows in size. This also allows multiple modules to contribute their own navigation graphs.

fun NavGraphBuilder.loginGraph(navController: NavController) {
navigation(startDestination = "username", route = "login") {
composable("username") { ... }
composable("password") { ... }
composable("registration") { ... }
}
}

By making the method an extension method on NavGraphBuilder, you can use it alongside the prebuilt navigation, composable, and dialog extension methods:

NavHost(navController, startDestination = "home") {
...
loginGraph(navController)
...
}

Example:

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("about") {}
navigation(
startDestination = "login",
route = "auth"
) {
composable("login") {
val viewModel = it.sharedViewModel<SampleViewModel>(navController)

Button(onClick = {
navController.navigate("calendar") {
popUpTo("auth") {
inclusive = true
}
}
}) {
}
}
composable("register") {
val viewModel = it.sharedViewModel<SampleViewModel>(navController)
}
composable("forgot_password") {
val viewModel = it.sharedViewModel<SampleViewModel>(navController)
}
}
navigation(
startDestination = "calendar_overview",
route = "calendar"
) {
composable("calendar_overview") { }
composable("calendar_entry") { }
}
}

Extension function for NavBackStack entry

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(navController: NavController): T {
val navGraphRoute = destination.parent?.route ?: return viewModel()
val parentEntry = remember(this) {
navController.getBackStackEntry(navGraphRoute)
}
return viewModel(parentEntry)
}

Integration with the bottom nav bar

By defining the NavController at a higher level in your composable hierarchy, you can connect Navigation with other components such as the bottom navigation component. Doing this allows you to navigate by selecting the icons in the bottom bar.

To use the BottomNavigation and BottomNavigationItem components, add the androidx.compose.material dependency to your Android application.

dependencies {
implementation "androidx.compose.material:material:1.5.1"
}
android {
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
kotlinOptions {
jvmTarget = "1.8"
}
}

To link the items in a bottom navigation bar to routes in your navigation graph, it is recommended to define a sealed class, such as Screen seen here, that contains the route and String resource ID for the destinations.

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
object Profile : Screen("profile", R.string.profile)
object FriendsList : Screen("friendslist", R.string.friends_list)
}

Then place those items in a list that can be used by the BottomNavigationItem:

val items = listOf(
Screen.Profile,
Screen.FriendsList,
)

In your BottomNavigation composable, get the current NavBackStackEntry using the currentBackStackEntryAsState() function. This entry gives you access to the current NavDestination. The selected state of each BottomNavigationItem can then be determined by comparing the item's route with the route of the current destination and its parent destinations (to handle cases when you are using nested navigation) via the NavDestination hierarchy.

The item’s route is also used to connect the onClick lambda to a call to navigate so that tapping on the item navigates to that item. By using the saveState and restoreState flags, the state and back stack of that item is correctly saved and restored as you swap between bottom navigation items.

val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
composable(Screen.Profile.route) { Profile(navController) }
composable(Screen.FriendsList.route) { FriendsList(navController) }
}
}

Here you take advantage of the NavController.currentBackStackEntryAsState() method to hoist the navController state out of the NavHost function, and share it with the BottomNavigation component. This means the BottomNavigation automatically has the most up-to-date state.

Thank you for reading. 🙌🙏✌

Don’t forget to clap 👏 and follow me for more such useful articles about Android Development, Kotlin & KMP.

If you need any help related to Android, Kotlin and KMP. I’m always happy to help you.

Follow me and DM me for App development freelancing work.

Medium, LinkedIn, Twitter, GitHub, and Instagram.

--

--

Kaushal Vasava

Android Developer | Kotlin | Jetpack Compose | Kotlin MultiPlatform | LinkedIn's Top Voice (3K+ Followers) | Apps with 100K+ Downloads on the Playstore