Skip to content

Commit

Permalink
Red spell check selection on iOS (#125162)
Browse files Browse the repository at this point in the history
iOS now hides the selection handles and shows red selection when tapping a misspelled word, like native.
  • Loading branch information
justinmc authored Apr 21, 2023
1 parent 4b188bd commit 8784eb1
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 12 deletions.
8 changes: 8 additions & 0 deletions packages/flutter/lib/src/cupertino/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -1297,6 +1303,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? CupertinoTextField.cupertinoMisspelledTextStyle,
misspelledSelectionColor: widget.spellCheckConfiguration!.misspelledSelectionColor
?? CupertinoTextField.kMisspelledSelectionColor,
spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4597,7 +4597,9 @@ class EditableTextState extends State<EditableText> 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,
Expand Down
13 changes: 12 additions & 1 deletion packages/flutter/lib/src/widgets/spell_check.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SpellCheckConfiguration {
/// for spell check.
const SpellCheckConfiguration({
this.spellCheckService,
this.misspelledSelectionColor,
this.misspelledTextStyle,
this.spellCheckSuggestionsToolbarBuilder,
}) : _spellCheckEnabled = true;
Expand All @@ -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]
Expand All @@ -56,6 +65,7 @@ class SpellCheckConfiguration {
/// specified overrides.
SpellCheckConfiguration copyWith({
SpellCheckService? spellCheckService,
Color? misspelledSelectionColor,
TextStyle? misspelledTextStyle,
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
if (!_spellCheckEnabled) {
Expand All @@ -65,6 +75,7 @@ class SpellCheckConfiguration {

return SpellCheckConfiguration(
spellCheckService: spellCheckService ?? this.spellCheckService,
misspelledSelectionColor: misspelledSelectionColor ?? this.misspelledSelectionColor,
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
);
Expand Down
40 changes: 31 additions & 9 deletions packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ class TextSelectionOverlay {
context: context,
builder: spellCheckSuggestionsToolbarBuilder,
);
hideHandles();
}

/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -990,7 +1007,7 @@ class SelectionOverlay {
/// [MagnifierController.shown].
/// {@endtemplate}
void showMagnifier(MagnifierInfo initialMagnifierInfo) {
if (_toolbar != null || _contextMenuControllerIsShown) {
if (_toolbarIsVisible) {
hideToolbar();
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1360,7 +1377,7 @@ class SelectionOverlay {
}

final RenderBox renderBox = context.findRenderObject()! as RenderBox;
_contextMenuController.show(
_spellCheckToolbarController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper(
Expand Down Expand Up @@ -1395,6 +1412,8 @@ class SelectionOverlay {
_toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild();
} else if (_spellCheckToolbarController.isShown) {
_spellCheckToolbarController.markNeedsBuild();
}
});
} else {
Expand All @@ -1405,6 +1424,8 @@ class SelectionOverlay {
_toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild();
} else if (_spellCheckToolbarController.isShown) {
_spellCheckToolbarController.markNeedsBuild();
}
}
}
Expand All @@ -1419,7 +1440,7 @@ class SelectionOverlay {
_handles![1].remove();
_handles = null;
}
if (_toolbar != null || _contextMenuControllerIsShown) {
if (_toolbar != null || _contextMenuController.isShown || _spellCheckToolbarController.isShown) {
hideToolbar();
}
}
Expand All @@ -1431,6 +1452,7 @@ class SelectionOverlay {
/// {@endtemplate}
void hideToolbar() {
_contextMenuController.remove();
_spellCheckToolbarController.remove();
if (_toolbar == null) {
return;
}
Expand Down
73 changes: 73 additions & 0 deletions packages/flutter/test/cupertino/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<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]
);
}

0 comments on commit 8784eb1

Please sign in to comment.