Computer scienceMobileJetpack ComposeBasics

Navigation In Compose

18 minutes read

Most Android apps today are not limited to just one screen. They usually consist of multiple screens that users move between by swiping, tapping buttons, or selecting options from menus. With the introduction of navigation in Android Architecture Components, implementing navigation in Android apps has become more organized and straightforward. This component now supports navigation in apps built with Jetpack Compose. This topic will give you a basic understanding of navigation in Compose. We'll start by learning how to use the navigation component in Jetpack Compose and then cover concepts like navigation graphs, the navigation back stack, routes, NavHostController class, and NavHost composable. This knowledge will enable us to implement the Navigation Component in a demonstration project. Finally, we'll explore how to pass arguments when moving between screens.

Understanding Navigation In Jetpack Compose

Every app starts with a main screen that users see first. From here, users typically perform actions that lead to other screens. In Jetpack Compose, these screens are different composables within the project. For instance, a mail app's main screen might show a list of current messages. Users can navigate from there to the Accounts Screen, which displays a list of accounts, or to the detail screen for comprehensive email information. On this detail screen, users can choose to respond, taking them to the Reply Screen. The accounts screen might also let users navigate to screens where they can add new accounts or remove existing ones. The app's navigation flow might look like this:

app's navigation representation

In Jetpack Compose, you navigate between screens using the Navigation Component from the Jetpack Library. This component helps with navigation, from simple button clicks to complex patterns like hierarchical navigation and navigation drawers.

Each screen in the app is a destination, usually a composable. In Jetpack Compose, with the Navigation component, a NavHost and a NavController manage navigation. The NavHost displays the correct composable based on the NavController's destination.

The NavController maintains a navigation stack, tracking the history. It manages a back stack of destinations, allowing users to navigate backward through the app's hierarchy by pressing the back button.

When you navigate to a new screen, the NavController adds the new destination to the stack, and when you navigate back, it removes the top destination.

Here's what the navigation stack might look like if a user navigated from the Main Screen to the Accounts Screen and then to the Add Account Screen in our earlier example:

bh

Implement Navigation Component

To begin using navigation in Jetpack Compose, start by adding this dependency to your app's build.gradle.kts file:

implementation "androidx.navigation:navigation-compose:2.7.7"
implementation("androidx.navigation:navigation-compose:2.7.7")

  • Declare a navigation controller

The next step is to declare the navController. This is an instance of the NavHostController class. You can use this object to navigate between screens by calling the navigate method to navigate to another destination. You can obtain the NavHostController by calling rememberNavController from a composable function. This creates and remembers a NavHostController that survives configuration changes.

val navController = rememberNavController()

NavHostController is a subclass of the NavController class that provides additional functionalities for use with a NavHost composable.

  • Declare a navigation host

The navigation host (NavHost) is a special component that acts as a container and displays the current destination of the 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.

NavHost(
    navController = navController, // previously created via rememberNavController()
    startDestination = "main_screen" // this is the "route" to the main screen composable
) {
    /* navigation graph destinations */
}

When it is called, NavHost must be passed a NavHostController instance, the route of the starting destination of the graph, and a lambda that defines the builder for the navigation graph.

The NavController is always associated with a single NavHost composable.

When using Navigation within Compose, each composable destination in your navigation graph is associated with a unique route. Routes are represented as strings that define the path to your composable and guide your navController to the right place.

  • Add destinations to the navigation graph

To add your navigation destinations, call the composable extension function for each destination. Provide the route and the composable to which this route should lead.

NavHost(
    navController = navController,
    startDestination = "main_screen"
) {
    composable("main_screen") {
        MainScreen() /* This defines the UI to be displayed when we navigate to the "main screen" route */
    }
    composable("accounts_screen") {
        AccountsScreen()
    }
}

In this example, our navigation graph consists of two destinations: the Main Screen and the Accounts Screen. Each composable destination is associated with a unique route. While the route can be any string, it defines the path to our composable, and we will need it in many places when we want to navigate to our screen. Therefore, it's not good practice to hard-code routes directly into the composable function. Instead, we need to define them in one central location within our project for better flexibility. One way to achieve this is by defining them inside a sealed class:

sealed class Screen(val route: String) {
    object MainScreen : Screen("main_screen")
    object AccountsScreen : Screen("accounts_screen")
}

Then, the NavHost can reference them this way:

NavHost(
    navController = navController,
    startDestination = Screen.MainScreen.route
) {
    composable(Screen.MainScreen.route) {
        MainScreen()
    }
    composable(Screen.AccountsScreen.route) {
        AccountsScreen()
    }
}
  • Navigate to destinations

To demonstrate simple navigation between two screens, our MainScreen displays a button that should trigger navigation to the AccountsScreen when clicked.

@Composable
fun MainScreen() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        /* other fields */
        Button(onClick = { /* navigate */ }) {
            Text(text = "Go to accounts screen")
        }
    }
}

The primary mechanism for triggering navigation is via calls to the navigate method of the NavHostController instance, specifying the route for the destination composable. This requires access to the navController variable created previously and associated with our navigation graph. While we can just pass the navController as a parameter to our MainScreen composable, this approach is not ideal for architecting our app since it makes it hard to test in isolation, preview, and reuse our composables.

The recommended way is to pass a callback that will be executed each time the button is clicked.

@Composable
fun MainScreen(
    onNavigate: () -> Unit
) {
    Column(
        /* column parameters */
    ) {
        /* other fields */

        Button(onClick = { onNavigate() }) { 
            Text(text = "Go to accounts screen")
        }
    }
}

In the NavHost, update our code by defining a callback. This callback invokes the navigate function and provides the route of the Accounts Screen.

composable(Screen.MainScreen.route) {
    MainScreen() {
        navController.navigate(Screen.AccountsScreen.route)
    }
}

Now, if we run the application and click the button on the main screen, we can see that the navigation is performed successfully:

navigation from main screen to accounts screen

To navigate back from the Accounts Screen, users can press the back button located at the bottom of the screen. The same behavior can be achieved programmatically by invoking the popBackStack function on the navigation controller instance. This function removes the top destination from the back stack, effectively navigating back to the previous screen.

We can associate this action with the Go Back button in the Accounts screen by passing a callback to the Accounts Screen, similar to how we've handled navigation from the Main Screen:

@Composable
fun AccountsScreen(
    onNavigateBack: () -> Unit
) {
    Column(
        /* Column parameters */
    ) {
        /* other fields */

        Button(onClick = { onNavigateBack() }) {
            Text(text = "Go Back")
        }
    }
}

The navigation graph can be modified accordingly to invoke the popBackStack function:

composable(Screen.AccountsScreen.route) {
    AccountsScreen() {
        navController.popBackStack()
    }
}

As mentioned before, the main screen displays a list of recent emails. When you click on an email, you navigate to the Detail Screen to show the message body. Typically, you fetch the email details from the local cache or the network. However, before fetching the details, you need a reference to the specific email that was clicked. You can pass this reference to the Detail Screen as a navigation argument. The Detail Screen then retrieves this reference from the arguments bundle and uses it to fetch the email details.

In general, you should pass only the minimal amount of data between destinations. In our example, rather than passing the email object itself, we pass just a reference to it, because the total space reserved for that purpose is limited on Android.

Compose supports passing arguments of various types. For a list of supported types, see the documentation on supported argument types. In our example, we would likely need to pass the ID of our selected email from the Main Screen to the Detail Screen, possibly of type Integer. To do this, we add an argument placeholder to the Detail Screen's route:

composable("${Screen.DetailScreen.route}/{emailId}") {...}

By default, all arguments are parsed as strings. But you can specify the exact type of emailId through the arguments parameter of the composable function, which accepts a list of NamedNavArgument objects. You can create a NamedNavArgument using the navArgument method and specify the type:

composable(
    "${Screen.DetailScreen.route}/{emailId}",
    arguments = listOf(navArgument("emailId") { type = NavType.IntType })
) {...}

This tells the navigation graph that we're expecting a navigation argument of type Int in the route.

When the app triggers navigation to the DetailScreen, the argument value is stored within the corresponding back stack entry. This back stack entry for the current navigation is passed as a parameter to the trailing lambda of the composable function, where it can be taken and passed to the DetailScreen composable:

composable(...) { backStackEntry: NavBackStackEntry ->
    val emailId = backStackEntry.arguments?.getInt("emailId")
        ...
}

For now, the DetailScreen will be a simple composable that takes the emailId argument and displays it inside a Text composable:

@Composable
fun DetailScreen(
    emailId: Int
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = emailId.toString())
    }
}

The full code for adding DetailScreen to the navigation graph will look like this:

composable(
    "${Screen.DetailScreen.route}/{emailId}",
    arguments = listOf(navArgument("emailId") { type = NavType.IntType })
) { backStackEntry: NavBackStackEntry ->
    val emailId = backStackEntry.arguments?.getInt("emailId")
    DetailScreen(emailId = emailId!!)
}

The final step is to pass a value for the argument when calling the navigate method from the main screen. You achieve this by appending the value to the route like this:

composable(Screen.MainScreen.route) {
    MainScreen() {
        navController.navigate("${Screen.DetailScreen.route}/1234")
    }
}

navigation with argument

Conclusion

We've explored how the Navigation Component in Jetpack Compose streamlines the process of creating destinations through composable functions that fit into navigation graphs. You create a navigation graph with the NavHost function, linking each destination to a unique route. This graph creation relies on the NavHostController, which facilitates navigation with the navigate method and keeps track of the back stack history. You define destinations in the navigation graph using the composabe function, which allows you to specify the arguments for navigation and their types.

8 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo