Skip to content

Navigation

Since MVU architecture is fractal and composable, navigation is simply an excercise in composition and delegation. Let's look at a simple example with two screens: List and Detail.

Data Structures

The navigation component needs to have an awareness of the logical screens in order to delegate to them, so we must create Model, Msg, and Props wrappers for each screen.

A navigation component's model consists of wrapper instances for each screen:

sealed class Model {
    // Delegates
    data class List(model: List.Model) : Model()
    data class Detail(model: Detail.Model) : Model()
}

Similarly, delegated components also need wrappers for their message types. Additionally, we have defined a navigation message to set a new screen.

sealed class Msg {
    // Delegates
    data class List(msg: List.Msg) : Msg()
    data class Detail(msg: Detail.Msg) : Msg()
    // Navigation
    data class SetScreen(next: Pair<Model, Effect<Msg>>): Msg()
}

Finally, we create delegation wrappers for each screen's props.

sealed class Props {
    // Delegates
    data class List(props: List.Props) : Props()
    data class Detail(props: Detail.Props) : Props()
}

Functions

Now that the appropriate types have been defined, we can define program functions which delegate to each screen. Let's look each function in order starting with init.

Oolong provides a few utility functions for common use-cases and one of these is bimap. The bimap function transforms an instance of Pair<A, Effect<B>> to an instance of Pair<C, Effect<D>>. We're going to use List as our initial screen, so in this case we're bimapping from an instance of Pair<List.Model , Effect<List.Msg >> to an instance of Pair<Model, Effect<Msg>>.

In other words, we're taking the List.Model and List.Msg returned from List.init and wrapping them in the delegated types of Model.List and Msg.List.

val init: () -> Pair<Model, Effect<Msg>> = {
    bimap(List.init(), Model::List, Msg::List)
}

The same bimap function is used to delegate to screens in the update function. If the msg is an instance of a screen message wrapper, then we delegate to that screen using bimap. However, we receive a SetScreen message, we simply return the next value provided.

val update: (Msg, Model) -> Pair<Model, Effect<Msg>> = { msg, model ->
    when (msg) {
        is Msg.List -> {
            bimap(
                List.update(msg.msg, (model as Model.List).model), 
                Model::List, 
                Msg::List
            )
        }
        is Msg.Detail -> {
            bimap(
                Detail.update(msg.msg, (model as Model.Detail).model), 
                Model::Detail, 
                Msg::Detail
            )
        }
        is Msg.SetScreen -> {
            msg.next
        }
    }
}

The view function is quite simple, as we only need to wrap the screen's props with it's respected instance in the navigation props.

val view: (Model) -> Props = { model ->
    when (model) {
        is Model.List -> {
            Props.List(List.view(model.model))
        }
        is Model.Detail -> {
            Props.Detail(Detail.view(model.model))
        }
    }
}

Finally, in the view function we unwrap the props and delegate to each screen's render function. There is one additional consideration we need to take in this function, however, which is mapping the dispatch function from the screen's Msg type to the parent's. For this, we can use the provided contramap fuction.

val render: (Props, Dispatch<Msg>) -> Any? = { props, dispatch ->
    when (props) {
        is Props.List -> {
            List.render(props.props, contramap(dispatch, Msg::List))
        }
        is Props.Detail -> {
            Detail.render(props.props, contramap(dispatch, Msg::Detail))
        }
    }
}

With our types and functions setup for the navigation component, changing screens is done by simply dispatching a SetScreen message with the initial state for that screen. How navigation is performed and dispatched is an excercise for the user on each platform. Typically, you will create an adapter for your backstack which dispatches SetScreen using the latest instance of dispatch.

val navigateToItemDetail = { id: Long, dispatch: Dispatch<Msg> ->
    val init = Detail.makeInit(id)
    val next = bimap(init(), Model::Detail, Msg::Detail)
    dispatch(Msg.SetScreen(next))
}