skip to content

In Flutter, how does PopScope stop the back gesture from closing a half-filled address form, and how do you confirm before discarding?

level: middleimportance: must knowfreq 52%

answer

  1. decide ahead of time, not on press
  2. canPop: false while the form is dirty
  3. onPopInvokedWithResult(didPop, result)
  4. return early when didPop is true
  5. Navigator.pop ignores canPop

basics

~20 s

Wrap the form in PopScope with canPop false while it has unsaved input. A back gesture then leaves the route in place and calls onPopInvokedWithResult with didPop false; show a dialog there and call Navigator.pop if the user confirms.

solid answer

~40 s

`PopScope` replaced the deprecated `WillPopScope`. Instead of answering "may I pop?" when back is pressed, you declare it ahead of time with `canPop`, which Android's predictive back needs before the gesture starts. For an address form, `canPop` is `!_dirty`. When the user presses back while it is dirty, the route stays and `onPopInvokedWithResult(didPop, result)` is called with `didPop == false`; there you show a confirmation dialog and, if the user agrees and `context.mounted` is still true, call `Navigator.pop(context)`. That explicit `pop` succeeds because `Navigator.pop` always pops; only `maybePop` and system back consult `canPop`. The callback also runs after successful pops with `didPop == true`, so it must return early then, or it pops a second route.

code

dart · 72 lines
dart
import 'package:flutter/material.dart';

class Address {
  const Address(this.street);
  final String street;
}

class NewAddressForm extends StatefulWidget {
  const NewAddressForm({super.key});

  @override
  State<NewAddressForm> createState() => _NewAddressFormState();
}

class _NewAddressFormState extends State<NewAddressForm> {
  final TextEditingController _street = TextEditingController();
  bool _dirty = false;

  @override
  void initState() {
    super.initState();
    _street.addListener(() {
      if (!_dirty && _street.text.isNotEmpty) setState(() => _dirty = true);
    });
  }

  @override
  void dispose() {
    _street.dispose();
    super.dispose();
  }

  Future<bool> _confirmDiscard() async {
    final bool? discard = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Discard this address?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Keep editing')),
          TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Discard')),
        ],
      ),
    );
    return discard ?? false;
  }

  @override
  Widget build(BuildContext context) {
    return PopScope<Address>(
      canPop: !_dirty,
      onPopInvokedWithResult: (bool didPop, Address? result) async {
        if (didPop) return; // already popped: saved, or nothing typed yet
        final bool discard = await _confirmDiscard();
        if (discard && context.mounted) Navigator.pop(context);
      },
      child: Scaffold(
        appBar: AppBar(title: const Text('New address')),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: TextField(
            controller: _street,
            decoration: const InputDecoration(labelText: 'Street'),
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => Navigator.pop(context, Address(_street.text)),
          child: const Icon(Icons.check),
        ),
      ),
    );
  }
}

go deeper

for a junior

Recall that PopScope with canPop false blocks back navigation, and the callback is where you ask before leaving.

for a middle

Explain the didPop flag, why the callback returns early when it is true, and which triggers respect canPop.

for a senior

Show the production pitfalls: double pops, mounted checks after dialogs, iOS swipe-back, and root routes that can no longer exit.

for a principal

Frame back handling as a product policy across screens, and when blocking back costs more user trust than it saves data.

## The problem A user half-way through typing a new delivery address presses back. Losing the input silently is bad; trapping the user is worse. The screen needs to **block the back navigation, ask, and then either stay or leave**. ## PopScope in one picture **`PopScope<T>`** is a widget placed inside a route's page. It has two parameters that matter: - **`canPop`** (default `true`): whether back navigation may pop this route right now. - **`onPopInvokedWithResult`**: a callback `(bool didPop, T? result)` called **after** a pop was attempted, reporting whether it happened. The type parameter `T` should match the route's result type, e.g. `PopScope<Address>` inside a `MaterialPageRoute<Address>`, so `result` is typed. ## The flow for a dirty form 1. The form sets `canPop: !_dirty`, so it is `true` until the user types. 2. The user presses Android back (or taps an app-bar back button, which calls `maybePop`). 3. Because `canPop` is `false`, the navigator **does not pop**. It calls `onPopInvokedWithResult(false, null)`. 4. The callback shows a confirmation dialog. 5. If the user chooses Discard and the widget is still mounted, the callback calls `Navigator.pop(context)`. 6. That `pop` succeeds, and the callback is called again with `didPop == true`; the early `return` stops it from doing anything more. ```dart PopScope<Address>( canPop: !_dirty, onPopInvokedWithResult: (bool didPop, Address? result) async { if (didPop) return; final bool discard = await _confirmDiscard(); if (discard && context.mounted) Navigator.pop(context); }, child: form, ) ``` ## What respects canPop and what does not | Trigger | Respects `canPop: false`? | |---|---| | Android system back button or gesture | yes: no pop, callback with `didPop: false` | | `Navigator.maybePop` (used by the app-bar back button) | yes | | `Navigator.pop` called by your code | **no**: it always pops; callback gets `didPop: true` | | iOS swipe-back on a Cupertino-style page transition | the gesture is disabled entirely, so the callback is **not** called | The third row is what makes the pattern work: the Save button and the confirmed Discard both call `Navigator.pop` and leave even while `canPop` is `false`. It is also a trap: code that calls `pop` to go back from inside the screen is not stopped by `PopScope`. ## Why WillPopScope was replaced `WillPopScope` took an async `onWillPop` callback and decided **when the back press arrived**. Android 14's **predictive back** starts its back animation as soon as the gesture begins, before the app could answer, so the decision has to be known in advance. Flutter deprecated `WillPopScope` after v3.12.0-1.0.pre in favour of `PopScope` with a boolean `canPop`. Later, `PopScope` gained its type parameter and `onPopInvokedWithResult`; the older `onPopInvoked(bool didPop)` was deprecated after v3.22.0-12.0.pre. Current code uses `PopScope` with `onPopInvokedWithResult` only. ## Testing the guard A widget test can drive both paths. Enter text, then simulate system back with `tester.binding.handlePopRoute()` and expect the confirmation dialog; choose Keep editing and expect the form to remain. Separately, `tester.pageBack()` taps the app-bar back button (it looks for the Back tooltip), which goes through `maybePop` and must also show the dialog. A third test taps Save and expects the previous screen to receive the address, proving `Navigator.pop` is not blocked. ## Common bugs - **Missing `if (didPop) return;`**: after a successful save-pop the callback pops again, removing the checkout screen underneath. - **Computing `canPop` inside the callback**: the decision must already be in `canPop` when the gesture starts. - **Forgetting `context.mounted`** after awaiting the dialog. - **Leaving `canPop: false` permanently**: on the root route this also stops Android back from leaving the app, which users read as a frozen screen. - **Using `Form`** without knowing it has its own `canPop` and `onPopInvokedWithResult`, which do the same job for form-based screens.

  • Why does the callback receive a result parameter, and when is it non-null?
    `onPopInvokedWithResult` reports the value the route was popped with. When the Save button calls `Navigator.pop(context, address)`, the callback runs with `didPop: true` and that address, which lets you log or react to the outcome. A blocked system back reports `didPop: false` and a `null` result.
  • On iOS, why might onPopInvokedWithResult never run when the form is dirty?
    The iOS swipe-back is recognized by Flutter's Cupertino page transition, not by the system. With `canPop: false` that gesture is disabled entirely, so no pop is attempted and the callback is not called. The app-bar back button still goes through `maybePop` and does trigger it.

saying these in an interview costs you the question

  • Still recommends WillPopScope with an async onWillPop
  • Thinks PopScope can cancel a pop from inside the callback
  • Believes canPop false also blocks Navigator.pop in code
  • Calls Navigator.pop in the callback without checking didPop
  • Leaves canPop false on the root route permanently