Prevent back button from closing the app

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.

@override
Widget build(BuildContext context) {
  return WillPopScope(
    onWillPop: () async {
      print('The user tries to pop()');
      return false;
    },
    child: Scaffold(
      appBar: AppBar(
        title: const Text("Exit app warning"),
      ),
      body: Container(),
    ),
  );
}

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.

AlertDialog(
  title: const Text('Please confirm'),
  content: const Text('Do you want to exit the app?'),
  actions: <Widget>[
    TextButton(
      onPressed: () => Navigator.of(context).pop(false),
      child: Text('No'),
    ),
    TextButton(
      onPressed: () => Navigator.of(context).pop(true),
      child: Text('Yes'),
    ),
  ],
);

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.

class HomePage extends StatelessWidget {
  Future<bool> _onWillPop(BuildContext context) async {
    bool? exitResult = await showDialog(
      context: context,
      builder: (context) => _buildExitDialog(context),
    );
    return exitResult ?? false;
  }

  Future<bool?> _showExitDialog(BuildContext context) async {
    return await showDialog(
      context: context,
      builder: (context) => _buildExitDialog(context),
    );
  }

  AlertDialog _buildExitDialog(BuildContext context) {
    return AlertDialog(
      title: const Text('Please confirm'),
      content: const Text('Do you want to exit the app?'),
      actions: <Widget>[
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: Text('No'),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: Text('Yes'),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () => _onWillPop(context),
      child: Scaffold(
        appBar: AppBar(
          title: const Text("Exit app warning"),
        ),
        body: Container(),
      ),
    );
  }
}

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.

Dialog after user pressed back button
Dialog after user pressed back button

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.

class HomePage extends StatelessWidget {
  Future<bool> _onWillPop(BuildContext context) async {
    bool? exitResult = await _showExitBottomSheet(context);
    return exitResult ?? false;
  }

  Future<bool?> _showExitBottomSheet(BuildContext context) async {
    return await showModalBottomSheet(
      backgroundColor: Colors.transparent,
      context: context,
      builder: (BuildContext context) {
        return Container(
          padding: const EdgeInsets.all(16),
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(24),
              topRight: Radius.circular(24),
            ),
          ),
          child: _buildBottomSheet(context),
        );
      },
    );
  }

  Widget _buildBottomSheet(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const SizedBox(
          height: 24,
        ),
        Text(
          'Do you really want to exit the app?',
          style: Theme.of(context).textTheme.headline6,
        ),
        const SizedBox(
          height: 24,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: <Widget>[
            TextButton(
              style: ButtonStyle(
                padding: MaterialStateProperty.all(
                  const EdgeInsets.symmetric(
                    horizontal: 8,
                  ),
                ),
              ),
              onPressed: () => Navigator.of(context).pop(false),
              child: const Text('CANCEL'),
            ),
            TextButton(
              style: ButtonStyle(
                padding: MaterialStateProperty.all(
                  const EdgeInsets.symmetric(
                    horizontal: 8,
                  ),
                ),
              ),
              onPressed: () => Navigator.of(context).pop(true),
              child: const Text('YES, EXIT'),
            ),
          ],
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () => _onWillPop(context),
      child: Scaffold(
        appBar: AppBar(
          title: const Text("Exit app warning"),
        ),
        body: Container(),
      ),
    );
  }
}

What is worth noting: to make it look a little bit prettier, we the borderRadius property of the BoxDecoration object inside the container.

Modal BottomSheet after user pressed back button
Modal BottomSheet after user pressed back button

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.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment