Unverified Commit e0742ebb authored by Camille Simon's avatar Camille Simon Committed by GitHub

[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:
parent fa3777bd
...@@ -159,6 +159,8 @@ export 'src/material/slider.dart'; ...@@ -159,6 +159,8 @@ export 'src/material/slider.dart';
export 'src/material/slider_theme.dart'; export 'src/material/slider_theme.dart';
export 'src/material/snack_bar.dart'; export 'src/material/snack_bar.dart';
export 'src/material/snack_bar_theme.dart'; export 'src/material/snack_bar_theme.dart';
export 'src/material/spell_check_suggestions_toolbar.dart';
export 'src/material/spell_check_suggestions_toolbar_layout_delegate.dart';
export 'src/material/stepper.dart'; export 'src/material/stepper.dart';
export 'src/material/switch.dart'; export 'src/material/switch.dart';
export 'src/material/switch_list_tile.dart'; export 'src/material/switch_list_tile.dart';
......
...@@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget { ...@@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
return localizations.pasteButtonLabel; return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll: case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel; return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
case ContextMenuButtonType.custom: case ContextMenuButtonType.custom:
return ''; return '';
} }
......
...@@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget { ...@@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
return localizations.pasteButtonLabel; return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll: case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel; return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
return localizations.deleteButtonTooltip.toUpperCase();
case ContextMenuButtonType.custom: case ContextMenuButtonType.custom:
return ''; return '';
} }
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart' show SuggestionSpan;
import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'material.dart';
import 'spell_check_suggestions_toolbar_layout_delegate.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';
// The default height of the SpellCheckSuggestionsToolbar, which
// assumes there are the maximum number of spell check suggestions available, 3.
// Size eyeballed on Pixel 4 emulator running Android API 31.
const double _kDefaultToolbarHeight = 193.0;
/// The default spell check suggestions toolbar for Android.
///
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
/// readjusts to fit above bottom view insets.
class SpellCheckSuggestionsToolbar extends StatelessWidget {
/// Constructs a [SpellCheckSuggestionsToolbar].
const SpellCheckSuggestionsToolbar({
super.key,
required this.anchor,
required this.buttonItems,
}) : assert(buttonItems != null);
/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
/// The focal point below which the toolbar attempts to position itself.
/// {@endtemplate}
final Offset anchor;
/// The [ContextMenuButtonItem]s that will be turned into the correct button
/// widgets and displayed in the spell check suggestions toolbar.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s that are used to build the buttons of the
/// text selection toolbar.
final List<ContextMenuButtonItem> buttonItems;
/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
/// running Android API 31.
static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0;
/// Builds the default Android Material spell check suggestions toolbar.
static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) {
return _SpellCheckSuggestionsToolbarContainer(
child: child,
);
}
/// Builds the button items for the toolbar based on the available
/// spell check suggestions.
static List<ContextMenuButtonItem>? buildButtonItems(
BuildContext context,
EditableTextState editableTextState,
) {
// Determine if composing region is misspelled.
final SuggestionSpan? spanAtCursorIndex =
editableTextState.findSuggestionSpanAtCursorIndex(
editableTextState.currentTextEditingValue.selection.baseOffset,
);
if (spanAtCursorIndex == null) {
return null;
}
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
// Build suggestion buttons.
for (final String suggestion in spanAtCursorIndex.suggestions) {
buttonItems.add(ContextMenuButtonItem(
onPressed: () {
editableTextState
.replaceComposingRegion(
SelectionChangedCause.toolbar,
suggestion,
);
},
label: suggestion,
));
}
// Build delete button.
final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem(
onPressed: () {
editableTextState.replaceComposingRegion(
SelectionChangedCause.toolbar,
'',
);
},
type: ContextMenuButtonType.delete,
);
buttonItems.add(deleteButton);
return buttonItems;
}
/// Determines the Offset that the toolbar will be anchored to.
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;
}
/// Builds the toolbar buttons based on the [buttonItems].
List<Widget> _buildToolbarButtons(BuildContext context) {
return buttonItems.map((ContextMenuButtonItem buttonItem) {
final TextSelectionToolbarTextButton button =
TextSelectionToolbarTextButton(
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
onPressed: buttonItem.onPressed,
alignment: Alignment.centerLeft,
child: Text(
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
style: buttonItem.type == ContextMenuButtonType.delete ? const TextStyle(color: Colors.blue) : null,
),
);
if (buttonItem.type != ContextMenuButtonType.delete) {
return button;
}
return DecoratedBox(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.grey))),
child: button,
);
}).toList();
}
@override
Widget build(BuildContext context) {
// Adjust toolbar height if needed.
final double spellCheckSuggestionsToolbarHeight =
_kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length));
// Incorporate the padding distance between the content and toolbar.
final Offset anchorPadded =
anchor + const Offset(0.0, kToolbarContentDistanceBelow);
final MediaQueryData mediaQueryData = MediaQuery.of(context);
final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom;
final double paddingAbove = mediaQueryData.padding.top + TextSelectionToolbar.kToolbarScreenPadding;
// Makes up for the Padding.
final Offset localAdjustment = Offset(TextSelectionToolbar.kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
TextSelectionToolbar.kToolbarScreenPadding,
kToolbarContentDistanceBelow,
TextSelectionToolbar.kToolbarScreenPadding,
TextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom,
),
child: CustomSingleChildLayout(
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
anchor: anchorPadded - localAdjustment,
),
child: AnimatedSize(
// This duration was eyeballed on a Pixel 2 emulator running Android
// API 28 for the Material TextSelectionToolbar.
duration: const Duration(milliseconds: 140),
child: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout(
height: spellCheckSuggestionsToolbarHeight,
children: <Widget>[..._buildToolbarButtons(context)],
)),
),
),
);
}
}
/// The Material-styled toolbar outline for the spell check suggestions
/// toolbar.
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
const _SpellCheckSuggestionsToolbarContainer({
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
// This elevation was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
elevation: 2.0,
type: MaterialType.card,
child: child,
);
}
}
/// Renders the spell check suggestions toolbar items in the correct positions
/// in the menu.
class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget {
const _SpellCheckSuggestsionsToolbarItemsLayout({
required this.height,
required this.children,
});
final double height;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return SizedBox(
// This width was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
width: 165,
height: height,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate;
/// Positions the toolbar below [anchor] or adjusts it higher to fit above
/// the bottom view insets, if applicable.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which uses this to position itself.
class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate].
SpellCheckSuggestionsToolbarLayoutDelegate({
required this.anchor,
});
/// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor}
///
/// Should be provided in local coordinates.
final Offset anchor;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
@override
Offset getPositionForChild(Size size, Size childSize) {
return Offset(
TextSelectionToolbarLayoutDelegate.centerOn(
anchor.dx,
childSize.width,
size.width,
),
// Positions child (of childSize) just enough upwards to fit within size
// if it otherwise does not fit below the anchor.
anchor.dy + childSize.height > size.height
? size.height - childSize.height
: anchor.dy,
);
}
@override
bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) {
return anchor != oldDelegate.anchor;
}
}
...@@ -20,6 +20,7 @@ import 'magnifier.dart'; ...@@ -20,6 +20,7 @@ import 'magnifier.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
import 'material_state.dart'; import 'material_state.dart';
import 'selectable_text.dart' show iOSHorizontalOffset; import 'selectable_text.dart' show iOSHorizontalOffset;
import 'spell_check_suggestions_toolbar.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -800,6 +801,32 @@ class TextField extends StatefulWidget { ...@@ -800,6 +801,32 @@ class TextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.wavy, decorationStyle: TextDecorationStyle.wavy,
); );
/// Default builder for the spell check suggestions toolbar in the Material
/// style.
///
/// See also:
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
// builder configured to show a spell check suggestions toolbar.
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
final Offset anchor =
SpellCheckSuggestionsToolbar.getToolbarAnchor(editableTextState.contextMenuAnchors);
final List<ContextMenuButtonItem>? buttonItems =
SpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);
if (buttonItems == null){
return const SizedBox.shrink();
}
return SpellCheckSuggestionsToolbar(
anchor: anchor,
buttonItems: buttonItems,
);
}
@override @override
State<TextField> createState() => _TextFieldState(); State<TextField> createState() => _TextFieldState();
...@@ -1192,7 +1219,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1192,7 +1219,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled() widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
? widget.spellCheckConfiguration!.copyWith( ? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? TextField.materialMisspelledTextStyle) ?? TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder
)
: const SpellCheckConfiguration.disabled(); : const SpellCheckConfiguration.disabled();
TextSelectionControls? textSelectionControls = widget.selectionControls; TextSelectionControls? textSelectionControls = widget.selectionControls;
......
...@@ -14,15 +14,7 @@ import 'icons.dart'; ...@@ -14,15 +14,7 @@ import 'icons.dart';
import 'material.dart'; import 'material.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
// Minimal padding from all edges of the selection toolbar to all edges of the
// viewport.
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0; const double _kToolbarHeight = 44.0;
const double _kHandleSize = 22.0;
// Padding between the toolbar and the anchor.
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0; const double _kToolbarContentDistance = 8.0;
/// A fully-functional Material-style text selection toolbar. /// A fully-functional Material-style text selection toolbar.
...@@ -84,6 +76,26 @@ class TextSelectionToolbar extends StatelessWidget { ...@@ -84,6 +76,26 @@ class TextSelectionToolbar extends StatelessWidget {
/// {@endtemplate} /// {@endtemplate}
final ToolbarBuilder toolbarBuilder; final ToolbarBuilder toolbarBuilder;
/// Minimal padding from all edges of the selection toolbar to all edges of the
/// viewport.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which uses this same value for its
/// padding from the edges of the viewport.
static const double kToolbarScreenPadding = 8.0;
/// The size of the text selection handles.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which references this value to calculate
/// the padding between the toolbar and anchor.
static const double kHandleSize = 22.0;
/// Padding between the toolbar and the anchor.
static const double kToolbarContentDistanceBelow = kHandleSize - 2.0;
// Build the default Android Material text selection menu toolbar. // Build the default Android Material text selection menu toolbar.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return _TextSelectionToolbarContainer( return _TextSelectionToolbarContainer(
...@@ -97,21 +109,21 @@ class TextSelectionToolbar extends StatelessWidget { ...@@ -97,21 +109,21 @@ class TextSelectionToolbar extends StatelessWidget {
final Offset anchorAbovePadded = final Offset anchorAbovePadded =
anchorAbove - const Offset(0.0, _kToolbarContentDistance); anchorAbove - const Offset(0.0, _kToolbarContentDistance);
final Offset anchorBelowPadded = final Offset anchorBelowPadded =
anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow); anchorBelow + const Offset(0.0, kToolbarContentDistanceBelow);
final double paddingAbove = MediaQuery.paddingOf(context).top final double paddingAbove = MediaQuery.paddingOf(context).top
+ _kToolbarScreenPadding; + kToolbarScreenPadding;
final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove; final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight; final bool fitsAbove = _kToolbarHeight <= availableHeight;
// Makes up for the Padding above the Stack. // Makes up for the Padding above the Stack.
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); final Offset localAdjustment = Offset(kToolbarScreenPadding, paddingAbove);
return Padding( return Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding, kToolbarScreenPadding,
paddingAbove, paddingAbove,
_kToolbarScreenPadding, kToolbarScreenPadding,
_kToolbarScreenPadding, kToolbarScreenPadding,
), ),
child: CustomSingleChildLayout( child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate( delegate: TextSelectionToolbarLayoutDelegate(
......
...@@ -31,6 +31,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget { ...@@ -31,6 +31,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
required this.child, required this.child,
required this.padding, required this.padding,
this.onPressed, this.onPressed,
this.alignment,
}); });
// These values were eyeballed to match the native text selection menu on a // These values were eyeballed to match the native text selection menu on a
...@@ -62,6 +63,15 @@ class TextSelectionToolbarTextButton extends StatelessWidget { ...@@ -62,6 +63,15 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
/// * [ButtonStyle.padding], which is where this padding is applied. /// * [ButtonStyle.padding], which is where this padding is applied.
final EdgeInsets padding; final EdgeInsets padding;
/// The alignment of the button's child.
///
/// By default, this will be [Alignment.center].
///
/// See also:
///
/// * [ButtonStyle.alignment], which is where this alignment is applied.
final AlignmentGeometry? alignment;
/// Returns the standard padding for a button at index out of a total number /// Returns the standard padding for a button at index out of a total number
/// of buttons. /// of buttons.
/// ///
...@@ -104,6 +114,22 @@ class TextSelectionToolbarTextButton extends StatelessWidget { ...@@ -104,6 +114,22 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
return _TextSelectionToolbarItemPosition.middle; return _TextSelectionToolbarItemPosition.middle;
} }
/// Returns a copy of the current [TextSelectionToolbarTextButton] instance
/// with specific overrides.
TextSelectionToolbarTextButton copyWith({
Widget? child,
VoidCallback? onPressed,
EdgeInsets? padding,
AlignmentGeometry? alignment,
}) {
return TextSelectionToolbarTextButton(
onPressed: onPressed ?? this.onPressed,
padding: padding ?? this.padding,
alignment: alignment ?? this.alignment,
child: child ?? this.child,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO(hansmuller): Should be colorScheme.onSurface // TODO(hansmuller): Should be colorScheme.onSurface
...@@ -117,6 +143,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget { ...@@ -117,6 +143,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
shape: const RoundedRectangleBorder(), shape: const RoundedRectangleBorder(),
minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension),
padding: padding, padding: padding,
alignment: alignment,
), ),
onPressed: onPressed, onPressed: onPressed,
child: child, child: child,
......
...@@ -23,6 +23,9 @@ enum ContextMenuButtonType { ...@@ -23,6 +23,9 @@ enum ContextMenuButtonType {
/// A button that selects all the contents of the focused text field. /// A button that selects all the contents of the focused text field.
selectAll, selectAll,
/// A button that deletes the current text selection.
delete,
/// Anything other than the default button types. /// Anything other than the default button types.
custom, custom,
} }
......
...@@ -1947,7 +1947,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1947,7 +1947,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// These results will be updated via calls to spell check through a /// These results will be updated via calls to spell check through a
/// [SpellCheckService] and used by this widget to build the [TextSpan] tree /// [SpellCheckService] and used by this widget to build the [TextSpan] tree
/// for text input and menus for replacement suggestions of misspelled words. /// for text input and menus for replacement suggestions of misspelled words.
SpellCheckResults? _spellCheckResults; SpellCheckResults? spellCheckResults;
bool get _spellCheckResultsReceived => spellCheckEnabled && spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty;
/// Whether to create an input connection with the platform for text editing /// Whether to create an input connection with the platform for text editing
/// or not. /// or not.
...@@ -2190,6 +2192,63 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2190,6 +2192,63 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
/// Replace composing region with specified text.
void replaceComposingRegion(SelectionChangedCause cause, String text) {
// Replacement cannot be performed if the text is read only or obscured.
assert(!widget.readOnly && !widget.obscureText);
_replaceText(ReplaceTextIntent(textEditingValue, text, textEditingValue.composing, cause));
if (cause == SelectionChangedCause.toolbar) {
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
bringIntoView(textEditingValue.selection.extent);
}
});
hideToolbar();
}
}
/// Finds specified [SuggestionSpan] that matches the provided index using
/// binary search.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], the Material style spell check
/// suggestions toolbar that uses this method to render the correct
/// suggestions in the toolbar for a misspelled word.
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
if (!_spellCheckResultsReceived
|| spellCheckResults!.suggestionSpans.last.range.end < cursorIndex) {
// No spell check results have been recieved or the cursor index is out
// of range that suggestionSpans covers.
return null;
}
final List<SuggestionSpan> suggestionSpans = spellCheckResults!.suggestionSpans;
int leftIndex = 0;
int rightIndex = suggestionSpans.length - 1;
int midIndex = 0;
while (leftIndex <= rightIndex) {
midIndex = ((leftIndex + rightIndex) / 2).floor();
final int currentSpanStart = suggestionSpans[midIndex].range.start;
final int currentSpanEnd = suggestionSpans[midIndex].range.end;
if (cursorIndex <= currentSpanEnd && cursorIndex >= currentSpanStart) {
return suggestionSpans[midIndex];
}
else if (cursorIndex <= currentSpanStart) {
rightIndex = midIndex - 1;
}
else {
leftIndex = midIndex + 1;
}
}
return null;
}
/// Infers the [SpellCheckConfiguration] used to perform spell check. /// Infers the [SpellCheckConfiguration] used to perform spell check.
/// ///
/// If spell check is enabled, this will try to infer a value for /// If spell check is enabled, this will try to infer a value for
...@@ -2562,9 +2621,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2562,9 +2621,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// `selection` is the only change. // `selection` is the only change.
_handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard);
} else { } else {
// Only hide the toolbar overlay, the selection handle's visibility will be handled if (value.text != _value.text) {
// by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673 // Hide the toolbar if the text was changed, but only hide the toolbar
hideToolbar(false); // overlay; the selection handle's visibility will be handled
// by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673
hideToolbar(false);
}
_currentPromptRectRange = null; _currentPromptRectRange = null;
final bool revealObscuredInput = _hasInputConnection final bool revealObscuredInput = _hasInputConnection
...@@ -3256,17 +3318,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3256,17 +3318,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
'Locale must be specified in widget or Localization widget must be in scope', 'Locale must be specified in widget or Localization widget must be in scope',
); );
final List<SuggestionSpan>? spellCheckResults = await final List<SuggestionSpan>? suggestions = await
_spellCheckConfiguration _spellCheckConfiguration
.spellCheckService! .spellCheckService!
.fetchSpellCheckSuggestions(localeForSpellChecking!, text); .fetchSpellCheckSuggestions(localeForSpellChecking!, text);
if (spellCheckResults == null) { if (suggestions == null) {
// The request to fetch spell check suggestions was canceled due to ongoing request. // The request to fetch spell check suggestions was canceled due to ongoing request.
return; return;
} }
_spellCheckResults = SpellCheckResults(text, spellCheckResults); spellCheckResults = SpellCheckResults(text, suggestions);
renderEditable.text = buildTextSpan(); renderEditable.text = buildTextSpan();
} catch (exception, stack) { } catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails( FlutterError.reportError(FlutterErrorDetails(
...@@ -3665,6 +3727,38 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3665,6 +3727,38 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
bool showSpellCheckSuggestionsToolbar() {
if (!spellCheckEnabled
|| widget.readOnly
|| _selectionOverlay == null
|| !_spellCheckResultsReceived) {
// Only attempt to show the spell check suggestions toolbar if there
// is a toolbar specified and spell check suggestions available to show.
return false;
}
assert(
_spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder != null,
'spellCheckSuggestionsToolbarBuilder must be defined in '
'SpellCheckConfiguration to show a toolbar with spell check '
'suggestions',
);
_selectionOverlay!
.showSpellCheckSuggestionsToolbar(
(BuildContext context) {
return _spellCheckConfiguration
.spellCheckSuggestionsToolbarBuilder!(
context,
this,
);
},
);
return true;
}
/// Shows the magnifier at the position given by `positionToShow`, /// Shows the magnifier at the position given by `positionToShow`,
/// if there is no magnifier visible. /// if there is no magnifier visible.
/// ///
...@@ -4321,9 +4415,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4321,9 +4415,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
], ],
); );
} }
final bool spellCheckResultsReceived = spellCheckEnabled && _spellCheckResults != null;
final bool withComposing = !widget.readOnly && _hasFocus; final bool withComposing = !widget.readOnly && _hasFocus;
if (spellCheckResultsReceived) { if (_spellCheckResultsReceived) {
// If the composing range is out of range for the current text, ignore it to // If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will // preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree. // be thrown and this EditableText will be built with a broken subtree.
...@@ -4336,7 +4429,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4336,7 +4429,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
composingRegionOutOfRange, composingRegionOutOfRange,
widget.style, widget.style,
_spellCheckConfiguration.misspelledTextStyle!, _spellCheckConfiguration.misspelledTextStyle!,
_spellCheckResults!, spellCheckResults!,
); );
} }
......
...@@ -2,11 +2,13 @@ ...@@ -2,11 +2,13 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter/services.dart' import 'package:flutter/services.dart'
show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue; show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
import 'editable_text.dart' show EditableTextContextMenuBuilder;
import 'framework.dart' show immutable;
/// Controls how spell check is performed for text input. /// Controls how spell check is performed for text input.
/// ///
/// This configuration determines the [SpellCheckService] used to fetch the /// This configuration determines the [SpellCheckService] used to fetch the
...@@ -19,12 +21,14 @@ class SpellCheckConfiguration { ...@@ -19,12 +21,14 @@ class SpellCheckConfiguration {
const SpellCheckConfiguration({ const SpellCheckConfiguration({
this.spellCheckService, this.spellCheckService,
this.misspelledTextStyle, this.misspelledTextStyle,
this.spellCheckSuggestionsToolbarBuilder,
}) : _spellCheckEnabled = true; }) : _spellCheckEnabled = true;
/// Creates a configuration that disables spell check. /// Creates a configuration that disables spell check.
const SpellCheckConfiguration.disabled() const SpellCheckConfiguration.disabled()
: _spellCheckEnabled = false, : _spellCheckEnabled = false,
spellCheckService = null, spellCheckService = null,
spellCheckSuggestionsToolbarBuilder = null,
misspelledTextStyle = null; misspelledTextStyle = null;
/// The service used to fetch spell check results for text input. /// The service used to fetch spell check results for text input.
...@@ -38,6 +42,10 @@ class SpellCheckConfiguration { ...@@ -38,6 +42,10 @@ class SpellCheckConfiguration {
/// assertion error. /// assertion error.
final TextStyle? misspelledTextStyle; final TextStyle? misspelledTextStyle;
/// Builds the toolbar used to display spell check suggestions for misspelled
/// words.
final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder;
final bool _spellCheckEnabled; final bool _spellCheckEnabled;
/// Whether or not the configuration should enable or disable spell check. /// Whether or not the configuration should enable or disable spell check.
...@@ -47,7 +55,8 @@ class SpellCheckConfiguration { ...@@ -47,7 +55,8 @@ class SpellCheckConfiguration {
/// specified overrides. /// specified overrides.
SpellCheckConfiguration copyWith({ SpellCheckConfiguration copyWith({
SpellCheckService? spellCheckService, SpellCheckService? spellCheckService,
TextStyle? misspelledTextStyle}) { TextStyle? misspelledTextStyle,
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
if (!_spellCheckEnabled) { if (!_spellCheckEnabled) {
// A new configuration should be constructed to enable spell check. // A new configuration should be constructed to enable spell check.
return const SpellCheckConfiguration.disabled(); return const SpellCheckConfiguration.disabled();
...@@ -56,6 +65,7 @@ class SpellCheckConfiguration { ...@@ -56,6 +65,7 @@ class SpellCheckConfiguration {
return SpellCheckConfiguration( return SpellCheckConfiguration(
spellCheckService: spellCheckService ?? this.spellCheckService, spellCheckService: spellCheckService ?? this.spellCheckService,
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
); );
} }
...@@ -65,6 +75,7 @@ class SpellCheckConfiguration { ...@@ -65,6 +75,7 @@ class SpellCheckConfiguration {
spell check enabled : $_spellCheckEnabled spell check enabled : $_spellCheckEnabled
spell check service : $spellCheckService spell check service : $spellCheckService
misspelled text style : $misspelledTextStyle misspelled text style : $misspelledTextStyle
spell check suggesstions toolbar builder: $spellCheckSuggestionsToolbarBuilder
''' '''
.trim(); .trim();
} }
...@@ -78,11 +89,12 @@ class SpellCheckConfiguration { ...@@ -78,11 +89,12 @@ class SpellCheckConfiguration {
return other is SpellCheckConfiguration return other is SpellCheckConfiguration
&& other.spellCheckService == spellCheckService && other.spellCheckService == spellCheckService
&& other.misspelledTextStyle == misspelledTextStyle && other.misspelledTextStyle == misspelledTextStyle
&& other.spellCheckSuggestionsToolbarBuilder == spellCheckSuggestionsToolbarBuilder
&& other._spellCheckEnabled == _spellCheckEnabled; && other._spellCheckEnabled == _spellCheckEnabled;
} }
@override @override
int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, _spellCheckEnabled); int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, spellCheckSuggestionsToolbarBuilder, _spellCheckEnabled);
} }
// Methods for displaying spell check results: // Methods for displaying spell check results:
......
...@@ -469,6 +469,20 @@ class TextSelectionOverlay { ...@@ -469,6 +469,20 @@ class TextSelectionOverlay {
return; return;
} }
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
void showSpellCheckSuggestionsToolbar(
WidgetBuilder spellCheckSuggestionsToolbarBuilder
) {
_updateSelectionOverlay();
assert(context.mounted);
_selectionOverlay
.showSpellCheckSuggestionsToolbar(
context: context,
builder: spellCheckSuggestionsToolbarBuilder,
);
}
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier} /// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
void showMagnifier(Offset positionToShow) { void showMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow); final TextPosition position = renderObject.getPositionForPoint(positionToShow);
...@@ -1347,6 +1361,29 @@ class SelectionOverlay { ...@@ -1347,6 +1361,29 @@ class SelectionOverlay {
); );
} }
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
void showSpellCheckSuggestionsToolbar({
BuildContext? context,
required WidgetBuilder builder,
}) {
if (context == null) {
return;
}
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
_contextMenuController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper(
layerLink: toolbarLayerLink,
offset: -renderBox.localToGlobal(Offset.zero),
child: builder(context),
);
},
);
}
bool _buildScheduled = false; bool _buildScheduled = false;
/// Rebuilds the selection toolbar or handles if they are present. /// Rebuilds the selection toolbar or handles if they are present.
...@@ -2124,6 +2161,15 @@ class TextSelectionGestureDetectorBuilder { ...@@ -2124,6 +2161,15 @@ class TextSelectionGestureDetectorBuilder {
} }
break; break;
case TargetPlatform.android: case TargetPlatform.android:
editableText.hideToolbar();
editableText.showSpellCheckSuggestionsToolbar();
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
}
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.fuchsia: case TargetPlatform.fuchsia:
editableText.hideToolbar(); editableText.hideToolbar();
if (isShiftPressedValid) { if (isShiftPressedValid) {
......
...@@ -41,9 +41,9 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate { ...@@ -41,9 +41,9 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// If not provided, it will be calculated. /// If not provided, it will be calculated.
final bool? fitsAbove; final bool? fitsAbove;
// Return the value that centers width as closely as possible to position /// Return the value that centers width as closely as possible to position
// while fitting inside of min and max. /// while fitting inside of min and max.
static double _centerOn(double position, double width, double max) { static double centerOn(double position, double width, double max) {
// If it overflows on the left, put it as far left as possible. // If it overflows on the left, put it as far left as possible.
if (position - width / 2.0 < 0.0) { if (position - width / 2.0 < 0.0) {
return 0.0; return 0.0;
...@@ -69,7 +69,7 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate { ...@@ -69,7 +69,7 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow; final Offset anchor = fitsAbove ? anchorAbove : anchorBelow;
return Offset( return Offset(
_centerOn( centerOn(
anchor.dx, anchor.dx,
childSize.width, childSize.width,
size.width, size.width,
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('positions itself at anchorAbove if it fits and shifts up when not', (WidgetTester tester) async {
late StateSetter setState;
const double toolbarOverlap = 100;
const double height = 500;
double anchorY = 200.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CustomSingleChildLayout(
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
anchor: Offset(50.0, anchorY),
),
child: Container(
width: 200.0,
height: height,
color: const Color(0xffff0000),
),
);
},
),
),
),
);
// When the toolbar doesn't fit below anchor, it positions itself such that
// it can just fit.
double toolbarY = tester.getTopLeft(find.byType(Container)).dy;
// Total height available is 600.
expect(toolbarY, equals(toolbarOverlap));
// When it does fit below anchor, it positions itself there.
setState(() {
anchorY = anchorY - toolbarOverlap;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorY));
});
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Vertical position at which to anchor the toolbar for testing.
const double _kAnchor = 200;
// Amount for toolbar to overlap bottom padding for testing.
const double _kTestToolbarOverlap = 10;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// Builds test button items for each of the suggestions provided.
List<ContextMenuButtonItem> buildSuggestionButtons(List<String> suggestions) {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
for (final String suggestion in suggestions) {
buttonItems.add(ContextMenuButtonItem(
onPressed: () {},
label: suggestion,
));
}
final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem(
onPressed: () {},
type: ContextMenuButtonType.delete,
label: 'DELETE',
);
buttonItems.add(deleteButton);
return buttonItems;
}
/// Finds the container of the [SpellCheckSuggestionsToolbar] so that
/// the position of the toolbar itself may be determined.
Finder findSpellCheckSuggestionsToolbar() {
return find.descendant(
of: find.byType(MaterialApp),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_SpellCheckSuggestionsToolbarContainer'),
);
}
testWidgets('positions toolbar below anchor when it fits above bottom view padding', (WidgetTester tester) async {
// We expect the toolbar to be positioned right below the anchor with padding accounted for.
const double expectedToolbarY =
_kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpellCheckSuggestionsToolbar(
anchor: const Offset(0.0, _kAnchor),
buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']),
),
),
),
);
final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy;
expect(toolbarY, equals(expectedToolbarY));
});
testWidgets('re-positions toolbar higher below anchor when it does not fit above bottom view padding', (WidgetTester tester) async {
// We expect the toolbar to be positioned _kTestToolbarOverlap pixels above the anchor with padding accounted for.
const double expectedToolbarY =
_kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding - _kTestToolbarOverlap;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpellCheckSuggestionsToolbar(
anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap),
buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']),
),
),
),
);
final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy;
expect(toolbarY, equals(expectedToolbarY));
});
}
...@@ -8,8 +8,6 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -8,8 +8,6 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition; import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
const double _kHandleSize = 22.0;
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0; const double _kToolbarContentDistance = 8.0;
// A custom text selection menu that just displays a single custom button. // A custom text selection menu that just displays a single custom button.
...@@ -35,7 +33,7 @@ class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls ...@@ -35,7 +33,7 @@ class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls
); );
final Offset anchorBelow = Offset( final Offset anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx, globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow,
); );
return TextSelectionToolbar( return TextSelectionToolbar(
...@@ -155,7 +153,7 @@ void main() { ...@@ -155,7 +153,7 @@ void main() {
// When the toolbar doesn't fit above aboveAnchor, it positions itself below // When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor. // belowAnchor.
double toolbarY = tester.getTopLeft(findToolbar()).dy; double toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow)); expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// Even when it barely doesn't fit. // Even when it barely doesn't fit.
setState(() { setState(() {
...@@ -163,7 +161,7 @@ void main() { ...@@ -163,7 +161,7 @@ void main() {
}); });
await tester.pump(); await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy; toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow)); expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// When it does fit above aboveAnchor, it positions itself there. // When it does fit above aboveAnchor, it positions itself there.
setState(() { setState(() {
......
...@@ -14020,27 +14020,305 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -14020,27 +14020,305 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
testWidgets( testWidgets(
'Error thrown when spell check enabled but no default spell check service available', 'Error thrown when spell check enabled but no default spell check service available',
(WidgetTester tester) async { (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
false; false;
await tester.pumpWidget( await tester.pumpWidget(
EditableText( EditableText(
controller: TextEditingController(text: 'A'), controller: TextEditingController(text: 'A'),
focusNode: FocusNode(), focusNode: FocusNode(),
style: const TextStyle(), style: const TextStyle(),
cursorColor: Colors.blue, cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey, backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true, cursorOpacityAnimates: true,
autofillHints: null, autofillHints: null,
spellCheckConfiguration: spellCheckConfiguration:
const SpellCheckConfiguration( const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle, misspelledTextStyle: TextField.materialMisspelledTextStyle,
), ),
)); ));
expect(tester.takeException(), isA<AssertionError>()); expect(tester.takeException(), isA<AssertionError>());
tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined(); tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined();
}); });
testWidgets(
'findSuggestionSpanAtCursorIndex finds correct span with cursor in middle of a word',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 21;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
// Omitting actual text in results for brevity. Same for following tests that test the findSuggestionSpanAtCursorIndex method.
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, equals(expectedSpan));
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds correct span with cursor on edge of a word',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 23;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, equals(expectedSpan));
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds no span when cursor out of range of spans',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 33;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, isNull);
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds no span when word correctly spelled',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 5;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, isNull);
});
testWidgets('can show spell check suggestions toolbar when there are spell check results', (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
const TextEditingValue value = TextEditingValue(
text: 'tset test test',
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
);
controller.value = value;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
// Can't show the toolbar when there's no focus.
expect(state.showSpellCheckSuggestionsToolbar(), false);
await tester.pumpAndSettle();
expect(find.text('DELETE'), findsNothing);
// Can't show the toolbar when there are no spell check results.
expect(state.showSpellCheckSuggestionsToolbar(), false);
await tester.pumpAndSettle();
expect(find.text('test'), findsNothing);
expect(find.text('sets'), findsNothing);
expect(find.text('set'), findsNothing);
expect(find.text('DELETE'), findsNothing);
// Can show the toolbar when there are spell check results.
state.spellCheckResults = const SpellCheckResults('test tset test', <SuggestionSpan>[SuggestionSpan(TextRange(start: 0, end: 4), <String>['test', 'sets', 'set'])]);
state.renderEditable.selectWordsInRange(
from: Offset.zero,
cause: SelectionChangedCause.tap,
);
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbar(), true);
await tester.pumpAndSettle();
expect(find.text('test'), findsOneWidget);
expect(find.text('sets'), findsOneWidget);
expect(find.text('set'), findsOneWidget);
expect(find.text('DELETE'), findsOneWidget);
});
testWidgets('spell check suggestions toolbar buttons correctly change the composing region', (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
const TextEditingValue value = TextEditingValue(
text: 'tset test test',
composing: TextRange(start: 0, end: 4),
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
);
controller.value = value;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
state.spellCheckResults = const SpellCheckResults('tset test test', <SuggestionSpan>[SuggestionSpan(TextRange(start: 0, end: 4), <String>['test', 'sets', 'set'])]);
state.renderEditable.selectWordsInRange(
from: Offset.zero,
cause: SelectionChangedCause.tap,
);
await tester.pumpAndSettle();
// Test misspelled word replacement buttons.
state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle();
expect(find.text('sets'), findsOneWidget);
await tester.tap(find.text('sets'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals('sets test test'));
// Test delete button.
state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle();
await tester.tap(find.text('DELETE'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals(' test test'));
});
}); });
group('magnifier', () { group('magnifier', () {
...@@ -14205,7 +14483,6 @@ class _CustomTextSelectionControls extends TextSelectionControls { ...@@ -14205,7 +14483,6 @@ class _CustomTextSelectionControls extends TextSelectionControls {
this.onCut, this.onCut,
}); });
static const double _kToolbarContentDistanceBelow = 20.0;
static const double _kToolbarContentDistance = 8.0; static const double _kToolbarContentDistance = 8.0;
final VoidCallback? onPaste; final VoidCallback? onPaste;
...@@ -14233,7 +14510,7 @@ class _CustomTextSelectionControls extends TextSelectionControls { ...@@ -14233,7 +14510,7 @@ class _CustomTextSelectionControls extends TextSelectionControls {
); );
final Offset anchorBelow = Offset( final Offset anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx, globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow,
); );
return _CustomTextSelectionToolbar( return _CustomTextSelectionToolbar(
anchorAbove: anchorAbove, anchorAbove: anchorAbove,
......
...@@ -618,6 +618,27 @@ void main() { ...@@ -618,6 +618,27 @@ void main() {
} }
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
testWidgets('test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on Android', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6);
renderEditable.hasFocus = true;
final TestGesture gesture = await tester.startGesture(
const Offset(25.0, 200.0),
pointer: 0,
);
await gesture.up();
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async { testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester); await pumpTextSelectionGestureDetectorBuilder(tester);
final TestGesture gesture = await tester.startGesture( final TestGesture gesture = await tester.startGesture(
...@@ -1568,6 +1589,7 @@ class FakeEditableTextState extends EditableTextState { ...@@ -1568,6 +1589,7 @@ class FakeEditableTextState extends EditableTextState {
final GlobalKey _editableKey = GlobalKey(); final GlobalKey _editableKey = GlobalKey();
bool showToolbarCalled = false; bool showToolbarCalled = false;
bool toggleToolbarCalled = false; bool toggleToolbarCalled = false;
bool showSpellCheckSuggestionsToolbarCalled = false;
@override @override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable; RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
...@@ -1584,6 +1606,12 @@ class FakeEditableTextState extends EditableTextState { ...@@ -1584,6 +1606,12 @@ class FakeEditableTextState extends EditableTextState {
return; return;
} }
@override
bool showSpellCheckSuggestionsToolbar() {
showSpellCheckSuggestionsToolbarCalled = true;
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment