Skip to content

Commit f04a5af

Browse files
authored
Limit the number of Material spell check suggestions to 3 (flutter#124899)
Fixes a bug where the spell check menu could overflow.
1 parent 0ea2f3b commit f04a5af

File tree

4 files changed

+147
-55
lines changed

4 files changed

+147
-55
lines changed

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,30 @@ import 'text_selection_toolbar.dart';
1212
import 'text_selection_toolbar_button.dart';
1313

1414
/// iOS only shows 3 spell check suggestions in the toolbar.
15-
const int _maxSuggestions = 3;
15+
const int _kMaxSuggestions = 3;
1616

1717
/// The default spell check suggestions toolbar for iOS.
1818
///
1919
/// Tries to position itself below the [anchors], but if it doesn't fit, then it
2020
/// readjusts to fit above bottom view insets.
2121
class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
2222
/// Constructs a [CupertinoSpellCheckSuggestionsToolbar].
23+
///
24+
/// [buttonItems] must not contain more than three items.
2325
const CupertinoSpellCheckSuggestionsToolbar({
2426
super.key,
2527
required this.anchors,
2628
required this.buttonItems,
27-
});
29+
}) : assert(buttonItems.length <= _kMaxSuggestions);
2830

2931
/// The location on which to anchor the menu.
3032
final TextSelectionToolbarAnchors anchors;
3133

3234
/// The [ContextMenuButtonItem]s that will be turned into the correct button
3335
/// widgets and displayed in the spell check suggestions toolbar.
3436
///
37+
/// Must not contain more than three items.
38+
///
3539
/// See also:
3640
///
3741
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
@@ -71,11 +75,7 @@ class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
7175
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
7276

7377
// Build suggestion buttons.
74-
int suggestionCount = 0;
75-
for (final String suggestion in spanAtCursorIndex.suggestions) {
76-
if (suggestionCount >= _maxSuggestions) {
77-
break;
78-
}
78+
for (final String suggestion in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) {
7979
buttonItems.add(ContextMenuButtonItem(
8080
onPressed: () {
8181
if (!editableTextState.mounted) {
@@ -89,7 +89,6 @@ class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
8989
},
9090
label: suggestion,
9191
));
92-
suggestionCount += 1;
9392
}
9493
return buttonItems;
9594
}

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

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,23 @@ import 'text_selection_toolbar_text_button.dart';
1818
// Size eyeballed on Pixel 4 emulator running Android API 31.
1919
const double _kDefaultToolbarHeight = 193.0;
2020

21+
/// The maximum number of suggestions in the toolbar is 3, plus a delete button.
22+
const int _kMaxSuggestions = 3;
23+
2124
/// The default spell check suggestions toolbar for Android.
2225
///
2326
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
2427
/// readjusts to fit above bottom view insets.
2528
class SpellCheckSuggestionsToolbar extends StatelessWidget {
2629
/// Constructs a [SpellCheckSuggestionsToolbar].
30+
///
31+
/// [buttonItems] must not contain more than four items, generally three
32+
/// suggestions and one delete button.
2733
const SpellCheckSuggestionsToolbar({
2834
super.key,
2935
required this.anchor,
3036
required this.buttonItems,
31-
});
37+
}) : assert(buttonItems.length <= _kMaxSuggestions + 1);
3238

3339
/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
3440
/// The focal point below which the toolbar attempts to position itself.
@@ -38,6 +44,9 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
3844
/// The [ContextMenuButtonItem]s that will be turned into the correct button
3945
/// widgets and displayed in the spell check suggestions toolbar.
4046
///
47+
/// Must not contain more than four items, typically three suggestions and a
48+
/// delete button.
49+
///
4150
/// See also:
4251
///
4352
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
@@ -52,13 +61,6 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
5261
/// running Android API 31.
5362
static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0;
5463

55-
/// Builds the default Android Material spell check suggestions toolbar.
56-
static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) {
57-
return _SpellCheckSuggestionsToolbarContainer(
58-
child: child,
59-
);
60-
}
61-
6264
/// Builds the button items for the toolbar based on the available
6365
/// spell check suggestions.
6466
static List<ContextMenuButtonItem>? buildButtonItems(
@@ -77,7 +79,7 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
7779
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
7880

7981
// Build suggestion buttons.
80-
for (final String suggestion in spanAtCursorIndex.suggestions) {
82+
for (final String suggestion in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) {
8183
buttonItems.add(ContextMenuButtonItem(
8284
onPressed: () {
8385
if (!editableTextState.mounted) {
@@ -190,10 +192,10 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
190192
// This duration was eyeballed on a Pixel 2 emulator running Android
191193
// API 28 for the Material TextSelectionToolbar.
192194
duration: const Duration(milliseconds: 140),
193-
child: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout(
195+
child: _SpellCheckSuggestionsToolbarContainer(
194196
height: spellCheckSuggestionsToolbarHeight,
195197
children: <Widget>[..._buildToolbarButtons(context)],
196-
)),
198+
),
197199
),
198200
),
199201
);
@@ -204,46 +206,30 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
204206
/// toolbar.
205207
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
206208
const _SpellCheckSuggestionsToolbarContainer({
207-
required this.child,
208-
});
209-
210-
final Widget child;
211-
212-
@override
213-
Widget build(BuildContext context) {
214-
return Material(
215-
// This elevation was eyeballed on a Pixel 4 emulator running Android
216-
// API 31 for the SpellCheckSuggestionsToolbar.
217-
elevation: 2.0,
218-
type: MaterialType.card,
219-
child: child,
220-
);
221-
}
222-
}
223-
224-
/// Renders the spell check suggestions toolbar items in the correct positions
225-
/// in the menu.
226-
class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget {
227-
const _SpellCheckSuggestsionsToolbarItemsLayout({
228209
required this.height,
229210
required this.children,
230211
});
231212

232213
final double height;
233-
234214
final List<Widget> children;
235215

236216
@override
237217
Widget build(BuildContext context) {
238-
return SizedBox(
239-
// This width was eyeballed on a Pixel 4 emulator running Android
218+
return Material(
219+
// This elevation was eyeballed on a Pixel 4 emulator running Android
240220
// API 31 for the SpellCheckSuggestionsToolbar.
241-
width: 165,
242-
height: height,
243-
child: Column(
244-
mainAxisSize: MainAxisSize.min,
245-
crossAxisAlignment: CrossAxisAlignment.stretch,
246-
children: children,
221+
elevation: 2.0,
222+
type: MaterialType.card,
223+
child: SizedBox(
224+
// This width was eyeballed on a Pixel 4 emulator running Android
225+
// API 31 for the SpellCheckSuggestionsToolbar.
226+
width: 165.0,
227+
height: height,
228+
child: Column(
229+
mainAxisSize: MainAxisSize.min,
230+
crossAxisAlignment: CrossAxisAlignment.stretch,
231+
children: children,
232+
),
247233
),
248234
);
249235
}

packages/flutter/test/cupertino/spell_check_suggestions_toolbar_test.dart

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

55
import 'package:flutter/cupertino.dart';
6+
import 'package:flutter/foundation.dart';
67
import 'package:flutter/services.dart';
78

89
import 'package:flutter_test/flutter_test.dart';
910

1011
void main() {
1112
TestWidgetsFlutterBinding.ensureInitialized();
1213

14+
testWidgets('more than three suggestions throws an error', (WidgetTester tester) async {
15+
Future<void> pumpToolbar(List<String> suggestions) async {
16+
await tester.pumpWidget(
17+
CupertinoApp(
18+
home: Center(
19+
child: CupertinoSpellCheckSuggestionsToolbar(
20+
anchors: const TextSelectionToolbarAnchors(
21+
primaryAnchor: Offset.zero,
22+
),
23+
buttonItems: suggestions.map((String string) {
24+
return ContextMenuButtonItem(
25+
onPressed: () {},
26+
label: string,
27+
);
28+
}).toList(),
29+
),
30+
),
31+
),
32+
);
33+
}
34+
await pumpToolbar(<String>['hello', 'yellow', 'yell']);
35+
expect(() async {
36+
await pumpToolbar(<String>['hello', 'yellow', 'yell', 'yeller']);
37+
}, throwsAssertionError);
38+
},
39+
skip: kIsWeb, // [intended]
40+
);
41+
42+
test('buildSuggestionButtons only considers the first three suggestions', () {
43+
final _FakeEditableTextState editableTextState = _FakeEditableTextState(
44+
suggestions: <String>[
45+
'hello',
46+
'yellow',
47+
'yell',
48+
'yeller',
49+
],
50+
);
51+
final List<ContextMenuButtonItem>? buttonItems =
52+
CupertinoSpellCheckSuggestionsToolbar.buildButtonItems(editableTextState);
53+
expect(buttonItems, isNotNull);
54+
final Iterable<String?> labels = buttonItems!.map((ContextMenuButtonItem buttonItem) {
55+
return buttonItem.label;
56+
});
57+
expect(labels, hasLength(3));
58+
expect(labels, contains('hello'));
59+
expect(labels, contains('yellow'));
60+
expect(labels, contains('yell'));
61+
expect(labels, isNot(contains('yeller')));
62+
});
63+
1364
testWidgets('buildButtonItems builds a "No Replacements Found" button when no suggestions', (WidgetTester tester) async {
1465
await tester.pumpWidget(
1566
CupertinoApp(
@@ -41,17 +92,22 @@ class _FakeEditableText extends EditableText {
4192
}
4293

4394
class _FakeEditableTextState extends EditableTextState {
95+
_FakeEditableTextState({
96+
this.suggestions,
97+
});
98+
99+
final List<String>? suggestions;
44100
@override
45101
TextEditingValue get currentTextEditingValue => TextEditingValue.empty;
46102

47103
@override
48104
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
49-
return const SuggestionSpan(
50-
TextRange(
105+
return SuggestionSpan(
106+
const TextRange(
51107
start: 0,
52108
end: 0,
53109
),
54-
<String>[],
110+
suggestions ?? <String>[],
55111
);
56112
}
57113
}

packages/flutter/test/material/spell_check_suggestions_toolbar_test.dart

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

55
import 'package:flutter/cupertino.dart' show CupertinoTextSelectionToolbar;
6+
import 'package:flutter/foundation.dart';
67
import 'package:flutter/material.dart';
78
import 'package:flutter/services.dart';
89
import 'package:flutter_test/flutter_test.dart';
@@ -87,6 +88,50 @@ void main() {
8788
expect(toolbarY, equals(expectedToolbarY));
8889
});
8990

91+
testWidgets('more than three suggestions throws an error', (WidgetTester tester) async {
92+
Future<void> pumpToolbar(List<String> suggestions) async {
93+
await tester.pumpWidget(
94+
MaterialApp(
95+
home: Scaffold(
96+
body: SpellCheckSuggestionsToolbar(
97+
anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap),
98+
buttonItems: buildSuggestionButtons(suggestions),
99+
),
100+
),
101+
),
102+
);
103+
}
104+
await pumpToolbar(<String>['hello', 'yellow', 'yell']);
105+
expect(() async {
106+
await pumpToolbar(<String>['hello', 'yellow', 'yell', 'yeller']);
107+
}, throwsAssertionError);
108+
},
109+
skip: kIsWeb, // [intended]
110+
);
111+
112+
test('buildSuggestionButtons only considers the first three suggestions', () {
113+
final _FakeEditableTextState editableTextState = _FakeEditableTextState(
114+
suggestions: <String>[
115+
'hello',
116+
'yellow',
117+
'yell',
118+
'yeller',
119+
],
120+
);
121+
final List<ContextMenuButtonItem>? buttonItems =
122+
SpellCheckSuggestionsToolbar.buildButtonItems(editableTextState);
123+
expect(buttonItems, isNotNull);
124+
final Iterable<String?> labels = buttonItems!.map((ContextMenuButtonItem buttonItem) {
125+
return buttonItem.label;
126+
});
127+
expect(labels, hasLength(4));
128+
expect(labels, contains('hello'));
129+
expect(labels, contains('yellow'));
130+
expect(labels, contains('yell'));
131+
expect(labels, contains(null)); // For the delete button.
132+
expect(labels, isNot(contains('yeller')));
133+
});
134+
90135
test('buildButtonItems builds only a delete button when no suggestions', () {
91136
final _FakeEditableTextState editableTextState = _FakeEditableTextState();
92137
final List<ContextMenuButtonItem>? buttonItems =
@@ -98,17 +143,23 @@ void main() {
98143
}
99144

100145
class _FakeEditableTextState extends EditableTextState {
146+
_FakeEditableTextState({
147+
this.suggestions,
148+
});
149+
150+
final List<String>? suggestions;
151+
101152
@override
102153
TextEditingValue get currentTextEditingValue => TextEditingValue.empty;
103154

104155
@override
105156
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
106-
return const SuggestionSpan(
107-
TextRange(
157+
return SuggestionSpan(
158+
const TextRange(
108159
start: 0,
109160
end: 0,
110161
),
111-
<String>[],
162+
suggestions ?? <String>[],
112163
);
113164
}
114165
}

0 commit comments

Comments
 (0)