parent
4ce5c2713b
commit
438c4ff21c
@ -756,7 +756,17 @@ abstract class TextSelectionDelegate {
|
|||||||
/// Gets the current text input.
|
/// Gets the current text input.
|
||||||
TextEditingValue get textEditingValue;
|
TextEditingValue get textEditingValue;
|
||||||
|
|
||||||
/// Sets the current text input (replaces the whole line).
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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);
|
set textEditingValue(TextEditingValue value);
|
||||||
|
|
||||||
/// Hides the text selection toolbar.
|
/// Hides the text selection toolbar.
|
||||||
@ -784,6 +794,7 @@ abstract class TextSelectionDelegate {
|
|||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [TextInput.attach]
|
/// * [TextInput.attach]
|
||||||
|
/// * [EditableText], a [TextInputClient] implementation.
|
||||||
abstract class TextInputClient {
|
abstract class TextInputClient {
|
||||||
/// Abstract const constructor. This constructor enables subclasses to provide
|
/// Abstract const constructor. This constructor enables subclasses to provide
|
||||||
/// const constructors so that they can be used in const expressions.
|
/// const constructors so that they can be used in const expressions.
|
||||||
@ -805,6 +816,9 @@ abstract class TextInputClient {
|
|||||||
AutofillScope? get currentAutofillScope;
|
AutofillScope? get currentAutofillScope;
|
||||||
|
|
||||||
/// Requests that this client update its editing state to the given value.
|
/// Requests that this client update its editing state to the given value.
|
||||||
|
///
|
||||||
|
/// The new [value] is treated as user input and thus may subject to input
|
||||||
|
/// formatting.
|
||||||
void updateEditingValue(TextEditingValue value);
|
void updateEditingValue(TextEditingValue value);
|
||||||
|
|
||||||
/// Requests that this client perform the given action.
|
/// Requests that this client perform the given action.
|
||||||
@ -832,7 +846,10 @@ abstract class TextInputClient {
|
|||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [TextInput.attach]
|
/// * [TextInput.attach], a method used to establish a [TextInputConnection]
|
||||||
|
/// between the system's text input and a [TextInputClient].
|
||||||
|
/// * [EditableText], a [TextInputClient] that connects to and interacts with
|
||||||
|
/// the system's text input using a [TextInputConnection].
|
||||||
class TextInputConnection {
|
class TextInputConnection {
|
||||||
TextInputConnection._(this._client)
|
TextInputConnection._(this._client)
|
||||||
: assert(_client != null),
|
: assert(_client != null),
|
||||||
@ -889,7 +906,8 @@ class TextInputConnection {
|
|||||||
TextInput._instance._updateConfig(configuration);
|
TextInput._instance._updateConfig(configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests that the text input control change its internal state to match the given state.
|
/// Requests that the text input control change its internal state to match
|
||||||
|
/// the given state.
|
||||||
void setEditingState(TextEditingValue value) {
|
void setEditingState(TextEditingValue value) {
|
||||||
assert(attached);
|
assert(attached);
|
||||||
TextInput._instance._setEditingState(value);
|
TextInput._instance._setEditingState(value);
|
||||||
@ -1042,9 +1060,57 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d
|
|||||||
|
|
||||||
/// An low-level interface to the system's text input control.
|
/// An low-level interface to the system's text input control.
|
||||||
///
|
///
|
||||||
|
/// To start interacting with the system's text input control, call [attach] to
|
||||||
|
/// establish a [TextInputConnection] between the system's text input control
|
||||||
|
/// and a [TextInputClient]. The majority of commands available for
|
||||||
|
/// interacting with the text input control reside in the returned
|
||||||
|
/// [TextInputConnection]. The communication between the system text input and
|
||||||
|
/// the [TextInputClient] is asynchronous.
|
||||||
|
///
|
||||||
|
/// The platform text input plugin (which represents the system's text input)
|
||||||
|
/// and the [TextInputClient] usually maintain their own text editing states
|
||||||
|
/// ([TextEditingValue]) separately. They must be kept in sync as long as the
|
||||||
|
/// [TextInputClient] is connected. The following methods can be used to send
|
||||||
|
/// [TextEditingValue] to update the other party, when either party's text
|
||||||
|
/// editing states change:
|
||||||
|
///
|
||||||
|
/// * The [TextInput.attach] method allows a [TextInputClient] to establish a
|
||||||
|
/// connection to the text input. An optional field in its `configuration`
|
||||||
|
/// parameter can be used to specify an initial value for the platform text
|
||||||
|
/// input plugin's [TextEditingValue].
|
||||||
|
///
|
||||||
|
/// * The [TextInputClient] sends its [TextEditingValue] to the platform text
|
||||||
|
/// input plugin using [TextInputConnection.setEditingState].
|
||||||
|
///
|
||||||
|
/// * The platform text input plugin sends its [TextEditingValue] to the
|
||||||
|
/// connected [TextInputClient] via a "TextInput.setEditingState" message.
|
||||||
|
///
|
||||||
|
/// * When autofill happens on a disconnected [TextInputClient], the platform
|
||||||
|
/// text input plugin sends the [TextEditingValue] to the connected
|
||||||
|
/// [TextInputClient]'s [AutofillScope], and the [AutofillScope] will further
|
||||||
|
/// relay the value to the correct [TextInputClient].
|
||||||
|
///
|
||||||
|
/// When synchronizing the [TextEditingValue]s, the communication may get stuck
|
||||||
|
/// in an infinite when both parties are trying to send their own update. To
|
||||||
|
/// mitigate the problem, only [TextInputClient]s are allowed to alter the
|
||||||
|
/// received [TextEditingValue]s while platform text input plugins are to accept
|
||||||
|
/// the received [TextEditingValue]s unmodified. More specifically:
|
||||||
|
///
|
||||||
|
/// * When a [TextInputClient] receives a new [TextEditingValue] from the
|
||||||
|
/// platform text input plugin, it's allowed to modify the value (for example,
|
||||||
|
/// apply [TextInputFormatter]s). If it decides to do so, it must send the
|
||||||
|
/// updated [TextEditingValue] back to the platform text input plugin to keep
|
||||||
|
/// the [TextEditingValue]s in sync.
|
||||||
|
///
|
||||||
|
/// * When the platform text input plugin receives a new value from the
|
||||||
|
/// connected [TextInputClient], it must accept the new value as-is, to avoid
|
||||||
|
/// sending back an updated value.
|
||||||
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [TextField], a widget in which the user may enter text.
|
/// * [TextField], a widget in which the user may enter text.
|
||||||
|
/// * [EditableText], a [TextInputClient] that connects to [TextInput] when it
|
||||||
|
/// wants to take user input from the keyboard.
|
||||||
class TextInput {
|
class TextInput {
|
||||||
TextInput._() {
|
TextInput._() {
|
||||||
_channel = SystemChannels.textInput;
|
_channel = SystemChannels.textInput;
|
||||||
|
@ -320,6 +320,19 @@ class ToolbarOptions {
|
|||||||
/// movement. This widget does not provide any focus management (e.g.,
|
/// movement. This widget does not provide any focus management (e.g.,
|
||||||
/// tap-to-focus).
|
/// tap-to-focus).
|
||||||
///
|
///
|
||||||
|
/// ## Handling User Input
|
||||||
|
///
|
||||||
|
/// Currently the user may change the text this widget contains via keyboard or
|
||||||
|
/// the text selection menu. When the user inserted or deleted text, you will be
|
||||||
|
/// notified of the change and get a chance to modify the new text value:
|
||||||
|
///
|
||||||
|
/// * The [inputFormatters] will be first applied to the user input.
|
||||||
|
///
|
||||||
|
/// * The [controller]'s [TextEditingController.value] will be updated with the
|
||||||
|
/// formatted result, and the [controller]'s listeners will be notified.
|
||||||
|
///
|
||||||
|
/// * The [onChanged] callback, if specified, will be called last.
|
||||||
|
///
|
||||||
/// ## Input Actions
|
/// ## Input Actions
|
||||||
///
|
///
|
||||||
/// A [TextInputAction] can be provided to customize the appearance of the
|
/// A [TextInputAction] can be provided to customize the appearance of the
|
||||||
@ -1082,7 +1095,9 @@ class EditableText extends StatefulWidget {
|
|||||||
/// {@template flutter.widgets.editableText.inputFormatters}
|
/// {@template flutter.widgets.editableText.inputFormatters}
|
||||||
/// Optional input validation and formatting overrides.
|
/// Optional input validation and formatting overrides.
|
||||||
///
|
///
|
||||||
/// Formatters are run in the provided order when the text input changes.
|
/// Formatters are run in the provided order when the text input changes. When
|
||||||
|
/// this parameter changes, the new formatters will not be applied until the
|
||||||
|
/// next time the user inserts or deletes text.
|
||||||
/// {@endtemplate}
|
/// {@endtemplate}
|
||||||
final List<TextInputFormatter>? inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
|
|
||||||
@ -1637,61 +1652,66 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
|
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
|
||||||
_clipboardStatus?.dispose();
|
_clipboardStatus?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextInputClient implementation:
|
// TextInputClient implementation:
|
||||||
|
|
||||||
// _lastFormattedUnmodifiedTextEditingValue tracks the last value
|
/// The last known [TextEditingValue] of the platform text input plugin.
|
||||||
// that the formatter ran on and is used to prevent double-formatting.
|
///
|
||||||
TextEditingValue? _lastFormattedUnmodifiedTextEditingValue;
|
/// This value is updated when the platform text input plugin sends a new
|
||||||
// _lastFormattedValue tracks the last post-format value, so that it can be
|
/// update via [updateEditingValue], or when [EditableText] calls
|
||||||
// reused without rerunning the formatter when the input value is repeated.
|
/// [TextInputConnection.setEditingState] to overwrite the platform text input
|
||||||
TextEditingValue? _lastFormattedValue;
|
/// plugin's [TextEditingValue].
|
||||||
// _receivedRemoteTextEditingValue is the direct value last passed in
|
///
|
||||||
// updateEditingValue. This value does not get updated with the formatted
|
/// Used in [_updateRemoteEditingValueIfNeeded] to determine whether the
|
||||||
// version.
|
/// remote value is outdated and needs updating.
|
||||||
TextEditingValue? _receivedRemoteTextEditingValue;
|
TextEditingValue? _lastKnownRemoteTextEditingValue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TextEditingValue get currentTextEditingValue => _value;
|
TextEditingValue get currentTextEditingValue => _value;
|
||||||
|
|
||||||
bool _updateEditingValueInProgress = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateEditingValue(TextEditingValue value) {
|
void updateEditingValue(TextEditingValue value) {
|
||||||
_updateEditingValueInProgress = true;
|
// This method handles text editing state updates from the platform text
|
||||||
|
// input plugin. The [EditableText] may not have the focus or an open input
|
||||||
|
// connection, as autofill can update a disconnected [EditableText].
|
||||||
|
|
||||||
// Since we still have to support keyboard select, this is the best place
|
// Since we still have to support keyboard select, this is the best place
|
||||||
// to disable text updating.
|
// to disable text updating.
|
||||||
if (!_shouldCreateInputConnection) {
|
if (!_shouldCreateInputConnection) {
|
||||||
_updateEditingValueInProgress = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.readOnly) {
|
if (widget.readOnly) {
|
||||||
// In the read-only case, we only care about selection changes, and reject
|
// In the read-only case, we only care about selection changes, and reject
|
||||||
// everything else.
|
// everything else.
|
||||||
value = _value.copyWith(selection: value.selection);
|
value = _value.copyWith(selection: value.selection);
|
||||||
}
|
}
|
||||||
_receivedRemoteTextEditingValue = value;
|
_lastKnownRemoteTextEditingValue = value;
|
||||||
if (value.text != _value.text) {
|
|
||||||
hideToolbar();
|
|
||||||
_showCaretOnScreen();
|
|
||||||
_currentPromptRectRange = null;
|
|
||||||
if (widget.obscureText && value.text.length == _value.text.length + 1) {
|
|
||||||
_obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks;
|
|
||||||
_obscureLatestCharIndex = _value.selection.baseOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value == _value) {
|
if (value == _value) {
|
||||||
// This is possible, for example, when the numeric keyboard is input,
|
// This is possible, for example, when the numeric keyboard is input,
|
||||||
// the engine will notify twice for the same value.
|
// the engine will notify twice for the same value.
|
||||||
// Track at https://github.com/flutter/flutter/issues/65811
|
// Track at https://github.com/flutter/flutter/issues/65811
|
||||||
_updateEditingValueInProgress = false;
|
|
||||||
return;
|
return;
|
||||||
} else if (value.text == _value.text && value.composing == _value.composing && value.selection != _value.selection) {
|
}
|
||||||
|
|
||||||
|
if (value.text == _value.text && value.composing == _value.composing) {
|
||||||
// `selection` is the only change.
|
// `selection` is the only change.
|
||||||
_handleSelectionChanged(value.selection, renderEditable, SelectionChangedCause.keyboard);
|
_handleSelectionChanged(value.selection, renderEditable, SelectionChangedCause.keyboard);
|
||||||
} else {
|
} else {
|
||||||
|
hideToolbar();
|
||||||
|
_currentPromptRectRange = null;
|
||||||
|
|
||||||
|
if (_hasInputConnection) {
|
||||||
|
_showCaretOnScreen();
|
||||||
|
if (widget.obscureText && value.text.length == _value.text.length + 1) {
|
||||||
|
_obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks;
|
||||||
|
_obscureLatestCharIndex = _value.selection.baseOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_formatAndSetValue(value);
|
_formatAndSetValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1701,7 +1721,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
_stopCursorTimer(resetCharTicks: false);
|
_stopCursorTimer(resetCharTicks: false);
|
||||||
_startCursorTimer();
|
_startCursorTimer();
|
||||||
}
|
}
|
||||||
_updateEditingValueInProgress = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -1859,33 +1878,52 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invoke optional callback with the user's submitted content.
|
// Invoke optional callback with the user's submitted content.
|
||||||
if (widget.onSubmitted != null) {
|
try {
|
||||||
try {
|
widget.onSubmitted?.call(_value.text);
|
||||||
widget.onSubmitted!(_value.text);
|
} catch (exception, stack) {
|
||||||
} catch (exception, stack) {
|
FlutterError.reportError(FlutterErrorDetails(
|
||||||
FlutterError.reportError(FlutterErrorDetails(
|
exception: exception,
|
||||||
exception: exception,
|
stack: stack,
|
||||||
stack: stack,
|
library: 'widgets',
|
||||||
library: 'widgets',
|
context: ErrorDescription('while calling onSubmitted for $action'),
|
||||||
context: ErrorDescription('while calling onSubmitted for $action'),
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _batchEditDepth = 0;
|
||||||
|
|
||||||
|
/// Begins a new batch edit, within which new updates made to the text editing
|
||||||
|
/// value will not be sent to the platform text input plugin.
|
||||||
|
///
|
||||||
|
/// Batch edits nest. When the outmost batch edit finishes, [endBatchEdit]
|
||||||
|
/// will attempt to send [currentTextEditingValue] to the text input plugin if
|
||||||
|
/// it detected a change.
|
||||||
|
void beginBatchEdit() {
|
||||||
|
_batchEditDepth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the current batch edit started by the last call to [beginBatchEdit],
|
||||||
|
/// and send [currentTextEditingValue] to the text input plugin if needed.
|
||||||
|
///
|
||||||
|
/// Throws an error in debug mode if this [EditableText] is not in a batch
|
||||||
|
/// edit.
|
||||||
|
void endBatchEdit() {
|
||||||
|
_batchEditDepth -= 1;
|
||||||
|
assert(
|
||||||
|
_batchEditDepth >= 0,
|
||||||
|
'Unbalanced call to endBatchEdit: beginBatchEdit must be called first.',
|
||||||
|
);
|
||||||
|
_updateRemoteEditingValueIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
void _updateRemoteEditingValueIfNeeded() {
|
void _updateRemoteEditingValueIfNeeded() {
|
||||||
if (!_hasInputConnection)
|
if (_batchEditDepth > 0 || !_hasInputConnection)
|
||||||
return;
|
return;
|
||||||
final TextEditingValue localValue = _value;
|
final TextEditingValue localValue = _value;
|
||||||
// We should not update back the value notified by the remote(engine) in reverse, this is redundant.
|
if (localValue == _lastKnownRemoteTextEditingValue)
|
||||||
// Unless we modify this value for some reason during processing, such as `TextInputFormatter`.
|
|
||||||
if (_updateEditingValueInProgress && localValue == _receivedRemoteTextEditingValue)
|
|
||||||
return;
|
return;
|
||||||
// In other cases, as long as the value of the [widget.controller.value] is modified,
|
|
||||||
// `setEditingState` should be called as we do not want to skip sending real changes
|
|
||||||
// to the engine.
|
|
||||||
// Also see https://github.com/flutter/flutter/issues/65059#issuecomment-690254379
|
|
||||||
_textInputConnection!.setEditingState(localValue);
|
_textInputConnection!.setEditingState(localValue);
|
||||||
|
_lastKnownRemoteTextEditingValue = localValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEditingValue get _value => widget.controller.value;
|
TextEditingValue get _value => widget.controller.value;
|
||||||
@ -1949,7 +1987,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
|
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasInputConnection => _textInputConnection != null && _textInputConnection!.attached;
|
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
|
||||||
bool get _needsAutofill => widget.autofillHints?.isNotEmpty ?? false;
|
bool get _needsAutofill => widget.autofillHints?.isNotEmpty ?? false;
|
||||||
bool get _shouldBeInAutofillContext => _needsAutofill && currentAutofillScope != null;
|
bool get _shouldBeInAutofillContext => _needsAutofill && currentAutofillScope != null;
|
||||||
|
|
||||||
@ -1959,7 +1997,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
}
|
}
|
||||||
if (!_hasInputConnection) {
|
if (!_hasInputConnection) {
|
||||||
final TextEditingValue localValue = _value;
|
final TextEditingValue localValue = _value;
|
||||||
_lastFormattedUnmodifiedTextEditingValue = localValue;
|
|
||||||
|
|
||||||
// When _needsAutofill == true && currentAutofillScope == null, autofill
|
// When _needsAutofill == true && currentAutofillScope == null, autofill
|
||||||
// is allowed but saving the user input from the text field is
|
// is allowed but saving the user input from the text field is
|
||||||
@ -2000,8 +2037,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
if (_hasInputConnection) {
|
if (_hasInputConnection) {
|
||||||
_textInputConnection!.close();
|
_textInputConnection!.close();
|
||||||
_textInputConnection = null;
|
_textInputConnection = null;
|
||||||
_lastFormattedUnmodifiedTextEditingValue = null;
|
_lastKnownRemoteTextEditingValue = null;
|
||||||
_receivedRemoteTextEditingValue = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2019,8 +2055,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
if (_hasInputConnection) {
|
if (_hasInputConnection) {
|
||||||
_textInputConnection!.connectionClosedReceived();
|
_textInputConnection!.connectionClosedReceived();
|
||||||
_textInputConnection = null;
|
_textInputConnection = null;
|
||||||
_lastFormattedUnmodifiedTextEditingValue = null;
|
_lastKnownRemoteTextEditingValue = null;
|
||||||
_receivedRemoteTextEditingValue = null;
|
|
||||||
_finalizeEditing(TextInputAction.done, shouldUnfocus: true);
|
_finalizeEditing(TextInputAction.done, shouldUnfocus: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2084,17 +2119,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
);
|
);
|
||||||
_selectionOverlay!.handlesVisible = widget.showSelectionHandles;
|
_selectionOverlay!.handlesVisible = widget.showSelectionHandles;
|
||||||
_selectionOverlay!.showHandles();
|
_selectionOverlay!.showHandles();
|
||||||
if (widget.onSelectionChanged != null) {
|
try {
|
||||||
try {
|
widget.onSelectionChanged?.call(selection, cause);
|
||||||
widget.onSelectionChanged!(selection, cause);
|
} catch (exception, stack) {
|
||||||
} catch (exception, stack) {
|
FlutterError.reportError(FlutterErrorDetails(
|
||||||
FlutterError.reportError(FlutterErrorDetails(
|
exception: exception,
|
||||||
exception: exception,
|
stack: stack,
|
||||||
stack: stack,
|
library: 'widgets',
|
||||||
library: 'widgets',
|
context: ErrorDescription('while calling onSelectionChanged for $cause'),
|
||||||
context: ErrorDescription('while calling onSelectionChanged for $cause'),
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2182,53 +2215,39 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
_lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom;
|
_lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
_WhitespaceDirectionalityFormatter? _whitespaceFormatter;
|
late final _WhitespaceDirectionalityFormatter _whitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection);
|
||||||
|
|
||||||
void _formatAndSetValue(TextEditingValue value) {
|
void _formatAndSetValue(TextEditingValue value) {
|
||||||
_whitespaceFormatter ??= _WhitespaceDirectionalityFormatter(textDirection: _textDirection);
|
|
||||||
|
|
||||||
// Check if the new value is the same as the current local value, or is the same
|
// Check if the new value is the same as the current local value, or is the same
|
||||||
// as the pre-formatting value of the previous pass (repeat call).
|
// as the pre-formatting value of the previous pass (repeat call).
|
||||||
final bool textChanged = _value.text != value.text;
|
final bool textChanged = _value.text != value.text || _value.composing != value.composing;
|
||||||
final bool isRepeat = value == _lastFormattedUnmodifiedTextEditingValue;
|
|
||||||
|
|
||||||
// There's no need to format when starting to compose or when continuing
|
if (textChanged) {
|
||||||
// an existing composition.
|
|
||||||
final bool isComposing = value.composing.isValid;
|
|
||||||
final bool isPreviouslyComposing = _lastFormattedUnmodifiedTextEditingValue?.composing.isValid ?? false;
|
|
||||||
|
|
||||||
if ((textChanged || (!isComposing && isPreviouslyComposing)) &&
|
|
||||||
widget.inputFormatters != null &&
|
|
||||||
widget.inputFormatters!.isNotEmpty) {
|
|
||||||
// Only format when the text has changed and there are available formatters.
|
// Only format when the text has changed and there are available formatters.
|
||||||
// Pass through the formatter regardless of repeat status if the input value is
|
// Pass through the formatter regardless of repeat status if the input value is
|
||||||
// different than the stored value.
|
// different than the stored value.
|
||||||
for (final TextInputFormatter formatter in widget.inputFormatters!) {
|
value = widget.inputFormatters?.fold<TextEditingValue>(
|
||||||
value = formatter.formatEditUpdate(_value, value);
|
value,
|
||||||
}
|
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
|
||||||
|
) ?? value;
|
||||||
|
|
||||||
// Always pass the text through the whitespace directionality formatter to
|
// Always pass the text through the whitespace directionality formatter to
|
||||||
// maintain expected behavior with carets on trailing whitespace.
|
// maintain expected behavior with carets on trailing whitespace.
|
||||||
value = _whitespaceFormatter!.formatEditUpdate(_value, value);
|
// TODO(LongCatIsLooong): The if statement here is for retaining the
|
||||||
_lastFormattedValue = value;
|
// previous behavior. The input formatter logic will be updated in an
|
||||||
|
// upcoming PR.
|
||||||
|
if (widget.inputFormatters?.isNotEmpty ?? false)
|
||||||
|
value = _whitespaceFormatter.formatEditUpdate(_value, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value == _value) {
|
// Put all optional user callback invocations in a batch edit to prevent
|
||||||
// If the value was modified by the formatter, the remote should be notified to keep in sync,
|
// sending multiple `TextInput.updateEditingValue` messages.
|
||||||
// if not modified, it will short-circuit.
|
beginBatchEdit();
|
||||||
_updateRemoteEditingValueIfNeeded();
|
|
||||||
} else {
|
|
||||||
// Setting _value here ensures the selection and composing region info is passed.
|
|
||||||
_value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the last formatted value when an identical repeat pass is detected.
|
_value = value;
|
||||||
if (isRepeat && textChanged && _lastFormattedValue != null) {
|
if (textChanged) {
|
||||||
_value = _lastFormattedValue!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textChanged && widget.onChanged != null) {
|
|
||||||
try {
|
try {
|
||||||
widget.onChanged!(value.text);
|
widget.onChanged?.call(value.text);
|
||||||
} catch (exception, stack) {
|
} catch (exception, stack) {
|
||||||
FlutterError.reportError(FlutterErrorDetails(
|
FlutterError.reportError(FlutterErrorDetails(
|
||||||
exception: exception,
|
exception: exception,
|
||||||
@ -2238,7 +2257,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue;
|
|
||||||
|
endBatchEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCursorColorTick() {
|
void _onCursorColorTick() {
|
||||||
|
@ -4729,6 +4729,246 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('batch editing', () {
|
||||||
|
final TextEditingController controller = TextEditingController(text: testText);
|
||||||
|
final EditableText editableText = EditableText(
|
||||||
|
showSelectionHandles: true,
|
||||||
|
maxLines: 2,
|
||||||
|
controller: controller,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
cursorColor: Colors.red,
|
||||||
|
backgroundCursorColor: Colors.blue,
|
||||||
|
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1.copyWith(fontFamily: 'Roboto'),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget widget = MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: editableText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('batch editing works', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: 'remote value'));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
state.beginBatchEdit();
|
||||||
|
|
||||||
|
controller.text = 'new change 1';
|
||||||
|
expect(state.currentTextEditingValue.text, 'new change 1');
|
||||||
|
expect(tester.testTextInput.log, isEmpty);
|
||||||
|
|
||||||
|
// Nesting.
|
||||||
|
state.beginBatchEdit();
|
||||||
|
controller.text = 'new change 2';
|
||||||
|
expect(state.currentTextEditingValue.text, 'new change 2');
|
||||||
|
expect(tester.testTextInput.log, isEmpty);
|
||||||
|
|
||||||
|
// End the innermost batch edit. Not yet.
|
||||||
|
state.endBatchEdit();
|
||||||
|
expect(tester.testTextInput.log, isEmpty);
|
||||||
|
|
||||||
|
controller.text = 'new change 3';
|
||||||
|
expect(state.currentTextEditingValue.text, 'new change 3');
|
||||||
|
expect(tester.testTextInput.log, isEmpty);
|
||||||
|
|
||||||
|
// Finish the outermost batch edit.
|
||||||
|
state.endBatchEdit();
|
||||||
|
expect(tester.testTextInput.log, hasLength(1));
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(matchesMethodCall('TextInput.setEditingState', args: containsPair('text', 'new change 3'))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('batch edits need to be nested properly', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: 'remote value'));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
String errorString;
|
||||||
|
try {
|
||||||
|
state.endBatchEdit();
|
||||||
|
} catch (e) {
|
||||||
|
errorString = e.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorString, contains('Unbalanced call to endBatchEdit'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('catch unfinished batch edits on disposal', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: 'remote value'));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
state.beginBatchEdit();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.pumpWidget(Container());
|
||||||
|
expect(tester.takeException(), isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EditableText does not send editing values more than once', () {
|
||||||
|
final TextEditingController controller = TextEditingController(text: testText);
|
||||||
|
final EditableText editableText = EditableText(
|
||||||
|
showSelectionHandles: true,
|
||||||
|
maxLines: 2,
|
||||||
|
controller: controller,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
cursorColor: Colors.red,
|
||||||
|
backgroundCursorColor: Colors.blue,
|
||||||
|
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1.copyWith(fontFamily: 'Roboto'),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
inputFormatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(6)],
|
||||||
|
onChanged: (String s) => controller.text += ' onChanged',
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget widget = MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: editableText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.addListener(() {
|
||||||
|
if (!controller.text.endsWith('listener'))
|
||||||
|
controller.text += ' listener';
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('input from text input plugin', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: 'remoteremoteremote'));
|
||||||
|
|
||||||
|
// Apply in order: length formatter -> listener -> onChanged -> listener.
|
||||||
|
expect(controller.text, 'remote listener onChanged listener');
|
||||||
|
final List<TextEditingValue> updates = tester.testTextInput.log
|
||||||
|
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
|
||||||
|
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remote listener onChanged listener')]);
|
||||||
|
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
// If by coincidence the text input plugin sends the same value back,
|
||||||
|
// do nothing.
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: 'remote listener onChanged listener'));
|
||||||
|
expect(controller.text, 'remote listener onChanged listener');
|
||||||
|
expect(tester.testTextInput.log, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('input from text selection menu', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
|
||||||
|
state.textEditingValue = const TextEditingValue(text: 'remoteremoteremote');
|
||||||
|
|
||||||
|
// Apply in order: length formatter -> listener -> onChanged -> listener.
|
||||||
|
expect(controller.text, 'remote listener onChanged listener');
|
||||||
|
final List<TextEditingValue> updates = tester.testTextInput.log
|
||||||
|
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
|
||||||
|
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remote listener onChanged listener')]);
|
||||||
|
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('input from controller', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
controller.text = 'remoteremoteremote';
|
||||||
|
final List<TextEditingValue> updates = tester.testTextInput.log
|
||||||
|
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
|
||||||
|
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remoteremoteremote listener')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('input from changing controller', (WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(text: testText);
|
||||||
|
Widget build({ TextEditingController textEditingController }) {
|
||||||
|
return MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: EditableText(
|
||||||
|
showSelectionHandles: true,
|
||||||
|
maxLines: 2,
|
||||||
|
controller: textEditingController ?? controller,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
cursorColor: Colors.red,
|
||||||
|
backgroundCursorColor: Colors.blue,
|
||||||
|
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1.copyWith(fontFamily: 'Roboto'),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
inputFormatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(6)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(build());
|
||||||
|
|
||||||
|
// Connect.
|
||||||
|
await tester.showKeyboard(find.byType(EditableText));
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
await tester.pumpWidget(build(textEditingController: TextEditingController(text: 'new text')));
|
||||||
|
|
||||||
|
List<TextEditingValue> updates = tester.testTextInput.log
|
||||||
|
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
|
||||||
|
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'new text')]);
|
||||||
|
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
await tester.pumpWidget(build(textEditingController: TextEditingController(text: 'new new text')));
|
||||||
|
|
||||||
|
updates = tester.testTextInput.log
|
||||||
|
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
|
||||||
|
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'new new text')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('input imm channel calls are ordered correctly', (WidgetTester tester) async {
|
testWidgets('input imm channel calls are ordered correctly', (WidgetTester tester) async {
|
||||||
const String testText = 'flutter is the best!';
|
const String testText = 'flutter is the best!';
|
||||||
final TextEditingController controller = TextEditingController(text: testText);
|
final TextEditingController controller = TextEditingController(text: testText);
|
||||||
@ -5311,12 +5551,12 @@ void main() {
|
|||||||
expect(formatter.formatCallCount, 3);
|
expect(formatter.formatCallCount, 3);
|
||||||
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // No text change, does not format
|
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // No text change, does not format
|
||||||
expect(formatter.formatCallCount, 3);
|
expect(formatter.formatCallCount, 3);
|
||||||
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2), composing: TextRange(start: 1, end: 2))); // Composing change does not reformat
|
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2), composing: TextRange(start: 1, end: 2))); // Composing change triggers reformat
|
||||||
expect(formatter.formatCallCount, 3);
|
|
||||||
expect(formatter.lastOldValue.composing, const TextRange(start: -1, end: -1));
|
|
||||||
expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1)); // Since did not format, the new composing was not registered in formatter.
|
|
||||||
state.updateEditingValue(const TextEditingValue(text: '01234', selection: TextSelection.collapsed(offset: 2))); // Formats, with oldValue containing composing region.
|
|
||||||
expect(formatter.formatCallCount, 4);
|
expect(formatter.formatCallCount, 4);
|
||||||
|
expect(formatter.lastOldValue.composing, const TextRange(start: -1, end: -1));
|
||||||
|
expect(formatter.lastNewValue.composing, const TextRange(start: 1, end: 2)); // The new composing was registered in formatter.
|
||||||
|
state.updateEditingValue(const TextEditingValue(text: '01234', selection: TextSelection.collapsed(offset: 2))); // Formats, with oldValue containing composing region.
|
||||||
|
expect(formatter.formatCallCount, 5);
|
||||||
expect(formatter.lastOldValue.composing, const TextRange(start: 1, end: 2));
|
expect(formatter.lastOldValue.composing, const TextRange(start: 1, end: 2));
|
||||||
expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1));
|
expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1));
|
||||||
|
|
||||||
@ -5327,8 +5567,10 @@ void main() {
|
|||||||
'[2]: normal aaaa',
|
'[2]: normal aaaa',
|
||||||
'[3]: 012, 0123',
|
'[3]: 012, 0123',
|
||||||
'[3]: normal aaaaaa',
|
'[3]: normal aaaaaa',
|
||||||
'[4]: 0123, 01234',
|
'[4]: 0123, 0123',
|
||||||
'[4]: normal aaaaaaaa'
|
'[4]: normal aaaaaaaa',
|
||||||
|
'[5]: 0123, 01234',
|
||||||
|
'[5]: normal aaaaaaaaaa',
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(formatter.log, referenceLog);
|
expect(formatter.log, referenceLog);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user