Nested Navigation
Sometimes you want to choose a page based on a route as well as the state of
that screen, e.g. the currently selected tab. In that case, you want to choose
not just the screen from a route but also the widgets nested inside the screen.
That's called "nested navigation". The key differentiator for "nested"
navigation is that there's no transition on the part of the page that stays the
same, e.g. the app bar stays the same as you navigate to different tabs on this
TabBarView
:
Of course, you can easily do this using the TabBarView
widget, but what makes
this nested "navigation" is that the location changes, i.e. notice the address
bar as the user transitions from tab to tab. This makes it easy for the user to
capture a dynamic link for any object in the
app, enabling deep linking.
To use nested navigation using go_router, you can simply navigate to the same
page via different paths or to the same path with different parameters, with the
differences dictating the different state of the page. For example, to implement
that screen with the TabBarView
above, you need a widget that changes the
selected tab via a parameter:
class FamilyTabsScreen extends StatefulWidget {
final int index;
FamilyTabsScreen({required Family currentFamily, Key? key})
: index = Families.data.indexWhere((f) => f.id == currentFamily.id),
super(key: key) {
assert(index != -1);
}
@override
_FamilyTabsScreenState createState() => _FamilyTabsScreenState();
}
class _FamilyTabsScreenState extends State<FamilyTabsScreen>
with TickerProviderStateMixin {
late final TabController _controller;
@override
void initState() {
super.initState();
_controller = TabController(
length: Families.data.length,
vsync: this,
initialIndex: widget.index,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(FamilyTabsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
_controller.index = widget.index;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(_title(context)),
bottom: TabBar(
controller: _controller,
tabs: [for (final f in Families.data) Tab(text: f.name)],
onTap: (index) => _tap(context, index),
),
),
body: TabBarView(
controller: _controller,
children: [for (final f in Families.data) FamilyView(family: f)],
),
);
void _tap(BuildContext context, int index) =>
context.go('/family/${Families.data[index].id}');
String _title(BuildContext context) =>
(context as Element).findAncestorWidgetOfExactType<MaterialApp>()!.title;
}
The FamilyTabsScreen
is a stateful widget that takes the currently selected
family as a parameter. It uses the index of that family in the list of families
to set the currently selected tab. However, instead of switching the currently
selected tab to whatever the user clicks on, it uses navigation to get to that
index instead. It's the use of navigation that changes the address in the
address bar. And, the way that the tab index is switched is via the call to
didUpdateWidget
. Because the FamilyTabsScreen
is a stateful widget, the
widget itself can be changed but the state is kept. When that happens, the call
to didUpdateWidget
will change the index of the TabController
to match the
new navigation location.
To implement the navigation part of this example, we need a route that
translates the location into an instance of FamilyTabsScreen
parameterized
with the currently selected family:
final _router = GoRouter(
routes: [
GoRoute(
path: '/',
redirect: (_) => '/family/${Families.data[0].id}',
),
GoRoute(
path: '/family/:fid',
builder: (context, state) {
final fid = state.params['fid']!;
final family = Families.data.firstWhere((f) => f.id == fid,
orElse: () => throw Exception('family not found: $fid'));
return FamilyTabsScreen(key: state.pageKey, currentFamily: family);
},
),
],
);
The /
route is a redirect to the first family. The /family/:fid
route is the
one that sets up nested navigation. It does this by first creating an
instance of FamilyTabsScreen
with the family that matches the fid
parameter.
And second, it uses state.pageKey
to signal to Flutter that this is the same
page as before. This combination is what causes the router to leave the page
alone, to update the browser's address bar and to let the TabBarView
navigate to
the new selection.
This may seem like a lot, but in summary, you need to do two things with the page you create in your page builder to support nested navigation:
-
Use a
StatefulWidget
as the base class of your screen widget. -
As the user navigates, you'll create the same
StatefulWidget
-derived type, passing in new data, e.g. which tab is currently selected. Because you're using a widget with the same key, Flutter will keep the state but swap out the widget wrapping w/ the new data as constructor args. When that new widget wrapper is in place, Flutter will calldidUpdateWidget
so that you can use the new data to update the existing widgets, e.g. the selected tab.
This example shows off the selected tab on a TabBarView
but you can use it for
any nested content of a page your app navigates to.
Keeping State
When doing nested navigation, the user expects that widgets will be in the same
state that they left them in when they navigated to a new page and return, e.g.
scroll position, text input values, etc. You can enable support for this by
using AutomaticKeepAliveClientMixin
on a stateful widget. You can see this in
action in the FamilyView
of the
nested_nav.dart
example:
class FamilyView extends StatefulWidget {
const FamilyView({required this.family, Key? key}) : super(key: key);
final Family family;
@override
State<FamilyView> createState() => _FamilyViewState();
}
/// Use the [AutomaticKeepAliveClientMixin] to keep the state.
class _FamilyViewState extends State<FamilyView>
with AutomaticKeepAliveClientMixin {
// Override `wantKeepAlive` when using `AutomaticKeepAliveClientMixin`.
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
// Call `super.build` when using `AutomaticKeepAliveClientMixin`.
super.build(context);
return ListView(
children: [
for (final p in widget.family.people)
ListTile(
title: Text(p.name),
onTap: () =>
context.go('/family/${widget.family.id}/person/${p.id}'),
),
],
);
}
}
To instruct the AutomaticKeepAliveClientMixin
to keep the state, you need to
override wantKeepAlive
to return true
and call super.build
in the State
class's build
method, as show above.
Notice that after scrolling to the bottom of the long list of children in the Hunting family, then going to another tab and then going to another page, when you return to the list of Huntings that the scroll position is maintained.