flutter / flutter

Flutter makes it easy and fast to build beautiful apps for mobile and beyond
https://flutter.dev
BSD 3-Clause "New" or "Revised" License
165.71k stars 27.36k forks source link

[go_router] Allow multiple GoRouters #130614

Open clragon opened 1 year ago

clragon commented 1 year ago

Is there an existing issue for this?

Use case

My app works more like a Web Browser than a single Web Page. As such, I would like to use mutliple Routers in my App, one for each "tab". Each tab then has its own route, completely independant of other tabs.

GoRouter would work great for this, but its always connected to the global platform route itself.

Conceptually, Routers are allowed to be arbitrarily nested and can be disconnected from the platforms route to maintain their own route instead, as per documentation.

Proposal

It would be nice if it was possible to disconnect a GoRouter from the platform route. With this, it would be possible to maintain multiple GoRouters throughout an app, which would be a great addition.

clragon commented 1 year ago

As far as I have understood that, the Route information is communicated by the RouteInformationProvider. To create a GoRouter that is disconnected from the global platform route, we would need a different version of the GoRouteInformationProvider that does not listen to WidgetsBindingObserver.

The RouteInformationProvider would then simply maintain its Route in memory. An example of an extremely crude version of this would look something like this:

class ValueRouteInformationProvider extends ValueNotifier<RouteInformation>
    implements RouteInformationProvider {
  ValueRouteInformationProvider({
    required String initialRoute,
  }) : super(RouteInformation(location: initialRoute));

  @override
  void routerReportsNewRouteInformation(RouteInformation routeInformation,
      {RouteInformationReportingType type =
          RouteInformationReportingType.none}) {}
}
clragon commented 1 year ago

To demonstrate what I mean, I have written a small piece of sample code with the Beamer package, which already supports this, as it does not come with its own RouteInformationProvider.

For other reasons, I would like to use GoRouter over Beamer.

Code ```dart import 'package:beamer/beamer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; class ValueRouteInformationProvider extends ValueNotifier implements RouteInformationProvider { ValueRouteInformationProvider({ required String initialRoute, }) : super(RouteInformation(location: initialRoute)); @override void routerReportsNewRouteInformation(RouteInformation routeInformation, {RouteInformationReportingType type = RouteInformationReportingType.none}) {} } void main() { usePathUrlStrategy(); runApp(const App()); } class App extends StatefulWidget { const App({super.key}); @override State createState() => _AppState(); } class _AppState extends State { final LocationBuilder locationBuilder = RoutesLocationBuilder( routes: { '/': (context, state, data) => const AppPage( title: 'Home', paths: [ '/1', '/2', '/3', ], ), RegExp(r'/(?\d+)'): (context, state, data) => AppPage( title: 'Home > ${state.pathParameters['id']}', ), }, ); late final routerDelegate1 = BeamerDelegate(locationBuilder: locationBuilder); late final routerDelegate2 = BeamerDelegate(locationBuilder: locationBuilder); RouteInformationProvider provider1 = PlatformRouteInformationProvider( initialRouteInformation: const RouteInformation(location: '/')); RouteInformationProvider provider2 = ValueRouteInformationProvider(initialRoute: '/'); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, brightness: Brightness.dark, ), useMaterial3: true, ), home: Material( child: DefaultTabController( length: 2, child: Column( children: [ const TabBar(tabs: [ Tab(text: 'Platform'), Tab(text: 'Virtual'), ]), Expanded( child: TabBarView( children: [ Router( key: const ValueKey('Platform'), routeInformationParser: BeamerParser(), routerDelegate: routerDelegate1, routeInformationProvider: provider1, ), Router( key: const ValueKey('Virtual'), routeInformationParser: BeamerParser(), routerDelegate: routerDelegate2, routeInformationProvider: provider2, ), ], ), ), ], ), ), ), ); } } class AppPage extends StatelessWidget { const AppPage({ super.key, this.title, this.paths, }); final String? title; final List? paths; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title!, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 32), if (paths != null) Wrap( spacing: 8, children: paths! .map( (e) => ActionChip( onPressed: () => Beamer.of(context).beamToNamed(e), label: Text(e), ), ) .toList(), ), ], ), ), ); } } ```

Video

https://github.com/flutter/flutter/assets/11785085/32c7b728-c740-4e3c-8a2d-3d3b7d9cbdb1

Note how the URL of my browser changes in the Platform tab, but not in the Virtual tab. In both situations, the same router configuration is used, except for the RouteInformationProvider.

clragon commented 1 year ago

As a temporary solution, I have also tried simply ignoring the GoRouteInformationProvider like in this code snippet, which is currently not functional due to a runtime error:

Code ```dart import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:go_router/go_router.dart'; class ValueRouteInformationProvider extends ValueNotifier implements RouteInformationProvider { ValueRouteInformationProvider({ required String initialRoute, }) : super(RouteInformation(location: initialRoute)); @override void routerReportsNewRouteInformation(RouteInformation routeInformation, {RouteInformationReportingType type = RouteInformationReportingType.none}) {} } void main() { usePathUrlStrategy(); runApp(const App()); } class App extends StatefulWidget { const App({super.key}); @override State createState() => _AppState(); } class _AppState extends State { final routes = [ GoRoute( path: '/', builder: (context, state) => const AppPage( title: 'Home', paths: [ '/1', '/2', '/3', ], ), routes: [ GoRoute( path: r':id(\d+)', builder: (context, state) => AppPage( title: 'Home > ${state.pathParameters['id']}', ), ) ], ), ]; late final router1 = GoRouter(routes: routes); late final router2 = GoRouter(routes: routes); RouteInformationProvider provider1 = PlatformRouteInformationProvider( initialRouteInformation: const RouteInformation(location: '/')); RouteInformationProvider provider2 = ValueRouteInformationProvider(initialRoute: '/'); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, brightness: Brightness.dark, ), useMaterial3: true, ), home: Material( child: DefaultTabController( length: 2, child: Column( children: [ const TabBar(tabs: [ Tab(text: 'Platform'), Tab(text: 'Virtual'), ]), Expanded( child: TabBarView(children: [ Router( routeInformationParser: router1.routeInformationParser, routerDelegate: router1.routerDelegate, routeInformationProvider: provider1, ), Router( routeInformationParser: router2.routeInformationParser, routerDelegate: router2.routerDelegate, routeInformationProvider: provider2, ), ]), ), ], ), ), ), ); } } class AppPage extends StatelessWidget { const AppPage({ super.key, this.title, this.paths, }); final String? title; final List? paths; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title!, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 32), if (paths != null) Wrap( spacing: 8, children: paths! .map( (e) => ActionChip( onPressed: () => GoRouter.of(context).go(e), label: Text(e), ), ) .toList(), ), ], ), ), ); } } ```

This then throws the following:

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building KeyedSubtree-[<0>]:
Assertion failed:
file:///<omitted>/Pub/Cache/hosted/pub.dev/go_router-9.0.3/lib/src/parser.dart:64:12
routeInformation.state != null
is not true
The relevant error-causing widget was:
TabBarView
When the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 288:49  throw_
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 29:3    assertFailed
packages/go_router/src/parser.dart 64:28                                      parseRouteInformationWithDependencies
packages/flutter/src/widgets/router.dart 704:12                               [_processRouteInformation]
packages/flutter/src/widgets/router.dart 583:7                                restoreState
packages/flutter/src/widgets/restoration.dart 912:5                           [_doRestore]
packages/flutter/src/widgets/restoration.dart 898:7                           didChangeDependencies
packages/flutter/src/widgets/router.dart 653:11                               didChangeDependencies
packages/flutter/src/widgets/framework.dart 5237:11                           [_firstBuild]

image

Which I assume is because GoRouter expects its RouteInformationParser to specifically be a GoRouteInformationProvider that has a certain behaviour.

clragon commented 1 year ago

I have done some extra research, and it is purely technically already possible to create such a GoRouter without modifying the package, by copying some of the source code and extending it. The following code:

Code ```dart import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:go_router/go_router.dart'; class CrookedGoRouteInformationProvider extends GoRouteInformationProvider { CrookedGoRouteInformationProvider({ required super.initialLocation, required super.initialExtra, super.refreshListenable, }); @override void routerReportsNewRouteInformation(RouteInformation routeInformation, {RouteInformationReportingType type = RouteInformationReportingType.none}) {} @override Future didPushRouteInformation( RouteInformation routeInformation) async => true; @override Future didPushRoute(String route) async => true; } class CrookedGoRouter extends GoRouter { CrookedGoRouter({ required super.routes, super.onException, super.errorPageBuilder, super.errorBuilder, super.redirect, this.refreshListenable, super.redirectLimit = 5, super.routerNeglect = false, this.initialLocation, this.initialExtra, super.observers, super.debugLogDiagnostics = false, super.navigatorKey, super.restorationScopeId, }) : super( initialLocation: initialLocation, initialExtra: initialExtra, refreshListenable: refreshListenable, ); final String? initialLocation; final Object? initialExtra; final Listenable? refreshListenable; late final GoRouteInformationProvider _routeInformationProvider = CrookedGoRouteInformationProvider( initialLocation: initialLocation ?? '/', initialExtra: initialExtra, refreshListenable: refreshListenable, ); @override set routeInformationProvider(RouteInformationProvider value) {} @override GoRouteInformationProvider get routeInformationProvider => _routeInformationProvider; } void main() { usePathUrlStrategy(); runApp(const App()); } class App extends StatefulWidget { const App({super.key}); @override State createState() => _AppState(); } class _AppState extends State { final routes = [ GoRoute( path: '/', builder: (context, state) => const AppPage( title: 'Home', paths: [ '/1', '/2', '/3', ], ), routes: [ GoRoute( path: r':id(\d+)', builder: (context, state) => AppPage( title: 'Home > ${state.pathParameters['id']}', ), ) ], ), ]; late final router1 = GoRouter(routes: routes); late final router2 = CrookedGoRouter(routes: routes); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, brightness: Brightness.dark, ), useMaterial3: true, ), home: Material( child: DefaultTabController( length: 2, child: Column( children: [ const TabBar(tabs: [ Tab(text: 'Platform'), Tab(text: 'Virtual'), ]), Expanded( child: TabBarView(children: [ Router.withConfig( config: router1, ), Router.withConfig( config: router2, ), ]), ), ], ), ), ), ); } } class AppPage extends StatelessWidget { const AppPage({ super.key, this.title, this.paths, }); final String? title; final List? paths; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title!, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 32), if (paths != null) Wrap( spacing: 8, children: paths! .map( (e) => ActionChip( onPressed: () => GoRouter.of(context).go(e), label: Text(e), ), ) .toList(), ), ], ), ), ); } } ```

This works by removing the links between WidgetsBinding and the GoRouteInformationProvider and GoRouter by making all communication a no-op. This demonstrates the behaviour I would like very clearly. I dont think however that a destructive override from the outside like this would be a nice solution and I would like a solution to be available inside the package, as proposed originally.

noga-dev commented 1 year ago

ShellRoute?

clragon commented 1 year ago

ShellRoute is used to draw UI elements around certain paths. It's functionality is not equivalent to having a separate router with it's entirely own path.

chunhtai commented 1 year ago

I can think of several way to do this.

  1. https://github.com/flutter/packages/pull/4502, this pr allows for substitutions of GoRouteInformationProvider. Once it is landed you can plug in a non-platform version of the GoRouteInformationProvider.
  2. Add a flag to GoRouter constructor to not hook up the platform channel.
DMouayad commented 1 year ago

hey👋🏻 @clragon so i have a similar usecase where i'm implementing a browser-like tabs feature. thankfully i got it working using a StatefulShellBranch for each tab. (ofc the router has to be updated at runtime). Now my question is: does using a separate router has a performance advantage?? or it's just a better way to manage each tab(router) history. PS: i haven't got the chance YET to dig deeper into how things work under the hood. so i just wanted to know if there's a reason u went with this design choice. ✌

chutuananh2k commented 6 months ago

@DMouayad Hi, Would you mind to share an example of how to implement dynamic browser-like tabs navigation using StatefulShellBranch?