Unverified Commit 8784eb1d authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Red spell check selection on iOS (#125162)

iOS now hides the selection handles and shows red selection when tapping a misspelled word, like native.
parent 4b188bd8
...@@ -789,6 +789,12 @@ class CupertinoTextField extends StatefulWidget { ...@@ -789,6 +789,12 @@ class CupertinoTextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.dotted, 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 /// Default builder for the spell check suggestions toolbar in the Cupertino
/// style. /// style.
/// ///
...@@ -1297,6 +1303,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1297,6 +1303,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
? widget.spellCheckConfiguration!.copyWith( ? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? CupertinoTextField.cupertinoMisspelledTextStyle, ?? CupertinoTextField.cupertinoMisspelledTextStyle,
misspelledSelectionColor: widget.spellCheckConfiguration!.misspelledSelectionColor
?? CupertinoTextField.kMisspelledSelectionColor,
spellCheckSuggestionsToolbarBuilder: spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, ?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
......
...@@ -10,7 +10,8 @@ import 'overlay.dart'; ...@@ -10,7 +10,8 @@ import 'overlay.dart';
/// Builds and manages a context menu at a given location. /// 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 /// 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} /// {@tool dartpad}
/// This example shows how to use a GestureDetector to show a context menu /// This example shows how to use a GestureDetector to show a context menu
......
...@@ -4597,7 +4597,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4597,7 +4597,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
minLines: widget.minLines, minLines: widget.minLines,
expands: widget.expands, expands: widget.expands,
strutStyle: widget.strutStyle, strutStyle: widget.strutStyle,
selectionColor: widget.selectionColor, selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
: widget.selectionColor,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
textAlign: widget.textAlign, textAlign: widget.textAlign,
textDirection: _textDirection, textDirection: _textDirection,
......
...@@ -21,6 +21,7 @@ class SpellCheckConfiguration { ...@@ -21,6 +21,7 @@ class SpellCheckConfiguration {
/// for spell check. /// for spell check.
const SpellCheckConfiguration({ const SpellCheckConfiguration({
this.spellCheckService, this.spellCheckService,
this.misspelledSelectionColor,
this.misspelledTextStyle, this.misspelledTextStyle,
this.spellCheckSuggestionsToolbarBuilder, this.spellCheckSuggestionsToolbarBuilder,
}) : _spellCheckEnabled = true; }) : _spellCheckEnabled = true;
...@@ -30,11 +31,19 @@ class SpellCheckConfiguration { ...@@ -30,11 +31,19 @@ class SpellCheckConfiguration {
: _spellCheckEnabled = false, : _spellCheckEnabled = false,
spellCheckService = null, spellCheckService = null,
spellCheckSuggestionsToolbarBuilder = null, spellCheckSuggestionsToolbarBuilder = null,
misspelledTextStyle = null; misspelledTextStyle = null,
misspelledSelectionColor = null;
/// The service used to fetch spell check results for text input. /// The service used to fetch spell check results for text input.
final SpellCheckService? spellCheckService; 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. /// Style used to indicate misspelled words.
/// ///
/// This is nullable to allow style-specific wrappers of [EditableText] /// This is nullable to allow style-specific wrappers of [EditableText]
...@@ -56,6 +65,7 @@ class SpellCheckConfiguration { ...@@ -56,6 +65,7 @@ class SpellCheckConfiguration {
/// specified overrides. /// specified overrides.
SpellCheckConfiguration copyWith({ SpellCheckConfiguration copyWith({
SpellCheckService? spellCheckService, SpellCheckService? spellCheckService,
Color? misspelledSelectionColor,
TextStyle? misspelledTextStyle, TextStyle? misspelledTextStyle,
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) { EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
if (!_spellCheckEnabled) { if (!_spellCheckEnabled) {
...@@ -65,6 +75,7 @@ class SpellCheckConfiguration { ...@@ -65,6 +75,7 @@ class SpellCheckConfiguration {
return SpellCheckConfiguration( return SpellCheckConfiguration(
spellCheckService: spellCheckService ?? this.spellCheckService, spellCheckService: spellCheckService ?? this.spellCheckService,
misspelledSelectionColor: misspelledSelectionColor ?? this.misspelledSelectionColor,
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder, spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
); );
......
...@@ -477,6 +477,7 @@ class TextSelectionOverlay { ...@@ -477,6 +477,7 @@ class TextSelectionOverlay {
context: context, context: context,
builder: spellCheckSuggestionsToolbarBuilder, builder: spellCheckSuggestionsToolbarBuilder,
); );
hideHandles();
} }
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier} /// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
...@@ -568,15 +569,25 @@ class TextSelectionOverlay { ...@@ -568,15 +569,25 @@ class TextSelectionOverlay {
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible; bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
/// Whether the toolbar is currently visible. /// Whether the toolbar is currently visible.
bool get toolbarIsVisible { ///
return selectionControls is TextSelectionHandleControls /// Includes both the text selection toolbar and the spell check menu.
? _selectionOverlay._contextMenuControllerIsShown ///
: _selectionOverlay._toolbar != null; /// 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. /// Whether the magnifier is currently visible.
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown; 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} /// {@macro flutter.widgets.SelectionOverlay.hide}
void hide() => _selectionOverlay.hide(); void hide() => _selectionOverlay.hide();
...@@ -979,6 +990,12 @@ class SelectionOverlay { ...@@ -979,6 +990,12 @@ class SelectionOverlay {
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration; final TextMagnifierConfiguration magnifierConfiguration;
bool get _toolbarIsVisible {
return selectionControls is TextSelectionHandleControls
? _contextMenuController.isShown || _spellCheckToolbarController.isShown
: _toolbar != null || _spellCheckToolbarController.isShown;
}
/// {@template flutter.widgets.SelectionOverlay.showMagnifier} /// {@template flutter.widgets.SelectionOverlay.showMagnifier}
/// Shows the magnifier, and hides the toolbar if it was showing when [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 /// was called. This is safe to call on platforms not mobile, since
...@@ -990,7 +1007,7 @@ class SelectionOverlay { ...@@ -990,7 +1007,7 @@ class SelectionOverlay {
/// [MagnifierController.shown]. /// [MagnifierController.shown].
/// {@endtemplate} /// {@endtemplate}
void showMagnifier(MagnifierInfo initialMagnifierInfo) { void showMagnifier(MagnifierInfo initialMagnifierInfo) {
if (_toolbar != null || _contextMenuControllerIsShown) { if (_toolbarIsVisible) {
hideToolbar(); hideToolbar();
} }
...@@ -1288,7 +1305,7 @@ class SelectionOverlay { ...@@ -1288,7 +1305,7 @@ class SelectionOverlay {
// Manages the context menu. Not necessarily visible when non-null. // Manages the context menu. Not necessarily visible when non-null.
final ContextMenuController _contextMenuController = ContextMenuController(); final ContextMenuController _contextMenuController = ContextMenuController();
bool get _contextMenuControllerIsShown => _contextMenuController.isShown; final ContextMenuController _spellCheckToolbarController = ContextMenuController();
/// {@template flutter.widgets.SelectionOverlay.showHandles} /// {@template flutter.widgets.SelectionOverlay.showHandles}
/// Builds the handles by inserting them into the [context]'s overlay. /// Builds the handles by inserting them into the [context]'s overlay.
...@@ -1360,7 +1377,7 @@ class SelectionOverlay { ...@@ -1360,7 +1377,7 @@ class SelectionOverlay {
} }
final RenderBox renderBox = context.findRenderObject()! as RenderBox; final RenderBox renderBox = context.findRenderObject()! as RenderBox;
_contextMenuController.show( _spellCheckToolbarController.show(
context: context, context: context,
contextMenuBuilder: (BuildContext context) { contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper( return _SelectionToolbarWrapper(
...@@ -1395,6 +1412,8 @@ class SelectionOverlay { ...@@ -1395,6 +1412,8 @@ class SelectionOverlay {
_toolbar?.markNeedsBuild(); _toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) { if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild(); _contextMenuController.markNeedsBuild();
} else if (_spellCheckToolbarController.isShown) {
_spellCheckToolbarController.markNeedsBuild();
} }
}); });
} else { } else {
...@@ -1405,6 +1424,8 @@ class SelectionOverlay { ...@@ -1405,6 +1424,8 @@ class SelectionOverlay {
_toolbar?.markNeedsBuild(); _toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) { if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild(); _contextMenuController.markNeedsBuild();
} else if (_spellCheckToolbarController.isShown) {
_spellCheckToolbarController.markNeedsBuild();
} }
} }
} }
...@@ -1419,7 +1440,7 @@ class SelectionOverlay { ...@@ -1419,7 +1440,7 @@ class SelectionOverlay {
_handles![1].remove(); _handles![1].remove();
_handles = null; _handles = null;
} }
if (_toolbar != null || _contextMenuControllerIsShown) { if (_toolbar != null || _contextMenuController.isShown || _spellCheckToolbarController.isShown) {
hideToolbar(); hideToolbar();
} }
} }
...@@ -1431,6 +1452,7 @@ class SelectionOverlay { ...@@ -1431,6 +1452,7 @@ class SelectionOverlay {
/// {@endtemplate} /// {@endtemplate}
void hideToolbar() { void hideToolbar() {
_contextMenuController.remove(); _contextMenuController.remove();
_spellCheckToolbarController.remove();
if (_toolbar == null) { if (_toolbar == null) {
return; return;
} }
......
...@@ -9395,4 +9395,77 @@ void main() { ...@@ -9395,4 +9395,77 @@ void main() {
expect(placeholderWidget.overflow, placeholderStyle.overflow); expect(placeholderWidget.overflow, placeholderStyle.overflow);
expect(placeholderWidget.style!.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<EditableTextState>(find.byType(EditableText));
state.spellCheckResults = SpellCheckResults(
controller.value.text,
const <SuggestionSpan>[
SuggestionSpan(TextRange(start: 10, end: 15), <String>['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>{ TargetPlatform.iOS }),
skip: kIsWeb, // [intended]
);
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment