From 39becb7770175263d1c76d398cba155779ccb117 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Tue, 18 Apr 2023 10:18:41 -0700 Subject: [PATCH] iOS spell check cursor placement (#124875) Fixes the cursor location after selecting a spell check result on iOS. --- .../spell_check_suggestions_toolbar.dart | 15 ++- .../test/widgets/editable_text_test.dart | 112 ++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/spell_check_suggestions_toolbar.dart b/packages/flutter/lib/src/cupertino/spell_check_suggestions_toolbar.dart index 8f3415305aa..fbeb4f89fff 100644 --- a/packages/flutter/lib/src/cupertino/spell_check_suggestions_toolbar.dart +++ b/packages/flutter/lib/src/cupertino/spell_check_suggestions_toolbar.dart @@ -98,10 +98,16 @@ class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { // 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, - ); + final TextEditingValue newValue = editableTextState.textEditingValue + .replaced( + replacementRange, + text, + ) + .copyWith( + selection: TextSelection.collapsed( + offset: replacementRange.start + text.length, + ), + ); editableTextState.userUpdateTextEditingValue(newValue,SelectionChangedCause.toolbar); // Schedule a call to bringIntoView() after renderEditable updates. @@ -111,7 +117,6 @@ class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { } }); editableTextState.hideToolbar(); - editableTextState.renderEditable.selectWordEdge(cause: SelectionChangedCause.toolbar); } /// Builds the toolbar buttons based on the [buttonItems]. diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index efecc764b10..ad25bf293a9 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -15294,6 +15294,118 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async expect(state.currentTextEditingValue.selection.baseOffset, equals(0)); } }); + + testWidgets('replacing puts cursor at the end of the word', (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + controller.value = const TextEditingValue( + // All misspellings of "test". One the same length, one shorter, and one + // longer. + text: 'tset tst testt', + selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4), + ); + await tester.pumpWidget( + CupertinoApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ), + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + state.spellCheckResults = SpellCheckResults( + controller.value.text, + const [ + SuggestionSpan(TextRange(start: 0, end: 4), ['test']), + SuggestionSpan(TextRange(start: 5, end: 8), ['test']), + SuggestionSpan(TextRange(start: 9, end: 13), ['test']), + ]); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(state.showSpellCheckSuggestionsToolbar(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('test'), findsOneWidget); + + // Replacing a word of the same length as the replacement puts the cursor + // at the end of the new word. + await tester.tap(find.text('test')); + await tester.pumpAndSettle(); + expect( + controller.value, + equals(const TextEditingValue( + text: 'test tst testt', + selection: TextSelection.collapsed( + offset: 4, + ), + )), + ); + + state.spellCheckResults = SpellCheckResults( + controller.value.text, + const [ + SuggestionSpan(TextRange(start: 5, end: 8), ['test']), + SuggestionSpan(TextRange(start: 9, end: 13), ['test']), + ]); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(state.showSpellCheckSuggestionsToolbar(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('test'), findsOneWidget); + + // Replacing a word of less length as the replacement puts the cursor at + // the end of the new word. + await tester.tap(find.text('test')); + await tester.pumpAndSettle(); + expect( + controller.value, + equals(const TextEditingValue( + text: 'test test testt', + selection: TextSelection.collapsed( + offset: 9, + ), + )), + ); + + state.spellCheckResults = SpellCheckResults( + controller.value.text, + const [ + SuggestionSpan(TextRange(start: 10, end: 15), ['test']), + ]); + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pumpAndSettle(); + expect(state.showSpellCheckSuggestionsToolbar(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('test'), findsOneWidget); + + // Replacing a word of greater length as the replacement puts the cursor + // at the end of the new word. + await tester.tap(find.text('test')); + await tester.pumpAndSettle(); + expect( + controller.value, + equals(const TextEditingValue( + text: 'test test test', + selection: TextSelection.collapsed( + offset: 14, + ), + )), + ); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android }), + skip: kIsWeb, // [intended] + ); }); group('magnifier', () {