Dart Tutorial

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: true lets back behave normally.
  • canPop: false blocks it. onPopInvokedWithResult is then called with didPop set to false, 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(),
)

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:

PlatformMechanismWhat you set up
AndroidApp LinksAn intent filter in AndroidManifest.xml, and an assetlinks.json file on your website
iOSUniversal LinksAn 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 #

StructureGood forExample
Stack onlySimple, linear appsA calculator with a history screen
Bottom navigationThree to five equal sectionsSocial, shopping, banking
DrawerMany sections of unequal importanceEmail, admin tools
Tabs within a screenViews of the same dataOrders: active and past
Rail or permanent drawerTablets and desktopA 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 go or pushAndRemoveUntil.
  • 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.

Practise in the playground Updated by Santosh Adhikari