Skip to content

Commit e0742eb

Browse files
authored
[Android] Add spell check suggestions toolbar (#114460)
* Add spell check suggestions toolbar * Fix test and move menu * Cleanup * Cleanup and fix bug * More cleanup * Make height dynamic and use localized delete * Begin adding tests * Create var checking for results * Add tests * Fix analyze (sorta) * Add back hideToolbar call for testing * Add back hidetoolbar in ts and delete one in et * Remove unecessary calls to hidToolbar * Fix analyze and docs * Test fix * Fix container issue * Clean up * Fix analyze * Move delegate * Fix typos * Start addressing review * Continue addressing review * Add assert * Some refactoring * Add test for button behavior * Undo test change * Make spell check results public * Rearrange test * Add comment * Address review * Finish addressing review * remove unused imports * Address nits * Address review * Fix formatting * Refactor findsuggestionspanatcursorindex and textselectiontoolbar constraints * Fix analyze:
1 parent fa3777b commit e0742eb

18 files changed

+998
-55
lines changed

packages/flutter/lib/material.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ export 'src/material/slider.dart';
159159
export 'src/material/slider_theme.dart';
160160
export 'src/material/snack_bar.dart';
161161
export 'src/material/snack_bar_theme.dart';
162+
export 'src/material/spell_check_suggestions_toolbar.dart';
163+
export 'src/material/spell_check_suggestions_toolbar_layout_delegate.dart';
162164
export 'src/material/stepper.dart';
163165
export 'src/material/switch.dart';
164166
export 'src/material/switch_list_tile.dart';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
9797
return localizations.pasteButtonLabel;
9898
case ContextMenuButtonType.selectAll:
9999
return localizations.selectAllButtonLabel;
100+
case ContextMenuButtonType.delete:
100101
case ContextMenuButtonType.custom:
101102
return '';
102103
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
211211
return localizations.pasteButtonLabel;
212212
case ContextMenuButtonType.selectAll:
213213
return localizations.selectAllButtonLabel;
214+
case ContextMenuButtonType.delete:
215+
return localizations.deleteButtonTooltip.toUpperCase();
214216
case ContextMenuButtonType.custom:
215217
return '';
216218
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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/services.dart' show SuggestionSpan;
6+
import 'package:flutter/widgets.dart';
7+
8+
import 'adaptive_text_selection_toolbar.dart';
9+
import 'colors.dart';
10+
import 'material.dart';
11+
import 'spell_check_suggestions_toolbar_layout_delegate.dart';
12+
import 'text_selection_toolbar.dart';
13+
import 'text_selection_toolbar_text_button.dart';
14+
15+
// The default height of the SpellCheckSuggestionsToolbar, which
16+
// assumes there are the maximum number of spell check suggestions available, 3.
17+
// Size eyeballed on Pixel 4 emulator running Android API 31.
18+
const double _kDefaultToolbarHeight = 193.0;
19+
20+
/// The default spell check suggestions toolbar for Android.
21+
///
22+
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
23+
/// readjusts to fit above bottom view insets.
24+
class SpellCheckSuggestionsToolbar extends StatelessWidget {
25+
/// Constructs a [SpellCheckSuggestionsToolbar].
26+
const SpellCheckSuggestionsToolbar({
27+
super.key,
28+
required this.anchor,
29+
required this.buttonItems,
30+
}) : assert(buttonItems != null);
31+
32+
/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
33+
/// The focal point below which the toolbar attempts to position itself.
34+
/// {@endtemplate}
35+
final Offset anchor;
36+
37+
/// The [ContextMenuButtonItem]s that will be turned into the correct button
38+
/// widgets and displayed in the spell check suggestions toolbar.
39+
///
40+
/// See also:
41+
///
42+
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
43+
/// [ContextMenuButtonItem]s that are used to build the buttons of the
44+
/// text selection toolbar.
45+
final List<ContextMenuButtonItem> buttonItems;
46+
47+
/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
48+
/// running Android API 31.
49+
static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0;
50+
51+
/// Builds the default Android Material spell check suggestions toolbar.
52+
static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) {
53+
return _SpellCheckSuggestionsToolbarContainer(
54+
child: child,
55+
);
56+
}
57+
58+
/// Builds the button items for the toolbar based on the available
59+
/// spell check suggestions.
60+
static List<ContextMenuButtonItem>? buildButtonItems(
61+
BuildContext context,
62+
EditableTextState editableTextState,
63+
) {
64+
// Determine if composing region is misspelled.
65+
final SuggestionSpan? spanAtCursorIndex =
66+
editableTextState.findSuggestionSpanAtCursorIndex(
67+
editableTextState.currentTextEditingValue.selection.baseOffset,
68+
);
69+
70+
if (spanAtCursorIndex == null) {
71+
return null;
72+
}
73+
74+
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
75+
76+
// Build suggestion buttons.
77+
for (final String suggestion in spanAtCursorIndex.suggestions) {
78+
buttonItems.add(ContextMenuButtonItem(
79+
onPressed: () {
80+
editableTextState
81+
.replaceComposingRegion(
82+
SelectionChangedCause.toolbar,
83+
suggestion,
84+
);
85+
},
86+
label: suggestion,
87+
));
88+
}
89+
90+
// Build delete button.
91+
final ContextMenuButtonItem deleteButton =
92+
ContextMenuButtonItem(
93+
onPressed: () {
94+
editableTextState.replaceComposingRegion(
95+
SelectionChangedCause.toolbar,
96+
'',
97+
);
98+
},
99+
type: ContextMenuButtonType.delete,
100+
);
101+
buttonItems.add(deleteButton);
102+
103+
return buttonItems;
104+
}
105+
106+
/// Determines the Offset that the toolbar will be anchored to.
107+
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
108+
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;
109+
}
110+
111+
/// Builds the toolbar buttons based on the [buttonItems].
112+
List<Widget> _buildToolbarButtons(BuildContext context) {
113+
return buttonItems.map((ContextMenuButtonItem buttonItem) {
114+
final TextSelectionToolbarTextButton button =
115+
TextSelectionToolbarTextButton(
116+
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
117+
onPressed: buttonItem.onPressed,
118+
alignment: Alignment.centerLeft,
119+
child: Text(
120+
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
121+
style: buttonItem.type == ContextMenuButtonType.delete ? const TextStyle(color: Colors.blue) : null,
122+
),
123+
);
124+
125+
if (buttonItem.type != ContextMenuButtonType.delete) {
126+
return button;
127+
}
128+
return DecoratedBox(
129+
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.grey))),
130+
child: button,
131+
);
132+
}).toList();
133+
}
134+
135+
@override
136+
Widget build(BuildContext context) {
137+
// Adjust toolbar height if needed.
138+
final double spellCheckSuggestionsToolbarHeight =
139+
_kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length));
140+
// Incorporate the padding distance between the content and toolbar.
141+
final Offset anchorPadded =
142+
anchor + const Offset(0.0, kToolbarContentDistanceBelow);
143+
final MediaQueryData mediaQueryData = MediaQuery.of(context);
144+
final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom;
145+
final double paddingAbove = mediaQueryData.padding.top + TextSelectionToolbar.kToolbarScreenPadding;
146+
// Makes up for the Padding.
147+
final Offset localAdjustment = Offset(TextSelectionToolbar.kToolbarScreenPadding, paddingAbove);
148+
149+
return Padding(
150+
padding: EdgeInsets.fromLTRB(
151+
TextSelectionToolbar.kToolbarScreenPadding,
152+
kToolbarContentDistanceBelow,
153+
TextSelectionToolbar.kToolbarScreenPadding,
154+
TextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom,
155+
),
156+
child: CustomSingleChildLayout(
157+
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
158+
anchor: anchorPadded - localAdjustment,
159+
),
160+
child: AnimatedSize(
161+
// This duration was eyeballed on a Pixel 2 emulator running Android
162+
// API 28 for the Material TextSelectionToolbar.
163+
duration: const Duration(milliseconds: 140),
164+
child: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout(
165+
height: spellCheckSuggestionsToolbarHeight,
166+
children: <Widget>[..._buildToolbarButtons(context)],
167+
)),
168+
),
169+
),
170+
);
171+
}
172+
}
173+
174+
/// The Material-styled toolbar outline for the spell check suggestions
175+
/// toolbar.
176+
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
177+
const _SpellCheckSuggestionsToolbarContainer({
178+
required this.child,
179+
});
180+
181+
final Widget child;
182+
183+
@override
184+
Widget build(BuildContext context) {
185+
return Material(
186+
// This elevation was eyeballed on a Pixel 4 emulator running Android
187+
// API 31 for the SpellCheckSuggestionsToolbar.
188+
elevation: 2.0,
189+
type: MaterialType.card,
190+
child: child,
191+
);
192+
}
193+
}
194+
195+
/// Renders the spell check suggestions toolbar items in the correct positions
196+
/// in the menu.
197+
class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget {
198+
const _SpellCheckSuggestsionsToolbarItemsLayout({
199+
required this.height,
200+
required this.children,
201+
});
202+
203+
final double height;
204+
205+
final List<Widget> children;
206+
207+
@override
208+
Widget build(BuildContext context) {
209+
return SizedBox(
210+
// This width was eyeballed on a Pixel 4 emulator running Android
211+
// API 31 for the SpellCheckSuggestionsToolbar.
212+
width: 165,
213+
height: height,
214+
child: Column(
215+
mainAxisSize: MainAxisSize.min,
216+
crossAxisAlignment: CrossAxisAlignment.stretch,
217+
children: children,
218+
),
219+
);
220+
}
221+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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/rendering.dart';
6+
import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate;
7+
8+
/// Positions the toolbar below [anchor] or adjusts it higher to fit above
9+
/// the bottom view insets, if applicable.
10+
///
11+
/// See also:
12+
///
13+
/// * [SpellCheckSuggestionsToolbar], which uses this to position itself.
14+
class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate {
15+
/// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate].
16+
SpellCheckSuggestionsToolbarLayoutDelegate({
17+
required this.anchor,
18+
});
19+
20+
/// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor}
21+
///
22+
/// Should be provided in local coordinates.
23+
final Offset anchor;
24+
25+
@override
26+
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
27+
return constraints.loosen();
28+
}
29+
30+
@override
31+
Offset getPositionForChild(Size size, Size childSize) {
32+
return Offset(
33+
TextSelectionToolbarLayoutDelegate.centerOn(
34+
anchor.dx,
35+
childSize.width,
36+
size.width,
37+
),
38+
// Positions child (of childSize) just enough upwards to fit within size
39+
// if it otherwise does not fit below the anchor.
40+
anchor.dy + childSize.height > size.height
41+
? size.height - childSize.height
42+
: anchor.dy,
43+
);
44+
}
45+
46+
@override
47+
bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) {
48+
return anchor != oldDelegate.anchor;
49+
}
50+
}

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'magnifier.dart';
2020
import 'material_localizations.dart';
2121
import 'material_state.dart';
2222
import 'selectable_text.dart' show iOSHorizontalOffset;
23+
import 'spell_check_suggestions_toolbar.dart';
2324
import 'text_selection.dart';
2425
import 'theme.dart';
2526

@@ -800,6 +801,32 @@ class TextField extends StatefulWidget {
800801
decorationStyle: TextDecorationStyle.wavy,
801802
);
802803

804+
/// Default builder for the spell check suggestions toolbar in the Material
805+
/// style.
806+
///
807+
/// See also:
808+
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
809+
// builder configured to show a spell check suggestions toolbar.
810+
@visibleForTesting
811+
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
812+
BuildContext context,
813+
EditableTextState editableTextState,
814+
) {
815+
final Offset anchor =
816+
SpellCheckSuggestionsToolbar.getToolbarAnchor(editableTextState.contextMenuAnchors);
817+
final List<ContextMenuButtonItem>? buttonItems =
818+
SpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);
819+
820+
if (buttonItems == null){
821+
return const SizedBox.shrink();
822+
}
823+
824+
return SpellCheckSuggestionsToolbar(
825+
anchor: anchor,
826+
buttonItems: buttonItems,
827+
);
828+
}
829+
803830
@override
804831
State<TextField> createState() => _TextFieldState();
805832

@@ -1192,7 +1219,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
11921219
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
11931220
? widget.spellCheckConfiguration!.copyWith(
11941221
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
1195-
?? TextField.materialMisspelledTextStyle)
1222+
?? TextField.materialMisspelledTextStyle,
1223+
spellCheckSuggestionsToolbarBuilder:
1224+
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
1225+
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder
1226+
)
11961227
: const SpellCheckConfiguration.disabled();
11971228

11981229
TextSelectionControls? textSelectionControls = widget.selectionControls;

0 commit comments

Comments
 (0)