fabulous-dev / Fabulous.XamarinForms

Declarative UIs for Xamarin.Forms with F# and MVU, using Fabulous
https://docs.fabulous.dev/xamarinforms
Apache License 2.0
13 stars 1 forks source link

[Experiment] New NavigationView with route-based navigation #22

Open TimLariviere opened 1 year ago

TimLariviere commented 1 year ago

Context

While working on Fabulous.Maui fabulous-dev/Fabulous#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

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:

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 commented 1 year ago

Tagging @twop @edgarfgp

edgarfgp commented 1 year ago

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

edgarfgp commented 1 year ago

@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 commented 1 year ago

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