
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 https://github.com/flutter/flutter/issues/109513 Depends on engine PR https://github.com/flutter/engine/pull/39208 ✔️ Design doc: https://docs.google.com/document/d/1BGCWy1_LRrXEB6qeqTAKlk-U2CZlKJ5xI97g45U7azk/edit# Migration guide: https://github.com/flutter/website/pull/8952
167 lines
4.6 KiB
Dart
167 lines
4.6 KiB
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:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
/// This sample demonstrates showing a confirmation dialog when the user
|
|
/// attempts to navigate away from a page with unsaved [Form] data.
|
|
|
|
void main() => runApp(const FormApp());
|
|
|
|
class FormApp extends StatelessWidget {
|
|
const FormApp({
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
home: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Confirmation Dialog Example'),
|
|
),
|
|
body: Center(
|
|
child: _SaveableForm(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SaveableForm extends StatefulWidget {
|
|
@override
|
|
State<_SaveableForm> createState() => _SaveableFormState();
|
|
}
|
|
|
|
class _SaveableFormState extends State<_SaveableForm> {
|
|
final TextEditingController _controller = TextEditingController();
|
|
String _savedValue = '';
|
|
bool _isDirty = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.addListener(_onChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_onChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onChanged() {
|
|
final bool nextIsDirty = _savedValue != _controller.text;
|
|
if (nextIsDirty == _isDirty) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isDirty = nextIsDirty;
|
|
});
|
|
}
|
|
|
|
Future<void> _showDialog() async {
|
|
final bool? shouldDiscard = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Are you sure?'),
|
|
content: const Text('Any unsaved changes will be lost!'),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
child: const Text('Yes, discard my changes'),
|
|
onPressed: () {
|
|
Navigator.pop(context, true);
|
|
},
|
|
),
|
|
TextButton(
|
|
child: const Text('No, continue editing'),
|
|
onPressed: () {
|
|
Navigator.pop(context, false);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (shouldDiscard ?? false) {
|
|
// Since this is the root route, quit the app where possible by invoking
|
|
// the SystemNavigator. If this wasn't the root route, then
|
|
// Navigator.maybePop could be used instead.
|
|
// See https://github.com/flutter/flutter/issues/11490
|
|
SystemNavigator.pop();
|
|
}
|
|
}
|
|
|
|
void _save(String? value) {
|
|
setState(() {
|
|
_savedValue = value ?? '';
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
const Text('If the field below is unsaved, a confirmation dialog will be shown on back.'),
|
|
const SizedBox(height: 20.0),
|
|
Form(
|
|
canPop: !_isDirty,
|
|
onPopInvoked: (bool didPop) {
|
|
if (didPop) {
|
|
return;
|
|
}
|
|
_showDialog();
|
|
},
|
|
autovalidateMode: AutovalidateMode.always,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
TextFormField(
|
|
controller: _controller,
|
|
onFieldSubmitted: (String? value) {
|
|
_save(value);
|
|
},
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
_save(_controller.text);
|
|
},
|
|
child: Row(
|
|
children: <Widget>[
|
|
const Text('Save'),
|
|
if (_controller.text.isNotEmpty)
|
|
Icon(
|
|
_isDirty ? Icons.warning : Icons.check,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (_isDirty) {
|
|
_showDialog();
|
|
return;
|
|
}
|
|
// Since this is the root route, quit the app where possible by
|
|
// invoking the SystemNavigator. If this wasn't the root route,
|
|
// then Navigator.maybePop could be used instead.
|
|
// See https://github.com/flutter/flutter/issues/11490
|
|
SystemNavigator.pop();
|
|
},
|
|
child: const Text('Go back'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|