Skip to content

Commit dedd100

Browse files
authored
Predictive back support for root routes (flutter#120385)
This PR aims to support Android's predictive back gesture when popping the entire Flutter app. Predictive route transitions between routes inside of a Flutter app will come later. <img width="200" src="https://user-images.githubusercontent.com/389558/217918109-945febaa-9086-41cc-a476-1a189c7831d8.gif" /> ### Trying it out If you want to try this feature yourself, here are the necessary steps: 1. Run Android 33 or above. 1. Enable the feature flag for predictive back on the device under "Developer options". 1. Create a Flutter project, or clone [my example project](https://github.com/justinmc/flutter_predictive_back_examples). 1. Set `android:enableOnBackInvokedCallback="true"` in android/app/src/main/AndroidManifest.xml (already done in the example project). 1. Check out this branch. 1. Run the app. Perform a back gesture (swipe from the left side of the screen). You should see the predictive back animation like in the animation above and be able to commit or cancel it. ### go_router support go_router works with predictive back out of the box because it uses a Navigator internally that dispatches NavigationNotifications! ~~go_router can be supported by adding a listener to the router and updating SystemNavigator.setFrameworkHandlesBack.~~ Similar to with nested Navigators, nested go_routers is supported by using a PopScope widget. <details> <summary>Full example of nested go_routers</summary> ```dart // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:go_router/go_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(_MyApp()); class _MyApp extends StatelessWidget { final GoRouter router = GoRouter( routes: <RouteBase>[ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _HomePage(), ), GoRoute( path: '/nested_navigators', builder: (BuildContext context, GoRouterState state) => _NestedGoRoutersPage(), ), ], ); @OverRide Widget build(BuildContext context) { return MaterialApp.router( routerConfig: router, ); } } class _HomePage extends StatelessWidget { @OverRide Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Nested Navigators Example'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text('Home Page'), const Text('A system back gesture here will exit the app.'), const SizedBox(height: 20.0), ListTile( title: const Text('Nested go_router route'), subtitle: const Text('This route has another go_router in addition to the one used with MaterialApp above.'), onTap: () { context.push('/nested_navigators'); }, ), ], ), ), ); } } class _NestedGoRoutersPage extends StatefulWidget { @OverRide State<_NestedGoRoutersPage> createState() => _NestedGoRoutersPageState(); } class _NestedGoRoutersPageState extends State<_NestedGoRoutersPage> { late final GoRouter _router; final GlobalKey<NavigatorState> _nestedNavigatorKey = GlobalKey<NavigatorState>(); // If the nested navigator has routes that can be popped, then we want to // block the root navigator from handling the pop so that the nested navigator // can handle it instead. bool get _popEnabled { // canPop will throw an error if called before build. Is this the best way // to avoid that? return _nestedNavigatorKey.currentState == null ? true : !_router.canPop(); } void _onRouterChanged() { // Here the _router reports the location correctly, but canPop is still out // of date. Hence the post frame callback. SchedulerBinding.instance.addPostFrameCallback((Duration duration) { setState(() {}); }); } @OverRide void initState() { super.initState(); final BuildContext rootContext = context; _router = GoRouter( navigatorKey: _nestedNavigatorKey, routes: [ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _LinksPage( title: 'Nested once - home route', backgroundColor: Colors.indigo, onBack: () { rootContext.pop(); }, buttons: <Widget>[ TextButton( onPressed: () { context.push('/two'); }, child: const Text('Go to another route in this nested Navigator'), ), ], ), ), GoRoute( path: '/two', builder: (BuildContext context, GoRouterState state) => _LinksPage( backgroundColor: Colors.indigo.withBlue(255), title: 'Nested once - page two', ), ), ], ); _router.addListener(_onRouterChanged); } @OverRide void dispose() { _router.removeListener(_onRouterChanged); super.dispose(); } @OverRide Widget build(BuildContext context) { return PopScope( popEnabled: _popEnabled, onPopped: (bool success) { if (success) { return; } _router.pop(); }, child: Router<Object>.withConfig( restorationScopeId: 'router-2', config: _router, ), ); } } class _LinksPage extends StatelessWidget { const _LinksPage ({ required this.backgroundColor, this.buttons = const <Widget>[], this.onBack, required this.title, }); final Color backgroundColor; final List<Widget> buttons; final VoidCallback? onBack; final String title; @OverRide Widget build(BuildContext context) { return Scaffold( backgroundColor: backgroundColor, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text(title), //const Text('A system back here will go back to Nested Navigators Page One'), ...buttons, TextButton( onPressed: onBack ?? () { context.pop(); }, child: const Text('Go back'), ), ], ), ), ); } } ``` </details> ### Resources Fixes flutter#109513 Depends on engine PR flutter/engine#39208 ✔️ Design doc: https://docs.google.com/document/d/1BGCWy1_LRrXEB6qeqTAKlk-U2CZlKJ5xI97g45U7azk/edit# Migration guide: flutter/website#8952
1 parent daf5827 commit dedd100

36 files changed

+3241
-302
lines changed

dev/integration_tests/flutter_gallery/lib/demo/cupertino/cupertino_navigation_demo.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ class CupertinoNavigationDemo extends StatelessWidget {
4848

4949
@override
5050
Widget build(BuildContext context) {
51-
return WillPopScope(
51+
return PopScope(
5252
// Prevent swipe popping of this page. Use explicit exit buttons only.
53-
onWillPop: () => Future<bool>.value(true),
53+
canPop: false,
5454
child: DefaultTextStyle(
5555
style: CupertinoTheme.of(context).textTheme.textStyle,
5656
child: CupertinoTabScaffold(

dev/integration_tests/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'package:flutter/material.dart';
6+
import 'package:flutter/services.dart';
67
import 'package:intl/intl.dart';
78

89
// This demo is based on
@@ -109,16 +110,15 @@ class FullScreenDialogDemoState extends State<FullScreenDialogDemo> {
109110
bool _hasName = false;
110111
late String _eventName;
111112

112-
Future<bool> _onWillPop() async {
113-
_saveNeeded = _hasLocation || _hasName || _saveNeeded;
114-
if (!_saveNeeded) {
115-
return true;
113+
Future<void> _handlePopInvoked(bool didPop) async {
114+
if (didPop) {
115+
return;
116116
}
117117

118118
final ThemeData theme = Theme.of(context);
119119
final TextStyle dialogTextStyle = theme.textTheme.titleMedium!.copyWith(color: theme.textTheme.bodySmall!.color);
120120

121-
return showDialog<bool>(
121+
final bool? shouldDiscard = await showDialog<bool>(
122122
context: context,
123123
builder: (BuildContext context) {
124124
return AlertDialog(
@@ -130,19 +130,31 @@ class FullScreenDialogDemoState extends State<FullScreenDialogDemo> {
130130
TextButton(
131131
child: const Text('CANCEL'),
132132
onPressed: () {
133-
Navigator.of(context).pop(false); // Pops the confirmation dialog but not the page.
133+
// Pop the confirmation dialog and indicate that the page should
134+
// not be popped.
135+
Navigator.of(context).pop(false);
134136
},
135137
),
136138
TextButton(
137139
child: const Text('DISCARD'),
138140
onPressed: () {
139-
Navigator.of(context).pop(true); // Returning true to _onWillPop will pop again.
141+
// Pop the confirmation dialog and indicate that the page should
142+
// be popped, too.
143+
Navigator.of(context).pop(true);
140144
},
141145
),
142146
],
143147
);
144148
},
145-
) as Future<bool>;
149+
);
150+
151+
if (shouldDiscard ?? false) {
152+
// Since this is the root route, quit the app where possible by invoking
153+
// the SystemNavigator. If this wasn't the root route, then
154+
// Navigator.maybePop could be used instead.
155+
// See https://github.com/flutter/flutter/issues/11490
156+
SystemNavigator.pop();
157+
}
146158
}
147159

148160
@override
@@ -162,7 +174,8 @@ class FullScreenDialogDemoState extends State<FullScreenDialogDemo> {
162174
],
163175
),
164176
body: Form(
165-
onWillPop: _onWillPop,
177+
canPop: !_saveNeeded && !_hasLocation && !_hasName,
178+
onPopInvoked: _handlePopInvoked,
166179
child: Scrollbar(
167180
child: ListView(
168181
primary: true,

dev/integration_tests/flutter_gallery/lib/demo/material/text_form_field_demo.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,9 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
143143
return null;
144144
}
145145

146-
Future<bool> _warnUserAboutInvalidData() async {
147-
final FormState? form = _formKey.currentState;
148-
if (form == null || !_formWasEdited || form.validate()) {
149-
return true;
146+
Future<void> _handlePopInvoked(bool didPop) async {
147+
if (didPop) {
148+
return;
150149
}
151150

152151
final bool? result = await showDialog<bool>(
@@ -168,7 +167,14 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
168167
);
169168
},
170169
);
171-
return result!;
170+
171+
if (result ?? false) {
172+
// Since this is the root route, quit the app where possible by invoking
173+
// the SystemNavigator. If this wasn't the root route, then
174+
// Navigator.maybePop could be used instead.
175+
// See https://github.com/flutter/flutter/issues/11490
176+
SystemNavigator.pop();
177+
}
172178
}
173179

174180
@override
@@ -185,7 +191,8 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
185191
child: Form(
186192
key: _formKey,
187193
autovalidateMode: _autovalidateMode,
188-
onWillPop: _warnUserAboutInvalidData,
194+
canPop: _formKey.currentState == null || !_formWasEdited || _formKey.currentState!.validate(),
195+
onPopInvoked: _handlePopInvoked,
189196
child: Scrollbar(
190197
child: SingleChildScrollView(
191198
primary: true,

dev/integration_tests/flutter_gallery/lib/demo/shrine/expanding_bottom_sheet.dart

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
// found in the LICENSE file.
44

55
import 'package:flutter/material.dart';
6-
import 'package:flutter/services.dart';
76
import 'package:scoped_model/scoped_model.dart';
87

98
import 'colors.dart';
@@ -361,14 +360,12 @@ class ExpandingBottomSheetState extends State<ExpandingBottomSheet> with TickerP
361360

362361
// Closes the cart if the cart is open, otherwise exits the app (this should
363362
// only be relevant for Android).
364-
Future<bool> _onWillPop() async {
365-
if (!_isOpen) {
366-
await SystemNavigator.pop();
367-
return true;
363+
void _handlePopInvoked(bool didPop) {
364+
if (didPop) {
365+
return;
368366
}
369367

370368
close();
371-
return true;
372369
}
373370

374371
@override
@@ -378,8 +375,9 @@ class ExpandingBottomSheetState extends State<ExpandingBottomSheet> with TickerP
378375
duration: const Duration(milliseconds: 225),
379376
curve: Curves.easeInOut,
380377
alignment: FractionalOffset.topLeft,
381-
child: WillPopScope(
382-
onWillPop: _onWillPop,
378+
child: PopScope(
379+
canPop: !_isOpen,
380+
onPopInvoked: _handlePopInvoked,
383381
child: AnimatedBuilder(
384382
animation: widget.hideController,
385383
builder: _buildSlideAnimation,

dev/integration_tests/flutter_gallery/lib/gallery/home.dart

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,14 @@ class _GalleryHomeState extends State<GalleryHome> with SingleTickerProviderStat
325325
backgroundColor: isDark ? _kFlutterBlue : theme.primaryColor,
326326
body: SafeArea(
327327
bottom: false,
328-
child: WillPopScope(
329-
onWillPop: () {
330-
// Pop the category page if Android back button is pressed.
331-
if (_category != null) {
332-
setState(() => _category = null);
333-
return Future<bool>.value(false);
328+
child: PopScope(
329+
canPop: _category == null,
330+
onPopInvoked: (bool didPop) {
331+
if (didPop) {
332+
return;
334333
}
335-
return Future<bool>.value(true);
334+
// Pop the category page if Android back button is pressed.
335+
setState(() => _category = null);
336336
},
337337
child: Backdrop(
338338
backTitle: const Text('Options'),

examples/api/lib/material/navigation_bar/navigation_bar.2.dart

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,10 @@ class _HomeState extends State<Home> with TickerProviderStateMixin<Home> {
7171

7272
@override
7373
Widget build(BuildContext context) {
74-
return WillPopScope(
75-
onWillPop: () async {
74+
return NavigatorPopHandler(
75+
onPop: () {
7676
final NavigatorState navigator = navigatorKeys[selectedIndex].currentState!;
77-
if (!navigator.canPop()) {
78-
return true;
79-
}
8077
navigator.pop();
81-
return false;
8278
},
8379
child: Scaffold(
8480
body: SafeArea(
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/services.dart';
7+
8+
/// This sample demonstrates showing a confirmation dialog when the user
9+
/// attempts to navigate away from a page with unsaved [Form] data.
10+
11+
void main() => runApp(const FormApp());
12+
13+
class FormApp extends StatelessWidget {
14+
const FormApp({
15+
super.key,
16+
});
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
return MaterialApp(
21+
home: Scaffold(
22+
appBar: AppBar(
23+
title: const Text('Confirmation Dialog Example'),
24+
),
25+
body: Center(
26+
child: _SaveableForm(),
27+
),
28+
),
29+
);
30+
}
31+
}
32+
33+
class _SaveableForm extends StatefulWidget {
34+
@override
35+
State<_SaveableForm> createState() => _SaveableFormState();
36+
}
37+
38+
class _SaveableFormState extends State<_SaveableForm> {
39+
final TextEditingController _controller = TextEditingController();
40+
String _savedValue = '';
41+
bool _isDirty = false;
42+
43+
@override
44+
void initState() {
45+
super.initState();
46+
_controller.addListener(_onChanged);
47+
}
48+
49+
@override
50+
void dispose() {
51+
_controller.removeListener(_onChanged);
52+
super.dispose();
53+
}
54+
55+
void _onChanged() {
56+
final bool nextIsDirty = _savedValue != _controller.text;
57+
if (nextIsDirty == _isDirty) {
58+
return;
59+
}
60+
setState(() {
61+
_isDirty = nextIsDirty;
62+
});
63+
}
64+
65+
Future<void> _showDialog() async {
66+
final bool? shouldDiscard = await showDialog<bool>(
67+
context: context,
68+
builder: (BuildContext context) {
69+
return AlertDialog(
70+
title: const Text('Are you sure?'),
71+
content: const Text('Any unsaved changes will be lost!'),
72+
actions: <Widget>[
73+
TextButton(
74+
child: const Text('Yes, discard my changes'),
75+
onPressed: () {
76+
Navigator.pop(context, true);
77+
},
78+
),
79+
TextButton(
80+
child: const Text('No, continue editing'),
81+
onPressed: () {
82+
Navigator.pop(context, false);
83+
},
84+
),
85+
],
86+
);
87+
},
88+
);
89+
90+
if (shouldDiscard ?? false) {
91+
// Since this is the root route, quit the app where possible by invoking
92+
// the SystemNavigator. If this wasn't the root route, then
93+
// Navigator.maybePop could be used instead.
94+
// See https://github.com/flutter/flutter/issues/11490
95+
SystemNavigator.pop();
96+
}
97+
}
98+
99+
void _save(String? value) {
100+
setState(() {
101+
_savedValue = value ?? '';
102+
});
103+
}
104+
105+
@override
106+
Widget build(BuildContext context) {
107+
return Center(
108+
child: Column(
109+
mainAxisAlignment: MainAxisAlignment.center,
110+
children: <Widget>[
111+
const Text('If the field below is unsaved, a confirmation dialog will be shown on back.'),
112+
const SizedBox(height: 20.0),
113+
Form(
114+
canPop: !_isDirty,
115+
onPopInvoked: (bool didPop) {
116+
if (didPop) {
117+
return;
118+
}
119+
_showDialog();
120+
},
121+
autovalidateMode: AutovalidateMode.always,
122+
child: Column(
123+
mainAxisAlignment: MainAxisAlignment.center,
124+
children: <Widget>[
125+
TextFormField(
126+
controller: _controller,
127+
onFieldSubmitted: (String? value) {
128+
_save(value);
129+
},
130+
),
131+
TextButton(
132+
onPressed: () {
133+
_save(_controller.text);
134+
},
135+
child: Row(
136+
children: <Widget>[
137+
const Text('Save'),
138+
if (_controller.text.isNotEmpty)
139+
Icon(
140+
_isDirty ? Icons.warning : Icons.check,
141+
),
142+
],
143+
),
144+
),
145+
],
146+
),
147+
),
148+
TextButton(
149+
onPressed: () {
150+
if (_isDirty) {
151+
_showDialog();
152+
return;
153+
}
154+
// Since this is the root route, quit the app where possible by
155+
// invoking the SystemNavigator. If this wasn't the root route,
156+
// then Navigator.maybePop could be used instead.
157+
// See https://github.com/flutter/flutter/issues/11490
158+
SystemNavigator.pop();
159+
},
160+
child: const Text('Go back'),
161+
),
162+
],
163+
),
164+
);
165+
}
166+
}

0 commit comments

Comments
 (0)