If you do not define anything else, then Flutter will react by exiting the app on the (Android) user triggering the hardware back button while the user finds himself on the top of the route stack (typically the home screen). This can lead for the user to accidentally close the app. Let’s see how we can prevent this behavior by showing a confirm dialog instead.
WillPopScope
Before we discover how to show a dialog, let’s have a look at the possibility of intercepting the back button.
There is a Widget called WillPopScope which does exactly that: it enables you to define the behavior that is triggered when the user tries to dismiss the current route.
1@override
2Widget build(BuildContext context) {
3 return WillPopScope(
4 onWillPop: () async {
5 print('The user tries to pop()');
6 return false;
7 },
8 child: Scaffold(
9 appBar: AppBar(
10 title: const Text("Exit app warning"),
11 ),
12 body: Container(),
13 ),
14 );
15}
Let’s say we have a Widget with a build()
method that looks like the one above. As you can see, there are two parameters: onWillPop
and child
. Since WillPopScope
is a widget that is supposed to be wrapped around another widget (in this case the Scaffold widget), we need a child
argument to define what the WillPopScope
will be applied to.
We could keep it this way and let the onWillPop
parameter be null
. This is possible because its type is Future<bool> Function()?
or in other words: a function that takes no arguments and returns a Future of bool or null (because it is an optional). But that would have zero effect: everything would stay the way it is – when the user hits the back button, the route will pop, the app will quit.
Instead, this function callback let’s us define two things:
- If the route will pop when the user hits the back button
- What will happen before that
The former is defined by the return value of the function (true
means: pop the route, false
means: prevent it from popping). The latter is defined by everything your code does inside of the function before the return
statement.
This is means in the above case the app will print “The user tries to pop()” and nothing else will happen (no popping the route).
Showing a dialog
A dialog is a modal element that requires an action from the user to be dismissed. It takes full attention of the user because it prevents the user from interacting with the underlying UI elements.
There are two different Dialog types in Flutter: AlertDialog and SimpleDialog. SimpleDialog
is used for the user to display choices (typically SimpleDialogOption) which the user is supposed to choose from. An AlertDialog
on the other hand can be more complex: it’s possible to set a title and a list of actions for the user to execute. If none of the types fit, then the Dialog class, which both of the other types are based on, can be used.
1AlertDialog(
2 title: const Text('Please confirm'),
3 content: const Text('Do you want to exit the app?'),
4 actions: <Widget>[
5 TextButton(
6 onPressed: () => Navigator.of(context).pop(false),
7 child: Text('No'),
8 ),
9 TextButton(
10 onPressed: () => Navigator.of(context).pop(true),
11 child: Text('Yes'),
12 ),
13 ],
14);
Looking at the code, you might ask yourself, why the pop()
function is called with a bool
being false
or true
depending on the option (close app or not).
That’s because we want the caller to execute different code depending on the user’s decision. If you provide an argument to the pop()
function, this will be the caller’s return value if the function leading to the current route. That can be Navigator.push()
but also showDialog()
.
In this case, the pop()
function dismisses the dialog, returning true
or false
to the caller.
Putting things together
Let’s use our newly acquired knowledge to solve our initial problem: showing a confirm dialog when the user tries to leave the app using the back button.
1class HomePage extends StatelessWidget {
2 Future<bool> _onWillPop(BuildContext context) async {
3 bool? exitResult = await showDialog(
4 context: context,
5 builder: (context) => _buildExitDialog(context),
6 );
7 return exitResult ?? false;
8 }
9
10 Future<bool?> _showExitDialog(BuildContext context) async {
11 return await showDialog(
12 context: context,
13 builder: (context) => _buildExitDialog(context),
14 );
15 }
16
17 AlertDialog _buildExitDialog(BuildContext context) {
18 return AlertDialog(
19 title: const Text('Please confirm'),
20 content: const Text('Do you want to exit the app?'),
21 actions: <Widget>[
22 TextButton(
23 onPressed: () => Navigator.of(context).pop(false),
24 child: Text('No'),
25 ),
26 TextButton(
27 onPressed: () => Navigator.of(context).pop(true),
28 child: Text('Yes'),
29 ),
30 ],
31 );
32 }
33
34 @override
35 Widget build(BuildContext context) {
36 return WillPopScope(
37 onWillPop: () => _onWillPop(context),
38 child: Scaffold(
39 appBar: AppBar(
40 title: const Text("Exit app warning"),
41 ),
42 body: Container(),
43 ),
44 );
45 }
46}
We wrap our Scaffold
widget with a WillPopScope
. As the onWillPop
parameter, we set the _onWillPop()
function. Inside of there we show the dialog and wait for its return value.
You might ask yourself why it’s an bool?
and not a bool
. That’s because the user can cause the dialog to hide without choosing an option. For example tapping the half-transparent background which by default also dismisses the dialog.
So we use the null coalescing operator (??
) to handle that case as false
making it not pop the current route.
Alternative ideas
Instead of showing a dialog, we could also go for a more modern solution: showing a (modal) bottom sheet. Lucky for us, the syntax is quite similar. Instead of calling showDialog, we can just call showModalBottomSheet.
1class HomePage extends StatelessWidget {
2 Future<bool> _onWillPop(BuildContext context) async {
3 bool? exitResult = await _showExitBottomSheet(context);
4 return exitResult ?? false;
5 }
6
7 Future<bool?> _showExitBottomSheet(BuildContext context) async {
8 return await showModalBottomSheet(
9 backgroundColor: Colors.transparent,
10 context: context,
11 builder: (BuildContext context) {
12 return Container(
13 padding: const EdgeInsets.all(16),
14 decoration: const BoxDecoration(
15 color: Colors.white,
16 borderRadius: BorderRadius.only(
17 topLeft: Radius.circular(24),
18 topRight: Radius.circular(24),
19 ),
20 ),
21 child: _buildBottomSheet(context),
22 );
23 },
24 );
25 }
26
27 Widget _buildBottomSheet(BuildContext context) {
28 return Column(
29 mainAxisSize: MainAxisSize.min,
30 children: [
31 const SizedBox(
32 height: 24,
33 ),
34 Text(
35 'Do you really want to exit the app?',
36 style: Theme.of(context).textTheme.headline6,
37 ),
38 const SizedBox(
39 height: 24,
40 ),
41 Row(
42 mainAxisAlignment: MainAxisAlignment.end,
43 children: <Widget>[
44 TextButton(
45 style: ButtonStyle(
46 padding: MaterialStateProperty.all(
47 const EdgeInsets.symmetric(
48 horizontal: 8,
49 ),
50 ),
51 ),
52 onPressed: () => Navigator.of(context).pop(false),
53 child: const Text('CANCEL'),
54 ),
55 TextButton(
56 style: ButtonStyle(
57 padding: MaterialStateProperty.all(
58 const EdgeInsets.symmetric(
59 horizontal: 8,
60 ),
61 ),
62 ),
63 onPressed: () => Navigator.of(context).pop(true),
64 child: const Text('YES, EXIT'),
65 ),
66 ],
67 ),
68 ],
69 );
70 }
71
72 @override
73 Widget build(BuildContext context) {
74 return WillPopScope(
75 onWillPop: () => _onWillPop(context),
76 child: Scaffold(
77 appBar: AppBar(
78 title: const Text("Exit app warning"),
79 ),
80 body: Container(),
81 ),
82 );
83 }
84}
What is worth noting: to make it look a little bit prettier, we the borderRadius
property of the BoxDecoration
object inside the container.
Conclusion
By combining two powerful widgets, WillPopScope
and Dialog
, it’s pretty easy to prevent the user from exiting the app instantly when the back button is pressed on the top route. Luckily, Flutter provides the freedom for the user to choose what happens instead.
Comment this 🤌