Support for vetoing an attempt to pop the current route (#7488)

This commit is contained in:
Hans Muller 2017-01-18 11:04:18 -08:00 committed by GitHub
parent 17bc188803
commit 0ce9917fb2
11 changed files with 479 additions and 9 deletions

View file

@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart';
class TextFieldDemo extends StatefulWidget {
@ -31,6 +33,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
}
bool _autovalidate = false;
bool _formWasEdited = false;
GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
GlobalKey<FormFieldState<InputValue>> _passwordFieldKey = new GlobalKey<FormFieldState<InputValue>>();
void _handleSubmitted() {
@ -45,6 +48,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
}
String _validateName(InputValue value) {
_formWasEdited = true;
if (value.text.isEmpty)
return 'Name is required.';
RegExp nameExp = new RegExp(r'^[A-za-z ]+$');
@ -54,6 +58,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
}
String _validatePhoneNumber(InputValue value) {
_formWasEdited = true;
RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$');
if (!phoneExp.hasMatch(value.text))
return '###-###-#### - Please enter a valid phone number.';
@ -61,6 +66,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
}
String _validatePassword(InputValue value) {
_formWasEdited = true;
FormFieldState<InputValue> passwordField = _passwordFieldKey.currentState;
if (passwordField.value == null || passwordField.value.text.isEmpty)
return 'Please choose a password.';
@ -69,6 +75,30 @@ class TextFieldDemoState extends State<TextFieldDemo> {
return null;
}
Future<bool> _warnUserAboutInvalidData() {
final FormState form = _formKey.currentState;
if (!_formWasEdited || form.validate())
return new Future<bool>.value(true);
return showDialog/*<bool>*/(
context: context,
child: new AlertDialog(
title: new Text('This form has errors'),
content: new Text('Really leave this form?'),
actions: <Widget> [
new FlatButton(
child: new Text('YES'),
onPressed: () { Navigator.of(context).pop(true); },
),
new FlatButton(
child: new Text('NO'),
onPressed: () { Navigator.of(context).pop(false); },
),
],
),
);
}
@override
Widget build(BuildContext context) {
return new Scaffold(
@ -79,6 +109,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
body: new Form(
key: _formKey,
autovalidate: _autovalidate,
onWillPop: _warnUserAboutInvalidData,
child: new Block(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: <Widget>[

View file

@ -44,6 +44,7 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async {
expect(backButton, findsOneWidget);
await tester.tap(backButton);
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
return null;
}

View file

@ -33,7 +33,9 @@ void main() {
expect(backButton, findsOneWidget);
await tester.tap(backButton);
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(seconds: 1)); // transition is complete
//await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(find.text('UPDATE'), findsNothing);
});

View file

@ -200,8 +200,21 @@ class MaterialPageRoute<T> extends PageRoute<T> {
_CupertinoBackGestureController _backGestureController;
/// Support for dismissing this route with a horizontal swipe is enabled
/// for [TargetPlatform.iOS]. If attempts to dismiss this route might be
/// vetoed because a [WillPopCallback] was defined for the route then the
/// platform-specific back gesture is disabled.
///
/// See also:
///
/// * [hasScopedWillPopCallback], which is true if a `willPop` callback
/// is defined for this route.
@override
NavigationGestureController startPopGesture(NavigatorState navigator) {
// If attempts to dismiss this route might be vetoed, then do not
// allow the user to dismiss the route with a swipe.
if (hasScopedWillPopCallback)
return null;
if (controller.status != AnimationStatus.completed)
return null;
assert(_backGestureController == null);

View file

@ -770,6 +770,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
bool _shouldShowBackArrow;
Future<Null> _back() async {
if (await Navigator.willPop(context) && mounted)
Navigator.pop(context);
}
Widget _getModifiedAppBar({ EdgeInsets padding, int elevation}) {
AppBar appBar = config.appBar;
if (appBar == null)
@ -800,7 +805,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
leading = new IconButton(
icon: new Icon(backIcon),
alignment: FractionalOffset.centerLeft,
onPressed: () => Navigator.pop(context),
onPressed: _back,
tooltip: 'Back' // TODO(ianh): Figure out how to localize this string
);
}

View file

@ -141,12 +141,15 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
super.dispose();
}
// On Android: the user has pressed the back button.
@override
bool didPopRoute() {
Future<bool> didPopRoute() async {
assert(mounted);
NavigatorState navigator = _navigator.currentState;
assert(navigator != null);
return navigator.pop();
if (!await navigator.willPop())
return true;
return mounted && navigator.pop();
}
@override

View file

@ -34,7 +34,7 @@ abstract class WidgetsBindingObserver {
/// box, and false otherwise. The [WidgetsApp] widget uses this
/// mechanism to notify the [Navigator] widget that it should pop
/// its current route if possible.
bool didPopRoute() => false;
Future<bool> didPopRoute() => new Future<bool>.value(false);
/// Called when the application's dimensions change. For example,
/// when a phone is rotated.
@ -158,9 +158,9 @@ abstract class WidgetsBinding extends BindingBase implements GestureBinding, Ren
/// [WidgetsApp] uses this in conjunction with a [Navigator] to
/// cause the back button to close dialog boxes, return from modal
/// pages, and so forth.
void handlePopRoute() {
for (WidgetsBindingObserver observer in _observers) {
if (observer.didPopRoute())
Future<Null> handlePopRoute() async {
for (WidgetsBindingObserver observer in new List<WidgetsBindingObserver>.from(_observers)) {
if (await observer.didPopRoute())
return;
}
SystemNavigator.pop();

View file

@ -5,6 +5,7 @@
import 'package:flutter/foundation.dart';
import 'framework.dart';
import 'routes.dart';
/// An optional container for grouping together multiple form field widgets
/// (e.g. [Input] widgets).
@ -23,6 +24,7 @@ class Form extends StatefulWidget {
Key key,
@required this.child,
this.autovalidate: false,
this.onWillPop,
}) : super(key: key) {
assert(child != null);
}
@ -48,6 +50,13 @@ class Form extends StatefulWidget {
/// [FormState.validate] to validate.
final bool autovalidate;
/// Enables the form to veto attempts by the user to dismiss the [ModalRoute]
/// that contains the form.
///
/// If the callback returns a Future that resolves to false, the form's route
/// will not be popped.
WillPopCallback onWillPop;
@override
FormState createState() => new FormState();
}
@ -56,8 +65,30 @@ class FormState extends State<Form> {
int _generation = 0;
Set<FormFieldState<dynamic>> _fields = new Set<FormFieldState<dynamic>>();
/// Called when a form field has changed. This will cause all form fields
/// to rebuild, useful if form fields have interdependencies.
@override
void dependenciesChanged() {
super.dependenciesChanged();
final ModalRoute<dynamic> route = ModalRoute.of(context);
if (route != null && config.onWillPop != null) {
// Avoid adding our callback twice by removing it first.
route.removeScopedWillPopCallback(config.onWillPop);
route.addScopedWillPopCallback(config.onWillPop);
}
}
@override
void didUpdateConfig(Form oldConfig) {
final ModalRoute<dynamic> route = ModalRoute.of(context);
if (config.onWillPop != oldConfig.onWillPop && route != null) {
if (oldConfig.onWillPop != null)
route.removeScopedWillPopCallback(oldConfig.onWillPop);
if (config.onWillPop != null)
route.addScopedWillPopCallback(config.onWillPop);
}
}
// Called when a form field has changed. This will cause all form fields
// to rebuild, useful if form fields have interdependencies.
void _fieldDidChange() {
setState(() {
++_generation;

View file

@ -66,6 +66,15 @@ abstract class Route<T> {
@mustCallSuper
void didReplace(Route<dynamic> oldRoute) { }
/// Returns false if this route wants to veto a [Navigator.pop]. This method is
/// called by [Naviagtor.willPop].
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that uses this mechanism.
Future<bool> willPop() async => true;
/// A request was made to pop this route. If the route can handle it
/// internally (e.g. because it has its own stack of internal state) then
/// return false, otherwise return true. Returning false will prevent the
@ -109,6 +118,11 @@ abstract class Route<T> {
/// back gesture), this should return a controller object that can be used to
/// control the transition animation's progress. Otherwise, it should return
/// null.
///
/// If attempts to dismiss this route might be vetoed, for example because
/// a [WillPopCallback] was defined for the route, then it may make sense
/// to disable the pop gesture. For example, the iOS back gesture is disabled
/// when [ModalRoute.hasScopedWillCallback] is true.
NavigationGestureController startPopGesture(NavigatorState navigator) {
return null;
}
@ -461,6 +475,20 @@ class Navigator extends StatefulWidget {
return Navigator.of(context).push(route);
}
/// Returns the value of the current route's `willPop` method. This method is
/// typically called before a user-initiated [pop]. For example on Android it's
/// called by the binding for the system's back button.
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that enables the form
/// to veto a [pop] initiated by the app's back button.
/// * [ModalRoute], which provides a `scopedWillPopCallback` that can be used
/// to define the route's `willPop` method.
static Future<bool> willPop(BuildContext context) {
return Navigator.of(context).willPop();
}
/// Pop a route off the navigator that most tightly encloses the given context.
///
/// Tries to removes the current route, calling its didPop() method. If that
@ -743,6 +771,22 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
assert(() { _debugLocked = false; return true; });
}
/// Returns the value of the current route's `willPop` method. This method is
/// typically called before a user-initiated [pop]. For example on Android it's
/// called by the binding for the system's back button.
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that enables the form
/// to veto a [pop] initiated by the app's back button.
/// * [ModalRoute], which has as a `willPop` method that can be defined
/// by a list of [WillPopCallback]s.
Future<bool> willPop() async {
final Route<dynamic> route = _history.last;
assert(route._navigator == this);
return route.willPop();
}
/// Removes the top route in the [Navigator]'s history.
///
/// If an argument is provided, that argument will be the return value of the

View file

@ -363,6 +363,12 @@ class _ModalScopeStatus extends InheritedWidget {
}
}
/// Signature for a callback that verifies that it's OK to call [Navigator.pop].
///
/// Used by [Form.onWillPop], [ModalRoute.addScopedWillPopCallback], and
/// [ModalRoute.removeScopedWillPopCallback].
typedef Future<bool> WillPopCallback();
class _ModalScope extends StatefulWidget {
_ModalScope({
Key key,
@ -378,6 +384,9 @@ class _ModalScope extends StatefulWidget {
}
class _ModalScopeState extends State<_ModalScope> {
// See addScopedWillPopCallback, removeScopedWillPopCallback in ModalRoute.
List<WillPopCallback> willPopCallbacks = <WillPopCallback>[];
@override
void initState() {
super.initState();
@ -394,9 +403,20 @@ class _ModalScopeState extends State<_ModalScope> {
void dispose() {
config.route.animation?.removeStatusListener(_animationStatusChanged);
config.route.forwardAnimation?.removeStatusListener(_animationStatusChanged);
willPopCallbacks = null;
super.dispose();
}
void addWillPopCallback(WillPopCallback callback) {
assert(mounted);
willPopCallbacks.add(callback);
}
void removeWillPopCallback(WillPopCallback callback) {
assert(mounted);
willPopCallbacks.remove(callback);
}
void _animationStatusChanged(AnimationStatus status) {
setState(() {
// The animation's states are our build state, and they changed already.
@ -618,6 +638,80 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
Animation<double> get forwardAnimation => _forwardAnimationProxy;
ProxyAnimation _forwardAnimationProxy;
/// Return the value of the first callback added with
/// [addScopedWillPopCallback] that returns false. Otherwise return true.
///
/// Typically this method is not overridden because applications usually
/// don't create modal routes directly, they use higher level primitives
/// like [showDialog]. The scoped [WillPopCallback] list makes it possible
/// for ModalRoute descendants to collectively define the value of `willPop`.
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that uses this mechanism.
/// * [addScopedWillPopCallback], which adds a callback to the list this
/// method checks.
/// * [removeScopedWillPopCallback], which removes a callback from the list
/// this method checks.
@override
Future<bool> willPop() async {
final _ModalScopeState scope = _scopeKey.currentState;
assert(scope != null);
for (WillPopCallback callback in new List<WillPopCallback>.from(scope.willPopCallbacks)) {
if (!await callback())
return false;
}
return true;
}
/// Enables this route to veto attempts by the user to dismiss it.
///
/// This callback is typically added by a stateful descendant of the modal route.
/// A stateful widget shown in a modal route, like the child passed to
/// [showDialog], can look up its modal route and then add a callback in its
/// `dependenciesChanged` method:
///
/// ```dart
/// @override
/// void dependenciesChanged() {
/// super.dependenciesChanged();
/// ModalRoute.of(context).addScopedWillPopCallback(askTheUserIfTheyAreSure);
/// }
/// ```
///
/// A typical application of this callback would be to warn the user about
/// unsaved [Form] data if the user attempts to back out of the form.
///
/// This callback runs asynchronously and it's possible that it will be called
/// after its route has been disposed. The callback should check [mounted] before
/// doing anything.
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that uses this mechanism.
/// * [willPop], which runs the callbacks added with this method.
/// * [removeScopedWillPopCallback], which removes a callback from the list
/// that [willPop] checks.
void addScopedWillPopCallback(WillPopCallback callback) {
assert(_scopeKey.currentState != null);
_scopeKey.currentState.addWillPopCallback(callback);
}
/// Remove one of the callbacks run by [willPop].
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that uses this mechanism.
/// * [addScopedWillPopCallback], which adds callback to the list
/// checked by [willPop].
void removeScopedWillPopCallback(WillPopCallback callback) {
assert(_scopeKey.currentState != null);
_scopeKey.currentState.removeWillPopCallback(callback);
}
bool get hasScopedWillPopCallback {
return _scopeKey.currentState == null || _scopeKey.currentState.willPopCallbacks.length > 0;
}
// Internals

View file

@ -0,0 +1,246 @@
// Copyright 2016 The Chromium 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
bool willPopValue = false;
class SamplePage extends StatefulWidget {
@override
SamplePageState createState() => new SamplePageState();
}
class SamplePageState extends State<SamplePage> {
@override
void dependenciesChanged() {
super.dependenciesChanged();
final ModalRoute<Null> route = ModalRoute.of(context);
if (route.isCurrent)
route.addScopedWillPopCallback(() async => willPopValue);
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text('Sample Page')),
);
}
}
int willPopCount = 0;
class SampleForm extends StatelessWidget {
SampleForm({ Key key, this.callback }) : super(key: key);
final WillPopCallback callback;
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text('Sample Form')),
body: new SizedBox.expand(
child: new Form(
onWillPop: () {
willPopCount += 1;
return callback();
},
child: new InputFormField(),
),
),
);
}
}
void main() {
testWidgets('ModalRoute scopedWillPopupCallback can inhibit back button', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
appBar: new AppBar(title: new Text('Home')),
body: new Builder(
builder: (BuildContext context) {
return new Center(
child: new FlatButton(
child: new Text('X'),
onPressed: () {
showDialog(
context: context,
child: new SamplePage(),
);
},
),
);
},
),
),
),
);
await tester.tap(find.text('X'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Sample Page'), findsOneWidget);
willPopValue = false;
await tester.tap(find.byTooltip('Back'));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Sample Page'), findsOneWidget);
willPopValue = true;
await tester.tap(find.byTooltip('Back'));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Sample Page'), findsNothing);
});
testWidgets('Form.willPop can inhibit back button', (WidgetTester tester) async {
Widget buildFrame() {
return new MaterialApp(
home: new Scaffold(
appBar: new AppBar(title: new Text('Home')),
body: new Builder(
builder: (BuildContext context) {
return new Center(
child: new FlatButton(
child: new Text('X'),
onPressed: () {
Navigator.of(context).push(new MaterialPageRoute<Null>(
builder: (BuildContext context) {
return new SampleForm(
callback: () => new Future<bool>.value(willPopValue),
);
}
));
},
),
);
},
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.tap(find.text('X'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Sample Form'), findsOneWidget);
willPopValue = false;
willPopCount = 0;
await tester.tap(find.byTooltip('Back'));
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
expect(find.text('Sample Form'), findsOneWidget);
expect(willPopCount, 1);
willPopValue = true;
willPopCount = 0;
await tester.tap(find.byTooltip('Back'));
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
expect(find.text('Sample Form'), findsNothing);
expect(willPopCount, 1);
});
testWidgets('Form.willPop callbacks do not accumulate', (WidgetTester tester) async {
Future<bool> showYesNoAlert(BuildContext context) {
return showDialog/*<bool>*/(
context: context,
child: new AlertDialog(
actions: <Widget> [
new FlatButton(
child: new Text('YES'),
onPressed: () { Navigator.of(context).pop(true); },
),
new FlatButton(
child: new Text('NO'),
onPressed: () { Navigator.of(context).pop(false); },
),
],
),
);
}
Widget buildFrame() {
return new MaterialApp(
home: new Scaffold(
appBar: new AppBar(title: new Text('Home')),
body: new Builder(
builder: (BuildContext context) {
return new Center(
child: new FlatButton(
child: new Text('X'),
onPressed: () {
Navigator.of(context).push(new MaterialPageRoute<Null>(
builder: (BuildContext context) {
return new SampleForm(
callback: () => showYesNoAlert(context),
);
}
));
},
),
);
},
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.tap(find.text('X'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Sample Form'), findsOneWidget);
// Press the Scaffold's back button. This causes the willPop callback
// to run, which shows the YES/NO Alert Dialog. Veto the back operation
// by pressing the Alert's NO button.
await tester.tap(find.byTooltip('Back'));
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Call willPop which will show an Alert.
await tester.tap(find.text('NO'));
await tester.pump(); // Start the dismiss animation.
await tester.pump(); // Resolve the willPop callback.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
expect(find.text('Sample Form'), findsOneWidget);
// Do it again. Note that each time the Alert is shown and dismissed
// the FormState's dependenciesChanged() method runs. We're making sure
// that the dependenciesChanged() method doesn't add an extra willPop
// callback.
await tester.tap(find.byTooltip('Back'));
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Call willPop which will show an Alert.
await tester.tap(find.text('NO'));
await tester.pump(); // Start the dismiss animation.
await tester.pump(); // Resolve the willPop callback.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
expect(find.text('Sample Form'), findsOneWidget);
// This time really dismiss the SampleForm by pressing the Alert's
// YES button.
await tester.tap(find.byTooltip('Back'));
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Call willPop which will show an Alert.
await tester.tap(find.text('YES'));
await tester.pump(); // Start the dismiss animation.
await tester.pump(); // Resolve the willPop callback.
await tester.pump(const Duration(seconds: 1)); // Wait until it has finished.
expect(find.text('Sample Form'), findsNothing);
});
}