Navigation in Jetpack Compose refers to the interactions that lets you move across composables in your app. And this is possible using the Navigation components API which is bundled with cool features to make handling navigation easier.
To have access to the navigation feature, we need the navigation-compose library. We will be adding this library as a dependency in our project.
Now you might be asking yourself what a dependency is, right?
Well, Android cannot include everything that every developer wants.
So it allows other developers to add functionalities that are not bundled into the core of android. These functionalities are packaged into something called a library. And you add these libraries to the android project as dependencies. The whole idea behind dependency is to make your apps use pluggable libraries that you can add or remove depending on if you need them.
You want to use Google Maps? Then you add the google maps dependency. You want to use Stripe payments? Then you add the stripe payment dependency. You get the idea.
Okay, now you understand the concept of dependencies in Android, it’s time to add the navigation-compose dependency.
Let’s do that now.
You add dependencies to your android app in the build.gradle file.
Open up the Gradle Scripts header in the project view of the window toolbar. You have two build.gradle files: one for the project and the other for the app module. Open up the one for the app module which is the second one in the list of files. You can see different configurations for our app.
Now scroll down to the end of the file. You can see the dependencies group at the end. And inside it you have different dependencies that your app depends on.
For example, the compose.material3 dependency which lets us use Material Design 3 based composables like Text, Button and the rest.
Okay, to add in the navigation-compose library, paste in the following code below the material3 dependency:
implementation('androidx.navigation:navigation-compose:2.5.3')
Now if you look at the top of the window, you’ll see a link telling you to sync this file. You always need to sync the gradle script with the application whenever you make changes to this file. This is important because syncing downloads the dependencies and registers them in the app.
Alright, go ahead and click on the “Sync Now” link. And if you look at the status bar below, you can see this action downloads the dependency, configures and then builds the project.
Cool!!! Now, let’s use the navigation feature in our app.
Firstly, we need to create a NavController which is the the main API for navigation in compose. We need to create it where all composables in the hierarchy can have access to it. And the best place to do this would be up in the widget tree.
So lets create a root screen for our app. That is, the main screen that other screens would be layed on top of. Open up the MainActivity.kt file.
Then create a new composable:
@Composable
fun MainScreen() {
}
This would be the main content for our app now. Let’s replace GameScreen with this inside the MainActivity.
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen() // Update Code
}
Now, let’s create the NavController.
Enter the following code inside the MainScreen composable:
val navController = rememberNavController()
This creates a NavController. The NavController is responsible for keeping track of the state of the screens and the back stacks of the screens it manages.
Do note that navigation in Jetpack compose uses the concept of stacks to handle screen navigation. So when you navigate to a new screen, the composable in pushed to the top of the navigation stack. And when you press the back button, it is popped off the stack.
A NavController must be linked up with a single NavHost and a NavHost is the shell that houses all the destinations in the app.
Add the following code below the navController:
NavHost(navController = navController, startDestination = "gamescreen") {
composable("gamescreen") { GameScreen() }
composable("about") { AboutScreen() }
}
We created a NavHost here and passed in the navController we created earlier. We also passed in the start destination which is going to be the default screen that is shown when the app starts.
Next we have the builder lambda which is responsible for building the destination routes and associating them with a composable. And this is done using the composable() function. So the route for the GameScreen composable is gamescreen while the route for the AboutScreen composable is about. So you navigate to those screens using their corresponding route string.
Run your app to try it out.
This shows us the GameScreen since it is the start destination. Let’s change the start destination to about.
Then restart the activity.
The AboutScreen is pushed to the top of the stack and displayed as expected. Change that back to gamescreen.
Now we’re not going to be changing the routes manually. I mean, that’s a no-brainer. We need a way to navigate to the about screen from inside the Bullseye app. We already know where we want to trigger the navigation code. We want to navigate to the AboutScreen when the info button is tapped.
Let’s open up GameDetail.kt file. Then scroll to the info button. You can see that we passed an empty lambda to the onClick listener of the button. The NavController has a navigate() method that is used to navigate to the route passed to it. But remember, I mentioned earlier that the NavController manages the navigation state of the destinations. This means that a call to the navigate method modifies the internal state of the NavController.
Now, you might be tempted to pass an instance of the navController and call the navigate method from inside the GameDetail composable. But that’ll be going against the single source of truth principle of state hoisting we learned from the previous course. This means that only the composable that has the state should make navigation calls.
So we need to update the state in the NavHost and expose an event that wil be triggered from inside the GameDetail composable. This is state hoisting in action so lets get to it.
Let’s add in the event that’ll be called as a parameter of GameDetail like so:
@Composable
fun GameDetail(
//...
onNavigateToAbout: () -> Unit // New Code
) {
This is a lambda that returns nothing. We just use it to trigger navigating to the about route.
Then call it inside the onClick lambda of the info button:
onClick = { onNavigateToAbout() },
We have an error and if we scroll down, you can see the preview complains that the GameDetail composable needs a value to be passed for this new parameter.
Add in an empty lambda like so:
GameDetail(onStartOver = {}, onNavigateToAbout = {})
We also need to pass in this argument where GameDetail is called and that is inside the GameScreen composable function. Let’s head over to the GameScreen file. Scroll to where GameDetail is called.
Then add it in like so:
GameDetail(
//...
onNavigateToAbout = onNavigateToAbout,
//...
)
Just ignore the double preview we have here. It’ll fix itself when the preview is refreshed. Alright, we have an error as expected because the onNavigateToAbout reference does not exist in the GameScreen function. So let’s add it in.
Update your code to the following:
fun GameScreen(
onNavigateToAbout: () -> Unit
) {
//...
}
This has the same signature with the onNavigateToAbout lambda in the GameDetail function so nothing new. We’re just lifting the event up until where it’ll be used.
Scroll down to the preview function and pass in an empty lambda to fix the error:
GameScreen(onNavigateToAbout = {})
Now we have this event that is available to trigger the navigation code, we need to implement the code in the parent tree and that is inside the MainScreen function.
Head over to MainActivity.kt file. And you can see the GameScreen call has an error because it needs that argument. It is inside that lambda we’ll implement the navigation code.
Update your code to the following:
composable("gamescreen") {
GameScreen(
onNavigateToAbout = { navController.navigate("about") } // New Code
)
}
We call navController.navigate() and pass the about route string. This code will push the AboutScreen to the top of the navigation stack when triggered.
This is the proper way to use the navigate() method. With this approach we’re making sure the MainScreen composable contains the only NavController that holds the navigation data and only that instance is updated whenever a change is made to the internal state. And we know calling the navigate() method modifies the NavControllers internal state.
Alright, run your app to try it out.
Tap the info button.
And this takes us to the AboutScreen as expected. Tap the back button in the top app bar. It doesn’t work yet because we’ve not added the code. But we can use the back button from the emulator window. And this correctly pops off the about screen from the stack.
You’ll handle the back button in the next episode.