Unverified Commit 52cacd9d authored by Camille Simon's avatar Camille Simon Committed by GitHub

[iOS] Add spell check suggestions toolbar on tap (#119189)

[iOS] Add spell check suggestions toolbar on tap
parent 9d820aa3
...@@ -58,6 +58,7 @@ export 'src/cupertino/search_field.dart'; ...@@ -58,6 +58,7 @@ export 'src/cupertino/search_field.dart';
export 'src/cupertino/segmented_control.dart'; export 'src/cupertino/segmented_control.dart';
export 'src/cupertino/slider.dart'; export 'src/cupertino/slider.dart';
export 'src/cupertino/sliding_segmented_control.dart'; export 'src/cupertino/sliding_segmented_control.dart';
export 'src/cupertino/spell_check_suggestions_toolbar.dart';
export 'src/cupertino/switch.dart'; export 'src/cupertino/switch.dart';
export 'src/cupertino/tab_scaffold.dart'; export 'src/cupertino/tab_scaffold.dart';
export 'src/cupertino/tab_view.dart'; export 'src/cupertino/tab_view.dart';
......
// 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/scheduler.dart';
import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan;
import 'package:flutter/widgets.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_button.dart';
/// iOS only shows 3 spell check suggestions in the toolbar.
const int _maxSuggestions = 3;
/// The default spell check suggestions toolbar for iOS.
///
/// Tries to position itself below the [anchors], but if it doesn't fit, then it
/// readjusts to fit above bottom view insets.
class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
/// Constructs a [CupertinoSpellCheckSuggestionsToolbar].
const CupertinoSpellCheckSuggestionsToolbar({
super.key,
required this.anchors,
required this.buttonItems,
});
/// The location on which to anchor the menu.
final TextSelectionToolbarAnchors anchors;
/// 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.
/// * [SpellCheckSuggestionsToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s used to build the Material style spell check
/// suggestions toolbar.
final List<ContextMenuButtonItem> buttonItems;
/// 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;
}
if (spanAtCursorIndex.suggestions.isEmpty) {
return <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {},
label: 'No Replacements Found',
)
];
}
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
// Build suggestion buttons.
int suggestionCount = 0;
for (final String suggestion in spanAtCursorIndex.suggestions) {
if (suggestionCount >= _maxSuggestions) {
break;
}
buttonItems.add(ContextMenuButtonItem(
onPressed: () {
if (!editableTextState.mounted) {
return;
}
_replaceText(
editableTextState,
suggestion,
spanAtCursorIndex.range,
);
},
label: suggestion,
));
suggestionCount += 1;
}
return buttonItems;
}
static void _replaceText(EditableTextState editableTextState, String text, TextRange replacementRange) {
// Replacement cannot be performed if the text is read only or obscured.
assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText);
final TextEditingValue newValue = editableTextState.textEditingValue.replaced(
replacementRange,
text,
);
editableTextState.userUpdateTextEditingValue(newValue,SelectionChangedCause.toolbar);
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
if (editableTextState.mounted) {
editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent);
}
});
editableTextState.hideToolbar();
editableTextState.renderEditable.selectWordEdge(cause: SelectionChangedCause.toolbar);
}
/// Builds the toolbar buttons based on the [buttonItems].
List<Widget> _buildToolbarButtons(BuildContext context) {
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoTextSelectionToolbarButton.buttonItem(
buttonItem: buttonItem,
);
}).toList();
}
@override
Widget build(BuildContext context) {
final List<Widget> children = _buildToolbarButtons(context);
return CupertinoTextSelectionToolbar(
anchorAbove: anchors.primaryAnchor,
anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
children: children,
);
}
}
...@@ -15,6 +15,7 @@ import 'colors.dart'; ...@@ -15,6 +15,7 @@ import 'colors.dart';
import 'desktop_text_selection.dart'; import 'desktop_text_selection.dart';
import 'icons.dart'; import 'icons.dart';
import 'magnifier.dart'; import 'magnifier.dart';
import 'spell_check_suggestions_toolbar.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -787,6 +788,32 @@ class CupertinoTextField extends StatefulWidget { ...@@ -787,6 +788,32 @@ class CupertinoTextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.dotted, decorationStyle: TextDecorationStyle.dotted,
); );
/// Default builder for the spell check suggestions toolbar in the Cupertino
/// style.
///
/// See also:
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
/// builder configured to show a spell check suggestions toolbar.
/// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], the builder
/// configured to show the Material style spell check suggestions toolbar.
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
final List<ContextMenuButtonItem>? buttonItems =
CupertinoSpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);
if (buttonItems == null || buttonItems.isEmpty){
return const SizedBox.shrink();
}
return CupertinoSpellCheckSuggestionsToolbar(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
/// {@macro flutter.widgets.undoHistory.controller} /// {@macro flutter.widgets.undoHistory.controller}
final UndoHistoryController? undoController; final UndoHistoryController? undoController;
...@@ -1274,7 +1301,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1274,7 +1301,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled() widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
? widget.spellCheckConfiguration!.copyWith( ? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? CupertinoTextField.cupertinoMisspelledTextStyle) ?? CupertinoTextField.cupertinoMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
)
: const SpellCheckConfiguration.disabled(); : const SpellCheckConfiguration.disabled();
final Widget paddedEditable = Padding( final Widget paddedEditable = Padding(
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart' show SuggestionSpan; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan;
import 'adaptive_text_selection_toolbar.dart'; import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart'; import 'colors.dart';
...@@ -42,6 +43,9 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget { ...@@ -42,6 +43,9 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s that are used to build the buttons of the /// [ContextMenuButtonItem]s that are used to build the buttons of the
/// text selection toolbar. /// text selection toolbar.
/// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s used to build the Cupertino style spell check
/// suggestions toolbar.
final List<ContextMenuButtonItem> buttonItems; final List<ContextMenuButtonItem> buttonItems;
/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator /// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
...@@ -77,10 +81,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget { ...@@ -77,10 +81,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
for (final String suggestion in spanAtCursorIndex.suggestions) { for (final String suggestion in spanAtCursorIndex.suggestions) {
buttonItems.add(ContextMenuButtonItem( buttonItems.add(ContextMenuButtonItem(
onPressed: () { onPressed: () {
editableTextState if (!editableTextState.mounted) {
.replaceComposingRegion( return;
SelectionChangedCause.toolbar, }
suggestion, _replaceText(
editableTextState,
suggestion,
spanAtCursorIndex.range,
); );
}, },
label: suggestion, label: suggestion,
...@@ -91,9 +98,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget { ...@@ -91,9 +98,13 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
final ContextMenuButtonItem deleteButton = final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem( ContextMenuButtonItem(
onPressed: () { onPressed: () {
editableTextState.replaceComposingRegion( if (!editableTextState.mounted) {
SelectionChangedCause.toolbar, return;
}
_replaceText(
editableTextState,
'', '',
editableTextState.currentTextEditingValue.composing,
); );
}, },
type: ContextMenuButtonType.delete, type: ContextMenuButtonType.delete,
...@@ -103,6 +114,25 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget { ...@@ -103,6 +114,25 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
return buttonItems; return buttonItems;
} }
static void _replaceText(EditableTextState editableTextState, String text, TextRange replacementRange) {
// Replacement cannot be performed if the text is read only or obscured.
assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText);
final TextEditingValue newValue = editableTextState.textEditingValue.replaced(
replacementRange,
text,
);
editableTextState.userUpdateTextEditingValue(newValue, SelectionChangedCause.toolbar);
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
if (editableTextState.mounted) {
editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent);
}
});
editableTextState.hideToolbar();
}
/// Determines the Offset that the toolbar will be anchored to. /// Determines the Offset that the toolbar will be anchored to.
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!; return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;
......
...@@ -805,7 +805,9 @@ class TextField extends StatefulWidget { ...@@ -805,7 +805,9 @@ class TextField extends StatefulWidget {
/// ///
/// See also: /// See also:
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
// builder configured to show a spell check suggestions toolbar. /// builder configured to show a spell check suggestions toolbar.
/// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], the builder
/// configured to show the Material style spell check suggestions toolbar.
@visibleForTesting @visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder( static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context, BuildContext context,
...@@ -1239,7 +1241,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1239,7 +1241,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
?? TextField.materialMisspelledTextStyle, ?? TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder ?? TextField.defaultSpellCheckSuggestionsToolbarBuilder,
) )
: const SpellCheckConfiguration.disabled(); : const SpellCheckConfiguration.disabled();
......
...@@ -15,9 +15,9 @@ import 'system_channels.dart'; ...@@ -15,9 +15,9 @@ import 'system_channels.dart';
/// to "Hello, wrold!" may be: /// to "Hello, wrold!" may be:
/// ```dart /// ```dart
/// SuggestionSpan suggestionSpan = /// SuggestionSpan suggestionSpan =
/// SuggestionSpan( /// const SuggestionSpan(
/// const TextRange(start: 7, end: 12), /// TextRange(start: 7, end: 12),
/// List<String>.of(<String>['word', 'world', 'old']), /// <String>['word', 'world', 'old'],
/// ); /// );
/// ``` /// ```
@immutable @immutable
......
...@@ -2347,24 +2347,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2347,24 +2347,6 @@ 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 /// Finds specified [SuggestionSpan] that matches the provided index using
/// binary search. /// binary search.
/// ///
...@@ -3980,7 +3962,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3980,7 +3962,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// Shows toolbar with spell check suggestions of misspelled words that are /// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace. /// available for click-and-replace.
bool showSpellCheckSuggestionsToolbar() { bool showSpellCheckSuggestionsToolbar() {
// Spell check suggestions toolbars are intended to be shown on non-web
// platforms. Additionally, the Cupertino style toolbar can't be drawn on
// the web with the HTML renderer due to
// https://github.com/flutter/flutter/issues/123560.
final bool platformNotSupported = kIsWeb && BrowserContextMenu.enabled;
if (!spellCheckEnabled if (!spellCheckEnabled
|| platformNotSupported
|| widget.readOnly || widget.readOnly
|| _selectionOverlay == null || _selectionOverlay == null
|| !_spellCheckResultsReceived) { || !_spellCheckResultsReceived) {
......
...@@ -2187,10 +2187,17 @@ class TextSelectionGestureDetectorBuilder { ...@@ -2187,10 +2187,17 @@ class TextSelectionGestureDetectorBuilder {
case PointerDeviceKind.trackpad: case PointerDeviceKind.trackpad:
case PointerDeviceKind.stylus: case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus: case PointerDeviceKind.invertedStylus:
// Precise devices should place the cursor at a precise position. // TODO(camsim99): Determine spell check toolbar behavior in these cases:
// https://github.com/flutter/flutter/issues/119573.
// Precise devices should place the cursor at a precise position if the
// word at the text position is not misspelled.
renderEditable.selectPosition(cause: SelectionChangedCause.tap); renderEditable.selectPosition(cause: SelectionChangedCause.tap);
case PointerDeviceKind.touch: case PointerDeviceKind.touch:
case PointerDeviceKind.unknown: case PointerDeviceKind.unknown:
// If the word that was tapped is misspelled, select the word and show the spell check suggestions
// toolbar once. If additional taps are made on a misspelled word, toggle the toolbar. If the word
// is not misspelled, default to the following behavior:
//
// Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the // Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the
// TextAffinity remains the same, and the editable is focused. The TextAffinity is important when the // TextAffinity remains the same, and the editable is focused. The TextAffinity is important when the
// cursor is on the boundary of a line wrap, if the affinity is different (i.e. it is downstream), the // 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 { ...@@ -2205,9 +2212,17 @@ class TextSelectionGestureDetectorBuilder {
final TextSelection previousSelection = renderEditable.selection ?? editableText.textEditingValue.selection; final TextSelection previousSelection = renderEditable.selection ?? editableText.textEditingValue.selection;
final TextPosition textPosition = renderEditable.getPositionForPoint(details.globalPosition); final TextPosition textPosition = renderEditable.getPositionForPoint(details.globalPosition);
final bool isAffinityTheSame = textPosition.affinity == previousSelection.affinity; final bool isAffinityTheSame = textPosition.affinity == previousSelection.affinity;
if (((_positionWasOnSelectionExclusive(textPosition) && !previousSelection.isCollapsed) final bool wordAtCursorIndexIsMisspelled = editableText.findSuggestionSpanAtCursorIndex(textPosition.offset) != null;
|| (_positionWasOnSelectionInclusive(textPosition) && previousSelection.isCollapsed && isAffinityTheSame))
&& renderEditable.hasFocus) { if (wordAtCursorIndexIsMisspelled) {
renderEditable.selectWord(cause: SelectionChangedCause.tap);
if (previousSelection != editableText.textEditingValue.selection) {
editableText.showSpellCheckSuggestionsToolbar();
} else {
editableText.toggleToolbar(false);
}
}
else if (((_positionWasOnSelectionExclusive(textPosition) && !previousSelection.isCollapsed) || (_positionWasOnSelectionInclusive(textPosition) && previousSelection.isCollapsed && isAffinityTheSame)) && renderEditable.hasFocus) {
editableText.toggleToolbar(false); editableText.toggleToolbar(false);
} else { } else {
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
......
...@@ -15045,23 +15045,23 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -15045,23 +15045,23 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4), selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
); );
controller.value = value; controller.value = value;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: EditableText( home: EditableText(
backgroundCursorColor: Colors.grey, backgroundCursorColor: Colors.grey,
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
style: textStyle, style: textStyle,
cursorColor: cursorColor, cursorColor: cursorColor,
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
spellCheckConfiguration: spellCheckConfiguration:
const SpellCheckConfiguration( const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle, misspelledTextStyle: TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder, spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder,
), ),
),
), ),
); ),
);
final EditableTextState state = final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText)); tester.state<EditableTextState>(find.byType(EditableText));
...@@ -15085,16 +15085,81 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -15085,16 +15085,81 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
from: Offset.zero, from: Offset.zero,
cause: SelectionChangedCause.tap, cause: SelectionChangedCause.tap,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbar(), true); // Toolbar will only show on non-web platforms.
expect(state.showSpellCheckSuggestionsToolbar(), !kIsWeb);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('test'), findsOneWidget);
expect(find.text('sets'), findsOneWidget); const Matcher matcher = kIsWeb ? findsNothing : findsOneWidget;
expect(find.text('set'), findsOneWidget); expect(find.text('test'), matcher);
expect(find.text('DELETE'), findsOneWidget); expect(find.text('sets'), matcher);
expect(find.text('set'), matcher);
expect(find.text('DELETE'), matcher);
}); });
testWidgets('spell check suggestions toolbar buttons correctly change the composing region', (WidgetTester tester) async { testWidgets('cupertino 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',
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
);
controller.value = value;
await tester.pumpWidget(
CupertinoApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: cupertinoTextSelectionControls,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: CupertinoTextField.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();
// Set last tap down position so that selecting the word edge will be
// a valid operation.
final Offset pos1 = textOffsetToPosition(tester, 1);
final TestGesture gesture = await tester.startGesture(pos1);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.selection.baseOffset, equals(1));
// Test that tapping misspelled word replacement buttons will replace
// the correct word and select the word edge.
state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle();
if (kIsWeb) {
expect(find.text('sets'), findsNothing);
}
else {
expect(find.text('sets'), findsOneWidget);
await tester.tap(find.text('sets'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals('sets test test'));
expect(state.currentTextEditingValue.selection.baseOffset, equals(4));
}
});
testWidgets('material spell check suggestions toolbar buttons correctly change the composing region', (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true; true;
const TextEditingValue value = TextEditingValue( const TextEditingValue value = TextEditingValue(
...@@ -15129,21 +15194,34 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -15129,21 +15194,34 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
cause: SelectionChangedCause.tap, cause: SelectionChangedCause.tap,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(state.currentTextEditingValue.selection.baseOffset, equals(0));
// Test misspelled word replacement buttons. // Test misspelled word replacement buttons.
state.showSpellCheckSuggestionsToolbar(); state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('sets'), findsOneWidget);
await tester.tap(find.text('sets')); if (kIsWeb) {
await tester.pumpAndSettle(); expect(find.text('sets'), findsNothing);
expect(state.currentTextEditingValue.text, equals('sets test test')); } else {
expect(find.text('sets'), findsOneWidget);
await tester.tap(find.text('sets'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals('sets test test'));
expect(state.currentTextEditingValue.selection.baseOffset, equals(0));
}
// Test delete button. // Test delete button.
state.showSpellCheckSuggestionsToolbar(); state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('DELETE')); if (kIsWeb) {
await tester.pumpAndSettle(); expect(find.text('DELETE'), findsNothing);
expect(state.currentTextEditingValue.text, equals(' test test')); } else {
expect(find.text('DELETE'), findsOneWidget);
await tester.tap(find.text('DELETE'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals(' test test'));
expect(state.currentTextEditingValue.selection.baseOffset, equals(0));
}
}); });
}); });
......
...@@ -12,6 +12,8 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -12,6 +12,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart'; import 'clipboard_utils.dart';
import 'editable_text_utils.dart'; import 'editable_text_utils.dart';
const int kSingleTapUpTimeout = 500;
void main() { void main() {
late int tapCount; late int tapCount;
late int singleTapUpCount; late int singleTapUpCount;
...@@ -688,6 +690,47 @@ void main() { ...@@ -688,6 +690,47 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
testWidgets('test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on iOS if word misspelled and text selection toolbar on additonal taps', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
const TextSelection selection = TextSelection.collapsed(offset: 1);
state.updateEditingValue(const TextEditingValue(text: 'something misspelled', selection: selection));
// Mark word to be tapped as misspelled for testing.
state.markCurrentSelectionAsMisspelled = true;
await tester.pump();
// Test spell check suggestions toolbar is shown on first tap of misspelled word.
const Offset position = Offset(25.0, 200.0);
await tester.tapAt(position);
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue);
// Reset and test text selection toolbar is toggled for additional taps.
state.showSpellCheckSuggestionsToolbarCalled = false;
renderEditable.selection = selection;
await tester.pump(const Duration(milliseconds: kSingleTapUpTimeout));
// Test first tap.
await tester.tapAt(position);
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
expect(state.toggleToolbarCalled, isTrue);
// Reset and test second tap.
state.toggleToolbarCalled = false;
await tester.pump(const Duration(milliseconds: kSingleTapUpTimeout));
await tester.tapAt(position);
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
expect(state.toggleToolbarCalled, isTrue);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
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(
...@@ -1646,6 +1689,7 @@ class FakeEditableTextState extends EditableTextState { ...@@ -1646,6 +1689,7 @@ class FakeEditableTextState extends EditableTextState {
bool showToolbarCalled = false; bool showToolbarCalled = false;
bool toggleToolbarCalled = false; bool toggleToolbarCalled = false;
bool showSpellCheckSuggestionsToolbarCalled = false; bool showSpellCheckSuggestionsToolbarCalled = false;
bool markCurrentSelectionAsMisspelled = false;
@override @override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable; RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
...@@ -1668,6 +1712,15 @@ class FakeEditableTextState extends EditableTextState { ...@@ -1668,6 +1712,15 @@ class FakeEditableTextState extends EditableTextState {
return true; return true;
} }
@override
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
return markCurrentSelectionAsMisspelled
? const SuggestionSpan(
TextRange(start: 7, end: 12),
<String>['word', 'world', 'old'],
) : null;
}
@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