This week will cover the use of the Navigation API (NavHost, NavGraph, NavController) to do basic navigation. Next week we will look at how we can create an App Bar and menu to enhance our navigation.
So far we have just looked at simple Android apps with a single screen. However, most real-world Android apps will feature multiple screens allowing the user to perform multiple operations. For example one screen might include a map, another screen might include a form allowing the user to add a new point of interest, and a third screen might show the app's settings.
How is this done? The previous approach was to use multiple activities for each screen, so that a secondary activity would be launched from the main activity. However it's now recommended to use the Android navigation API to navigate from one screen to another within a single activity. Luckily, Jetpack Compose works very well with the navigation API. With the navigation API and Compose, you define different composables in different screens, and host them in a NavHost
- see below.
The navigation API consists of a series of classes which work together to enable navigation. It is a separate library from Compose itself so must be included in your package.json (see the documentation):
implementation("androidx.navigation:navigation-compose:2.7.7")Note that version 2.7.7 requires Android API 34 to be installed. The university computers have API 33 as installation took place last summer. If on a university computer you'll need to downgrade to 2.6.0:
implementation("androidx.navigation:navigation-compose:2.6.0")
The navigation API consists of these key classes:
NavHost
: a "host" composable for your navigation. This contains all the composables for each screen within it.NavGraph
: a data structure representing the different navigation destinations, known as routes. Each route has a name (in this respect, they are not unlike routes in web frameworks such as Express, for those of you who are familiar with web development.NavController
: class to control the navigation. With the NavController
you can navigate to other routes.You need to make these imports:
import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController
NavController
:
val navController = rememberNavController()This will setup a navigation controller and remember it if the screen rotates.
Surface
, create a NavHost
to host the composables:
NavHost(navController=navController, startDestination="mainScreen") { composable("mainScreen") { MainScreenComposable() } composable("settingsScreen") { SettingsComposable() } }This sets up a
NavHost
associated with the given NavController
. We specify a lambda to set up the navigation graph. Note that the navigation graph is setup using a series of calls to composable()
each of which takes the route of that composable plus a lambda containing the appropriate composable for that route. So here we are specifying that:
mainScreen
route corresponds to the MainScreenComposable()
;settingsScreen
route corresponds to the SettingsComposable()
;navController
to navigate to a particular composable using its route, e.g:
navController.navigate("settingsScreen")would navigate to the
settingsScreen
route, corresponding to the SettingsComposable
. This might run, for example, in response to a button press or selecting a menu item.This is discussed in the Android documentation; see "Expose events from your composables".
An application making use of navigation would probably navigate to another composable by means of a button press or menu selection. How might we implement this? Let's say a press of a button from Composable A causes navigation to Composable B. We have a problem because the navController
is not accessible in Composable A, only the top-level, parent composable. So we can't directly navigate to Composable B from Composable A. So how can we do it? We can define a callback function to perform the navigation. This callback function is defined in the parent composable but passed into any child composable that needs it. So in our example, we might define the callback in the Surface
(which has access to the navController
) but pass it into Composable A. When the button is clicked in Composable A, it will run the callback (because it was passed into Composable A as a parameter) without having any direct knowledge of what the callback is doing. Thus, Composable A becomes loosely coupled. It's not coupled to the parent composable because it doesn't have any direct reference to the parent's variables. It just has a callback, which could come from any application, and blindly calls the callback when the button is pressed.
So Composable A might look like this:
@Composable fun ComposableA(addPersonCallback: ()->Unit) { Button(onClick = { addPersonCallback() } ) { Text("Add Person") } }Note how it takes a callback (which takes a string) as a parameter, and calls that callback when the button is pressed, passing back the name state variable.
The parent composable could then pass the callback in to ComposableA
, as follows:
@Composable fun ParentComposable() { val navController = rememberNavController() NavHost(navController=navController) { composable("mainScreen") { ComposableA(addPersonCallback = { navController.navigate("addPersonScreen") }) } composable("addPersonScreen") { ComposableB() } }Note how when we set up
ComposableA
we pass in a callback which when called (when the button in ComposableA
is pressed) we'll navigate to the composable with a route of addPersonScreen
.
When using the Navigation API, each new navigation is added to a stack of screens (see the COM421 notes on stacks). Each time we navigate to a new composable, a new entry is added to the back stack, and when we press the "back" button, the top entry is removed from the back stack and we return to the previous composable.
The back stack is shown below:
This has some consequences for usability. For example, imagine a user starts by viewing Composable A and they then navigate to Composable B. What if clicking a button on Composable B returned the user to Composable A? There are two ways we could implement this:
popBackStack()
method of our NavController
, e.g:
navController.popBackStack()So if we wanted to navigate back to Composable A from Composable B when the user clicks a button, we could pass a callback into Composable B (in a similar manner to the callback example given above) which calls
popBackStack()
.
The documentation here describes a common problem in navigation. As discussed in the documentation, if we have three composables on the back stack (A, B, and C) and the sequence of navigation is A to B to C, then back to A again, we might want to pop both B and C from the back stack when the user returns to A. How can we do that? We could call popBackStack()
twice. But what if the user could also navigate from A to C, in which case we would only need to pop once. We clearly need some way of removing all composables above composable A. As the article discusses, we can use popUpTo()
to remove everything from the back stack up to the composable passed in as an argument. To do this we must supply a lambda as the final argument of navigate()
, containing the popUpTo()
call. So for example:
navController.navigate("composableA") {popUpTo("composableA")}will remove all composables from the back stack above the latest copy of
composableA
.
map1.setTileSource( if (opentopomap) TileSourceFactory.OpenTopo else TileSourceFactory.MAPNIK )Implement this using a boolean state variable
openTopo
which should be set to true if the user wants the OpenTopoMap. In the settings screen use a Switch
to set the OpenTopoMap status. A Switch
is a composable which can be "flicked" on and off, and is normally bound to a boolean state variable:
Switch
:
Switch( checked=openTopo, onCheckedChange = { openTopo = it } )where
openTopo
is a state variable. Note how, with a Switch
, we specify the checked
status (on or off) and bind it to a boolean state variable. Then, when the user "flicks" the switch, the onCheckedChange
lambda is run, which sets the boolean state variable to the current status of the switch. addStudent()
adds a student to the list and then syncs the live data - liveStudents
- with the list.
class StudentViewModel : ViewModel() { val students = mutableListOf<Student>() fun addStudent(s: Student) { students.add(s) liveStudents.value = students } val liveStudents = MutableLiveData<MutableList<Student>>() }