Skip to content

Commit 52cacd9

Browse files
authored
[iOS] Add spell check suggestions toolbar on tap (flutter#119189)
[iOS] Add spell check suggestions toolbar on tap
1 parent 9d820aa commit 52cacd9

File tree

10 files changed

+393
-64
lines changed

10 files changed

+393
-64
lines changed

packages/flutter/lib/cupertino.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export 'src/cupertino/search_field.dart';
5858
export 'src/cupertino/segmented_control.dart';
5959
export 'src/cupertino/slider.dart';
6060
export 'src/cupertino/sliding_segmented_control.dart';
61+
export 'src/cupertino/spell_check_suggestions_toolbar.dart';
6162
export 'src/cupertino/switch.dart';
6263
export 'src/cupertino/tab_scaffold.dart';
6364
export 'src/cupertino/tab_view.dart';
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/scheduler.dart';
6+
import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan;
7+
import 'package:flutter/widgets.dart';
8+
9+
import 'text_selection_toolbar.dart';
10+
import 'text_selection_toolbar_button.dart';
11+
12+
/// iOS only shows 3 spell check suggestions in the toolbar.
13+
const int _maxSuggestions = 3;
14+
15+
/// The default spell check suggestions toolbar for iOS.
16+
///
17+
/// Tries to position itself below the [anchors], but if it doesn't fit, then it
18+
/// readjusts to fit above bottom view insets.
19+
class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
20+
/// Constructs a [CupertinoSpellCheckSuggestionsToolbar].
21+
const CupertinoSpellCheckSuggestionsToolbar({
22+
super.key,
23+
required this.anchors,
24+
required this.buttonItems,
25+
});
26+
27+
/// The location on which to anchor the menu.
28+
final TextSelectionToolbarAnchors anchors;
29+
30+
/// The [ContextMenuButtonItem]s that will be turned into the correct button
31+
/// widgets and displayed in the spell check suggestions toolbar.
32+
///
33+
/// See also:
34+
///
35+
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
36+
/// [ContextMenuButtonItem]s that are used to build the buttons of the
37+
/// text selection toolbar.
38+
/// * [SpellCheckSuggestionsToolbar.buttonItems], the list of
39+
/// [ContextMenuButtonItem]s used to build the Material style spell check
40+
/// suggestions toolbar.
41+
final List<ContextMenuButtonItem> buttonItems;
42+
43+
/// Builds the button items for the toolbar based on the available
44+
/// spell check suggestions.
45+
static List<ContextMenuButtonItem>? buildButtonItems(
46+
BuildContext context,
47+
EditableTextState editableTextState,
48+
) {
49+
// Determine if composing region is misspelled.
50+
final SuggestionSpan? spanAtCursorIndex =
51+
editableTextState.findSuggestionSpanAtCursorIndex(
52+
editableTextState.currentTextEditingValue.selection.baseOffset,
53+
);
54+
55+
if (spanAtCursorIndex == null) {
56+
return null;
57+
}
58+
if (spanAtCursorIndex.suggestions.isEmpty) {
59+
return <ContextMenuButtonItem>[
60+
ContextMenuButtonItem(
61+
onPressed: () {},
62+
label: 'No Replacements Found',
63+
)
64+
];
65+
}
66+
67+
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
68+
69+
// Build suggestion buttons.
70+
int suggestionCount = 0;
71+
for (final String suggestion in spanAtCursorIndex.suggestions) {
72+
if (suggestionCount >= _maxSuggestions) {
73+
break;
74+
}
75+
buttonItems.add(ContextMenuButtonItem(
76+
onPressed: () {
77+
if (!editableTextState.mounted) {
78+
return;
79+
}
80+
_replaceText(
81+
editableTextState,
82+
suggestion,
83+
spanAtCursorIndex.range,
84+
);
85+
},
86+
label: suggestion,
87+
));
88+
suggestionCount += 1;
89+
}
90+
return buttonItems;
91+
}
92+
93+
static void _replaceText(EditableTextState editableTextState, String text, TextRange replacementRange) {
94+
// Replacement cannot be performed if the text is read only or obscured.
95+
assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText);
96+
97+
final TextEditingValue newValue = editableTextState.textEditingValue.replaced(
98+
replacementRange,
99+
text,
100+
);
101+
editableTextState.userUpdateTextEditingValue(newValue,SelectionChangedCause.toolbar);
102+
103+
// Schedule a call to bringIntoView() after renderEditable updates.
104+
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
105+
if (editableTextState.mounted) {
106+
editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent);
107+
}
108+
});
109+
editableTextState.hideToolbar();
110+
editableTextState.renderEditable.selectWordEdge(cause: SelectionChangedCause.toolbar);
111+
}
112+
113+
/// Builds the toolbar buttons based on the [buttonItems].
114+
List<Widget> _buildToolbarButtons(BuildContext context) {
115+
return buttonItems.map((ContextMenuButtonItem buttonItem) {
116+
return CupertinoTextSelectionToolbarButton.buttonItem(
117+
buttonItem: buttonItem,
118+
);
119+
}).toList();
120+
}
121+
122+
@override
123+
Widget build(BuildContext context) {
124+
final List<Widget> children = _buildToolbarButtons(context);
125+
return CupertinoTextSelectionToolbar(
126+
anchorAbove: anchors.primaryAnchor,
127+
anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
128+
children: children,
129+
);
130+
}
131+
}

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'colors.dart';
1515
import 'desktop_text_selection.dart';
1616
import 'icons.dart';
1717
import 'magnifier.dart';
18+
import 'spell_check_suggestions_toolbar.dart';
1819
import 'text_selection.dart';
1920
import 'theme.dart';
2021

@@ -787,6 +788,32 @@ class CupertinoTextField extends StatefulWidget {
787788
decorationStyle: TextDecorationStyle.dotted,
788789
);
789790

791+
/// Default builder for the spell check suggestions toolbar in the Cupertino
792+
/// style.
793+
///
794+
/// See also:
795+
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
796+
/// builder configured to show a spell check suggestions toolbar.
797+
/// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], the builder
798+
/// configured to show the Material style spell check suggestions toolbar.
799+
@visibleForTesting
800+
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
801+
BuildContext context,
802+
EditableTextState editableTextState,
803+
) {
804+
final List<ContextMenuButtonItem>? buttonItems =
805+
CupertinoSpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);
806+
807+
if (buttonItems == null || buttonItems.isEmpty){
808+
return const SizedBox.shrink();
809+
}
810+
811+
return CupertinoSpellCheckSuggestionsToolbar(
812+
anchors: editableTextState.contextMenuAnchors,
813+
buttonItems: buttonItems,
814+
);
815+
}
816+
790817
/// {@macro flutter.widgets.undoHistory.controller}
791818
final UndoHistoryController? undoController;
792819

@@ -1274,7 +1301,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
12741301
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
12751302
? widget.spellCheckConfiguration!.copyWith(
12761303
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
1277-
?? CupertinoTextField.cupertinoMisspelledTextStyle)
1304+
?? CupertinoTextField.cupertinoMisspelledTextStyle,
1305+
spellCheckSuggestionsToolbarBuilder:
1306+
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
1307+
?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
1308+
)
12781309
: const SpellCheckConfiguration.disabled();
12791310

12801311
final Widget paddedEditable = Padding(

packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
// found in the LICENSE file.
44

55
import 'package:flutter/cupertino.dart';
6-
import 'package:flutter/services.dart' show SuggestionSpan;
6+
import 'package:flutter/scheduler.dart';
7+
import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan;
78

89
import 'adaptive_text_selection_toolbar.dart';
910
import 'colors.dart';
@@ -42,6 +43,9 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
4243
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
4344
/// [ContextMenuButtonItem]s that are used to build the buttons of the
4445
/// text selection toolbar.
46+
/// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of
47+
/// [ContextMenuButtonItem]s used to build the Cupertino style spell check
48+
/// suggestions toolbar.
4549
final List<ContextMenuButtonItem> buttonItems;
4650

4751
/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
@@ -77,10 +81,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
7781
for (final String suggestion in spanAtCursorIndex.suggestions) {
7882
buttonItems.add(ContextMenuButtonItem(
7983
onPressed: () {
80-
editableTextState
81-
.replaceComposingRegion(
82-
SelectionChangedCause.toolbar,
83-
suggestion,
84+
if (!editableTextState.mounted) {
85+
return;
86+
}
87+
_replaceText(
88+
editableTextState,
89+
suggestion,
90+
spanAtCursorIndex.range,
8491
);
8592
},
8693
label: suggestion,
@@ -91,9 +98,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
9198
final ContextMenuButtonItem deleteButton =
9299
ContextMenuButtonItem(
93100
onPressed: () {
94-
editableTextState.replaceComposingRegion(
95-
SelectionChangedCause.toolbar,
101+
if (!editableTextState.mounted) {
102+
return;
103+
}
104+
_replaceText(
105+
editableTextState,
96106
'',
107+
editableTextState.currentTextEditingValue.composing,
97108
);
98109
},
99110
type: ContextMenuButtonType.delete,
@@ -103,6 +114,25 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
103114
return buttonItems;
104115
}
105116

117+
static void _replaceText(EditableTextState editableTextState, String text, TextRange replacementRange) {
118+
// Replacement cannot be performed if the text is read only or obscured.
119+
assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText);
120+
121+
final TextEditingValue newValue = editableTextState.textEditingValue.replaced(
122+
replacementRange,
123+
text,
124+
);
125+
editableTextState.userUpdateTextEditingValue(newValue, SelectionChangedCause.toolbar);
126+
127+
// Schedule a call to bringIntoView() after renderEditable updates.
128+
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
129+
if (editableTextState.mounted) {
130+
editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent);
131+
}
132+
});
133+
editableTextState.hideToolbar();
134+
}
135+
106136
/// Determines the Offset that the toolbar will be anchored to.
107137
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
108138
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,9 @@ class TextField extends StatefulWidget {
805805
///
806806
/// See also:
807807
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
808-
// builder configured to show a spell check suggestions toolbar.
808+
/// builder configured to show a spell check suggestions toolbar.
809+
/// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], the builder
810+
/// configured to show the Material style spell check suggestions toolbar.
809811
@visibleForTesting
810812
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
811813
BuildContext context,
@@ -1239,7 +1241,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
12391241
?? TextField.materialMisspelledTextStyle,
12401242
spellCheckSuggestionsToolbarBuilder:
12411243
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
1242-
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder
1244+
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder,
12431245
)
12441246
: const SpellCheckConfiguration.disabled();
12451247

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import 'system_channels.dart';
1515
/// to "Hello, wrold!" may be:
1616
/// ```dart
1717
/// SuggestionSpan suggestionSpan =
18-
/// SuggestionSpan(
19-
/// const TextRange(start: 7, end: 12),
20-
/// List<String>.of(<String>['word', 'world', 'old']),
18+
/// const SuggestionSpan(
19+
/// TextRange(start: 7, end: 12),
20+
/// <String>['word', 'world', 'old'],
2121
/// );
2222
/// ```
2323
@immutable

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

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2347,24 +2347,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
23472347
}
23482348
}
23492349

2350-
/// Replace composing region with specified text.
2351-
void replaceComposingRegion(SelectionChangedCause cause, String text) {
2352-
// Replacement cannot be performed if the text is read only or obscured.
2353-
assert(!widget.readOnly && !widget.obscureText);
2354-
2355-
_replaceText(ReplaceTextIntent(textEditingValue, text, textEditingValue.composing, cause));
2356-
2357-
if (cause == SelectionChangedCause.toolbar) {
2358-
// Schedule a call to bringIntoView() after renderEditable updates.
2359-
SchedulerBinding.instance.addPostFrameCallback((_) {
2360-
if (mounted) {
2361-
bringIntoView(textEditingValue.selection.extent);
2362-
}
2363-
});
2364-
hideToolbar();
2365-
}
2366-
}
2367-
23682350
/// Finds specified [SuggestionSpan] that matches the provided index using
23692351
/// binary search.
23702352
///
@@ -3980,7 +3962,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
39803962
/// Shows toolbar with spell check suggestions of misspelled words that are
39813963
/// available for click-and-replace.
39823964
bool showSpellCheckSuggestionsToolbar() {
3965+
// Spell check suggestions toolbars are intended to be shown on non-web
3966+
// platforms. Additionally, the Cupertino style toolbar can't be drawn on
3967+
// the web with the HTML renderer due to
3968+
// https://github.com/flutter/flutter/issues/123560.
3969+
final bool platformNotSupported = kIsWeb && BrowserContextMenu.enabled;
39833970
if (!spellCheckEnabled
3971+
|| platformNotSupported
39843972
|| widget.readOnly
39853973
|| _selectionOverlay == null
39863974
|| !_spellCheckResultsReceived) {

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2187,10 +2187,17 @@ class TextSelectionGestureDetectorBuilder {
21872187
case PointerDeviceKind.trackpad:
21882188
case PointerDeviceKind.stylus:
21892189
case PointerDeviceKind.invertedStylus:
2190-
// Precise devices should place the cursor at a precise position.
2190+
// TODO(camsim99): Determine spell check toolbar behavior in these cases:
2191+
// https://github.com/flutter/flutter/issues/119573.
2192+
// Precise devices should place the cursor at a precise position if the
2193+
// word at the text position is not misspelled.
21912194
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
21922195
case PointerDeviceKind.touch:
21932196
case PointerDeviceKind.unknown:
2197+
// If the word that was tapped is misspelled, select the word and show the spell check suggestions
2198+
// toolbar once. If additional taps are made on a misspelled word, toggle the toolbar. If the word
2199+
// is not misspelled, default to the following behavior:
2200+
//
21942201
// Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the
21952202
// TextAffinity remains the same, and the editable is focused. The TextAffinity is important when the
21962203
// cursor is on the boundary of a line wrap, if the affinity is different (i.e. it is downstream), the
@@ -2205,9 +2212,17 @@ class TextSelectionGestureDetectorBuilder {
22052212
final TextSelection previousSelection = renderEditable.selection ?? editableText.textEditingValue.selection;
22062213
final TextPosition textPosition = renderEditable.getPositionForPoint(details.globalPosition);
22072214
final bool isAffinityTheSame = textPosition.affinity == previousSelection.affinity;
2208-
if (((_positionWasOnSelectionExclusive(textPosition) && !previousSelection.isCollapsed)
2209-
|| (_positionWasOnSelectionInclusive(textPosition) && previousSelection.isCollapsed && isAffinityTheSame))
2210-
&& renderEditable.hasFocus) {
2215+
final bool wordAtCursorIndexIsMisspelled = editableText.findSuggestionSpanAtCursorIndex(textPosition.offset) != null;
2216+
2217+
if (wordAtCursorIndexIsMisspelled) {
2218+
renderEditable.selectWord(cause: SelectionChangedCause.tap);
2219+
if (previousSelection != editableText.textEditingValue.selection) {
2220+
editableText.showSpellCheckSuggestionsToolbar();
2221+
} else {
2222+
editableText.toggleToolbar(false);
2223+
}
2224+
}
2225+
else if (((_positionWasOnSelectionExclusive(textPosition) && !previousSelection.isCollapsed) || (_positionWasOnSelectionInclusive(textPosition) && previousSelection.isCollapsed && isAffinityTheSame)) && renderEditable.hasFocus) {
22112226
editableText.toggleToolbar(false);
22122227
} else {
22132228
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);

0 commit comments

Comments
 (0)