diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index adab06c22b..a32a7026fd 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -102,6 +102,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe @override void onSingleTapUp(TapUpDetails details) { + editableText.hideToolbar(); // Because TextSelectionGestureDetector listens to taps that happen on // widgets in front of it, tapping the clear button will also trigger // this handler. If the clear button widget recognizes the up event, diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index b431d58f75..06eb64e6c1 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -494,6 +494,8 @@ class _SelectableTextState extends State with AutomaticKeepAlive }); } + TextSelection? _lastSeenTextSelection; + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); if (willShowSelectionHandles != _showSelectionHandles) { @@ -501,10 +503,12 @@ class _SelectableTextState extends State with AutomaticKeepAlive _showSelectionHandles = willShowSelectionHandles; }); } - - if (widget.onSelectionChanged != null) { + // TODO(chunhtai): The selection may be the same. We should remove this + // check once this is fixed https://github.com/flutter/flutter/issues/76349. + if (widget.onSelectionChanged != null && _lastSeenTextSelection != selection) { widget.onSelectionChanged!(selection, cause); } + _lastSeenTextSelection = selection; switch (Theme.of(context).platform) { case TargetPlatform.iOS: diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 326fcddd27..870933c617 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -31,39 +31,13 @@ const Radius _kFloatingCaretRadius = Radius.circular(1.0); /// (including the cursor location). /// /// Used by [RenderEditable.onSelectionChanged]. +@Deprecated( + 'Signature of a deprecated class method, ' + 'textSelectionDelegate.userUpdateTextEditingValue. ' + 'This feature was deprecated after v1.26.0-17.2.pre.' +) typedef SelectionChangedHandler = void Function(TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause); -/// Indicates what triggered the change in selected text (including changes to -/// the cursor location). -enum SelectionChangedCause { - /// The user tapped on the text and that caused the selection (or the location - /// of the cursor) to change. - tap, - - /// The user tapped twice in quick succession on the text and that caused - /// the selection (or the location of the cursor) to change. - doubleTap, - - /// The user long-pressed the text and that caused the selection (or the - /// location of the cursor) to change. - longPress, - - /// The user force-pressed the text and that caused the selection (or the - /// location of the cursor) to change. - forcePress, - - /// The user used the keyboard to change the selection or the location of the - /// cursor. - /// - /// Keyboard-triggered selection changes may be caused by the IME as well as - /// by accessibility tools (e.g. TalkBack on Android). - keyboard, - - /// The user used the mouse to change the selection by dragging over a piece - /// of text. - drag, -} - /// Signature for the callback that reports when the caret location changes. /// /// Used by [RenderEditable.onCaretChanged]. @@ -158,10 +132,6 @@ bool _isWhitespace(int codeUnit) { /// If, when the render object paints, the caret is found to have changed /// location, [onCaretChanged] is called. /// -/// The user may interact with the render object by tapping or long-pressing. -/// When the user does so, the selection is updated, and [onSelectionChanged] is -/// called. -/// /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value /// to actually blink the cursor, and other features not mentioned above are the /// responsibility of higher layers and not handled by this object. @@ -198,6 +168,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { double textScaleFactor = 1.0, TextSelection? selection, required ViewportOffset offset, + @Deprecated( + 'Uses the textSelectionDelegate.userUpdateTextEditingValue instead. ' + 'This feature was deprecated after v1.26.0-17.2.pre.' + ) this.onSelectionChanged, this.onCaretChanged, this.ignorePointer = false, @@ -401,6 +375,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// Called when the selection changes. /// /// If this is null, then selection changes will be ignored. + @Deprecated( + 'Uses the textSelectionDelegate.userUpdateTextEditingValue instead. ' + 'This feature was deprecated after v1.26.0-17.2.pre.' + ) SelectionChangedHandler? onSelectionChanged; double? _textLayoutLastMaxWidth; @@ -579,7 +557,19 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // down in a multiline text field when selecting using the keyboard. bool _wasSelectingVerticallyWithKeyboard = false; - // Call through to onSelectionChanged. + void _setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause) { + textSelectionDelegate.textEditingValue = newValue; + textSelectionDelegate.userUpdateTextEditingValue(newValue, cause); + } + + void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) { + _handleSelectionChange(nextSelection, cause); + _setTextEditingValue( + textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection), + cause, + ); + } + void _handleSelectionChange( TextSelection nextSelection, SelectionChangedCause cause, @@ -642,7 +632,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return; } - if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null) + if (keyEvent is! RawKeyDownEvent) return; final Set keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); final LogicalKeyboardKey key = keyEvent.logicalKey; @@ -908,12 +898,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset)); } - _handleSelectionChange( + _setSelection( newSelection, SelectionChangedCause.keyboard, ); - // Update the text selection delegate so that the engine knows what we did. - textSelectionDelegate.textEditingValue = textSelectionDelegate.textEditingValue.copyWith(selection: newSelection); } // Handles shortcut functionality including cut, copy, paste and select all @@ -961,13 +949,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ); } if (value != null) { - if (textSelectionDelegate.textEditingValue.selection != value.selection) { - _handleSelectionChange( - value.selection, - SelectionChangedCause.keyboard, - ); - } - textSelectionDelegate.textEditingValue = value; + _setTextEditingValue( + value, + SelectionChangedCause.keyboard, + ); } } @@ -994,15 +979,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } final TextSelection newSelection = TextSelection.collapsed(offset: cursorPosition); - if (selection != newSelection) { - _handleSelectionChange( - newSelection, - SelectionChangedCause.keyboard, - ); - } - textSelectionDelegate.textEditingValue = TextEditingValue( - text: textBefore + textAfter, - selection: newSelection, + _setTextEditingValue( + TextEditingValue( + text: textBefore + textAfter, + selection: newSelection, + ), + SelectionChangedCause.keyboard, ); } @@ -1530,7 +1512,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // callbacks are invoked, in which case the callbacks will crash... void _handleSetSelection(TextSelection selection) { - _handleSelectionChange(selection, SelectionChangedCause.keyboard); + _setSelection(selection, SelectionChangedCause.keyboard); } void _handleMoveCursorForwardByCharacter(bool extentSelection) { @@ -1539,8 +1521,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (extentOffset == null) return; final int baseOffset = !extentSelection ? extentOffset : selection!.baseOffset; - _handleSelectionChange( - TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), + SelectionChangedCause.keyboard, ); } @@ -1550,8 +1533,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (extentOffset == null) return; final int baseOffset = !extentSelection ? extentOffset : selection!.baseOffset; - _handleSelectionChange( - TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), + SelectionChangedCause.keyboard ); } @@ -1562,7 +1546,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (nextWord == null) return; final int baseOffset = extentSelection ? selection!.baseOffset : nextWord.start; - _handleSelectionChange( + _setSelection( TextSelection( baseOffset: baseOffset, extentOffset: nextWord.start, @@ -1578,12 +1562,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (previousWord == null) return; final int baseOffset = extentSelection ? selection!.baseOffset : previousWord.start; - _handleSelectionChange( + _setSelection( TextSelection( baseOffset: baseOffset, extentOffset: previousWord.start, ), - SelectionChangedCause.keyboard, + SelectionChangedCause.keyboard ); } @@ -1894,7 +1878,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { textSpan.recognizer?.addPointer(event); } - if (!ignorePointer && onSelectionChanged != null) { + if (!ignorePointer) { // Propagates the pointer event to selection handlers. _tap.addPointer(event); _longPress.addPointer(event); @@ -1992,9 +1976,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { assert(cause != null); assert(from != null); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - if (onSelectionChanged == null) { - return; - } final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); final TextPosition? toPosition = to == null ? null @@ -2008,8 +1989,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { extentOffset: extentOffset, affinity: fromPosition.affinity, ); - // Call [onSelectionChanged] only when the selection actually changed. - _handleSelectionChange(newSelection, cause); + _setSelection(newSelection, cause); } /// Select a word around the location of the last tap down. @@ -2029,22 +2009,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { assert(cause != null); assert(from != null); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - if (onSelectionChanged == null) { - return; - } final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); final TextSelection firstWord = _selectWordAtOffset(firstPosition); final TextSelection lastWord = to == null ? firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); - - _handleSelectionChange( - TextSelection( - baseOffset: firstWord.base.offset, - extentOffset: lastWord.extent.offset, - affinity: firstWord.affinity, - ), - cause, + final TextSelection newSelection = TextSelection( + baseOffset: firstWord.base.offset, + extentOffset: lastWord.extent.offset, + affinity: firstWord.affinity, ); + _setSelection(newSelection, cause); } /// Move the selection to the beginning or end of a word. @@ -2054,22 +2028,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { assert(cause != null); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); assert(_lastTapDownPosition != null); - if (onSelectionChanged == null) { - return; - } final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition! - _paintOffset)); final TextRange word = _textPainter.getWordBoundary(position); + late TextSelection newSelection; if (position.offset - word.start <= 1) { - _handleSelectionChange( - TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream), - cause, - ); + newSelection = TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream); } else { - _handleSelectionChange( - TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream), - cause, - ); + newSelection = TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream); } + _setSelection(newSelection, cause); } TextSelection _selectWordAtOffset(TextPosition position) { diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index c778a10e92..d9788cb684 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -754,12 +754,60 @@ class TextEditingValue { ); } +/// Indicates what triggered the change in selected text (including changes to +/// the cursor location). +enum SelectionChangedCause { + /// The user tapped on the text and that caused the selection (or the location + /// of the cursor) to change. + tap, + + /// The user tapped twice in quick succession on the text and that caused + /// the selection (or the location of the cursor) to change. + doubleTap, + + /// The user long-pressed the text and that caused the selection (or the + /// location of the cursor) to change. + longPress, + + /// The user force-pressed the text and that caused the selection (or the + /// location of the cursor) to change. + forcePress, + + /// The user used the keyboard to change the selection or the location of the + /// cursor. + /// + /// Keyboard-triggered selection changes may be caused by the IME as well as + /// by accessibility tools (e.g. TalkBack on Android). + keyboard, + + /// The user used the selection toolbar to change the selection or the + /// location of the cursor. + /// + /// An example is when the user taps on select all in the tool bar. + toolBar, + + /// The user used the mouse to change the selection by dragging over a piece + /// of text. + drag, +} + /// An interface for manipulating the selection, to be used by the implementor /// of the toolbar widget. abstract class TextSelectionDelegate { /// Gets the current text input. TextEditingValue get textEditingValue; + /// Indicates that the user has requested the delegate to replace its current + /// text editing state with [value]. + /// + /// The new [value] is treated as user input and thus may subject to input + /// formatting. + @Deprecated( + 'Use the userUpdateTextEditingValue instead. ' + 'This feature was deprecated after v1.26.0-17.2.pre.' + ) + set textEditingValue(TextEditingValue value) {} + /// Indicates that the user has requested the delegate to replace its current /// text editing state with [value]. /// @@ -768,10 +816,10 @@ abstract class TextSelectionDelegate { /// /// See also: /// - /// * [EditableTextState.textEditingValue]: an implementation that applies - /// additional pre-processing to the specified [value], before updating the - /// text editing state. - set textEditingValue(TextEditingValue value); + /// * [EditableTextState.userUpdateTextEditingValue]: an implementation that + /// applies additional pre-processing to the specified [value], before + /// updating the text editing state. + void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause); /// Hides the text selection toolbar. /// diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index d5373f7425..e97564f9c7 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -32,8 +32,7 @@ import 'text.dart'; import 'text_selection.dart'; import 'ticker_provider.dart'; -export 'package:flutter/rendering.dart' show SelectionChangedCause; -export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; +export 'package:flutter/services.dart' show SelectionChangedCause, TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; /// Signature for the callback that reports when the user changes the selection /// (including the cursor location). @@ -1492,7 +1491,7 @@ class EditableText extends StatefulWidget { } /// State for a [EditableText]. -class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin implements TextSelectionDelegate, TextInputClient, AutofillClient { +class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate implements TextInputClient, AutofillClient { Timer? _cursorTimer; bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); @@ -1727,7 +1726,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (value.text == _value.text && value.composing == _value.composing) { // `selection` is the only change. - _handleSelectionChanged(value.selection, renderEditable, SelectionChangedCause.keyboard); + _handleSelectionChanged(value.selection, SelectionChangedCause.keyboard); } else { hideToolbar(); _currentPromptRectRange = null; @@ -1739,7 +1738,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - _formatAndSetValue(value); + _formatAndSetValue(value, SelectionChangedCause.keyboard); } // Wherever the value is changed by the user, schedule a showCaretOnScreen @@ -1852,7 +1851,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien renderEditable.setFloatingCursor(FloatingCursorDragState.End, finalPosition, _lastTextPosition!); if (_lastTextPosition!.offset != renderEditable.selection!.baseOffset) // The cause is technically the force cursor, but the cause is listed as tap as the desired functionality is the same. - _handleSelectionChanged(TextSelection.collapsed(offset: _lastTextPosition!.offset), renderEditable, SelectionChangedCause.forcePress); + _handleSelectionChanged(TextSelection.collapsed(offset: _lastTextPosition!.offset), SelectionChangedCause.forcePress); _startCaretRect = null; _lastTextPosition = null; _pointOffsetOrigin = null; @@ -2118,7 +2117,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - void _handleSelectionChanged(TextSelection selection, RenderEditable renderObject, SelectionChangedCause? cause) { + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { // We return early if the selection is not valid. This can happen when the // text of [EditableText] is updated at the same time as the selection is // changed by a gesture event. @@ -2130,37 +2129,43 @@ class EditableTextState extends State with AutomaticKeepAliveClien // This will show the keyboard for all selection changes on the // EditableWidget, not just changes triggered by user gestures. requestKeyboard(); - - _selectionOverlay?.hide(); - _selectionOverlay = null; - - if (widget.selectionControls != null) { - _selectionOverlay = TextSelectionOverlay( - clipboardStatus: _clipboardStatus, - context: context, - value: _value, - debugRequiredFor: widget, - toolbarLayerLink: _toolbarLayerLink, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - renderObject: renderObject, - selectionControls: widget.selectionControls, - selectionDelegate: this, - dragStartBehavior: widget.dragStartBehavior, - onSelectionHandleTapped: widget.onSelectionHandleTapped, - ); + if (widget.selectionControls == null) { + _selectionOverlay?.hide(); + _selectionOverlay = null; + } else { + if (_selectionOverlay == null) { + _selectionOverlay = TextSelectionOverlay( + clipboardStatus: _clipboardStatus, + context: context, + value: _value, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditable, + selectionControls: widget.selectionControls, + selectionDelegate: this, + dragStartBehavior: widget.dragStartBehavior, + onSelectionHandleTapped: widget.onSelectionHandleTapped, + ); + } else { + _selectionOverlay!.update(_value); + } _selectionOverlay!.handlesVisible = widget.showSelectionHandles; _selectionOverlay!.showHandles(); - try { - widget.onSelectionChanged?.call(selection, cause); - } catch (exception, stack) { - FlutterError.reportError(FlutterErrorDetails( - exception: exception, - stack: stack, - library: 'widgets', - context: ErrorDescription('while calling onSelectionChanged for $cause'), - )); - } + } + // TODO(chunhtai): we should make sure selection actually changed before + // we call the onSelectionChanged. + // https://github.com/flutter/flutter/issues/76349. + try { + widget.onSelectionChanged?.call(selection, cause); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while calling onSelectionChanged for $cause'), + )); } // To keep the cursor from blinking while it moves, restart the timer here. @@ -2247,7 +2252,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien late final _WhitespaceDirectionalityFormatter _whitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection); - void _formatAndSetValue(TextEditingValue value) { + void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) { // Only apply input formatters if the text has changed (including uncommited // text in the composing region), or when the user committed the composing // text. @@ -2279,6 +2284,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien // sending multiple `TextInput.updateEditingValue` messages. beginBatchEdit(); _value = value; + // Changes made by the keyboard can sometimes be "out of band" for listening + // components, so always send those events, even if we didn't think it + // changed. Also, the user long pressing should always send a selection change + // as well. + if (selectionChanged || + (userInteraction && + (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.keyboard))) { + _handleSelectionChanged(value.selection, cause); + } if (textChanged) { try { widget.onChanged?.call(value.text); @@ -2292,19 +2307,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - if (selectionChanged) { - try { - widget.onSelectionChanged?.call(value.selection, null); - } catch (exception, stack) { - FlutterError.reportError(FlutterErrorDetails( - exception: exception, - stack: stack, - library: 'widgets', - context: ErrorDescription('while calling onSelectionChanged'), - )); - } - } - endBatchEdit(); } @@ -2416,7 +2418,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } if (!_value.selection.isValid) { // Place cursor at the end if the selection is invalid when we receive focus. - _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null); + _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null); } } else { WidgetsBinding.instance!.removeObserver(this); @@ -2478,8 +2480,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien double get _devicePixelRatio => MediaQuery.of(context).devicePixelRatio; @override - set textEditingValue(TextEditingValue value) { - _selectionOverlay?.update(value); + void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause? cause) { // Compare the current TextEditingValue with the pre-format new // TextEditingValue value, in case the formatter would reject the change. final bool shouldShowCaret = widget.readOnly @@ -2488,7 +2489,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (shouldShowCaret) { _scheduleShowCaretOnScreen(); } - _formatAndSetValue(value); + _formatAndSetValue(value, cause, userInteraction: true); } @override @@ -2657,7 +2658,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien smartQuotesType: widget.smartQuotesType, enableSuggestions: widget.enableSuggestions, offset: offset, - onSelectionChanged: _handleSelectionChanged, onCaretChanged: _handleCaretChanged, rendererIgnoresPointer: widget.rendererIgnoresPointer, cursorWidth: widget.cursorWidth, @@ -2741,7 +2741,6 @@ class _Editable extends LeafRenderObjectWidget { required this.smartQuotesType, required this.enableSuggestions, required this.offset, - this.onSelectionChanged, this.onCaretChanged, this.rendererIgnoresPointer = false, required this.cursorWidth, @@ -2789,7 +2788,6 @@ class _Editable extends LeafRenderObjectWidget { final SmartQuotesType smartQuotesType; final bool enableSuggestions; final ViewportOffset offset; - final SelectionChangedHandler? onSelectionChanged; final CaretChangedHandler? onCaretChanged; final bool rendererIgnoresPointer; final double cursorWidth; @@ -2829,7 +2827,6 @@ class _Editable extends LeafRenderObjectWidget { locale: locale ?? Localizations.maybeLocaleOf(context), selection: value.selection, offset: offset, - onSelectionChanged: onSelectionChanged, onCaretChanged: onCaretChanged, ignorePointer: rendererIgnoresPointer, obscuringCharacter: obscuringCharacter, @@ -2874,7 +2871,6 @@ class _Editable extends LeafRenderObjectWidget { ..locale = locale ?? Localizations.maybeLocaleOf(context) ..selection = value.selection ..offset = offset - ..onSelectionChanged = onSelectionChanged ..onCaretChanged = onCaretChanged ..ignorePointer = rendererIgnoresPointer ..textHeightBehavior = textHeightBehavior diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 7a77f88250..c937631f0a 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -205,12 +205,15 @@ abstract class TextSelectionControls { Clipboard.setData(ClipboardData( text: value.selection.textInside(value.text), )); - delegate.textEditingValue = TextEditingValue( - text: value.selection.textBefore(value.text) - + value.selection.textAfter(value.text), - selection: TextSelection.collapsed( - offset: value.selection.start + delegate.userUpdateTextEditingValue( + TextEditingValue( + text: value.selection.textBefore(value.text) + + value.selection.textAfter(value.text), + selection: TextSelection.collapsed( + offset: value.selection.start + ) ), + SelectionChangedCause.toolBar, ); delegate.bringIntoView(delegate.textEditingValue.selection.extent); delegate.hideToolbar(); @@ -241,9 +244,12 @@ abstract class TextSelectionControls { case TargetPlatform.linux: case TargetPlatform.windows: // Collapse the selection and hide the toolbar and handles. - delegate.textEditingValue = TextEditingValue( - text: value.text, - selection: TextSelection.collapsed(offset: value.selection.end), + delegate.userUpdateTextEditingValue( + TextEditingValue( + text: value.text, + selection: TextSelection.collapsed(offset: value.selection.end), + ), + SelectionChangedCause.toolBar, ); delegate.hideToolbar(); return; @@ -265,13 +271,16 @@ abstract class TextSelectionControls { final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`. final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null) { - delegate.textEditingValue = TextEditingValue( - text: value.selection.textBefore(value.text) - + data.text! - + value.selection.textAfter(value.text), - selection: TextSelection.collapsed( - offset: value.selection.start + data.text!.length + delegate.userUpdateTextEditingValue( + TextEditingValue( + text: value.selection.textBefore(value.text) + + data.text! + + value.selection.textAfter(value.text), + selection: TextSelection.collapsed( + offset: value.selection.start + data.text!.length + ), ), + SelectionChangedCause.toolBar, ); } delegate.bringIntoView(delegate.textEditingValue.selection.extent); @@ -286,12 +295,15 @@ abstract class TextSelectionControls { /// This is called by subclasses when their select-all affordance is activated /// by the user. void handleSelectAll(TextSelectionDelegate delegate) { - delegate.textEditingValue = TextEditingValue( - text: delegate.textEditingValue.text, - selection: TextSelection( - baseOffset: 0, - extentOffset: delegate.textEditingValue.text.length, + delegate.userUpdateTextEditingValue( + TextEditingValue( + text: delegate.textEditingValue.text, + selection: TextSelection( + baseOffset: 0, + extentOffset: delegate.textEditingValue.text.length, + ), ), + SelectionChangedCause.toolBar, ); delegate.bringIntoView(delegate.textEditingValue.selection.extent); } @@ -450,13 +462,16 @@ class TextSelectionOverlay { /// Builds the handles by inserting them into the [context]'s overlay. void showHandles() { - assert(_handles == null); + if (_handles != null) + return; + _handles = [ OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)), OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)), ]; - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insertAll(_handles!); + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! + .insertAll(_handles!); } /// Destroys the handles by removing them from overlay. @@ -636,10 +651,13 @@ class TextSelectionOverlay { textPosition = newSelection.base; break; case _TextSelectionHandlePosition.end: - textPosition =newSelection.extent; + textPosition = newSelection.extent; break; } - selectionDelegate!.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty); + selectionDelegate!.userUpdateTextEditingValue( + _value.copyWith(selection: newSelection, composing: TextRange.empty), + SelectionChangedCause.drag, + ); selectionDelegate!.bringIntoView(textPosition); } } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index d87898cfce..c2ecf47e4c 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -3475,7 +3475,6 @@ void main() { from: tester.getTopRight(find.byType(CupertinoApp)), cause: SelectionChangedCause.tap, ); - expect(state.showToolbar(), true); await tester.pumpAndSettle(); // -1 because we want to reach the end of the line, not the start of a new line. @@ -3536,7 +3535,6 @@ void main() { from: tester.getCenter(find.byType(EditableText)), cause: SelectionChangedCause.tap, ); - expect(state.showToolbar(), true); await tester.pumpAndSettle(); bottomLeftSelectionPosition = textOffsetToBottomLeftPosition(tester, state.renderEditable.selection!.baseOffset); diff --git a/packages/flutter/test/material/text_field_focus_test.dart b/packages/flutter/test/material/text_field_focus_test.dart index 1fe547b169..aa308d8277 100644 --- a/packages/flutter/test/material/text_field_focus_test.dart +++ b/packages/flutter/test/material/text_field_focus_test.dart @@ -119,6 +119,8 @@ void main() { expect(tester.testTextInput.isVisible, isTrue); tester.testTextInput.hide(); + final EditableTextState state = tester.state(find.byType(EditableText)); + state.connectionClosed(); expect(tester.testTextInput.isVisible, isFalse); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index cc85b93012..264d8c1232 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -18,6 +18,8 @@ import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; +typedef FormatEditUpdateCallback = void Function(TextEditingValue, TextEditingValue); + class MockClipboard { Object _clipboardData = { 'text': null, @@ -127,6 +129,16 @@ double getOpacity(WidgetTester tester, Finder finder) { ).opacity.value; } +class TestFormatter extends TextInputFormatter { + TestFormatter(this.onFormatEditUpdate); + FormatEditUpdateCallback onFormatEditUpdate; + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + onFormatEditUpdate(oldValue, newValue); + return newValue; + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); final MockClipboard mockClipboard = MockClipboard(); @@ -474,6 +486,47 @@ void main() { ); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('TextInputFormatter gets correct selection value', (WidgetTester tester) async { + late TextEditingValue actualOldValue; + late TextEditingValue actualNewValue; + final FormatEditUpdateCallback callBack = (TextEditingValue oldValue, TextEditingValue newValue) { + actualOldValue = oldValue; + actualNewValue = newValue; + }; + final FocusNode focusNode = FocusNode(); + final TextEditingController controller = TextEditingController(text: '123'); + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: controller, + focusNode: focusNode, + inputFormatters: [TestFormatter(callBack)], + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + expect( + actualOldValue, + const TextEditingValue( + text: '123', + selection: TextSelection.collapsed(offset: 3, affinity: TextAffinity.upstream), + ), + ); + expect( + actualNewValue, + const TextEditingValue( + text: '12', + selection: TextSelection.collapsed(offset: 2), + ), + ); + }); + testWidgets('text field selection toolbar renders correctly inside opacity', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -1071,11 +1124,9 @@ void main() { )); expect(find.text('Paste'), findsNothing); - final Offset emptyPos = textOffsetToPosition(tester, 0); await tester.longPressAt(emptyPos, pointer: 7); await tester.pumpAndSettle(); - expect(find.text('Paste'), findsOneWidget); }); @@ -3497,8 +3548,11 @@ void main() { // scrolls to make the caret visible. scrollableState = tester.firstState(find.byType(Scrollable)); final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); - editableTextState.textEditingValue = editableTextState.textEditingValue.copyWith( - selection: TextSelection.collapsed(offset: longText.length), + editableTextState.userUpdateTextEditingValue( + editableTextState.textEditingValue.copyWith( + selection: TextSelection.collapsed(offset: longText.length), + ), + null, ); await tester.pump(); // TODO(ianh): Figure out why this extra pump is needed. @@ -3533,8 +3587,11 @@ void main() { // Move the caret to the end of the text and check that the text field // scrolls to make the caret visible. final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); - editableTextState.textEditingValue = editableTextState.textEditingValue.copyWith( - selection: const TextSelection.collapsed(offset: tallText.length), + editableTextState.userUpdateTextEditingValue( + editableTextState.textEditingValue.copyWith( + selection: const TextSelection.collapsed(offset: tallText.length), + ), + null, ); await tester.pump(); await skipPastScrollingAnimation(tester); diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index d6912b21a1..65d2f33a51 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -21,6 +21,9 @@ class FakeEditableTextState with TextSelectionDelegate { @override void hideToolbar([bool hideHandles = true]) { } + @override + void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) { } + @override void bringIntoView(TextPosition position) { } } diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart index d57a7d8e27..5303fe958c 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -48,6 +48,7 @@ void main() { }); testWidgets('cursor layout has correct width', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; final GlobalKey editableTextKey = GlobalKey(); late String changedValue; @@ -87,8 +88,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Paste')); - // Wait for cursor to appear. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); expect(changedValue, clipboardContent); @@ -96,6 +96,7 @@ void main() { find.byKey(const ValueKey(1)), matchesGoldenFile('editable_text_test.0.png'), ); + EditableText.debugDeterministicCursor = false; }); testWidgets('cursor layout has correct radius', (WidgetTester tester) async { @@ -787,6 +788,7 @@ void main() { }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('cursor layout', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; final GlobalKey editableTextKey = GlobalKey(); late String changedValue; @@ -831,8 +833,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Paste')); - // Wait for cursor to appear. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); expect(changedValue, clipboardContent); @@ -840,9 +841,11 @@ void main() { find.byKey(const ValueKey(1)), matchesGoldenFile('editable_text_test.2.png'), ); + EditableText.debugDeterministicCursor = false; }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('cursor layout has correct height', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; final GlobalKey editableTextKey = GlobalKey(); late String changedValue; @@ -888,8 +891,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Paste')); - // Wait for cursor to appear. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); expect(changedValue, clipboardContent); @@ -897,5 +899,6 @@ void main() { find.byKey(const ValueKey(1)), matchesGoldenFile('editable_text_test.3.png'), ); + EditableText.debugDeterministicCursor = false; }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); } diff --git a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart index 74b30e1007..1ff6d421ea 100644 --- a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart +++ b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart @@ -646,7 +646,10 @@ void main() { // Change the selection. Show caret on screen even when readyOnly is // false. - state.textEditingValue = state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)); + state.userUpdateTextEditingValue( + state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)), + null, + ); await tester.pumpAndSettle(); expect(isCaretOnScreen(tester), isTrue); expect(scrollController.offset, greaterThan(0.0)); @@ -667,7 +670,10 @@ void main() { await tester.pumpAndSettle(); expect(isCaretOnScreen(tester), isFalse); - state.textEditingValue = state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)); + state.userUpdateTextEditingValue( + state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)), + null, + ); await tester.pumpAndSettle(); expect(isCaretOnScreen(tester), isTrue); expect(scrollController.offset, greaterThan(0.0)); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 892bce72e9..59b7bc669b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -5198,7 +5198,7 @@ void main() { tester.testTextInput.log.clear(); final EditableTextState state = tester.state(find.byWidget(editableText)); - state.textEditingValue = const TextEditingValue(text: 'remoteremoteremote'); + state.userUpdateTextEditingValue(const TextEditingValue(text: 'remoteremoteremote'), SelectionChangedCause.keyboard); // Apply in order: length formatter -> listener -> onChanged -> listener. expect(controller.text, 'remote listener onChanged listener'); @@ -5354,6 +5354,7 @@ void main() { 'TextInput.setEditingState', 'TextInput.setEditingState', 'TextInput.show', + 'TextInput.show', ]; expect(tester.testTextInput.log.length, logOrder.length); int index = 0; @@ -5468,16 +5469,18 @@ void main() { log.clear(); final EditableTextState state = tester.firstState(find.byType(EditableText)); - // setEditingState is not called when only the remote changes - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'a', + selection: controller.selection, )); + expect(log.length, 0); // setEditingState is called when remote value modified by the formatter. - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'I will be modified by the formatter.', + selection: controller.selection, )); expect(log.length, 1); MethodCall methodCall = log[0]; @@ -5591,8 +5594,9 @@ void main() { final EditableTextState state = tester.firstState(find.byType(EditableText)); // setEditingState is called when remote value modified by the formatter. - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'I will be modified by the formatter.', + selection: controller.selection, )); expect(log.length, 1); expect(log, contains(matchesMethodCall( @@ -5664,8 +5668,9 @@ void main() { final EditableTextState state = tester.firstState(find.byType(EditableText)); - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'a', + selection: controller.selection, )); await tester.pump(); @@ -5688,8 +5693,9 @@ void main() { log.clear(); // Send repeat value from the engine. - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'a', + selection: controller.selection, )); await tester.pump(); @@ -5783,8 +5789,9 @@ void main() { final EditableTextState state = tester.firstState(find.byType(EditableText)); - state.updateEditingValue(const TextEditingValue( + state.updateEditingValue(TextEditingValue( text: 'a', + selection: controller.selection, )); await tester.pump(); @@ -6578,6 +6585,7 @@ void main() { final EditableTextState state = tester.state(find.byType(EditableText)); state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); controller.selection = const TextSelection.collapsed(offset: 2); @@ -6586,6 +6594,7 @@ void main() { // Reset the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); expect(state.currentTextEditingValue.composing, const TextRange(start: 4, end: 12)); @@ -6593,13 +6602,14 @@ void main() { // Positioning cursor after the composing range should clear the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); controller.selection = const TextSelection.collapsed(offset: 14); expect(state.currentTextEditingValue.composing, TextRange.empty); }); - testWidgets('Clears composing range if cursor moves outside that range', (WidgetTester tester) async { + testWidgets('Clears composing range if cursor moves outside that range - case two', (WidgetTester tester) async { final Widget widget = MaterialApp( home: EditableText( backgroundCursorColor: Colors.grey, @@ -6616,6 +6626,7 @@ void main() { final EditableTextState state = tester.state(find.byType(EditableText)); state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); controller.selection = const TextSelection(baseOffset: 1, extentOffset: 2); @@ -6624,6 +6635,7 @@ void main() { // Reset the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); expect(state.currentTextEditingValue.composing, const TextRange(start: 4, end: 12)); @@ -6631,6 +6643,7 @@ void main() { // Setting a selection within the composing range clears the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); controller.selection = const TextSelection(baseOffset: 5, extentOffset: 7); @@ -6639,6 +6652,7 @@ void main() { // Reset the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); expect(state.currentTextEditingValue.composing, const TextRange(start: 4, end: 12)); @@ -6646,6 +6660,7 @@ void main() { // Setting a selection after the composing range clears the composing range. state.updateEditingValue(const TextEditingValue( text: 'foo composing bar', + selection: TextSelection.collapsed(offset: 4), composing: TextRange(start: 4, end: 12), )); controller.selection = const TextSelection(baseOffset: 13, extentOffset: 15); diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 2f8a89caf1..67ae7f67a6 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -797,6 +797,7 @@ class FakeRenderEditable extends RenderEditable { ), startHandleLayerLink: LayerLink(), endHandleLayerLink: LayerLink(), + ignorePointer: true, textAlign: TextAlign.start, textDirection: TextDirection.ltr, locale: const Locale('en', 'US'),