Few things destroy the "native feel" of a Flutter app on Android faster than a broken hardware back button. With the migration to Android 14’s Predictive Back gesture and the deprecation of WillPopScope, many developers implementing GoRouter find themselves in a bind: either the back button does nothing, it closes the entire app instead of the route, or it ignores interception logic (like "Save changes?" dialogs).
The issue lies in the friction between GoRouter’s declarative routing API and Flutter’s Imperative Navigator 2.0 implementation of the new PopScope widget.
The Root Cause: Predictive Back & The PopScope Shift
Historically, WillPopScope allowed you to veto a pop action asynchronously. This is incompatible with Android 14's Predictive Back, which requires the OS to know in advance if a back gesture will be accepted or rejected to render the animation.
Flutter replaced this with PopScope. Unlike its predecessor, PopScope is not a veto system; it is a listener and a toggle.
canPop: false: This tells the Android OS, "Do not perform the system navigation. Send a signal to the app instead."- GoRouter's Role: GoRouter wraps the standard
Navigator. When you press back, the System Navigator sends a pop request. IfcanPopis false, the request stops at thePopScope, and GoRouter never receives the signal to pop the route stack.
The bug usually manifests when developers set canPop: false to show a confirmation dialog but fail to manually drive the navigation controller afterwards, effectively soft-locking the user on that screen.
The Fix: Correctly Implementing PopScope with GoRouter
To fix this, we must decouple the permission to pop from the action of popping.
The following solution demonstrates a "Dirty Form" scenario. We want to prevent the user from navigating back (via hardware button or swipe) unless they confirm discarding unsaved changes.
Prerequisites
- Flutter 3.22+ (for
onPopInvokedWithResult) go_routerlatest
Complete Implementation
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
/// 1. Router Configuration
/// We define a simple two-screen flow to demonstrate the back stack behavior.
final _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/edit',
builder: (context, state) => const EditProfileScreen(),
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
theme: ThemeData(useMaterial3: true),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: FilledButton(
onPressed: () => context.go('/edit'),
child: const Text('Go to Edit Profile'),
),
),
);
}
}
/// 2. The Interception Logic
/// This screen implements the PopScope logic.
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
// Simulates a form state. If true, we block exit.
bool _hasUnsavedChanges = false;
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
final isDirty = _controller.text.isNotEmpty;
if (_hasUnsavedChanges != isDirty) {
setState(() {
_hasUnsavedChanges = isDirty;
});
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Logic to determine if we should allow exit
Future<bool> _onWillPop() async {
// Show confirmation dialog
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Discard changes?'),
content: const Text('You have unsaved changes. Are you sure you want to leave?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Discard'),
),
],
);
},
);
return shouldPop ?? false;
}
@override
Widget build(BuildContext context) {
// CRITICAL: PopScope Configuration
return PopScope(
// If _hasUnsavedChanges is true, canPop is false.
// This disables the system back gesture immediately.
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: (didPop, result) async {
// 1. If didPop is true, the system has already handled the navigation.
// DO NOT run logic here, or you will get double-pops or crashes.
if (didPop) {
return;
}
// 2. Since canPop is false, we are here because the user tried to go back.
// We now run our async logic.
final bool shouldPop = await _onWillPop();
if (shouldPop) {
// 3. User confirmed discard. We must manually pop the context.
if (context.mounted) {
// We cannot just call context.pop() immediately if we haven't changed state,
// because `canPop` is still false, which might trigger an infinite loop
// depending on implementation.
//
// However, GoRouter's `pop` usually bypasses the system gesture check
// when called programmatically *within* the app, but to be
// strictly compliant with Predictive Back, it is cleaner to:
// A) Allow the pop programmatically.
context.pop();
}
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Type something to lock back button',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
if (_hasUnsavedChanges)
const Text(
'Unsaved changes - Back button is locked',
style: TextStyle(color: Colors.red),
),
],
),
),
),
);
}
}
The Explanation
1. canPop Controls the OS, Not the Router
When canPop is set to false, Flutter informs the Android embedding layer that the back gesture is disabled. Android stops the gesture at the edge of the screen (or the button press). It does not bubble up to GoRouter's stack logic. This is why setting canPop: false and doing nothing else results in a "dead" back button.
2. onPopInvokedWithResult vs didPop
The callback onPopInvokedWithResult is called after a pop attempt. The didPop boolean is the most critical check.
didPop: true: The system successfully popped a route. This happens ifcanPopwas true. You must return immediately. If you try to show a dialog here, the screen is likely already gone or closing.didPop: false: The pop was blocked becausecanPopwas false. This is your event hook. The user wants to leave, but you stopped them. Now you execute your custom logic (the dialog).
3. Manual Navigation
Because you blocked the system's automatic pop mechanism, you are now responsible for the navigation. If the user accepts the dialog (chooses "Discard"), you must call context.pop().
Note on GoRouter: Calling context.pop() programmatically typically bypasses the PopScope blocking mechanism in the Flutter framework, allowing the navigation to proceed even if canPop is technically still false in the widget tree state.
Conclusion
The Android hardware back button is not merely a trigger for Navigator.pop(); it is an OS-level intent that interacts with the specific history stack implementation of your router. When using GoRouter with modern Flutter, stop trying to veto the pop. Instead, disable the pop via canPop: false, intercept the intent in onPopInvokedWithResult, and manually drive the GoRouter controller when your business logic is satisfied.