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

Declarative vs. imperative routing API #15

Closed
InMatrix opened this issue Mar 9, 2021 · 58 comments
Closed

Declarative vs. imperative routing API #15

InMatrix opened this issue Mar 9, 2021 · 58 comments

Comments

@InMatrix
Copy link
Contributor

InMatrix commented Mar 9, 2021

Looking at the many packages for routing in Flutter as listed in #13, most of them seem to offer an imperative API similar to Navigator 1.0 (e.g. push, pop). I'm curious why a declarative routing API, a key feature of Navigator 2.0 (see example below), didn't get adopted in those package. Did I miss a package that does offer a declarative routing API?

@override
Widget build(BuildContext context) {
    // Return a Navigator with a list of Pages representing the current app
    // state.
    return Navigator(
      key: navigatorKey,
      onPopPage: _handlePopPage,
      pages: [
        MaterialPageBuilder(
          key: ValueKey<String>('home'),
          builder: (context) => HomePageWidget(),
        ),
        if (state.searchQuery != null)
          MaterialPageBuilder(
            key: ValueKey<String>('search'),
            builder: (context) => SearchPageWidget(),
          ),
        if (state.stockSymbol != null)
          MaterialPageBuilder(
            key: ValueKey<String>('details'),
            builder: (context) => DetailsPageWidget(),
          ),
      ],
    );
  }

Here's a link to full example in Navigator 2.0's design doc.

Cc: @slovnicki, @johnpryan, @theweiweiway, @lulupointu, @nguyenxndaidev

@lulupointu
Copy link
Contributor

I think the question of a "declarative API" is quite vague. To some degree we all support some kind of declarative programming.

I will try to explain:

  • What are the different types of declarative programming that we can use for navigation
  • What vrouter supports
  • My opinion on navigator 2.0 declarative routing

This is my own ideas, after a lot of research and experimentation. However I might be proven wrong and change my opinion!

What are the different types of declarative programming that we can use for navigation

I think there are 3 different types:

  1. Navigation Guards (Rerouting based on the state)
  2. Changing in the routes based on the state
  3. Using the state as a declarative way to navigate (navigator 2.0 declarative routing)

Here is a description of each of them, along-side code snippet.

1. Navigation Guards (Rerouting based on the state)

The idea here is that we navigate using the url, but if we try to access a url that we are not supposed to, we can change the url again to go to a valid one.

Here is an example (using vrouter but it does not matter):

VRouter(
  routes: [
    VStacked(
      path: '/login',
      widget: LoginPage(),
    ),
    VStacked(
      beforeEnter: (vRedirector) => !state.isLoggedIn ? vRedirector.push('/login') : null,
      path: '/home',
      widget: HomePage(),
    ),
  ],
)

2. Changing in the routes based on the state

While (1) uses the state, the routes do not change in a declarative manner. This is what this second point is about, and you will start to see that it starts to resembles Navigator 2.0 declarative navigation.

VRouter(
  routes: [
    VStacked(
      path: '/login',
      widget: LoginPage(),
    ),
    if (state.isLoggedIn)
      VStacked(
        path: '/home',
        widget: HomePage(),
      ),
  ],
)

Here the route "/home" does not exist if state.isLoggedIn is false. This means that the state directly influences the different available routes. However we still use only the url to navigate.

3. Using the state as a declarative way to navigate

This is basically the example you proposed. The state is what determines the navigation.

Navigator(
  pages: [
    MaterialPage(
      child: HomePageWidget(),
    ),
    if (state.searchQuery != null)
      MaterialPage(
        child: SearchPageWidget(),
      ),
    if (state.stockSymbol != null)
      MaterialPage(
        child: DetailsPageWidget(),
      ),
  ],
)

What vrouter supports

Scenario 1: VRouter, as many of the other navigation packages, supports this scenario.

Scenario 2: VRouter does not currently supports this scenario, but might in the future.
The issue with scenario 2: Removing a route based on the state means that the developer should take into account what happens when a user tries to access the invalid route, which this scenario does not encourages.
I do think it has valid use cases though so this is likely to be supported in the future.

Scenario 3: VRouter does not support this scenario and it is not planned. I will explain why in the next part. If I am proven wrong this statement could be invalidated of course.

My opinion on navigator 2.0 declarative routing (Scenario 3)

First, I want to say that this kind of routing is amazing. This is one of the main reasons I had a look into Navigator 2.0 and I think it is brilliant.

However, I don't think this is viable for a cross-platform framework. Indeed, if we want easy cross-platform compatibility, we must agree that the way developer navigate should be very similar in scenario 1, 2 and 3.
I don't think this is possible for the following reason:

  • Scenario 1 and 2 use the url to navigate (even if they take into account the state, the url is still what is used for the navigation).
  • Scenario 3 uses the state to navigate BUT MUST ALSO SUPPORT URL NAVIGATION. Indeed, if you consider the web, the user can enter a url, from there the url must be considered to navigate.

Basically:

  • Scenario 1 and 2 are "url -> state"
  • Scenario 3 is "state -> url" (by design) AND "url -> state" (because of web)

The means that, if we want our package usage to be platform agnostic we can either support only "url -> state" OR "state -> url" and "url -> state".

You might say that we can support "state -> url" for mobile and allow both when we are on the web. However this is not at all platform agnostic and adding "url -> state" afterwards (when converting a mobile app to a web app for example) would be a lot of work that a developer did not take into account and which might even change its app architecture.

I hope I was clear, I have been looking into it for some time now, not only from the perspective of flutter but also by looking what other framework do. I do think state management and navigation should interact but not be blended.

@slovnicki
Copy link
Contributor

@InMatrix beamer provides the same declarative approach to building the list of pages as Pages API. In beamer, you override pagesBuilder within which you have access to all of the URI parameters and BuildContext to decide (with your arbitrary code) which pages you want to build and how. The output of pagesBuilder is plugged directly into Navigator.pages.

from https://github.com/slovnicki/beamer/blob/master/example/lib/main.dart (although not using context and any state from it):

@override
  List<BeamPage> pagesBuilder(BuildContext context) => [
        if (pathSegments.contains('books'))
          BeamPage(
            key: ValueKey('books-${queryParameters['title'] ?? ''}'),
            child: BooksScreen(),
          ),
        if (pathParameters.containsKey('bookId'))
          BeamPage(
            key: ValueKey('book-${pathParameters['bookId']}'),
            child: BookDetailsScreen(
              bookId: pathParameters['bookId'],
            ),
          ),
      ];

The only imperative-like APIs are beamTo and BeamToNamed, but they serve the same purpose as when browser is reporting the URI. Pages still get built decleratively, beam commands are just for directing which pagesBuilder to take.

Although there can be just a single BeamLocation/pagesBuilder to handle all, I consider it a poor practice when using beamer in an application with many contextually unrelated worlds/features/sub-menus; for example, books and articles (snippet below). I would say that this contextual grouping of page stacks was the main idea behind beamer, more specifically - behind BeamLocation.

class BooksLocation extends BeamLocation {
  @override
  List<String> get pathBlueprints => [
        '/books/:bookId/genres/:genreId',
        '/books/:bookId/buy',
      ];

  @override
  List<BeamPage> pagesBuilder(BuildContext context) => [
        if (pathSegments.contains('books'))
          BeamPage(
            key: ValueKey('books-${queryParameters['title'] ?? ''}'),
            child: BooksScreen(
              titleQuery: queryParameters['title'] ?? '',
            ),
          ),
        if (pathParameters.containsKey('bookId'))
          BeamPage(
            key: ValueKey('book-${pathParameters['bookId']}'),
            child: BookDetailsScreen(
              bookId: pathParameters['bookId'],
            ),
          ),
        if (pathSegments.contains('buy'))
          BeamPage(
            key: ValueKey('book-${pathParameters['bookId']}-buy'),
            child: BuyScreen(
              book: data['book'],
            ),
          ),
        if (pathSegments.contains('genres'))
          BeamPage(
            key: ValueKey('book-${pathParameters['bookId']}-genres'),
            child: GenresScreen(
              book: data['book'],
            ),
          ),
        if (pathParameters.containsKey('genreId'))
          BeamPage(
            key: ValueKey('genres-${pathParameters['genreId']}'),
            child: GenreDetailsScreen(
              genre: data['genre'],
            ),
          ),
      ];
}

class ArticlesLocation extends BeamLocation {
  @override
  List<String> get pathBlueprints => ['/articles/:articleId'];

  @override
  List<BeamPage> pagesBuilder(BuildContext context) => [
        ...HomeLocation().pagesBuilder(context),
        if (pathSegments.contains('articles'))
          BeamPage(
            key: ValueKey('articles'),
            child: ArticlesScreen(),
          ),
        if (pathParameters.containsKey('articleId'))
          BeamPage(
            key: ValueKey('articles-${pathParameters['articleId']}'),
            child: ArticleDetailsScreen(
              articleId: pathParameters['articleId'],
            ),
          ),
      ];
}

@theweiweiway
Copy link
Contributor

theweiweiway commented Mar 9, 2021

@lulupointu Thanks for pointing that out, I never thought of how declarative routing might work on mobile vs web in respect to whether state or the url drives the current route.

To me, Scenario 2 doesn't really resemble declarative routing at all. It's more just creating and destroying routes based on state like you mentioned.

However, to your point about Scenario 3 - I think changing routes based on state is just too powerful of a tool, and in my opinion we should definitely add it into the UXR Storybook. To get around your point about cross-platform navigation and state vs url, please consider this case:

The following routes are defined:

  • /sign_up
  • /sign_up/email
  • /sign_up/password
  • /sign_up/confirm

Now, "/sign_up" is the start of the flow (home page of the flow). On mobile, everything can work normally like you mentioned.

Now, if you are on web and you try to go directly to any of the other routes like "/sign_up/password", but the state says you should be at "/sign_up", you just simply get auto-redirected back to "/sign_up". I feel like this should be the default behaviour for declarative routing on web.

So basically on web, initially url -> state, but then state -> url right after initialization

I know that blending state and navigation is not standard, and I haven't seen it done in other frameworks. But it feels like this could be a new feature and eventually a standard for navigation packages in Flutter,

especially since navigation and state are completely intertwined for flows.

Furthermore, the Navigator 2.0 API in Flutter makes this much easier to implement than other languages.

Please let me know all your thoughts though!

@lulupointu
Copy link
Contributor

As you said, given your example, we would have to implement url -> state (Be it to use it only on initialization this does not matter). No one wants to program this AND state -> url.
Moreover, as I said, having implemented state -> url (because you have a mobile app) and then adding url -> state on top of that (because let's say you want to convert it to a web app) will turn into a nightmare very quick.

Actually if you look into the example of @slovnicki you see that he is not implementing state -> url but ONLY url -> state. And if you want to redirect, you should use Navigation Guards (as in my described scenario 1).
This is different from the example of the flutter team, where you really have state -> url, and I guess that is what everybody understand when we say "Declarative routing API"

If you want a library that handle both navigation and state 100% I guess that would be theoretically possible, but:

  1. This will enforce a simple state library, which developers do not like
  2. This might mitigate but never obviate the need to have url -> state AND state -> url in such an architecture

@slovnicki
Copy link
Contributor

slovnicki commented Mar 9, 2021

@lulupointu @theweiweiway Interesting discussion about state -> url. I had an idea about that in the beginning but didn't see much value for it, certainly amount of extra work outweighed the benefits.

What is the situation in app's code when state needs to drive your navigation and you cannot intervene and say: ok, this happened so we should head over to route /some/route?

(obviously, we need url -> state so the concept of route names exists already to be used)

@lulupointu
Copy link
Contributor

I agree with you, I do not think there is a situation where you would want that.

This is why I think:

  1. We should not look at this as a scenario (or be particularly careful to the drawbacks of the given package)
  2. This answers to the question that started this issue (why no package does that)

@slovnicki
Copy link
Contributor

slovnicki commented Mar 9, 2021

Or maybe we just want to append some parameters to url based on state change? That is definitely a good feature, but I wouldn't consider this "state driving navigation" because we are not navigating anywhere.

As recently proposed here

@lulupointu
Copy link
Contributor

This is definitely feasible. However this might press user into confusing navigation and state management so I don't think I would personally go for it.

I understand the example you gave but I would personally still go for a push, which given the fact that the path of the url does not change would not update the app visually but will just append the given parameter.

@theweiweiway
Copy link
Contributor

theweiweiway commented Mar 9, 2021

Hey all, great discussion, to clear up some things:

@slovnicki

What is the situation in app's code when state needs to drive your navigation and you cannot intervene and say: ok, this
happened so we should head over to route /some/route?

This type of routing is really only for flows. The Flow Builder package by felangel really highlights this well, where routes need to change based on state. I feel like this scenario happens in almost every app, web or mobile. Because chances are that users will need to fill out a flow or form at some point.

@InMatrix , @lulupointu

I'm curious why a declarative routing API, a key feature of Navigator 2.0 (see example below), didn't get adopted in those > package. Did I miss a package that does offer a declarative routing API?

This answers to the question that started this issue (why no package does that)

AutoRoute supports this, please see: https://github.com/Milad-Akarie/auto_route_library/blob/master/auto_route/example/lib/mobile/screens/user-data/data_collector.dart

@lulupointu

If you want a library that handle both navigation and state 100% I guess that would be theoretically possible, but...

On, no no. We definitely do not want to handle navigation and state together. That would be an absolute nightmare disaster. If you take a look at the AutoRoute example above, there is no baked state into the package. It's relying on Provider

@lulupointu

As you said, given your example, we would have to implement url -> state (Be it to use it only on initialization this does > > not matter). No one wants to program this AND state -> url.
Moreover, as I said, having implemented state -> url (because you have a mobile app) and then adding url -> state on top > of that (because let's say you want to convert it to a web app) will turn into a nightmare very quick.

My thinking was that the developer doesn't need to do anything. By default, the package would handle this, and it would redirect the user to the correct path if the state requirements are not met. My gut feeling is that AutoRoute does this already, but I will need to test it. I'll get back to everyone with those results later today!

@idkq
Copy link

idkq commented Mar 9, 2021

I'm building a mid-to-large app and initially was using 100% declarative (not using any package). I ended up creating dozens of state variables with provider to manage not only which screens are shown but also which screens are not shown (depending on the order of the stack). I quickly got lost on which screens were linked to each variables. It was just nonsense to be honest. I gave up.

This is true especially for static screens (information only display). Screen A -> Screen B. How do you control the state of what screen is being shown. To have a variable to for this became a real nightmare. Unless there is a better/genius way to do this?

I'm still using declarative but only have 1 variable per bottomnavigator that controls all screens inside (basically an index). It is a bit weird, but much better than initially.

In your example @InMatrix you have to (somehow) remember that:

  • When state.searchQuery is populated then screen SearchPageWidget() is visible. Instead, when state.stockSymbol is populated, DetailsPageWidget() is visible. Now imagine doing this mental logic with 30+ screens. What if both are populated? Etc.

@SchabanBo
Copy link
Contributor

I am with @theweiweiway, we should add it to the UXR Storybook.
qlevar_router does not support the declarative yet, but I will add it soon.
how I see it is, the declarative routing should not be the main way in a project to define the routes, because it will get messy very easily as @idkq said. but there are some cases where the declarative is the easy simple way to do it (like Nested navigation). and as a developer, I would like a package that can provide me this way when I needed it.
To me, Scenario 2 or Scenario 3 will give the same result, which to use, it is up to the developer to chose.

@InMatrix
Copy link
Contributor Author

InMatrix commented Mar 9, 2021

Thanks to everyone who has provided input here. To clarify, I was referring to the third situation "using the state as a declarative way to navigate" mentioned by @lulupointu with the term "declarative routing API". It seems that the main issue here is whether routing should be driven by URL or app state based on the above discussion.

In addition, @slovnicki seems to propose a middle ground, where routing to major locations in an app is URL driven, but local transitions can be driven by state variables.

As an argument against (full?) state-based routing, @idkq points out that managing a large number of state variable for controlling which page is currently visible is messy. That seems like a valid point.

@SchabanBo and @theweiweiway could you give a concrete example where you think a state-based routing API is advantageous?

I am with @theweiweiway, we should add it to the UXR Storybook.

I understand the desire, but the scenarios in the storyboard deck describe end-user interactions with an app. Those scenarios don't seek to specify how they should be implemented. As a next step (#9), we'll invite package authors to provide sample code for implementing those scenarios, and there will be a process to critique implementations using different APIs.

The best way to advocate for a state-based declarative routing API is to propose a user-facing navigation scenario where such an API can show its distinctive advantage over the alternative.

@idkq
Copy link

idkq commented Mar 9, 2021

Just to expand a bit, from my experience, issues with declarative (@lulupointu scenario 3):

  • Controlling the state: How to control the state can be an issue. Basically you need a expression/condition/etc to resolve the state that will determine if a screen is visible or not. This is what state.searchQuery != null is doing in the example. You don't need this at all using imperative. If we could have a default state controller that would that for free, it would be amazing.
  • In many cases, navigation is a result of an event, not a change in state of another object. For example, if the user click on a button, show screen. The state is more linked to the event that happened than to any object in memory. It is not intuitive. What is the state in this case? The state of being clicked? No. The state of page B being shown? No. There is no defined state. Surely you can have a variable to control the state of anything (button, page displayed), but it is unnecessary.
  • Determine which screen is visible when coding. Changing state variables can affect how the navigation works. Therefore, developer must be constantly looking at the page navigation stack. Not sure how to improve here.
  • Loose decoupling between screens and objects. Although this is good and has benefits, there is no hard wiring between state objects and screens. So to the point above, remembering which variables affect which screen is hard, even if you use suggestive names.
  • Code organization. Often, each screen sits on its own .dart. For organization, most of the logic for the screen is contained there or maybe in some business/services. Having a state manager for the screen requires a third layer. This is also required for urls management, but the latter is much simpler.
  • Just more code. Basically you need at least a few variables to manage the state, and often is difficult to use existing objects to resolve the logic.

I am not saying it is all bad, but we can find ways to improve.

@idkq
Copy link

idkq commented Mar 9, 2021

The best way to advocate for a state-based declarative routing API is to propose a user-facing navigation scenario where such an API can show its distinctive advantage over the alternative.

Absolutely! I believe it is totally achievable.

The clear advantage of declarative is that your app is self-aware of navigation in a reactive way: as object's states changes, your app navigation's change automatically. The challenge is how to blend these events/state/urls in a robust but easy way.

@theweiweiway
Copy link
Contributor

theweiweiway commented Mar 9, 2021

@InMatrix

I understand the desire, but the scenarios in the storyboard deck describe end-user interactions with an app. Those
scenarios don't seek to specify how they should be implemented.

I see, sorry for encroaching

@SchabanBo and @theweiweiway could you give a concrete example where you think a state-based routing API is
advantageous?

To show you the advantages, please consider the following scenario. An auth sign up flow with the following screens:

  • /sign_up
  • /sign_up/email
  • /sign_up/username
  • /sign_up/password
  • /sign_up/age
  • /sign_up/gender

Now, let's assume each page has a next button that pushes the user to the next page of the sign up flow.

...
ElevatedButton(
  child: Text("Next"),
  onPressed: () {
    saveFormDataToState(data);
    router.push(<NEXT_PAGE>); // we have to push the next page on each one of our pages
  }
)
...

We need have a router.push line, and also define the next page being pushed. This isn't needed at all with declarative routing.

Here's the example in a declarative fashion:

...
ElevatedButton(
  child: Text("Next"),
  onPressed: () {
    saveFormDataToState(data);
    // router.push(<NEXT_PAGE>); we can now take this line out
  }
)
...

where the declarative router looks something like this:

...
return AutoRoute.declarative(
  pages: [
    SignUpPage(),
    if (state.email != null) SignUpEmailPage(),
    if (state.username != null) SignUpUsernamePage(),
    if (state.password != null) SignUpPasswordPage(),
    if (state.age != null) SignUpAgePage(),
    if (state.gender != null) SignUpGenderPage(),
  ],
);
...

Whoop-di-do. So what. We saved 1 line of code in each of our pages and added a whole chunk of code for the declarative router, which is hardly great.

However, this really makes things much much easier to work with in terms of maintainability and modification of the code. If I wanted to add another route, I just stick it in the appropriate place in the declarative router. That's it. I don't ever need to go to the respective pages themselves and change up the router.push to go to the correct page.

I also have a nice representation of all the pages in this flow, and know exactly when they are in the stack and when they are not. You might be able to see how this becomes very powerful in more complicated flows, where pages should only be shown if criteria A, B, C and D are met.

Finally, I'd just like to stress that this kind of routing seems to only good for flows and should probably never be used for the majority of an app. In my experience, it seems like around 20-30% of mobile pages are flow-based? Much less for web though, since you can stuff more form fields onto a web page than a mobile page. But I don't think this defeats the notion of having declarative routing.

@idkq
Copy link

idkq commented Mar 9, 2021

  • Declarative is advantageous when you want the navigation to react to a state change.
  • Imperative is advantageous when you want the navigation to react directly to an event.

But often a state only changes after an event is triggered. So connecting navigation to state change requires an additional step (conceptual step, not necessarily coded step).

Declarative: a) Event occurred -> b) State changed -> c) Navigation change
Imperative: a) Event occurred -> c) Navigation change

Using declarative I can change the state of something from many places (b) in my app and cause navigation to change. That cannot be done with imperative.

@tomgilder
Copy link

My main thought here is that URLs are already declarative. I don't understand the benefits of adding an extra app state object to update.

My initial biggest issue with implementing Navigator 2.0 was, from the examples give, there were three sets of state to manage:

  1. The URL
  2. The app state object
  3. The actual widgets on screen

What's the source of truth with these? They can all be:

  1. When the URL changes, that's the source - the app state and app should update
  2. When the app state changes, that's the source - the URL and widgets should update
  3. When widgets change (e.g. tab index changing), that's the source - the app state and URL should update

In Flutter, we're used to a really clear data flow, it's one of the beautiful things about the framework:

State ➡️ Widgets ➡️ Elements ➡️ RenderObjects.

From a developer's perspective, the data flow is one-way and really easy to understand. There are single sources of truth.

But with having a declarative state object, you've suddenly got three sources of truth, and it's really complicated to think about. What updates what, and when? This, I believe, is the primary source of complexity with Navigator 2.0.

Given that URLs are already declarative, and if you're always going to have to handle them, aren't they the easiest way of always declaring the navigator state?

@idkq
Copy link

idkq commented Mar 9, 2021

@tomgilder Basically you have to decide between 1 & 2, which one controls 3.

In flutter for web 1 controls 2 which controls 3 in some cases (when url is typed). In others, 2 controls 3 and url is just updated to reflect current url.

@theweiweiway
Copy link
Contributor

theweiweiway commented Mar 9, 2021

Hey @tomgilder ,

That is a great point. Having a feature like Flow-based routing (I'm going to stop calling this Declarative routing because you are right, URLs are already declarative) definitely introduces complexity where we now have potentially 3 sources of truth. However, I'm not completely convinced that this statement is true:

Given that URLs are already declarative, and if you're always going to have to handle them, aren't they the easiest way of
always declaring the navigator state?

I actually think that for flows, URLs are not the easiest way of declaring navigator state.

Furthermore, I think that if we can define a clear separation between the 3 cases/sources of truth that you mentioned, we can make it intuitive to the developer - all while keeping the advantages of each case. I'll try to explain:

URL source of truth

For most routes in an app. You can push to certain pages, pop from pages as well. This is what everyone is familiar with and is used for most routes.

State source of truth

For flows. I truly believe this source of truth is the easiest to work with for flows, where a user has to go through a set of pages to fill in information (like a Profile data collection or sign up flow). The reason I think this, is because flows are so closely linked to state that it makes sense to make the routing change in response to state.

In a sign up flow with an:
-email page
-username page
-password page

when do we want to navigate to the next page? We want to go to the username page when the email has been added to state. We want to go to the password page when the username has been added to state. We literally want to route to the next page in response to or at the same time the state is updated.

Please see my last comment to see my reasoning behind why state-based routing is better than normal URL-based routing for flows.

Also, I don't think this case is a one-off thing. Almost every app I can think of uses flows because the end user is almost always required to fill in some sort of information through a flow.

Widget source of truth

For bottom navigation bars and similar widgets that change state, resulting in the page to change.

Now what?

Now, we have our clear separations between each type of routing and when to use them:

  • State-based routing for flows
  • Widget-based routing for bottom navigation bars and similar widgets
  • URL-based routing for everything else

I understand there is a little bit of a learning curve. When I first saw this in the Flow Builder and AutoRoute package, I will admit I didn't completely understand what was happening and why I needed it. But then as soon as I started trying Flow-based routing for flows, it just made sense.

Finally, I think there is almost some confusion surrounding this because it isn't entirely clear how one might implement URL-based navigation and state-based navigation together in an app. If you take a look at the autoroute docs at https://autoroute.vercel.app, and go through the the last 5 sections, you can see how all 3 sources of truth can co-exist in the same app while being intuitive to the developer.

The beauty of the AutoRoute package though, is that you can make a perfectly functional URL-based app like you are suggesting without making anything state-based. But it also opens up the possibility for anyone to tap into the advantages of Flow-based routing as well.

@idkq
Copy link

idkq commented Mar 9, 2021

I think that state navigation would be better if they were by default:

  • centralized, all state variables in one single class, single declaration
  • coupled, somehow indicating which screens are shown based on the variable’s value (at coding time)
  • 1:1 mapped to screens

Which is more or less what the URLs are. Any fancy scenario would be handled outside of this rules.

@tomgilder
Copy link

@theweiweiway I think this is an excellent point. Flows are very different from the rest of navigation, and I agree that it's worth treating them as a separate case.

I would argue that often flows don't want a URL change - page 2 probably depends on state from page 1, and too much state to add into a URL.

My gut feeling is the best way of dealing with this is to have a nested Navigator with its own list of pages, which is generated from the flow's state. I'm going to experiment with this and see if it makes sense.

Something I didn't say above: I think it's easy to make global navigation state objects look like a good idea on a fairly simple example app. But they just don't scale to a complicated real-world one. When I saw the Flutter book example app, my reaction was "oh, cool, it's declarative" - but then trying to bring that to a full app was just too complicated.

@SchabanBo
Copy link
Contributor

So as another example for the declarative is that I can define the complete stack to show.
So let's take the "Skipping Stacks" scenario from the UXR Storybook.
The user search product and then go to the product page and back to the Category Landing Page. but I want if the user from Germany to show him Today Offers Page before he can go back to the Category Landing Page.

Navigator(
  pages: [
     MaterialPage(child: CategoryPage()),
     if (user.country == 'Germany') MaterialPage(child: OffersPage()),      
     MaterialPage(child: ProductPage()),      
  ],
)

So I think doing it here as declarative is easier than imperative

@idkq
Copy link

idkq commented Mar 10, 2021

Idea snippet for Declarative

Here is my idea. I've introduced two classes PageOrder and PageResolver. PageOrder will resolve the order of appearance of the pages so we don't need to do it by hand (the actual order in the code does not matter necessarily because PageOrder can reshuffle it). PageResolver links states with screens, allow passing a function to resolve in what condition the screen should be shown.

Navigator(
  pages: [
     PageOrder( children:[
        PageResolver(visibleWhen: ()=> user.country == 'Germany', child: OffersPage())
        MaterialPage(child: CategoryPage()),
        MaterialPage(child: ProductPage()),      
    ])
  ],
)

Note that two other pages don't have a PageResolver because the user can choose between declarative by state or declarative by URL. With this we can add other parameters to each constructor to control how the pages will be shown. For example we can add to PageResolver a URLs in which the forward slash / will dictate its level by default. This is an alternative to chain the classes in the code. It might add a small overhead because it needs computation as opposed to using the order hardcoded.

PageResolver(visibleWhen: ()=> user.country == 'Germany', child: OffersPage(), url: 'root/main/offers/germany' )

And then to PageOrder

PageOrder(orderLogic: OrderLogic.UrlHierarchy)

Or the user can customize their own order logic by extending OrderLogic. Other attributes to the PageResolver could be takePriorityWhen etc.

What does this solve?

  • avoids the need to look up the chain of screens every time developer wants to add a new screen
  • better code, eliminates several ifs for every screen
  • get structure logic for free using url pattern instead of coding the order
  • flexibility, allows user to subclass the logic if complex scenarios exist

Summary

  • PageResolver determines wether or not a page will be visible.
  • PageOrder determines the order of the stack automatically.

@theweiweiway
Copy link
Contributor

Hey @tomgilder,

I would argue that often flows don't want a URL change - page 2 probably depends on state from page > 1, and too much state to add into a URL.

Agreed! Initially I was thinking that the URL would change as well, but you are right. Often times, I don't think you really need a URL change for flows. This would also solve the problem @lulupointu mentioned about how to handle url driving state and vice versa for flows.

However, one use-case I am able to think of that would require a URL change for flows, is for analytics. If we had a sign-up flow and we wanted to track where the user drops off in the sign up flow, we could just listen to URL changes to figure this out. However, if there are no URL changes for the flow, we would need to manually log an event for each page in the flow.

I think tracking where users drop out of a flow is pretty useful, but not sure how much this is done in practice.

Something I didn't say above: I think it's easy to make global navigation state objects look like a good idea on a
fairly simple example app. But they just don't scale to a complicated real-world one. When I saw the Flutter book
example app, my reaction was "oh, cool, it's declarative" - but then trying to bring that to a full app was just too
complicated.

Definitely agreed as well - i totally do not think this is a good routing solution if attached to global navigation state since it gets way to complicated, as @idkq realized. That's why this solution I would recommend only to be used with flows, and with your idea of having a nested navigator, you could scope that nested navigator to a piece of local state (like a SignUpBloc). Then, for each additional flow you just have an additional piece of local state.

This keeps everything clean, separated, and super easy to use/maintain/modify.

@xuanswe
Copy link
Contributor

xuanswe commented Mar 13, 2021

Nice discussion! I like your ideas a lot!

My personal opinion is to keep simple by default and open a hole where developers could easily change the default approach to solve complicated scenarios when needed.

My experience is that, if navigation works with Browsers, it will definitely work with Native Apps. Therefore, any navigation solution must consider to work with Web first. Otherwise, cross-platform philosophy will not be satisfied.
If your agree with me and say, Web first, we mean URL first.

Now, for some scenarios, maybe developers want to navigate with state. We need to answer some questions:

  • How to manage with state?

    I agree with @tomgilder, state should be managed in small scopes. But I think developers should be abled to decide this scope. If they want global scope, let's them do it. But I think they will definitely making multiple smaller scopes for sub/nested routes. Technically, if navigation framework allows to manage state for nested routes. It can easily allow to make global scope state without much effort.

    Actually, I think I can use global scope state to manage the root level of the hierarchy stacks without details. Then in each smaller scope, I will control complicated logic for sub/nested routes, deeper and deeper. If I split the logic reasonable at the right scopes, then I am using 100% declarative routing without increasing the complexity.

  • How state related to URL?

    By default, I think we don't need to sync state and URL. It means, the state scope belongs to just current single URL. Changing state doesn't change the current URL.

    If developers want, they can optionally implement methods to convert between state and URL.
    In this case, converting state to URL should be restricted to sub-routes of current URL. If the URL is converted to a URL, which jumps to outside of current route scope, it should be an error. Converting URL to state doesn't have this problem.

I didn't try, but https://autoroute.vercel.app/advanced/declarative_routing describes the approach quite similar to what I think about managing navigation state.

@xuanswe
Copy link
Contributor

xuanswe commented Mar 16, 2021

@nguyenxndaidev do you have any code you could share?

Sure @johnpryan, I will reorganize my imagined API and will share here soon.

@idkq
Copy link

idkq commented Mar 17, 2021

hierarchy of stacks

I would recommend strongly against this in most cases. I don't see any benefits on having a hierarchy of stacks hardcoded. Navigation changes frequently and having to update the hierarchy in the code is unnecessary. That's why I suggested PageOrder #15 (comment).

Developers need a simple way stating all URLs without a hardcoded hierarchical wiring.

@xuanswe
Copy link
Contributor

xuanswe commented Mar 17, 2021

@nguyenxndaidev do you have any code you could share?

I documented my work progress here.
Below is a copy and some more information. Please give your feedback and suggestions.

Example

Let's start with an example, which is complex enough to see the problem.

  • We have an online store (Web and Mobile), and we want to organize our products into categories similar to Amazon. So we want:
    • Home page
      • shows all root categories
      • shows top products, recent products, etc.
      • Etc.
    • Category page
      • select a root category to open category page of 2nd level categories, so on and so on.
        For example of a 3rd level category, Computer & Accessories › Data Storage › External Data Storage.
      • in each category page,
        • shows all next level categories if it has
        • shows all products of current category and sub-categories
      • click in-app back button to go up to parent category page and finally home page.
    • Product overview page
      • general information of the product
      • click in-app back button to open category page of the product.
    • Product details page
      • details of the product
      • click in-app back button to open product overview page.
    • 404 page
      • click in-app back button to open home page.
  • Hierarchy pages we want
    • Navigator.pages for home page: [HomePage]
    • Navigator.pages for a root category: [HomePage, CategoryPage]
    • Navigator.pages for a 2nd level category: [HomePage, CategoryPage, CategoryPage]. We can have 3rd, 4th level category, but let's stop here.
    • Navigator.pages for product overview page, which belongs to a 2nd level category: [HomePage, CategoryPage, CategoryPage, ProductOverviewPage]
    • Navigator.pages for product details page: [HomePage, CategoryPage, CategoryPage, ProductOverviewPage, ProductDetailsPage]
  • Mapping URLs to pages
    • /: HomePage
    • /categories/:id: CategoryPage
    • /products/:id: ProductOverviewPage. Automatically find the default category it belongs to.
    • /products/:id?categoryId=:categoryId: ProductOverviewPage. Use the given category if valid or fallback to default category it belongs to.
    • /products/:id/details: ProductDetailsPage. Automatically find the default category it belongs to.
    • /products/:id/details?categoryId=:categoryId: ProductDetailsPage. Use the given category if valid or fallback to default category it belongs to.

High level idea

Managing the whole navigation system in one place Navigator(pages: [...]) is too difficult.

Therefore, the idea is splitting into smaller navigation domains (I call them stacks) and combine them into a single Navigator.pages.

  • How we organize these smaller domains and their relationships?
// Navigator.pages will be [HomePage()]
class RootStack {
  List<RouteStack> get parentStacks => [];

  List<RouteEntry> get pages => [HomePage()];
}

// Navigator.pages will be [HomePage(), CategoryPage(id: 1), CategoryPage(id: 2), CategoryPage(id: 3)]
class CategoryStack {
  CategoryStack({required this.id});

  final int id;
  
  List<RouteStack> get parentStacks => [RootStack()];

  List<RouteEntry> get pages {
    // assume, this.id = 3,
    // calling remote endpoint to see parent categories: 1, 2.
    return [CategoryPage(id: 1), CategoryPage(id: 2), CategoryPage(id: id)];
  }
}

// Navigator.pages will be smt like [HomePage(), CategoryPage(id: 1), CategoryPage(id: 2), CategoryPage(id: 3), ProductOverviewPage(id: 1)]
// or [HomePage(), CategoryPage(id: 1), CategoryPage(id: 2), CategoryPage(id: 3), ProductOverviewPage(id: 1), ProductDetailPage(id: 1)]
class ProductStack {
  ProductStack({required this.id, this.categoryId, this.pageId});
  
  final int id;
  final int? categoryId;
  final String? pageId;
  
  List<RouteStack> get parentStacks {
    // calling service to validate categoryId, if not valid, return a default category for the product.
    // assuming the categoryId is valid and we use it directly to simplify the example.
    
    return [
      RootStack(),
      CategoryStack(id: categoryId),
    ];
  };

  List<RouteEntry> get pages {
    // assume, this.id = 1,
    return [
      ProductOverviewPage(id: id),
      if (pageId == 'details') ProductDetailPage(id: id),
    ];
  }
}
  • How to sync URL and the current navigation domain?
    • Instead of mapping URL to specific page, I only need to configure rules to map URL to the stack.
      Stack will provide all logic to build the final pages and sync path params and query params.
    • When navigate by a URL, it will provide path params and query params automatically to the stack as initial state for the stack.
    • When navigating by action (or update state stream), it will update path params and query params automatically to update browser URL.
    • Other technical requirements
      • Typesafe
      • Simple for end developers to use
    • Status:
      • I still does not find a solution without code generation yet.
      • The only solution is to use code generation like auto_route is doing right now.
        I tried this package, and it works (still requires more work to improve current API of auto_route to meet my expectations).

@idkq
Copy link

idkq commented Mar 17, 2021

Looks like the hierarchy/page order is a separate (and critical) issue, so I opened this here #21 hope its okay with you @InMatrix

@slovnicki
Copy link
Contributor

slovnicki commented Mar 17, 2021

@nguyenxndaidev this is literally beamer. Your Stack is BeamLocation :)

If you would to continue this idea further, you would realize that instead of pages getter you will need pagesBuilder that receives context (as I realized between v0.7.0 and v0.8.0).

The only thing beamer doesn't have yet is a more convenient state -> url. It's definitely possible already to navigate with state (simply "watch" the context in pagesBuilder), but requires some additional action to update URL.

beamer example:

class BooksLocation extends BeamLocation {
  @override
  List<String> get pathBlueprints => ['/books/:bookId'];

  @override
  List<BeamPage> pagesBuilder(BuildContext context) => [
        ...HomeLocation().pagesBuilder(context),
        if (pathSegments.contains('books'))
          BeamPage(
            key: ValueKey('books'),
            child: BooksScreen(),
          ),
        if (pathParameters.containsKey('bookId'))
          BeamPage(
            key: ValueKey('book-${pathParameters['bookId']}'),
            child: BookDetailsScreen(
              bookId: pathParameters['bookId'],
            ),
          ),
      ];
}

By typesafe, I suppose you mean that URL parameters are automatically mapped to correct types. I think this is slightly out of scope (which makes it easier) for navigation package. My only argument here (and I suppose sufficient) is that Uri does also not deal with types. It parses everything as String. Those parameters never mean anything by themselves and developer must decide what do do with them anyway.

@xuanswe
Copy link
Contributor

xuanswe commented Mar 18, 2021

I updated my declarative API library (Navi) I am working on.

You can check details in the link above. The API is also mocked with real source code here. The code is only to review to know how the API is used in an application, it doesn't work yet. I am starting the implementation.

Below is a quick introduction.

To use the library, you only need to know how to use 2 simple classes: RouteStack class, StackOutlet widget and nothing more.

// This stack only have a single page: [HomePage()]
class HomeStack extends RouteStack {
  List<RouteStack> get upperStacks => [];
  List<Widget> get pages => [HomePage()];
}

// This stack reuse upper stack `HomeStack` and join with category pages in current stack.
// Result: [HomePage(), CategoryPage(id: 1), CategoryPage(id: 2), CategoryPage(id: 3)]
class CategoriesStack extends RouteStack {
  CategoriesStack({required this.categoryId});

  final int categoryId;

  List<RouteStack> get upperStacks => [HomeStack()];

  List<Widget> get pages {
    // Assume parent categories are 1, 2
    return [
      CategoryPage(categoryId: 1),
      CategoryPage(categoryId: 2),
      CategoryPage(categoryId: categoryId),
    ];
  }
}

If you want to have nested routes, use StackOutlet widget.

The example below use BottomNavigationBar to demonstrate how you can use declarative API to switch between 2 tabs,
each tab is a route.

Calling setState() will update the current nested stack, and therefore switching the tabs.

class _ProductDetailsPageState extends State<ProductDetailsPage> {
  ProductDetailsTab tab = ProductDetailsTab.specs;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ... removed for concise
      body: StackOutlet(
        stack: ProductDetailsStack(
          productId: widget.productId,
          tab: tab,
        ),
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.list)),
          BottomNavigationBarItem(icon: Icon(Icons.plumbing_sharp)),
        ],
        currentIndex: tab == ProductDetailsTab.specs ? 0 : 1,
        onTap: (tabIndex) {
          setState(() {
            tab = tabIndex == 0
                ? ProductDetailsTab.specs
                : ProductDetailsTab.accessories;
          });
        },
      ),
    );
  }
}
enum ProductDetailsTab { specs, accessories }

class ProductDetailsStack extends RouteStack {
  ProductDetailsStack({required this.productId, required this.tab});
  
  // ... removed for concise
  
  List<Widget> get pages {
    return [
      if (tab == ProductDetailsTab.specs)
        ProductDetailsSpecsPage(productId: productId),
      if (tab == ProductDetailsTab.accessories)
        ProductDetailsAccessoriesPage(productId: productId),
    ];
  }
}

@slovnicki
Copy link
Contributor

slovnicki commented Mar 21, 2021

I have been working on improving the declarative experience of beamer and I think I found a sweet spot regarding the discussion in this issue. I would like to hear your thoughts on this.

I'll give some introduction on core concepts of beamer that will lead to the main point in section 4.

1. Group contextually related page stacks into a BeamLocation

For example,

[ ProfilePage(), ProfileSettingsPage() ]

and

[ ProfilePage(), ProfileDetailsPage() ]

would be handled by a ProfileLocation that extends BeamLocation.

BeamLocation has 3 important roles:

  • know which URI it can handle - pathBlueprints
  • keep BeamState with URI parameters and arbitrary user defined data - state
  • build a list of pages that will go into Navigator.pages - pagesBuilder

An example of BeamLocation:

class BooksLocation extends BeamLocation {
  @override
  List<String> get pathBlueprints => ['/books/:bookId'];

  @override
  List<BeamPage> pagesBuilder(BuildContext context) => [
        BeamPage(
          key: ValueKey('home'),
          child: HomeScreen(),
        ),
        if (state.uri.pathSegments.contains('books'))
          BeamPage(
            key: ValueKey('books-${state.queryParameters['title'] ?? ''}'),
            child: BooksScreen(),
          ),
        if (state.pathParameters.containsKey('bookId'))
          BeamPage(
            key: ValueKey('book-${state.pathParameters['bookId']}'),
            child: BookDetailsScreen(
              bookId: state.pathParameters['bookId'],
            ),
          ),
      ];
}

2. Route through BeamLocations and within BeamLocation

Through

BeamerRouterDelegate keeps a list of visited unique BeamLocations where the last one is currentLocation. This was inspired by this use-case of switching tabs: flutter/flutter#77360 , where each tab is a BeamLocation with it's page stack. The only other solution for this problem is for the developer to manually keep and clean the history of tab navigation (regardless of using a single router or having each tab be a router).

Within

The logic inside pagesBuilder controls how a "single stack" is manipulated. Data for this can be used either from context or from state (that is the BeamState that a BeamLocation holds).

3. Navigation process

  1. BeamLocation or URI comes into BeamerRouterDelegate. This can in either case be either from a developer or from OS.
  2. BeamLocation is given explicitly or BeamLocation.pathBlueprints are checked and appropriate BeamLocation that can handle the incoming URI is selected
    a. BeamLocation.state is already set or is created with passed parameters (from URI and user)
    b. BeamLocation.pagesBuilder is called to give pages to Navigator.pages
  3. Now the currentBeamLocation can be accessed from the nearest Beamer in context (Beamer is basically Router). This location can then be manipulated to rebuild the current page stack.

4. Navigation API

The above implementation led me to the following conclusion for the "universal API":

IMPERATIVE THROUGH + DECLARATIVE WITHIN

Through BeamLocations

  • Beamer.of(context).beamTo(MyLocation())
  • Beamer.of(context).beamToNamed('/my-location')
  • Beamer.of(context).beamBack()

Within BeamLocation

  • Beamer.of(context).currentLocation.state = NewState(...);
  • Beamer.of(context).currentLocation.update((state) => state.copyWith(...))

(As Beamer by default prefers updating the current location if it's the same type as the one being requested, below imperative beamTo and beamToNamed will have the same result as updating the state, so they can also be used for navigating within)

  • Beamer.of(context).beamTo(MyLocation(path: '/my-location/details'))
  • Beamer.of(context).beamToNamed('/my-location/details')

Final thoughts

With the recent addition of currentLocation.update, a lot of possibilities have opened. Developer can extend BeamState and keep arbitrary data there, but also can build arbitrary URIs that will be shown in browser's URL, that do not necessarily correspond to pages in any way, count or meaning. (this is important as that is essentially the state -> uri that ignited an interesting discussion here)

I haven't had much time yet to try all the interesting possibilities, but will.

As it seems, I could also easily add a top-level state (Beamer.state let's say) that would be for going through locations declaratively, but I'm not sure how different could this feel from imperative approach.

(edit: I started to work on this Beamer.state concepts. This will make beamer both purely imperative and purely declarative, whatever user chooses to use or even mix)

@xuanswe
Copy link
Contributor

xuanswe commented Mar 26, 2021

Hi everyone,

Finally I published the first working version of Navi, a declarative navigation API.
You are welcome to try and share your wishes so I will change it accordingly.

Currently, I have only a short documentation and 2 simple working examples to show how it works. More will be added soon to serve the Navigator-2.0-API-Usability-Research.

I will concentrate on main functionalities of declarative API before adding imperative API.

@kevlar700
Copy link

kevlar700 commented Mar 26, 2021

Personally. I handle state within pages via widgets and switch statements. It is far easier to read. Dart can be written in a readable fashion and riverpod/provider helps. Outside the widget tree idiomatic Flutter is often unreadable. The situation even for context free Nav 1.0 is far more complicated than it should be.

FWIW a simple context free (key by default) imperative api that enables the back button and url entry by default would be perfect.

Actually I am not too bothered about declarative or imperative, it is just that imperative is more likely to offer a one line to switch page and record nav history automatically, without the boilerplate.

brianegan/flutter_redux#5

@xuanswe
Copy link
Contributor

xuanswe commented Mar 26, 2021

@kevlar700

Personally. I handle state within pages via widgets and switch statements. It is far easier to read. Dart can be written in a readable fashion and riverpod/provider helps. Outside the widget tree idiomatic Flutter is often unreadable. The situation even for context free Nav 1.0 is far more complicated than it should be.
FWIW a simple context free (key by default) imperative api that enables the back button and url entry by default would be perfect.

For simple cases, sometime imperative is better than declarative.
But in real apps, mostly declarative will be easier to control and more reliable. Of course, you need a good declarative API, otherwise, imperative API is better than a bad declarative API.

If you see the examples I provided in Navi package, you will see that StackOutlet widget allows you staying in your widget tree and control it like any normal widget.

Actually I am not too bothered about declarative or imperative, it is just that imperative is more likely to offer a one line to switch page and record nav history automatically, without the boilerplate.

I think you misunderstood the purpose of page hierarchy. Please check Material Navigation Guide to see 3 different navigation types.

Nav 1.0 only work with default history navigation (chronological navigation). You cannot control Lateral and Upward navigation with Nav 1.0. And even for the chronological navigation in Nav 1.0, it's too hard to remove previous pages from the history.

With Nav 2.0 declarative API, you are having full control for Lateral and Upward navigation. But it has 2 big problems: too difficult to use and no support for chronological navigation.

I am planning to solve problems above in the Navi package:

  • Simple yet flexible declarative API, which keeps all the power of Nav 2.
  • Chronological navigation will be added.
  • Simple imperative API will be added. Navi's imperative API will allow you to control all 3 types of navigation.

@kevlar700
Copy link

kevlar700 commented Mar 26, 2021

I am afraid that I disagree with mostly. Declarative has it's place and so does imperative. Similar to those that think OOP everywhere is optimal are wrong. Like everything, it depends.

I believe the material link you provided confuses together global navigation with transitions and local widget switching. If you see them being served by the same tool.

Only web will have a url in any case. So the app better work well without it anyway.

Finally. You shouldn't have to worry about whether the build has disposed before your navigation state decision (keyed by default instead of build context).

@xuanswe
Copy link
Contributor

xuanswe commented Mar 26, 2021

I am afraid that I disagree with mostly. Declarative has it's place and so does imperative. Similar to those that think OOP everywhere is optimal are wrong. Like everything, it depends.

I don't say that imperative is not good. We will need both, imperative for simple cases and declarative for other cases. The only problem of declarative approach is that, we don't have yet a good API ready to fully compare to imperative. You need to wait until a good declarative idea released to have a final conclusion.

When a declarative API is ready, we will need to implement exactly the same scenarios (simple and complex) in both ways. Otherwise, everything we are saying is just theory.

@kevlar700
Copy link

kevlar700 commented Mar 26, 2021

Well if all that you want to do is navigate to a url then there is no debate. My point is that even the current imperative API could be simplified and work anywhere. Debating declarative vs imperative, distracts from the real issues. Navigation should not be complex, especially by default. I believe e.g. ref tags should be a separate API.

It is possible that this stems from a fear of Global state. Global state should be avoided but sometimes it is THE BEST option.

Some of the most reliable systems in the world run on nothing but dedicated global state, such as one of green hills embedded os.

Atleast base url navigation is inherently global.

@xuanswe
Copy link
Contributor

xuanswe commented Mar 26, 2021

@kevlar700 I plan to support both declarative and imperative in Navi. So comparing these 2 approaches becomes useless if the library gives you both to choose anyway :-).

@slovnicki
Copy link
Contributor

slovnicki commented Mar 26, 2021

@nguyenxndaidev Exactly.
And so many developers really need/want just a simple imperative navigation. We cannot ignore that, regardless of how declarative might seem more controllable and/or Flutter-like.

Having both imperative and declarative is a holy grail for a navigation package, which I pursue in beamer also.

@kevlar700
Copy link

kevlar700 commented Mar 27, 2021

Again. despite saying devs prefer imperative myself in the past. I no longer think that imperative or declarative is related to what most developers want by default. They do prefer nav 1 for other reasons.

They ideally want to be able to set a global list of routes and navigate to any route from anywhere and have the browser back button and url bar work.

Ideally without Any other boilerplate. That is what web browsers deliver with html.

I am not sure it is possible without editing flutters navigator code itself, however.

Potentially having both declarative and imperative could cause confusion but it shouldn't matter too much. Perhaps they could serve different parts of the API.

@idkq
Copy link

idkq commented Mar 28, 2021

Agree with you all. The challenge is how to make it simple, short, easy to read and use.

I haven’t seen any solution yet that is close to this ultimate goal unfortunately, despite everyone’s contribution and effort.

The baseline for the new api should be 1 line of code. Or around that. We need a solution.

@esDotDev
Copy link

esDotDev commented Mar 31, 2021

Wow, great thread and discussion. On the topic of "Flows" being a good use case for state-based navigation.

What is also really well suited to flows is just the classic imperative navigation!

  • Flows are almost always linear, so hard-coding a Push(FlowStep2()) into each view is easy and fairly robust
  • You rarely need to deep-link into flow
  • If you need to add a new page, or re-order things, it's always very simple as most flows are <5 pages.
  • If you do need to report a flow step for analytics, the view can just report itself

So I think maybe we are overthinking it a bit. I think just some really intuitive clear declarative API for app navigation is what we need, and really the classic imperitive API are more than fine to handle flows.

State based declarative nav is mainly good at clearing up confusion around a complex nav stack, where many entities may be pushing and popping the stack. In a flow scenario, really none of that confusion exists, you're just drilling forwards and back so it doesn't really need fixing imo.

@esDotDev
Copy link

Ideally without Any other boilerplate. That is what web browsers deliver with html.

I think this is the key point, we should be starting from this use case, and working backwards.

The web is fundamentally an imperative paradigm with a bookmarking system. It is constantly just calling pushReplacement(), while at the same time, accumulating more and more 'state' in memory that the views can share.

Trying to marry this inherently imperative approach with a declarative stacked approach is what causes all the headaches I think.

I'm not too sure where I'm going with this, except I guess I agree that making the existing imperative API clearer and more of a first class citizen would be good. What I really want from Flutter are just 3 things:

  • Map Routes to Paths (like html does)
  • An easy way to read/set query params from anywhere (like html does)
  • Auto update of browser path + params when changing routes (like html does)

I think for the most part this is well served already by MaterialApp.onGenerateRoute, but maybe it can be made even easier to use with a dedicated widget that can be stuck anywhere in the tree? Or losing the dependence on PageRoute which has loads of baggage and legacy considerations.

Using MaterialApp.onGenerateRoute to navigate is still much more complicated than basic HTML hrefs and .location for example.

To be honest, I think the jury is still out on whether declarative is actually better at all in these complex scenarios. I think you can argue it has inherent scaling issues, which imperative doesn't really suffer from. Imperative is more just a constant level of confusion vs an ever growing one :p

@esDotDev
Copy link

esDotDev commented Apr 4, 2021

Having played around with VRouter for a bit, I've landed on what I think is my preferred approach, which to NOT have state drive navigation, but rather allow navigation to change state, and to declare all of that in one place inside the routing table.

There is no getting around the fact that some navigation changes are event based, not state based. Trying to support both event based and state based quickly ends in a mess. You start creating state, to emulate events, like isShowingSettings, rather than showSettings(), and this just doesn't scale well. It starts out simple, and then very quickly becomes a headache when realworld complexities enter the scene.

It seems much more straightforward to keep the actual navigation changes imperative (pushNamed), but then to have some declared logic around those routes that can modify the state.

This would be identical to how html has worked for decades. When a page is loaded, args are extracted from the path, and (maybe) injected into the current $_SESSION, which acts as shared state for all pages. All page changes are imperative. If someone lands somewhere they shouldn't be, they are simply re-directed elsewhere.

This is a bit of pseudocode, but for example, if we want to show a specific product, that can be deeplinked to, you can have something like:

AppRoute(
  path: ProductPage.path + "/:id",
  // App state is updated, anytime we goto this page. Not vice versa...
  beforeEnter: (redirector) async {
    if (getModel().isLoggedIn == false) {
      redirector.pushNamed(LoginPage.path);
      return;
    }
    getModel().currentPage = HomePage.path;
    getModel().currentId = navKey.currentState!.pathParameters["id"];
  },
  widget: ProductPage(),
),

So here we can go to any product url, and the app state is updated to match. To move around our app we'd have links like:
pushNamed("${ProductPage.Path}/$itemId"), which when triggered, update the app state.

The product page can then simply check the model for the productId, and load what it needs. Or, we could pass it directly if using a view model, something like:

  beforeEnter: (_) async {
    getModel().currentPage = ProductPage.path;
  },
  widget: ProductPage(productId: vrouter.pathParameters["id"]),

This makes for a very nice mix of declarative and imperitive, with a foot in each. The tree structure and nesting are all declarative, defined in one spot and easy to grokk/change. But the actual changing of views is event based as most developers are used to, and then the updating of state is a mix of declarative callbacks and imperative code inside them.

There is no need to consider how to convert your app state to a url, because the data flow only goes one way. All you need to consider, is what state should change when you enter a given path, and what the pre-conditions are for viewing that path.

@lulupointu
Copy link
Contributor

I'm happy that you could reach this point of view.
It is exactly what I have been trying to defend in this thread and what I had in mind when building VRouter.

I am happy that other are trying different approaches but I still strongly believe that this single flow way of thinking is the best way to navigate.

@xuanswe
Copy link
Contributor

xuanswe commented Apr 5, 2021

There is no need to consider how to convert your app state to a url, because the data flow only goes one way. All you need to consider, is what state should change when you enter a given path.

This is exactly what I see when I am trying to create a complex scenario with declarative approach. That's why in my plan for Navi 0.2.0, I will not allow to navigate by manipulating state directly. So developers will use declarative for converting current state to pages. But when they need to change the navigation state, they need to start with changing the current URL, not the state directly.

For other points in your comment, I will spend more time to evaluate with some ideas to find the best solution.

@idkq
Copy link

idkq commented Apr 8, 2021

I've been using declarative for a while now and the latest challenge I found is regarding paths.

Consider these paths:

/main/
/main/product/
/main/product/details/

Using declarative, I can link state <-> screen. Let's assume 'product' is currently visible now. If go 100% declarative-model and want 'details' to be visible then I simply put a condition to trigger/show 'details'. HOWEVER (here is the tricky part) if I want to mix declarative with a bit of imperative, it gets ugly.

For example, if I have a enum-holding-variable AppState.currentVisibleScreen = ScreensEnum.productScreen and I want to go back & up, I can read my list of paths and find what is above 'product' which is 'main'. Easy. But if I introduce a new path:

/main/checkout/product/

I can show 'product'. But to go up & back, now I have two candidates for parent of product: 'checkout' and 'main'. I could store a list of previous visited paths but it is really a lot of work!

Long story short: the more I use declarative, the more I reverse course to imperative.

@xuanswe
Copy link
Contributor

xuanswe commented Apr 25, 2021

Hello guys, finally I found an elegant API, which is simple and easy to learn, yet keeping full power of Navigator 2.0 combine with imperative API on top of the core declarative API.

The result is published as Navi 0.2.0.
Navi 0.2.0 marks the end of POC phase of the package.
I am confident to start using it in my application from now on.
Next step is to write more tests, more documentation, optimization and of course continuing to add new features.

I hope you will enjoy Navi!

@slovnicki
Copy link
Contributor

@nguyenxndaidev I like where navi is going.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests