From 24d0b1db0a421ee82f3c8683ffddaef7ed9b7fc6 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Tue, 10 Sep 2024 09:54:00 -0700 Subject: [PATCH] SearchBar context menu (#154833) SearchBar and SearchAnchor can now control their context menu. They both received new contextMenuBuilder parameters. See the docs for EditableText.contextMenuBuilder for how to use this, including how to use the native context menu on iOS and to control the browser's context menu on web. --- .../lib/src/material/search_anchor.dart | 23 ++++++++ .../flutter/lib/src/material/text_field.dart | 2 + .../lib/src/widgets/editable_text.dart | 8 +-- .../test/material/search_anchor_test.dart | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index ad41c02304..d78b39b95d 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 'adaptive_text_selection_toolbar.dart'; import 'back_button.dart'; import 'button_style.dart'; import 'color_scheme.dart'; @@ -186,6 +187,7 @@ class SearchAnchor extends StatefulWidget { TextInputAction? textInputAction, TextInputType? keyboardType, EdgeInsets scrollPadding, + EditableTextContextMenuBuilder contextMenuBuilder, }) = _SearchAnchorWithSearchBar; /// Whether the search view grows to fill the entire screen when the @@ -1053,6 +1055,7 @@ class _SearchAnchorWithSearchBar extends SearchAnchor { super.textInputAction, super.keyboardType, EdgeInsets scrollPadding = const EdgeInsets.all(20.0), + EditableTextContextMenuBuilder contextMenuBuilder = SearchBar._defaultContextMenuBuilder, }) : super( viewHintText: viewHintText ?? barHintText, headerHeight: viewHeaderHeight, @@ -1087,6 +1090,7 @@ class _SearchAnchorWithSearchBar extends SearchAnchor { textInputAction: textInputAction, keyboardType: keyboardType, scrollPadding: scrollPadding, + contextMenuBuilder: contextMenuBuilder, ); } ); @@ -1208,6 +1212,7 @@ class SearchBar extends StatefulWidget { this.textInputAction, this.keyboardType, this.scrollPadding = const EdgeInsets.all(20.0), + this.contextMenuBuilder = _defaultContextMenuBuilder, }); /// Controls the text being edited in the search bar's text field. @@ -1356,6 +1361,23 @@ class SearchBar extends StatefulWidget { /// {@macro flutter.widgets.editableText.scrollPadding} final EdgeInsets scrollPadding; + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + @override State createState() => _SearchBarState(); } @@ -1497,6 +1519,7 @@ class _SearchBarState extends State { textInputAction: widget.textInputAction, keyboardType: widget.keyboardType, scrollPadding: widget.scrollPadding, + contextMenuBuilder: widget.contextMenuBuilder, ), ), ), diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 6c8177a1bd..86dff783a9 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -838,6 +838,8 @@ class TextField extends StatefulWidget { /// See also: /// /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. final EditableTextContextMenuBuilder? contextMenuBuilder; /// Determine whether this text field can request the primary focus. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 548e39bdd9..538dafbae7 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1902,10 +1902,10 @@ class EditableText extends StatefulWidget { /// The [TextSelectionToolbarLayoutDelegate] class may be particularly useful /// in honoring the preferred anchor positions. /// - /// For backwards compatibility, when [selectionControls] is set to an object - /// that does not mix in [TextSelectionHandleControls], [contextMenuBuilder] - /// is ignored and the [TextSelectionControls.buildToolbar] method is used - /// instead. + /// For backwards compatibility, when [EditableText.selectionControls] is set + /// to an object that does not mix in [TextSelectionHandleControls], + /// [contextMenuBuilder] is ignored and the + /// [TextSelectionControls.buildToolbar] method is used instead. /// /// {@tool dartpad} /// This example shows how to customize the menu, in this case by keeping the diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart index a3100b94e8..bce7578124 100644 --- a/packages/flutter/test/material/search_anchor_test.dart +++ b/packages/flutter/test/material/search_anchor_test.dart @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../widgets/semantics_tester.dart'; @@ -3361,6 +3362,63 @@ void main() { final EditableText editableText = tester.widget(find.byType(EditableText)); expect(editableText.scrollPadding, scrollPadding); }); + + group('contextMenuBuilder', () { + setUp(() async { + if (!kIsWeb) { + return; + } + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.contextMenu, + (MethodCall call) { + // Just complete successfully, so that BrowserContextMenu thinks that + // the engine successfully received its call. + return Future.value(); + }, + ); + await BrowserContextMenu.disableContextMenu(); + }); + + tearDown(() async { + if (!kIsWeb) { + return; + } + await BrowserContextMenu.enableContextMenu(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null); + }); + + testWidgets('SearchAnchor.bar.contextMenuBuilder is passed through to EditableText', (WidgetTester tester) async { + Widget contextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + return const Placeholder(); + } + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + contextMenuBuilder: contextMenuBuilder, + ), + ), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.contextMenuBuilder, contextMenuBuilder); + + expect(find.byType(Placeholder), findsNothing); + + await tester.tap( + find.byType(SearchBar), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); + + expect(find.byType(Placeholder), findsOneWidget); + }); + }); } Future checkSearchBarDefaults(WidgetTester tester, ColorScheme colorScheme, Material material) async {