Mobile Application Development - Part 5

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.

We will also take a look at delegated properties.

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 as a dependency (see the documentation).

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 can access the NavController. So we can't directly navigate to Composable B from Composable A. So how can we do it?

We looked at the callback-based approach in OODD but we will revise it now. The 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 a top-level Surface composable (which has access to the navController), but pass it into Composable A as an argument. When the button is clicked in Composable A, it will run the callback (because it was passed into Composable A) 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, in a callback approach, Composable A might look like this:

@Composable
fun ComposableA(addPersonCallback: ()->Unit) {
    Button(onClick = { addPersonCallback() } ) {
        Text("Add Person")
    }
}
Note how it takes a callback as a parameter, and calls that callback when the button is pressed.

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 (you all did stacks in Data Structures: see the COM421 notes for revision). 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 we wanted to return the user to Composable A by clicking a button on Composable B? 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.


Delegated properties

As well as an introduction to navigation, we will also cover the concept of delegated properties in Kotlin.

You will have seen this syntax when declaring state variables (which are data type MutableState) in Compose:

var name = remember { mutableStateOf("") }
As we have seen, you then use MutableState's value property to get the actual data from the MutableState object.

We can, however, use an alternate syntax as follows:
var name: String by remember { mutableStateOf("") }
If you use by remember, rather than just remember, you receive a variable holding the actual data, with a data type of whatever that data is (e.g. String in this example), rather than a variable of type MutableState. This means that you do not need to use the value property when accessing or updating the value, as the variable represents the data itself, not the MutableState. You can just read or update the variable directly - and the UI will still recompose (be redrawn) if it's updated. But how can this be, if the variable isn't a MutableState anymore? And what does the by mean? We have also seen it when initialising a ViewModel.

When we use by, we are using a Kotlin feature known as delegated properties. Delegated properties, like custom getters and setters, allow you to customise what happens when you retrieve or update a property (attribute) of an object. They do it with a delegate class; see the documentation for details. So delegates are rather like custom getters and setters, but rather than simply customising the getter and setter, you delegate the job of getting and setting a property to an entirely separate class - the delegate - which can give more flexibility in some situations. The by keyword specifies that we will be using a delegate.

Here is an example of a basic property delegate, to show how they work:

package com.example.delegates1

import kotlin.reflect.KProperty

class Student (var name: String, var course: String) {
    var mark: Int by MarkDelegate()

    override fun toString(): String {
        return "Student $name on $course with mark $mark"
    }
}

class MarkDelegate(private var controlledProperty: Int = 0) {
    operator fun getValue(thisRef: Any?, property: KProperty<*>) : Int{
        return controlledProperty
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) {
        if(newValue in 0..100) {
           controlledProperty = newValue
        } else {
            println("Cannot set ${property.name} to $newValue for $thisRef")
        }
    }
}


fun main () {
    val john = Student("John", "Software Engineering")
    john.mark = 101
    println(john)
    john.mark = 83
    println(john)
    john.mark = -1
    println(john)
}
Note how we are declaring a property mark inside the Student class but declaring it with by MarkDelegate(). This means that the MarkDelegate class will be responsible for handling retrieving the value of, and setting the value of, the mark property. MarkDelegate is thus a delegate. A delegate should contain two methods, getValue() and setValue(), which retrieve and update the associated value. Note also how the delegate class takes the property that it's controlling as a parameter:
class Mark(private var controlledProperty: Int=0)
If you look at getValue(), it's just returning the mark, so no custom behaviour occurs if we simply retrieve the value (e.g. by printing it). However setValue() is more interesting. Note how it take the new value as a parameter (newValue). The setValue() checks that the new value is in the range 0 to 100, and only updates the mark to the value if it is. So in other words, marks below 0 or above 100 will be rejected. So if we try to set the mark to any value less than 0 or greater than 100, it will not be updated. Note that the thisRef parameter in setValue() and getValue() refers to the object that the delegated property belongs to. Also the property parameter represents the property being controlled, and the property's name can be retrieved with property.name. Therefore the output of this program will be as follows:
Cannot set mark to 101 for Student John on course Software Engineering with mark 0
Student John on course Software Engineering with mark 0
Student John on course Software Engineering with mark 83
Cannot set mark to -1 for Student John on course Software Engineering with mark 83
Student John on course Software Engineering with mark 83
When we try to set the mark to 101 or -1, it's rejected. However when we set the mark to 83, it's accepted.

Note that you can pass additional parameters to the delegate, but the first parameter always reference the property that the delegate is controlling.

Relevance to Compose state

The relevance to Compose state, then, is that the MutableState is acting as a delegate for the underlying data (of type Int, String, and so on) stored within it. You get back a basic variable of type Int, String, and so on, from by remember, but it's controlled by a delegate. This delegate is the MutableState, and has a custom setValue() method. So, if we change the basic variable returned from by remember the delegate's setValue() (containing custom behaviour to redraw the composable) runs, and as a result, a recompose occurs.

Exercises

  1. Change your mapping application so that all variables holding state are initialised with by remember, in other words they use the MutableState as a delegate.
  2. Enhance your mapping application so that the map style can be set. It should have two screens: one for the map itself, and a settings screen to set the map's style, latitude and longitude. The style we have used so far is the OpenFreeMap bright style, however others are available, including the black-and-white Positron style.

    Implement this using a boolean state variable blackAndWhite which should be set to true if the user wants the black and white (Positron) style. In the settings screen use a Switch to set the blackAndWhite 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=blackAndWhite,
        onCheckedChange = {
            blackAndWhite = it
        }
    )
    where blackAndWhite 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.
    When creating your map composable, test the value of blackAndWhite and if it's true, set the style to https://tiles.openfreemap.org/styles/positron. Otherwise set it to httpS://tiles.openfreemap.org/styles/bright, as before.
  3. 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>>()
    }