Skip to content

Commit 8784eb1

Browse files
authored
Red spell check selection on iOS (#125162)
iOS now hides the selection handles and shows red selection when tapping a misspelled word, like native.
1 parent 4b188bd commit 8784eb1

File tree

6 files changed

+129
-12
lines changed

6 files changed

+129
-12
lines changed

packages/flutter/lib/src/cupertino/text_field.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,12 @@ class CupertinoTextField extends StatefulWidget {
789789
decorationStyle: TextDecorationStyle.dotted,
790790
);
791791

792+
/// The color of the selection highlight when the spell check menu is visible.
793+
///
794+
/// Eyeballed from a screenshot taken on an iPhone 11 running iOS 16.2.
795+
@visibleForTesting
796+
static const Color kMisspelledSelectionColor = Color(0x62ff9699);
797+
792798
/// Default builder for the spell check suggestions toolbar in the Cupertino
793799
/// style.
794800
///
@@ -1297,6 +1303,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
12971303
? widget.spellCheckConfiguration!.copyWith(
12981304
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
12991305
?? CupertinoTextField.cupertinoMisspelledTextStyle,
1306+
misspelledSelectionColor: widget.spellCheckConfiguration!.misspelledSelectionColor
1307+
?? CupertinoTextField.kMisspelledSelectionColor,
13001308
spellCheckSuggestionsToolbarBuilder:
13011309
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
13021310
?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,

packages/flutter/lib/src/widgets/context_menu_controller.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import 'overlay.dart';
1010
/// Builds and manages a context menu at a given location.
1111
///
1212
/// There can only ever be one context menu shown at a given time in the entire
13-
/// app.
13+
/// app. Calling [show] on one instance of this class will hide any other
14+
/// shown instances.
1415
///
1516
/// {@tool dartpad}
1617
/// This example shows how to use a GestureDetector to show a context menu

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4597,7 +4597,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
45974597
minLines: widget.minLines,
45984598
expands: widget.expands,
45994599
strutStyle: widget.strutStyle,
4600-
selectionColor: widget.selectionColor,
4600+
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
4601+
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
4602+
: widget.selectionColor,
46014603
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
46024604
textAlign: widget.textAlign,
46034605
textDirection: _textDirection,

packages/flutter/lib/src/widgets/spell_check.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class SpellCheckConfiguration {
2121
/// for spell check.
2222
const SpellCheckConfiguration({
2323
this.spellCheckService,
24+
this.misspelledSelectionColor,
2425
this.misspelledTextStyle,
2526
this.spellCheckSuggestionsToolbarBuilder,
2627
}) : _spellCheckEnabled = true;
@@ -30,11 +31,19 @@ class SpellCheckConfiguration {
3031
: _spellCheckEnabled = false,
3132
spellCheckService = null,
3233
spellCheckSuggestionsToolbarBuilder = null,
33-
misspelledTextStyle = null;
34+
misspelledTextStyle = null,
35+
misspelledSelectionColor = null;
3436

3537
/// The service used to fetch spell check results for text input.
3638
final SpellCheckService? spellCheckService;
3739

40+
/// The color the paint the selection highlight when spell check is showing
41+
/// suggestions for a misspelled word.
42+
///
43+
/// For example, on iOS, the selection appears red while the spell check menu
44+
/// is showing.
45+
final Color? misspelledSelectionColor;
46+
3847
/// Style used to indicate misspelled words.
3948
///
4049
/// This is nullable to allow style-specific wrappers of [EditableText]
@@ -56,6 +65,7 @@ class SpellCheckConfiguration {
5665
/// specified overrides.
5766
SpellCheckConfiguration copyWith({
5867
SpellCheckService? spellCheckService,
68+
Color? misspelledSelectionColor,
5969
TextStyle? misspelledTextStyle,
6070
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
6171
if (!_spellCheckEnabled) {
@@ -65,6 +75,7 @@ class SpellCheckConfiguration {
6575

6676
return SpellCheckConfiguration(
6777
spellCheckService: spellCheckService ?? this.spellCheckService,
78+
misspelledSelectionColor: misspelledSelectionColor ?? this.misspelledSelectionColor,
6879
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
6980
spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
7081
);

packages/flutter/lib/src/widgets/text_selection.dart

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ class TextSelectionOverlay {
477477
context: context,
478478
builder: spellCheckSuggestionsToolbarBuilder,
479479
);
480+
hideHandles();
480481
}
481482

482483
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
@@ -568,15 +569,25 @@ class TextSelectionOverlay {
568569
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
569570

570571
/// Whether the toolbar is currently visible.
571-
bool get toolbarIsVisible {
572-
return selectionControls is TextSelectionHandleControls
573-
? _selectionOverlay._contextMenuControllerIsShown
574-
: _selectionOverlay._toolbar != null;
575-
}
572+
///
573+
/// Includes both the text selection toolbar and the spell check menu.
574+
///
575+
/// See also:
576+
///
577+
/// * [spellCheckToolbarIsVisible], which is only whether the spell check menu
578+
/// specifically is visible.
579+
bool get toolbarIsVisible => _selectionOverlay._toolbarIsVisible;
576580

577581
/// Whether the magnifier is currently visible.
578582
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
579583

584+
/// Whether the spell check menu is currently visible.
585+
///
586+
/// See also:
587+
///
588+
/// * [toolbarIsVisible], which is whether any toolbar is visible.
589+
bool get spellCheckToolbarIsVisible => _selectionOverlay._spellCheckToolbarController.isShown;
590+
580591
/// {@macro flutter.widgets.SelectionOverlay.hide}
581592
void hide() => _selectionOverlay.hide();
582593

@@ -979,6 +990,12 @@ class SelectionOverlay {
979990
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
980991
final TextMagnifierConfiguration magnifierConfiguration;
981992

993+
bool get _toolbarIsVisible {
994+
return selectionControls is TextSelectionHandleControls
995+
? _contextMenuController.isShown || _spellCheckToolbarController.isShown
996+
: _toolbar != null || _spellCheckToolbarController.isShown;
997+
}
998+
982999
/// {@template flutter.widgets.SelectionOverlay.showMagnifier}
9831000
/// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier]
9841001
/// was called. This is safe to call on platforms not mobile, since
@@ -990,7 +1007,7 @@ class SelectionOverlay {
9901007
/// [MagnifierController.shown].
9911008
/// {@endtemplate}
9921009
void showMagnifier(MagnifierInfo initialMagnifierInfo) {
993-
if (_toolbar != null || _contextMenuControllerIsShown) {
1010+
if (_toolbarIsVisible) {
9941011
hideToolbar();
9951012
}
9961013

@@ -1288,7 +1305,7 @@ class SelectionOverlay {
12881305
// Manages the context menu. Not necessarily visible when non-null.
12891306
final ContextMenuController _contextMenuController = ContextMenuController();
12901307

1291-
bool get _contextMenuControllerIsShown => _contextMenuController.isShown;
1308+
final ContextMenuController _spellCheckToolbarController = ContextMenuController();
12921309

12931310
/// {@template flutter.widgets.SelectionOverlay.showHandles}
12941311
/// Builds the handles by inserting them into the [context]'s overlay.
@@ -1360,7 +1377,7 @@ class SelectionOverlay {
13601377
}
13611378

13621379
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
1363-
_contextMenuController.show(
1380+
_spellCheckToolbarController.show(
13641381
context: context,
13651382
contextMenuBuilder: (BuildContext context) {
13661383
return _SelectionToolbarWrapper(
@@ -1395,6 +1412,8 @@ class SelectionOverlay {
13951412
_toolbar?.markNeedsBuild();
13961413
if (_contextMenuController.isShown) {
13971414
_contextMenuController.markNeedsBuild();
1415+
} else if (_spellCheckToolbarController.isShown) {
1416+
_spellCheckToolbarController.markNeedsBuild();
13981417
}
13991418
});
14001419
} else {
@@ -1405,6 +1424,8 @@ class SelectionOverlay {
14051424
_toolbar?.markNeedsBuild();
14061425
if (_contextMenuController.isShown) {
14071426
_contextMenuController.markNeedsBuild();
1427+
} else if (_spellCheckToolbarController.isShown) {
1428+
_spellCheckToolbarController.markNeedsBuild();
14081429
}
14091430
}
14101431
}
@@ -1419,7 +1440,7 @@ class SelectionOverlay {
14191440
_handles![1].remove();
14201441
_handles = null;
14211442
}
1422-
if (_toolbar != null || _contextMenuControllerIsShown) {
1443+
if (_toolbar != null || _contextMenuController.isShown || _spellCheckToolbarController.isShown) {
14231444
hideToolbar();
14241445
}
14251446
}
@@ -1431,6 +1452,7 @@ class SelectionOverlay {
14311452
/// {@endtemplate}
14321453
void hideToolbar() {
14331454
_contextMenuController.remove();
1455+
_spellCheckToolbarController.remove();
14341456
if (_toolbar == null) {
14351457
return;
14361458
}

packages/flutter/test/cupertino/text_field_test.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9395,4 +9395,77 @@ void main() {
93959395
expect(placeholderWidget.overflow, placeholderStyle.overflow);
93969396
expect(placeholderWidget.style!.overflow, placeholderStyle.overflow);
93979397
});
9398+
9399+
testWidgets('tapping on a misspelled word on iOS hides the handles and shows red selection', (WidgetTester tester) async {
9400+
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
9401+
true;
9402+
// The default derived color for the iOS text selection highlight.
9403+
const Color defaultSelectionColor = Color(0x33007aff);
9404+
final TextEditingController controller = TextEditingController(
9405+
text: 'test test testt',
9406+
);
9407+
await tester.pumpWidget(
9408+
CupertinoApp(
9409+
home: Center(
9410+
child: CupertinoTextField(
9411+
controller: controller,
9412+
spellCheckConfiguration:
9413+
const SpellCheckConfiguration(
9414+
misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle,
9415+
spellCheckSuggestionsToolbarBuilder: CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
9416+
),
9417+
),
9418+
),
9419+
),
9420+
);
9421+
9422+
final EditableTextState state =
9423+
tester.state<EditableTextState>(find.byType(EditableText));
9424+
state.spellCheckResults = SpellCheckResults(
9425+
controller.value.text,
9426+
const <SuggestionSpan>[
9427+
SuggestionSpan(TextRange(start: 10, end: 15), <String>['test']),
9428+
]);
9429+
9430+
// Double tapping a non-misspelled word shows the normal blue selection and
9431+
// the selection handles.
9432+
expect(state.selectionOverlay, isNull);
9433+
await tester.tapAt(textOffsetToPosition(tester, 2));
9434+
await tester.pump(const Duration(milliseconds: 50));
9435+
expect(state.selectionOverlay!.handlesAreVisible, isFalse);
9436+
await tester.tapAt(textOffsetToPosition(tester, 2));
9437+
await tester.pumpAndSettle();
9438+
expect(
9439+
controller.selection,
9440+
const TextSelection(baseOffset: 0, extentOffset: 4),
9441+
);
9442+
expect(state.selectionOverlay!.handlesAreVisible, isTrue);
9443+
expect(state.renderEditable.selectionColor, defaultSelectionColor);
9444+
9445+
// Single tapping a non-misspelled word shows a collpased cursor.
9446+
await tester.tapAt(textOffsetToPosition(tester, 7));
9447+
await tester.pumpAndSettle();
9448+
expect(
9449+
controller.selection,
9450+
const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream),
9451+
);
9452+
expect(state.selectionOverlay!.handlesAreVisible, isFalse);
9453+
expect(state.renderEditable.selectionColor, defaultSelectionColor);
9454+
9455+
// Single tapping a misspelled word selects it in red with no handles.
9456+
await tester.tapAt(textOffsetToPosition(tester, 13));
9457+
await tester.pumpAndSettle();
9458+
expect(
9459+
controller.selection,
9460+
const TextSelection(baseOffset: 10, extentOffset: 15),
9461+
);
9462+
expect(state.selectionOverlay!.handlesAreVisible, isFalse);
9463+
expect(
9464+
state.renderEditable.selectionColor,
9465+
CupertinoTextField.kMisspelledSelectionColor,
9466+
);
9467+
},
9468+
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
9469+
skip: kIsWeb, // [intended]
9470+
);
93989471
}

0 commit comments

Comments
 (0)