diff --git a/dev/integration_tests/new_gallery/lib/studies/fortnightly/shared.dart b/dev/integration_tests/new_gallery/lib/studies/fortnightly/shared.dart index d8cc8b4709..5fd2aeef49 100644 --- a/dev/integration_tests/new_gallery/lib/studies/fortnightly/shared.dart +++ b/dev/integration_tests/new_gallery/lib/studies/fortnightly/shared.dart @@ -305,6 +305,7 @@ class NavigationMenu extends StatelessWidget { Row( children: [ IconButton( + key: StandardComponentType.closeButton.key, icon: const Icon(Icons.close), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: () => Navigator.pop(context), diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 495fd62e15..00b3c05eee 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -1669,7 +1669,10 @@ class _BackChevron extends StatelessWidget { break; } - return iconWidget; + return KeyedSubtree( + key: StandardComponentType.backButton.key, + child: iconWidget, + ); } } diff --git a/packages/flutter/lib/src/material/action_buttons.dart b/packages/flutter/lib/src/material/action_buttons.dart index 5b6b486b88..4d6e125a54 100644 --- a/packages/flutter/lib/src/material/action_buttons.dart +++ b/packages/flutter/lib/src/material/action_buttons.dart @@ -28,6 +28,7 @@ abstract class _ActionButton extends StatelessWidget { this.color, required this.icon, required this.onPressed, + this.standardComponent, this.style, }); @@ -56,6 +57,10 @@ abstract class _ActionButton extends StatelessWidget { /// Null by default. final ButtonStyle? style; + /// An enum value to use to identify this button as a type of + /// [StandardComponentType]. + final StandardComponentType? standardComponent; + /// This returns the appropriate tooltip text for this action button. String _getTooltip(BuildContext context); @@ -67,6 +72,7 @@ abstract class _ActionButton extends StatelessWidget { Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); return IconButton( + key: standardComponent?.key, icon: icon, style: style, color: color, @@ -212,7 +218,10 @@ class BackButton extends _ActionButton { super.color, super.style, super.onPressed, - }) : super(icon: const BackButtonIcon()); + }) : super( + icon: const BackButtonIcon(), + standardComponent: StandardComponentType.backButton, + ); @override void _onPressedCallback(BuildContext context) => Navigator.maybePop(context); @@ -281,7 +290,10 @@ class CloseButtonIcon extends StatelessWidget { class CloseButton extends _ActionButton { /// Creates a Material Design close icon button. const CloseButton({ super.key, super.color, super.onPressed, super.style }) - : super(icon: const CloseButtonIcon()); + : super( + icon: const CloseButtonIcon(), + standardComponent: StandardComponentType.closeButton, + ); @override void _onPressedCallback(BuildContext context) => Navigator.maybePop(context); @@ -347,7 +359,10 @@ class DrawerButton extends _ActionButton { super.color, super.style, super.onPressed, - }) : super(icon: const DrawerButtonIcon()); + }) : super( + icon: const DrawerButtonIcon(), + standardComponent: StandardComponentType.drawerButton, + ); @override void _onPressedCallback(BuildContext context) => Scaffold.of(context).openDrawer(); diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index b6ed82d4a6..d542632cdb 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -1548,6 +1548,7 @@ class PopupMenuButtonState extends State> { } return IconButton( + key: StandardComponentType.moreButton.key, icon: widget.icon ?? Icon(Icons.adaptive.more), padding: widget.padding, splashRadius: widget.splashRadius, diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index 94a36af30e..4b876d2888 100644 --- a/packages/flutter/lib/src/material/search_anchor.dart +++ b/packages/flutter/lib/src/material/search_anchor.dart @@ -8,6 +8,7 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; +import 'back_button.dart'; import 'button_style.dart'; import 'color_scheme.dart'; import 'colors.dart'; @@ -865,11 +866,9 @@ class _ViewContentState extends State<_ViewContent> { @override Widget build(BuildContext context) { - final Widget defaultLeading = IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () { Navigator.of(context).pop(); }, + final Widget defaultLeading = BackButton( style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { Navigator.of(context).pop(); }, ); final List defaultTrailing = [ diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index da4627c1bf..5a6742c3b5 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -678,6 +678,7 @@ class _SnackBarState extends State { final IconButton? iconButton = showCloseIcon ? IconButton( + key: StandardComponentType.closeButton.key, icon: const Icon(Icons.close), iconSize: 24.0, color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, diff --git a/packages/flutter/lib/src/material/text_selection_toolbar.dart b/packages/flutter/lib/src/material/text_selection_toolbar.dart index 1707990892..17daa3397c 100644 --- a/packages/flutter/lib/src/material/text_selection_toolbar.dart +++ b/packages/flutter/lib/src/material/text_selection_toolbar.dart @@ -220,6 +220,7 @@ class _TextSelectionToolbarOverflowableState extends State<_TextSelectionToolbar // The navButton that shows and hides the overflow menu is the // first child. _TextSelectionToolbarOverflowButton( + key: _overflowOpen ? StandardComponentType.backButton.key : StandardComponentType.moreButton.key, icon: Icon(_overflowOpen ? Icons.arrow_back : Icons.more_vert), onPressed: () { setState(() { @@ -731,6 +732,7 @@ class _TextSelectionToolbarContainer extends StatelessWidget { // forward and back controls. class _TextSelectionToolbarOverflowButton extends StatelessWidget { const _TextSelectionToolbarOverflowButton({ + super.key, required this.icon, this.onPressed, this.tooltip, diff --git a/packages/flutter/lib/src/widgets/standard_component_type.dart b/packages/flutter/lib/src/widgets/standard_component_type.dart new file mode 100644 index 0000000000..a22713ecab --- /dev/null +++ b/packages/flutter/lib/src/widgets/standard_component_type.dart @@ -0,0 +1,54 @@ +// 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'; + +/// An enum identifying standard UI components. +/// +/// This enum is used to attach a key to a widget identifying it as a standard +/// UI component for testing and discovery purposes. +/// +/// It is used by the testing infrastructure (e.g. the `find` object in the +/// Flutter test framework) to positively identify and/or activate specific +/// widgets as representing standard UI components, since many of these +/// components vary slightly in the icons or tooltips that they use, and making +/// an effective test matcher for them is fragile and error prone. +/// +/// The keys don't have any effect on the functioning of the UI elements, they +/// are just a means of identifying them. A widget won't be treated specially if +/// it has this key, other than to be found by the testing infrastructure. If +/// tests are not searching for them, then adding them to a widget serves no +/// purpose. +/// +/// Any widget with the [key] from a value here applied to it will be considered +/// to be that type of standard UI component in tests. +/// +/// Types included here are generally only those for which it can be difficult +/// or fragile to create a reliable test matcher for. It is not (nor should it +/// become) an exhaustive list of standard UI components. +/// +/// These are typically used in tests via `find.backButton()` or +/// `find.closeButton()`. +enum StandardComponentType { + /// Indicates the associated widget is a standard back button, typically used + /// to navigate back to the previous screen. + backButton, + + /// Indicates the associated widget is a close button, typically used to + /// dismiss a dialog or modal sheet. + closeButton, + + /// Indicates the associated widget is a "more" button, typically used to + /// display a menu of additional options. + moreButton, + + /// Indicates the associated widget is a drawer button, typically used to open + /// a drawer. + drawerButton; + + /// Returns a [ValueKey] for this [StandardComponentType]. + /// + /// Attach this key to a widget to indicate it is a standard UI component. + ValueKey get key => ValueKey(this); +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 42731ac853..c5dbf6f206 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -145,6 +145,7 @@ export 'src/widgets/slotted_render_object_widget.dart'; export 'src/widgets/snapshot_widget.dart'; export 'src/widgets/spacer.dart'; export 'src/widgets/spell_check.dart'; +export 'src/widgets/standard_component_type.dart'; export 'src/widgets/status_transitions.dart'; export 'src/widgets/system_context_menu.dart'; export 'src/widgets/table.dart'; diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart index 5ea9db4e35..a99d779480 100644 --- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -463,7 +463,7 @@ void main() { expect( flying( tester, - find.byWidgetPredicate((Widget widget) => widget.key != null), + find.byWidgetPredicate((Widget widget) => widget.key != null && widget.key is GlobalKey), ), findsNothing, ); diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 0e6ba00706..334ebfd144 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -875,7 +875,7 @@ void main() { final Icon icon = tester.widget(find.byType(Icon)); expect(icon.icon, expectedIcon, reason: "didn't find close icon for $type"); - expect(find.byType(CloseButton), findsOneWidget, reason: "didn't find close button for $type"); + expect(find.byKey(StandardComponentType.closeButton.key), findsOneWidget, reason: "didn't find close button for $type"); } PageRoute materialRouteBuilder() { diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart index 9f1c115b90..e7c8465889 100644 --- a/packages/flutter/test/material/search_anchor_test.dart +++ b/packages/flutter/test/material/search_anchor_test.dart @@ -888,7 +888,7 @@ void main() { await tester.pumpAndSettle(); TextField textField = tester.widget(find.byType(TextField)); expect(textField.textCapitalization, TextCapitalization.characters); - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pump(); await tester.pumpWidget(buildSearchAnchor(TextCapitalization.none)); @@ -981,7 +981,7 @@ void main() { final TextField textFieldInView = tester.widget(textFieldFinder); expect(textFieldInView.textCapitalization, TextCapitalization.characters); // Close search view. - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pumpAndSettle(); final TextField textField = tester.widget(find.byType(TextField)); expect(textField.textCapitalization, TextCapitalization.characters); @@ -1139,7 +1139,7 @@ void main() { expect(decoration.border!.bottom.color, colorScheme.outline); // Default search view has a leading back button on the start of the header. - expect(find.widgetWithIcon(IconButton, Icons.arrow_back), findsOneWidget); + expect(find.backButton(), findsOneWidget); final Text helperText = tester.widget(find.text('hint text')); expect(helperText.style?.color, colorScheme.onSurfaceVariant); @@ -1408,13 +1408,13 @@ void main() { await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); await tester.pumpAndSettle(); // Default is a icon button with arrow_back. - expect(find.widgetWithIcon(IconButton, Icons.arrow_back), findsOneWidget); + expect(find.backButton(), findsOneWidget); await tester.pumpWidget(Container()); await tester.pumpWidget(buildAnchor(viewLeading: const Icon(Icons.history))); await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.arrow_back), findsNothing); + expect(find.backButton(), findsNothing); expect(find.byIcon(Icons.history), findsOneWidget); }); @@ -2189,13 +2189,13 @@ void main() { // Open the search view await tester.tap(find.byIcon(Icons.search)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.arrow_back), findsOneWidget); + expect(find.backButton(), findsOneWidget); // Change window size tester.view.physicalSize = const Size(250.0, 200.0); tester.view.devicePixelRatio = 1.0; await tester.pumpAndSettle(); - expect(find.byIcon(Icons.arrow_back), findsNothing); + expect(find.backButton(), findsNothing); }); testWidgets('Full-screen search view route should stay if the window size changes', (WidgetTester tester) async { @@ -2230,13 +2230,13 @@ void main() { // Open a full-screen search view await tester.tap(find.byIcon(Icons.search)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.arrow_back), findsOneWidget); + expect(find.backButton(), findsOneWidget); // Change window size tester.view.physicalSize = const Size(250.0, 200.0); tester.view.devicePixelRatio = 1.0; await tester.pumpAndSettle(); - expect(find.byIcon(Icons.arrow_back), findsOneWidget); + expect(find.backButton(), findsOneWidget); }); testWidgets('Search view route does not throw exception during pop animation', (WidgetTester tester) async { @@ -2275,7 +2275,7 @@ void main() { await tester.pumpAndSettle(); // Pop search view route - await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pumpAndSettle(); // No exception. @@ -2815,7 +2815,7 @@ void main() { await tester.pumpAndSettle(); TextField textField = tester.widget(find.byType(TextField)); expect(textField.keyboardType, TextInputType.number); - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pump(); await tester.pumpWidget(buildSearchAnchor(TextInputType.phone)); @@ -2849,7 +2849,7 @@ void main() { final TextField textFieldInView = tester.widget(textFieldFinder); expect(textFieldInView.keyboardType, TextInputType.number); // Close search view. - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pumpAndSettle(); final TextField textField = tester.widget(find.byType(TextField)); expect(textField.keyboardType, TextInputType.number); @@ -2907,7 +2907,7 @@ void main() { await tester.pumpAndSettle(); TextField textField = tester.widget(find.byType(TextField)); expect(textField.textInputAction, TextInputAction.previous); - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pump(); await tester.pumpWidget(buildSearchAnchor(TextInputAction.send)); @@ -2941,7 +2941,7 @@ void main() { final TextField textFieldInView = tester.widget(textFieldFinder); expect(textFieldInView.textInputAction, TextInputAction.previous); // Close search view. - await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back)); + await tester.tap(find.backButton()); await tester.pumpAndSettle(); final TextField textField = tester.widget(find.byType(TextField)); expect(textField.textInputAction, TextInputAction.previous); diff --git a/packages/flutter/test/widgets/text_selection_toolbar_utils.dart b/packages/flutter/test/widgets/text_selection_toolbar_utils.dart index 23a9830bc5..2c921487f4 100644 --- a/packages/flutter/test/widgets/text_selection_toolbar_utils.dart +++ b/packages/flutter/test/widgets/text_selection_toolbar_utils.dart @@ -192,11 +192,11 @@ void expectMaterialToolbarForFullSelection() { } Finder findMaterialOverflowNextButton() { - return find.byIcon(Icons.more_vert); + return find.byKey(StandardComponentType.moreButton.key); } Finder findMaterialOverflowBackButton() { - return find.byIcon(Icons.arrow_back); + return find.byKey(StandardComponentType.backButton.key); } Future tapMaterialOverflowNextButton(WidgetTester tester) async { diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index 8d37f8299f..d543571b0e 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -468,11 +468,77 @@ class CommonFinders { return _AncestorWidgetFinder(of, matching, matchLeaves: matchRoot); } + /// Finds a standard "back" button. + /// + /// A common element on many user interfaces is the "back" button. This is the + /// button which takes the user back to the previous page/screen/state. + /// + /// It is useful in tests to be able to find these buttons, both for tapping + /// them or verifying their existence, but because different platforms and + /// locales have different icons representing them with different labels and + /// tooltips, it's not desirable to have to look them up by these attributes. + /// + /// This finder uses the [StandardComponentType] enum to look for buttons that + /// have the key associated with [StandardComponentType.backButton]. If + /// another widget is assigned that key, then it too will be considered an + /// "official" back button in the widget tree, allowing this matcher to still + /// find it even though it might use a different icon or tooltip. + /// + /// ## Sample code + /// + /// ```dart + /// expect(find.backButton(), findsOneWidget); + /// ``` + /// + /// See also: + /// + /// * [StandardComponentType], the enum that enumerates components that are + /// both common in user interfaces, but which also can vary slightly in + /// presentation across different platforms, locales, and devices. + /// * [BackButton], the Flutter Material widget that represents the back + /// button. + Finder backButton() { + return byKey(StandardComponentType.backButton.key); + } + + /// Finds a standard "close" button. + /// + /// A common element on many user interfaces is the "close" button. This is + /// the button which closes or cancels whatever it is attached to. + /// + /// It is useful in tests to be able to find these buttons, both for tapping + /// them or verifying their existence, but because different platforms and + /// locales have different icons representing them with different labels and + /// tooltips, it's not desirable to have to look them up by these attributes. + /// + /// This finder uses the [StandardComponentType] enum to look for buttons that + /// have the key associated with [StandardComponentType.closeButton]. If + /// another widget is assigned that key, then it too will be considered an + /// "official" close button in the widget tree, allowing this matcher to still + /// find it even though it might use a different icon or tooltip. + /// + /// ## Sample code + /// + /// ```dart + /// expect(find.closeButton(), findsOneWidget); + /// ``` + /// + /// See also: + /// + /// * [StandardComponentType], the enum that enumerates components that are + /// both common in user interfaces, but which also can vary slightly in + /// presentation across different platforms, locales, and devices. + /// * [CloseButton], the Flutter Material widget that represents a close + /// button. + Finder closeButton() { + return byKey(StandardComponentType.closeButton.key); + } + /// Finds [Semantics] widgets matching the given `label`, either by /// [RegExp.hasMatch] or string equality. /// /// The framework may combine semantics labels in certain scenarios, such as - /// when multiple [Text] widgets are in a [MaterialButton] widget. In such a + /// when multiple [Text] widgets are in a [TextButton] widget. In such a /// case, it may be preferable to match by regular expression. Consumers of /// this API __must not__ introduce unsuitable content into the semantics tree /// for the purposes of testing; in particular, you should prefer matching by @@ -515,7 +581,6 @@ class CommonFinders { } } - /// Provides lightweight syntax for getting frequently used semantics finders. /// /// This class is instantiated once, as [CommonFinders.semantics], under [find].