csells / go_router

The purpose of the go_router for Flutter is to use declarative routes to reduce complexity, regardless of the platform you're targeting (mobile, web, desktop), handling deep linking from Android, iOS and the web while still allowing an easy-to-use developer experience.
https://gorouter.dev
442 stars 96 forks source link

provide for multiple navigation stacks? #82

Closed csells closed 2 years ago

csells commented 2 years ago

e.g. https://medium.com/coding-with-flutter/flutter-case-study-multiple-navigators-with-bottomnavigationbar-90eb6caa6dbf

rydmike commented 2 years ago

The iOS style nested state keeping bottom navigation pattern is one that is often asked for when talking about nested navigation.

I wonder if it can't be built already with current version and some tiny bit of extra Flutter code. I might try it later.

csells commented 2 years ago

That would be great if you could give it a shot @rydmike . It's become the #1 outstanding issue.

bizz84 commented 2 years ago

Happy to see this on the roadmap. I think it would be a great addition.

My approach uses Navigator 1.0 and has some drawbacks as it keeps all the navigation stacks in memory.

Potentially it would be better if a Navigator 2.0-based solution could keep track of the state of each page/stack, and recreate the pages as needed when the user switches between tabs.

rydmike commented 2 years ago

If you want to check out how the "competition" is doing this, then Routemaster supports it. But it is based on Cupertino solution in the SDK and limited to it.

I wanted to have this feature also on other "custom" top level navigation widgets like sidebars, top navs in web, nav rails (SDK and custom variant) etc.

When experimenting with Routemaster a few versions back, I "borrowed" the stack solution from Flutter SDK Cupertino code, and used it as a stack for any indexed custom navigator widget to create the same multiple navigation stacks solution.

I think it can be used as is with GoRouter as well, but I have not tried the idea yet, been to busy with other things, but very curios to test it, ...eventually, hopefully soon. When I do, I will use a suitable package sample to try the idea.

ChauCM commented 2 years ago

If you want to check out how the "competition" is doing this, then Routemaster supports it. But it is based on Cupertino solution in the SDK and limited to it.

I wanted to have this feature also on other "custom" top level navigation widgets like sidebars, top navs in web, nav rails (SDK and custom variant) etc.

When experimenting with Routemaster a few versions back, I "borrowed" the stack solution from Flutter SDK Cupertino code, and used it as a stack for any indexed custom navigator widget to create the same multiple navigation stacks solution.

I think it can be used as is with GoRouter as well, but I have not tried the idea yet, been to busy with other things, but very curios to test it, ...eventually, hopefully soon. When I do, I will use a suitable package sample to try the idea.

Beamer also supports it, they have an example in here: https://github.com/slovnicki/beamer/tree/master/examples/bottom_navigation_multiple_beamers

csells commented 2 years ago

Thanks, @rydmike . I'm looking forward to what you come up with.

KristianBalaj commented 2 years ago

I'm focusing on this feature (and on the nested navigation in general) in my package routeborn.

When there is a nested navigation, the navigation stack is branching into multiple navigation stacks like a tree. Currently the implementation is in a way of single source of truth regarding the navigation stacks (the SSOT is the NavigationNotifier in my package). The NavigationNotifier is managing all the stacks.

Since the branching analogy, a method for changing branches in nested navigation is the setNestingBranch method, where by default the navigation stacks are preserved. In case a user doesn't want the stack preservance feature, there is an optional parameter bool resetBranchStack.

EDIT: Also, this example is using the CupertinoTabScaffold, where for each branch a separate Router is used and the stacks are preserved. But the routeborn package can be used with a single Router covering all the branches, too.

csells commented 2 years ago

Hey @KristianBalaj would you want to join forces and bring your multiple navigation stack expertise to go_router?

KristianBalaj commented 2 years ago

That would be nice, but currently I don't have resources to contribute to open source. So it's more optimal for me to maintain a separate package for now, since I'm using this package on 2 projects under development.

samdogg7 commented 2 years ago

I would love to see this feature! This is the one thing holding me back from using go_router. Thanks @csells for all of your amazing work so far.

esDotDev commented 2 years ago

I think go_router does everything it can do to implement this feature, it can't really be implemented "out of the box", the flutter developer needs to do a little bit of extra lifting.

This is because go_router is path based, there is fundamentally no such thing as 'multiple stacks' ever in memory. There is simply a path, and that will match to some stack of pages, and you can wrap those pages in an external tab menu, which gets you most of the way to nested nav in appearence, just not in behavior.

What's left to do, is the key feature people expect from this paradigm which is "each tab preserves it's state". This breaks down into 2 parts as far as I see:

The first thing can be done by having some imperative code that saves off the last viewed path for each tab, and when the tab is pressed, it tries to look up the last known page, and show that rather than the default. For example, the default url for tab1 might be /tab1/messages but it could link instead /tab1/messages/compose if that was the last recorded path for tab1. Given that later path, GoRouter would be smart enough to stack the Compose page on top of Messages and pop() would work as expected.

The second requirement is harder, having the stateful views remember/restore their state. I don't think there is actually a good solution for this that will work with GoRouter, unless you just have 1 top level pageRoute and all subsequent page routing is handled internally inside that widget. In theory the Restoration API could be extended to serve this use case I think. In the meantime it would probably have to be done via some form of persistent storage and having views manually restore their state when loaded, using an id (just like Restoration API does).

davidmartos96 commented 2 years ago

I think go_router does everything it can do to implement this feature, it can't really be implemented "out of the box", the flutter developer needs to do a little bit of extra lifting.

This is because go_router is path based, there is fundamentally no such thing as 'multiple stacks' ever in memory. There is simply a path, and that will match to some stack of pages, and you can wrap those pages in an external tab menu, which gets you most of the way to nested nav in appearence, just not in behavior.

What's left to do, is the key feature people expect from this paradigm which is "each tab preserves it's state". This breaks down into 2 parts as far as I see:

  • Restore the correct page when switching to a tab (it should load the page it was showing previously, and remember the viewstack)
  • Have the page itself remember it's state (scroll position, selected sort options, etc)

The first thing can be done by having some imperative code that saves off the last viewed path for each tab, and when the tab is pressed, it tries to look up the last known page, and show that rather than the default. For example, the default url for tab1 might be /tab1/messages but it could link instead /tab1/messages/compose if that was the last recorded path for tab1. Given that later path, GoRouter would be smart enough to stack the Compose page on top of Messages and pop() would work as expected.

The second requirement is harder, having the stateful views remember/restore their state. I don't think there is actually a good solution for this that will work with GoRouter, unless you just have 1 top level pageRoute and all subsequent page routing is handled internally inside that widget. In theory the Restoration API could be extended to serve this use case I think. In the meantime it would probably have to be done via some form of persistent storage and having views manually restore their state when loaded, using an id (just like Restoration API does).

Couldn't that second requirement be accomplished with Offstage widgets and a Stack? (As described in the article at the top) That way when switching tabs the state wouldn't be lost.

esDotDev commented 2 years ago

Ya, that's basically what I mean with "1 top level pageRoute and all subsequent page routing is handled internally inside that widget", like GoRouter is mapped to one persistent route, and an IndexedStack or something is handling the page-routing from there somehow? But then GoRouter is not doing much, except parsing some args for us and updating the browser location.

It's worth noting too, if you can access the navigation path history, then it's pretty trivial to have these menu buttons that know they are "/tab1", and can either link to their default path, or look up the last history location that contained their path. So that part is not really as cludgy as it sounds and you end up with the UX most clients seem to want.

Maybe there's a way with nested routers or navigators that I just don't understand.

csells commented 2 years ago

I'd love a sample of that...

esDotDev commented 2 years ago

I do have a lib I made for almost this exact purpose https://pub.dev/packages/path_stack

It just wraps IndexedStack, but uses strings as keys instead of int, and supports nesting: https://pub.dev/packages/path_stack

So all the pages are just kept in memory for free. I'll see what I can do for a little demo of combining this with GoRouter :)

csells commented 2 years ago

Nice!

esDotDev commented 2 years ago

Thinking about it a bit more, my worry is that I don't think this is the right direction for GoRouter. We'd lose all the nice stacking logic you have, and have to reproduce most of the path parsing and guard logic.

Seems like the better approach is to just try and remember tabs, and restore page state manually somehow. It's a bit harder, but then everything should just click together.

csells commented 2 years ago

is there some of the useful bits of GoRouter that can be exposed to implement this functionality?

esDotDev commented 2 years ago

As long as you expose the history of paths, then the tab btn stuff should be pretty trivial.

Restoration is the hard one, This line from the docs is interesting: In addition to providing restoration data when the app is launched, restoration data may also be provided to a running app to restore it to a previous state (e.g. when the user hits the back/forward button in the web browser). When this happens, the RestorationManager notifies its listeners (added via addListener) that a new rootBucket is available. In response to the notification, listeners must stop using the old bucket and restore their state from the information in the new rootBucket. https://api.flutter.dev/flutter/services/RestorationManager-class.html

If we could somehow trigger that restoration call each time we change routes, I think in theory we could get the "Stateful Tabs" paradigm working pretty neatly. Maybe it already does get called? I haven't played restoration API's for quite a while.

csells commented 2 years ago

I'm not sure what you mean by "history of paths". Do you mean the stack of routes that a location creates? Those trivial to expose.

esDotDev commented 2 years ago

I guess I should have said history of locations. The equivalent of your browser history. A list of strings, representing the chronological history of your current browsing session.

This would allow each tab to effectively "remember" it's last visited child page by just looking at the history, and checking for the most recent location, and loading that when the tab is pressed.

If you don't expose this, if would be easy enough to listen to the router, and record each new location as it comes in allowing us to generate this history stack manually. Nice if it were exposed though.

csells commented 2 years ago

go_router doesn't track this info. you can listen to changes on the GoRouter and pull out the location property as it changes.

esDotDev commented 2 years ago

Ok, ya as I mentioned I know goRouter is a changeNotifier, so we could always listen to it and do it manually.

Would be nice if it maintained this state though, since it's quite useful to be able to peer into history stack and do stuff with it, like we just demonstrated :D

esDotDev commented 2 years ago

VRouter provides something like this: https://pub.dev/documentation/vrouter/latest/vrouter/VHistory-class.html so these use cases are made a little easier.

esDotDev commented 2 years ago

Any thoughts on the more difficult aspect which is having routes be able to restore their state when loaded?

I wonder if there is some way we can trigger RestorationManager to run when a new route is shown, so we have a nice clean mechanism for routes to restore their state?

csells commented 2 years ago

Maybe we implement the UX first and then get the state restoration working after?

aytunch commented 2 years ago

I do have a lib I made for almost this exact purpose https://pub.dev/packages/path_stack

It just wraps IndexedStack, but uses strings as keys instead of int, and supports nesting: https://pub.dev/packages/path_stack

So all the pages are just kept in memory for free. I'll see what I can do for a little demo of combining this with GoRouter :)

@esDotDev If we have Google maps with lots of markers in one of the bottom nav tabs and a video feed on another, etc, the Offstage + IndexedStack strategy to keep state would consume a lot of memory right? When you said "memory for free", I wonder if there is something I am missing from the picture Maybe Flutter does something clever behind the scenes? Because I sure don't want to keep the restoration states of all my pages manually.

esDotDev commented 2 years ago

Yep, we're trading RAM for improved UX in this case. "memory for free", I just mean that the routes are cached in ram, so no extra work is needed for the dev, and all state is maintained, from viewstate to internal scroll state etc.

In my pathStack lib, I make it optional, so a route could say that it doesn't want to be persistent, so maybe you'd set the video view to be non persistent, and instead do a manual restoration on that page.

esDotDev commented 2 years ago

Some digging this morning turned up this very interesting looking API: https://api.flutter.dev/flutter/widgets/Navigator/restorablePush.html there is nothing on all of the internet about how to use it though. Guess it's time to play :)

Docs are pretty opaque, it states: Unlike Routes pushed via push, Routes pushed with this method are restored during state restoration according to the rules outlined in the "State Restoration" section of Navigator.

If you jump over to see the rules, you get: A Route added with the restorable imperative API (restorablePush, restorablePushNamed, and all other imperative methods with "restorable" in their name) restores its state if all routes below it up to and including the first Page-based route below it are restored. If there is no Page-based route below it, it only restores its state if all routes below it restore theirs

Which I don't really understand.

aytunch commented 2 years ago

@esDotDev this is great find. If RestorablePush can be implemented in go_router with a simple API, then this could be a game changer. We can easily choose which pages we want to be restorable and even dynamically change them if needed (If user is too deep in a nested route)

Other than parameters and query fields, we can have a restorableParameters field and use those fields to populate the UI in the desired state in our views?

esDotDev commented 2 years ago

Turns out there is an old API PageStorage that I was unaware of, this should allow us to restore route state with a bit of work. So I think this + a little imperative code for the tab buttons should get the job done! Albeit it in a slightly cumbersome way. Things will need to be assigned keys and each stateful view is responsible for saving/loading it's own state, but I imagine some helper classes could ease this quite a bit... https://api.flutter.dev/flutter/widgets/PageStorage-class.html

RestorableRoutes I think is more for restoring whatever arguments you had passed into the route? Which might be something go_router wants to support internally? I'm not sure...

csells commented 2 years ago

if we can have a sample of it working with go_router today, then we can evaluate what we'd want to push into go_router to make the scenario easier

esDotDev commented 2 years ago

Ok put together a working sample here, it uses a combination of imperative logic to find the most recent sub-path for a given tab, and also the PageStorage API to allow each route to restore it's state: https://github.com/esDotDev/flutter_experiments/tree/master/gorouter_with_stored_tabs

https://user-images.githubusercontent.com/736973/139731436-c3357ed9-0157-4ccc-b3ca-6a2eb36ecf3a.mp4

You can see an example of how to manually save/restore state using a page storage bucket here: https://github.com/esDotDev/flutter_experiments/blob/master/gorouter_with_stored_tabs/lib/demo_pages/messages_page.dart

And how to use PageStorage API to restore scroll positions here: https://github.com/esDotDev/flutter_experiments/blob/master/gorouter_with_stored_tabs/lib/demo_pages/feed_page.dart

The 'smart tab btns', just work like this:

void _handleBtnPressed(GoRouter router) {
    // Assume we're going to link to our basePath which is the common case
    String newPath = basePath;
    // If we're not currently selected, attempt to restore the last page the user was viewing for this tab.
    bool isNotSelected = router.location.contains(basePath) == false;
    if (isNotSelected) {
      // Look for most recent location that contains our basePath.
      bool match(String s) => s.contains(basePath);
      String mostRecentSubPage = App.locationHistory.firstWhere(match, orElse: () => '');
      // If we found a match, use it. Otherwise fall back to the basePath.
      if (mostRecentSubPage.isNotEmpty) {
        newPath = mostRecentSubPage;
      }
    }
    router.go(newPath);
  }
esDotDev commented 2 years ago

In terms of what go_router could do to make this easier, a couple thoughts come to mind:

If you wanted to make it even more turn key, GoRouter could have it's own internal PageStoreBucket and expose the writePageState and readPageState API so then we can just use goRouter to write state, and it can automatically prefix the page name onto the various keys.

Now instead of:

_count = pageBucket.readState(context, identifier: ValueKey('page1_count')) ?? 0;
pageBucket.writeState(context, _count, identifier: ValueKey('page1_count'));

I can write:

_count = goRouter.readPageState('_count') ?? 0;
goRouter.writePageState(_count, '_count');

The way I'm thinking of it, it's basically analogous to $_SESSION in PHP.

aytunch commented 2 years ago

@esDotDev this looks awesome. And thanks for the video illustration. In the 57th second of the video when we go from messages tab to the feed tab(while feed settings is open), we first see the feed page and also see the transition animation of the feed settings page being pushed. Is there a way to directly see the page which is on the top of the stack with this method?

esDotDev commented 2 years ago

Ya that's a bug because I wasn't properly assigning page keys, but I was too lazy to re-record. It works properly when page keys are assigned:

https://user-images.githubusercontent.com/736973/139758914-017ea456-02e5-4402-b7fc-1bb78b546b69.mp4

Basically Navigator was getting confused, and thinking we were adding a new page on top of the old, rather than replacing both pages. Since as far as it could tell it was just 2 children, of type MaterialPage.

esDotDev commented 2 years ago

Intersting comment in the src code here: image

Seems like I wasn't too far off thinking restoration could be used for this. It seems very similar to the PageStorage API. I'm just not sure how it works, like how do views request state restoration to happen when a new route is pushed?

It seems the flutter team is only letting the OS drive these restores, when we want more manual control.

esDotDev commented 2 years ago

Unfortunately it seems all flutter PageRoute extend ModalRoute, which add a PageStorage element, and no ability for you to provide your own bucket.

This means it's not possible for us to provide a single PageStorage outside the Navigator, as the pages will never be able to reach it. PageStorage.of(context), will always return the storage from the route, not the one at the top of the tree that we want for persistent storage.

Not a deal breaker, but it means each toplevel page view that wants to restore it's children needs to add some boilerplate like:

return PageStorage(
  bucket: GoRouter.of(context).storage.bucket,
  ....
)

Seems like restoration will be a better end-solution, as PageStorage is a little problematic. It has this rather major limitation, and also has some pretty nasty edge cases because of some odd design decisions: https://github.com/flutter/flutter/issues/53040

esDotDev commented 2 years ago

Thinking more about how GoRouter can support "stateful routes" in general, without needing any restoration/storage hassles, it fundamentally comes down to: can you cache the results of pageBuilder so that next time that same route is built, we get the cached version with intact state.

I think that comes down to a question of whether the child-widget of a Page can be cached somehow. Normally you would cache views like this with a Stack of Offstage widgets, but I'm not sure how to make that work when working with Pages.

If you can have stateful routes, and you can use a bit of logic to remember the last viewed page for any tab, then I think you get a really elegant and easy implementation of the "multiple nav stacks" paradigm, with full browser support.

Also, fwiw, I have logged an issue last April requesting support for this use case w/ Restoration https://github.com/flutter/flutter/issues/80303

esDotDev commented 2 years ago

I created an issue specifically to discuss state preservation of routes, cause it's really it's own feature.

Curious if anyone has any bright ideas on this problem in particular: https://github.com/csells/go_router/issues/134

davidmartos96 commented 2 years ago

I've created a small demo of a persistent bottom navigation with independent routes and helper methods to navigate across different tabs (similar to how the Play Store Console behaves with its Navigation Rail).

I don't know how feasible would be for go_router to implement something like this out of the box. This approach uses IndexedStack which persists the different children states in memory.

https://github.com/davidmartos96/go_router_bottom_nav_demo/tree/main/lib

olof-dev commented 2 years ago

For reference, in addition to Andrea Bizzotto's article mentioned at the start, there's also Hans Muller's take on nested navigation: https://medium.com/flutter/getting-to-the-bottom-of-navigation-in-flutter-b3e440b9386

techouse commented 2 years ago

https://github.com/davidmartos96/go_router_bottom_nav_demo/tree/main/lib

Thanx for the idea of using provided router delegates @davidmartos96 💟 I've implemented this approach in combination with a PageView (instead of an IndexedStack) and AutomaticKeepAliveClientMixin on the screens themselves and it works as expected. 👍🏻

csells commented 2 years ago

fyi @lulupointu on these approaches

lulupointu commented 2 years ago

@csells yes I've read these before designing the Multi stack API.

State restoration would not be at the level that @esDotDev (since this would means caching every visited page). However the API uses IndexedStack (a lazy version actually) under the hood so switching between tabs would keep the state of the others (as described in the doc). So this and that would be possible.

bahag-raesenerm commented 2 years ago

@esDotDev how is the current state of this topic? we'd really like to go with go_router but need this functionality ... we could also consider "to hack it" for a temporary solution but maybe we could also contribute somehow to bring things forward?

esDotDev commented 2 years ago

For now, I think your best approach is to just forward a bunch of urls to the same Page, and then parse the url yourself inside that page, and set the index on an indexed stack. When you change tabs, you'll need to decide whether you want to try and remember the last viewed page for each tab, or not.

Unfortunately that means not using a lot of GR's path-parsing mechanisms as it just forwards /:path into your view, and you have to parse things from there.

esDotDev commented 2 years ago

Multi-Stack API looks like the best idea to me, it does all of the above stuff automatically, and allows you to continue to declare complex paths with GR.

Where it might get a bit weird is the path-matching... maybe we can dig into any issues there. At the very least, it seems hard to explain in a simple way.

johnpryan commented 2 years ago

There are definitely some cases where using a nested Navigator makes sense (see Hans' article for an example). But I wonder if we should recommend configuring the inner Navigator using the page parameter based off of GoRouterState, since that is more declarative.

johnpryan commented 2 years ago

Here's a gist that shows what I mean. The app uses a BottomNavigation bar and an AnimatedSwitcher to set up lateral navigation between two screens. The first screen (LibraryScreen) builds a nested Navigator with the pages parameter. When the route is /, it displays a single page, but when the route is /song/:songId it's songId field is non-null, and an additional page is provided to the inner Navigator to show the SongScreen too:

class LibraryScreen extends StatelessWidget {
  final String? songId;

  const LibraryScreen({
    this.songId,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // Use a local variable to promote to a non-nullable type
    final songId = this.songId;

    return Navigator(
      onPopPage: (route, dynamic result) {
        final didPop = route.didPop(result);
        if (didPop) {
          // The user isn't viewing the song screen anymore. Go to the
          // parent screen.
          GoRouter.of(context).go('/');
        }
        return didPop;
      },
      pages: [
        MaterialPage(
          child: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    'Home Screen',
                    style: Theme.of(context).textTheme.headline4,
                  ),
                  TextButton(
                    onPressed: () {
                      GoRouter.of(context).go('/song/123');
                    },
                    child: const Text('View song 123'),
                  ),
                ],
              ),
            ),
          ),
        ),
        if (songId != null) MaterialPage(child: SongScreen(songId: songId))
      ],
    );
  }
}

The problem is that once the user taps the "recents" tab in the BottomNavigationBar, the previous route might have been /song/1, and there's no obvious way to use go_router to store this state, but I'm not sure if it should.