Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
TimLariviere opened this issue Aug 30, 2022 · 4 comments
Open

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

TimLariviere opened this issue Aug 30, 2022 · 4 comments

Comments

@TimLariviere
Copy link
Member

TimLariviere commented Aug 30, 2022

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:

  • 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
Copy link
Member Author

Tagging @twop @edgarfgp

@TimLariviere TimLariviere changed the title [Experiment] New NavigationPage with support for route-based navigation [Experiment] New NavigationView with support for route-based navigation Aug 30, 2022
@TimLariviere TimLariviere changed the title [Experiment] New NavigationView with support for route-based navigation [Experiment] New NavigationView with route-based navigation Aug 30, 2022
@edgarfgp
Copy link
Member

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

@edgarfgp
Copy link
Member

edgarfgp commented Sep 3, 2022

@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
Copy link
Member

edgarfgp commented Sep 3, 2022

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

@TimLariviere TimLariviere transferred this issue from fabulous-dev/Fabulous Jan 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants