-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Automatically handle back gesture in nested Navigators #152330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
It looks like this pull request may not have tests. Please make sure to add tests before merging. If you need an exemption to this rule, contact "@test-exemption-reviewer" in the #hackers channel in Chat (don't just cc them here, they won't see it! Use Discord!). If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix? Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
ae6a6e8 to
09c5b70
Compare
… active The tabs are not managed by routes, they are just shown and hidden, so this widget needs special custom handling of backs. This is a good example of when we can't do this automatically.
They don't need to do PopScope themselves anymore.
…ons do work Red herring, it only happens in complex interleaving scenarios, and that is a bug in master too.
|
@maRci002 I'm interested in your thoughts here if you have a chance to take a look. |
|
|
||
| // If this is a nested Navigator, handle system backs here so that the root | ||
| // Navigator doesn't get all of them. | ||
| if (widget.handlesBacksWhenNested && Navigator.maybeOf(context, rootNavigator: true) != this) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Navigator.maybeOf may be costly in this situation since this will walk ancestor chain every rebuild, I will probably put the check in didChangeDependencies
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
packages/flutter/lib/src/material/predictive_back_page_transitions_builder.dart
Outdated
Show resolved
Hide resolved
| // Manually use a NavigatorPopHandler to handle backs only on the active | ||
| // tab, instead of letting Navigator use its default back handling for | ||
| // nested Navigators. | ||
| handlesBacksWhenNested: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is unfortunate. if there are multiple parallel navigators, what decide which one receives the pop?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See this test for how tabbed navigation is typically done here:
| testWidgets('System back navigation inside of tabs', (WidgetTester tester) async { |
Each tab has its own nested Navigator wrapped by NavigatorPopHandler. Only the single active tab's NavigatorPopHandler is enabled though, so it's the only one that receives a call to onPop.
Or for the more generic case, where you have 2 sibling Navigators each wrapped with a PopScope, and both nested under a root Navigator? Both will be called. I think that's probably what we want.
| void onPopInvokedWithResult(bool didPop, T? result) { |
But for the even more generic case where you have two WidgetsBindingObservers listening for didPopRoute, only the one that was registered first will be called. I think that's very arbitrary and probably a mistake. It would probably solve a lot of the complexity with PopScope etc. if it was able to just call it on the current route or something.
| Future<bool> handlePopRoute() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chunhtai I wonder what you think about this handlesBacksWhenNested parameter versus scrapping this PR and adding NavigatorPopHandler to go_router?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the current approach of adding this parameter may be easier for developer
|
Here are some contexts for managing the back button in Flutter:
Currently, we can delegate the back button to the appropriate I am unsure whether the current solution is capable of determining which
I believe the approach is good and we shouldn't break it. We might need a flag on the flutter/packages/flutter/lib/src/material/predictive_back_page_transitions_builder.dart Lines 88 to 92 in b0850be
|
…ions_builder.dart Co-authored-by: chunhtai <[email protected]>
|
@maRci002 What would that flag do? This PR was not attempting to fix the problem with predictive back route transitions. I'm just realizing that this PR does not fix the problem with ShellRoute either! Because go_router does not have a root Navigator. I need to reproduce that problem with just MaterialApp.router and no go_router so I fully understand it. |
Possibly avoiding weird situations where a nested Navigator is above the WidgetsApp.
|
I have a fix for the Google test failures, and flutter/packages#9856 is a fix for the go_router failures. Example for go_routerimport 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shellNavigatorKeyA =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shellNavigatorKeyB =
GlobalKey<NavigatorState>();
late final List<RouteBase> routes;
late final GoRouter goRouter;
@override
void initState() {
super.initState();
routes = <RouteBase>[
ShellRoute(
navigatorKey: shellNavigatorKeyA,
builder: (BuildContext context, GoRouterState state, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('Shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/a',
builder: (BuildContext context, GoRouterState state) {
return Scaffold(
body: Column(
children: <Widget>[
Text('Screen A'),
TextButton(
onPressed: () {
context.push('/a/b');
},
child: const Text('Next'),
),
],
),
);
},
routes: <RouteBase>[
ShellRoute(
navigatorKey: shellNavigatorKeyB,
builder:
(BuildContext context, GoRouterState state, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('Shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: 'b',
parentNavigatorKey: shellNavigatorKeyB,
builder: (BuildContext context, GoRouterState state) {
return Scaffold(
body: Column(
children: <Widget>[
Text('Screen B'),
TextButton(
onPressed: () {
context.push('/a/c');
},
child: const Text('Next'),
),
],
),
);
},
),
GoRoute(
path: 'c',
parentNavigatorKey: shellNavigatorKeyB,
builder: (BuildContext context, GoRouterState state) {
return Scaffold(
body: Column(
children: <Widget>[
Text('Screen C'),
child: const Text('Next'),
),
*/
],
),
);
},
),
],
),
],
),
],
),
];
goRouter = GoRouter(
initialLocation: '/a/b',
navigatorKey: rootNavigatorKey,
requestFocus: true,
overridePlatformDefaultLocation: false,
redirectLimit: 5,
restorationScopeId: null,
routes: routes,
);
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: goRouter,
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
},
),
),
);
}
} |
This test was failing in flutter/flutter#152330. That PR adds a PopScope inside of nested Navigators. When a back gesture happens, the PopScope takes 1 frame to rerender and update the state of its pop handling. The test was breaking because it performed two back gestures in one frame. Putting a pump in between them fixes it when the PR is merged (and works fine without the PR too). It took me so many hours to realize this one little pump was the fix I needed 😭 .
|
The go_router fix has landed and rolled into Google, so hopefully those tests will pass now. |
|
Looks like all go_router tests are passing, and the only failure is a test that I have a fix for. I just need to clean up this PR. |
|
@chunhtai This is finally ready for re-review. I was worried that this PR would be too breaking, but now go_router’s breakages are fixed, the failing Google tests are very minimal (and I have a g3fix), and there are no customer test failures. I think I’m confident enough to try merging this. It should be much easier for users in the majority of cases, where they don’t want to think about handling predictive back gestures manually for their nested Navigators. |
| /// * [PopScope], which provides even more control over system back behavior. | ||
| /// * [CupertinoTabView], which sets this parameter to false in order to | ||
| /// provide custom back handling for its nested [Navigator]s. | ||
| final bool handlesBacksWhenNested; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a thought, should this be tristate to make things easier? in case of null, it automatically check whether this is nested by calling Navigator.of
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what true does currently, see _isRootNavigator and _getIsRootNavigator.
I think there is really no third state to be handled. When the Navigator is not root, then it doesn't need a PopScope widget, it will handle back gestures anyway. So an unqualified handlesBacks set to true would mean the same thing as handlesBacksWhenNested is true. And it's not really possible to not handle backs at all for a root Navigator.
Maybe the "WhenNested" part of the name makes this confusing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sgtm
| late bool _isRootNavigator; | ||
|
|
||
| bool _lastCanPopCached = false; // Because history is empty to start. | ||
| bool get _lastCanPop => _lastCanPopCached; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider move the _lastCanPopCached into the middle
getter
private property
setter
| // If this Navigator handles back gestures, then rebuild when canPop changes | ||
| // so that the PopScope can be updated. | ||
| if (_handlesBackGestures && value != _lastCanPop) { | ||
| ServicesBinding.instance.addPostFrameCallback((Duration timestamp) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should check for schedule phase I image we may not always changeing this in build state in the future
|
|
||
| // If this is a nested Navigator, handle system backs so that the root | ||
| // Navigator doesn't get all of them. | ||
| bool get _handlesBackGestures => widget.handlesBacksWhenNested && !_isRootNavigator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are checking _isRootNavigator, does the root navigator need to set this to false?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The root navigator itself will have _isRootNavigator set to false, so _handlesBackGestures will always be false for the root navigator. So it doesn't matter what handlesBackWhenNested is for the root navigator.
Does that answer your question? Maybe the naming makes this confusing...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in that case, I don't think we need to set handlesBackWhenNested: false in WidgetsApp
|
|
||
| bool _lastCanPopCached = false; // Because history is empty to start. | ||
| bool get _lastCanPop => _lastCanPopCached; | ||
| set _lastCanPop(bool value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not something need to be addressed now. we now has canPop, _lastCanPop, feels a bit weird. and when to use which in the navigatorState. Consider consolidate them (maybe a breaking change or deprcating canPop()) and turn them into changenotifier
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I'll add a comment here to help explain lastCanPop in the meantime.
| // Navigator doesn't get all of them. | ||
| if (widget.handlesBacksWhenNested && Navigator.maybeOf(context, rootNavigator: true) != this) { | ||
| return PopScope( | ||
| canPop: !canPop(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment here says rebuild will be triggered when _lastCanPop changed
chunhtai
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, just one suggestion
| debugLabel: 'Navigator Scope', | ||
| autofocus: true, | ||
| child: Navigator( | ||
| handlesBacksWhenNested: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not needed since handlesBacksWhenNested: true can still handle the root case. While uncommon, but I have seen people doing MaterialApp inside a MaterialApp...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I did this specifically to work around a weird setup like that in the Google tests. I will try it and see if it passes, maybe that's out of date or I'm misremembering.
|
|
||
| // If this is a nested Navigator, handle system backs so that the root | ||
| // Navigator doesn't get all of them. | ||
| bool get _handlesBackGestures => widget.handlesBacksWhenNested && !_isRootNavigator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in that case, I don't think we need to set handlesBackWhenNested: false in WidgetsApp
|
Greetings from stale PR triage! 👋 |
There is a known problem with using nested Navigator widgets in Flutter. System back events will still go to the root Navigator instead of the nested one. The solution has been to use WillPopScope (and later PopScope/NavigatorPopHandler) to catch the system back and handle it manually. See the docs on NavigatorPopHandler for more on this problem and how we recommend solving it.
This PR is exploring the idea: why doesn't Flutter handle this complexity automatically for users? The navigation scenarios may be varied enough that Flutter can't solve this automatically without breaking some of them, but I hope to find out if that's true.
Fixes #145290
Fixes #153672
Discovered while working on this: #152578
Multiple nested Navigators
I thought this would require more work, but it seems
maybePopcorrectly doesn't pop the inactive Navigator.Multiple nested Navigators example
TODO