Flutter PopScope Localization: Back Navigation Control for Multilingual Apps
PopScope is a Flutter widget that controls whether the current route can be popped, replacing the deprecated WillPopScope. In multilingual applications, PopScope is essential for showing localized confirmation dialogs when users attempt to leave unsaved forms, providing translated "discard changes?" prompts, and handling back navigation with locale-aware messaging.
Understanding PopScope in Localization Context
PopScope intercepts back navigation attempts (system back button, swipe gesture, or programmatic pop) and lets you conditionally prevent or allow the pop. For multilingual apps, this enables:
- Localized "unsaved changes" confirmation dialogs before navigating away
- Translated exit prompts for multi-step forms and wizards
- Locale-aware "are you sure?" messaging for destructive navigation
- Accessible back navigation announcements in the active language
Why PopScope Matters for Multilingual Apps
PopScope provides:
- Form protection: Prevent accidental loss of form data with translated confirmation prompts
- Wizard navigation: Control step-by-step flow exit with localized messaging
- Exit confirmation: Show translated "leave app?" dialogs on the root route
- Conditional pops: Allow or block navigation based on form validation state with localized errors
Basic PopScope Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedPopScopeExample extends StatefulWidget {
const LocalizedPopScopeExample({super.key});
@override
State<LocalizedPopScopeExample> createState() =>
_LocalizedPopScopeExampleState();
}
class _LocalizedPopScopeExampleState extends State<LocalizedPopScopeExample> {
bool _hasUnsavedChanges = false;
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() => _hasUnsavedChanges = _controller.text.isNotEmpty);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<bool> _showDiscardDialog() async {
final l10n = AppLocalizations.of(context)!;
final result = await showDialog<bool>(
context: context,
builder: (context) {
final dialogL10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(dialogL10n.discardChangesTitle),
content: Text(dialogL10n.discardChangesMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(dialogL10n.keepEditingButton),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(dialogL10n.discardButton),
),
],
);
},
);
return result ?? false;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldDiscard = await _showDiscardDialog();
if (shouldDiscard && mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
appBar: AppBar(title: Text(l10n.editProfileTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: l10n.nameFieldLabel,
hintText: l10n.nameFieldHint,
),
),
const SizedBox(height: 16),
if (_hasUnsavedChanges)
Text(
l10n.unsavedChangesWarning,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
),
);
}
}
Advanced PopScope Patterns for Localization
Multi-Step Form with Localized Exit Confirmation
Wizard-style forms should warn users before they lose progress across multiple steps, with step-aware translated messages.
class LocalizedFormWizard extends StatefulWidget {
const LocalizedFormWizard({super.key});
@override
State<LocalizedFormWizard> createState() => _LocalizedFormWizardState();
}
class _LocalizedFormWizardState extends State<LocalizedFormWizard> {
int _currentStep = 0;
final int _totalSteps = 3;
Future<bool> _confirmExit() async {
final l10n = AppLocalizations.of(context)!;
final result = await showDialog<bool>(
context: context,
builder: (context) {
final dialogL10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(dialogL10n.exitWizardTitle),
content: Text(
dialogL10n.exitWizardMessage(_currentStep + 1, _totalSteps),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(dialogL10n.continueEditingButton),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(dialogL10n.exitButton),
),
],
);
},
);
return result ?? false;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: _currentStep == 0,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_currentStep > 0) {
setState(() => _currentStep--);
} else {
final shouldExit = await _confirmExit();
if (shouldExit && mounted) {
Navigator.pop(context);
}
}
},
child: Scaffold(
appBar: AppBar(
title: Text(l10n.registrationTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(
value: (_currentStep + 1) / _totalSteps,
),
const SizedBox(height: 8),
Text(
l10n.stepIndicator(_currentStep + 1, _totalSteps),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 24),
Text(
l10n.stepTitle(_currentStep + 1),
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
Row(
children: [
if (_currentStep > 0)
Expanded(
child: OutlinedButton(
onPressed: () =>
setState(() => _currentStep--),
child: Text(l10n.previousButton),
),
),
if (_currentStep > 0) const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
if (_currentStep < _totalSteps - 1) {
setState(() => _currentStep++);
} else {
Navigator.pop(context);
}
},
child: Text(
_currentStep < _totalSteps - 1
? l10n.nextButton
: l10n.submitButton,
),
),
),
],
),
],
),
),
),
);
}
}
App Exit Confirmation
On the root route, PopScope can show a localized "exit app?" dialog.
class AppExitConfirmation extends StatelessWidget {
const AppExitConfirmation({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) {
final dialogL10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(dialogL10n.exitAppTitle),
content: Text(dialogL10n.exitAppMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(dialogL10n.stayButton),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(dialogL10n.exitButton),
),
],
);
},
);
if (shouldExit == true) {
SystemNavigator.pop();
}
},
child: Scaffold(
appBar: AppBar(title: Text(l10n.homeTitle)),
body: Center(child: Text(l10n.welcomeMessage)),
),
);
}
}
Conditional Pop Based on Form Validation
PopScope can check form validation state and show locale-specific validation errors before allowing navigation.
class ValidatedFormPopScope extends StatefulWidget {
const ValidatedFormPopScope({super.key});
@override
State<ValidatedFormPopScope> createState() => _ValidatedFormPopScopeState();
}
class _ValidatedFormPopScopeState extends State<ValidatedFormPopScope> {
final _formKey = GlobalKey<FormState>();
bool _isDirty = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: !_isDirty,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final action = await showDialog<String>(
context: context,
builder: (context) {
final dialogL10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(dialogL10n.unsavedFormTitle),
content: Text(dialogL10n.unsavedFormMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, 'discard'),
child: Text(dialogL10n.discardButton),
),
OutlinedButton(
onPressed: () => Navigator.pop(context, 'save'),
child: Text(dialogL10n.saveAndLeaveButton),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'cancel'),
child: Text(dialogL10n.keepEditingButton),
),
],
);
},
);
if (!mounted) return;
if (action == 'discard') {
Navigator.pop(context);
} else if (action == 'save') {
if (_formKey.currentState?.validate() == true) {
_formKey.currentState?.save();
Navigator.pop(context);
}
}
},
child: Scaffold(
appBar: AppBar(title: Text(l10n.editFormTitle)),
body: Form(
key: _formKey,
onChanged: () => setState(() => _isDirty = true),
child: Padding(
padding: const EdgeInsets.all(16),
child: TextFormField(
decoration: InputDecoration(
labelText: l10n.contentFieldLabel,
),
validator: (value) =>
value?.isEmpty == true ? l10n.fieldRequiredError : null,
),
),
),
),
);
}
}
RTL Support and Bidirectional Layouts
PopScope's confirmation dialogs and form content automatically adapt to RTL through the inherited directionality. Button order in AlertDialog follows platform conventions.
class BidirectionalPopScope extends StatelessWidget {
const BidirectionalPopScope({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
Navigator.pop(context);
},
child: Scaffold(
appBar: AppBar(title: Text(l10n.formTitle)),
body: Padding(
padding: const EdgeInsetsDirectional.all(16),
child: Text(
l10n.formInstructions,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
),
),
);
}
}
Testing PopScope Localization
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
Widget buildTestWidget({Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const LocalizedPopScopeExample(),
),
),
child: const Text('Open'),
),
),
),
);
}
testWidgets('PopScope shows localized dialog', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.byType(PopScope), findsOneWidget);
});
}
Best Practices
Use
canPop: falsewithonPopInvokedWithResultfor the modern PopScope API, replacing the deprecated WillPopScope.Always show localized confirmation dialogs before discarding user input, using parameterized ARB strings for step counts.
Provide multiple dialog actions (Discard, Save & Leave, Keep Editing) with translated labels for form-based PopScope.
Track dirty state explicitly using form
onChangedcallbacks to determine when PopScope should intercept.Test back navigation in RTL to verify that confirmation dialogs display correctly and button actions work as expected.
Use
mountedchecks after async dialog results to prevent state updates on disposed widgets.
Conclusion
PopScope is the modern way to control back navigation in Flutter, replacing the deprecated WillPopScope. For multilingual apps, it provides the interception point for showing localized confirmation dialogs before users navigate away from unsaved forms, multi-step wizards, or the app itself. By combining PopScope with translated dialog content, parameterized step indicators, and conditional validation, you can build navigation flows that protect user work while communicating clearly in every supported language.