In Flutter, how does PopScope stop the back gesture from closing a half-filled address form, and how do you confirm before discarding?
answer
- decide ahead of time, not on press
- canPop: false while the form is dirty
- onPopInvokedWithResult(didPop, result)
- return early when didPop is true
- Navigator.pop ignores canPop
basics
~20 sWrap 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 linesimport '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
Recall that PopScope with canPop false blocks back navigation, and the callback is where you ask before leaving.
Explain the didPop flag, why the callback returns early when it is true, and which triggers respect canPop.
Show the production pitfalls: double pops, mounted checks after dialogs, iOS swipe-back, and root routes that can no longer exit.
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