blob: b99e473d65b9778b798fa8cc16236290e950f091 [file] [log] [blame] [edit]
// 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_test/flutter_test.dart';
bool willPopValue = false;
class SamplePage extends StatefulWidget {
const SamplePage({ super.key });
@override
SamplePageState createState() => SamplePageState();
}
class SamplePageState extends State<SamplePage> {
ModalRoute<void>? _route;
Future<bool> _callback() async => willPopValue;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_route?.removeScopedWillPopCallback(_callback);
_route = ModalRoute.of(context);
_route?.addScopedWillPopCallback(_callback);
}
@override
void dispose() {
super.dispose();
_route?.removeScopedWillPopCallback(_callback);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample Page')),
);
}
}
int willPopCount = 0;
class SampleForm extends StatelessWidget {
const SampleForm({ super.key, required this.callback });
final WillPopCallback callback;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample Form')),
body: SizedBox.expand(
child: Form(
onWillPop: () {
willPopCount += 1;
return callback();
},
child: const TextField(),
),
),
);
}
}
// Expose the protected hasScopedWillPopCallback getter
class _TestPageRoute<T> extends MaterialPageRoute<T> {
_TestPageRoute({
super.settings,
required super.builder,
}) : super(maintainState: true);
bool get hasCallback => super.hasScopedWillPopCallback;
}
class _TestPage extends Page<dynamic> {
_TestPage({
required this.builder,
required LocalKey key,
}) : _key = GlobalKey(),
super(key: key);
final WidgetBuilder builder;
final GlobalKey<dynamic> _key;
@override
Route<dynamic> createRoute(BuildContext context) {
return _TestPageRoute<dynamic>(
settings: this,
builder: (BuildContext context) {
// keep state during move to another location in tree
return KeyedSubtree(key: _key, child: builder.call(context));
});
}
}
void main() {
testWidgets('ModalRoute scopedWillPopupCallback can inhibit back button', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Builder(
builder: (BuildContext context) {
return Center(
child: TextButton(
child: const Text('X'),
onPressed: () {
showDialog<void>(
context: context,
builder: (BuildContext context) => const SamplePage(),
);
},
),
);
},
),
),
),
);
expect(find.byTooltip('Back'), findsNothing);
expect(find.text('Sample Page'), findsNothing);
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);
// Use didPopRoute() to simulate the system back button. Check that
// didPopRoute() indicates that the notification was handled.
final dynamic widgetsAppState = tester.state(find.byType(WidgetsApp));
// ignore: avoid_dynamic_calls
expect(await widgetsAppState.didPopRoute(), isTrue);
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('willPop will only pop if the callback returns true', (WidgetTester tester) async {
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Builder(
builder: (BuildContext context) {
return Center(
child: TextButton(
child: const Text('X'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (BuildContext context) {
return SampleForm(
callback: () => Future<bool>.value(willPopValue),
);
},
));
},
),
);
},
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
expect(find.text('Sample Form'), findsOneWidget);
// Should pop if callback returns true
willPopValue = true;
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(find.text('Sample Form'), findsNothing);
});
testWidgets('Form.willPop can inhibit back button', (WidgetTester tester) async {
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Builder(
builder: (BuildContext context) {
return Center(
child: TextButton(
child: const Text('X'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (BuildContext context) {
return SampleForm(
callback: () => 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) async {
return (await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
actions: <Widget> [
TextButton(
child: const Text('YES'),
onPressed: () { Navigator.of(context).pop(true); },
),
TextButton(
child: const Text('NO'),
onPressed: () { Navigator.of(context).pop(false); },
),
],
);
},
))!;
}
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Builder(
builder: (BuildContext context) {
return Center(
child: TextButton(
child: const Text('X'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (BuildContext context) {
return 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.
// Each time the Alert is shown and dismissed the FormState's
// didChangeDependencies() method runs. We're making sure that the
// didChangeDependencies() 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);
});
testWidgets('Route.scopedWillPop callbacks do not accumulate', (WidgetTester tester) async {
late StateSetter contentsSetState; // call this to rebuild the route's SampleForm contents
bool contentsEmpty = false; // when true, don't include the SampleForm in the route
final _TestPageRoute<void> route = _TestPageRoute<void>(
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
contentsSetState = setState;
return contentsEmpty ? Container() : SampleForm(key: UniqueKey(), callback: () async => false);
},
);
},
);
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Builder(
builder: (BuildContext context) {
return Center(
child: TextButton(
child: const Text('X'),
onPressed: () {
Navigator.of(context).push(route);
},
),
);
},
),
),
);
}
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);
expect(route.hasCallback, isTrue);
// Rebuild the route's SampleForm child an additional 3x for good measure.
contentsSetState(() { });
await tester.pump();
contentsSetState(() { });
await tester.pump();
contentsSetState(() { });
await tester.pump();
// Now build the route's contents without the sample form.
contentsEmpty = true;
contentsSetState(() { });
await tester.pump();
expect(route.hasCallback, isFalse);
});
testWidgets('should handle new route if page moved from one navigator to another', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/89133
late StateSetter contentsSetState;
bool moveToAnotherNavigator = false;
final List<Page<dynamic>> pages = <Page<dynamic>>[
_TestPage(
key: UniqueKey(),
builder: (BuildContext context) {
return WillPopScope(
onWillPop: () async => true,
child: const Text('anchor'),
);
},
),
];
Widget buildNavigator(Key? key, List<Page<dynamic>> pages) {
return Navigator(
key: key,
pages: pages,
onPopPage: (Route<dynamic> route, dynamic result) {
return route.didPop(result);
},
);
}
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
contentsSetState = setState;
if (moveToAnotherNavigator) {
return buildNavigator(const ValueKey<int>(1), pages);
}
return buildNavigator(const ValueKey<int>(2), pages);
},
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.pump();
final _TestPageRoute<dynamic> route1 = ModalRoute.of(tester.element(find.text('anchor')))! as _TestPageRoute<dynamic>;
expect(route1.hasCallback, isTrue);
moveToAnotherNavigator = true;
contentsSetState(() {});
await tester.pump();
final _TestPageRoute<dynamic> route2 = ModalRoute.of(tester.element(find.text('anchor')))! as _TestPageRoute<dynamic>;
expect(route1.hasCallback, isFalse);
expect(route2.hasCallback, isTrue);
});
}