Closed InMatrix closed 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!
I think there are 3 different types:
Here is a description of each of them, along-side code snippet.
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(),
),
],
)
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.
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(),
),
],
)
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.
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.
@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'],
),
),
];
}
@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!
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:
url
-> state
AND state
-> url
in such an architecture@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)
I agree with you, I do not think there is a situation where you would want that.
This is why I think:
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
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.
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!
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:
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.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.
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.
Just to expand a bit, from my experience, issues with declarative (@lulupointu scenario 3):
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.I am not saying it is all bad, but we can find ways to improve.
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.
@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.
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.
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:
What's the source of truth with these? They can all be:
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?
@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.
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:
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.
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.
For bottom navigation bars and similar widgets that change state, resulting in the page to change.
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.
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.
@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.
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
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.
if
s for every screenPageResolver
determines wether or not a page will be visible.PageOrder
determines the order of the stack automatically.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.
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.
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?
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:
/registration?email=abc@def.com
and sync the email with state. But we don't want to sync the password in the URL.
If user enters the URL without a valid email, email page is shown. If there's a valid email from URL, password page is shown.
After having both email and password in state, the registration could be completed with a confirm button./registration
the first time, email page is shown. User enters email and click next to store email in state (but URL doesn't change). If email is valid, password page is shown.
Now, user don't want to click confirm button. He want to open /policy
page and then open /registration
page again.
If the app still keep the email and password somewhere (ex. in-memory), state is restored and user can just click confirm button without filling these information again.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.
So basically, don't think that navigation by URL or by State. Think like that, navigation by URL (+ State when needed).
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:
Please give me some time to finish the first prototype version. It looks super promising.
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.
@nguyenxndaidev do you have any code you could share?
@nguyenxndaidev do you have any code you could share?
Sure @johnpryan, I will reorganize my imagined API and will share here soon.
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.
@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.
Let's start with an example, which is complex enough to see the problem.
Computer & Accessories › Data Storage › External Data Storage
.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]
/
: 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.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),
];
}
}
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
@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.
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),
];
}
}
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.
BeamLocation
For example,
[ ProfilePage(), ProfileSettingsPage() ]
and
[ ProfilePage(), ProfileDetailsPage() ]
would be handled by a ProfileLocation
that extends BeamLocation
.
BeamLocation
has 3 important roles:
pathBlueprints
BeamState
with URI parameters and arbitrary user defined data - state
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'],
),
),
];
}
BeamLocation
s and within BeamLocation
BeamerRouterDelegate
keeps a list of visited unique BeamLocation
s 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).
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).
BeamLocation
or URI comes into BeamerRouterDelegate
. This can in either case be either from a developer or from OS.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
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.The above implementation led me to the following conclusion for the "universal API":
IMPERATIVE THROUGH + DECLARATIVE WITHIN
BeamLocation
sBeamer.of(context).beamTo(MyLocation())
Beamer.of(context).beamToNamed('/my-location')
Beamer.of(context).beamBack()
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')
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)
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.
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.
@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:
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).
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.
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.
@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 :-).
@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.
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.
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.
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?
Here's a link to full example in Navigator 2.0's design doc.
Cc: @slovnicki, @johnpryan, @theweiweiway, @lulupointu, @nguyenxndaidev