lulupointu / vrouter

MIT License
202 stars 39 forks source link

customValue support for VNester and route elements #176

Open klondikedragon opened 2 years ago

klondikedragon commented 2 years ago

This enhancement is motivated by the code example here from #32 to use VNester and sync bottom nav bar and nested tab bar with routes while preserving state.

While the code example from the linked comment above works, you have to repeat the whole VNester and the widgetBuilder (only varying the currentIndex in the constructor to the widget). This gets pretty cumbersome if you have more complex nested routes with VGuard or as the nesting level goes 3-deep in a more complex app.

The reason why the whole VNester has to be repeated in the example is that there is no way to associate with the child widget the tab index (even a mixin to Widget won't work, as the actual child widget is wrapped in a Builder and cannot be accessed directly from the child parameter passed to the widgetBuilder function).

This PR adds the concept of an optional customValue dynamic value to VRouteElement, and then an optional second type of widgetBuilder that will take (child, customValue) instead of just (child). VNester is enhanced to then search for and provide the "nearest" customValue on the matched route to the child to the widgetBuilder (logic to do this only happens if widgetBuilder has the new type -- the old type is still accepted for backwards compatibility as well). This simplifies the code example to the following:

Show modified "advanced" example that uses `customValue` ```dart import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; void main() { runApp( VRouter( debugShowCheckedModeBanner: false, routes: [ VNester( path: '/', widgetBuilder: (child, customValue) => MyScaffold(child, currentIndex: customValue as int), nestedRoutes: [ VWidget( path: null, key: ValueKey('Home'), customValue: 0, widget: HomeScreen(), stackedRoutes: [ VNester( path: null, widgetBuilder: (child, customValue) => MyTabs(child, currentIndex: customValue as int), nestedRoutes: [ VWidget( path: 'red', customValue: 0, widget: ColorScreen(color: Colors.redAccent, title: 'Red')), VWidget( path: 'green', customValue: 1, widget: ColorScreen( color: Colors.greenAccent, title: 'Green')), ], ), ], ), VWidget( path: 'profile', customValue: 1, widget: ProfileScreen(), stackedRoutes: [ VWidget(path: 'settings', widget: SettingsScreen()) ], ), ], ), ], ), ); } class BaseWidget extends StatefulWidget { final String title; final String buttonText; final String to; BaseWidget({required this.title, required this.buttonText, required this.to}); @override _BaseWidgetState createState() => _BaseWidgetState(); } class _BaseWidgetState extends State { bool isChecked = false; @override Widget build(BuildContext context) { return Material( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(widget.title), SizedBox(height: 50), ElevatedButton( onPressed: () => context.vRouter.to(widget.to), child: Text(widget.buttonText), ), SizedBox(height: 50), Checkbox( value: isChecked, onChanged: (value) => setState(() => isChecked = value ?? false), ), ], ), ), ); } } class MyScaffold extends StatefulWidget { final Widget child; final int currentIndex; const MyScaffold(this.child, {required this.currentIndex}); @override _MyScaffoldState createState() => _MyScaffoldState(); } class _MyScaffoldState extends State { List tabs = [Container(), Container()]; List tabsLastVisitedUrls = ['/', '/profile']; @override Widget build(BuildContext context) { // Populate the tabs when needed tabs[widget.currentIndex] = widget.child; // Populate tabs last visited url tabsLastVisitedUrls[widget.currentIndex] = context.vRouter.url; return Scaffold( body: IndexedStack( index: widget.currentIndex, children: tabs, ), bottomNavigationBar: BottomNavigationBar( currentIndex: widget.currentIndex, onTap: (value) => context.vRouter.to(tabsLastVisitedUrls[value]), items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Profile'), ], ), ); } } class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BaseWidget( title: 'Home', buttonText: 'Go to Color Tabs', to: '/red'); } } class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BaseWidget(title: 'Settings', buttonText: 'Pop', to: '/profile'); } } class ProfileScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BaseWidget( title: 'Profile', buttonText: 'Go to Settings', to: '/profile/settings'); } } class MyTabs extends StatefulWidget { final Widget child; final int currentIndex; const MyTabs(this.child, {required this.currentIndex}); @override _MyTabsState createState() => _MyTabsState(); } class _MyTabsState extends State with SingleTickerProviderStateMixin { late final tabController = TabController( initialIndex: widget.currentIndex, length: tabs.length, vsync: this, ); // We use this as the index to easily fetch the new widget when in comes into view int get tabControllerIndex => tabController.index + tabController.offset.sign.toInt(); List tabs = [Container(), Container()]; @override Widget build(BuildContext context) { // Sync the tabController with the url if (!tabController.indexIsChanging && tabControllerIndex != widget.currentIndex) tabController.animateTo(widget.currentIndex); // Populate the tabs when needed tabs[widget.currentIndex] = widget.child; tabs = List.from(tabs); // Needed so that TabBarView updates its children return Scaffold( appBar: AppBar( title: const Text('Colors'), bottom: TabBar( controller: tabController, tabs: const [Tab(text: 'Red'), Tab(text: 'Green')], ), ), body: NotificationListener( onNotification: (_) { // Syncs the url with the tabController if (tabControllerIndex != widget.currentIndex) context.vRouter.to(tabControllerIndex == 0 ? '/red' : '/green'); return false; }, child: TabBarView( controller: tabController, children: tabs, ), ), ); } } class ColorScreen extends StatefulWidget { final Color color; final String title; const ColorScreen({required this.color, required this.title}); @override _ColorScreenState createState() => _ColorScreenState(); } class _ColorScreenState extends State with AutomaticKeepAliveClientMixin { bool isChecked = false; @override Widget build(BuildContext context) { super.build(context); return Container( color: widget.color, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(widget.title), SizedBox(height: 50), ElevatedButton( onPressed: () => context.vRouter.to('/'), child: Text('Pop'), ), SizedBox(height: 50), Checkbox( value: isChecked, onChanged: (value) => setState(() => isChecked = value ?? false), ), ], ), ), ); } @override bool get wantKeepAlive => true; } ```

However, it is causing an issue where after navigating back and forth between different nested routes now that there is only a single VNester, flutter (2.8.0) throws the error:

════════ Exception caught by widgets library ═══════════════════════════════════
Duplicate GlobalKey detected in widget tree.
════════════════════════════════════════════════════════════════════════════════

(Note that there is a RenderIndexedStack exception getting thrown sometimes too, but that wasn't happening in the full-scale app, it was just the Duplicate GlobalKey exception that was occurring whenever changing the route to a different tab at the same "nesting level" as the current route.)

And inspecting the debug label of the GlobalKey, it is the global key of the top-most level VNester. Using multiple VNesters like the original example (even with customValue) doesn't have this problem, so it doesn't seem like the customValue changes cause this directly, but it might be something unexpected with how this is working now that only a single VNester is needed at each level. I'm not a flutter expert (yet!), and so was hoping @lulupointu you might have some insights why this might be happening / how to fix it.

I'd also love your feedback on the general direction of this PR as well! At first I tried to add a custom value chain (similar to name) in VRoute, but this was complex and also was difficult to know exactly which customValue should be passed to widgetBuilder (especially when there are multiple nested VNester). This PR is the second attempt that is simpler where it just searches the element list (which does have only the list of child route elements at the point of VNester, so it was both simpler and worked with all levels of nesting).

In the meantime, I made a custom VRouteElementBuilder that generates the multiple VNester (one for each tab) so that it removes boilerplate / duplicate code, and still works with the current vRouter as released. In case it's useful to anyone:

Show code for TabbedNester ```dart class TabbedNesterEntry { final List nestedRoutes; final List stackedRoutes; TabbedNesterEntry( this.nestedRoutes, { this.stackedRoutes = const [], }); } class TabbedNester extends VRouteElementBuilder { final String? path; final Widget Function(Widget child, int currentTabIndex) widgetBuilder; /// The route(s) that should be associated with each tab entry. /// Each tab has one entry in this list. /// Each entry in this list is either a single VRouteElement, /// a list of VRouteElements (becomes nestedRoutes in the VNester /// specifically for this tab), or a TabbedNesterEntry (allowing full /// specification of both the nestedRoutes and stackedRoutes). final List entries; /// Set the alias to be the same as the path if you want to avoid /// animating between tabs within this route. final List aliases; TabbedNester({ required this.path, required this.widgetBuilder, required this.entries, this.aliases = const [], }); @override List buildRoutes() { var routes = []; for (int i = 0; i < entries.length; i++) { var nestedRoutes = const []; var stackedRoutes = const []; if (entries[i] is TabbedNesterEntry) { var e = entries[i] as TabbedNesterEntry; nestedRoutes = e.nestedRoutes; stackedRoutes = e.stackedRoutes; } else if (entries[i] is List) { nestedRoutes = entries[i] as List; } else if (entries[i] is VRouteElement) { nestedRoutes = [entries[i] as VRouteElement]; } else { throw UnsupportedError("Unknown type of TabbedNester entry #$i"); } routes.add(VNester( path: path, widgetBuilder: (child) => widgetBuilder(child, i), nestedRoutes: nestedRoutes, stackedRoutes: stackedRoutes, )); } return routes; } } ```
tannermeade commented 2 years ago

I'm having this exact problem and I'm grateful there is some serious work to improve it.

tannermeade commented 2 years ago

I believe this might have something to do with this comment? https://github.com/lulupointu/vrouter/issues/77#issuecomment-851869374