WillPopScope's callback isn't being fired when I expected it to be. How can I use WillPopScope inside a Navigator so a route can handle it's own back pressed behaviour.
I'm learning to use flutter, and I came across Navigator for creating multiple pages (I know there are other navigation widgets but I'm after something which only supports programatic navigation so I can handle all the UI).
The next thing I thought to look at with the Navigator was going back, I found WillPopScope which wraps a component and has a callback that gets called when the back button is pressed (if the component is rendered). This seemed ideal for me since I only want the callback to be called if the Widget is rendered.
I tried to use WillPopScope within a Navigator with the intention for only the rendered route to have it's callback (onWillPop) called when the back button is pressed, but putting WillPopScope within a Navigator does nothing (the callback isn't called).
The intention is to have a Navigator navigate to top level routes and those routes themselves potentially having Navigators, so putting WillPopScope inside means each route (or subroute) is responsible for it's own back navigation.
Many questions I've looked up seem to focus on MaterialApp, Scaffold, or other ways of handling navigation; I'm looking how to handle this without the UI that those things bring in (a use case could be a quiz app, where you need to click a next button to move forward, or something similar).
Here is the minimal main.dart file I expect route 2 to handle it's own back navigation (to keep things simple I've not put nested routes in this example).
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Navigation',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
#override
Widget build(BuildContext parentContext) {
return Container(
color: Theme.of(context).colorScheme.primary,
child: Navigator(
initialRoute: "1",
onGenerateRoute: (settings) {
return PageRouteBuilder(pageBuilder: (BuildContext context,
Animation animation, Animation secondaryAnimation) {
switch (settings.name) {
case "1":
return Container(
color: Colors.red,
child: GestureDetector(onTap: () {
debugPrint("going from 1 to 2");
Navigator.of(context).pushNamed("2");
}));
case "2":
return WillPopScope(
child: Container(color: Colors.green),
onWillPop: ()async {
debugPrint("popping from route 2 disabled");
return false;
},
);
default:
throw Exception("unrecognised route \"${settings.name}\"");
}
});
})
);
}
}
When the back button is pressed in either route 1 or 2, the app exits. I expect that to only be the case in route 1, and route 2 should only log popping from route 2 disabled (with no navigation away from the page or leaving the app).
From what I understand, Navigator and WillPopScope are the Widgets to use for this sort of thing, but if not then how would I implement self contained (potentially nested) routes.
You can forward onWillPop to another navigator by using below code:
onWillPop: () async {
return !await otherNavigatorKey.currentState.maybePop();
}
When you create a Navigator you are automatically creating a new stack. Everything you .push below your Navigator using Navigator.of(context) is added to the new Navigator stack you just created. However, when you press the backbutton it doesn't know what you want to pop (if the root navigator or the new navigator).
First you need to add a WillPopScope outside your Navigator and add a NavigatorKey to your Navigator
return WillPopScope(
onWillPop: () => _backPressed(_yourKey),
child: Scaffold(
body: Navigator(
key: _yourKey
onGenerateRoute: _yourMaterialPageRouteLogic,
),
bottomNavigationBar: CustomNavigationBar(navBarOnTapCallback),
),
);
Your key can be instatiated like this
GlobalKey<NavigatorState> _yourKey = GlobalKey<NavigatorState>();
The _backPressed method will receive any backPressed you do on your device. By definition it returns true, and we don't always want to pop.
We've added a key to the navigator, and now it will be used to understand whether the new Navigator has anything in the stack in order to be popped (ie if it 'canPop').
Future<bool> _backPressed(GlobalKey<NavigatorState> _yourKey) async {
//Checks if current Navigator still has screens on the stack.
if (_yourKey.currentState.canPop()) {
// 'maybePop' method handles the decision of 'pop' to another WillPopScope if they exist.
//If no other WillPopScope exists, it returns true
_yourKey.currentState.maybePop();
return Future<bool>.value(false);
}
//if nothing remains in the stack, it simply pops
return Future<bool>.value(true);
Next you just need to add the WillPopScope anywhere inside your Navigator. Its onWillPop will be called with the logic you want. Don't forget to return true or false depending on whether you want to pop it or not :)
Here is a example of my onWillPop method (_popCamera) in a WillPopScope widget, which is placed inside my Navigator widget tree. In this example I've added a dialog when the user presses the back button in the Camera Widget:
static Future<bool> _popCamera(BuildContext context) {
debugPrint("_popCamera");
showDialog(
context: context,
builder: (_) => ExitCameraDialog(),
barrierDismissible: true);
return Future.value(false);
}
class ExitCameraDialog extends StatelessWidget {
const ExitCameraDialog({
Key key,
}) : super(key: key);
#override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Leaving camera forever'),
content:
Text('Are you S-U-R-E, S-I-R?'),
actions: <Widget>[
FlatButton(
child: Text('no'),
onPressed: Navigator.of(context).pop,
),
FlatButton(
//yes button
child: Text('yes'),
onPressed: () {
Navigator.of(context).pop();
_yourKey.currentState.pop();
},
),
],
);
}
Hope it's clear!
Related
How can I update some properties of the roots scaffold in a child widget(page).
Here is a snippet from my root scaffold.
CupertinoPageScaffold(
resizeToAvoidBottomInset:
state.resizeToAvoidBottomInsets, //update this here
child: CupertinoTabScaffold(
controller: _tabController,
tabBar: CupertinoTabBar(
onTap: onTap,
items: widget.items,
),
tabBuilder: (BuildContext context, index) {
return StatusBarPadding(child: _tabs[index]);
}),
),
The docs say, I should add a listener to avoid a nested scaffold (e.g. to update resizeToAvoidBottomInset).
However, this does only work for one page per tab. When I nest tabs, I can't access them directly anymore.
I tried two solutions which I will explain in the follow (+problems):
Solution 1: Provider
I used a Provider to keep track of a global Navbar State:
class NavbarState extends ChangeNotifier {
bool _resizeBottom;
NavbarState(this._resizeBottom);
get resizeBottom => _resizeBottom;
void setResizeBottom(bool state) {
_resizeBottom = state;
notifyListeners();
}
}
Then In my Pages I set the state in the initState-Method with BlocProvider.of<NavbarState>(context).setResizeBottom(val) (respective for dispose).
This has 2 problems:
Calling notifyListeners triggers a setState in the consumer and you can't call setState in the initState method.
I have to declare this in every initState and dispose method.
Solution 2: Bloc
Once again I have a global state, but it does not have to inherit from `ChangeNotifier`. I track the state with a `NavbarBloc`-class.
Then I can add an event in the onGenerateRoute method. This is more handy then the provider approach, because there is just one place where I manage this state.
However, there is still a big problem:
When I navigate back, the onGenerateRoute Method does not get called and hence the state is not getting updated.
What the easiest solution would be
At least from an app-developer perspective it would be nice if I could just ask for the the current widget which is sitting in the active navigator.
Example of a Navbar
Here is an illustration of 3 navigators for the given cupertinotabscaffold.
The middle "stack" is active and the topmost widget is seen on the screen. Thus, currently the resize param should be false. On navigating between the stacks (tapping navigation icon), the resize parameter should adjust. Furthermore, on navigating in between a single stack (push, pop) should also adjust the resize param (E.g. on a pop the param should be set to true).
I couldn't find anything like that. Thus I need your help.
For setting the state how about a setter as a callback to onTap?
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return CupertinoApp(
title: 'Flutter Demo',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key}) : super(key: key);
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var values = [
[true, false],
[false, true],
];
int stackIndex = 0;
bool _resizeToAvoidBottomInsets = true;
set _resizeToAvoidButtomInsets(bool value) => setState(() {
print("set _resizeToAvoidBottomInsets = $value");
_resizeToAvoidBottomInsets = value;
});
void handleTap(int i) {
print("tapped: $i");
_resizeToAvoidBottomInsets = values[stackIndex][i];
}
#override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
resizeToAvoidBottomInset: _resizeToAvoidBottomInsets,
child: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.ac_unit)),
BottomNavigationBarItem(icon: Icon(Icons.wb_sunny)),
],
onTap: handleTap,
),
tabBuilder: (BuildContext context, int index) {
return CupertinoTabView(
builder: (BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('Page 1 of tab $index'),
),
child: Center(
child: CupertinoButton(
child: Text(
'resizeToAvoidBottomInsets: $_resizeToAvoidBottomInsets',
),
onPressed: () {
// set state and increment stack index before push
stackIndex++;
_resizeToAvoidButtomInsets = values[stackIndex][0];
Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('Page 2 of tab $index'),
),
child: Center(
child: CupertinoButton(
child: Text(
'resizeToAvoidBottomInsets: $_resizeToAvoidBottomInsets',
),
onPressed: () {
// set state and decrement stack index before pop
stackIndex--;
_resizeToAvoidButtomInsets =
values[stackIndex][0];
Navigator.of(context).pop();
}),
),
);
},
),
);
},
),
),
);
},
);
},
),
);
}
}
So I implemented a solution for the given problem. I don't think this is very smooth, but at least one can abstract the scaffold-properties from each single page.
I use a bloc for that as already mentioned in the question-solution-try-2.
The bloc emits a state MyScaffoldState which contains a ScaffoldProperties attribute. For this toy example I implemented it as follows:
class ScaffoldProperties {
bool resizeBottom;
String title;
ScaffoldProperties({this.resizeBottom = false, required this.title});
ScaffoldProperties copyWith({ScaffoldProperties? state}) {
return ScaffoldProperties(
resizeBottom: state?.resizeBottom ?? resizeBottom,
title: state?.title ?? title,
);
}
}
You could add any scaffold-property to this class.
The main work happens in my MultiTabScaffold class. This class takes the parameters
List<Widget> pages;
List<BottomNavigationBarItem> items;
Route<dynamic>? Function(RouteSettings)? onGenerateRoute;
List<NavigatorObserver> navigatorObservers
And most importantly
Function<ScaffoldProperties> getScaffoldPropertiesFromRouteName(String? route);
The class wraps the necessary Scaffolds around the pages. Additionally, it uses a BlocBuilder and thus can use the ScaffoldProperties of the current state.
The getScaffoldPropertiesFromRouteName Method takes a route and returns a ScaffoldProperties-Instance. Thus all title, resizeProperties,... have to be collected here.
To update the current state (or emit events to the bloc), I had to modify a few points.
onGenerateRoute: After generating the route (pushing to navigator), the ScaffoldProperties of the current page have to be emitted (with usage of the getScaffoldPropertiesFromRouteName Function)
WillPopScope: Also after popping the page, I emit an event with the properties of the page below.
Initial Value: Initially, when you open the app, neither onGenerateRoute nor willpop will be called. Thus, you have to have a different handling for the initial route '/' of every Navigator.
I have a toy example fully implemented.
This feels like a lot of work for such a simple use case, but I couldn't find a simpler way to do this. If there is one, please let me know.
In Flutter, all Navigator functions that push a new element onto the navigation stack return a Future as it's possible for the caller to wait for the execution and handle the result.
I make heavy use of it e. g. when redirecting the user (via push()) to a new page. As the user finishes the interaction with that page I sometimes want the original page to also pop():
onTap: () async {
await Navigator.of(context).pushNamed(
RoomAddPage.routeName,
arguments: room,
);
Navigator.of(context).pop();
},
A common example is the usage of a bottom sheet with a button with a sensitive action (like deleting an entity). When a user clicks the button, another bottom sheet is opened that asks for the confirmation. When the user confirms, the confirm dialog is to be dismissed, as well as the first bottom sheet that opened the confirm bottom sheet.
So basically the onTap property of the DELETE button inside the bottom sheet looks like this:
onTap: () async {
bool deleteConfirmed = await showModalBottomSheet<bool>(/* open the confirm dialog */);
if (deleteConfirmed) {
Navigator.of(context).pop();
}
},
Everything is fine with this approach. The only problem I have is that the linter raises a warning: use_build_context_synchronously because I use the same BuildContext after the completion of an async function.
Is it safe for me to ignore / suspend this warning? But how would I wait for a push action on the navigation stack with a follow-up code where I use the same BuildContext? Is there a proper alternative? There has to be a possibility to do that, right?
PS: I can not and I do not want to check for the mounted property as I am not using StatefulWidget.
Short answer:
It's NOT SAFE to always ignore this warning, even in a Stateless Widget.
A workaround in this case is to use the context before the async call. For example, find the Navigator and store it as a variable. This way you are passing the Navigator around, not passing the BuildContext around, like so:
onPressed: () async {
final navigator = Navigator.of(context); // store the Navigator
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Dialog Title'),
),
);
navigator.pop(); // use the Navigator, not the BuildContext
},
Long answer:
This warning essentially reminds you that, after an async call, the BuildContext might not be valid anymore. There are several reasons for the BuildContext to become invalid, for example, having the original widget destroyed during the waiting, could be one of the (leading) reasons. This is why it's a good idea to check if your stateful widget is still mounted.
However, we cannot check mounted on stateless widgets, but it absolutely does not mean they cannot become unmounted during the wait. If conditions are met, they can become unmounted too! For example, if their parent widget is stateful, and if their parent triggered a rebuild during the wait, and if somehow a stateless widget's parameter is changed, or if its key is different, it will be destroyed and recreated. This will make the old BuildContext invalid, and will result in a crash if you try to use the old context.
To demonstrate the danger, I created a small project. In the TestPage (Stateful Widget), I'm refreshing it every 500 ms, so the build function is called frequently. Then I made 2 buttons, both open a dialog then try to pop the current page (like you described in the question). One of them stores the Navigator before opening the dialog, the other one dangerously uses the BuildContext after the async call (like you described in the question). After clicking a button, if you sit and wait on the alert dialog for a few seconds, then exit it (by clicking anywhere outside the dialog), the safer button works as expected and pops the current page, while the other button does not.
The error it prints out is:
[VERBOSE-2:ui_dart_state.cc(209)] Unhandled Exception: Looking up a
deactivated widget's ancestor is unsafe. At this point the state of
the widget's element tree is no longer stable. To safely refer to a
widget's ancestor in its dispose() method, save a reference to the
ancestor by calling dependOnInheritedWidgetOfExactType() in the
widget's didChangeDependencies() method.
#0 Element._debugCheckStateIsActiveForAncestorLookup. (package:flutter/src/widgets/framework.dart:4032:9)
#1 Element._debugCheckStateIsActiveForAncestorLookup (package:flutter/src/widgets/framework.dart:4046:6)
#2 Element.findAncestorStateOfType (package:flutter/src/widgets/framework.dart:4093:12)
#3 Navigator.of (package:flutter/src/widgets/navigator.dart:2736:40)
#4 MyDangerousButton.build. (package:helloworld/main.dart:114:19)
Full source code demonstrating the problem:
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
home: HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home Page')),
body: Center(
child: ElevatedButton(
child: Text('Open Test Page'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => TestPage()),
);
},
),
),
);
}
}
class TestPage extends StatefulWidget {
#override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
late final Timer timer;
#override
void initState() {
super.initState();
timer = Timer.periodic(Duration(milliseconds: 500), (timer) {
setState(() {});
});
}
#override
void dispose() {
timer.cancel();
super.dispose();
}
#override
Widget build(BuildContext context) {
final time = DateTime.now().millisecondsSinceEpoch;
return Scaffold(
appBar: AppBar(title: Text('Test Page')),
body: Center(
child: Column(
children: [
Text('Current Time: $time'),
MySafeButton(key: UniqueKey()),
MyDangerousButton(key: UniqueKey()),
],
),
),
);
}
}
class MySafeButton extends StatelessWidget {
const MySafeButton({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return ElevatedButton(
child: Text('Open Dialog Then Pop Safely'),
onPressed: () async {
final navigator = Navigator.of(context);
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Dialog Title'),
),
);
navigator.pop();
},
);
}
}
class MyDangerousButton extends StatelessWidget {
const MyDangerousButton({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return ElevatedButton(
child: Text('Open Dialog Then Pop Dangerously'),
onPressed: () async {
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Dialog Title'),
),
);
Navigator.of(context).pop();
},
);
}
}
Flutter ≥ 3.7 answer:
You can now use mounted on a StatelessWidget. This solution will not show linter warning:
onTap: () async {
bool deleteConfirmed = await showModalBottomSheet<bool>(/* open the confirm dialog */);
if (mounted && deleteConfirmed) {
Navigator.of(context).pop();
}
},
Alternatively, you can use context.mounted if outside of the widget.
I use ChangeNotifierProvider of a model, which has a "number" property and a method to set a random value to it. There are 2 routes. In the first one, there is a variable which listens to this property using context.select or context.watch (does not matter) and then used in a Text widget. And there is also a button that calls a method of the model, which sets random value. Initially, if no screens have changed, when this button is pressed, the widget is rebuilt as expected. The problem occurs when pushing to another route and then returning back to the first one. If the button is pressed again, the widget is rebuilt twice, because provider the for some reason is also changed twice. And the more times you switch between screens, the more times the widget is rebuilt when you press the button for setting a random value. Below you can see a screenshot from Dart DevTools with logs. Here I gave a small example, just to illustrate my problem, but in another project, where instead of a simple text widget there is a table with data, the decrease in performance after each screen change becomes more noticeable, because the response to user actions, leading to change in the Model, becomes longer.
class MyModel extends ChangeNotifier {
int _number = 0;
int get number => _number;
void setRandomNumber() {
print('Set random number');
_number = Random().nextInt(1000);
notifyListeners();
}
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return ChangeNotifierProvider<MyModel>(
create: (context) => MyModel(),
child: MaterialApp(
routes: {
'/home': (context) => MyHomePage(),
'/second': (context) => SecondPage(),
},
initialRoute: '/home',
),
);
}
}
class MyHomePage extends StatelessWidget {
#override
Widget build(BuildContext context) {
final number = context.select<MyModel, num>((model) => model.number);
print('REBUILD');
return Scaffold(
body: SafeArea(
child: Column(
children: [
Text(number.toString()),
TextButton(
child: Text('Go to Page 2'),
onPressed: () {
print('Go to Page 2');
Navigator.pushNamed(context, '/second');
},
),
TextButton(
child: Text('Set Random Number'),
onPressed: () => context.read<MyModel>().setRandomNumber(),
),
],
),
),
);
}
}
class SecondPage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Center(
child: TextButton(
child: Text('Go to Home Page'),
onPressed: () {
print('Go to Home Page');
Navigator.pushNamed(context, '/home');
},
),
);
}
}
Console:
flutter: Set random number
flutter: REBUILT
flutter: Go to Page 2
flutter: Go to Home Page
flutter: Set random number
flutter: REBUILT
flutter: REBUILT
Logs in DevTools
It's because you push without popping. You have a stack of Screen 1, Screen 2, Screen 1. You press the button on Screen 1, it executes the set random number function and it notifies the listeners on both instances of screen 1.
It's generally a good practice not to have duplicate screens in your navigation stack at any time. Packages like auto_route enforce this somewhat, though you can also manage this without the use of a package. Just be diligent and be wary of pushing when popping can lead you to the same screen.
I'm working on writing a Flutter application where I want to check on start up if a certain value is stored. If this value is not stored (i.e. the app was just downloaded or data was cleared), I want to create a dialog and have the user choose which value to store out of 3 options. I also want users to be able to change this value through the settings pane as well where I feel it would be easier to just make the same alert dialog pop up.
For my app, my main view is a stateful widget and I've added the code to make the dialog display on startup.
class Home extends StatefulWidget {
const Home({Key key}) : super(key: key);
#override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
bool is_setting_set = false;
#override
void initState() {
super.initState();
showSettingChoosingDialog(context);
}
void showSettingChoosingDialog(BuildContext context) async {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Choose your setting'),
content: CustomRadioListTileWidget(),
actions: <Widget>[
TextButton(
child: const Text('Done'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
));
}
#override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
drawer: Drawer(
child: ListView(
children: [
ListTile(
title: const Text('Settings'),
onTap: () {},
)
],
),
),
appBar: AppBar(
title: const Text('App'),
),
body: TabBarView(...),
),
);
}
}
but this throws an error because the CustomRadioListTileWidget is also a StatefulWidget. The stack trace is:
VERBOSE-2:ui_dart_state.cc(199)] Unhandled Exception: dependOnInheritedWidgetOfExactType<_LocalizationsScope>() or dependOnInheritedElement() was called before _HomeState.initState() completed.
When an inherited widget changes, for example if the value of Theme.of() changes, its dependent widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor or an initState() method, then the rebuilt dependent widget will not reflect the changes in the inherited widget.
Typically references to inherited widgets should occur in widget build() methods. Alternatively, initialization based on inherited widgets can be placed in the didChangeDependencies method, which is called after initState and whenever the dependencies change thereafter.
#0 StatefulElement.dependOnInheritedElement.<anonymous closure> (package:flutter/src/widgets/framework.dart:4821:9)
#1 StatefulElement.dependOnInheritedElement (package:flutter/src/widgets/framework.dart:4864:6)
#2 Element.<…>
I could move the CustomRadioListTileWidget code to this widget here but then I don't want to copy paste the same code for use in my settings pane. Is there a way to share this alert dialog with multiple components?
IMO it's not related to the CustomRadioListTileWidget widget and the fact that it's a stateful widget.
The thing is that you don't have access to context in the initState, you need to call the showSettingChoosingDialog after the build method has been called once and then you have access to context object.
But you can still do it in initState, just call the showSettingChoosingDialog function in a Future.delayed(Duration.zero, clbk) call back, like the code below:
#override
void initState() {
Future.delayed(Duration.zero, () => showSettingChoosingDialog(context));
super.initState();
}
This ensures that the build has been run once, why? Because the callback inside the Future.delayed is called when the current call stack is empty.
BTW, you don't need to pass the context into the showSettingChoosingDialog function, it has access to the context provided by State class.
So it can become:
Future.delayed(Duration.zero, () => showSettingChoosingDialog());
Or shorter:
Future.delayed(Duration.zero, showSettingChoosingDialog);
One of the main mecanism of Flutter Navigator 2.0 it the function onPopPage inside RouterDelegate > build > Navigator. However, I do not understand when route.didPop(result) returns false.
We can use the John Ryan's famous example to show my question. His demo code.
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update the list of pages by setting _selectedBook to null
_selectedBook = null;
show404 = false;
notifyListeners();
return true;
},
On all of my tests, using AppBar autogenerated back button, route.didPop(result) returns true.
The doc stays :
bool didPop(dynamic result)
package:flutter/src/widgets/navigator.dart
A request was made to pop this route. If the route can handle it internally (e.g. because it has its own stack of internal state) then return false, otherwise return true (by returning the value of calling super.didPop). Returning false will prevent the default behavior of [NavigatorState.pop].
When this function returns true, the navigator removes this route from the history but does not yet call [dispose]. Instead, it is the route's responsibility to call [NavigatorState.finalizeRoute], which will in turn call [dispose] on the route. This sequence lets the route perform an exit animation (or some other visual effect) after being popped but prior to being disposed.
This method should call [didComplete] to resolve the [popped] future (and this is all that the default implementation does); routes should not wait for their exit animation to complete before doing so.
See [popped], [didComplete], and [currentResult] for a discussion of the result argument.
But was does "If the route can handle it internally (e.g. because it has its own stack of internal state) then return false" mean ? The route has its own stack of internal state ? How to produce this result ?
Thank you, stay safe
After some research to fully understand the Navigator 2.0, I think this might be the answer to the question:
route.didPop(result) will return false, when the Route, which are asked to pop, keeps local history entries and they have to be removed before popping the complete Route.
So what are local history entries (the stack of internal states)?
Local history entries are a way to implement local navigation within a page. You can do so using the method addLocalHistoryEntry. To understand this better, take a look at the official Flutter Docs sample:
The following example is an app with 2 pages: HomePage and SecondPage.
The HomePage can navigate to the SecondPage. The SecondPage uses a
LocalHistoryEntry to implement local navigation within that page.
Pressing 'show rectangle' displays a red rectangle and adds a local
history entry. At that point, pressing the '< back' button pops the
latest route, which is the local history entry, and the red rectangle
disappears. Pressing the '< back' button a second time once again pops
the latest route, which is the SecondPage, itself. Therefore, the
second press navigates back to the HomePage.
class App extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: '/',
routes: {
'/': (BuildContext context) => HomePage(),
'/second_page': (BuildContext context) => SecondPage(),
},
);
}
}
class HomePage extends StatefulWidget {
HomePage();
#override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('HomePage'),
// Press this button to open the SecondPage.
ElevatedButton(
child: Text('Second Page >'),
onPressed: () {
Navigator.pushNamed(context, '/second_page');
},
),
],
),
),
);
}
}
class SecondPage extends StatefulWidget {
#override
_SecondPageState createState() => _SecondPageState();
}
class _SecondPageState extends State<SecondPage> {
bool _showRectangle = false;
void _navigateLocallyToShowRectangle() async {
// This local history entry essentially represents the display of the red
// rectangle. When this local history entry is removed, we hide the red
// rectangle.
setState(() => _showRectangle = true);
ModalRoute.of(context).addLocalHistoryEntry(
LocalHistoryEntry(
onRemove: () {
// Hide the red rectangle.
setState(() => _showRectangle = false);
}
)
);
}
#override
Widget build(BuildContext context) {
final localNavContent = _showRectangle
? Container(
width: 100.0,
height: 100.0,
color: Colors.red,
)
: ElevatedButton(
child: Text('Show Rectangle'),
onPressed: _navigateLocallyToShowRectangle,
);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
localNavContent,
ElevatedButton(
child: Text('< Back'),
onPressed: () {
// Pop a route. If this is pressed while the red rectangle is
// visible then it will will pop our local history entry, which
// will hide the red rectangle. Otherwise, the SecondPage will
// navigate back to the HomePage.
Navigator.of(context).pop();
},
),
],
),
),
);
}
}
To see the sample in the docs, click here.
I hope I answered the question in an understandable way.