Navigation in Jetpack Compose. What does it change?

Jetpack Compose is a modern toolkit for building native Android UI, introduced in Android 11. Jetpack Compose is designed to make it easier for developers to build complex, responsive, and dynamic UI using a declarative programming model.

With this new toolkit, we no longer write our screens in XML or use fragments to hold them; instead, we use composables.

Since the Navigation Component works with fragments and activities, which we no longer use to hold our screens, Android released a new version of the library that supports navigation between composable screens.

In this article, we’ll show how to use it and what changes it brings compared to the version for the legacy UI toolkit.

Navigation with Jetpack compose

The new version of the library retains the same components as the previous version, but adapted to Jetpack Compose. For example, instead of using NavHostFragment to host our screens and navigate between them, we can now use NavHost with composables.

Let’s have a look at the following scenario:

@Composable
fun MyApp() {
  HomeScreen()
}

@Composable
fun HomeScreen() {
  Text("Welcome to the Home screen!")
}

@Composable
fun SettingsScreen() {
  Text("Welcome to the Settings screen!")
}

In this code snippet, we have two composable screens – HomeScreen and SettingsScreen – and one composable function that represents our app – MyApp. Let’s see how we can implement navigation between these two screens.

Implement the library in your project

To use the navigation library in a Jetpack Compose app, you’ll need to add the dependency to your app module’s build.gradle file, as shown in the example below:

implementation "androidx.navigation:navigation-compose:$compose_nav_version"

You replace $compose_nav_version by the latest version, which you can find in the official https://developer.android.com/jetpack/androidx/releases/navigation page.

Creating the NavHost

As we mentioned earlier, the NavHost is a composable that serves as the container for the navigation stack. It is responsible for managing the navigation stack and updating the UI to reflect the current destination.

To create a NavHost, you can use the NavHost composable function, passing it the starting destination composable and the navigation graph.

@Composable
fun MyApp() { 
  NavHost(startDestination = "home") {
    composable("home") { HomeScreen() } 
    composable("settings") { SettingsScreen() } 
  } 
}

As you can see in this example, the NavHost accepts a lambda function that has access to the NavGraphBuilder, which we use in the example to call its composable function.

The NavGraphBuilder enables us to create a NavGraph, which defines the navigation in our app. The composable function we use for each screen creates a node in the navigation graph, which includes at least a path and a composable function.

In this example, we have created two nodes in the navigation graph. The path “home” leads to the HomeScreen, and the path “settings” leads to the SettingsScreen.

Now, we will show you how to use the NavController to navigate to these two screens.

Navigating between screens with the NavHostController

To prepare for this example, we are going to add some additions to our Composable screens.

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    navigateToSettings: () -> Unit
) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Welcome to the Home screen!")
        Button(onClick = navigateToSettings) {
            Text("Go to Settings")
        }
    }
}

@Composable
fun SettingsScreen(
    modifier: Modifier = Modifier,
    navigateBack: () -> Unit
) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Welcome to the Settings screen!")
        Button(onClick = navigateBack) {
            Text("Go back to Home!")
        }
    }
}

Since this is not a post about Jetpack Compose itself, I’m not going to explain most of the additions made to make the screen look nicer. The important thing to note are the two lambda functions added in the constructor on the two screens (navigateToSettings and navigateBack).

This will allow us to define the navigation from the NavHost using the NavHostController. To do that, we will go back to our MyApp composable function and add the NavController implementation as below.

@Composable
fun MyApp(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(navigateToSettings = {
                navController.navigate("settings")
            })
        }
        composable("settings") {
            SettingsScreen(navigateBack = {
                navController.popBackStack()
            })
        }
    }
}

In this example, the NavHostController is added using the “rememberNavController” function. This creates an instance of the NavHostController that will survive configuration changes such as rotations or recomposed by Compose when a change is detected.

Then, the instance of the NavHostController is passed to the constructor of the NavHost. This is necessary because the NavHost will provide important information that the NavHostController needs to function, such as a reference to the graph, the lifecycleOwner, viewModelOwner and more.

Finally, the lambda functions defined earlier are passed to the constructors of the two screens. On the HomeScreen, the “navigate” function is called, which triggers navigation to the screen specified as an argument (in this case, “settings”, which was defined as the composable in the NavGraphBuilder for the SettingsScreen). On the SettingsScreen, the “popBackStack” function is triggered, which goes back to the previous destination.

Now, the “MyApp()” function is called from the MainActivity in the app.

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeNavigationTheme {
                MyApp()
            }
        }
    }
}

This results in an app with the two screens we defined that looks like this:

Passing data between screens

Now, let’s imagine that we want to share data between two screens. For example, we want to display the name of the previous screen in the label of the button in the SettingsScreen. To achieve this, we will adapt the SettingsScreen to display the name of the screen that navigated to it, instead of hardcoding “Home“. We will do this by adding an extra parameter in the SettingsScreen’s constructor called “originScreen“.

@Composable
fun SettingsScreen(
    modifier: Modifier = Modifier,
    originScreen: String,
    navigateBack: () -> Unit
) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Welcome to the Settings screen!")
        Button(onClick = navigateBack) {
            Text("Go back to $originScreen!")
        }
    }
}

The example above simply displays the name of the screen that was passed through the originScreen parameter. Now, we’ll come back to the NavHost to add the final details necessary to make navigation with arguments happen.

@Composable
fun MyApp(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(navigateToSettings = {
                navController.navigate("settings/home")
            })
        }
        composable("settings/{originScreen}") { backStackEntry ->
            SettingsScreen(
                originScreen = backstackEntry.arguments?.getString("originScreen") ?: "",
                navigateBack = {
                    navController.popBackStack()
                }
            )
        }
    }
}

First, we added the originScreen at the end of the path “settings“. Notice that we add the name parameters in between brackets {}. This way we can just write our arguments replacing the parameter by the actual arguments (like settings/home in the example above).

As you can see in the example, we are getting the navigation arguments from a NavBackStackEntry (that we renamed to backStackEntry for simplicity). This object holds information about our destinations (in this case SettingsScreen) and will survive as long as this destination is not removed from the backStack.

This means that if we navigate to other screens and we come back later, the arguments will still be there.

Now, let’s see an example of our final result.

Conclusion

What changed compared to the previous version?

  • The NavHost composable replaces the NavHostFragment
  • The navigate function replaces the findNavController().navigate() function
  • The navigation graph is defined within the NavHost composable instead of in a separate xml file (it can be also defined outside and provided as an argument to the NavHost also)

In conclusion, the new version of the Navigation library for Jetpack Compose makes it easy to implement navigation in your app, while also keeping a consistent and familiar API for developers which already used the previous version. It also allows for more flexibility and a cleaner codebase by removing the need for xml and Fragments.

Leave a Reply

Your email address will not be published.