flutter / uxr

UXR work for Flutter
BSD 3-Clause "New" or "Revised" License
229 stars 28 forks source link

Declarative vs. imperative routing API #15

Closed InMatrix closed 3 years ago

InMatrix commented 3 years ago

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 commented 3 years ago

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:

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:

Basically:

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 commented 3 years ago

@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 commented 3 years ago

@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:

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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:

SchabanBo commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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

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

idkq commented 3 years ago

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 commented 3 years ago

@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:

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

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:

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 commented 3 years ago

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

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

tomgilder commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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?

Summary

theweiweiway commented 3 years ago

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 commented 3 years ago

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:

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.

idkq commented 3 years ago

I believe that there are some fundamental questions to be asked when it comes to declarative so we can better propose the solution. That is: what are the use cases in which reacting to a state change is preferred to reacting to an event? What are the benefits? (Just another way to pose the original question of this topic. We briefly discussed flows already. )

State management development is/might not be an absolute advantage in all 100% cases. Navigation could be one in which state/declarative doesn’t fit well.

I really like the declarative nature of Flutter/Dart but in my experience I was trying to over engineer something very simple which is move from Screen A to Screen B.

We need to address the most basic scenarios: Clicking on a button (event) to open a secondary screen. In many cases the app needs a way to know where the user is and is going. URLs are perhaps the most effective way to answer these questions.

Declarative answer different questions, what is the state of the app and what will be the state of the app after an event. It’s hard to reconcile the two concepts because they are different things. One is location and another is state (which can be location).

If you think about a Venn diagram, state encompasses location. You can use state to manage location but the reverse is impossible. The question is how to engineer this in a way that tackle both problems and keeping it simple.

I like the idea of scopes on state management, but isn’t it what the forward slash does in the URL? Such as /main/product and /main/product/detail isn’t it a clever scope/organization way to structure the scopes?

xuanswe commented 3 years ago

State management development is/might not be an absolute advantage in all 100% cases. Navigation could be one in which state/declarative doesn’t fit well.

Yes, that's why I said, state is optional. Stay simple without state if it's better.

If you think about a Venn diagram, state encompasses location. You can use state to manage location but the reverse is impossible. The question is how to engineer this in a way that tackle both problems and keeping it simple.

In case developers want to use state for declarative navigation, they need to know that they have 3 options:

Developers are responsible for choosing the right solution for their need. The complexity level is in their hands. They need to know what is the best option for them.

I like the idea of scopes on state management, but isn’t it what the forward slash does in the URL? Such as /main/product and /main/product/detail isn’t it a clever scope/organization way to structure the scopes?

Everything is still organized by URL. State is only an additional tool for navigation. So IMO, to keep thing simple, state should belong to a URL and its sub/nested routes.

xuanswe commented 3 years ago

So basically, don't think that navigation by URL or by State. Think like that, navigation by URL (+ State when needed).

xuanswe commented 3 years ago

I am playing with the idea of splitting whole navigation system into hierarchy of stacks. Each stack has a separated state to build the final pages.

Status of my current API right now:

  1. 100% declarative => it seems, we don't need guards or lifecycle with declarative approach
  2. To end developers: simple and easy to understand
  3. Architecture is super clean and organized if splitting into reasonable hierarchy of stacks
  4. Imperative will also be supported if 1. works as expected

Please give me some time to finish the first prototype version. It looks super promising.

slovnicki commented 3 years ago

@nguyenxndaidev sounds a lot like beamer

xuanswe commented 3 years ago

sounds a lot like beamer

@slovnicki Not really similar, I checked how beamer works. My current implementation will look totally different when compare it to beamer, vrouter and auto_route.

xuanswe commented 3 years ago

Result after trying to implement 100% declarative API approach

  1. [+] I can prove that, splitting into multiple small stacks is a good approach
  2. [-] When it comes to implementation details
    • if I want to keep simple API for end developers, I must use code generator.
    • If I don't use code generator, the API becomes too verbose to end developers.

My personal conclusion regarding 100% declarative API for now

  1. It seems auto_route is going the right direction, even it will need to be improved a lot.
  2. When Dart becomes a more powerful language, I will retry to have a similar API without code generation.
johnpryan commented 3 years ago

@nguyenxndaidev do you have any code you could share?

xuanswe commented 3 years ago

@nguyenxndaidev do you have any code you could share?

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

idkq commented 3 years ago

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 https://github.com/flutter/uxr/issues/15#issuecomment-795704991.

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

xuanswe commented 3 years ago

@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.

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.

// 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),
    ];
  }
}
idkq commented 3 years ago

Looks like the hierarchy/page order is a separate (and critical) issue, so I opened this here https://github.com/flutter/uxr/issues/21 hope its okay with you @InMatrix

slovnicki commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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:

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: https://github.com/flutter/flutter/issues/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

Within BeamLocation

(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)

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 commented 3 years ago

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 commented 3 years ago

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.

https://github.com/brianegan/flutter_redux/issues/5

xuanswe commented 3 years ago

@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:

kevlar700 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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.