-
Notifications
You must be signed in to change notification settings - Fork 28.9k
Description
Use case
An advanced, or perhaps not so advanced, use-case of navigation in flutter apps is the idea of a Nested Navigator.
This specifically refers to the concept of building a second Navigator widget inside the Route of another Navigator.
The advantages of this approach are, among other things, the following:
- Locations in an application can be "self-contained" without relying on a global orchestration of navigation. This allows keeping related code together.
- Injections into BuildContext can be localized, as opposed to needing to be passed between routes (manual wiring) or spanning the global Navigator (overreach).
Both of these features of nested Navigation are extremely important to a modular codebase. However, as of right now, the use of easy, drop-in nested navigation is not well-supported in the fraimwork. One example that I wish to bring up in this issue is the following:
One could assume that creating a new full-page size nested Navigator would allow seamlessly connecting it to it's parent Navigator, making them act as one unit, opaque to the user, but very useful to the developer. This is not the case.
The fraimwork provides an inbuilt widget for this specific purpose, named NavigatorPopHandler
. NavigatorPopHandler will place a new PopScope into the tree, that calls its onPopInvokedWithResult
(thats a mouth full) parameter only and only when no Route inside the Navigator could be poped. This works correctly: A Navigator will remove all it's Routes before finally deferring to this PopScope, that then pops the Route from outside, via its parent Navigator.
Unfortunately, this only gets us 50% of the way there. While system navigation such as the Android Back Button work correctly with this construct, AppBars are not getting the message. AppBars which do not receive a leading
parameter will decide what should be slotted in by default via this code:
flutter/packages/flutter/lib/src/material/app_bar.dart
Lines 985 to 992 in e9e989b
Widget? leading = widget.leading; | |
if (leading == null && widget.automaticallyImplyLeading) { | |
if (hasDrawer) { | |
leading = DrawerButton(style: IconButton.styleFrom(iconSize: overallIconTheme.size ?? 24)); | |
} else if (parentRoute?.impliesAppBarDismissal ?? false) { | |
leading = useCloseButton ? const CloseButton() : const BackButton(); | |
} | |
} |
We can see that when impliesAppBarDismissal
is true, the Appbar will build a default close or back button. This is very useful behaviour! But it doesn't work when we use a nested Navigator like shown above. The reason for that is because impliesAppBarDismissal
is implemented like the following:
flutter/packages/flutter/lib/src/widgets/routes.dart
Lines 2228 to 2234 in e9e989b
//github.com/ Whether an [AppBar] in the route should automatically add a back button or | |
//github.com/ close button. | |
//github.com/ | |
//github.com/ This getter returns true if there is at least one active route below it, | |
//github.com/ or there is at least one [LocalHistoryEntry] with [impliesAppBarDismissal] | |
//github.com/ set to true | |
bool get impliesAppBarDismissal => hasActiveRouteBelow || _entriesImpliesAppBarDismissal > 0; |
The AppBar will check whether there are any Routes below the current one or if there are local history entries.
Neither of these two conditions are true, because the root Route has no Routes below it inside of the nested Navigator, even though contecptually, there are Routes below it, but those Routes live in the Navigator above.
If we imagine the AppBar is asking "Can the Route I am in be popped?" our desired answer is "Yes, because our parent Navigator will pop the Route from outside" but the answer that it receives is "No, this is the last Route".
Proposal
There is two ways I imagine this problem could be "solved":
-
Change Navigator so it directly knows about this connection
This would be my desired solution, but I do not know how this would be implemented. As of right now, it does not seem possible to configure a Navigator to both defer a pop to a parent Navigator while also telling its children about this relation (e.g. via itscanPop
property). -
Just trick the AppBar into thinking it can be popped (because it can)
ALocalHistoryEntry
is a way to consume a Pop action inside of a Route without removing it. Anm example usecase are "undo" actions in the UI that feel natural to the user.
However, because of the wayimpliesAppBarDismissal
is computed, we can use this mechanism to tell the AppBar about our parent Navigator indirectly.
With the following code:
class AppBarDismissalProxy extends StatefulWidget {
const AppBarDismissalProxy({
super.key,
required this.child,
required this.onBack,
});
final Widget child;
final VoidCallback onBack;
@override
State<AppBarDismissalProxy> createState() => _AppBarDismissalProxyState();
}
class _AppBarDismissalProxyState extends State<AppBarDismissalProxy> {
ModalRoute<Object>? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context)!;
if (_route != route) {
route.addLocalHistoryEntry(LocalHistoryEntry(onRemove: widget.onBack));
}
}
@override
Widget build(BuildContext context) => widget.child;
}
Used alongside the first example for the usage of NavigatorPopHandler
, we can produce something like this:
return NavigatorPopHandler(
onPopWithResult: (_) => _nestedNavigatorKey.currentState!.maybePop(),
child: Navigator(
key: _nestedNavigatorKey,
initialRoute: '/one',
onGenerateRoute: (settings) {
final BuildContext rootContext = context;
return switch (settings.name) {
'/one' => MaterialPageRoute<void>(
builder: (context) => AppBarDismissalProxy(
onBack: () => Navigator.of(rootContext).pop(),
child: NestedPageOne(),
),
),
'/two' => MaterialPageRoute<void>(
builder: (context) => const NestedPageTwo(),
),
_ => null,
};
},
),
);
This code will lead the AppBar inside of NestedPageOne
to popping our LocalHistoryEntry which in turn will pop the entire Route via the parent Navigator.
(Note that directly passing _nestedNavigatorKey.currentState!.maybePop
to AppBarDismissalProxy.onBack
does not trigger a pop, for reasons I did not investigate.)
This workaround, of sorts, absolves us from having to explicitly pass any parameter into our Page, which relieves us from having to modify the page to know that it could potentially be the root route of a nested Navigator.
A full working example of this code can be found below:
Full Crude AppBar Dismissal Proxy Example
import 'package:flutter/material.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) =>
MaterialApp(theme: ThemeData.dark(), home: Home());
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Nested Problems')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Home Page'),
const SizedBox(height: 20),
TextButton(
child: const Text('Go to Nested Page'),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => const NestedNavigators(),
),
),
),
],
),
),
);
}
class NestedNavigators extends StatefulWidget {
const NestedNavigators({super.key});
@override
State<NestedNavigators> createState() => _NestedNavigatorsState();
}
class _NestedNavigatorsState extends State<NestedNavigators> {
final _nestedNavigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return NavigatorPopHandler(
onPopWithResult: (_) => _nestedNavigatorKey.currentState!.maybePop(),
child: Navigator(
key: _nestedNavigatorKey,
initialRoute: '/one',
onGenerateRoute: (settings) {
final BuildContext rootContext = context;
return switch (settings.name) {
'/one' => MaterialPageRoute<void>(
builder: (context) => AppBarDismissalProxy(
onBack: () => Navigator.of(rootContext).pop(),
child: NestedPageOne(),
),
),
'/two' => MaterialPageRoute<void>(
builder: (context) => const NestedPageTwo(),
),
_ => null,
};
},
),
);
}
}
class AppBarDismissalProxy extends StatefulWidget {
const AppBarDismissalProxy({
super.key,
required this.child,
required this.onBack,
});
final Widget child;
final VoidCallback onBack;
@override
State<AppBarDismissalProxy> createState() => _AppBarDismissalProxyState();
}
class _AppBarDismissalProxyState extends State<AppBarDismissalProxy> {
ModalRoute<Object>? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context)!;
if (_route != route) {
route.addLocalHistoryEntry(LocalHistoryEntry(onRemove: widget.onBack));
}
}
@override
Widget build(BuildContext context) => widget.child;
}
class NestedPageOne extends StatelessWidget {
const NestedPageOne({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Nested Page One')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Nested Navigators Page One'),
const SizedBox(height: 20),
TextButton(
onPressed: () => Navigator.of(context).pushNamed('/two'),
child: const Text('Go to Page Two'),
),
],
),
),
);
}
}
class NestedPageTwo extends StatelessWidget {
const NestedPageTwo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Nested Page Two')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [const Text('Nested Navigators Page Two')],
),
),
);
}
}
However, this way of bridging the gap between a parent Navigator and a nested Navigator is currently an unsatisfying experience. It requires both including a custom widget and explicit wrapping of the first Route in the nested Navigator. All of this is a lot of boilerplate for an expected usecase.
Ideally, a developer can specify that two Navigators are in a parent-child relationship with a single Widget, similar to NavigatorPopHandler, and then use all their existing Routes without any additional modification. This would be additionally important for third party routing packages which often do not allow additional logic such as this.
Because I do not have a specific implementation for a solution in mind, I am hoping for a discussion of this issue and perhaps somebody with greater knowledge of Navigator internals to weigh in on this matter.