From f68d03f1cdf16dcffe2a53ae645ec87094e85d40 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 17 Aug 2023 16:55:05 -0700 Subject: [PATCH] Reland root predictive back (#132249) Root predictive back (https://github.com/flutter/flutter/pull/120385) was reverted in https://github.com/flutter/flutter/pull/132167. This PR is an attempt to reland it. The reversion happened due to failed Google tests (b/295073110). --- .../cupertino/cupertino_navigation_demo.dart | 4 +- .../material/full_screen_dialog_demo.dart | 31 +- .../demo/material/text_form_field_demo.dart | 19 +- .../demo/shrine/expanding_bottom_sheet.dart | 14 +- .../flutter_gallery/lib/gallery/home.dart | 14 +- .../navigation_bar/navigation_bar.2.dart | 8 +- examples/api/lib/widgets/form/form.1.dart | 166 ++++ .../navigator_pop_handler.0.dart | 164 ++++ .../navigator_pop_handler.1.dart | 250 +++++ .../lib/widgets/pop_scope/pop_scope.0.dart | 128 +++ .../will_pop_scope/will_pop_scope.0.dart | 77 -- .../api/test/widgets/form/form.1_test.dart | 37 + .../navigator_pop_handler.0_test.dart | 48 + .../navigator_pop_handler.1_test.dart | 38 + .../api/test/widgets/navigator_utils.dart | 20 + .../widgets/pop_scope/pop_scope.0_test.dart | 66 ++ .../will_pop_scope/will_pop_scope.0_test.dart | 32 - packages/flutter/lib/src/cupertino/app.dart | 6 + packages/flutter/lib/src/cupertino/route.dart | 3 +- .../flutter/lib/src/cupertino/tab_view.dart | 28 +- packages/flutter/lib/src/material/about.dart | 13 +- packages/flutter/lib/src/material/app.dart | 6 + .../lib/src/services/system_navigator.dart | 34 + packages/flutter/lib/src/widgets/app.dart | 73 +- packages/flutter/lib/src/widgets/binding.dart | 28 +- packages/flutter/lib/src/widgets/form.dart | 59 +- .../flutter/lib/src/widgets/navigator.dart | 294 +++++- .../src/widgets/navigator_pop_handler.dart | 110 +++ .../flutter/lib/src/widgets/pop_scope.dart | 137 +++ packages/flutter/lib/src/widgets/routes.dart | 219 +++-- .../lib/src/widgets/will_pop_scope.dart | 17 +- packages/flutter/lib/widgets.dart | 2 + .../test/cupertino/tab_scaffold_test.dart | 131 ++- .../flutter/test/widgets/navigator_test.dart | 866 ++++++++++++++++++ .../flutter/test/widgets/navigator_utils.dart | 20 + .../flutter/test/widgets/pop_scope_test.dart | 361 ++++++++ 36 files changed, 3225 insertions(+), 298 deletions(-) create mode 100644 examples/api/lib/widgets/form/form.1.dart create mode 100644 examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart create mode 100644 examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart create mode 100644 examples/api/lib/widgets/pop_scope/pop_scope.0.dart delete mode 100644 examples/api/lib/widgets/will_pop_scope/will_pop_scope.0.dart create mode 100644 examples/api/test/widgets/form/form.1_test.dart create mode 100644 examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.0_test.dart create mode 100644 examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.1_test.dart create mode 100644 examples/api/test/widgets/navigator_utils.dart create mode 100644 examples/api/test/widgets/pop_scope/pop_scope.0_test.dart delete mode 100644 examples/api/test/widgets/will_pop_scope/will_pop_scope.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/navigator_pop_handler.dart create mode 100644 packages/flutter/lib/src/widgets/pop_scope.dart create mode 100644 packages/flutter/test/widgets/navigator_utils.dart create mode 100644 packages/flutter/test/widgets/pop_scope_test.dart diff --git a/dev/integration_tests/flutter_gallery/lib/demo/cupertino/cupertino_navigation_demo.dart b/dev/integration_tests/flutter_gallery/lib/demo/cupertino/cupertino_navigation_demo.dart index f6626b85e20..a68c2a194c9 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/cupertino/cupertino_navigation_demo.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/cupertino/cupertino_navigation_demo.dart @@ -48,9 +48,9 @@ class CupertinoNavigationDemo extends StatelessWidget { @override Widget build(BuildContext context) { - return WillPopScope( + return PopScope( // Prevent swipe popping of this page. Use explicit exit buttons only. - onWillPop: () => Future.value(true), + canPop: false, child: DefaultTextStyle( style: CupertinoTheme.of(context).textTheme.textStyle, child: CupertinoTabScaffold( diff --git a/dev/integration_tests/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart b/dev/integration_tests/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart index b6f7bbe6b6a..81ee4dacd86 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; // This demo is based on @@ -109,16 +110,15 @@ class FullScreenDialogDemoState extends State { bool _hasName = false; late String _eventName; - Future _onWillPop() async { - _saveNeeded = _hasLocation || _hasName || _saveNeeded; - if (!_saveNeeded) { - return true; + Future _handlePopInvoked(bool didPop) async { + if (didPop) { + return; } final ThemeData theme = Theme.of(context); final TextStyle dialogTextStyle = theme.textTheme.titleMedium!.copyWith(color: theme.textTheme.bodySmall!.color); - return showDialog( + final bool? shouldDiscard = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( @@ -130,19 +130,31 @@ class FullScreenDialogDemoState extends State { TextButton( child: const Text('CANCEL'), onPressed: () { - Navigator.of(context).pop(false); // Pops the confirmation dialog but not the page. + // Pop the confirmation dialog and indicate that the page should + // not be popped. + Navigator.of(context).pop(false); }, ), TextButton( child: const Text('DISCARD'), onPressed: () { - Navigator.of(context).pop(true); // Returning true to _onWillPop will pop again. + // Pop the confirmation dialog and indicate that the page should + // be popped, too. + Navigator.of(context).pop(true); }, ), ], ); }, - ) as Future; + ); + + 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(); + } } @override @@ -162,7 +174,8 @@ class FullScreenDialogDemoState extends State { ], ), body: Form( - onWillPop: _onWillPop, + canPop: !_saveNeeded && !_hasLocation && !_hasName, + onPopInvoked: _handlePopInvoked, child: Scrollbar( child: ListView( primary: true, diff --git a/dev/integration_tests/flutter_gallery/lib/demo/material/text_form_field_demo.dart b/dev/integration_tests/flutter_gallery/lib/demo/material/text_form_field_demo.dart index 5d3fee8d60f..c6f644ee74c 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/material/text_form_field_demo.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/material/text_form_field_demo.dart @@ -143,10 +143,9 @@ class TextFormFieldDemoState extends State { return null; } - Future _warnUserAboutInvalidData() async { - final FormState? form = _formKey.currentState; - if (form == null || !_formWasEdited || form.validate()) { - return true; + Future _handlePopInvoked(bool didPop) async { + if (didPop) { + return; } final bool? result = await showDialog( @@ -168,7 +167,14 @@ class TextFormFieldDemoState extends State { ); }, ); - return result!; + + if (result ?? 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(); + } } @override @@ -185,7 +191,8 @@ class TextFormFieldDemoState extends State { child: Form( key: _formKey, autovalidateMode: _autovalidateMode, - onWillPop: _warnUserAboutInvalidData, + canPop: _formKey.currentState == null || !_formWasEdited || _formKey.currentState!.validate(), + onPopInvoked: _handlePopInvoked, child: Scrollbar( child: SingleChildScrollView( primary: true, diff --git a/dev/integration_tests/flutter_gallery/lib/demo/shrine/expanding_bottom_sheet.dart b/dev/integration_tests/flutter_gallery/lib/demo/shrine/expanding_bottom_sheet.dart index 0391ef95bee..de2cfa43ef7 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/shrine/expanding_bottom_sheet.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/shrine/expanding_bottom_sheet.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:scoped_model/scoped_model.dart'; import 'colors.dart'; @@ -361,14 +360,12 @@ class ExpandingBottomSheetState extends State with TickerP // Closes the cart if the cart is open, otherwise exits the app (this should // only be relevant for Android). - Future _onWillPop() async { - if (!_isOpen) { - await SystemNavigator.pop(); - return true; + void _handlePopInvoked(bool didPop) { + if (didPop) { + return; } close(); - return true; } @override @@ -378,8 +375,9 @@ class ExpandingBottomSheetState extends State with TickerP duration: const Duration(milliseconds: 225), curve: Curves.easeInOut, alignment: FractionalOffset.topLeft, - child: WillPopScope( - onWillPop: _onWillPop, + child: PopScope( + canPop: !_isOpen, + onPopInvoked: _handlePopInvoked, child: AnimatedBuilder( animation: widget.hideController, builder: _buildSlideAnimation, diff --git a/dev/integration_tests/flutter_gallery/lib/gallery/home.dart b/dev/integration_tests/flutter_gallery/lib/gallery/home.dart index c661ce64486..4a5f01fede8 100644 --- a/dev/integration_tests/flutter_gallery/lib/gallery/home.dart +++ b/dev/integration_tests/flutter_gallery/lib/gallery/home.dart @@ -325,14 +325,14 @@ class _GalleryHomeState extends State with SingleTickerProviderStat backgroundColor: isDark ? _kFlutterBlue : theme.primaryColor, body: SafeArea( bottom: false, - child: WillPopScope( - onWillPop: () { - // Pop the category page if Android back button is pressed. - if (_category != null) { - setState(() => _category = null); - return Future.value(false); + child: PopScope( + canPop: _category == null, + onPopInvoked: (bool didPop) { + if (didPop) { + return; } - return Future.value(true); + // Pop the category page if Android back button is pressed. + setState(() => _category = null); }, child: Backdrop( backTitle: const Text('Options'), diff --git a/examples/api/lib/material/navigation_bar/navigation_bar.2.dart b/examples/api/lib/material/navigation_bar/navigation_bar.2.dart index 7af02cfe007..981eb5d8121 100644 --- a/examples/api/lib/material/navigation_bar/navigation_bar.2.dart +++ b/examples/api/lib/material/navigation_bar/navigation_bar.2.dart @@ -71,14 +71,10 @@ class _HomeState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { + return NavigatorPopHandler( + onPop: () { final NavigatorState navigator = navigatorKeys[selectedIndex].currentState!; - if (!navigator.canPop()) { - return true; - } navigator.pop(); - return false; }, child: Scaffold( body: SafeArea( diff --git a/examples/api/lib/widgets/form/form.1.dart b/examples/api/lib/widgets/form/form.1.dart new file mode 100644 index 00000000000..b5e8b6e09ca --- /dev/null +++ b/examples/api/lib/widgets/form/form.1.dart @@ -0,0 +1,166 @@ +// 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 _showDialog() async { + final bool? shouldDiscard = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Are you sure?'), + content: const Text('Any unsaved changes will be lost!'), + actions: [ + 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: [ + 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: [ + TextFormField( + controller: _controller, + onFieldSubmitted: (String? value) { + _save(value); + }, + ), + TextButton( + onPressed: () { + _save(_controller.text); + }, + child: Row( + children: [ + 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'), + ), + ], + ), + ); + } +} diff --git a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart new file mode 100644 index 00000000000..d81b74f65f7 --- /dev/null +++ b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart @@ -0,0 +1,164 @@ +// 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'; + +/// This sample demonstrates using [NavigatorPopHandler] to handle system back +/// gestures when there are nested [Navigator] widgets by delegating to the +/// current [Navigator]. + +void main() => runApp(const NavigatorPopHandlerApp()); + +class NavigatorPopHandlerApp extends StatelessWidget { + const NavigatorPopHandlerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _HomePage(), + '/nested_navigators': (BuildContext context) => const NestedNavigatorsPage(), + }, + ); + } +} + +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: [ + 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 Navigator route'), + subtitle: const Text('This route has another Navigator widget in addition to the one inside MaterialApp above.'), + onTap: () { + Navigator.of(context).pushNamed('/nested_navigators'); + }, + ), + ], + ), + ), + ); + } +} + +class NestedNavigatorsPage extends StatefulWidget { + const NestedNavigatorsPage({super.key}); + + @override + State createState() => _NestedNavigatorsPageState(); +} + +class _NestedNavigatorsPageState extends State { + final GlobalKey _nestedNavigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return NavigatorPopHandler( + onPop: () { + _nestedNavigatorKey.currentState!.maybePop(); + }, + child: Navigator( + key: _nestedNavigatorKey, + initialRoute: 'nested_navigators/one', + onGenerateRoute: (RouteSettings settings) { + switch (settings.name) { + case 'nested_navigators/one': + final BuildContext rootContext = context; + return MaterialPageRoute( + builder: (BuildContext context) => NestedNavigatorsPageOne( + onBack: () { + Navigator.of(rootContext).pop(); + }, + ), + ); + case 'nested_navigators/one/another_one': + return MaterialPageRoute( + builder: (BuildContext context) => const NestedNavigatorsPageTwo( + ), + ); + default: + throw Exception('Invalid route: ${settings.name}'); + } + }, + ), + ); + } +} + +class NestedNavigatorsPageOne extends StatelessWidget { + const NestedNavigatorsPageOne({ + required this.onBack, + super.key, + }); + + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Nested Navigators Page One'), + const Text('A system back here returns to the home page.'), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('nested_navigators/one/another_one'); + }, + child: const Text('Go to another route in this nested Navigator'), + ), + TextButton( + // Can't use Navigator.of(context).pop() because this is the root + // route, so it can't be popped. The Navigator above this needs to + // be popped. + onPressed: onBack, + child: const Text('Go back'), + ), + ], + ), + ), + ); + } +} + +class NestedNavigatorsPageTwo extends StatelessWidget { + const NestedNavigatorsPageTwo({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.withBlue(180), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Nested Navigators Page Two'), + const Text('A system back here will go back to Nested Navigators Page One'), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Go back'), + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart new file mode 100644 index 00000000000..04fbdedd34e --- /dev/null +++ b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart @@ -0,0 +1,250 @@ +// 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. + +// This sample demonstrates nested navigation in a bottom navigation bar. + +import 'package:flutter/material.dart'; + +// There are three possible tabs. +enum _Tab { + home, + one, + two, +} + +// Each tab has two possible pages. +enum _TabPage { + home, + one, +} + +typedef _TabPageCallback = void Function(List<_TabPage> pages); + +void main() => runApp(const NavigatorPopHandlerApp()); + +class NavigatorPopHandlerApp extends StatelessWidget { + const NavigatorPopHandlerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + initialRoute: '/home', + routes: { + '/home': (BuildContext context) => const _BottomNavPage( + ), + }, + ); + } +} + +class _BottomNavPage extends StatefulWidget { + const _BottomNavPage(); + + @override + State<_BottomNavPage> createState() => _BottomNavPageState(); +} + +class _BottomNavPageState extends State<_BottomNavPage> { + _Tab _tab = _Tab.home; + + final GlobalKey _tabHomeKey = GlobalKey(); + final GlobalKey _tabOneKey = GlobalKey(); + final GlobalKey _tabTwoKey = GlobalKey(); + + List<_TabPage> _tabHomePages = <_TabPage>[_TabPage.home]; + List<_TabPage> _tabOnePages = <_TabPage>[_TabPage.home]; + List<_TabPage> _tabTwoPages = <_TabPage>[_TabPage.home]; + + BottomNavigationBarItem _itemForPage(_Tab page) { + switch (page) { + case _Tab.home: + return const BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Go to Home', + ); + case _Tab.one: + return const BottomNavigationBarItem( + icon: Icon(Icons.one_k), + label: 'Go to One', + ); + case _Tab.two: + return const BottomNavigationBarItem( + icon: Icon(Icons.two_k), + label: 'Go to Two', + ); + } + } + + Widget _getPage(_Tab page) { + switch (page) { + case _Tab.home: + return _BottomNavTab( + key: _tabHomeKey, + title: 'Home Tab', + color: Colors.grey, + pages: _tabHomePages, + onChangedPages: (List<_TabPage> pages) { + setState(() { + _tabHomePages = pages; + }); + }, + ); + case _Tab.one: + return _BottomNavTab( + key: _tabOneKey, + title: 'Tab One', + color: Colors.amber, + pages: _tabOnePages, + onChangedPages: (List<_TabPage> pages) { + setState(() { + _tabOnePages = pages; + }); + }, + ); + case _Tab.two: + return _BottomNavTab( + key: _tabTwoKey, + title: 'Tab Two', + color: Colors.blueGrey, + pages: _tabTwoPages, + onChangedPages: (List<_TabPage> pages) { + setState(() { + _tabTwoPages = pages; + }); + }, + ); + } + } + + void _onItemTapped(int index) { + setState(() { + _tab = _Tab.values.elementAt(index); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: _getPage(_tab), + ), + bottomNavigationBar: BottomNavigationBar( + items: _Tab.values.map(_itemForPage).toList(), + currentIndex: _Tab.values.indexOf(_tab), + selectedItemColor: Colors.amber[800], + onTap: _onItemTapped, + ), + ); + } +} + +class _BottomNavTab extends StatefulWidget { + const _BottomNavTab({ + super.key, + required this.color, + required this.onChangedPages, + required this.pages, + required this.title, + }); + + final Color color; + final _TabPageCallback onChangedPages; + final List<_TabPage> pages; + final String title; + + @override + State<_BottomNavTab> createState() => _BottomNavTabState(); +} + +class _BottomNavTabState extends State<_BottomNavTab> { + final GlobalKey _navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return NavigatorPopHandler( + onPop: () { + _navigatorKey.currentState?.maybePop(); + }, + child: Navigator( + key: _navigatorKey, + onPopPage: (Route route, void result) { + if (!route.didPop(null)) { + return false; + } + widget.onChangedPages(<_TabPage>[ + ...widget.pages, + ]..removeLast()); + return true; + }, + pages: widget.pages.map((_TabPage page) { + switch (page) { + case _TabPage.home: + return MaterialPage( + child: _LinksPage( + title: 'Bottom nav - tab ${widget.title} - route $page', + backgroundColor: widget.color, + buttons: [ + TextButton( + onPressed: () { + widget.onChangedPages(<_TabPage>[ + ...widget.pages, + _TabPage.one, + ]); + }, + child: const Text('Go to another route in this nested Navigator'), + ), + ], + ), + ); + case _TabPage.one: + return MaterialPage( + child: _LinksPage( + backgroundColor: widget.color, + title: 'Bottom nav - tab ${widget.title} - route $page', + buttons: [ + TextButton( + onPressed: () { + widget.onChangedPages(<_TabPage>[ + ...widget.pages, + ]..removeLast()); + }, + child: const Text('Go back'), + ), + ], + ), + ); + } + }).toList(), + ), + ); + } +} + +class _LinksPage extends StatelessWidget { + const _LinksPage ({ + required this.backgroundColor, + this.buttons = const [], + required this.title, + }); + + final Color backgroundColor; + final List buttons; + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: backgroundColor, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title), + ...buttons, + ], + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/pop_scope/pop_scope.0.dart b/examples/api/lib/widgets/pop_scope/pop_scope.0.dart new file mode 100644 index 00000000000..6d144bd088d --- /dev/null +++ b/examples/api/lib/widgets/pop_scope/pop_scope.0.dart @@ -0,0 +1,128 @@ +// 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. + +// This sample demonstrates showing a confirmation dialog before navigating +// away from a page. + +import 'package:flutter/material.dart'; + +void main() => runApp(const NavigatorPopHandlerApp()); + +class NavigatorPopHandlerApp extends StatelessWidget { + const NavigatorPopHandlerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + initialRoute: '/home', + routes: { + '/home': (BuildContext context) => const _HomePage(), + '/two': (BuildContext context) => const _PageTwo(), + }, + ); + } +} + +class _HomePage extends StatefulWidget { + const _HomePage(); + + @override + State<_HomePage> createState() => _HomePageState(); +} + +class _HomePageState extends State<_HomePage> { + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Page One'), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/two'); + }, + child: const Text('Next page'), + ), + ], + ), + ), + ); + } +} + +class _PageTwo extends StatefulWidget { + const _PageTwo(); + + @override + State<_PageTwo> createState() => _PageTwoState(); +} + +class _PageTwoState extends State<_PageTwo> { + void _showBackDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Are you sure?'), + content: const Text( + 'Are you sure you want to leave this page?', + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Nevermind'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Leave'), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Page Two'), + PopScope( + canPop: false, + onPopInvoked: (bool didPop) { + if (didPop) { + return; + } + _showBackDialog(); + }, + child: TextButton( + onPressed: () { + _showBackDialog(); + }, + child: const Text('Go back'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/will_pop_scope/will_pop_scope.0.dart b/examples/api/lib/widgets/will_pop_scope/will_pop_scope.0.dart deleted file mode 100644 index 46dafa57b98..00000000000 --- a/examples/api/lib/widgets/will_pop_scope/will_pop_scope.0.dart +++ /dev/null @@ -1,77 +0,0 @@ -// 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'; - -/// Flutter code sample for [WillPopScope]. - -void main() => runApp(const WillPopScopeExampleApp()); - -class WillPopScopeExampleApp extends StatelessWidget { - const WillPopScopeExampleApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: WillPopScopeExample(), - ); - } -} - -class WillPopScopeExample extends StatefulWidget { - const WillPopScopeExample({super.key}); - - @override - State createState() => _WillPopScopeExampleState(); -} - -class _WillPopScopeExampleState extends State { - bool shouldPop = true; - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Scaffold( - appBar: AppBar( - title: const Text('Flutter WillPopScope demo'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - child: const Text('Push'), - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return const WillPopScopeExample(); - }, - ), - ); - }, - ), - OutlinedButton( - child: Text('shouldPop: $shouldPop'), - onPressed: () { - setState( - () { - shouldPop = !shouldPop; - }, - ); - }, - ), - const Text('Push to a new screen, then tap on shouldPop ' - 'button to toggle its value. Press the back ' - 'button in the appBar to check its behavior ' - 'for different values of shouldPop'), - ], - ), - ), - ), - ); - } -} diff --git a/examples/api/test/widgets/form/form.1_test.dart b/examples/api/test/widgets/form/form.1_test.dart new file mode 100644 index 00000000000..f9ccd71d521 --- /dev/null +++ b/examples/api/test/widgets/form/form.1_test.dart @@ -0,0 +1,37 @@ +// 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_api_samples/widgets/form/form.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can go back when form is clean', (WidgetTester tester) async { + await tester.pumpWidget( + const example.FormApp(), + ); + + expect(find.text('Are you sure?'), findsNothing); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + + expect(find.text('Are you sure?'), findsNothing); + }); + + testWidgets('Cannot go back when form is dirty', (WidgetTester tester) async { + await tester.pumpWidget( + const example.FormApp(), + ); + + expect(find.text('Are you sure?'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'some new text'); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + + expect(find.text('Are you sure?'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.0_test.dart b/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.0_test.dart new file mode 100644 index 00000000000..88f29223f60 --- /dev/null +++ b/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.0_test.dart @@ -0,0 +1,48 @@ +// 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_api_samples/widgets/navigator_pop_handler/navigator_pop_handler.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +import '../navigator_utils.dart'; + +void main() { + testWidgets('Can go back with system back gesture', (WidgetTester tester) async { + await tester.pumpWidget( + const example.NavigatorPopHandlerApp(), + ); + + expect(find.text('Nested Navigators Example'), findsOneWidget); + expect(find.text('Nested Navigators Page One'), findsNothing); + expect(find.text('Nested Navigators Page Two'), findsNothing); + + await tester.tap(find.text('Nested Navigator route')); + await tester.pumpAndSettle(); + + expect(find.text('Nested Navigators Example'), findsNothing); + expect(find.text('Nested Navigators Page One'), findsOneWidget); + expect(find.text('Nested Navigators Page Two'), findsNothing); + + await tester.tap(find.text('Go to another route in this nested Navigator')); + await tester.pumpAndSettle(); + + expect(find.text('Nested Navigators Example'), findsNothing); + expect(find.text('Nested Navigators Page One'), findsNothing); + expect(find.text('Nested Navigators Page Two'), findsOneWidget); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested Navigators Example'), findsNothing); + expect(find.text('Nested Navigators Page One'), findsOneWidget); + expect(find.text('Nested Navigators Page Two'), findsNothing); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested Navigators Example'), findsOneWidget); + expect(find.text('Nested Navigators Page One'), findsNothing); + expect(find.text('Nested Navigators Page Two'), findsNothing); + }); +} diff --git a/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.1_test.dart b/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.1_test.dart new file mode 100644 index 00000000000..a6ea0ac8280 --- /dev/null +++ b/examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.1_test.dart @@ -0,0 +1,38 @@ +// 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_api_samples/widgets/navigator_pop_handler/navigator_pop_handler.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +import '../navigator_utils.dart'; + +void main() { + testWidgets("System back gesture operates on current tab's nested Navigator", (WidgetTester tester) async { + await tester.pumpWidget( + const example.NavigatorPopHandlerApp(), + ); + + expect(find.text('Bottom nav - tab Home Tab - route _TabPage.home'), findsOneWidget); + + // Go to the next route in this tab. + await tester.tap(find.text('Go to another route in this nested Navigator')); + await tester.pumpAndSettle(); + expect(find.text('Bottom nav - tab Home Tab - route _TabPage.one'), findsOneWidget); + + // Go to another tab. + await tester.tap(find.text('Go to One')); + await tester.pumpAndSettle(); + expect(find.text('Bottom nav - tab Tab One - route _TabPage.home'), findsOneWidget); + + // Return to the home tab. The navigation state is preserved. + await tester.tap(find.text('Go to Home')); + await tester.pumpAndSettle(); + expect(find.text('Bottom nav - tab Home Tab - route _TabPage.one'), findsOneWidget); + + // A back pops the navigation stack of the current tab's nested Navigator. + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Bottom nav - tab Home Tab - route _TabPage.home'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/navigator_utils.dart b/examples/api/test/widgets/navigator_utils.dart new file mode 100644 index 00000000000..46f1f9b1ac4 --- /dev/null +++ b/examples/api/test/widgets/navigator_utils.dart @@ -0,0 +1,20 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Simulates a system back, like a back gesture on Android. +/// +/// Sends the same platform channel message that the engine sends when it +/// receives a system back. +Future simulateSystemBack() { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + const JSONMessageCodec().encodeMessage({ + 'method': 'popRoute', + }), + (ByteData? _) {}, + ); +} diff --git a/examples/api/test/widgets/pop_scope/pop_scope.0_test.dart b/examples/api/test/widgets/pop_scope/pop_scope.0_test.dart new file mode 100644 index 00000000000..ac334fc3222 --- /dev/null +++ b/examples/api/test/widgets/pop_scope/pop_scope.0_test.dart @@ -0,0 +1,66 @@ +// 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_api_samples/widgets/pop_scope/pop_scope.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +import '../navigator_utils.dart'; + +void main() { + testWidgets('Can choose to stay on page', (WidgetTester tester) async { + await tester.pumpWidget( + const example.NavigatorPopHandlerApp(), + ); + + expect(find.text('Page One'), findsOneWidget); + expect(find.text('Page Two'), findsNothing); + expect(find.text('Are you sure?'), findsNothing); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsNothing); + expect(find.text('Page Two'), findsOneWidget); + expect(find.text('Are you sure?'), findsNothing); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsNothing); + expect(find.text('Page Two'), findsOneWidget); + expect(find.text('Are you sure?'), findsOneWidget); + + await tester.tap(find.text('Nevermind')); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsNothing); + expect(find.text('Page Two'), findsOneWidget); + expect(find.text('Are you sure?'), findsNothing); + }); + + testWidgets('Can choose to go back', (WidgetTester tester) async { + await tester.pumpWidget( + const example.NavigatorPopHandlerApp(), + ); + + expect(find.text('Page One'), findsOneWidget); + expect(find.text('Page Two'), findsNothing); + expect(find.text('Are you sure?'), findsNothing); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsNothing); + expect(find.text('Page Two'), findsOneWidget); + expect(find.text('Are you sure?'), findsNothing); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsNothing); + expect(find.text('Page Two'), findsOneWidget); + expect(find.text('Are you sure?'), findsOneWidget); + + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(find.text('Page One'), findsOneWidget); + expect(find.text('Page Two'), findsNothing); + expect(find.text('Are you sure?'), findsNothing); + }); +} diff --git a/examples/api/test/widgets/will_pop_scope/will_pop_scope.0_test.dart b/examples/api/test/widgets/will_pop_scope/will_pop_scope.0_test.dart deleted file mode 100644 index ad059431087..00000000000 --- a/examples/api/test/widgets/will_pop_scope/will_pop_scope.0_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -// 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_api_samples/widgets/will_pop_scope/will_pop_scope.0.dart' as example; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('pressing shouldPop button changes shouldPop', (WidgetTester tester) async { - await tester.pumpWidget( - const example.WillPopScopeExampleApp(), - ); - - final Finder buttonFinder = find.text('shouldPop: true'); - expect(buttonFinder, findsOneWidget); - await tester.tap(buttonFinder); - await tester.pump(); - expect(find.text('shouldPop: false'), findsOneWidget); - }); - testWidgets('pressing Push button pushes route', (WidgetTester tester) async { - await tester.pumpWidget( - const example.WillPopScopeExampleApp(), - ); - - final Finder buttonFinder = find.text('Push'); - expect(buttonFinder, findsOneWidget); - expect(find.byType(example.WillPopScopeExample), findsOneWidget); - await tester.tap(buttonFinder); - await tester.pumpAndSettle(); - expect(find.byType(example.WillPopScopeExample, skipOffstage: false), findsNWidgets(2)); - }); -} diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index 47e45b198b0..43f92f56943 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -157,6 +157,7 @@ class CupertinoApp extends StatefulWidget { this.onGenerateRoute, this.onGenerateInitialRoutes, this.onUnknownRoute, + this.onNavigationNotification, List this.navigatorObservers = const [], this.builder, this.title = '', @@ -202,6 +203,7 @@ class CupertinoApp extends StatefulWidget { this.builder, this.title = '', this.onGenerateTitle, + this.onNavigationNotification, this.color, this.locale, this.localizationsDelegates, @@ -268,6 +270,9 @@ class CupertinoApp extends StatefulWidget { /// {@macro flutter.widgets.widgetsApp.onUnknownRoute} final RouteFactory? onUnknownRoute; + /// {@macro flutter.widgets.widgetsApp.onNavigationNotification} + final NotificationListenerCallback? onNavigationNotification; + /// {@macro flutter.widgets.widgetsApp.navigatorObservers} final List? navigatorObservers; @@ -573,6 +578,7 @@ class _CupertinoAppState extends State { onGenerateRoute: widget.onGenerateRoute, onGenerateInitialRoutes: widget.onGenerateInitialRoutes, onUnknownRoute: widget.onUnknownRoute, + onNavigationNotification: widget.onNavigationNotification, builder: widget.builder, title: widget.title, onGenerateTitle: widget.onGenerateTitle, diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index f922eaf33e9..21157821487 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -196,7 +196,8 @@ mixin CupertinoRouteTransitionMixin on PageRoute { } // If attempts to dismiss this route might be vetoed such as in a page // with forms, then do not allow the user to dismiss the route with a swipe. - if (route.hasScopedWillPopCallback) { + if (route.hasScopedWillPopCallback + || route.popDisposition == RoutePopDisposition.doNotPop) { return false; } // Fullscreen dialogs aren't dismissible by back swipe. diff --git a/packages/flutter/lib/src/cupertino/tab_view.dart b/packages/flutter/lib/src/cupertino/tab_view.dart index 8728196eee9..f41d0a4a317 100644 --- a/packages/flutter/lib/src/cupertino/tab_view.dart +++ b/packages/flutter/lib/src/cupertino/tab_view.dart @@ -162,15 +162,39 @@ class _CupertinoTabViewState extends State { ..add(_heroController); } + GlobalKey? _ownedNavigatorKey; + GlobalKey get _navigatorKey { + if (widget.navigatorKey != null) { + return widget.navigatorKey!; + } + _ownedNavigatorKey ??= GlobalKey(); + return _ownedNavigatorKey!; + } + + // Whether this tab is currently the active tab. + bool get _isActive => TickerMode.of(context); + @override Widget build(BuildContext context) { - return Navigator( - key: widget.navigatorKey, + final Widget child = Navigator( + key: _navigatorKey, onGenerateRoute: _onGenerateRoute, onUnknownRoute: _onUnknownRoute, observers: _navigatorObservers, restorationScopeId: widget.restorationScopeId, ); + + // Handle system back gestures only if the tab is currently active. + return NavigatorPopHandler( + enabled: _isActive, + onPop: () { + if (!_isActive) { + return; + } + _navigatorKey.currentState!.pop(); + }, + child: child, + ); } Route? _onGenerateRoute(RouteSettings settings) { diff --git a/packages/flutter/lib/src/material/about.dart b/packages/flutter/lib/src/material/about.dart index 44d4e7b1cf4..76462c8c603 100644 --- a/packages/flutter/lib/src/material/about.dart +++ b/packages/flutter/lib/src/material/about.dart @@ -1179,9 +1179,10 @@ class _MasterDetailFlowState extends State<_MasterDetailFlow> implements _PageOp _builtLayout = _LayoutMode.nested; final MaterialPageRoute masterPageRoute = _masterPageRoute(context); - return WillPopScope( - // Push pop check into nested navigator. - onWillPop: () async => !(await _navigatorKey.currentState!.maybePop()), + return NavigatorPopHandler( + onPop: () { + _navigatorKey.currentState!.maybePop(); + }, child: Navigator( key: _navigatorKey, initialRoute: 'initial', @@ -1234,12 +1235,10 @@ class _MasterDetailFlowState extends State<_MasterDetailFlow> implements _PageOp MaterialPageRoute _detailPageRoute(Object? arguments) { return MaterialPageRoute(builder: (BuildContext context) { - return WillPopScope( - onWillPop: () async { + return PopScope( + onPopInvoked: (bool didPop) { // No need for setState() as rebuild happens on navigation pop. focus = _Focus.master; - Navigator.of(context).pop(); - return false; }, child: BlockSemantics(child: widget.detailPageBuilder(context, arguments, null)), ); diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 2438c458289..b3ef34adf65 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -214,6 +214,7 @@ class MaterialApp extends StatefulWidget { this.onGenerateRoute, this.onGenerateInitialRoutes, this.onUnknownRoute, + this.onNavigationNotification, List this.navigatorObservers = const [], this.builder, this.title = '', @@ -267,6 +268,7 @@ class MaterialApp extends StatefulWidget { this.builder, this.title = '', this.onGenerateTitle, + this.onNavigationNotification, this.color, this.theme, this.darkTheme, @@ -343,6 +345,9 @@ class MaterialApp extends StatefulWidget { /// {@macro flutter.widgets.widgetsApp.onUnknownRoute} final RouteFactory? onUnknownRoute; + /// {@macro flutter.widgets.widgetsApp.onNavigationNotification} + final NotificationListenerCallback? onNavigationNotification; + /// {@macro flutter.widgets.widgetsApp.navigatorObservers} final List? navigatorObservers; @@ -1019,6 +1024,7 @@ class _MaterialAppState extends State { onGenerateRoute: widget.onGenerateRoute, onGenerateInitialRoutes: widget.onGenerateInitialRoutes, onUnknownRoute: widget.onUnknownRoute, + onNavigationNotification: widget.onNavigationNotification, builder: _materialBuilder, title: widget.title, onGenerateTitle: widget.onGenerateTitle, diff --git a/packages/flutter/lib/src/services/system_navigator.dart b/packages/flutter/lib/src/services/system_navigator.dart index 9edff64b3cd..1ea16f921ac 100644 --- a/packages/flutter/lib/src/services/system_navigator.dart +++ b/packages/flutter/lib/src/services/system_navigator.dart @@ -2,10 +2,44 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; + import 'system_channels.dart'; /// Controls specific aspects of the system navigation stack. abstract final class SystemNavigator { + /// Informs the platform of whether or not the Flutter framework will handle + /// back events. + /// + /// Currently, this is used only on Android to inform its use of the + /// predictive back gesture when exiting the app. When true, predictive back + /// is disabled. + /// + /// See also: + /// + /// * The + /// [migration guide](https://developer.android.com/guide/navigation/predictive-back-gesture) + /// for predictive back in native Android apps. + static Future setFrameworkHandlesBack(bool frameworkHandlesBack) async { + // Currently, this method call is only relevant on Android. + if (kIsWeb) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return; + case TargetPlatform.android: + return SystemChannels.platform.invokeMethod( + 'SystemNavigator.setFrameworkHandlesBack', + frameworkHandlesBack, + ); + } + } + /// Removes the topmost Flutter instance, presenting what was before /// it. /// diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index af5d404c430..f8dc851ab00 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -19,6 +19,7 @@ import 'framework.dart'; import 'localizations.dart'; import 'media_query.dart'; import 'navigator.dart'; +import 'notification_listener.dart'; import 'pages.dart'; import 'performance_overlay.dart'; import 'restoration.dart'; @@ -313,6 +314,7 @@ class WidgetsApp extends StatefulWidget { this.onGenerateRoute, this.onGenerateInitialRoutes, this.onUnknownRoute, + this.onNavigationNotification, List this.navigatorObservers = const [], this.initialRoute, this.pageRouteBuilder, @@ -420,6 +422,7 @@ class WidgetsApp extends StatefulWidget { this.builder, this.title = '', this.onGenerateTitle, + this.onNavigationNotification, this.textStyle, required this.color, this.locale, @@ -701,6 +704,13 @@ class WidgetsApp extends StatefulWidget { /// {@endtemplate} final RouteFactory? onUnknownRoute; + /// {@template flutter.widgets.widgetsApp.onNavigationNotification} + /// The callback to use when receiving a [NavigationNotification]. + /// + /// By default this updates the engine with the navigation status. + /// {@endtemplate} + final NotificationListenerCallback? onNavigationNotification; + /// {@template flutter.widgets.widgetsApp.initialRoute} /// The name of the first route to show, if a [Navigator] is built. /// @@ -1328,6 +1338,28 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { ? WidgetsBinding.instance.platformDispatcher.defaultRouteName : widget.initialRoute ?? WidgetsBinding.instance.platformDispatcher.defaultRouteName; + AppLifecycleState? _appLifecycleState; + + /// The default value for [onNavigationNotification]. + /// + /// Does nothing and stops bubbling if the app is detached. Otherwise, updates + /// the platform with [NavigationNotification.canHandlePop] and stops + /// bubbling. + bool _defaultOnNavigationNotification(NavigationNotification notification) { + // Don't do anything with navigation notifications if there is no engine + // attached. + if (_appLifecycleState != AppLifecycleState.detached) { + SystemNavigator.setFrameworkHandlesBack(notification.canHandlePop); + } + return true; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appLifecycleState = state; + super.didChangeAppLifecycleState(state); + } + @override void initState() { super.initState(); @@ -1751,25 +1783,28 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { return RootRestorationScope( restorationId: widget.restorationScopeId, child: SharedAppData( - child: Shortcuts( - debugLabel: '', - shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, - // DefaultTextEditingShortcuts is nested inside Shortcuts so that it can - // fall through to the defaultShortcuts. - child: DefaultTextEditingShortcuts( - child: Actions( - actions: widget.actions ?? >{ - ...WidgetsApp.defaultActions, - ScrollIntent: Action.overridable(context: context, defaultAction: ScrollAction()), - }, - child: FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: TapRegionSurface( - child: ShortcutRegistrar( - child: Localizations( - locale: appLocale, - delegates: _localizationsDelegates.toList(), - child: title, + child: NotificationListener( + onNotification: widget.onNavigationNotification ?? _defaultOnNavigationNotification, + child: Shortcuts( + debugLabel: '', + shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, + // DefaultTextEditingShortcuts is nested inside Shortcuts so that it can + // fall through to the defaultShortcuts. + child: DefaultTextEditingShortcuts( + child: Actions( + actions: widget.actions ?? >{ + ...WidgetsApp.defaultActions, + ScrollIntent: Action.overridable(context: context, defaultAction: ScrollAction()), + }, + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: TapRegionSurface( + child: ShortcutRegistrar( + child: Localizations( + locale: appLocale, + delegates: _localizationsDelegates.toList(), + child: title, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index ca8347edbe4..f5d2a4fe8d5 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -54,9 +54,8 @@ export 'dart:ui' show AppLifecycleState, Locale; /// ** See code in examples/api/lib/widgets/binding/widget_binding_observer.0.dart ** /// {@end-tool} abstract mixin class WidgetsBindingObserver { - /// Called when the system tells the app to pop the current route. - /// For example, on Android, this is called when the user presses - /// the back button. + /// Called when the system tells the app to pop the current route, such as + /// after a system back button press or back gesture. /// /// Observers are notified in registration order until one returns /// true. If none return true, the application quits. @@ -69,6 +68,8 @@ abstract mixin class WidgetsBindingObserver { /// /// This method exposes the `popRoute` notification from /// [SystemChannels.navigation]. + /// + /// {@macro flutter.widgets.AndroidPredictiveBack} Future didPopRoute() => Future.value(false); /// Called when the host tells the application to push a new route onto the @@ -703,6 +704,27 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// /// This method exposes the `popRoute` notification from /// [SystemChannels.navigation]. + /// + /// {@template flutter.widgets.AndroidPredictiveBack} + /// ## Handling backs ahead of time + /// + /// Not all system backs will result in a call to this method. Some are + /// handled entirely by the system without informing the Flutter framework. + /// + /// Android API 33+ introduced a feature called predictive back, which allows + /// the user to peek behind the current app or route during a back gesture and + /// then decide to cancel or commit the back. Flutter enables or disables this + /// feature ahead of time, before a back gesture occurs, and back gestures + /// that trigger predictive back are handled entirely by the system and do not + /// trigger this method here in the framework. + /// + /// By default, the framework communicates when it would like to handle system + /// back gestures using [SystemNavigator.setFrameworkHandlesBack] in + /// [WidgetsApp]. This is done automatically based on the status of the + /// [Navigator] stack and the state of any [PopScope] widgets present. + /// Developers can manually set this by calling the method directly or by + /// using [NavigationNotification]. + /// {@endtemplate} @protected @visibleForTesting Future handlePopRoute() async { diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index 0860532a78f..07476568a35 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -10,8 +10,10 @@ import 'package:flutter/rendering.dart'; import 'basic.dart'; import 'framework.dart'; import 'navigator.dart'; +import 'pop_scope.dart'; import 'restoration.dart'; import 'restoration_properties.dart'; +import 'routes.dart'; import 'will_pop_scope.dart'; // Duration for delay before announcement in IOS so that the announcement won't be interrupted. @@ -52,10 +54,17 @@ class Form extends StatefulWidget { const Form({ super.key, required this.child, + this.canPop, + this.onPopInvoked, + @Deprecated( + 'Use canPop and/or onPopInvoked instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', + ) this.onWillPop, this.onChanged, AutovalidateMode? autovalidateMode, - }) : autovalidateMode = autovalidateMode ?? AutovalidateMode.disabled; + }) : autovalidateMode = autovalidateMode ?? AutovalidateMode.disabled, + assert((onPopInvoked == null && canPop == null) || onWillPop == null, 'onWillPop is deprecated; use canPop and/or onPopInvoked.'); /// Returns the [FormState] of the closest [Form] widget which encloses the /// given context, or null if none is found. @@ -134,8 +143,44 @@ class Form extends StatefulWidget { /// /// * [WillPopScope], another widget that provides a way to intercept the /// back button. + @Deprecated( + 'Use canPop and/or onPopInvoked instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', + ) final WillPopCallback? onWillPop; + /// {@macro flutter.widgets.PopScope.canPop} + /// + /// {@tool dartpad} + /// This sample demonstrates how to use this parameter to show a confirmation + /// dialog when a navigation pop would cause form data to be lost. + /// + /// ** See code in examples/api/lib/widgets/form/form.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [onPopInvoked], which also comes from [PopScope] and is often used in + /// conjunction with this parameter. + /// * [PopScope.canPop], which is what [Form] delegates to internally. + final bool? canPop; + + /// {@macro flutter.widgets.navigator.onPopInvoked} + /// + /// {@tool dartpad} + /// This sample demonstrates how to use this parameter to show a confirmation + /// dialog when a navigation pop would cause form data to be lost. + /// + /// ** See code in examples/api/lib/widgets/form/form.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [canPop], which also comes from [PopScope] and is often used in + /// conjunction with this parameter. + /// * [PopScope.onPopInvoked], which is what [Form] delegates to internally. + final PopInvokedCallback? onPopInvoked; + /// Called when one of the form fields changes. /// /// In addition to this callback being invoked, all the form fields themselves @@ -200,6 +245,18 @@ class FormState extends State
{ break; } + if (widget.canPop != null || widget.onPopInvoked != null) { + return PopScope( + canPop: widget.canPop ?? true, + onPopInvoked: widget.onPopInvoked, + child: _FormScope( + formState: this, + generation: _generation, + child: widget.child, + ), + ); + } + return WillPopScope( onWillPop: widget.onWillPop, child: _FormScope( diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 82c4090037c..4f7041d45b6 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -20,6 +20,7 @@ import 'focus_scope.dart'; import 'focus_traversal.dart'; import 'framework.dart'; import 'heroes.dart'; +import 'notification_listener.dart'; import 'overlay.dart'; import 'restoration.dart'; import 'restoration_properties.dart'; @@ -67,6 +68,10 @@ typedef RoutePredicate = bool Function(Route route); /// /// Used by [Form.onWillPop], [ModalRoute.addScopedWillPopCallback], /// [ModalRoute.removeScopedWillPopCallback], and [WillPopScope]. +@Deprecated( + 'Use PopInvokedCallback instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', +) typedef WillPopCallback = Future Function(); /// Signature for the [Navigator.onPopPage] callback. @@ -89,19 +94,21 @@ typedef PopPageCallback = bool Function(Route route, dynamic result); enum RoutePopDisposition { /// Pop the route. /// - /// If [Route.willPop] returns [pop] then the back button will actually pop - /// the current route. + /// If [Route.willPop] or [Route.popDisposition] return [pop] then the back + /// button will actually pop the current route. pop, /// Do not pop the route. /// - /// If [Route.willPop] returns [doNotPop] then the back button will be ignored. + /// If [Route.willPop] or [Route.popDisposition] return [doNotPop] then the + /// back button will be ignored. doNotPop, /// Delegate this to the next level of navigation. /// - /// If [Route.willPop] returns [bubble] then the back button will be handled - /// by the [SystemNavigator], which will usually close the application. + /// If [Route.willPop] or [Route.popDisposition] return [bubble] then the back + /// button will be handled by the [SystemNavigator], which will usually close + /// the application. bubble, } @@ -294,10 +301,51 @@ abstract class Route { /// mechanism. /// * [WillPopScope], another widget that provides a way to intercept the /// back button. + @Deprecated( + 'Use popDisposition instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', + ) Future willPop() async { return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop; } + /// Returns whether calling [Navigator.maybePop] when this [Route] is current + /// ([isCurrent]) should do anything. + /// + /// [Navigator.maybePop] is usually used instead of [Navigator.pop] to handle + /// the system back button, when it hasn't been disabled via + /// [SystemNavigator.setFrameworkHandlesBack]. + /// + /// By default, if a [Route] is the first route in the history (i.e., if + /// [isFirst]), it reports that pops should be bubbled + /// ([RoutePopDisposition.bubble]). This behavior prevents the user from + /// popping the first route off the history and being stranded at a blank + /// screen; instead, the larger scope is popped (e.g. the application quits, + /// so that the user returns to the previous application). + /// + /// In other cases, the default behavior is to accept the pop + /// ([RoutePopDisposition.pop]). + /// + /// The third possible value is [RoutePopDisposition.doNotPop], which causes + /// the pop request to be ignored entirely. + /// + /// See also: + /// + /// * [Form], which provides a [Form.canPop] boolean that is similar. + /// * [PopScope], a widget that provides a way to intercept the back button. + RoutePopDisposition get popDisposition { + return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop; + } + + /// {@template flutter.widgets.navigator.onPopInvoked} + /// Called after a route pop was handled. + /// + /// Even when the pop is canceled, for example by a [PopScope] widget, this + /// will still be called. The `didPop` parameter indicates whether or not the + /// back navigation actually happened successfully. + /// {@endtemplate} + void onPopInvoked(bool didPop) {} + /// Whether calling [didPop] would return false. bool get willHandlePopInternally => false; @@ -2415,6 +2463,9 @@ class Navigator extends StatefulWidget { /// the initial route. /// /// If there is no [Navigator] in scope, returns false. + /// + /// Does not consider anything that might externally prevent popping, such as + /// [PopEntry]. /// {@endtemplate} /// /// See also: @@ -2426,21 +2477,22 @@ class Navigator extends StatefulWidget { return navigator != null && navigator.canPop(); } - /// Consults the current route's [Route.willPop] method, and acts accordingly, - /// potentially popping the route as a result; returns whether the pop request - /// should be considered handled. + /// Consults the current route's [Route.popDisposition] getter or + /// [Route.willPop] method, and acts accordingly, potentially popping the + /// route as a result; returns whether the pop request should be considered + /// handled. /// /// {@template flutter.widgets.navigator.maybePop} - /// If [Route.willPop] returns [RoutePopDisposition.pop], then the [pop] + /// If the [RoutePopDisposition] is [RoutePopDisposition.pop], then the [pop] /// method is called, and this method returns true, indicating that it handled /// the pop request. /// - /// If [Route.willPop] returns [RoutePopDisposition.doNotPop], then this + /// If the [RoutePopDisposition] is [RoutePopDisposition.doNotPop], then this /// method returns true, but does not do anything beyond that. /// - /// If [Route.willPop] returns [RoutePopDisposition.bubble], then this method - /// returns false, and the caller is responsible for sending the request to - /// the containing scope (e.g. by closing the application). + /// If the [RoutePopDisposition] is [RoutePopDisposition.bubble], then this + /// method returns false, and the caller is responsible for sending the + /// request to the containing scope (e.g. by closing the application). /// /// This method is typically called for a user-initiated [pop]. For example on /// Android it's called by the binding for the system's back button. @@ -3015,6 +3067,7 @@ class _RouteEntry extends RouteTransitionRecord { assert(isPresent); pendingResult = result; currentState = _RouteLifecycle.pop; + route.onPopInvoked(true); } bool _reportRemovalToObserver = true; @@ -3295,12 +3348,78 @@ class _NavigatorReplaceObservation extends _NavigatorObservation { } } +typedef _IndexWhereCallback = bool Function(_RouteEntry element); + +/// A collection of _RouteEntries representing a navigation history. +/// +/// Acts as a ChangeNotifier and notifies after its List of _RouteEntries is +/// mutated. +class _History extends Iterable<_RouteEntry> with ChangeNotifier { + final List<_RouteEntry> _value = <_RouteEntry>[]; + + int indexWhere(_IndexWhereCallback test, [int start = 0]) { + return _value.indexWhere(test, start); + } + + void add(_RouteEntry element) { + _value.add(element); + notifyListeners(); + } + + void addAll(Iterable<_RouteEntry> elements) { + _value.addAll(elements); + if (elements.isNotEmpty) { + notifyListeners(); + } + } + + void clear() { + final bool valueWasEmpty = _value.isEmpty; + _value.clear(); + if (!valueWasEmpty) { + notifyListeners(); + } + } + + void insert(int index, _RouteEntry element) { + _value.insert(index, element); + notifyListeners(); + } + + _RouteEntry removeAt(int index) { + final _RouteEntry entry = _value.removeAt(index); + notifyListeners(); + return entry; + } + + _RouteEntry removeLast() { + final _RouteEntry entry = _value.removeLast(); + notifyListeners(); + return entry; + } + + _RouteEntry operator [](int index) { + return _value[index]; + } + + @override + Iterator<_RouteEntry> get iterator { + return _value.iterator; + } + + @override + String toString() { + return _value.toString(); + } +} + /// The state for a [Navigator] widget. /// /// A reference to this class can be obtained by calling [Navigator.of]. class NavigatorState extends State with TickerProviderStateMixin, RestorationMixin { late GlobalKey _overlayKey; - List<_RouteEntry> _history = <_RouteEntry>[]; + final _History _history = _History(); + /// A set for entries that are waiting to dispose until their subtrees are /// disposed. /// @@ -3330,12 +3449,43 @@ class NavigatorState extends State with TickerProviderStateMixin, Res late List _effectiveObservers; + bool get _usingPagesAPI => widget.pages != const >[]; + + void _handleHistoryChanged() { + final bool navigatorCanPop = canPop(); + late final bool routeBlocksPop; + if (!navigatorCanPop) { + final _RouteEntry? lastEntry = _lastRouteEntryWhereOrNull(_RouteEntry.isPresentPredicate); + routeBlocksPop = lastEntry != null + && lastEntry.route.popDisposition == RoutePopDisposition.doNotPop; + } else { + routeBlocksPop = false; + } + final NavigationNotification notification = NavigationNotification( + canHandlePop: navigatorCanPop || routeBlocksPop, + ); + // Avoid dispatching a notification in the middle of a build. + switch (SchedulerBinding.instance.schedulerPhase) { + case SchedulerPhase.postFrameCallbacks: + notification.dispatch(context); + case SchedulerPhase.idle: + case SchedulerPhase.midFrameMicrotasks: + case SchedulerPhase.persistentCallbacks: + case SchedulerPhase.transientCallbacks: + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!mounted) { + return; + } + notification.dispatch(context); + }); + } + } + @override void initState() { super.initState(); assert(() { - if (widget.pages != const >[]) { - // This navigator uses page API. + if (_usingPagesAPI) { if (widget.pages.isEmpty) { FlutterError.reportError( FlutterErrorDetails( @@ -3378,6 +3528,8 @@ class NavigatorState extends State with TickerProviderStateMixin, Res if (widget.reportsRouteUpdateToEngine) { SystemNavigator.selectSingleEntryHistory(); } + + _history.addListener(_handleHistoryChanged); } // Use [_nextPagelessRestorationScopeId] to get the next id. @@ -3560,7 +3712,7 @@ class NavigatorState extends State with TickerProviderStateMixin, Res void didUpdateWidget(Navigator oldWidget) { super.didUpdateWidget(oldWidget); assert(() { - if (widget.pages != const >[]) { + if (_usingPagesAPI) { // This navigator uses page API. if (widget.pages.isEmpty) { FlutterError.reportError( @@ -3672,6 +3824,8 @@ class NavigatorState extends State with TickerProviderStateMixin, Res _rawNextPagelessRestorationScopeId.dispose(); _serializableHistory.dispose(); userGestureInProgressNotifier.dispose(); + _history.removeListener(_handleHistoryChanged); + _history.dispose(); super.dispose(); // don't unlock, so that the object becomes unusable assert(_debugLocked); @@ -3957,7 +4111,7 @@ class NavigatorState extends State with TickerProviderStateMixin, Res pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, ).cast<_RouteEntry>(); } - _history = <_RouteEntry>[]; + _history.clear(); // Adds the leading pageless routes if there is any. if (pageRouteToPagelessRoutes.containsKey(null)) { _history.addAll(pageRouteToPagelessRoutes[null]!); @@ -4973,17 +5127,17 @@ class NavigatorState extends State with TickerProviderStateMixin, Res return true; // there's at least two routes, so we can pop } - /// Consults the current route's [Route.willPop] method, and acts accordingly, - /// potentially popping the route as a result; returns whether the pop request - /// should be considered handled. + /// Consults the current route's [Route.popDisposition] method, and acts + /// accordingly, potentially popping the route as a result; returns whether + /// the pop request should be considered handled. /// /// {@macro flutter.widgets.navigator.maybePop} /// /// 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 + /// * [Form], which provides a [Form.canPop] boolean that enables the + /// form to prevent any [pop]s initiated by the app's back button. + /// * [ModalRoute], which provides a `scopedOnPopCallback` that can be used /// to define the route's `willPop` method. @optionalTypeArgs Future maybePop([ T? result ]) async { @@ -4992,23 +5146,31 @@ class NavigatorState extends State with TickerProviderStateMixin, Res return false; } assert(lastEntry.route._navigator == this); - final RoutePopDisposition disposition = await lastEntry.route.willPop(); // this is asynchronous + + // TODO(justinmc): When the deprecated willPop method is removed, delete + // this code and use only popDisposition, below. + final RoutePopDisposition willPopDisposition = await lastEntry.route.willPop(); if (!mounted) { // Forget about this pop, we were disposed in the meantime. return true; } + if (willPopDisposition == RoutePopDisposition.doNotPop) { + return true; + } final _RouteEntry? newLastEntry = _lastRouteEntryWhereOrNull(_RouteEntry.isPresentPredicate); if (lastEntry != newLastEntry) { // Forget about this pop, something happened to our history in the meantime. return true; } - switch (disposition) { + + switch (lastEntry.route.popDisposition) { case RoutePopDisposition.bubble: return false; case RoutePopDisposition.pop: pop(result); return true; case RoutePopDisposition.doNotPop: + lastEntry.route.onPopInvoked(false); return true; } } @@ -5298,29 +5460,46 @@ class NavigatorState extends State with TickerProviderStateMixin, Res Widget build(BuildContext context) { assert(!_debugLocked); assert(_history.isNotEmpty); + // Hides the HeroControllerScope for the widget subtree so that the other // nested navigator underneath will not pick up the hero controller above // this level. return HeroControllerScope.none( - child: Listener( - onPointerDown: _handlePointerDown, - onPointerUp: _handlePointerUpOrCancel, - onPointerCancel: _handlePointerUpOrCancel, - child: AbsorbPointer( - absorbing: false, // it's mutated directly by _cancelActivePointers above - child: FocusTraversalGroup( - policy: FocusTraversalGroup.maybeOf(context), - child: Focus( - focusNode: focusNode, - autofocus: true, - skipTraversal: true, - includeSemantics: false, - child: UnmanagedRestorationScope( - bucket: bucket, - child: Overlay( - key: _overlayKey, - clipBehavior: widget.clipBehavior, - initialEntries: overlay == null ? _allRouteOverlayEntries.toList(growable: false) : const [], + child: NotificationListener( + onNotification: (NavigationNotification notification) { + // If the state of this Navigator does not change whether or not the + // whole framework can pop, propagate the Notification as-is. + if (notification.canHandlePop || !canPop()) { + return false; + } + // Otherwise, dispatch a new Notification with the correct canPop and + // stop the propagation of the old Notification. + const NavigationNotification nextNotification = NavigationNotification( + canHandlePop: true, + ); + nextNotification.dispatch(context); + return true; + }, + child: Listener( + onPointerDown: _handlePointerDown, + onPointerUp: _handlePointerUpOrCancel, + onPointerCancel: _handlePointerUpOrCancel, + child: AbsorbPointer( + absorbing: false, // it's mutated directly by _cancelActivePointers above + child: FocusTraversalGroup( + policy: FocusTraversalGroup.maybeOf(context), + child: Focus( + focusNode: focusNode, + autofocus: true, + skipTraversal: true, + includeSemantics: false, + child: UnmanagedRestorationScope( + bucket: bucket, + child: Overlay( + key: _overlayKey, + clipBehavior: widget.clipBehavior, + initialEntries: overlay == null ? _allRouteOverlayEntries.toList(growable: false) : const [], + ), ), ), ), @@ -5481,7 +5660,7 @@ class _HistoryProperty extends RestorableProperty>?> { // Updating. - void update(List<_RouteEntry> history) { + void update(_History history) { assert(isRegistered); final bool wasUninitialized = _pageToPagelessRoutes == null; bool needsSerialization = wasUninitialized; @@ -5804,3 +5983,26 @@ class RestorableRouteFuture extends RestorableProperty { static NavigatorState _defaultNavigatorFinder(BuildContext context) => Navigator.of(context); } + +/// A notification that a change in navigation has taken place. +/// +/// Specifically, this notification indicates that at least one of the following +/// has occurred: +/// +/// * That route stack of a [Navigator] has changed in any way. +/// * The ability to pop has changed, such as controlled by [PopScope]. +class NavigationNotification extends Notification { + /// Creates a notification that some change in navigation has happened. + const NavigationNotification({ + required this.canHandlePop, + }); + + /// Indicates that the originator of this [Notification] is capable of + /// handling a navigation pop. + final bool canHandlePop; + + @override + String toString() { + return 'NavigationNotification canHandlePop: $canHandlePop'; + } +} diff --git a/packages/flutter/lib/src/widgets/navigator_pop_handler.dart b/packages/flutter/lib/src/widgets/navigator_pop_handler.dart new file mode 100644 index 00000000000..203a85beded --- /dev/null +++ b/packages/flutter/lib/src/widgets/navigator_pop_handler.dart @@ -0,0 +1,110 @@ +// 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 'framework.dart'; +import 'navigator.dart'; +import 'notification_listener.dart'; +import 'pop_scope.dart'; + +/// Enables the handling of system back gestures. +/// +/// Typically wraps a nested [Navigator] widget and allows it to handle system +/// back gestures in the [onPop] callback. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use this widget to properly handle system +/// back gestures when using nested [Navigator]s. +/// +/// ** See code in examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates how to use this widget to properly handle system +/// back gestures with a bottom navigation bar whose tabs each have their own +/// nested [Navigator]s. +/// +/// ** See code in examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopScope], which allows toggling the ability of a [Navigator] to +/// handle pops. +/// * [NavigationNotification], which indicates whether a [Navigator] in a +/// subtree can handle pops. +class NavigatorPopHandler extends StatefulWidget { + /// Creates an instance of [NavigatorPopHandler]. + const NavigatorPopHandler({ + super.key, + this.onPop, + this.enabled = true, + required this.child, + }); + + /// The widget to place below this in the widget tree. + /// + /// Typically this is a [Navigator] that will handle the pop when [onPop] is + /// called. + final Widget child; + + /// Whether this widget's ability to handle system back gestures is enabled or + /// disabled. + /// + /// When false, there will be no effect on system back gestures. If provided, + /// [onPop] will still be called. + /// + /// This can be used, for example, when the nested [Navigator] is no longer + /// active but remains in the widget tree, such as in an inactive tab. + /// + /// Defaults to true. + final bool enabled; + + /// Called when a handleable pop event happens. + /// + /// For example, a pop is handleable when a [Navigator] in [child] has + /// multiple routes on its stack. It's not handleable when it has only a + /// single route, and so [onPop] will not be called. + /// + /// Typically this is used to pop the [Navigator] in [child]. See the sample + /// code on [NavigatorPopHandler] for a full example of this. + final VoidCallback? onPop; + + @override + State createState() => _NavigatorPopHandlerState(); +} + +class _NavigatorPopHandlerState extends State { + bool _canPop = true; + + @override + Widget build(BuildContext context) { + // When the widget subtree indicates it can handle a pop, disable popping + // here, so that it can be manually handled in canPop. + return PopScope( + canPop: !widget.enabled || _canPop, + onPopInvoked: (bool didPop) { + if (didPop) { + return; + } + widget.onPop?.call(); + }, + // Listen to changes in the navigation stack in the widget subtree. + child: NotificationListener( + onNotification: (NavigationNotification notification) { + // If this subtree cannot handle pop, then set canPop to true so + // that our PopScope will allow the Navigator higher in the tree to + // handle the pop instead. + final bool nextCanPop = !notification.canHandlePop; + if (nextCanPop != _canPop) { + setState(() { + _canPop = nextCanPop; + }); + } + return false; + }, + child: widget.child, + ), + ); + } +} diff --git a/packages/flutter/lib/src/widgets/pop_scope.dart b/packages/flutter/lib/src/widgets/pop_scope.dart new file mode 100644 index 00000000000..b47d83fcdbb --- /dev/null +++ b/packages/flutter/lib/src/widgets/pop_scope.dart @@ -0,0 +1,137 @@ +// 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/foundation.dart'; + +import 'framework.dart'; +import 'navigator.dart'; +import 'routes.dart'; + +/// Manages system back gestures. +/// +/// The [canPop] parameter can be used to disable system back gestures. Defaults +/// to true, meaning that back gestures happen as usual. +/// +/// The [onPopInvoked] parameter reports when system back gestures occur, +/// regardless of whether or not they were successful. +/// +/// If [canPop] is false, then a system back gesture will not pop the route off +/// of the enclosing [Navigator]. [onPopInvoked] will still be called, and +/// `didPop` will be `false`. +/// +/// If [canPop] is true, then a system back gesture will cause the enclosing +/// [Navigator] to receive a pop as usual. [onPopInvoked] will be called with +/// `didPop` as `true`, unless the pop failed for reasons unrelated to +/// [PopScope], in which case it will be `false`. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use this widget to handle nested navigation +/// in a bottom navigation bar. +/// +/// ** See code in examples/api/lib/widgets/pop_scope/pop_scope.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [NavigatorPopHandler], which is a less verbose way to handle system back +/// gestures in simple cases of nested [Navigator]s. +/// * [Form.canPop] and [Form.onPopInvoked], which can be used to handle system +/// back gestures in the case of a form with unsaved data. +/// * [ModalRoute.registerPopEntry] and [ModalRoute.unregisterPopEntry], +/// which this widget uses to integrate with Flutter's navigation system. +class PopScope extends StatefulWidget { + /// Creates a widget that registers a callback to veto attempts by the user to + /// dismiss the enclosing [ModalRoute]. + const PopScope({ + super.key, + required this.child, + this.canPop = true, + this.onPopInvoked, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// {@template flutter.widgets.PopScope.onPopInvoked} + /// Called after a route pop was handled. + /// {@endtemplate} + /// + /// It's not possible to prevent the pop from happening at the time that this + /// method is called; the pop has already happened. Use [canPop] to + /// disable pops in advance. + /// + /// This will still be called even when the pop is canceled. A pop is canceled + /// when the relevant [Route.popDisposition] returns false, such as when + /// [canPop] is set to false on a [PopScope]. The `didPop` parameter + /// indicates whether or not the back navigation actually happened + /// successfully. + /// + /// See also: + /// + /// * [Route.onPopInvoked], which is similar. + final PopInvokedCallback? onPopInvoked; + + /// {@template flutter.widgets.PopScope.canPop} + /// When false, blocks the current route from being popped. + /// + /// This includes the root route, where upon popping, the Flutter app would + /// exit. + /// + /// If multiple [PopScope] widgets appear in a route's widget subtree, then + /// each and every `canPop` must be `true` in order for the route to be + /// able to pop. + /// + /// [Android's predictive back](https://developer.android.com/guide/navigation/predictive-back-gesture) + /// feature will not animate when this boolean is false. + /// {@endtemplate} + final bool canPop; + + @override + State createState() => _PopScopeState(); +} + +class _PopScopeState extends State implements PopEntry { + ModalRoute? _route; + + @override + PopInvokedCallback? get onPopInvoked => widget.onPopInvoked; + + @override + late final ValueNotifier canPopNotifier; + + @override + void initState() { + super.initState(); + canPopNotifier = ValueNotifier(widget.canPop); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ModalRoute? nextRoute = ModalRoute.of(context); + if (nextRoute != _route) { + _route?.unregisterPopEntry(this); + _route = nextRoute; + _route?.registerPopEntry(this); + } + } + + @override + void didUpdateWidget(PopScope oldWidget) { + super.didUpdateWidget(oldWidget); + canPopNotifier.value = widget.canPop; + } + + @override + void dispose() { + _route?.unregisterPopEntry(this); + canPopNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 441486e5ed6..e54e46ab3c7 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -717,6 +717,10 @@ mixin LocalHistoryRoute on Route { } } + @Deprecated( + 'Use popDisposition instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', + ) @override Future willPop() async { if (willHandlePopInternally) { @@ -725,6 +729,14 @@ mixin LocalHistoryRoute on Route { return super.willPop(); } + @override + RoutePopDisposition get popDisposition { + if (willHandlePopInternally) { + return RoutePopDisposition.pop; + } + return super.popDisposition; + } + @override bool didPop(T? result) { if (_localHistory != null && _localHistory!.isNotEmpty) { @@ -1490,6 +1502,8 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute _willPopCallbacks = []; + final Set _popEntries = {}; + /// Returns [RoutePopDisposition.doNotPop] if any of callbacks added with /// [addScopedWillPopCallback] returns either false or null. If they all /// return true, the base [Route.willPop]'s result will be returned. The @@ -1508,6 +1522,10 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute willPop() async { final _ModalScopeState? scope = _scopeKey.currentState; @@ -1520,26 +1538,44 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute { - /// ModalRoute? _route; - /// - /// // ... - /// - /// @override - /// void didChangeDependencies() { - /// super.didChangeDependencies(); - /// _route?.removeScopedWillPopCallback(askTheUserIfTheyAreSure); - /// _route = ModalRoute.of(context); - /// _route?.addScopedWillPopCallback(askTheUserIfTheyAreSure); - /// } - /// } - /// ``` - /// {@end-tool} - /// - /// {@tool snippet} - /// If you register a callback manually, be sure to remove the callback with - /// [removeScopedWillPopCallback] by the time the widget has been disposed. A - /// stateful widget can do this in its dispose method (continuing the previous - /// example): - /// - /// ```dart - /// abstract class _MyWidgetState2 extends State { - /// ModalRoute? _route; - /// - /// // ... - /// - /// @override - /// void dispose() { - /// _route?.removeScopedWillPopCallback(askTheUserIfTheyAreSure); - /// _route = null; - /// super.dispose(); - /// } - /// } - /// ``` - /// {@end-tool} - /// /// See also: /// /// * [WillPopScope], which manages the registration and unregistration @@ -1599,6 +1592,10 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute '${objectRuntimeType(this, 'ModalRoute')}($settings, animation: $_animation)'; } @@ -2212,3 +2279,33 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child); + +/// A callback type for informing that a navigation pop has been invoked, +/// whether or not it was handled successfully. +/// +/// Accepts a didPop boolean indicating whether or not back navigation +/// succeeded. +typedef PopInvokedCallback = void Function(bool didPop); + +/// Allows listening to and preventing pops. +/// +/// Can be registered in [ModalRoute] to listen to pops with [onPopInvoked] or +/// to enable/disable them with [canPopNotifier]. +/// +/// See also: +/// +/// * [PopScope], which provides similar functionality in a widget. +/// * [ModalRoute.registerPopEntry], which unregisters instances of this. +/// * [ModalRoute.unregisterPopEntry], which unregisters instances of this. +abstract class PopEntry { + /// {@macro flutter.widgets.PopScope.onPopInvoked} + PopInvokedCallback? get onPopInvoked; + + /// {@macro flutter.widgets.PopScope.canPop} + ValueListenable get canPopNotifier; + + @override + String toString() { + return 'PopEntry canPop: ${canPopNotifier.value}, onPopInvoked: $onPopInvoked'; + } +} diff --git a/packages/flutter/lib/src/widgets/will_pop_scope.dart b/packages/flutter/lib/src/widgets/will_pop_scope.dart index ab90c7f49de..eefe4379833 100644 --- a/packages/flutter/lib/src/widgets/will_pop_scope.dart +++ b/packages/flutter/lib/src/widgets/will_pop_scope.dart @@ -9,26 +9,25 @@ import 'routes.dart'; /// Registers a callback to veto attempts by the user to dismiss the enclosing /// [ModalRoute]. /// -/// {@tool dartpad} -/// Whenever the back button is pressed, you will get a callback at [onWillPop], -/// which returns a [Future]. If the [Future] returns true, the screen is -/// popped. -/// -/// ** See code in examples/api/lib/widgets/will_pop_scope/will_pop_scope.0.dart ** -/// {@end-tool} -/// /// See also: /// /// * [ModalRoute.addScopedWillPopCallback] and [ModalRoute.removeScopedWillPopCallback], /// which this widget uses to register and unregister [onWillPop]. /// * [Form], which provides an `onWillPop` callback that enables the form /// to veto a `pop` initiated by the app's back button. -/// +@Deprecated( + 'Use PopScope instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', +) class WillPopScope extends StatefulWidget { /// Creates a widget that registers a callback to veto attempts by the user to /// dismiss the enclosing [ModalRoute]. /// /// The [child] argument must not be null. + @Deprecated( + 'Use PopScope instead. ' + 'This feature was deprecated after v3.12.0-1.0.pre.', + ) const WillPopScope({ super.key, required this.child, diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 539d6aac629..3ca0999b994 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -81,6 +81,7 @@ export 'src/widgets/media_query.dart'; export 'src/widgets/modal_barrier.dart'; export 'src/widgets/navigation_toolbar.dart'; export 'src/widgets/navigator.dart'; +export 'src/widgets/navigator_pop_handler.dart'; export 'src/widgets/nested_scroll_view.dart'; export 'src/widgets/notification_listener.dart'; export 'src/widgets/orientation_builder.dart'; @@ -95,6 +96,7 @@ export 'src/widgets/placeholder.dart'; export 'src/widgets/platform_menu_bar.dart'; export 'src/widgets/platform_selectable_region_context_menu.dart'; export 'src/widgets/platform_view.dart'; +export 'src/widgets/pop_scope.dart'; export 'src/widgets/preferred_size.dart'; export 'src/widgets/primary_scroll_controller.dart'; export 'src/widgets/raw_keyboard_listener.dart'; diff --git a/packages/flutter/test/cupertino/tab_scaffold_test.dart b/packages/flutter/test/cupertino/tab_scaffold_test.dart index a8a43b51027..b376a92ecf2 100644 --- a/packages/flutter/test/cupertino/tab_scaffold_test.dart +++ b/packages/flutter/test/cupertino/tab_scaffold_test.dart @@ -2,13 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:typed_data'; - import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../image_data.dart'; import '../rendering/rendering_tester.dart' show TestCallbackPainter; +import '../widgets/navigator_utils.dart'; late List selectedTabs; @@ -1215,6 +1216,132 @@ void main() { expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsNothing); }); + + group('Android Predictive Back', () { + bool? lastFrameworkHandlesBack; + setUp(() { + // Initialize to false. Because this uses a static boolean internally, it + // is not reset between tests or calls to pumpWidget. Explicitly setting + // it to false before each test makes them behave deterministically. + SystemNavigator.setFrameworkHandlesBack(false); + lastFrameworkHandlesBack = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'SystemNavigator.setFrameworkHandlesBack') { + expect(methodCall.arguments, isA()); + lastFrameworkHandlesBack = methodCall.arguments as bool; + } + return; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + SystemNavigator.setFrameworkHandlesBack(true); + }); + + testWidgets('System back navigation inside of tabs', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData( + viewInsets: EdgeInsets.only(bottom: 200), + ), + child: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 1 of tab ${index + 1}'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Next page'), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 2 of tab ${index + 1}'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + expect(find.text('Page 2 of tab 2'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Tab 1')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + expect(find.text('Page 2 of tab 2'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: kIsWeb, // [intended] frameworkHandlesBack not used on web. + ); + }); } CupertinoTabBar _buildTabBar({ int selectedTab = 0 }) { diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 26d14636392..d403ceba9c8 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -6,9 +6,12 @@ import 'dart:ui' show FlutterView; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'navigator_utils.dart'; import 'observer_tester.dart'; import 'semantics_tester.dart'; @@ -4153,6 +4156,719 @@ void main() { expect(const RouteSettings().toString(), 'RouteSettings(none, null)'); }); }); + + group('Android Predictive Back', () { + bool? lastFrameworkHandlesBack; + setUp(() { + // Initialize to false. Because this uses a static boolean internally, it + // is not reset between tests or calls to pumpWidget. Explicitly setting + // it to false before each test makes them behave deterministically. + SystemNavigator.setFrameworkHandlesBack(false); + lastFrameworkHandlesBack = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'SystemNavigator.setFrameworkHandlesBack') { + expect(methodCall.arguments, isA()); + lastFrameworkHandlesBack = methodCall.arguments as bool; + } + return; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + SystemNavigator.setFrameworkHandlesBack(true); + }); + + testWidgets('a single route is already defaulted to false', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Text('home'), + ) + ) + ); + + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + testWidgets('navigating around a single Navigator with .pop', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Go to one'), + ), + ], + ), + '/one': (BuildContext context) => _LinksPage( + title: 'Page one', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one/one'); + }, + child: const Text('Go to one/one'), + ), + ], + ), + '/one/one': (BuildContext context) => const _LinksPage( + title: 'Page one - one', + ), + }, + ), + ); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go to one/one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one - one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + testWidgets('navigating around a single Navigator with system back', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Go to one'), + ), + ], + ), + '/one': (BuildContext context) => _LinksPage( + title: 'Page one', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one/one'); + }, + child: const Text('Go to one/one'), + ), + ], + ), + '/one/one': (BuildContext context) => const _LinksPage( + title: 'Page one - one', + ), + }, + ), + ); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go to one/one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one - one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + testWidgets('a single Navigator with a PopScope that defaults to enabled', (WidgetTester tester) async { + bool canPop = true; + late StateSetter setState; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + canPop: canPop, + ), + }, + ); + }, + ), + ); + + expect(lastFrameworkHandlesBack, isFalse); + + setState(() { + canPop = false; + }); + await tester.pump(); + + expect(lastFrameworkHandlesBack, isTrue); + + setState(() { + canPop = true; + }); + await tester.pump(); + + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + testWidgets('a single Navigator with a PopScope that defaults to disabled', (WidgetTester tester) async { + bool canPop = false; + late StateSetter setState; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + canPop: canPop, + ), + }, + ); + }, + ), + ); + + expect(lastFrameworkHandlesBack, isTrue); + + setState(() { + canPop = true; + }); + await tester.pump(); + + expect(lastFrameworkHandlesBack, isFalse); + + setState(() { + canPop = false; + }); + await tester.pump(); + + expect(lastFrameworkHandlesBack, isTrue); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + // Test both system back gestures and Navigator.pop. + for (final _BackType backType in _BackType.values) { + testWidgets('navigating around nested Navigators', (WidgetTester tester) async { + final GlobalKey nav = GlobalKey(); + final GlobalKey nestedNav = GlobalKey(); + Future goBack() async { + switch (backType) { + case _BackType.systemBack: + return simulateSystemBack(); + case _BackType.navigatorPop: + if (nestedNav.currentState != null) { + if (nestedNav.currentState!.mounted && nestedNav.currentState!.canPop()) { + return nestedNav.currentState?.pop(); + } + } + return nav.currentState?.pop(); + } + } + await tester.pumpWidget( + MaterialApp( + navigatorKey: nav, + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Go to one'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/nested'); + }, + child: const Text('Go to nested'), + ), + ], + ), + '/one': (BuildContext context) => _LinksPage( + title: 'Page one', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one/one'); + }, + child: const Text('Go to one/one'), + ), + ], + ), + '/nested': (BuildContext context) => _NestedNavigatorsPage( + navigatorKey: nestedNav, + ), + }, + ), + ); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await goBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to nested')); + await tester.pumpAndSettle(); + + expect(find.text('Nested - home'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go to nested/one')); + await tester.pumpAndSettle(); + + expect(find.text('Nested - page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await goBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested - home'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await goBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + } + + testWidgets('nested Navigators with a nested PopScope', (WidgetTester tester) async { + bool canPop = true; + late StateSetter setState; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Go to one'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/nested'); + }, + child: const Text('Go to nested'), + ), + ], + ), + '/one': (BuildContext context) => _LinksPage( + title: 'Page one', + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one/one'); + }, + child: const Text('Go to one/one'), + ), + ], + ), + '/nested': (BuildContext context) => _NestedNavigatorsPage( + popScopePageEnabled: canPop, + ), + }, + ); + }, + ), + ); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to nested')); + await tester.pumpAndSettle(); + + expect(find.text('Nested - home'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go to nested/popscope')); + await tester.pumpAndSettle(); + + expect(find.text('Nested - PopScope'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + // Going back works because canPop is true. + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested - home'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Go to nested/popscope')); + await tester.pumpAndSettle(); + + expect(find.text('Nested - PopScope'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + setState(() { + canPop = false; + }); + await tester.pumpAndSettle(); + + expect(lastFrameworkHandlesBack, isTrue); + + // Now going back doesn't work because canPop is false, but it still + // has no effect on the system navigator due to all of the other routes. + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested - PopScope'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + setState(() { + canPop = true; + }); + await tester.pump(); + + expect(lastFrameworkHandlesBack, isTrue); + + // And going back works again after switching canPop back to true. + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Nested - home'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + group('Navigator page API', () { + testWidgets('starting with one route as usual', (WidgetTester tester) async { + late StateSetter builderSetState; + final List<_Page> pages = <_Page>[_Page.home]; + bool canPop() => pages.length <= 1; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + builderSetState = setState; + return PopScope( + canPop: canPop(), + onPopInvoked: (bool success) { + if (success || pages.last == _Page.noPop) { + return; + } + setState(() { + pages.removeLast(); + }); + }, + child: Navigator( + onPopPage: (Route route, void result) { + if (!route.didPop(null)) { + return false; + } + setState(() { + pages.removeLast(); + }); + return true; + }, + pages: pages.map((_Page page) { + switch (page) { + case _Page.home: + return MaterialPage( + child: _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + setState(() { + pages.add(_Page.one); + }); + }, + child: const Text('Go to _Page.one'), + ), + TextButton( + onPressed: () { + setState(() { + pages.add(_Page.noPop); + }); + }, + child: const Text('Go to _Page.noPop'), + ), + ], + ), + ); + case _Page.one: + return const MaterialPage( + child: _LinksPage( + title: 'Page one', + ), + ); + case _Page.noPop: + return const MaterialPage( + child: _LinksPage( + title: 'Cannot pop page', + canPop: false, + ), + ); + } + }).toList(), + ), + ); + }, + ), + ), + ); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to _Page.one')); + await tester.pumpAndSettle(); + + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Go to _Page.noPop')); + await tester.pumpAndSettle(); + + expect(find.text('Cannot pop page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Cannot pop page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + // Circumvent "Cannot pop page" by directly modifying pages. + builderSetState(() { + pages.removeLast(); + }); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + + testWidgets('starting with existing route history', (WidgetTester tester) async { + final List<_Page> pages = <_Page>[_Page.home, _Page.one]; + bool canPop() => pages.length <= 1; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return PopScope( + canPop: canPop(), + onPopInvoked: (bool success) { + if (success || pages.last == _Page.noPop) { + return; + } + setState(() { + pages.removeLast(); + }); + }, + child: Navigator( + onPopPage: (Route route, void result) { + if (!route.didPop(null)) { + return false; + } + setState(() { + pages.removeLast(); + }); + return true; + }, + pages: pages.map((_Page page) { + switch (page) { + case _Page.home: + return MaterialPage( + child: _LinksPage( + title: 'Home page', + buttons: [ + TextButton( + onPressed: () { + setState(() { + pages.add(_Page.one); + }); + }, + child: const Text('Go to _Page.one'), + ), + TextButton( + onPressed: () { + setState(() { + pages.add(_Page.noPop); + }); + }, + child: const Text('Go to _Page.noPop'), + ), + ], + ), + ); + case _Page.one: + return const MaterialPage( + child: _LinksPage( + title: 'Page one', + ), + ); + case _Page.noPop: + return const MaterialPage( + child: _LinksPage( + title: 'Cannot pop page', + canPop: false, + ), + ); + } + }).toList(), + ), + ); + }, + ), + ), + ); + + expect(find.text('Home page'), findsNothing); + expect(find.text('Page one'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home page'), findsOneWidget); + expect(find.text('Page one'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android }), + skip: isBrowser, // [intended] only non-web Android supports predictive back. + ); + }); + }); } typedef AnnouncementCallBack = void Function(Route?); @@ -4435,3 +5151,153 @@ class TestDependencies extends StatelessWidget { ); } } + +enum _BackType { + systemBack, + navigatorPop, +} + +enum _Page { + home, + one, + noPop, +} + +class _LinksPage extends StatelessWidget { + const _LinksPage ({ + this.buttons = const [], + this.canPop, + required this.title, + this.onBack, + }); + + final List buttons; + final bool? canPop; + final VoidCallback? onBack; + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title), + ...buttons, + if (Navigator.of(context).canPop()) + TextButton( + onPressed: onBack ?? () { + Navigator.of(context).pop(); + }, + child: const Text('Go back'), + ), + if (canPop != null) + PopScope( + canPop: canPop!, + child: const SizedBox.shrink(), + ), + ], + ), + ), + ); + } +} + +class _NestedNavigatorsPage extends StatefulWidget { + const _NestedNavigatorsPage({ + this.popScopePageEnabled, + this.navigatorKey, + }); + + /// Whether the PopScope on the /popscope page is enabled. + /// + /// If null, then no PopScope is built at all. + final bool? popScopePageEnabled; + + final GlobalKey? navigatorKey; + + @override + State<_NestedNavigatorsPage> createState() => _NestedNavigatorsPageState(); +} + +class _NestedNavigatorsPageState extends State<_NestedNavigatorsPage> { + late final GlobalKey _navigatorKey; + + @override + void initState() { + super.initState(); + _navigatorKey = widget.navigatorKey ?? GlobalKey(); + } + + @override + Widget build(BuildContext context) { + final BuildContext rootContext = context; + return NavigatorPopHandler( + onPop: () { + if (widget.popScopePageEnabled == false) { + return; + } + _navigatorKey.currentState!.pop(); + }, + child: Navigator( + key: _navigatorKey, + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + switch (settings.name) { + case '/': + return MaterialPageRoute( + builder: (BuildContext context) { + return _LinksPage( + title: 'Nested - home', + onBack: () { + Navigator.of(rootContext).pop(); + }, + buttons: [ + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Go to nested/one'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/popscope'); + }, + child: const Text('Go to nested/popscope'), + ), + TextButton( + onPressed: () { + Navigator.of(rootContext).pop(); + }, + child: const Text('Go back out of nested nav'), + ), + ], + ); + }, + ); + case '/one': + return MaterialPageRoute( + builder: (BuildContext context) { + return const _LinksPage( + title: 'Nested - page one', + ); + }, + ); + case '/popscope': + return MaterialPageRoute( + builder: (BuildContext context) { + return _LinksPage( + canPop: widget.popScopePageEnabled, + title: 'Nested - PopScope', + ); + }, + ); + default: + throw Exception('Invalid route: ${settings.name}'); + } + }, + ), + ); + } +} diff --git a/packages/flutter/test/widgets/navigator_utils.dart b/packages/flutter/test/widgets/navigator_utils.dart new file mode 100644 index 00000000000..46f1f9b1ac4 --- /dev/null +++ b/packages/flutter/test/widgets/navigator_utils.dart @@ -0,0 +1,20 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Simulates a system back, like a back gesture on Android. +/// +/// Sends the same platform channel message that the engine sends when it +/// receives a system back. +Future simulateSystemBack() { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + const JSONMessageCodec().encodeMessage({ + 'method': 'popRoute', + }), + (ByteData? _) {}, + ); +} diff --git a/packages/flutter/test/widgets/pop_scope_test.dart b/packages/flutter/test/widgets/pop_scope_test.dart new file mode 100644 index 00000000000..c5d0e885450 --- /dev/null +++ b/packages/flutter/test/widgets/pop_scope_test.dart @@ -0,0 +1,361 @@ +// 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/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'navigator_utils.dart'; + +void main() { + bool? lastFrameworkHandlesBack; + setUp(() { + // Initialize to false. Because this uses a static boolean internally, it + // is not reset between tests or calls to pumpWidget. Explicitly setting + // it to false before each test makes them behave deterministically. + SystemNavigator.setFrameworkHandlesBack(false); + lastFrameworkHandlesBack = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'SystemNavigator.setFrameworkHandlesBack') { + expect(methodCall.arguments, isA()); + lastFrameworkHandlesBack = methodCall.arguments as bool; + } + return; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + SystemNavigator.setFrameworkHandlesBack(true); + }); + + testWidgets('toggling canPop on root route allows/prevents backs', (WidgetTester tester) async { + bool canPop = false; + late StateSetter setState; + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext buildContext) => Scaffold( + body: StatefulBuilder( + builder: (BuildContext buildContext, StateSetter stateSetter) { + context = buildContext; + setState = stateSetter; + return PopScope( + canPop: canPop, + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Home/PopScope Page'), + ], + ), + ), + ); + }, + ), + ), + }, + ), + ); + + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.doNotPop); + + setState(() { + canPop = true; + }); + await tester.pump(); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.bubble); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('toggling canPop on secondary route allows/prevents backs', (WidgetTester tester) async { + final GlobalKey nav = GlobalKey(); + bool canPop = true; + late StateSetter setState; + late BuildContext homeContext; + late BuildContext oneContext; + late bool lastPopSuccess; + await tester.pumpWidget( + MaterialApp( + navigatorKey: nav, + initialRoute: '/', + routes: { + '/': (BuildContext context) { + homeContext = context; + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Home Page'), + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/one'); + }, + child: const Text('Next'), + ), + ], + ), + ), + ); + }, + '/one': (BuildContext context) => Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + oneContext = context; + setState = stateSetter; + return PopScope( + canPop: canPop, + onPopInvoked: (bool didPop) { + lastPopSuccess = didPop; + }, + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('PopScope Page'), + ], + ), + ), + ); + }, + ), + ), + }, + ), + ); + + expect(find.text('Home Page'), findsOneWidget); + expect(ModalRoute.of(homeContext)!.popDisposition, RoutePopDisposition.bubble); + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.pop); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + + // When canPop is true, can use pop to go back. + nav.currentState!.maybePop(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, true); + expect(find.text('Home Page'), findsOneWidget); + expect(find.text('PopScope Page'), findsNothing); + expect(ModalRoute.of(homeContext)!.popDisposition, RoutePopDisposition.bubble); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.pop); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + + // When canPop is true, can use system back to go back. + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, true); + expect(find.text('Home Page'), findsOneWidget); + expect(find.text('PopScope Page'), findsNothing); + expect(ModalRoute.of(homeContext)!.popDisposition, RoutePopDisposition.bubble); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.pop); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + + setState(() { + canPop = false; + }); + await tester.pump(); + + // When canPop is false, can't use pop to go back. + nav.currentState!.maybePop(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, false); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.doNotPop); + + // When canPop is false, can't use system back to go back. + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, false); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.doNotPop); + + // Toggle canPop back to true and back works again. + setState(() { + canPop = true; + }); + await tester.pump(); + + nav.currentState!.maybePop(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, true); + expect(find.text('Home Page'), findsOneWidget); + expect(find.text('PopScope Page'), findsNothing); + expect(ModalRoute.of(homeContext)!.popDisposition, RoutePopDisposition.bubble); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('PopScope Page'), findsOneWidget); + expect(find.text('Home Page'), findsNothing); + expect(ModalRoute.of(oneContext)!.popDisposition, RoutePopDisposition.pop); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(lastPopSuccess, true); + expect(find.text('Home Page'), findsOneWidget); + expect(find.text('PopScope Page'), findsNothing); + expect(ModalRoute.of(homeContext)!.popDisposition, RoutePopDisposition.bubble); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('removing PopScope from the tree removes its effect on navigation', (WidgetTester tester) async { + bool usePopScope = true; + late StateSetter setState; + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext buildContext) => Scaffold( + body: StatefulBuilder( + builder: (BuildContext buildContext, StateSetter stateSetter) { + context = buildContext; + setState = stateSetter; + const Widget child = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Home/PopScope Page'), + ], + ), + ); + if (!usePopScope) { + return child; + } + return const PopScope( + canPop: false, + child: child, + ); + }, + ), + ), + }, + ), + ); + + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.doNotPop); + + setState(() { + usePopScope = false; + }); + await tester.pump(); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.bubble); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('identical PopScopes', (WidgetTester tester) async { + bool usePopScope1 = true; + bool usePopScope2 = true; + late StateSetter setState; + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext buildContext, StateSetter stateSetter) { + context = buildContext; + setState = stateSetter; + return Column( + children: [ + if (usePopScope1) + const PopScope( + canPop: false, + child: Text('hello'), + ), + if (usePopScope2) + const PopScope( + canPop: false, + child: Text('hello'), + ), + ], + ); + }, + ), + ), + ), + ); + + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.doNotPop); + + // Despite being in the widget tree twice, the ModalRoute has only ever + // registered one PopScopeInterface for it. Removing one makes it think that + // both have been removed. + setState(() { + usePopScope1 = false; + }); + await tester.pump(); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isTrue); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.doNotPop); + + setState(() { + usePopScope2 = false; + }); + await tester.pump(); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + expect(lastFrameworkHandlesBack, isFalse); + } + expect(ModalRoute.of(context)!.popDisposition, RoutePopDisposition.bubble); + }, + variant: TargetPlatformVariant.all(), + ); +}