Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Clarify Nested Routing scenario #35

Closed
johnpryan opened this issue Mar 31, 2021 · 38 comments
Closed

Clarify Nested Routing scenario #35

johnpryan opened this issue Mar 31, 2021 · 38 comments

Comments

@johnpryan
Copy link
Contributor

The Nested Routing scenario does not specify how it should be implemented. It can be implemented using the Route API using a single parser and AppState object. Pull request #34 provides sample code for this scenario, but it's not clear this solution meets our users' needs. Each of these sections is something I think should be considered as we learn more about what users are looking for.

Please comment if you feel these scenarios are important / unimportant, or if there are any misunderstandings.

Hierarchical Routing

Scenario: A developer would like to define what UI should be displayed for a route path in a hierarchical structure.

Consider an app that handles the following routes:

  • / -> redirects to /home
  • /home - displays the home screen with links to /home/articles and /home/users
  • /home/users
  • /home/articles
  • /settings

Screen Shot 2021-03-31 at 3 30 10 PM

Another scenario where this could come up is if multiple teams are working on independent parts of an app, perhaps as separate packages or libraries:

routing with multiple teams

Router View / Router Outlets

Scenario: a developer would like to define a position in UI (e.g. the widget tree) where the sub-routes should be displayed.

In other frameworks this is called Nested Routes or Nesting.

Each "layer" of the tree corresponds to a part of the UI (e.g. a Widget), which defines a position in its sub-tree where the UI for the sub-routes should be displayed. In Vue, this is the <router-view> component, in Angular / AngularDart this is called the <router-outlet> and in react-router this is a <Switch>.

Nested Stacks

Scenario: A developer would like display a stack of pages in a sub-section of their app's UI.

For example, Getting to the Bottom of Navigation in Flutter describes a scenario where a Navigator is required for each destination view associated with a BottomNavigationBar. This example shows a BottomNavigationBar with an inner Navigator managing a stack of screens (/, /list and /text). Even though the inner Navigator is keeping a stack of pages, the bottom navigation bar remains on-screen.

@johnpryan
Copy link
Contributor Author

cc: @jackkim9 @chunhtai

@johnpryan
Copy link
Contributor Author

Here's an AngularDart example of the first two scenarios. Route paths are defined as a tree:

  static final articles = RoutePath(
      path: 'articles', parent: app.RoutePaths.home, useAsDefault: true);
  static final users = RoutePath(path: 'users', parent: app.RoutePaths.home);

And each component can use the router-outlet component to define where the UI for the sub-routes should be displayed:

<p>home</p>

<nav>
    <a [routerLink]="RoutePaths.articles.toUrl()"
       [routerLinkActive]="'active'">Articles</a>
    <a [routerLink]="RoutePaths.users.toUrl()"
       [routerLinkActive]="'active'">Users</a>
</nav>

<router-outlet [routes]="HomeComponent.routes"></router-outlet>

The vrouter package uses an InheritedWidget to determine what widget should be displayed in a certain sub-section of the widget tree:

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          children: [
            Text('Home'),
            Expanded(
                child: VRouteElementData.of(context).vChild ?? Placeholder()),
          ],
        ),
      ),
    );
  }
}

@lulupointu
Copy link
Contributor

Thanks for dedicating an issue to this. Nesting is so important in Flutter I think this is something we must take time to consider.

Concerning what you described:

Hierarchical Routing

I think this is interesting, not as a new feature, but really something which makes development easier.
What you describe here can be achieved in every packages as long as there is a path -> widget mapping. But what is really interesting is how the package makes it easier for developer by allowing or not things like relative paths.

Here is an example (using vrouter since I think the syntax is easy to understand in that case):

Solution 1, any path -> widget mapping would allow that

VRouter(
  routes: [
    VWidget(
      path: '/home',
      widget: HomeScreen(),
      stackedRoutes: [
        VWidget(path: '/home/articles', widget: ArticlesScreen()),
        VWidget(path: '/home/users', widget: UsersScreen()),
      ],
    ),
  ],
)

Solution 2, in vrouter this is relative path, but the important thing is that in avoids verbosity

VRouter(
  routes: [
    VWidget(
      path: '/home',
      widget: HomeScreen(),
      stackedRoutes: [
        VWidget(path: 'articles', widget: ArticlesScreen()),
        VWidget(path: 'users', widget: UsersScreen()),
      ],
    ),
  ],
)

Something interesting to note is that this can be taken to the next level. The issue with the example above in that if someone developed ArticlesScreen and UsersScreen and then added those later in HomeScreen, they will have to go to each push and change from push('/articles') to push('/home/articles') and push('/users') to push('/home/users'). So painful and hard to maintain separately.

This is something that Reach router does really well, I encourage anyone to read the Large Scale Apps sections, showing that not only the path but also the links are relative. This allows true separation of work.

Pushing relative path is allowed in VRouter but not to the extend that I wish, anyway this is not the place to discuss my future plans but I hope that you get my point.

Router View / Router Outlets AND Nested Stacks

First, I don't really understand the difference between the two. If any, I would say that Router View / Router Outlets is a subpart of Nested Stacks.

That being said, I think this is one of the most important topic with Flutter navigation. Flutter loves scoped models and nesting while navigation is inherently global (and least in the web where there is a single path). Re-conciliating is one of the greatest challenges of any navigation package imo.

Based on my experience, this example showing a BottomNavigationBar with an inner Navigator managing a stack is a though challenge that should be a really important scenario to conciser (since basically any mobile app faces it)

@xuanswe
Copy link
Contributor

xuanswe commented Apr 5, 2021

Showing nested navigator is easy but it becomes a very complicated topic when we need to support deep-linking and URL on the web. The more time I spend to work with nested routing, the more problems I see on this topic.

It has many edge cases to consider:

  • Do we want each tab as a page of the same Navigator? Or each tab as a separated Navigator with multiple child pages?
    For example, in showing book, I have 2 tabs: info and reviews.
    In the reviews tab, I have previous/next button to open previous/next review. So each review is a child page inside the reviews tab.
  • In each tab, we can have another nested navigator, ex. sections inside tabs. It could be 2 or 3 levels deep. Not sure if there's real scenario, where we need to control URL for deeper nested navigator.
  • Do we want to remember the state of all tabs (and nested routes inside each tab)?
  • In small screen, we have tabs. But in large screen, what if we want to show 2 or more nested navigators on the screen at the same time? Also consider that we may want to change the layout dynamically when user expands the browser window width.
  • Next thing is animation when the transition inside the part of the UI.

While there'are technical difficulties, we still need to maintain the architecture to make sure we can easily split our works to multiple teams. This point is a very important thing to me.
To do this in Navi, nested RouteStack doesn't need to know the URL of the parent stack.
For example, the package will automatically know how to merge the current URL in parent stack (ex. /books/1) and nested stack (ex. /reviews) to generate the final URL for you (ex. /books/1/reviews).

btw, @lulupointu, Large Scale Apps is an useful link.

@chunhtai
Copy link

chunhtai commented Apr 5, 2021

@lulupointu
From what I learn from your reply (please correct me if I am wrong), this topic can be separate into two different scenarios.

  1. Hierarchical routing, there can be sub parsing of the url, and they can define the page for each sub route, this can be useful when separating out functional teams that each team works on one page.
  2. Router View / Router Outlets, a page that has some static content is universal across routes, and some contents that will change based on the URL

You suggested both scenarios are important and we should probably have both when we do the usability analysis.

@nguyenxndaidev on the other hand suggest on the Router View / Router Outlets scenario, it is also important we analysis the ability to to split the works across teams.

To combine both feedbacks, I we were to cover all use cases we have so far. There are two different categories that both focusing on building app in large scale across multiple teams.

  1. Multiple teams each works independently on their own page, and they can easily combine their works together.
  2. Multiple teams that works independently on different parts of the same page.

I think (2) is the most tricky one, and it is likely if we cover (2), we will also cover (1)

What do you think?

@xuanswe
Copy link
Contributor

@chunhtai generally, sound good to me.

@lulupointu
Copy link
Contributor

Same!

@InMatrix
Copy link
Contributor

Multiple teams that works independently on different parts of the same page.

I'm concerned that we could have many speculative edge cases that push the API design to be more complex than it needs to be for the majority of Flutter users. Adding support for such modularity in the routing system is unlikely to be free of a usability cost. If anyone is interested in exploring this, comparing API design with and without this requirement could be illuminating.

@lulupointu
Copy link
Contributor

@InMatrix concerning scalability without usability cost, allow me to tell you what I want to achieve with vrouter, which will illustrate the points which are awaited when we talk about modularity:

Here is what one could do

final settingsRouter = VRouter(
  routes: [
    VWidget(path: '/basic', widget: BasicSettingsScreen()),
    VWidget(path: '/network', widget: NetworkSettingsScreen()),
    VWidget(path: '/advanced', widget: AdvancedSettingsScreen()),
  ],
);

final loginRouter = VRouter(
  routes: [
    VWidget(path: '/login', widget: LoginScreen()),
  ],
);

final mainRouter = VRouter(
  routes: [
    loginRouter,
    VNester(
      path: '/settings', 
      widgetBuilder: (child) => SettingsScreen(child),
      nestedRoutes: [settingsRouter],
    ),
  ]
);

The goals here are that:

  • A team working on settings can work in their router and navigate between its pages completely independently of what the team working on login implements
  • When using the mainRouter, nothing has to be modified. This means that if we had push('/network') in settingsRouter, this should not be changed when settingsRouter is used inside mainRouter (even if the path when using mainRouter is /settings/network)
  • Navigating between completely different part of the app should still be possible. Here navigating between /login and /settings/network would be done using something like a root router. This link will be dead when using loginRouter independently of course.

Something interesting to note here is that using this implementation provides no breaking changes to the current vrouter. Which answer your concern:

Adding support for such modularity in the routing system is unlikely to be free of a usability cost

I don't think this has to be true. This example is using vrouter because I am familiar with it but the important thing is that it is theoretically possible.

@xuanswe
Copy link
Contributor

xuanswe commented Apr 6, 2021

@InMatrix In Navi, a nested route is just a widget RouteStack and works like a normal widget. RouteStacks work independently and don't need to know each other. Navi will combine the URL parts provided by them and merge into a single URL automatically.

It means that, using Navi, it's freely to distribute the work exactly as we are doing with normal flutter project with the normal widget tree.

In case, the child RouteStack want to access parent RouteStack to read the current state or to navigate, the teams only need to know what is the StackMarker for the stack. StackMarker is just an immutable data class, you can optionally provide when you create RouteStack.

So, at the end, Navi is friendly to the large project and support both issues we mentioned above by default without doing anything.

@aliyazdi75
Copy link

Hi folks,
I've added some scenarios for my project here: aliyazdi75/gallery#5 (comment)
Hope these are useful and tell me if they are wrong.

@esDotDev
Copy link

esDotDev commented Apr 7, 2021

I don't totally get the team aspect tbh, any routing solution should be able to create testable widgets where the params are injected right into the pages: BooksPage(bookId: ...), at which pt teams can work and test fairly independently as long as they can set the initial route locally. I guess if there are complex sub-routes I can see the argument of why having your own nested router would be preferable.

The best real-world example of a multi-platform app with complex nesting scenarios I can find is actually https://twitter.com/. Surfing through there will pretty much generate every combination of nesting you would need to support in most apps.

If you compare their website to their app it is pretty consistent. It breaks down into basically:

  • Main Pages that are in the menu/tab bar (/home, /explore, /messages etc)
  • Main pages with deeplinks /messages/[MessageID]
  • Main pages with params /search?q="..."&src="..."
  • Main pages with nested stacks /explore/trending, explore/news, explore/sports
  • Double-nested pages like settings/notifications/advanced_filters or settings/explore/location
  • Full-screen pages that are not associated with a menu item /someuser/[PostID]

That's not every use case out there, but it's pretty close!

On Android the tabs do a really nice job of maintaining state/scroll position as you switch between them. On web they kinda bailed on that concept (we can do better!)

@InMatrix
Copy link
Contributor

@lulupointu @nguyenxndaidev Thank you both for your explanation! It does seem to be doable.

@esDotDev
Copy link

esDotDev commented Apr 12, 2021

Been digging into this a lot more, and some thoughts...

There is a rather critical high level things that can drastically change what we mean by "nested routers".

  1. In it's simplest form, nested routers is just an extension of map:widget mapping, where you can construct relative paths:
Route(
  path: "settings/", 
  child: SettingsPage(),
  nested: [  
     Route(path: "alerts", child: AlertsPage())  // Matches settings/alerts 
    ]
)
  1. In a more advanced form, nested routes allow you to wrap a scaffold around the contents of each route. This is very important for having tab menus, and nesting them (as you see in the twitter android app). There is usually be some way to define custom animations for these routes as well.
Route(
  path: "settings",
  nestedBuilder: (_, c) => SettingsScaffold(c), // All nested routes are wrapped in this
  nested: [  
     Route(path: "alerts", child: AlertsPage()),
     Route(path: "profile", child: ProfilePage()),
    ]
)
  1. In an even more advanced form, each nested page actually maintains state. You can see this in the twitter android app. When you change tabs, scroll-position and sub-tab values are maintained, there is no loading or loss of state when you switch routes. Additionally, the state of the Scaffold widgets are also preserved, so you can animate your tab menu when changing routes.

I think 1 and 2 are really pretty trivial, and will not really be a challenge for any routing solution. 3 is the holy grail, and 3 is kinda hard to pull off in Flutter. I don't think many, if any, packages can actually handle it. I think this is the bar we should be chasing. If we can bring that paradigm to the web, of a full stateful app, it will be very impressive.

I have a very ugly example, but it works:

  • This is all url-driven (not yet hooked up to Router, but should be easy)
  • This has 4 routes, all of them maintain state (text fields, scroll position, internal view state, all of it), but this can be turned off per-route
  • Notice that the InkWell animations on the menu buttons maintain their state we we navigate around. This shows the 2 scaffolds are fully maintaining their state as we change child routes, meaning you could play any animation you want in your scaffolds, they won't get lost as a new page animates in (imagine a marker animating to the selected menu, or maybe the menu itemn itself flips over with some cool 2.5 effect)
qbVU4xuBM4.mp4

This is constructed declaratively, from code looking like:

return PathStack(
  path: currentPath,
  childBuilder: (_, stack) => MainScaffold(child: stack),
  transitionBuilder: (_, stack, animation) => FadeTransition(opacity: animation, child: stack),
  entries: [
    /// Home
    PathStackEntry(path: "home", builder: (_) => HomePage("")),

    /// Settings
    PathStackEntry(
      path: "settings/",
      builder: (_) {
        return PathStack(
          path: currentPath,
          parentPath: "settings/",
          childBuilder: (_, stack) => SettingsScaffold(child: stack),
          entries: [
            PathStackEntry(path: "profile", builder: (_) => ProfileSettings("${Random().nextInt(999)}")),
            PathStackEntry(path: "alerts", builder: (_) => AlertSettings("${Random().nextInt(999)}")),
            PathStackEntry(path: "billing", builder: (_) => BillingSettings("${Random().nextInt(999)}")),
          ],
        );
      },
    ),
  ],
);

It's all driven by a single ValueNotifier<String> urlValue right now, but shouldn't be too hard to tie into Router instead. Under the hood, PathedStack is basically just an IndexedStack + Map<String, Widget> https://gist.github.com/esDotDev/09b0cb9fe2604c44b1d5a642d5a9ac29

My plan right now with this is to release a path_stack package, that would have nothing to do with Navigation per se. Then build a very small nav_stack package that just wires up path_stack => MaterialApp.Router. Will also add some mixins or builders to support route guarding and param parsing. I think this should be pretty easy, as I plan to follow the web-paradigm of just-in-time redirects and parsing within the pages themselves.

@esDotDev
Copy link

esDotDev commented Apr 12, 2021

Just a quick small update, I've updated the "persistent navigator" prototype to support history, so we can easily go "back". This solves most use cases needed for "pop", and also gets us one consistent nav paradigm we can think of across all platforms. The one use case not supported is getting results from the popped route, but this can be done using Dialogs, or hoisting view state.

F2mn6FEx0t.mp4

The back button here looks like:

onPressed: () {
    String prevPath = PathStack.of(context).history.last; 
    urlNotifier.value = prevPath; // todo: Change the router url instead
},

Regular links look like:

urlNotifier.value = "${AppNav.tabsCategory}${SettingsHome.path}${ProfileSettings.path}");

I think when I add a NavStack wrapper around PathStack, we can provide convenience methods like NavStack.of(context).goBack() to make these even easier.

This was made by using 3 nested path stacks, full screen routes site in the root stack, while routes wrapped by the main app scaffold set in the 2nd stack, under that all the Settings views are wrapped in a 3rd stack:

return PathStack(
  path: currentPath,
  key: pathStackKey,
  entries: [
    PathStackEntry(
        path: AppNav.tabsCategory,
        builder: (_) {
          /// Main-app path-stack, wraps all childed in MainScaffold
          return PathStack(
            path: currentPath,
            parentPath: AppNav.tabsCategory,
            // Main scaffold is wrapped here
            childBuilder: (_, stack) => MainScaffold(child: stack),
            transitionBuilder: (_, stack, animation) => FadeTransition(opacity: animation, child: stack),
            entries: [
              /// Home
              PathStackEntry(path: HomePage.path, builder: (_) => HomePage("")),

              /// Settings
              PathStackEntry(
                path: SettingsHome.path,
                builder: (_) {
                  /// Settings PathStack, wraps all Settings children in SettingsScaffold
                  return PathStack(
                    path: currentPath,
                    parentPath: AppNav.tabsCategory + SettingsHome.path,
                    // Settings scaffold is wrapped here
                    childBuilder: (_, child) => SettingsScaffold(child: child),
                    entries: [
                      PathStackEntry(path: ProfileSettings.path, builder: (_) => ProfileSettings("")),
                      PathStackEntry(path: AlertSettings.path, builder: (_) => AlertSettings("")),
                      PathStackEntry(path: BillingSettings.path, builder: (_) => BillingSettings("")),
                    ],
                  );
                },
              ),
            ],
          );
        }),

    /// Full-screen Routes, note that maintainState is deliberately turned off here, so we get a fresh compose sheet each time
    PathStackEntry(path: ComposePage.path, builder: (_) => ComposePage(""), maintainState: false)
  ],
);

@InMatrix
Copy link
Contributor

InMatrix commented Apr 12, 2021

@esDotDev Thank you for your investigation here. @johnpryan and a few others working on this project discussed this "offline" last week, and IIRC, we agreed that we need a separate Router for each layer of routes in a nested routing scenario in order to support 1) transition animations in each nested layer, and 2) preservation of state in each nested view/page.

Our current storyboard for the nested routing scenario does imply state preservation (e.g., when the user presses the browser's back button on Screen 4, they'd go back Screen 3 instead of Screen 2). Is there any change you'd suggest we make to the storyboard to make the requirements clearer?

image

@xuanswe
Copy link
Contributor

@InMatrix

We could have multiple nested routes scenarios for isolated problems.
Then we could introduce a more complex nested routes scenario to combine them.
What do you think?

After spent a lot of time for different problems with nested routes. I think this feature will influence the final decision when developers choosing a solution for them.

@esDotDev
Copy link

esDotDev commented Apr 12, 2021

A couple things that could be added here are:

  • User scrolls down in StaffPicks, when they come back, scroll position is retained (show scrollController retaining state)
  • User enters a searchFilter into StaffPicks, when they go back it is retained (show textField retaining state)
  • The audiobook section has an first-load animation, that is only played once (show general view state being retained)
  • When user changes tabs, the tab bar animates to the new position (doesn't matter how). This shows that it retain state and is not simply replaced. Otherwise many effects are not possible, like this very common pattern:
3AyeKKoePO.mp4

The first 3 are handled automatically if the StatefulElement of each route is truly preserved in memory. Otherwise, it's a lot of work for devs to support. I think a truly great routing solution for Flutter would just handle this for us.

But maybe there is a way to piggyback on the new Restoration API to do something like this at the Widget level instead of the Router level... in which case it wouldn't be important to retain the StatefulElement, but to rather just define appropriate storage buckets for each stateful-route and they would restore themselves. This would be slower, and use more CPU, but it would save RAM.

@esDotDev
Copy link

esDotDev commented Apr 12, 2021

I've created an issue for StateRestoration at runtime, which would likely mitigate much of these issues and remove the need for a Router to try and maintain state for it's children, or at least give an alternative approach that is not a ton of boilerplate.
flutter/flutter#80303

@InMatrix
Copy link
Contributor

We could have multiple nested routes scenarios for isolated problems.
Then we could introduce a more complex nested routes scenario to combine them.

@nguyenxndaidev So there might be a way to have a single scenario with a base version and a few advanced ones. The base version could show a tabbed UI within each page (e.g., the tabs within each books section in the current UXR storyboard). This base version should include customizable transition animations between tabs. A slightly more advanced version would preserve tab selections when switching between pages using the left menu. The most advanced version would preserve additional state within each page and each tab, such as scrollbar position, textfield input, etc, as @esDotDev has suggested. What do you think?

But maybe there is a way to piggyback on the new Restoration API to do something like this at the Widget level instead of the Router level...

@chunhtai Your thoughts on whether or not the Restoration API can help would be helpful here.

@xuanswe
Copy link
Contributor

This base version should include customizable transition animations between tabs. A slightly more advanced version would preserve tab selections when switching between pages using the left menu. The most advanced version would preserve additional state within each page and each tab, such as scrollbar position, textfield input, etc

I think this's a good approach.

@idkq
Copy link

idkq commented Apr 13, 2021

This is a great thread sorry to join late.

I see the topic of navigation as five topics.

Theory of navigation

  1. What should be shown in screen?
    This seems simple but it is rather complex. For static screens it is basically a snapshot of visual elements. For animations, we can think of a stream of snapshots. In abstract terms a uid/key/URL should identify a screen to be shown. This is what the URL does for web.

  2. What data should be passed and kept in memory?
    This is the book:id for deep link this is the data that should be passed to the screen for whatever reason. It is the metadata encoded on the uid/key/URL mentioned above. Anything can be encoded as data, since the characters themselves is data (i.e. www.exampleAAA.com where ...AAA is ASCII for 65 65 65)

  3. What is underneath and above?
    This is a special case for stacks, transitions, etc. when one removes a top layer/card from the stack. Here order is important, since removing one or more cards should reveal the screens in order. Of course we are dealing with 1 dimension here, in which x is either on top or below each other (2 dimensions would be x and y & 3 x, y ,z).

  4. What is the history?
    Self explanatory, a stack of navigation - the previous screens. One important question here is wether we should have a state-preserving history, in which we need not only the uid/key/URL but also the state, since sometimes the URL is not sufficient to recreate the state.

  5. What is the hierarchy?
    This is different form (3) and (4) because it is hypothetical. Take the example of a folder structure. It has a parent and child. The user can navigate directly to the child without going to the parent, so the history (4) is independent of the structure. The user can then go up one level, which reveals the parent. This is different from a stack (3) because one can have the same screen reference in multiple hierarchies (which does not happen on a folder structure).

EDIT

  1. What is the transition effect?
    Not sure how relevant this is, but basically how the transition between two screens will take place A -> B and B -> A. That includes animations, colors, effects, etc.

Anything else I missed?

Hierarchical Routing

  • Here we are answering (1) and (4) simultaneously.

Router View / Router Outlets

  • Here we are answering (1) only which widget (and descendants) should be visible. Although it is hierarchical in the code of the widget tree, it is not hierarchical for navigation.

Nested Stacks

  • Here we are answering (1) and (3) since we specify which Pages are underneath.

Vision

Ideally we would like to have a simple but powerful navigation framework that can answer all these 5 questions. In the web, the idea of a traditional URL is that it can answer (1) (2) (5) simultaneously, leaving only (4) for the browser/client and (3) is not applicable. This is why URL is so powerful and almost complete. One could use 'data' portion of the URL to store the missing parts (3) and (4) - in which would be a very long URL.

But we don't need that.

Probably existing classes already exist and satisfy but if not, a new set of classes could be created to answer these questions.

class NavEntry{ // this is equivalent to a URL, and could be written in the URL form
    Widget view;
    dynamic data;
    Widget parent;

    NavEntry.fromURL(String URL) {...}
}
class Nav{ // this is the overall nav
    List<NavEntry> entries; // ordered
    List<NavEntry> history; // ordered
    List<NavEntry> stack; // this achieves the same as NavEntry.parent

    Nav.hierarchical(List<NavEntry> entries) {...} //by default, creates the stack, history, and hierarchy based on the URL hierarchy
}

With that I can:

  1. Tell which screen/widgets to show
  2. Read any data
  3. Go up and down the stack
  4. Go back and forward on the history
  5. Go up and down the hierarchy

The only missing part is state perhaps is state-aware screens. Not sure if this is really a goal here.

Nested nav

Whether "a" screen is really one screen, or multiple screens has no visual implications, and I believe that this is a philosophical discussion. Consider bottomnav for instance, which could be a Navigator inside another, or two stacks of cards inside one another (if it was possible). Would you say, in a flat surface that it is two screens or one screen? It does not matter.

Now, in terms of navigation history, stack and hierarchy, one can decide to control them separately or together. If controlled together, then just need one Navigator. If controlled separately, two or more navigators are needed in which the top most screen should be visible.

I believe that if we dissect the problem this way we have a better easier understanding of the problem.

@esDotDev
Copy link

esDotDev commented Apr 13, 2021

I think that thinking of things in web terms, and using a folder/page paradigm is spot on, because we are forced to do so on the web anyways. I'm not sure I agree that it makes sense to think of things in terms of entries vs stack... I'm not sure going "Up" is worth all the problems it inherently comes with, when a simple back() or goto(otherPage) will often do. The web has done without this paradigm for decades, they just embed links to specific "parent" pages to support this.

Just as a code example, I've gotten my "path_stack" to a pretty nice place. It handles all nesting scenarios I can imagine, can easily support history like the web, or similar patterns like popUntil to popMatching.

This creates a fairly complex nested route structure, with dedicated nested menus, persistent pages, and full-screen (non-persistent) routes. This essentially emulates the Twitter for Android app, and I could build the entire app with this approach:

return PathStack(
  path: currentPath, // eg.  settings/alerts  or details/99
  key: pathStackKey,
  routes: {
    [AppPaths.tabsCategory]: StackRouteBuilder(builder: (_, __) {
      /// Main-app path-stack, wraps all childed in MainScaffold
      return PathStack(
        path: currentPath,
        basePath: AppPaths.tabsCategory,
        // Main scaffold is wrapped here
        scaffoldBuilder: (_, stack) => MainScaffold(child: stack),
        transitionBuilder: (_, stack, animation) => FadeTransition(opacity: animation, child: stack),
        routes: {
          // Home
          [HomePage.path]: StackRouteBuilder(builder: (_, __) => HomePage("")),
          // Settings
          [SettingsHome.path]: StackRouteBuilder(
            builder: (_, __) {
              return PathStack(
                path: currentPath,
                basePath: AppPaths.tabsCategory + SettingsHome.path,
                // Settings scaffold is wrapped here
                scaffoldBuilder: (_, child) => SettingsScaffold(child: child),
                routes: {
                  // Use a "" alias here, so links to `tabs/settings/` will fall through to this view
                  [ProfileSettings.path, ""]: ProfileSettings("").buildStackRoute(),
                  [AlertSettings.path]: AlertSettings("").buildStackRoute(),
                  [BillingSettings.path]: BillingSettings("").buildStackRoute(maintainState: false),
                },
              );
            },
          ),
        },
      );
    }),

    /// Full-screen Routes
    [ComposePage.path]: ComposePage().buildStackRoute(maintainState: false),
    // Supports reg-exp based matching on path suffix
    [DetailsPage.path + ":id"]: StackRouteBuilder(
      maintainState: false,
      builder: (_, args) => DetailsPage(itemId: args["id"]),
    )
  },
);

I think this demonstrates a really nice mix of declarative structure, with imperative control and it's super flexible.

"Back" in this example is just walking a List<String> history, "Close" is just going back in history before it sees a page that is not Details, and it jumps there.

P8X6oWc0Mh.mp4

@lulupointu
Copy link
Contributor

@idkq I really like some of the idea your are trying to express. However there is two point where I can't agree.

But we don't need that.

Probably existing classes already exist and satisfy but if not, a new set of classes could be created to answer these questions.

Well we do need the url for Flutter web. Keep it as a second though and you will only get your API more completed when you will have to take it into account.

Actually this is shown in your example, NavEntry.fromURL(String URL) {...} . The url has to store the information anyway since you convert it to your NavEntry here.

Whether "a" screen is really one screen, or multiple screens has no visual implications, and I believe that this is a philosophical discussion.

I desagree, take the example of a Scaffold with an animated BottomNavigationBar such as this one. If when navigating you only change the body of the Scaffold, the BottomNavigationBar will be animated nicely. If you change the entire screen however, this won't work.

@idkq
Copy link

Sorry.. this:

But we don't need that.

refers to:

One could use 'data' portion of the URL to store the missing parts (3) and (4) - in which would be a very long URL.

...In the sense we don't need history embedded in the URL. But agree, we need URL.

I desagree, take the example of a Scaffold with an animated BottomNavigationBar such as this one.

We can think of animation as a stream of frames. In that sense, each frame is visually/literally one screen painted each time.

@idkq
Copy link

I'm not sure going "Up" is worth all the problems it inherently comes with

Going up is very handy and simple. There is always one parent up to the root. If you pass a bunch of urls the app could easily built the navigation itself by inferring the paths. Going up is definitely not the ultimate solution, but should cover the most basic scenarios.

The complexities lies on how we implement it. It is nothing else but a goto parent.

@esDotDev
Copy link

esDotDev commented Apr 13, 2021

Going up is very handy and simple. There is always one parent up to the root. If you pass a bunch of urls the app could easily built the navigation itself by inferring the paths. Going up is definitely not the ultimate solution, but should cover the most basic scenarios.

The complexities lies on how we implement it. It is nothing else but a goto parent.

Right, I didn't mean we should lose "up" as a navigational concept, it just complicates things a lot at the routing level to define this declaratively. Either the page can just know it's parent (as html has always done), or can sniff it out imperatively from the history stack, seems to be a better approach to me. As you say, many different ways to theoretically inject a parent link into a child.

@esDotDev
Copy link

esDotDev commented Apr 14, 2021

fwiw, I have released my nav_stack and path_stack packages:
https://pub.dev/packages/nav_stack
https://pub.dev/packages/path_stack

NavStack allows you to express a full routed, tabbed scaffold, with persistent state like:

return NavStack(
  stackBuilder: (context, controller) => PathStack(
    scaffoldBuilder: (_, stack) => _TabScaffold(["/home", "/profile"], child: stack),
    routes: {
      ["/home"]: HomeScreen().buildStackRoute(),
      ["/profile"]: ProfileScreen().buildStackRoute(),
}));
...
// Change path using a simple api:
void handleHomeTabPressed() => NavStack.of(context).path = "/home";
void handleProfileTabPressed() => NavStack.of(context).path = "/profile";

A more detailed example, with query args, and route guards, looks like:

bool isConnected = false; // change this to allow access
return NavStack(
  stackBuilder: (context, controller) {
    return PathStack(
      scaffoldBuilder: (_, stack) => _MyScaffold(stack),
      routes: {
        ["/login", "/"]: LoginScreen().buildStackRoute(),
        ["/in/"]: PathStack(
          routes: {
            ["profile/:profileId"]: StackRouteBuilder(
              builder: (_, args) => ProfileScreen(profileId: args["profileId"] ?? ""),
            ),
            ["settings"]: SettingsScreen().buildStackRoute(),
          },
        ).buildStackRoute(onBeforeEnter: (_) {
          // Redirect and show dialog warning
          if (!isConnected) controller.redirect("/login", () => showAuthWarning(context));
          return isConnected; // If we return false, the route will not be entered.
         })
,},);},);
...
NavStack.of(context).path = "/login"; // Allowed
NavStack.of(context).path = "/in/profile/99"; // Blocked!
NavStack.of(context).path = "/in/settings"; // Blocked!

@esDotDev
Copy link

I think one thing that would go really far to being able to compare example would be to separate the view code, from the routing code (as much as possible). Instead of snippets, have authors prepare a complete routing solution using shared views.

A set of pages, scaffolds and menus could be pre-built, and then each package author can make small tweaks to the views, but should generally leave them alone.

In my own demos for example,

  • My boilerplate view code is 300 lines (3 tab menus, 3 scaffolds, and 6 pages.)
  • My routing code to express it is 42 lines

There's nothing unique to my router in the 300 lines of view code, other than the onPressed handlers.

Currently I'm using this sort of layout to adequately tested nesting scenarios, shows a mix of full-screen and tabbed views, and stateful and non-stateful, which you often want. This seems pretty exhaustive?

/tabs
    /home
    /settings
        /profile
        /alerts
        /billing
    /messages
      /friends
      /unread
      /archived
/compose (maintainState: false)
/details (maintainState: false)

image

@slovnicki
Copy link
Contributor

slovnicki commented Apr 15, 2021

@esDotDev this seems like a great exhaustive example. Can you share the code? I would like to test this UI with beamer.
I definitely agree with your goals of focusing on routing only and keeping the rest minimally changed. I suggested the same here at the beginnings of this UXR 👍

@idkq
Copy link

@esDotDev It is a great idea. Number of lines is one of the most important measures.

@esDotDev
Copy link

Sure, I took a pass on further extracting routing code from the views themselves, this led to me extracting a bunch of pre-made btns, which I assume we'll all have to customize.

So main routing table is here:
https://github.com/gskinnerTeam/flutter_nav_stack/blob/master/example/lib/advanced_tabs_demo.dart

Opinionated btns here:
https://github.com/gskinnerTeam/flutter_nav_stack/blob/master/example/lib/advanced_tabs_buttons.dart

Then agnostic supporting widgets are:
https://github.com/gskinnerTeam/flutter_nav_stack/blob/master/example/lib/advanced_tabs_demo_pages.dart
https://github.com/gskinnerTeam/flutter_nav_stack/blob/master/example/lib/advanced_tabs_demo_scaffold.dart

It's still a work in progress, I wrote much of it today and yesterday, but can definitely use another set of eye on it for some feedback. Happy to roll this into it's own repo too, which we could all share via a git dep.

@cedvdb
Copy link

cedvdb commented May 26, 2021

I'm just going to shime in here, maybe it's not the right place though but I hope that in your future design you are going to have a clear delimitation between:

  • The chronological stack
  • Overlays
  • The back stack (I personally call it the upstack)

I also hope tabs are going to be handled by url.

Let me touch on a few of those:

Overlays

Modal dialog, bottom sheet, etc are mixed in the navigation system as you have to call Navigator.of(context).pop();

Things that should not change the url should be in a separate mechanism altogether. If something is in a dialog or any sort of overlay, then it's not supposed to be shown by direct navigation. Meaning that there is no url you can refresh to directly see a specific dialog (unless the developer specifically opens it when the page is opened like an onboarding dialog).

The chronological back stack

That's the essence of the router, navigating on a specific URL gets you on a page, going back goes to the page you were before.

The upstack

So that little arrow at the top of an app bar that let's you go a level up in your application until you reach the root.
For this I'll go against the grain, but unless the up stack as a performance benefit I don't think it's a good architecture to rely on. It would be much easier to either:

  • go back a level in the urls => eg: from /products/:id to /products then to / (and have at most 1 page on the stack)
  • or let the user specify it altogether, at that point it is just a link.

I personally built my nav 2.0 library by creating an upstack containing all the routes that matches a path. So the upstack for /products/:id contains 3 elements (ProductDetails, ProductList, Home). However I don't see any benefit on having 3 elements in the stack instead of one and I think ditching that concept would make things easier.

@idkq
Copy link

@cedvdb Good points I touched based on them with a different perspective here #35 (comment)

Overlays -> My number: 1 - What is shown/displayed
Chronological back stack -> My number: 4 - History
The upstack -> My number 5: - The hierarchy

The question whether something changes the URL or not is an interesting discussion because in theory the URL text does not necessarily represent a view, but instead, the result of the URL resolution represents a view. There is a big difference between URL text and URL resolution. URL text is simple the characters on the navigation bar of the browser. URL resolution is the interpretation of the text, often using RegEx.

The reason I point out this difference is that sometimes on a overlay/popup/modal the URL does change - but it does not change to cause a change in the view. Example: 'www.mysite.com' and 'www.mysite.com/?showpopup'. In this example we are passing data but we could have a new view as well 'www.mysite.com/withpopup' that would resolve to the same view but with a different Boolean variable to switch the popup.

@cedvdb
Copy link

cedvdb commented May 26, 2021

www.mysite.com/withpopup

I totally disagree with this as things that are in an overlay should not be part of the URL. When you add sub paths to your URL there, you are implying it is a sub page, adding state management to your URL (that's what is being done here), and further more to the path is just far removed from the intended purpose of the web URL scheme. If anything you'd add it to the query parameters, but never in the path.

Overlays are secondary views that are not meant to be directly accessed. They are part of the state of a page.

However my point is that in all case overlays should not be part of the URL path for the same reasons that whether an input is focussed or not, whether a tooltip is shown or not, whether an animation is starting or not should not be part of the URL path. It's just the state of a page.

@chunhtai
Copy link

I am working on flutter/flutter#80546 which will allow developer to store state that you want browser navigation to preserve and don't want it to be part of the url.

@idkq
Copy link

things that are in an overlay should not be part of the URL

Again, you are getting confused with URL text and URL resolving. Past the domain text, one can write anything he wants as URL text. You might not agree with the structure and best practices definitely exist but there is nothing that prevents it.

If Flutter is compatible with web, then, it has to deal with it.

@cedvdb
Copy link

cedvdb commented May 26, 2021

Going up is very handy and simple. There is always one parent up to the root. If you pass a bunch of urls the app could easily built the navigation itself by inferring the paths. Going up is definitely not the ultimate solution, but should cover the most basic scenarios. (...) It is nothing else but a goto parent.

Alternatively the framework could not deal with it at all and let the user do goTo(parent) when one clicks the arrow. The benefit being that the api would be more web like. The arrow is just a link.

Past the domain text, one can write anything he wants as URL text.

I don't know what we are discussing about tbh. Yes anything can be written in the path and it will resolve the path to a file (for an hosted website). You can write popup in your path, still the server has to return you a file, which then opens the popup. I don't know where / if we are disagreeing on something.

My point was more along the line of that window.history.back() will always bring you to the last page visited while Navigator.pop() might just pop an overlay. Yes they are not the same thing but I'm advocating for a more stateless web like approach. Put the overlays in their separate package to not conflate them with navigation. IE:

  • Overlay.pop() // Overlay for displaying / removing overlays (dialogs, bottomsheets, etc)
  • Router.goTo() // Router for navigating between urls
  • RouterHistory.pop() // RouterHistory for modifying the history stack
  • no backstack

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

No branches or pull requests

10 participants