Navigator
The “navigator” and navigation framework in the e39-rpi HMI is modeled after Android’s multi-activity model.
The Android example
Android now has ActivityResultContracts, but in the past, Android adopted a simple model for handling activity transitions.
There are some notable properties of Android’s activity transitions:
- Activity A does not hold a reference to Activity B.
- Using
context.startActivity()orcontext.startActivityForResult, Activity A requests that Android start Activity B. - When Activity B finishes, Android restarts Activity A with the
result fromB. This bookkeeping is set up with request code / result code. Activity B could pass an extras Intent when returning a result as well. - All activities that can be started are “registered somewhere”: the Application Manifest.
The e39-RPI solution
I came up with the e39-RPi navigation solution in July 2021. I think if I were starting from scratch in the present day, I’d try Slack Circuit which was being written at the same time.
Avoiding Dependency Loops
The property that Activity A does not hold a reference to Activity B was the driving force behind the design. Dagger dependency loops would’ve been really messy to untangle with Provider<T> sprinkled all over the place.
The main trick I used was to split the Navigator and the NavigationNodeTraverser into two separate classes.
Using a Provider<T> between NavigationNodeTraverser and Navigator breaks the loop. See Provider Injections for more details.
Navigator depends on only the Root Node (Empty Menu), which no other menu is able to navigate to.
class NavigationNodeTraverser @Inject constructor(
private val navigator: Provider<Navigator>,
@Named(ALL_NODES) private val allNodes : Provider<Set<NavigationNode<*>>>,
private val logger: Logger
) {
private fun findNode(node : Class<out NavigationNode<*>>) : NavigationNode<*>? {
val newNode = allNodes.get().find { it.thisClass == node }
if (newNode == null) {
logger.e("NAVIGATOR", "No new node found. Requested ${node.toGenericString()}")
return null
}
return newNode
}
fun navigateToNode(node : Class<out NavigationNode<*>>) {
findNode(node)?.let { newNode ->
navigator.get().navigateToNode(newNode)
}
}
...
}and
@ApplicationScope
class Navigator @Inject constructor(
@Named(ROOT_NODE) private val rootNode : NavigationNode<*>,
private val logger: Logger
) { ... }Back Stack & Screen Navigation
Android implements the concept of a back-stack, where screens are pushed and popped into a virtual stack. “Going back” goes back to the previous screen by popping the top of the stack. Android also has the concept of Task Affinities, which I didn’t implement for this project.
In e39-Rpi, the Navigator is responsible for maintaining the back stack. The current entry on the back-stack is pushed into a MutableStateFlow, which is subscribed to in the Pane Manager
// Navigator.kt
class Navigator @Inject constructor(
@Named(ROOT_NODE) private val rootNode : NavigationNode<*>,
private val logger: Logger
) {
data class BackStackRecord<R>(
val node : NavigationNode<R>,
var incomingResult : IncomingResult?
)
data class IncomingResult(
val requestParameters : Any?,
val resultFrom : Class<out NavigationNode<*>>?,
val result : Any?
)
private val _mainContentScreen = MutableStateFlow(BackStackRecord(rootNode, null))
....
}
// MenuWindow.kt
...
mainContent = {
Box(
Modifier.fillMaxSize()
) {
val currentNode : Navigator.BackStackRecord<*> = navigator.mainContentScreen.collectAsState()
...
currentNode.provideMainContent().invoke(currentNode.incomingResult)
}
},
...The line currentNode.provideMainContent().invoke(currentNode.incomingResult) provides a hint to how
an individual screen is built.
interface NavigationNode<Result> {
val thisClass : Class<out NavigationNode<Result>>
fun provideMainContent() : @Composable (incomingResult : Navigator.IncomingResult?) -> Unit
}and the simplest possible screen:
@ScreenDoc(
screenName = "EmptyMenu",
description = "The GUI entrypoint. Immediately go to BMWMainMenu",
navigatesTo = [
ScreenDoc.NavigateTo(BMWMainMenu::class)
]
)
@AutoDiscover
class EmptyMenu @Inject constructor(
private val navigationNodeTraverser: NavigationNodeTraverser
) : NavigationNode<Nothing> {
override val thisClass: Class<out NavigationNode<Nothing>>
get() = EmptyMenu::class.java
override fun provideMainContent(): @Composable (incomingResult: Navigator.IncomingResult?) -> Unit = {
// Spacers are here so that the background is blue.
FullScreenMenu.TwoColumnFillFromTop(
leftItems = listOf(MenuItem.SPACER, MenuItem.SPACER),
rightItems = listOf(MenuItem.SPACER, MenuItem.SPACER)
)
LaunchedEffect(true) {
navigationNodeTraverser.navigateToNode(BMWMainMenu::class.java)
}
}
}Notice that provideMainContent() provides a Composable lambda that takes in a parameter: override fun provideMainContent(): @Composable (incomingResult: Navigator.IncomingResult?) -> Unit = { incomingResult -> ...}
This is the key to making the back stack navigation work for both incoming parameters and returned responses.
Incoming Parameter:
data class IncomingResult(
val requestParameters : Any?,
val resultFrom : Class<out NavigationNode<*>>?, // Always null when IncomingResult is an incoming parameter
val result : Any? // Always null when IncomingResult is an incoming parameter
)Returned response:
data class IncomingResult(
val requestParameters : Any?, //Always null when IncomingResult is a returned response
val resultFrom : Class<out NavigationNode<*>>?, // The class of the node returning the result, helps with type-casting the result
val result : Any? // The result
)How does the navigator set the result on another node?
When a node wants to set a result, the navigator peeks at the node that is one-deep in the backstack, and sets the IncomingResult on the backstack record.
Example: Incoming Parameters (BT Pin Confirmation Dialog)
A good example is the Pin Confirmation Screen in the Bluetooth flow. This screenshot shows the BT Pin Confirmation Dialog, and in the Navigation Debug Window, it shows the contents of the backstack and the IncomingResult.
@AutoDiscover
class BluetoothPinConfirmationScreen @Inject constructor(
private val navigationNodeTraverser: NavigationNodeTraverser
) : NavigationNode<BluetoothPinConfirmationScreen.PinConfirmationResult> {
data class PinConfirmationResult(
val isApproved : Boolean
) : UiResult()
data class PinConfirmationParameters(
val phoneName : String,
val pin : String
)
override val thisClass: Class<out NavigationNode<PinConfirmationResult>>
get() = BluetoothPinConfirmationScreen::class.java
override fun provideMainContent(): @Composable (incomingResult: Navigator.IncomingResult?) -> Unit = content@ {
val parameters = it?.requestParameters as? PinConfirmationParameters ?: return@content
FullScreenPrompts.OptionPrompt(
header = "Pair with Device?",
options = FullScreenPrompts.YesNoOptions(
onYesSelected = {
navigationNodeTraverser.setResultAndGoBack(this,
PinConfirmationResult(true)
)
},
onNoSelected = {
navigationNodeTraverser.setResultAndGoBack(this,
PinConfirmationResult(false)
)
}
)
) {
Column(
Modifier.background(ThemeWrapper.ThemeHandle.current.colors.menuBackground)
) {
Text("Do you want to pair with this device?", color = Color.White, fontSize = 28.sp.halveIfNotPixelDoubled())
Text("", color = Color.White, fontSize = 28.sp.halveIfNotPixelDoubled())
Text("Name: ${parameters.phoneName}", color = Color.White, fontSize = 28.sp.halveIfNotPixelDoubled())
Text(parameters.pin, color = Color.White, fontSize = 64.sp.halveIfNotPixelDoubled(), textAlign = TextAlign.Center)
}
}
}
}Which goes to
//NavigationNodeTraverser.kt
fun <R> setResultAndGoBack(node : NavigationNode<R>, result : R) {
//Nothing really preventing a bad child from getting a copy of ALL_NODES
//and setting results on random things.
navigator.get().setResultForNodeAndGoBack(node, result)
}which calls
//Navigator.kt
fun <R> setResultForNodeAndGoBack(node: NavigationNode<R>, result : R) {
if (node != mainContentScreen.value.node) {
logger.w("Navigator", "WARNING: Trying to navigate back from " +
"not the top node! Node: $node, ${mainContentScreen.value.node}")
}
//Mutate the record at the top of the back stack
backStack.last().incomingResult = IncomingResult(
resultFrom = _mainContentScreen.value.node.thisClass,
result = result,
requestParameters = null
)
//Remove the record at the top of the backstack.
_mainContentScreen.value = backStack.removeLast()
}Example: Map Selection Dialog (Result Code)
The Map Center Configuration dialog uses a result from the Map-Screen to set configuration.
override fun provideMainContent(): @Composable (incomingResult: Navigator.IncomingResult?) -> Unit = { params ->
val chosenCenter = if (params?.resultFrom == MapScreen::class.java && params.result is MapScreenResult) {
(params.result as? MapScreenResult.PointSelectedResult)?.point
} else { null }
....A round-trip through the MapScreen looks like this. Pictures are from the Navigation HMI Debug Window
Step 1: Open the Map Settings Center Screen:
Step 2: Click “Select Default Center”. The Map Screen Opens in selection mode.
Step 3: Select a center in the map screen. The Map Screen sets a result and goes back. The Map Settings Center Screen is opened with a result.