Mobile Application Development - Part 7

Navigation - Part 1

Introduction

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.

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

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:

Setting up the navigation API

You need to make these imports:

import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController

How to structure the application - using callbacks to enable loose coupling

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.

The back stack

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:
The back stack

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:

In most cases the second is probably the most desirable option, though it will depend on the specific application. How can this be done? We can call the 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().

Circular navigation

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.

Exercises

  1. Enhance your mapping application so that it has two screens: one for the map itself, and a settings screen to set the map's tile source, latitude and longitude. The default tile source is the Mapnik style, however others are available. The OpenTopoMap tile source shows maps with relief-shading, i.e. hills are clearly shown. To set the tile source:
    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:
    A Switch composable
    To use a 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.

    There should be a button on the main map screen to take the user to the settings screen, and another button on the settings screen to return the user to the map by popping the back stack.
  2. Implement a shopping list application with two screens. The first screen should display the list (see Topic 3). The second screen should allow the user to add a new entry. Use navigation to navigate between both; each screen should have a button allowing the user to navigate to the other screen. When the user has added an entry and returns to the main screen, pop the back stack to return to the main screen. Use a ViewModel to hold the list items and observe it from the main screen.

    The code below shows how you can use a view model holding live data as a list. Note how 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>>()
    }