diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 310f07c6b3af..f470385bc1f7 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -789,6 +789,12 @@ class CupertinoTextField extends StatefulWidget { decorationStyle: TextDecorationStyle.dotted, ); + /// The color of the selection highlight when the spell check menu is visible. + /// + /// Eyeballed from a screenshot taken on an iPhone 11 running iOS 16.2. + @visibleForTesting + static const Color kMisspelledSelectionColor = Color(0x62ff9699); + /// Default builder for the spell check suggestions toolbar in the Cupertino /// style. /// @@ -1297,6 +1303,8 @@ class _CupertinoTextFieldState extends State with Restoratio ? widget.spellCheckConfiguration!.copyWith( misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle ?? CupertinoTextField.cupertinoMisspelledTextStyle, + misspelledSelectionColor: widget.spellCheckConfiguration!.misspelledSelectionColor + ?? CupertinoTextField.kMisspelledSelectionColor, spellCheckSuggestionsToolbarBuilder: widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder ?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, diff --git a/packages/flutter/lib/src/widgets/context_menu_controller.dart b/packages/flutter/lib/src/widgets/context_menu_controller.dart index 664c27d62a47..df75571d46d4 100644 --- a/packages/flutter/lib/src/widgets/context_menu_controller.dart +++ b/packages/flutter/lib/src/widgets/context_menu_controller.dart @@ -10,7 +10,8 @@ import 'overlay.dart'; /// Builds and manages a context menu at a given location. /// /// There can only ever be one context menu shown at a given time in the entire -/// app. +/// app. Calling [show] on one instance of this class will hide any other +/// shown instances. /// /// {@tool dartpad} /// This example shows how to use a GestureDetector to show a context menu diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 0d21dd111271..6ef90359efa0 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4597,7 +4597,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien minLines: widget.minLines, expands: widget.expands, strutStyle: widget.strutStyle, - selectionColor: widget.selectionColor, + selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false + ? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor + : widget.selectionColor, textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), textAlign: widget.textAlign, textDirection: _textDirection, diff --git a/packages/flutter/lib/src/widgets/spell_check.dart b/packages/flutter/lib/src/widgets/spell_check.dart index a6fb8887e295..3a4574aa0f92 100644 --- a/packages/flutter/lib/src/widgets/spell_check.dart +++ b/packages/flutter/lib/src/widgets/spell_check.dart @@ -21,6 +21,7 @@ class SpellCheckConfiguration { /// for spell check. const SpellCheckConfiguration({ this.spellCheckService, + this.misspelledSelectionColor, this.misspelledTextStyle, this.spellCheckSuggestionsToolbarBuilder, }) : _spellCheckEnabled = true; @@ -30,11 +31,19 @@ class SpellCheckConfiguration { : _spellCheckEnabled = false, spellCheckService = null, spellCheckSuggestionsToolbarBuilder = null, - misspelledTextStyle = null; + misspelledTextStyle = null, + misspelledSelectionColor = null; /// The service used to fetch spell check results for text input. final SpellCheckService? spellCheckService; + /// The color the paint the selection highlight when spell check is showing + /// suggestions for a misspelled word. + /// + /// For example, on iOS, the selection appears red while the spell check menu + /// is showing. + final Color? misspelledSelectionColor; + /// Style used to indicate misspelled words. /// /// This is nullable to allow style-specific wrappers of [EditableText] @@ -56,6 +65,7 @@ class SpellCheckConfiguration { /// specified overrides. SpellCheckConfiguration copyWith({ SpellCheckService? spellCheckService, + Color? misspelledSelectionColor, TextStyle? misspelledTextStyle, EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) { if (!_spellCheckEnabled) { @@ -65,6 +75,7 @@ class SpellCheckConfiguration { return SpellCheckConfiguration( spellCheckService: spellCheckService ?? this.spellCheckService, + misspelledSelectionColor: misspelledSelectionColor ?? this.misspelledSelectionColor, misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder, ); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index f33ba25ea9a2..6a5d788fb88e 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -477,6 +477,7 @@ class TextSelectionOverlay { context: context, builder: spellCheckSuggestionsToolbarBuilder, ); + hideHandles(); } /// {@macro flutter.widgets.SelectionOverlay.showMagnifier} @@ -568,15 +569,25 @@ class TextSelectionOverlay { bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible; /// Whether the toolbar is currently visible. - bool get toolbarIsVisible { - return selectionControls is TextSelectionHandleControls - ? _selectionOverlay._contextMenuControllerIsShown - : _selectionOverlay._toolbar != null; - } + /// + /// Includes both the text selection toolbar and the spell check menu. + /// + /// See also: + /// + /// * [spellCheckToolbarIsVisible], which is only whether the spell check menu + /// specifically is visible. + bool get toolbarIsVisible => _selectionOverlay._toolbarIsVisible; /// Whether the magnifier is currently visible. bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown; + /// Whether the spell check menu is currently visible. + /// + /// See also: + /// + /// * [toolbarIsVisible], which is whether any toolbar is visible. + bool get spellCheckToolbarIsVisible => _selectionOverlay._spellCheckToolbarController.isShown; + /// {@macro flutter.widgets.SelectionOverlay.hide} void hide() => _selectionOverlay.hide(); @@ -979,6 +990,12 @@ class SelectionOverlay { /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} final TextMagnifierConfiguration magnifierConfiguration; + bool get _toolbarIsVisible { + return selectionControls is TextSelectionHandleControls + ? _contextMenuController.isShown || _spellCheckToolbarController.isShown + : _toolbar != null || _spellCheckToolbarController.isShown; + } + /// {@template flutter.widgets.SelectionOverlay.showMagnifier} /// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier] /// was called. This is safe to call on platforms not mobile, since @@ -990,7 +1007,7 @@ class SelectionOverlay { /// [MagnifierController.shown]. /// {@endtemplate} void showMagnifier(MagnifierInfo initialMagnifierInfo) { - if (_toolbar != null || _contextMenuControllerIsShown) { + if (_toolbarIsVisible) { hideToolbar(); } @@ -1288,7 +1305,7 @@ class SelectionOverlay { // Manages the context menu. Not necessarily visible when non-null. final ContextMenuController _contextMenuController = ContextMenuController(); - bool get _contextMenuControllerIsShown => _contextMenuController.isShown; + final ContextMenuController _spellCheckToolbarController = ContextMenuController(); /// {@template flutter.widgets.SelectionOverlay.showHandles} /// Builds the handles by inserting them into the [context]'s overlay. @@ -1360,7 +1377,7 @@ class SelectionOverlay { } final RenderBox renderBox = context.findRenderObject()! as RenderBox; - _contextMenuController.show( + _spellCheckToolbarController.show( context: context, contextMenuBuilder: (BuildContext context) { return _SelectionToolbarWrapper( @@ -1395,6 +1412,8 @@ class SelectionOverlay { _toolbar?.markNeedsBuild(); if (_contextMenuController.isShown) { _contextMenuController.markNeedsBuild(); + } else if (_spellCheckToolbarController.isShown) { + _spellCheckToolbarController.markNeedsBuild(); } }); } else { @@ -1405,6 +1424,8 @@ class SelectionOverlay { _toolbar?.markNeedsBuild(); if (_contextMenuController.isShown) { _contextMenuController.markNeedsBuild(); + } else if (_spellCheckToolbarController.isShown) { + _spellCheckToolbarController.markNeedsBuild(); } } } @@ -1419,7 +1440,7 @@ class SelectionOverlay { _handles![1].remove(); _handles = null; } - if (_toolbar != null || _contextMenuControllerIsShown) { + if (_toolbar != null || _contextMenuController.isShown || _spellCheckToolbarController.isShown) { hideToolbar(); } } @@ -1431,6 +1452,7 @@ class SelectionOverlay { /// {@endtemplate} void hideToolbar() { _contextMenuController.remove(); + _spellCheckToolbarController.remove(); if (_toolbar == null) { return; } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index db4e297df874..8f3692f4261f 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -9395,4 +9395,77 @@ void main() { expect(placeholderWidget.overflow, placeholderStyle.overflow); expect(placeholderWidget.style!.overflow, placeholderStyle.overflow); }); + + testWidgets('tapping on a misspelled word on iOS hides the handles and shows red selection', (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + // The default derived color for the iOS text selection highlight. + const Color defaultSelectionColor = Color(0x33007aff); + final TextEditingController controller = TextEditingController( + text: 'test test testt', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ), + ), + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + state.spellCheckResults = SpellCheckResults( + controller.value.text, + const [ + SuggestionSpan(TextRange(start: 10, end: 15), ['test']), + ]); + + // Double tapping a non-misspelled word shows the normal blue selection and + // the selection handles. + expect(state.selectionOverlay, isNull); + await tester.tapAt(textOffsetToPosition(tester, 2)); + await tester.pump(const Duration(milliseconds: 50)); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + await tester.tapAt(textOffsetToPosition(tester, 2)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 4), + ); + expect(state.selectionOverlay!.handlesAreVisible, isTrue); + expect(state.renderEditable.selectionColor, defaultSelectionColor); + + // Single tapping a non-misspelled word shows a collpased cursor. + await tester.tapAt(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream), + ); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect(state.renderEditable.selectionColor, defaultSelectionColor); + + // Single tapping a misspelled word selects it in red with no handles. + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 10, extentOffset: 15), + ); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect( + state.renderEditable.selectionColor, + CupertinoTextField.kMisspelledSelectionColor, + ); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + skip: kIsWeb, // [intended] + ); }