flutter / uxr

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

Clarify Nested Routing scenario #35

Closed johnpryan closed 2 years ago

johnpryan commented 3 years ago

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

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

cc: @jackkim9 @chunhtai

johnpryan commented 3 years ago

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

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

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:

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

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

@chunhtai generally, sound good to me.

lulupointu commented 3 years ago

Same!

InMatrix commented 3 years ago

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

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

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

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

Hi folks, I've added some scenarios for my project here: https://github.com/aliyazdi75/gallery/discussions/5#discussioncomment-571684 Hope these are useful and tell me if they are wrong.

esDotDev commented 3 years ago

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:

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

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

esDotDev commented 3 years ago

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

2) 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()),
    ]
)

3) 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:

https://user-images.githubusercontent.com/736973/114346715-23190380-9b21-11eb-85d9-35dd7be45126.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 commented 3 years ago

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.

https://user-images.githubusercontent.com/736973/114435199-2217be80-9b81-11eb-872d-f885ad87b161.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 commented 3 years ago

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

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

A couple things that could be added here are:

https://user-images.githubusercontent.com/736973/114454857-49c65100-9b98-11eb-965a-e1ef6010ab37.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 commented 3 years ago

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

InMatrix commented 3 years ago

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

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

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

Router View / Router Outlets

Nested Stacks

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

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.

https://user-images.githubusercontent.com/736973/114611233-0d5d2880-9c5e-11eb-892e-5db3a90f64f6.mp4

lulupointu commented 3 years ago

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

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

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

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

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

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,

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

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

idkq commented 3 years ago

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

esDotDev commented 3 years ago

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

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:

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:

idkq commented 3 years ago

@cedvdb Good points I touched based on them with a different perspective here https://github.com/flutter/uxr/issues/35#issuecomment-818974846

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

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

I am working on https://github.com/flutter/flutter/issues/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 commented 3 years ago

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

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: