Fabulous icon indicating copy to clipboard operation
Fabulous copied to clipboard

[Experiment] New NavigationView with route-based navigation

Open TimLariviere opened this issue 1 year ago • 4 comments

Context

While working on Fabulous.Maui #919, I noticed the Maui team opened up the implementation of NavigationPage via the new IStackNavigationView interface (https://github.com/dotnet/maui/blob/main/src/Core/src/Core/IStackNavigation.cs). This interface gives us way more flexibility in how we want to make the navigation work inside Fabulous.

So it got me thinking: what would be a good navigation experience in Fabulous?

Today in Fabulous.XamarinForms, we are simply mapping 1-to-1 the NavigationPage. This NavigationPage uses the Push/Pop method to add or remove pages from the stack.

In order to make it play nicely with MVU, we hid the Push/Pop calls by implicitly calling them as users add and remove child pages under NavigationPage.

NavigationPage() {
    // First page
    ContentPage(...)

    // Second page
    if model.UserHasNavigatedToSecondPage then
        ContentPage(...)
}

Here, we will only push the second page if model.UserHasNavigatedToSecondPage = true. As soon as model.UserHasNavigatedToSecondPage reverts back to false, we will call pop - only showing the 1st screen.

This model is nice but lacks flexibility. You need to explicitly define the whole navigation hierarchy of your app. If you need to navigate to any page in any order, it is not currently possible in Fabulous.

NavigationPage() {
    // First page
    ContentPage(...)

    // Second page
    if showSecondPage then
        ContentPage(...)

    // Third page
    if showThirdPage then
        ContentPage(...)

    // Fourth page
    if showFourthPage then
        ContentPage(...)

    (...)
}

Prior arts

  • [Deprecated] SwiftUI NavigationView: https://www.hackingwithswift.com/articles/216/complete-guide-to-navigationview-in-swiftui
  • SwiftUI NavigationStack: https://swiftwithmajid.com/2022/06/15/mastering-navigationstack-in-swiftui-navigator-pattern/
  • Android Jetpack Compose NavigationController: https://developer.android.com/jetpack/compose/navigation
  • Flutter Navigator: https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade

Challenges

Fabulous uses the MVU architecture.

This means ideally the complete state of the application MUST BE stored in the Model record so we can ensure consistency and repeatability.

This also means the view function needs to explicitly list all the subviews, including all pages in the navigation stack.

SwiftUI, Compose and Flutter all choose to let an external party handle their navigation. This means it breaks the 2 rules above.

Proposition

I would like to introduce 3 new types:

  • NavigationStack that will be in charge of keeping a list of all pages visited and their models; will be stored in the App.Model
  • Route, a widget taking a page key and the related page view function to be called when the navigation requires it
  • NavigationView, a new widget that will use one or more Route to describe which pages are available for navigation and NavigationStack to know which pages to actually show on screen

Route is only here to describe a page and won't ever make it to the UI tree.

type NavigationStack private () =
   // We need to enforce the initialisation with at least 1 page
   static member init(key, model)
   member this.push(...)
   member this.pop(...)
   member [<Event>] this.Pushed
   member [<Event>] this.Popped

let view model =
    NavigationView(stack: NavigationStack, onPushPop: NavigationStack -> 'msg) {
        Route(key: string, viewFn: obj -> WidgetBuilder<'pageMsg, #IView>, mapMsgFn: int * 'pageMsg -> 'rootMsg)
        Route(...)
        Route(...)
    }

The implementation will require a new RouteBuilder computation expression that only accepts Route. When compiling this CE, it would instead for-loop into the stack, call the corresponding Route view function and append the resulting view into the NavigationView.Pages attribute.

Usage

module Pages =
    let [<Literal>] home = "home"
    let [<Literal>] list = "list"
    let [<Literal>] detail = "detail"

module AppRoot =
    type Model = { Stack: NavigationStack }

    type Msg =
        | NavStackUpdated of NavigationStack
        | HomePageMsg of (...)
        | ListPageMsg of (...)
        | DetailPageMsg of (...)

    let init() =
        { Stack = NavigationStack.init(Pages.home, HomePage.init()) }

    let update msg model = (...)

    let view model =
        NavigationView(model.Stack, NavStackUpdated) {
            Route(Pages.home, HomePage.view, HomePageMsg)
            Route(Pages.list, ListPage.view, ListPageMsg)
            Route(Pages.detail, DetailPage.view, DetailPageMsg)
        }
type Msg =
    // This NavStackUpdated msg is here to trigger a update-view loop
    // in Fabulous in case we call NavStack.push/pop
    | NavStackUpdated of NavigationStack

    // Since we can have multiple times the same page in the nav stack,
    // we have to include the index which triggered the msg
    | HomePageMsg of index: int * model: HomePage.Model
    | ListPageMsg of index: int * model: ListPage.Model
    | DetailPageMsg of index: int * model: DetailPage.Model

let update msg model =
    match msg with
    | NavStackUpdated newStack ->
        { model with Stack = newStack }

    // We can provide a helper function that will update a specific index
    // in the nav stack by calling the function passed to it (here HomePage.update)
    | HomePageMsg (index, msg) ->
        { model with
            Stack = model.Stack |> NavigationStack.update HomePage.update msg index }

Child pages can directly interact with the NavigationStack by passing the stack to them when calling the update function. Since NavigationStack is not part of the UI tree, it can't dispatch messages for Fabulous. Instead NavigationView will subscribe to the Pushed / Popped event of its NavigationStack and dispatch a NavStackUpdated message to force Fabulous to trigger an update-view loop.

module ListPage =
    type Model = { ... }
    type Msg = GoBack | GoToDetail of id: int

    let init () = { ... }

    let update navStack msg model =
        match msg with
        | GoBack ->
            navStack.pop()
            model
        | GoToDetail id ->
            navStack.push(Pages.list, DetailPage.init id)
            model

    let view model = (...)

Additional comments

The good thing about this proposition is that it's also compatible with Xamarin.Forms NavigationPage. This is thanks to the fact at runtime we still use the Pages collection attribute.

TimLariviere avatar Aug 30 '22 07:08 TimLariviere

Tagging @twop @edgarfgp

TimLariviere avatar Aug 30 '22 07:08 TimLariviere

@TimLariviere . Based in the current experience . I think this is a good improvement.

edgarfgp avatar Aug 30 '22 15:08 edgarfgp

@TimLariviere We could separate one msg for Push and other for pop ? This way we bind onPush navigating forward and on Pop to navigate back ?

NavigationView(stack: NavigationStack, onPush: NavigationStack -> 'msg, onPop:  NavigationStack -> 'msg) {
        Route(key: string, viewFn: obj -> WidgetBuilder<'pageMsg, #IView>, mapMsgFn: int * 'pageMsg -> 'rootMsg)
        Route(...)
        Route(...)
    }

// We can add some extension methods on Route i.e
Route(...)
    .isRoot(true)
   ....

Edit : Found a similar approach here https://github.com/frzi/SwiftUIRouter

edgarfgp avatar Sep 03 '22 07:09 edgarfgp

would be possible to pass the NavigationStackModel as part of the push and pop functions . So we can conditionally push and pop

let update navStack msg model =
        match msg with
        | GoBack ->
            navStack.pop(fun model -> if model.myProperty then `pop the stack` else ` no `)
            model
        | GoToDetail id ->
            navStack.push(
               fun model -> if model.myProperty then  Pages.list, DetailPage.init id else ...)
            model

edgarfgp avatar Sep 03 '22 07:09 edgarfgp