Flutter Lesson 36 of 83 4 min read
Navigation Patterns in Flutter: Back Handling and Deep Links
Handle the back button with PopScope, confirm before leaving a form, set up deep links, and choose a navigation structure for your app.
On this page
Moving between screens is the easy part. This lesson covers the situations that make navigation feel right.
Intercepting back with PopScope #
A user fills in half a form and presses back. You should ask before discarding their work. PopScope controls whether the current route may be popped.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: StartPage()));
class StartPage extends StatelessWidget {
const StartPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: FilledButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute<void>(builder: (_) => const EditPage()),
),
child: const Text('Edit profile'),
),
),
);
}
}
class EditPage extends StatefulWidget {
const EditPage({super.key});
@override
State<EditPage> createState() => _EditPageState();
}
class _EditPageState extends State<EditPage> {
bool _dirty = false;
Future<bool> _confirmDiscard() async {
final discard = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep editing'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
),
],
),
);
return discard ?? false;
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_dirty, // back works normally until something is typed
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return; // already popped, nothing to do
final navigator = Navigator.of(context);
if (await _confirmDiscard()) navigator.pop();
},
child: Scaffold(
appBar: AppBar(title: const Text('Edit profile')),
body: Padding(
padding: const EdgeInsets.all(24),
child: TextField(
decoration: const InputDecoration(labelText: 'Name'),
onChanged: (_) {
if (!_dirty) setState(() => _dirty = true);
},
),
),
),
);
}
}
How it works:
canPop: truelets back behave normally.canPop: falseblocks it.onPopInvokedWithResultis then called withdidPopset tofalse, and you decide what to do.
PopScope replaces the older WillPopScope, which you will still find in existing code.
Double back to exit #
On a home screen, some apps ask for a second back press before closing.
DateTime? _lastBack;
PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
final now = DateTime.now();
if (_lastBack != null && now.difference(_lastBack!) < const Duration(seconds: 2)) {
SystemNavigator.pop(); // close the app (needs package:flutter/services.dart)
return;
}
_lastBack = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Press back again to exit')),
);
},
child: const HomeContent(),
)
Deep links #
A deep link opens the app at a specific screen from outside: a link in an email, a notification, a QR code.
With go_router, the Dart side is already done. A link to /product/42 is matched against your routes like any other location. What remains is telling each platform that your app handles your domain:
| Platform | Mechanism | What you set up |
|---|---|---|
| Android | App Links | An intent filter in AndroidManifest.xml, and an assetlinks.json file on your website |
| iOS | Universal Links | An Associated Domains capability in Xcode, and an apple-app-site-association file on your website |
The files on your website prove that you own both the app and the domain. Follow the current steps in the Flutter deep-linking documentation, since they change between releases.
Design for deep links from the start:
- Every screen that makes sense to open directly gets its own path.
- Screens load their data from an id in the path, not from an object passed in memory.
- The redirect logic handles a signed-out user arriving by link: send them to login, then on to where they were going.
Choosing a structure #
| Structure | Good for | Example |
|---|---|---|
| Stack only | Simple, linear apps | A calculator with a history screen |
| Bottom navigation | Three to five equal sections | Social, shopping, banking |
| Drawer | Many sections of unequal importance | Email, admin tools |
| Tabs within a screen | Views of the same data | Orders: active and past |
| Rail or permanent drawer | Tablets and desktop | A dashboard |
Rules that keep navigation predictable #
- Back always goes to where the user came from, never somewhere surprising.
- Top-level destinations do not stack. Switching tabs replaces the view; it does not push.
- After finishing a flow, clear it. After checkout, back should not return to the payment form. Use
goorpushAndRemoveUntil. - Do not navigate inside
build. If you must navigate in response to state, do it from a callback or a listener. - Show where the user is: a title in the app bar and a highlighted destination.
Showing the result of a flow #
A pattern for multi-step flows such as checkout:
final placed = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const CheckoutFlow()),
);
if (!context.mounted) return;
if (placed == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Order placed')),
);
}
The flow is one route containing its own steps, so popping it once returns to the cart with a result.
Try it yourself #
Take a form screen and add a PopScope that asks for confirmation only when the user has typed something. Then make the Save button pop with true without showing the dialog.