// 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); }); }