From 0ce9917fb2f206088aca629fed3e54951cb4d06f Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Wed, 18 Jan 2017 11:04:18 -0800 Subject: [PATCH] Support for vetoing an attempt to pop the current route (#7488) --- .../lib/demo/text_field_demo.dart | 31 +++ examples/flutter_gallery/test/smoke_test.dart | 1 + .../flutter_gallery/test/update_test.dart | 2 + packages/flutter/lib/src/material/page.dart | 13 + .../flutter/lib/src/material/scaffold.dart | 7 +- packages/flutter/lib/src/widgets/app.dart | 7 +- packages/flutter/lib/src/widgets/binding.dart | 8 +- packages/flutter/lib/src/widgets/form.dart | 35 ++- .../flutter/lib/src/widgets/navigator.dart | 44 ++++ packages/flutter/lib/src/widgets/routes.dart | 94 +++++++ .../flutter/test/material/will_pop_test.dart | 246 ++++++++++++++++++ 11 files changed, 479 insertions(+), 9 deletions(-) create mode 100644 packages/flutter/test/material/will_pop_test.dart diff --git a/examples/flutter_gallery/lib/demo/text_field_demo.dart b/examples/flutter_gallery/lib/demo/text_field_demo.dart index f82150f7573..8d5828f45a8 100644 --- a/examples/flutter_gallery/lib/demo/text_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/text_field_demo.dart @@ -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 { } bool _autovalidate = false; + bool _formWasEdited = false; GlobalKey _formKey = new GlobalKey(); GlobalKey> _passwordFieldKey = new GlobalKey>(); void _handleSubmitted() { @@ -45,6 +48,7 @@ class TextFieldDemoState extends State { } 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 { } 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 { } String _validatePassword(InputValue value) { + _formWasEdited = true; FormFieldState passwordField = _passwordFieldKey.currentState; if (passwordField.value == null || passwordField.value.text.isEmpty) return 'Please choose a password.'; @@ -69,6 +75,30 @@ class TextFieldDemoState extends State { return null; } + Future _warnUserAboutInvalidData() { + final FormState form = _formKey.currentState; + if (!_formWasEdited || form.validate()) + return new Future.value(true); + + return showDialog/**/( + context: context, + child: new AlertDialog( + title: new Text('This form has errors'), + content: new Text('Really leave this form?'), + actions: [ + 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 { body: new Form( key: _formKey, autovalidate: _autovalidate, + onWillPop: _warnUserAboutInvalidData, child: new Block( padding: const EdgeInsets.symmetric(horizontal: 16.0), children: [ diff --git a/examples/flutter_gallery/test/smoke_test.dart b/examples/flutter_gallery/test/smoke_test.dart index 9a445b9c45d..bc70e4b39f6 100644 --- a/examples/flutter_gallery/test/smoke_test.dart +++ b/examples/flutter_gallery/test/smoke_test.dart @@ -44,6 +44,7 @@ Future 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; } diff --git a/examples/flutter_gallery/test/update_test.dart b/examples/flutter_gallery/test/update_test.dart index 184a3d8e938..5ab567c0235 100644 --- a/examples/flutter_gallery/test/update_test.dart +++ b/examples/flutter_gallery/test/update_test.dart @@ -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); }); diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index abf09ad602f..495c7169aae 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -200,8 +200,21 @@ class MaterialPageRoute extends PageRoute { _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); diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 0f5705f8145..58a444e5491 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -770,6 +770,11 @@ class ScaffoldState extends State with TickerProviderStateMixin { bool _shouldShowBackArrow; + Future _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 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 ); } diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index df0f359877d..3fc268fed68 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -141,12 +141,15 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv super.dispose(); } + // On Android: the user has pressed the back button. @override - bool didPopRoute() { + Future 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 diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index d77917916a0..9fa9ddcbeb9 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -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 didPopRoute() => new Future.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 handlePopRoute() async { + for (WidgetsBindingObserver observer in new List.from(_observers)) { + if (await observer.didPopRoute()) return; } SystemNavigator.pop(); diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index 8fba49696c6..306c998bf1b 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -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
{ int _generation = 0; Set> _fields = new Set>(); - /// 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 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 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; diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index d77069cd812..f7c51401cc0 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -66,6 +66,15 @@ abstract class Route { @mustCallSuper void didReplace(Route 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 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 { /// 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 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 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 willPop() async { + final Route 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 diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index dc85e8ba77c..9b8d572dd03 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -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 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 willPopCallbacks = []; + @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 extends TransitionRoute with LocalHistoryRoute 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 willPop() async { + final _ModalScopeState scope = _scopeKey.currentState; + assert(scope != null); + for (WillPopCallback callback in new List.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 diff --git a/packages/flutter/test/material/will_pop_test.dart b/packages/flutter/test/material/will_pop_test.dart new file mode 100644 index 00000000000..3737365c20c --- /dev/null +++ b/packages/flutter/test/material/will_pop_test.dart @@ -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 { + @override + void dependenciesChanged() { + super.dependenciesChanged(); + final ModalRoute 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( + builder: (BuildContext context) { + return new SampleForm( + callback: () => new Future.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 showYesNoAlert(BuildContext context) { + return showDialog/**/( + context: context, + child: new AlertDialog( + actions: [ + 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( + 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); + }); + +}