Return to Home Page

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.

Start Activity for Result, before ActivityResultContracts were invented

Source

There are some notable properties of Android’s activity transitions:

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.

BT Pin Confirmation Dialog

@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.

Map Default Center

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 1

Step 2: Click “Select Default Center”. The Map Screen Opens in selection mode. Step 2

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. Step 3

Return to Top