blob: 2d201a3bf503225927dea595dfbe23e5db21ccc9 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui hide TextStyle;
import 'package:characters/characters.dart' show CharacterRange, StringCharacters;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'autofill.dart';
import 'automatic_keep_alive.dart';
import 'basic.dart';
import 'binding.dart';
import 'constants.dart';
import 'debug.dart';
import 'default_selection_style.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'localizations.dart';
import 'media_query.dart';
import 'scroll_configuration.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scrollable.dart';
import 'shortcuts.dart';
import 'text.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
import 'ticker_provider.dart';
import 'widget_span.dart';
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).
typedef SelectionChangedCallback = void Function(TextSelection selection, SelectionChangedCause? cause);
/// Signature for the callback that reports the app private command results.
typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500);
// Number of cursor ticks during which the most recently entered character
// is shown in an obscured text field.
const int _kObscureShowLatestCharCursorTicks = 3;
// The minimum width of an iPad screen. The smallest iPad is currently the
// iPad Mini 6th Gen according to ios-resolution.com.
const double _kIPadWidth = 1488.0;
/// A controller for an editable text field.
///
/// Whenever the user modifies a text field with an associated
/// [TextEditingController], the text field updates [value] and the controller
/// notifies its listeners. Listeners can then read the [text] and [selection]
/// properties to learn what the user has typed or how the selection has been
/// updated.
///
/// Similarly, if you modify the [text] or [selection] properties, the text
/// field will be notified and will update itself appropriately.
///
/// A [TextEditingController] can also be used to provide an initial value for a
/// text field. If you build a text field with a controller that already has
/// [text], the text field will use that text as its initial value.
///
/// The [value] (as well as [text] and [selection]) of this controller can be
/// updated from within a listener added to this controller. Be aware of
/// infinite loops since the listener will also be notified of the changes made
/// from within itself. Modifying the composing region from within a listener
/// can also have a bad interaction with some input methods. Gboard, for
/// example, will try to restore the composing region of the text if it was
/// modified programmatically, creating an infinite loop of communications
/// between the framework and the input method. Consider using
/// [TextInputFormatter]s instead for as-you-type text modification.
///
/// If both the [text] or [selection] properties need to be changed, set the
/// controller's [value] instead.
///
/// Remember to [dispose] of the [TextEditingController] when it is no longer
/// needed. This will ensure we discard any resources used by the object.
/// {@tool dartpad}
/// This example creates a [TextField] with a [TextEditingController] whose
/// change listener forces the entered text to be lower case and keeps the
/// cursor at the end of the input.
///
/// ** See code in examples/api/lib/widgets/editable_text/text_editing_controller.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [TextField], which is a Material Design text field that can be controlled
/// with a [TextEditingController].
/// * [EditableText], which is a raw region of editable text that can be
/// controlled with a [TextEditingController].
/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://flutter.dev/docs/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller).
class TextEditingController extends ValueNotifier<TextEditingValue> {
/// Creates a controller for an editable text field.
///
/// This constructor treats a null [text] argument as if it were the empty
/// string.
TextEditingController({ String? text })
: super(text == null ? TextEditingValue.empty : TextEditingValue(text: text));
/// Creates a controller for an editable text field from an initial [TextEditingValue].
///
/// This constructor treats a null [value] argument as if it were
/// [TextEditingValue.empty].
TextEditingController.fromValue(TextEditingValue? value)
: assert(
value == null || !value.composing.isValid || value.isComposingRangeValid,
'New TextEditingValue $value has an invalid non-empty composing range '
'${value.composing}. It is recommended to use a valid composing range, '
'even for readonly text fields',
),
super(value ?? TextEditingValue.empty);
/// The current string the user is editing.
String get text => value.text;
/// Setting this will notify all the listeners of this [TextEditingController]
/// that they need to update (it calls [notifyListeners]). For this reason,
/// this value should only be set between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases.
///
/// This property can be set from a listener added to this
/// [TextEditingController]; however, one should not also set [selection]
/// in a separate statement. To change both the [text] and the [selection]
/// change the controller's [value].
set text(String newText) {
value = value.copyWith(
text: newText,
selection: const TextSelection.collapsed(offset: -1),
composing: TextRange.empty,
);
}
@override
set value(TextEditingValue newValue) {
assert(
!newValue.composing.isValid || newValue.isComposingRangeValid,
'New TextEditingValue $newValue has an invalid non-empty composing range '
'${newValue.composing}. It is recommended to use a valid composing range, '
'even for readonly text fields',
);
super.value = newValue;
}
/// Builds [TextSpan] from current editing value.
///
/// By default makes text in composing range appear as underlined. Descendants
/// can override this method to customize appearance of text.
TextSpan buildTextSpan({required BuildContext context, TextStyle? style , required bool withComposing}) {
assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid);
// If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree.
if (!value.isComposingRangeValid || !withComposing) {
return TextSpan(style: style, text: text);
}
final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
?? const TextStyle(decoration: TextDecoration.underline);
return TextSpan(
style: style,
children: <TextSpan>[
TextSpan(text: value.composing.textBefore(value.text)),
TextSpan(
style: composingStyle,
text: value.composing.textInside(value.text),
),
TextSpan(text: value.composing.textAfter(value.text)),
],
);
}
/// The currently selected [text].
///
/// If the selection is collapsed, then this property gives the offset of the
/// cursor within the text.
TextSelection get selection => value.selection;
/// Setting this will notify all the listeners of this [TextEditingController]
/// that they need to update (it calls [notifyListeners]). For this reason,
/// this value should only be set between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases.
///
/// This property can be set from a listener added to this
/// [TextEditingController]; however, one should not also set [text]
/// in a separate statement. To change both the [text] and the [selection]
/// change the controller's [value].
///
/// If the new selection is of non-zero length, or is outside the composing
/// range, the composing range is cleared.
set selection(TextSelection newSelection) {
if (!isSelectionWithinTextBounds(newSelection)) {
throw FlutterError('invalid text selection: $newSelection');
}
final TextRange newComposing =
newSelection.isCollapsed && _isSelectionWithinComposingRange(newSelection)
? value.composing
: TextRange.empty;
value = value.copyWith(selection: newSelection, composing: newComposing);
}
/// Set the [value] to empty.
///
/// After calling this function, [text] will be the empty string and the
/// selection will be collapsed at zero offset.
///
/// Calling this will notify all the listeners of this [TextEditingController]
/// that they need to update (it calls [notifyListeners]). For this reason,
/// this method should only be called between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases.
void clear() {
value = const TextEditingValue(selection: TextSelection.collapsed(offset: 0));
}
/// Set the composing region to an empty range.
///
/// The composing region is the range of text that is still being composed.
/// Calling this function indicates that the user is done composing that
/// region.
///
/// Calling this will notify all the listeners of this [TextEditingController]
/// that they need to update (it calls [notifyListeners]). For this reason,
/// this method should only be called between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases.
void clearComposing() {
value = value.copyWith(composing: TextRange.empty);
}
/// Check that the [selection] is inside of the bounds of [text].
bool isSelectionWithinTextBounds(TextSelection selection) {
return selection.start <= text.length && selection.end <= text.length;
}
/// Check that the [selection] is inside of the composing range.
bool _isSelectionWithinComposingRange(TextSelection selection) {
return selection.start >= value.composing.start && selection.end <= value.composing.end;
}
}
/// Toolbar configuration for [EditableText].
///
/// Toolbar is a context menu that will show up when user right click or long
/// press the [EditableText]. It includes several options: cut, copy, paste,
/// and select all.
///
/// [EditableText] and its derived widgets have their own default [ToolbarOptions].
/// Create a custom [ToolbarOptions] if you want explicit control over the toolbar
/// option.
class ToolbarOptions {
/// Create a toolbar configuration with given options.
///
/// All options default to false if they are not explicitly set.
const ToolbarOptions({
this.copy = false,
this.cut = false,
this.paste = false,
this.selectAll = false,
}) : assert(copy != null),
assert(cut != null),
assert(paste != null),
assert(selectAll != null);
/// Whether to show copy option in toolbar.
///
/// Defaults to false. Must not be null.
final bool copy;
/// Whether to show cut option in toolbar.
///
/// If [EditableText.readOnly] is set to true, cut will be disabled regardless.
///
/// Defaults to false. Must not be null.
final bool cut;
/// Whether to show paste option in toolbar.
///
/// If [EditableText.readOnly] is set to true, paste will be disabled regardless.
///
/// Defaults to false. Must not be null.
final bool paste;
/// Whether to show select all option in toolbar.
///
/// Defaults to false. Must not be null.
final bool selectAll;
}
// A time-value pair that represents a key frame in an animation.
class _KeyFrame {
const _KeyFrame(this.time, this.value);
// Values extracted from iOS 15.4 UIKit.
static const List<_KeyFrame> iOSBlinkingCaretKeyFrames = <_KeyFrame>[
_KeyFrame(0, 1), // 0
_KeyFrame(0.5, 1), // 1
_KeyFrame(0.5375, 0.75), // 2
_KeyFrame(0.575, 0.5), // 3
_KeyFrame(0.6125, 0.25), // 4
_KeyFrame(0.65, 0), // 5
_KeyFrame(0.85, 0), // 6
_KeyFrame(0.8875, 0.25), // 7
_KeyFrame(0.925, 0.5), // 8
_KeyFrame(0.9625, 0.75), // 9
_KeyFrame(1, 1), // 10
];
// The timing, in seconds, of the specified animation `value`.
final double time;
final double value;
}
class _DiscreteKeyFrameSimulation extends Simulation {
_DiscreteKeyFrameSimulation.iOSBlinkingCaret() : this._(_KeyFrame.iOSBlinkingCaretKeyFrames, 1);
_DiscreteKeyFrameSimulation._(this._keyFrames, this.maxDuration)
: assert(_keyFrames.isNotEmpty),
assert(_keyFrames.last.time <= maxDuration),
assert(() {
for (int i = 0; i < _keyFrames.length -1; i += 1) {
if (_keyFrames[i].time > _keyFrames[i + 1].time) {
return false;
}
}
return true;
}(), 'The key frame sequence must be sorted by time.');
final double maxDuration;
final List<_KeyFrame> _keyFrames;
@override
double dx(double time) => 0;
@override
bool isDone(double time) => time >= maxDuration;
// The index of the KeyFrame corresponds to the most recent input `time`.
int _lastKeyFrameIndex = 0;
@override
double x(double time) {
final int length = _keyFrames.length;
// Perform a linear search in the sorted key frame list, starting from the
// last key frame found, since the input `time` usually monotonically
// increases by a small amount.
int searchIndex;
final int endIndex;
if (_keyFrames[_lastKeyFrameIndex].time > time) {
// The simulation may have restarted. Search within the index range
// [0, _lastKeyFrameIndex).
searchIndex = 0;
endIndex = _lastKeyFrameIndex;
} else {
searchIndex = _lastKeyFrameIndex;
endIndex = length;
}
// Find the target key frame. Don't have to check (endIndex - 1): if
// (endIndex - 2) doesn't work we'll have to pick (endIndex - 1) anyways.
while (searchIndex < endIndex - 1) {
assert(_keyFrames[searchIndex].time <= time);
final _KeyFrame next = _keyFrames[searchIndex + 1];
if (time < next.time) {
break;
}
searchIndex += 1;
}
_lastKeyFrameIndex = searchIndex;
return _keyFrames[_lastKeyFrameIndex].value;
}
}
/// A basic text input field.
///
/// This widget interacts with the [TextInput] service to let the user edit the
/// text it contains. It also provides scrolling, selection, and cursor
/// movement. This widget does not provide any focus management (e.g.,
/// 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
///
/// A [TextInputAction] can be provided to customize the appearance of the
/// action button on the soft keyboard for Android and iOS. The default action
/// is [TextInputAction.done].
///
/// Many [TextInputAction]s are common between Android and iOS. However, if a
/// [textInputAction] is provided that is not supported by the current
/// platform in debug mode, an error will be thrown when the corresponding
/// EditableText receives focus. For example, providing iOS's "emergencyCall"
/// action when running on an Android device will result in an error when in
/// debug mode. In release mode, incompatible [TextInputAction]s are replaced
/// either with "unspecified" on Android, or "default" on iOS. Appropriate
/// [textInputAction]s can be chosen by checking the current platform and then
/// selecting the appropriate action.
///
/// {@template flutter.widgets.EditableText.lifeCycle}
/// ## Lifecycle
///
/// Upon completion of editing, like pressing the "done" button on the keyboard,
/// two actions take place:
///
/// 1st: Editing is finalized. The default behavior of this step includes
/// an invocation of [onChanged]. That default behavior can be overridden.
/// See [onEditingComplete] for details.
///
/// 2nd: [onSubmitted] is invoked with the user's input value.
///
/// [onSubmitted] can be used to manually move focus to another input widget
/// when a user finishes with the currently focused input widget.
///
/// When the widget has focus, it will prevent itself from disposing via
/// [AutomaticKeepAliveClientMixin.wantKeepAlive] in order to avoid losing the
/// selection. Removing the focus will allow it to be disposed.
/// {@endtemplate}
///
/// Rather than using this widget directly, consider using [TextField], which
/// is a full-featured, material-design text input field with placeholder text,
/// labels, and [Form] integration.
///
/// ## Text Editing [Intent]s and Their Default [Action]s
///
/// This widget provides default [Action]s for handling common text editing
/// [Intent]s such as deleting, copying and pasting in the text field. These
/// [Action]s can be directly invoked using [Actions.invoke] or the
/// [Actions.maybeInvoke] method. The default text editing keyboard [Shortcuts]
/// also use these [Intent]s and [Action]s to perform the text editing
/// operations they are bound to.
///
/// The default handling of a specific [Intent] can be overridden by placing an
/// [Actions] widget above this widget. See the [Action] class and the
/// [Action.overridable] constructor for more information on how a pre-defined
/// overridable [Action] can be overridden.
///
/// ### Intents for Deleting Text and Their Default Behavior
///
/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a [caret](https://en.wikipedia.org/wiki/Caret_navigation) (The selection is [TextSelection.collapsed])** |
/// | :------------------------------- | :--------------------------------------------------- | :----------------------------------------------------------------------- |
/// | [DeleteCharacterIntent] | Deletes the selected text | Deletes the user-perceived character before or after the caret location. |
/// | [DeleteToNextWordBoundaryIntent] | Deletes the selected text and the word before/after the selection's [TextSelection.extent] position | Deletes from the caret location to the previous or the next word boundary |
/// | [DeleteToLineBreakIntent] | Deletes the selected text, and deletes to the start/end of the line from the selection's [TextSelection.extent] position | Deletes from the caret location to the logical start or end of the current line |
///
/// ### Intents for Moving the [Caret](https://en.wikipedia.org/wiki/Caret_navigation)
///
/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** |
/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- |
/// | [ExtendSelectionByCharacterIntent](`collapseSelection: true`) | Collapses the selection to the logical start/end of the selection | Moves the caret past the user-perceived character before or after the current caret location. |
/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position | Moves the caret to the previous/next word boundary. |
/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position, or [TextSelection.base], whichever is closest in the given direction | Moves the caret to the previous/next word boundary. |
/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the line at the selection's [TextSelection.extent] position | Moves the caret to the start/end of the current line .|
/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent line | Moves the caret to the closest position on the previous/next adjacent line. |
/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the document | Moves the caret to the start/end of the document. |
///
/// #### Intents for Extending the Selection
///
/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** |
/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- |
/// | [ExtendSelectionByCharacterIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] past the user-perceived character before/after it |
/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary |
/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary, or [TextSelection.base] whichever is closest in the given direction | Moves the selection's [TextSelection.extent] to the previous/next word boundary. |
/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the line |
/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent line |
/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the document |
/// | [SelectAllTextIntent] | Selects the entire document |
///
/// ### Other Intents
///
/// | **Intent Class** | **Default Behavior** |
/// | :-------------------------------------- | :--------------------------------------------------- |
/// | [DoNothingAndStopPropagationTextIntent] | Does nothing in the input field, and prevents the key event from further propagating in the widget tree. |
/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [TextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. |
/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [TextEditingController], and triggers the [onSelectionChanged] callback. |
/// | [CopySelectionTextIntent] | Copies or cuts the selected text into the clipboard |
/// | [PasteTextIntent] | Inserts the current text in the clipboard after the caret location, or replaces the selected text if the selection is not collapsed. |
///
/// ## Gesture Events Handling
///
/// This widget provides rudimentary, platform-agnostic gesture handling for
/// user actions such as tapping, long-pressing and scrolling when
/// [rendererIgnoresPointer] is false (false by default). To tightly conform
/// to the platform behavior with respect to input gestures in text fields, use
/// [TextField] or [CupertinoTextField]. For custom selection behavior, call
/// methods such as [RenderEditable.selectPosition],
/// [RenderEditable.selectWord], etc. programmatically.
///
/// {@template flutter.widgets.editableText.showCaretOnScreen}
/// ## Keep the caret visible when focused
///
/// When focused, this widget will make attempts to keep the text area and its
/// caret (even when [showCursor] is `false`) visible, on these occasions:
///
/// * When the user focuses this text field and it is not [readOnly].
/// * When the user changes the selection of the text field, or changes the
/// text when the text field is not [readOnly].
/// * When the virtual keyboard pops up.
/// {@endtemplate}
///
/// {@template flutter.widgets.editableText.accessibility}
/// ## Troubleshooting Common Accessibility Issues
///
/// ### Customizing User Input Accessibility Announcements
///
/// To customize user input accessibility announcements triggered by text
/// changes, use [SemanticsService.announce] to make the desired
/// accessibility announcement.
///
/// On iOS, the on-screen keyboard may announce the most recent input
/// incorrectly when a [TextInputFormatter] inserts a thousands separator to
/// a currency value text field. The following example demonstrates how to
/// suppress the default accessibility announcements by always announcing
/// the content of the text field as a US currency value:
/// ```dart
/// onChanged: (String newText) {
/// if (newText.isNotEmpty) {
/// SemanticsService.announce('\$' + newText, Directionality.of(context));
/// }
/// }
/// ```
///
/// {@endtemplate}
///
/// See also:
///
/// * [TextField], which is a full-featured, material-design text input field
/// with placeholder text, labels, and [Form] integration.
class EditableText extends StatefulWidget {
/// Creates a basic text input control.
///
/// The [maxLines] property can be set to null to remove the restriction on
/// the number of lines. By default, it is one, meaning this is a single-line
/// text field. [maxLines] must be null or greater than zero.
///
/// If [keyboardType] is not set or is null, its value will be inferred from
/// [autofillHints], if [autofillHints] is not empty. Otherwise it defaults to
/// [TextInputType.text] if [maxLines] is exactly one, and
/// [TextInputType.multiline] if [maxLines] is null or greater than one.
///
/// The text cursor is not shown if [showCursor] is false or if [showCursor]
/// is null (the default) and [readOnly] is true.
///
/// The [controller], [focusNode], [obscureText], [autocorrect], [autofocus],
/// [showSelectionHandles], [enableInteractiveSelection], [forceLine],
/// [style], [cursorColor], [cursorOpacityAnimates],[backgroundCursorColor],
/// [enableSuggestions], [paintCursorAboveText], [selectionHeightStyle],
/// [selectionWidthStyle], [textAlign], [dragStartBehavior], [scrollPadding],
/// [dragStartBehavior], [toolbarOptions], [rendererIgnoresPointer],
/// [readOnly], and [enableIMEPersonalizedLearning] arguments must not be null.
EditableText({
super.key,
required this.controller,
required this.focusNode,
this.readOnly = false,
this.obscuringCharacter = '•',
this.obscureText = false,
this.autocorrect = true,
SmartDashesType? smartDashesType,
SmartQuotesType? smartQuotesType,
this.enableSuggestions = true,
required this.style,
StrutStyle? strutStyle,
required this.cursorColor,
required this.backgroundCursorColor,
this.textAlign = TextAlign.start,
this.textDirection,
this.locale,
this.textScaleFactor,
this.maxLines = 1,
this.minLines,
this.expands = false,
this.forceLine = true,
this.textHeightBehavior,
this.textWidthBasis = TextWidthBasis.parent,
this.autofocus = false,
bool? showCursor,
this.showSelectionHandles = false,
this.selectionColor,
this.selectionControls,
TextInputType? keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.onChanged,
this.onEditingComplete,
this.onSubmitted,
this.onAppPrivateCommand,
this.onSelectionChanged,
this.onSelectionHandleTapped,
List<TextInputFormatter>? inputFormatters,
this.mouseCursor,
this.rendererIgnoresPointer = false,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorOpacityAnimates = false,
this.cursorOffset,
this.paintCursorAboveText = false,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.scrollPadding = const EdgeInsets.all(20.0),
this.keyboardAppearance = Brightness.light,
this.dragStartBehavior = DragStartBehavior.start,
bool? enableInteractiveSelection,
this.scrollController,
this.scrollPhysics,
this.autocorrectionTextRectColor,
ToolbarOptions? toolbarOptions,
this.autofillHints = const <String>[],
this.autofillClient,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.scrollBehavior,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
}) : assert(controller != null),
assert(focusNode != null),
assert(obscuringCharacter != null && obscuringCharacter.length == 1),
assert(obscureText != null),
assert(autocorrect != null),
smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(enableSuggestions != null),
assert(showSelectionHandles != null),
assert(readOnly != null),
assert(forceLine != null),
assert(style != null),
assert(cursorColor != null),
assert(cursorOpacityAnimates != null),
assert(paintCursorAboveText != null),
assert(backgroundCursorColor != null),
assert(selectionHeightStyle != null),
assert(selectionWidthStyle != null),
assert(textAlign != null),
assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0),
assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
"minLines can't be greater than maxLines",
),
assert(expands != null),
assert(
!expands || (maxLines == null && minLines == null),
'minLines and maxLines must be null when expands is true.',
),
assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'),
assert(autofocus != null),
assert(rendererIgnoresPointer != null),
assert(scrollPadding != null),
assert(dragStartBehavior != null),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
toolbarOptions = toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
? const ToolbarOptions()
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
paste: true,
))
: (readOnly
// Read-only, not obscured.
? const ToolbarOptions(
selectAll: true,
copy: true,
)
// Writable, not obscured.
: const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
))),
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
_strutStyle = strutStyle,
keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines),
inputFormatters = maxLines == 1
? <TextInputFormatter>[
FilteringTextInputFormatter.singleLineFormatter,
...inputFormatters ?? const Iterable<TextInputFormatter>.empty(),
]
: inputFormatters,
showCursor = showCursor ?? !readOnly;
/// Controls the text being edited.
final TextEditingController controller;
/// Controls whether this widget has keyboard focus.
final FocusNode focusNode;
/// {@template flutter.widgets.editableText.obscuringCharacter}
/// Character used for obscuring text if [obscureText] is true.
///
/// Must be only a single character.
///
/// Defaults to the character U+2022 BULLET (•).
/// {@endtemplate}
final String obscuringCharacter;
/// {@template flutter.widgets.editableText.obscureText}
/// Whether to hide the text being edited (e.g., for passwords).
///
/// When this is set to true, all the characters in the text field are
/// replaced by [obscuringCharacter], and the text in the field cannot be
/// copied with copy or cut. If [readOnly] is also true, then the text cannot
/// be selected.
///
/// Defaults to false. Cannot be null.
/// {@endtemplate}
final bool obscureText;
/// {@macro dart.ui.textHeightBehavior}
final TextHeightBehavior? textHeightBehavior;
/// {@macro flutter.painting.textPainter.textWidthBasis}
final TextWidthBasis textWidthBasis;
/// {@template flutter.widgets.editableText.readOnly}
/// Whether the text can be changed.
///
/// When this is set to true, the text cannot be modified
/// by any shortcut or keyboard operation. The text is still selectable.
///
/// Defaults to false. Must not be null.
/// {@endtemplate}
final bool readOnly;
/// Whether the text will take the full width regardless of the text width.
///
/// When this is set to false, the width will be based on text width, which
/// will also be affected by [textWidthBasis].
///
/// Defaults to true. Must not be null.
///
/// See also:
///
/// * [textWidthBasis], which controls the calculation of text width.
final bool forceLine;
/// Configuration of toolbar options.
///
/// By default, all options are enabled. If [readOnly] is true, paste and cut
/// will be disabled regardless. If [obscureText] is true, cut and copy will
/// be disabled regardless. If [readOnly] and [obscureText] are both true,
/// select all will also be disabled.
final ToolbarOptions toolbarOptions;
/// Whether to show selection handles.
///
/// When a selection is active, there will be two handles at each side of
/// boundary, or one handle if the selection is collapsed. The handles can be
/// dragged to adjust the selection.
///
/// See also:
///
/// * [showCursor], which controls the visibility of the cursor.
final bool showSelectionHandles;
/// {@template flutter.widgets.editableText.showCursor}
/// Whether to show cursor.
///
/// The cursor refers to the blinking caret when the [EditableText] is focused.
/// {@endtemplate}
///
/// See also:
///
/// * [showSelectionHandles], which controls the visibility of the selection handles.
final bool showCursor;
/// {@template flutter.widgets.editableText.autocorrect}
/// Whether to enable autocorrection.
///
/// Defaults to true. Cannot be null.
/// {@endtemplate}
final bool autocorrect;
/// {@macro flutter.services.TextInputConfiguration.smartDashesType}
final SmartDashesType smartDashesType;
/// {@macro flutter.services.TextInputConfiguration.smartQuotesType}
final SmartQuotesType smartQuotesType;
/// {@macro flutter.services.TextInputConfiguration.enableSuggestions}
final bool enableSuggestions;
/// The text style to use for the editable text.
final TextStyle style;
/// {@template flutter.widgets.editableText.strutStyle}
/// The strut style used for the vertical layout.
///
/// [StrutStyle] is used to establish a predictable vertical layout.
/// Since fonts may vary depending on user input and due to font
/// fallback, [StrutStyle.forceStrutHeight] is enabled by default
/// to lock all lines to the height of the base [TextStyle], provided by
/// [style]. This ensures the typed text fits within the allotted space.
///
/// If null, the strut used will inherit values from the [style] and will
/// have [StrutStyle.forceStrutHeight] set to true. When no [style] is
/// passed, the theme's [TextStyle] will be used to generate [strutStyle]
/// instead.
///
/// To disable strut-based vertical alignment and allow dynamic vertical
/// layout based on the glyphs typed, use [StrutStyle.disabled].
///
/// Flutter's strut is based on [typesetting strut](https://en.wikipedia.org/wiki/Strut_(typesetting))
/// and CSS's [line-height](https://www.w3.org/TR/CSS2/visudet.html#line-height).
/// {@endtemplate}
///
/// Within editable text and text fields, [StrutStyle] will not use its standalone
/// default values, and will instead inherit omitted/null properties from the
/// [TextStyle] instead. See [StrutStyle.inheritFromTextStyle].
StrutStyle get strutStyle {
if (_strutStyle == null) {
return StrutStyle.fromTextStyle(style, forceStrutHeight: true);
}
return _strutStyle!.inheritFromTextStyle(style);
}
final StrutStyle? _strutStyle;
/// {@template flutter.widgets.editableText.textAlign}
/// How the text should be aligned horizontally.
///
/// Defaults to [TextAlign.start] and cannot be null.
/// {@endtemplate}
final TextAlign textAlign;
/// {@template flutter.widgets.editableText.textDirection}
/// The directionality of the text.
///
/// This decides how [textAlign] values like [TextAlign.start] and
/// [TextAlign.end] are interpreted.
///
/// This is also used to disambiguate how to render bidirectional text. For
/// example, if the text is an English phrase followed by a Hebrew phrase,
/// in a [TextDirection.ltr] context the English phrase will be on the left
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
/// context, the English phrase will be on the right and the Hebrew phrase on
/// its left.
///
/// Defaults to the ambient [Directionality], if any.
/// {@endtemplate}
final TextDirection? textDirection;
/// {@template flutter.widgets.editableText.textCapitalization}
/// Configures how the platform keyboard will select an uppercase or
/// lowercase keyboard.
///
/// Only supports text keyboards, other keyboard types will ignore this
/// configuration. Capitalization is locale-aware.
///
/// Defaults to [TextCapitalization.none]. Must not be null.
///
/// See also:
///
/// * [TextCapitalization], for a description of each capitalization behavior.
///
/// {@endtemplate}
final TextCapitalization textCapitalization;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale.
///
/// It's rarely necessary to set this property. By default its value
/// is inherited from the enclosing app with `Localizations.localeOf(context)`.
///
/// See [RenderEditable.locale] for more information.
final Locale? locale;
/// {@template flutter.widgets.editableText.textScaleFactor}
/// The number of font pixels for each logical pixel.
///
/// For example, if the text scale factor is 1.5, text will be 50% larger than
/// the specified font size.
///
/// Defaults to the [MediaQueryData.textScaleFactor] obtained from the ambient
/// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope.
/// {@endtemplate}
final double? textScaleFactor;
/// The color to use when painting the cursor.
///
/// Cannot be null.
final Color cursorColor;
/// The color to use when painting the autocorrection Rect.
///
/// For [CupertinoTextField]s, the value is set to the ambient
/// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the
/// value is null on non-iOS platforms and the same color used in [CupertinoTextField]
/// on iOS.
///
/// Currently the autocorrection Rect only appears on iOS.
///
/// Defaults to null, which disables autocorrection Rect painting.
final Color? autocorrectionTextRectColor;
/// The color to use when painting the background cursor aligned with the text
/// while rendering the floating cursor.
///
/// Cannot be null. By default it is the disabled grey color from
/// CupertinoColors.
final Color backgroundCursorColor;
/// {@template flutter.widgets.editableText.maxLines}
/// The maximum number of lines to show at one time, wrapping if necessary.
///
/// This affects the height of the field itself and does not limit the number
/// of lines that can be entered into the field.
///
/// If this is 1 (the default), the text will not wrap, but will scroll
/// horizontally instead.
///
/// If this is null, there is no limit to the number of lines, and the text
/// container will start with enough vertical space for one line and
/// automatically grow to accommodate additional lines as they are entered, up
/// to the height of its constraints.
///
/// If this is not null, the value must be greater than zero, and it will lock
/// the input to the given number of lines and take up enough horizontal space
/// to accommodate that number of lines. Setting [minLines] as well allows the
/// input to grow and shrink between the indicated range.
///
/// The full set of behaviors possible with [minLines] and [maxLines] are as
/// follows. These examples apply equally to [TextField], [TextFormField],
/// [CupertinoTextField], and [EditableText].
///
/// Input that occupies a single line and scrolls horizontally as needed.
/// ```dart
/// TextField()
/// ```
///
/// Input whose height grows from one line up to as many lines as needed for
/// the text that was entered. If a height limit is imposed by its parent, it
/// will scroll vertically when its height reaches that limit.
/// ```dart
/// TextField(maxLines: null)
/// ```
///
/// The input's height is large enough for the given number of lines. If
/// additional lines are entered the input scrolls vertically.
/// ```dart
/// TextField(maxLines: 2)
/// ```
///
/// Input whose height grows with content between a min and max. An infinite
/// max is possible with `maxLines: null`.
/// ```dart
/// TextField(minLines: 2, maxLines: 4)
/// ```
///
/// See also:
///
/// * [minLines], which sets the minimum number of lines visible.
/// {@endtemplate}
/// * [expands], which determines whether the field should fill the height of
/// its parent.
final int? maxLines;
/// {@template flutter.widgets.editableText.minLines}
/// The minimum number of lines to occupy when the content spans fewer lines.
///
/// This affects the height of the field itself and does not limit the number
/// of lines that can be entered into the field.
///
/// If this is null (default), text container starts with enough vertical space
/// for one line and grows to accommodate additional lines as they are entered.
///
/// This can be used in combination with [maxLines] for a varying set of behaviors.
///
/// If the value is set, it must be greater than zero. If the value is greater
/// than 1, [maxLines] should also be set to either null or greater than
/// this value.
///
/// When [maxLines] is set as well, the height will grow between the indicated
/// range of lines. When [maxLines] is null, it will grow as high as needed,
/// starting from [minLines].
///
/// A few examples of behaviors possible with [minLines] and [maxLines] are as follows.
/// These apply equally to [TextField], [TextFormField], [CupertinoTextField],
/// and [EditableText].
///
/// Input that always occupies at least 2 lines and has an infinite max.
/// Expands vertically as needed.
/// ```dart
/// TextField(minLines: 2)
/// ```
///
/// Input whose height starts from 2 lines and grows up to 4 lines at which
/// point the height limit is reached. If additional lines are entered it will
/// scroll vertically.
/// ```dart
/// TextField(minLines:2, maxLines: 4)
/// ```
///
/// Defaults to null.
///
/// See also:
///
/// * [maxLines], which sets the maximum number of lines visible, and has
/// several examples of how minLines and maxLines interact to produce
/// various behaviors.
/// {@endtemplate}
/// * [expands], which determines whether the field should fill the height of
/// its parent.
final int? minLines;
/// {@template flutter.widgets.editableText.expands}
/// Whether this widget's height will be sized to fill its parent.
///
/// If set to true and wrapped in a parent widget like [Expanded] or
/// [SizedBox], the input will expand to fill the parent.
///
/// [maxLines] and [minLines] must both be null when this is set to true,
/// otherwise an error is thrown.
///
/// Defaults to false.
///
/// See the examples in [maxLines] for the complete picture of how [maxLines],
/// [minLines], and [expands] interact to produce various behaviors.
///
/// Input that matches the height of its parent:
/// ```dart
/// Expanded(
/// child: TextField(maxLines: null, expands: true),
/// )
/// ```
/// {@endtemplate}
final bool expands;
/// {@template flutter.widgets.editableText.autofocus}
/// Whether this text field should focus itself if nothing else is already
/// focused.
///
/// If true, the keyboard will open as soon as this text field obtains focus.
/// Otherwise, the keyboard is only shown after the user taps the text field.
///
/// Defaults to false. Cannot be null.
/// {@endtemplate}
// See https://github.com/flutter/flutter/issues/7035 for the rationale for this
// keyboard behavior.
final bool autofocus;
/// The color to use when painting the selection.
///
/// If this property is null, this widget gets the selection color from the
/// [DefaultSelectionStyle].
///
/// For [CupertinoTextField]s, the value is set to the ambient
/// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the
/// value is set to the ambient [TextSelectionThemeData.selectionColor].
final Color? selectionColor;
/// {@template flutter.widgets.editableText.selectionControls}
/// Optional delegate for building the text selection handles and toolbar.
///
/// The [EditableText] widget used on its own will not trigger the display
/// of the selection toolbar by itself. The toolbar is shown by calling
/// [EditableTextState.showToolbar] in response to an appropriate user event.
///
/// See also:
///
/// * [CupertinoTextField], which wraps an [EditableText] and which shows the
/// selection toolbar upon user events that are appropriate on the iOS
/// platform.
/// * [TextField], a Material Design themed wrapper of [EditableText], which
/// shows the selection toolbar upon appropriate user events based on the
/// user's platform set in [ThemeData.platform].
/// {@endtemplate}
final TextSelectionControls? selectionControls;
/// {@template flutter.widgets.editableText.keyboardType}
/// The type of keyboard to use for editing the text.
///
/// Defaults to [TextInputType.text] if [maxLines] is one and
/// [TextInputType.multiline] otherwise.
/// {@endtemplate}
final TextInputType keyboardType;
/// The type of action button to use with the soft keyboard.
final TextInputAction? textInputAction;
/// {@template flutter.widgets.editableText.onChanged}
/// Called when the user initiates a change to the TextField's
/// value: when they have inserted or deleted text.
///
/// This callback doesn't run when the TextField's text is changed
/// programmatically, via the TextField's [controller]. Typically it
/// isn't necessary to be notified of such changes, since they're
/// initiated by the app itself.
///
/// To be notified of all changes to the TextField's text, cursor,
/// and selection, one can add a listener to its [controller] with
/// [TextEditingController.addListener].
///
/// [onChanged] is called before [onSubmitted] when user indicates completion
/// of editing, such as when pressing the "done" button on the keyboard. That default
/// behavior can be overridden. See [onEditingComplete] for details.
///
/// {@tool dartpad}
/// This example shows how onChanged could be used to check the TextField's
/// current value each time the user inserts or deletes a character.
///
/// ** See code in examples/api/lib/widgets/editable_text/editable_text.on_changed.0.dart **
/// {@end-tool}
/// {@endtemplate}
///
/// ## Handling emojis and other complex characters
/// {@template flutter.widgets.EditableText.onChanged}
/// It's important to always use
/// [characters](https://pub.dev/packages/characters) when dealing with user
/// input text that may contain complex characters. This will ensure that
/// extended grapheme clusters and surrogate pairs are treated as single
/// characters, as they appear to the user.
///
/// For example, when finding the length of some user input, use
/// `string.characters.length`. Do NOT use `string.length` or even
/// `string.runes.length`. For the complex character "👨‍👩‍👦", this
/// appears to the user as a single character, and `string.characters.length`
/// intuitively returns 1. On the other hand, `string.length` returns 8, and
/// `string.runes.length` returns 5!
/// {@endtemplate}
///
/// See also:
///
/// * [inputFormatters], which are called before [onChanged]
/// runs and can validate and change ("format") the input value.
/// * [onEditingComplete], [onSubmitted], [onSelectionChanged]:
/// which are more specialized input change notifications.
final ValueChanged<String>? onChanged;
/// {@template flutter.widgets.editableText.onEditingComplete}
/// Called when the user submits editable content (e.g., user presses the "done"
/// button on the keyboard).
///
/// The default implementation of [onEditingComplete] executes 2 different
/// behaviors based on the situation:
///
/// - When a completion action is pressed, such as "done", "go", "send", or
/// "search", the user's content is submitted to the [controller] and then
/// focus is given up.
///
/// - When a non-completion action is pressed, such as "next" or "previous",
/// the user's content is submitted to the [controller], but focus is not
/// given up because developers may want to immediately move focus to
/// another input widget within [onSubmitted].
///
/// Providing [onEditingComplete] prevents the aforementioned default behavior.
/// {@endtemplate}
final VoidCallback? onEditingComplete;
/// {@template flutter.widgets.editableText.onSubmitted}
/// Called when the user indicates that they are done editing the text in the
/// field.
///
/// By default, [onSubmitted] is called after [onChanged] when the user
/// has finalized editing; or, if the default behavior has been overridden,
/// after [onEditingComplete]. See [onEditingComplete] for details.
/// {@endtemplate}
final ValueChanged<String>? onSubmitted;
/// {@template flutter.widgets.editableText.onAppPrivateCommand}
/// This is used to receive a private command from the input method.
///
/// Called when the result of [TextInputClient.performPrivateCommand] is
/// received.
///
/// This can be used to provide domain-specific features that are only known
/// between certain input methods and their clients.
///
/// See also:
/// * [performPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputConnection#performPrivateCommand\(java.lang.String,%20android.os.Bundle\)),
/// which is the Android documentation for performPrivateCommand, used to
/// send a command from the input method.
/// * [sendAppPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#sendAppPrivateCommand),
/// which is the Android documentation for sendAppPrivateCommand, used to
/// send a command to the input method.
/// {@endtemplate}
final AppPrivateCommandCallback? onAppPrivateCommand;
/// {@template flutter.widgets.editableText.onSelectionChanged}
/// Called when the user changes the selection of text (including the cursor
/// location).
/// {@endtemplate}
final SelectionChangedCallback? onSelectionChanged;
/// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
final VoidCallback? onSelectionHandleTapped;
/// {@template flutter.widgets.editableText.inputFormatters}
/// Optional input validation and formatting overrides.
///
/// 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}
final List<TextInputFormatter>? inputFormatters;
/// The cursor for a mouse pointer when it enters or is hovering over the
/// widget.
///
/// If this property is null, [SystemMouseCursors.text] will be used.
///
/// The [mouseCursor] is the only property of [EditableText] that controls the
/// appearance of the mouse pointer. All other properties related to "cursor"
/// stands for the text cursor, which is usually a blinking vertical line at
/// the editing position.
final MouseCursor? mouseCursor;
/// If true, the [RenderEditable] created by this widget will not handle
/// pointer events, see [RenderEditable] and [RenderEditable.ignorePointer].
///
/// This property is false by default.
final bool rendererIgnoresPointer;
/// {@template flutter.widgets.editableText.cursorWidth}
/// How thick the cursor will be.
///
/// Defaults to 2.0.
///
/// The cursor will draw under the text. The cursor width will extend
/// to the right of the boundary between characters for left-to-right text
/// and to the left for right-to-left text. This corresponds to extending
/// downstream relative to the selected position. Negative values may be used
/// to reverse this behavior.
/// {@endtemplate}
final double cursorWidth;
/// {@template flutter.widgets.editableText.cursorHeight}
/// How tall the cursor will be.
///
/// If this property is null, [RenderEditable.preferredLineHeight] will be used.
/// {@endtemplate}
final double? cursorHeight;
/// {@template flutter.widgets.editableText.cursorRadius}
/// How rounded the corners of the cursor should be.
///
/// By default, the cursor has no radius.
/// {@endtemplate}
final Radius? cursorRadius;
/// Whether the cursor will animate from fully transparent to fully opaque
/// during each cursor blink.
///
/// By default, the cursor opacity will animate on iOS platforms and will not
/// animate on Android platforms.
final bool cursorOpacityAnimates;
///{@macro flutter.rendering.RenderEditable.cursorOffset}
final Offset? cursorOffset;
///{@macro flutter.rendering.RenderEditable.paintCursorAboveText}
final bool paintCursorAboveText;
/// Controls how tall the selection highlight boxes are computed to be.
///
/// See [ui.BoxHeightStyle] for details on available styles.
final ui.BoxHeightStyle selectionHeightStyle;
/// Controls how wide the selection highlight boxes are computed to be.
///
/// See [ui.BoxWidthStyle] for details on available styles.
final ui.BoxWidthStyle selectionWidthStyle;
/// The appearance of the keyboard.
///
/// This setting is only honored on iOS devices.
///
/// Defaults to [Brightness.light].
final Brightness keyboardAppearance;
/// {@template flutter.widgets.editableText.scrollPadding}
/// Configures padding to edges surrounding a [Scrollable] when the Textfield scrolls into view.
///
/// When this widget receives focus and is not completely visible (for example scrolled partially
/// off the screen or overlapped by the keyboard)
/// then it will attempt to make itself visible by scrolling a surrounding [Scrollable], if one is present.
/// This value controls how far from the edges of a [Scrollable] the TextField will be positioned after the scroll.
///
/// Defaults to EdgeInsets.all(20.0).
/// {@endtemplate}
final EdgeInsets scrollPadding;
/// {@template flutter.widgets.editableText.enableInteractiveSelection}
/// Whether to enable user interface affordances for changing the
/// text selection.
///
/// For example, setting this to true will enable features such as
/// long-pressing the TextField to select text and show the
/// cut/copy/paste menu, and tapping to move the text caret.
///
/// When this is false, the text selection cannot be adjusted by
/// the user, text cannot be copied, and the user cannot paste into
/// the text field from the clipboard.
///
/// Defaults to true.
/// {@endtemplate}
final bool enableInteractiveSelection;
/// Setting this property to true makes the cursor stop blinking or fading
/// on and off once the cursor appears on focus. This property is useful for
/// testing purposes.
///
/// It does not affect the necessity to focus the EditableText for the cursor
/// to appear in the first place.
///
/// Defaults to false, resulting in a typical blinking cursor.
static bool debugDeterministicCursor = false;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.editableText.scrollController}
/// The [ScrollController] to use when vertically scrolling the input.
///
/// If null, it will instantiate a new ScrollController.
///
/// See [Scrollable.controller].
/// {@endtemplate}
final ScrollController? scrollController;
/// {@template flutter.widgets.editableText.scrollPhysics}
/// The [ScrollPhysics] to use when vertically scrolling the input.
///
/// If not specified, it will behave according to the current platform.
///
/// See [Scrollable.physics].
/// {@endtemplate}
///
/// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
/// [ScrollPhysics] provided by that behavior will take precedence after
/// [scrollPhysics].
final ScrollPhysics? scrollPhysics;
/// {@template flutter.widgets.editableText.scribbleEnabled}
/// Whether iOS 14 Scribble features are enabled for this widget.
///
/// Only available on iPads.
///
/// Defaults to true.
/// {@endtemplate}
final bool scribbleEnabled;
/// {@template flutter.widgets.editableText.selectionEnabled}
/// Same as [enableInteractiveSelection].
///
/// This getter exists primarily for consistency with
/// [RenderEditable.selectionEnabled].
/// {@endtemplate}
bool get selectionEnabled => enableInteractiveSelection;
/// {@template flutter.widgets.editableText.autofillHints}
/// A list of strings that helps the autofill service identify the type of this
/// text input.
///
/// When set to null, this text input will not send its autofill information
/// to the platform, preventing it from participating in autofills triggered
/// by a different [AutofillClient], even if they're in the same
/// [AutofillScope]. Additionally, on Android and web, setting this to null
/// will disable autofill for this text field.
///
/// The minimum platform SDK version that supports Autofill is API level 26
/// for Android, and iOS 10.0 for iOS.
///
/// Defaults to an empty list.
///
/// ### Setting up iOS autofill:
///
/// To provide the best user experience and ensure your app fully supports
/// password autofill on iOS, follow these steps:
///
/// * Set up your iOS app's
/// [associated domains](https://developer.apple.com/documentation/safariservices/supporting_associated_domains_in_your_app).
/// * Some autofill hints only work with specific [keyboardType]s. For example,
/// [AutofillHints.name] requires [TextInputType.name] and [AutofillHints.email]
/// works only with [TextInputType.emailAddress]. Make sure the input field has a
/// compatible [keyboardType]. Empirically, [TextInputType.name] works well
/// with many autofill hints that are predefined on iOS.
///
/// ### Troubleshooting Autofill
///
/// Autofill service providers rely heavily on [autofillHints]. Make sure the
/// entries in [autofillHints] are supported by the autofill service currently
/// in use (the name of the service can typically be found in your mobile
/// device's system settings).
///
/// #### Autofill UI refuses to show up when I tap on the text field
///
/// Check the device's system settings and make sure autofill is turned on,
/// and there are available credentials stored in the autofill service.
///
/// * iOS password autofill: Go to Settings -> Password, turn on "Autofill
/// Passwords", and add new passwords for testing by pressing the top right
/// "+" button. Use an arbitrary "website" if you don't have associated
/// domains set up for your app. As long as there's at least one password
/// stored, you should be able to see a key-shaped icon in the quick type
/// bar on the software keyboard, when a password related field is focused.
///
/// * iOS contact information autofill: iOS seems to pull contact info from
/// the Apple ID currently associated with the device. Go to Settings ->
/// Apple ID (usually the first entry, or "Sign in to your iPhone" if you
/// haven't set up one on the device), and fill out the relevant fields. If
/// you wish to test more contact info types, try adding them in Contacts ->
/// My Card.
///
/// * Android autofill: Go to Settings -> System -> Languages & input ->
/// Autofill service. Enable the autofill service of your choice, and make
/// sure there are available credentials associated with your app.
///
/// #### I called `TextInput.finishAutofillContext` but the autofill save
/// prompt isn't showing
///
/// * iOS: iOS may not show a prompt or any other visual indication when it
/// saves user password. Go to Settings -> Password and check if your new
/// password is saved. Neither saving password nor auto-generating strong
/// password works without properly setting up associated domains in your
/// app. To set up associated domains, follow the instructions in
/// <https://developer.apple.com/documentation/safariservices/supporting_associated_domains_in_your_app>.
///
/// {@endtemplate}
/// {@macro flutter.services.AutofillConfiguration.autofillHints}
final Iterable<String>? autofillHints;
/// The [AutofillClient] that controls this input field's autofill behavior.
///
/// When null, this widget's [EditableTextState] will be used as the
/// [AutofillClient]. This property may override [autofillHints].
final AutofillClient? autofillClient;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// Restoration ID to save and restore the scroll offset of the
/// [EditableText].
///
/// If a restoration id is provided, the [EditableText] will persist its
/// current scroll offset and restore it during state restoration.
///
/// The scroll offset is persisted in a [RestorationBucket] claimed from
/// the surrounding [RestorationScope] using the provided restoration ID.
///
/// Persisting and restoring the content of the [EditableText] is the
/// responsibility of the owner of the [controller], who may use a
/// [RestorableTextEditingController] for that purpose.
///
/// See also:
///
/// * [RestorationManager], which explains how state restoration works in
/// Flutter.
final String? restorationId;
/// {@template flutter.widgets.shadow.scrollBehavior}
/// A [ScrollBehavior] that will be applied to this widget individually.
///
/// Defaults to null, wherein the inherited [ScrollBehavior] is copied and
/// modified to alter the viewport decoration, like [Scrollbar]s.
/// {@endtemplate}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [scrollPhysics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
///
/// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be
/// modified by default to only apply a [Scrollbar] if [maxLines] is greater
/// than 1.
final ScrollBehavior? scrollBehavior;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
// Infer the keyboard type of an `EditableText` if it's not specified.
static TextInputType _inferKeyboardType({
required Iterable<String>? autofillHints,
required int? maxLines,
}) {
if (autofillHints == null || autofillHints.isEmpty) {
return maxLines == 1 ? TextInputType.text : TextInputType.multiline;
}
final String effectiveHint = autofillHints.first;
// On iOS oftentimes specifying a text content type is not enough to qualify
// the input field for autofill. The keyboard type also needs to be compatible
// with the content type. To get autofill to work by default on EditableText,
// the keyboard type inference on iOS is done differently from other platforms.
//
// The entries with "autofill not working" comments are the iOS text content
// types that should work with the specified keyboard type but won't trigger
// (even within a native app). Tested on iOS 13.5.
if (!kIsWeb) {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
const Map<String, TextInputType> iOSKeyboardType = <String, TextInputType> {
AutofillHints.addressCity : TextInputType.name,
AutofillHints.addressCityAndState : TextInputType.name, // Autofill not working.
AutofillHints.addressState : TextInputType.name,
AutofillHints.countryName : TextInputType.name,
AutofillHints.creditCardNumber : TextInputType.number, // Couldn't test.
AutofillHints.email : TextInputType.emailAddress,
AutofillHints.familyName : TextInputType.name,
AutofillHints.fullStreetAddress : TextInputType.name,
AutofillHints.givenName : TextInputType.name,
AutofillHints.jobTitle : TextInputType.name, // Autofill not working.
AutofillHints.location : TextInputType.name, // Autofill not working.
AutofillHints.middleName : TextInputType.name, // Autofill not working.
AutofillHints.name : TextInputType.name,
AutofillHints.namePrefix : TextInputType.name, // Autofill not working.
AutofillHints.nameSuffix : TextInputType.name, // Autofill not working.
AutofillHints.newPassword : TextInputType.text,
AutofillHints.newUsername : TextInputType.text,
AutofillHints.nickname : TextInputType.name, // Autofill not working.
AutofillHints.oneTimeCode : TextInputType.number,
AutofillHints.organizationName : TextInputType.text, // Autofill not working.
AutofillHints.password : TextInputType.text,
AutofillHints.postalCode : TextInputType.name,
AutofillHints.streetAddressLine1 : TextInputType.name,
AutofillHints.streetAddressLine2 : TextInputType.name, // Autofill not working.
AutofillHints.sublocality : TextInputType.name, // Autofill not working.
AutofillHints.telephoneNumber : TextInputType.name,
AutofillHints.url : TextInputType.url, // Autofill not working.
AutofillHints.username : TextInputType.text,
};
final TextInputType? keyboardType = iOSKeyboardType[effectiveHint];
if (keyboardType != null) {
return keyboardType;
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
break;
}
}
if (maxLines != 1) {
return TextInputType.multiline;
}
const Map<String, TextInputType> inferKeyboardType = <String, TextInputType> {
AutofillHints.addressCity : TextInputType.streetAddress,
AutofillHints.addressCityAndState : TextInputType.streetAddress,
AutofillHints.addressState : TextInputType.streetAddress,
AutofillHints.birthday : TextInputType.datetime,
AutofillHints.birthdayDay : TextInputType.datetime,
AutofillHints.birthdayMonth : TextInputType.datetime,
AutofillHints.birthdayYear : TextInputType.datetime,
AutofillHints.countryCode : TextInputType.number,
AutofillHints.countryName : TextInputType.text,
AutofillHints.creditCardExpirationDate : TextInputType.datetime,
AutofillHints.creditCardExpirationDay : TextInputType.datetime,
AutofillHints.creditCardExpirationMonth : TextInputType.datetime,
AutofillHints.creditCardExpirationYear : TextInputType.datetime,
AutofillHints.creditCardFamilyName : TextInputType.name,
AutofillHints.creditCardGivenName : TextInputType.name,
AutofillHints.creditCardMiddleName : TextInputType.name,
AutofillHints.creditCardName : TextInputType.name,
AutofillHints.creditCardNumber : TextInputType.number,
AutofillHints.creditCardSecurityCode : TextInputType.number,
AutofillHints.creditCardType : TextInputType.text,
AutofillHints.email : TextInputType.emailAddress,
AutofillHints.familyName : TextInputType.name,
AutofillHints.fullStreetAddress : TextInputType.streetAddress,
AutofillHints.gender : TextInputType.text,
AutofillHints.givenName : TextInputType.name,
AutofillHints.impp : TextInputType.url,
AutofillHints.jobTitle : TextInputType.text,
AutofillHints.language : TextInputType.text,
AutofillHints.location : TextInputType.streetAddress,
AutofillHints.middleInitial : TextInputType.name,
AutofillHints.middleName : TextInputType.name,
AutofillHints.name : TextInputType.name,
AutofillHints.namePrefix : TextInputType.name,
AutofillHints.nameSuffix : TextInputType.name,
AutofillHints.newPassword : TextInputType.text,
AutofillHints.newUsername : TextInputType.text,
AutofillHints.nickname : TextInputType.text,
AutofillHints.oneTimeCode : TextInputType.text,
AutofillHints.organizationName : TextInputType.text,
AutofillHints.password : TextInputType.text,
AutofillHints.photo : TextInputType.text,
AutofillHints.postalAddress : TextInputType.streetAddress,
AutofillHints.postalAddressExtended : TextInputType.streetAddress,
AutofillHints.postalAddressExtendedPostalCode : TextInputType.number,
AutofillHints.postalCode : TextInputType.number,
AutofillHints.streetAddressLevel1 : TextInputType.streetAddress,
AutofillHints.streetAddressLevel2 : TextInputType.streetAddress,
AutofillHints.streetAddressLevel3 : TextInputType.streetAddress,
AutofillHints.streetAddressLevel4 : TextInputType.streetAddress,
AutofillHints.streetAddressLine1 : TextInputType.streetAddress,
AutofillHints.streetAddressLine2 : TextInputType.streetAddress,
AutofillHints.streetAddressLine3 : TextInputType.streetAddress,
AutofillHints.sublocality : TextInputType.streetAddress,
AutofillHints.telephoneNumber : TextInputType.phone,
AutofillHints.telephoneNumberAreaCode : TextInputType.phone,
AutofillHints.telephoneNumberCountryCode : TextInputType.phone,
AutofillHints.telephoneNumberDevice : TextInputType.phone,
AutofillHints.telephoneNumberExtension : TextInputType.phone,
AutofillHints.telephoneNumberLocal : TextInputType.phone,
AutofillHints.telephoneNumberLocalPrefix : TextInputType.phone,
AutofillHints.telephoneNumberLocalSuffix : TextInputType.phone,
AutofillHints.telephoneNumberNational : TextInputType.phone,
AutofillHints.transactionAmount : TextInputType.numberWithOptions(decimal: true),
AutofillHints.transactionCurrency : TextInputType.text,
AutofillHints.url : TextInputType.url,
AutofillHints.username : TextInputType.text,
};
return inferKeyboardType[effectiveHint] ?? TextInputType.text;
}
@override
EditableTextState createState() => EditableTextState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>('controller', controller));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode));
properties.add(DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('readOnly', readOnly, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: true));
properties.add(EnumProperty<SmartDashesType>('smartDashesType', smartDashesType, defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled));
properties.add(EnumProperty<SmartQuotesType>('smartQuotesType', smartQuotesType, defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled));
properties.add(DiagnosticsProperty<bool>('enableSuggestions', enableSuggestions, defaultValue: true));
style.debugFillProperties(properties);
properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 1));
properties.add(IntProperty('minLines', minLines, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
properties.add(DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: null));
properties.add(DiagnosticsProperty<ScrollController>('scrollController', scrollController, defaultValue: null));
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
properties.add(DiagnosticsProperty<Iterable<String>>('autofillHints', autofillHints, defaultValue: null));
properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true));
}
}
/// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate, TextInputClient implements AutofillClient {
Timer? _cursorTimer;
AnimationController get _cursorBlinkOpacityController {
return _backingCursorBlinkOpacityController ??= AnimationController(
vsync: this,
)..addListener(_onCursorColorTick);
}
AnimationController? _backingCursorBlinkOpacityController;
late final Simulation _iosBlinkCursorSimulation = _DiscreteKeyFrameSimulation.iOSBlinkingCaret();
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
final GlobalKey _editableKey = GlobalKey();
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
TextInputConnection? _textInputConnection;
TextSelectionOverlay? _selectionOverlay;
ScrollController? _internalScrollController;
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
bool _didAutoFocus = false;
AutofillGroupState? _currentAutofillScope;
@override
AutofillScope? get currentAutofillScope => _currentAutofillScope;
AutofillClient get _effectiveAutofillClient => widget.autofillClient ?? this;
/// Whether to create an input connection with the platform for text editing
/// or not.
///
/// Read-only input fields do not need a connection with the platform since
/// there's no need for text editing capabilities (e.g. virtual keyboard).
///
/// On the web, we always need a connection because we want some browser
/// functionalities to continue to work on read-only input fields like:
///
/// - Relevant context menu.
/// - cmd/ctrl+c shortcut to copy.
/// - cmd/ctrl+a to select all.
/// - Changing the selection using a physical keyboard.
bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly;
// The time it takes for the floating cursor to snap to the text aligned
// cursor position after the user has finished placing it.
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
AnimationController? _floatingCursorResetController;
Orientation? _lastOrientation;
@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@override
bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}
TextEditingValue get _textEditingValueforTextLayoutMetrics {
final Widget? editableWidget =_editableKey.currentContext?.widget;
if (editableWidget is! _Editable) {
throw StateError('_Editable must be mounted.');
}
return editableWidget.value;
}
/// Copy current selection to [Clipboard].
@override
void copySelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection;
assert(selection != null);
if (selection.isCollapsed || widget.obscureText) {
return;
}
final String text = textEditingValue.text;
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar(false);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: TextSelection.collapsed(offset: textEditingValue.selection.end),
),
SelectionChangedCause.toolbar,
);
break;
}
}
_clipboardStatus?.update();
}
/// Cut current selection to [Clipboard].
@override
void cutSelection(SelectionChangedCause cause) {
if (widget.readOnly || widget.obscureText) {
return;
}
final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
assert(selection != null);
if (selection.isCollapsed) {
return;
}
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
_replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause));
if (cause == SelectionChangedCause.toolbar) {
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
bringIntoView(textEditingValue.selection.extent);
}
});
hideToolbar();
}
_clipboardStatus?.update();
}
/// Paste text from [Clipboard].
@override
Future<void> pasteText(SelectionChangedCause cause) async {
if (widget.readOnly) {
return;
}
final TextSelection selection = textEditingValue.selection;
assert(selection != null);
if (!selection.isValid) {
return;
}
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data == null) {
return;
}
// After the paste, the cursor should be collapsed and located after the
// pasted content.
final int lastSelectionIndex = math.max(selection.baseOffset, selection.extentOffset);
final TextEditingValue collapsedTextEditingValue = textEditingValue.copyWith(
selection: TextSelection.collapsed(offset: lastSelectionIndex),
);
userUpdateTextEditingValue(
collapsedTextEditingValue.replaced(selection, data.text!),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
bringIntoView(textEditingValue.selection.extent);
}
});
hideToolbar();
}
}
/// Select the entire text value.
@override
void selectAll(SelectionChangedCause cause) {
if (widget.readOnly && widget.obscureText) {
// If we can't modify it, and we can't copy it, there's no point in
// selecting it.
return;
}
userUpdateTextEditingValue(
textEditingValue.copyWith(
selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length),
),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
}
}
// State lifecycle:
@override
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_updateSelectionOverlayForScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
}
// Whether `TickerMode.of(context)` is true and animations (like blinking the
// cursor) are supposed to run.
bool _tickersEnabled = true;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final AutofillGroupState? newAutofillGroup = AutofillGroup.of(context);
if (currentAutofillScope != newAutofillGroup) {
_currentAutofillScope?.unregister(autofillId);
_currentAutofillScope = newAutofillGroup;
_currentAutofillScope?.register(_effectiveAutofillClient);
}
if (!_didAutoFocus && widget.autofocus) {
_didAutoFocus = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted && renderEditable.hasSize) {
FocusScope.of(context).autofocus(widget.focusNode);
}
});
}
// Restart or stop the blinking cursor when TickerMode changes.
final bool newTickerEnabled = TickerMode.of(context);
if (_tickersEnabled != newTickerEnabled) {
_tickersEnabled = newTickerEnabled;
if (_tickersEnabled && _cursorActive) {
_startCursorBlink();
} else if (!_tickersEnabled && _cursorTimer != null) {
// Cannot use _stopCursorTimer because it would reset _cursorActive.
_cursorTimer!.cancel();
_cursorTimer = null;
}
}
if (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android) {
return;
}
// Hide the text selection toolbar on mobile when orientation changes.
final Orientation orientation = MediaQuery.of(context).orientation;
if (_lastOrientation == null) {
_lastOrientation = orientation;
return;
}
if (orientation != _lastOrientation) {
_lastOrientation = orientation;
if (defaultTargetPlatform == TargetPlatform.iOS) {
hideToolbar(false);
}
if (defaultTargetPlatform == TargetPlatform.android) {
hideToolbar();
}
}
}
@override
void didUpdateWidget(EditableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_didChangeTextEditingValue);
widget.controller.addListener(_didChangeTextEditingValue);
_updateRemoteEditingValueIfNeeded();
}
if (widget.controller.selection != oldWidget.controller.selection) {
_selectionOverlay?.update(_value);
}
_selectionOverlay?.handlesVisible = widget.showSelectionHandles;
if (widget.autofillClient != oldWidget.autofillClient) {
_currentAutofillScope?.unregister(oldWidget.autofillClient?.autofillId ?? autofillId);
_currentAutofillScope?.register(_effectiveAutofillClient);
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive();
}
if (widget.scrollController != oldWidget.scrollController) {
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll);
_scrollController.addListener(_updateSelectionOverlayForScroll);
}
if (!_shouldCreateInputConnection) {
_closeInputConnectionIfNeeded();
} else if (oldWidget.readOnly && _hasFocus) {
_openInputConnection();
}
if (kIsWeb && _hasInputConnection) {
if (oldWidget.readOnly != widget.readOnly) {
_textInputConnection!.updateConfig(_effectiveAutofillClient.textInputConfiguration);
}
}
if (widget.style != oldWidget.style) {
final TextStyle style = widget.style;
// The _textInputConnection will pick up the new style when it attaches in
// _openInputConnection.
if (_hasInputConnection) {
_textInputConnection!.setStyle(
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textDirection: _textDirection,
textAlign: widget.textAlign,
);
}
}
if (widget.selectionEnabled && pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) {
_clipboardStatus?.update();
}
}
@override
void dispose() {
_internalScrollController?.dispose();
_currentAutofillScope?.unregister(autofillId);
widget.controller.removeListener(_didChangeTextEditingValue);
_floatingCursorResetController?.dispose();
_floatingCursorResetController = null;
_closeInputConnectionIfNeeded();
assert(!_hasInputConnection);
_cursorTimer?.cancel();
_cursorTimer = null;
_backingCursorBlinkOpacityController?.dispose();
_backingCursorBlinkOpacityController = null;
_selectionOverlay?.dispose();
_selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged);
WidgetsBinding.instance.removeObserver(this);
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
_clipboardStatus?.dispose();
_cursorVisibilityNotifier.dispose();
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
}
// TextInputClient implementation:
/// The last known [TextEditingValue] of the platform text input plugin.
///
/// This value is updated when the platform text input plugin sends a new
/// update via [updateEditingValue], or when [EditableText] calls
/// [TextInputConnection.setEditingState] to overwrite the platform text input
/// plugin's [TextEditingValue].
///
/// Used in [_updateRemoteEditingValueIfNeeded] to determine whether the
/// remote value is outdated and needs updating.
TextEditingValue? _lastKnownRemoteTextEditingValue;
@override
TextEditingValue get currentTextEditingValue => _value;
@override
void updateEditingValue(TextEditingValue value) {
// 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
// to disable text updating.
if (!_shouldCreateInputConnection) {
return;
}
if (widget.readOnly) {
// In the read-only case, we only care about selection changes, and reject
// everything else.
value = _value.copyWith(selection: value.selection);
}
_lastKnownRemoteTextEditingValue = value;
if (value == _value) {
// This is possible, for example, when the numeric keyboard is input,
// the engine will notify twice for the same value.
// Track at https://github.com/flutter/flutter/issues/65811
return;
}
if (value.text == _value.text && value.composing == _value.composing) {
// `selection` is the only change.
_handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard);
} else {
hideToolbar();
_currentPromptRectRange = null;
final bool revealObscuredInput = _hasInputConnection
&& widget.obscureText
&& WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
&& value.text.length == _value.text.length + 1;
_obscureShowCharTicksPending = revealObscuredInput ? _kObscureShowLatestCharCursorTicks : 0;
_obscureLatestCharIndex = revealObscuredInput ? _value.selection.baseOffset : null;
_formatAndSetValue(value, SelectionChangedCause.keyboard);
}
// Wherever the value is changed by the user, schedule a showCaretOnScreen
// to make sure the user can see the changes they just made. Programmatical
// changes to `textEditingValue` do not trigger the behavior even if the
// text field is focused.
_scheduleShowCaretOnScreen(withAnimation: true);
if (_hasInputConnection) {
// To keep the cursor from blinking while typing, we want to restart the
// cursor timer every time a new character is typed.
_stopCursorBlink(resetCharTicks: false);
_startCursorBlink();
}
}
@override
void performAction(TextInputAction action) {
switch (action) {
case TextInputAction.newline:
// If this is a multiline EditableText, do nothing for a "newline"
// action; The newline is already inserted. Otherwise, finalize
// editing.
if (!_isMultiline) {
_finalizeEditing(action, shouldUnfocus: true);
}
break;
case TextInputAction.done:
case TextInputAction.go:
case TextInputAction.next:
case TextInputAction.previous:
case TextInputAction.search:
case TextInputAction.send:
_finalizeEditing(action, shouldUnfocus: true);
break;
case TextInputAction.continueAction:
case TextInputAction.emergencyCall:
case TextInputAction.join:
case TextInputAction.none:
case TextInputAction.route:
case TextInputAction.unspecified:
// Finalize editing, but don't give up focus because this keyboard
// action does not imply the user is done inputting information.
_finalizeEditing(action, shouldUnfocus: false);
break;
}
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
widget.onAppPrivateCommand!(action, data);
}
// The original position of the caret on FloatingCursorDragState.start.
Rect? _startCaretRect;
// The most recent text position as determined by the location of the floating
// cursor.
TextPosition? _lastTextPosition;
// The offset of the floating cursor as determined from the start call.
Offset? _pointOffsetOrigin;
// The most recent position of the floating cursor.
Offset? _lastBoundedOffset;
// Because the center of the cursor is preferredLineHeight / 2 below the touch
// origin, but the touch origin is used to determine which line the cursor is
// on, we need this offset to correctly render and move the cursor.
Offset get _floatingCursorOffset => Offset(0, renderEditable.preferredLineHeight / 2);
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
_floatingCursorResetController ??= AnimationController(
vsync: this,
)..addListener(_onFloatingCursorResetTick);
switch(point.state) {
case FloatingCursorDragState.Start:
if (_floatingCursorResetController!.isAnimating) {
_floatingCursorResetController!.stop();
_onFloatingCursorResetTick();
}
// We want to send in points that are centered around a (0,0) origin, so
// we cache the position.
_pointOffsetOrigin = point.offset;
final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset);
_startCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
_lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset;
_lastTextPosition = currentTextPosition;
renderEditable.setFloatingCursor(point.state, _lastBoundedOffset!, _lastTextPosition!);
break;
case FloatingCursorDragState.Update:
final Offset centeredPoint = point.offset! - _pointOffsetOrigin!;
final Offset rawCursorOffset = _startCaretRect!.center + centeredPoint - _floatingCursorOffset;
_lastBoundedOffset = renderEditable.calculateBoundedFloatingCursorOffset(rawCursorOffset);
_lastTextPosition = renderEditable.getPositionForPoint(renderEditable.localToGlobal(_lastBoundedOffset! + _floatingCursorOffset));
renderEditable.setFloatingCursor(point.state, _lastBoundedOffset!, _lastTextPosition!);
break;
case FloatingCursorDragState.End:
// We skip animation if no update has happened.
if (_lastTextPosition != null && _lastBoundedOffset != null) {
_floatingCursorResetController!.value = 0.0;
_floatingCursorResetController!.animateTo(1.0, duration: _floatingCursorResetTime, curve: Curves.decelerate);
}
break;
}
}
void _onFloatingCursorResetTick() {
final Offset finalPosition = renderEditable.getLocalRectForCaret(_lastTextPosition!).centerLeft - _floatingCursorOffset;
if (_floatingCursorResetController!.isCompleted) {
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), SelectionChangedCause.forcePress);
}
_startCaretRect = null;
_lastTextPosition = null;
_pointOffsetOrigin = null;
_lastBoundedOffset = null;
} else {
final double lerpValue = _floatingCursorResetController!.value;
final double lerpX = ui.lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!;
final double lerpY = ui.lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!;
renderEditable.setFloatingCursor(FloatingCursorDragState.Update, Offset(lerpX, lerpY), _lastTextPosition!, resetLerpValue: lerpValue);
}
}
@pragma('vm:notify-debugger-on-exception')
void _finalizeEditing(TextInputAction action, {required bool shouldUnfocus}) {
// Take any actions necessary now that the user has completed editing.
if (widget.onEditingComplete != null) {
try {
widget.onEditingComplete!();
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets',
context: ErrorDescription('while calling onEditingComplete for $action'),
));
}
} else {
// Default behavior if the developer did not provide an
// onEditingComplete callback: Finalize editing and remove focus, or move
// it to the next/previous field, depending on the action.
widget.controller.clearComposing();
if (shouldUnfocus) {
switch (action) {
case TextInputAction.none:
case TextInputAction.unspecified:
case TextInputAction.done:
case TextInputAction.go:
case TextInputAction.search:
case TextInputAction.send:
case TextInputAction.continueAction:
case TextInputAction.join:
case TextInputAction.route:
case TextInputAction.emergencyCall:
case TextInputAction.newline:
widget.focusNode.unfocus();
break;
case TextInputAction.next:
widget.focusNode.nextFocus();
break;
case TextInputAction.previous:
widget.focusNode.previousFocus();
break;
}
}
}
final ValueChanged<String>? onSubmitted = widget.onSubmitted;
if (onSubmitted == null) {
return;
}
// Invoke optional callback with the user's submitted content.
try {
onSubmitted(_value.text);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets',
context: ErrorDescription('while calling onSubmitted for $action'),
));
}
// If `shouldUnfocus` is true, the text field should no longer be focused
// after the microtask queue is drained. But in case the developer cancelled
// the focus change in the `onSubmitted` callback by focusing this input
// field again, reset the soft keyboard.
// See https://github.com/flutter/flutter/issues/84240.
//
// `_restartConnectionIfNeeded` creates a new TextInputConnection to replace
// the current one. This on iOS switches to a new input view and on Android
// restarts the input method, and in both cases the soft keyboard will be
// reset.
if (shouldUnfocus) {
_scheduleRestartConnection();
}
}
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 outermost 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() {
if (_batchEditDepth > 0 || !_hasInputConnection) {
return;
}
final TextEditingValue localValue = _value;
if (localValue == _lastKnownRemoteTextEditingValue) {
return;
}
_textInputConnection!.setEditingState(localValue);
_lastKnownRemoteTextEditingValue = localValue;
}
TextEditingValue get _value => widget.controller.value;
set _value(TextEditingValue value) {
widget.controller.value = value;
}
bool get _hasFocus => widget.focusNode.hasFocus;
bool get _isMultiline => widget.maxLines != 1;
// Finds the closest scroll offset to the current scroll offset that fully
// reveals the given caret rect. If the given rect's main axis extent is too
// large to be fully revealed in `renderEditable`, it will be centered along
// the main axis.
//
// If this is a multiline EditableText (which means the Editable can only
// scroll vertically), the given rect's height will first be extended to match
// `renderEditable.preferredLineHeight`, before the target scroll offset is
// calculated.
RevealedOffset _getOffsetToRevealCaret(Rect rect) {
if (!_scrollController.position.allowImplicitScrolling) {
return RevealedOffset(offset: _scrollController.offset, rect: rect);
}
final Size editableSize = renderEditable.size;
final double additionalOffset;
final Offset unitOffset;
if (!_isMultiline) {
additionalOffset = rect.width >= editableSize.width
// Center `rect` if it's oversized.
? editableSize.width / 2 - rect.center.dx
// Valid additional offsets range from (rect.right - size.width)
// to (rect.left). Pick the closest one if out of range.
: clampDouble(0.0, rect.right - editableSize.width, rect.left);
unitOffset = const Offset(1, 0);
} else {
// The caret is vertically centered within the line. Expand the caret's
// height so that it spans the line because we're going to ensure that the
// entire expanded caret is scrolled into view.
final Rect expandedRect = Rect.fromCenter(
center: rect.center,
width: rect.width,
height: math.max(rect.height, renderEditable.preferredLineHeight),
);
additionalOffset = expandedRect.height >= editableSize.height
? editableSize.height / 2 - expandedRect.center.dy
: clampDouble(0.0, expandedRect.bottom - editableSize.height, expandedRect.top);
unitOffset = const Offset(0, 1);
}
// No overscrolling when encountering tall fonts/scripts that extend past
// the ascent.
final double targetOffset = clampDouble(
additionalOffset + _scrollController.offset,
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent,
);
final double offsetDelta = _scrollController.offset - targetOffset;
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
}
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
/// Whether to send the autofill information to the autofill service. True by
/// default.
bool get _needsAutofill => _effectiveAutofillClient.textInputConfiguration.autofillConfiguration.enabled;
void _openInputConnection() {
if (!_shouldCreateInputConnection) {
return;
}
if (!_hasInputConnection) {
final TextEditingValue localValue = _value;
// When _needsAutofill == true && currentAutofillScope == null, autofill
// is allowed but saving the user input from the text field is
// discouraged.
//
// In case the autofillScope changes from a non-null value to null, or
// _needsAutofill changes to false from true, the platform needs to be
// notified to exclude this field from the autofill context. So we need to
// provide the autofillId.
_textInputConnection = _needsAutofill && currentAutofillScope != null
? currentAutofillScope!.attach(this, _effectiveAutofillClient.textInputConfiguration)
: TextInput.attach(this, _effectiveAutofillClient.textInputConfiguration);
_updateSizeAndTransform();
_updateComposingRectIfNeeded();
_updateCaretRectIfNeeded();
final TextStyle style = widget.style;
_textInputConnection!
..setStyle(
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textDirection: _textDirection,
textAlign: widget.textAlign,
)
..setEditingState(localValue)
..show();
if (_needsAutofill) {
// Request autofill AFTER the size and the transform have been sent to
// the platform text input plugin.
_textInputConnection!.requestAutofill();
}
_lastKnownRemoteTextEditingValue = localValue;
} else {
_textInputConnection!.show();
}
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
}
}
void _openOrCloseInputConnectionIfNeeded() {
if (_hasFocus && widget.focusNode.consumeKeyboardToken()) {
_openInputConnection();
} else if (!_hasFocus) {
_closeInputConnectionIfNeeded();
widget.controller.clearComposing();
}
}
bool _restartConnectionScheduled = false;
void _scheduleRestartConnection() {
if (_restartConnectionScheduled) {
return;
}
_restartConnectionScheduled = true;
scheduleMicrotask(_restartConnectionIfNeeded);
}
// Discards the current [TextInputConnection] and establishes a new one.
//
// This method is rarely needed. This is currently used to reset the input
// type when the "submit" text input action is triggered and the developer
// puts the focus back to this input field..
void _restartConnectionIfNeeded() {
_restartConnectionScheduled = false;
if (!_hasInputConnection || !_shouldCreateInputConnection) {
return;
}
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
final AutofillScope? currentAutofillScope = _needsAutofill ? this.currentAutofillScope : null;
final TextInputConnection newConnection = currentAutofillScope?.attach(this, textInputConfiguration)
?? TextInput.attach(this, _effectiveAutofillClient.textInputConfiguration);
_textInputConnection = newConnection;
final TextStyle style = widget.style;
newConnection
..show()
..setStyle(
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textDirection: _textDirection,
textAlign: widget.textAlign,
)
..setEditingState(_value);
_lastKnownRemoteTextEditingValue = _value;
}
@override
void connectionClosed() {
if (_hasInputConnection) {
_textInputConnection!.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_finalizeEditing(TextInputAction.done, shouldUnfocus: true);
}
}
/// Express interest in interacting with the keyboard.
///
/// If this control is already attached to the keyboard, this function will
/// request that the keyboard become visible. Otherwise, this function will
/// ask the focus system that it become focused. If successful in acquiring
/// focus, the control will then attach to the keyboard and request that the
/// keyboard become visible.
void requestKeyboard() {
if (_hasFocus) {
_openInputConnection();
} else {
widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
}
}
void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) {
if (_hasFocus) {
_selectionOverlay!.update(_value);
} else {
_selectionOverlay!.dispose();
_selectionOverlay = null;
}
}
}
void _updateSelectionOverlayForScroll() {
_selectionOverlay?.updateForScroll();
}
void _createSelectionOverlay() {
_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,
);
}
@pragma('vm:notify-debugger-on-exception')
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.
if (!widget.controller.isSelectionWithinTextBounds(selection)) {
return;
}
widget.controller.selection = selection;
// This will show the keyboard for all selection changes on the
// EditableText except for those triggered by a keyboard input.
// Typically EditableText shouldn't take user keyboard input if
// it's not focused already. If the EditableText is being
// autofilled it shouldn't request focus.
switch (cause) {
case null:
case SelectionChangedCause.doubleTap:
case SelectionChangedCause.drag:
case SelectionChangedCause.forcePress:
case SelectionChangedCause.longPress:
case SelectionChangedCause.scribble:
case SelectionChangedCause.tap:
case SelectionChangedCause.toolbar:
requestKeyboard();
break;
case SelectionChangedCause.keyboard:
if (_hasFocus) {
requestKeyboard();
}
break;
}
if (widget.selectionControls == null) {
_selectionOverlay?.dispose();
_selectionOverlay = null;
} else {
if (_selectionOverlay == null) {
_createSelectionOverlay();
} else {
_selectionOverlay!.update(_value);
}
_selectionOverlay!.handlesVisible = widget.showSelectionHandles;
_selectionOverlay!.showHandles();
}
// 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.
if (_cursorTimer != null) {
_stopCursorBlink(resetCharTicks: false);
_startCursorBlink();
}
}
Rect? _currentCaretRect;
// ignore: use_setters_to_change_properties, (this is used as a callback, can't be a setter)
void _handleCaretChanged(Rect caretRect) {
_currentCaretRect = caretRect;
}
// Animation configuration for scrolling the caret back on screen.
static const Duration _caretAnimationDuration = Duration(milliseconds: 100);
static const Curve _caretAnimationCurve = Curves.fastOutSlowIn;
bool _showCaretOnScreenScheduled = false;
void _scheduleShowCaretOnScreen({required bool withAnimation}) {
if (_showCaretOnScreenScheduled) {
return;
}
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_showCaretOnScreenScheduled = false;
if (_currentCaretRect == null || !_scrollController.hasClients) {
return;
}
final double lineHeight = renderEditable.preferredLineHeight;
// Enlarge the target rect by scrollPadding to ensure that caret is not
// positioned directly at the edge after scrolling.
double bottomSpacing = widget.scrollPadding.bottom;
if (_selectionOverlay?.selectionControls != null) {
final double handleHeight = _selectionOverlay!.selectionControls!
.getHandleSize(lineHeight).height;
final double interactiveHandleHeight = math.max(
handleHeight,
kMinInteractiveDimension,
);
final Offset anchor = _selectionOverlay!.selectionControls!
.getHandleAnchor(
TextSelectionHandleType.collapsed,
lineHeight,
);
final double handleCenter = handleHeight / 2 - anchor.dy;
bottomSpacing = math.max(
handleCenter + interactiveHandleHeight / 2,
bottomSpacing,
);
}
final EdgeInsets caretPadding = widget.scrollPadding
.copyWith(bottom: bottomSpacing);
final RevealedOffset targetOffset = _getOffsetToRevealCaret(_currentCaretRect!);
if (withAnimation) {
_scrollController.animateTo(
targetOffset.offset,
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
renderEditable.showOnScreen(
rect: caretPadding.inflateRect(targetOffset.rect),
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
} else {
_scrollController.jumpTo(targetOffset.offset);
renderEditable.showOnScreen(
rect: caretPadding.inflateRect(targetOffset.rect),
);
}
});
}
late double _lastBottomViewInset;
@override
void didChangeMetrics() {
if (_lastBottomViewInset != WidgetsBinding.instance.window.viewInsets.bottom) {
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_selectionOverlay?.updateForScroll();
});
if (_lastBottomViewInset < WidgetsBinding.instance.window.viewInsets.bottom) {
// Because the metrics change signal from engine will come here every frame
// (on both iOS and Android). So we don't need to show caret with animation.
_scheduleShowCaretOnScreen(withAnimation: false);
}
}
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
}
@pragma('vm:notify-debugger-on-exception')
void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) {
// Only apply input formatters if the text has changed (including uncommitted
// text in the composing region), or when the user committed the composing
// text.
// Gboard is very persistent in restoring the composing region. Applying
// input formatters on composing-region-only changes (except clearing the
// current composing region) is very infinite-loop-prone: the formatters
// will keep trying to modify the composing region while Gboard will keep
// trying to restore the original composing region.
final bool textChanged = _value.text != value.text
|| (!_value.composing.isCollapsed && value.composing.isCollapsed);
final bool selectionChanged = _value.selection != value.selection;
if (textChanged) {
try {
value = widget.inputFormatters?.fold<TextEditingValue>(
value,
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
) ?? value;
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets',
context: ErrorDescription('while applying input formatters'),
));
}
}
// Put all optional user callback invocations in a batch edit to prevent
// 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);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets',
context: ErrorDescription('while calling onChanged'),
));
}
}
endBatchEdit();
}
void _onCursorColorTick() {
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0;
}
/// Whether the blinking cursor is actually visible at this precise moment
/// (it's hidden half the time, since it blinks).
@visibleForTesting
bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0;
/// The cursor blink interval (the amount of time the cursor is in the "on"
/// state or the "off" state). A complete cursor blink period is twice this
/// value (half on, half off).
@visibleForTesting
Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod;
/// The current status of the text selection handles.
@visibleForTesting
TextSelectionOverlay? get selectionOverlay => _selectionOverlay;
int _obscureShowCharTicksPending = 0;
int? _obscureLatestCharIndex;
// Indicates whether the cursor should be blinking right now (but it may
// actually not blink because it's disabled via TickerMode.of(context)).
bool _cursorActive = false;
void _startCursorBlink() {
assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
_cursorActive = true;
if (!_tickersEnabled) {
return;
}
_cursorTimer?.cancel();
_cursorBlinkOpacityController.value = 1.0;
if (EditableText.debugDeterministicCursor) {
return;
}
if (widget.cursorOpacityAnimates) {
_cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
} else {
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
}
}
void _onCursorTick() {
if (_obscureShowCharTicksPending > 0) {
_obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
? _obscureShowCharTicksPending - 1
: 0;
if (_obscureShowCharTicksPending == 0) {
setState(() { });
}
}
if (widget.cursorOpacityAnimates) {
_cursorTimer?.cancel();
// Schedule this as an async task to avoid blocking tester.pumpAndSettle
// indefinitely.
_cursorTimer = Timer(Duration.zero, () => _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick));
} else {
if (!(_cursorTimer?.isActive ?? false) && _tickersEnabled) {
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
}
_cursorBlinkOpacityController.value = _cursorBlinkOpacityController.value == 0 ? 1 : 0;
}
}
void _stopCursorBlink({ bool resetCharTicks = true }) {
_cursorActive = false;
_cursorBlinkOpacityController.value = 0.0;
if (EditableText.debugDeterministicCursor) {
return;
}
_cursorBlinkOpacityController.value = 0.0;
if (resetCharTicks) {
_obscureShowCharTicksPending = 0;
}
}
void _startOrStopCursorTimerIfNeeded() {
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
_startCursorBlink();
}
else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
_stopCursorBlink();
}
}
void _didChangeTextEditingValue() {
_updateRemoteEditingValueIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
// to avoid this setState().
setState(() { /* We use widget.controller.value in build(). */ });
_adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges();
}
void _handleFocusChanged() {
_openOrCloseInputConnectionIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
if (_hasFocus) {
// Listen for changing viewInsets, which indicates keyboard showing up.
WidgetsBinding.instance.addObserver(this);
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
if (!widget.readOnly) {
_scheduleShowCaretOnScreen(withAnimation: true);
}
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), null);
}
_cachedText = '';
_cachedFirstRect = null;
_cachedSize = Size.zero;
_cachedPlaceholder = -1;
} else {
WidgetsBinding.instance.removeObserver(this);
setState(() { _currentPromptRectRange = null; });
}
updateKeepAlive();
}
String _cachedText = '';
Rect? _cachedFirstRect;
Size _cachedSize = Size.zero;
int _cachedPlaceholder = -1;
TextStyle? _cachedTextStyle;
void _updateSelectionRects({bool force = false}) {
if (!widget.scribbleEnabled) {
return;
}
if (defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
// This is to avoid sending selection rects on non-iPad devices.
if (WidgetsBinding.instance.window.physicalSize.shortestSide < _kIPadWidth) {
return;
}
final String text = renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? '';
final List<Rect> firstSelectionBoxes = renderEditable.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1));
final Rect? firstRect = firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null;
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
final Size size = renderEditable.size;
final bool textChanged = text != _cachedText;
final bool textStyleChanged = _cachedTextStyle != widget.style;
final bool firstRectChanged = _cachedFirstRect != firstRect;
final bool sizeChanged = _cachedSize != size;
final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation;
if (scrollDirection == ScrollDirection.idle && (force || textChanged || textStyleChanged || firstRectChanged || sizeChanged || placeholderChanged)) {
_cachedText = text;
_cachedFirstRect = firstRect;
_cachedTextStyle = widget.style;
_cachedSize = size;
_cachedPlaceholder = _placeholderLocation;
bool belowRenderEditableBottom = false;
final List<SelectionRect> rects = List<SelectionRect?>.generate(
_cachedText.characters.length,
(int i) {
if (belowRenderEditableBottom) {
return null;
}
final int offset = _cachedText.characters.getRange(0, i).string.length;
final List<Rect> boxes = renderEditable.getBoxesForSelection(TextSelection(baseOffset: offset, extentOffset: offset + _cachedText.characters.characterAt(i).string.length));
if (boxes.isEmpty) {
return null;
}
final SelectionRect selectionRect = SelectionRect(
bounds: boxes.first,
position: offset,
);
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) {
belowRenderEditableBottom = true;
return null;
}
return selectionRect;
},
).where((SelectionRect? selectionRect) {
if (selectionRect == null) {
return false;
}
if (renderEditable.paintBounds.right < selectionRect.bounds.left || selectionRect.bounds.right < renderEditable.paintBounds.left) {
return false;
}
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || selectionRect.bounds.bottom < renderEditable.paintBounds.top) {
return false;
}
return true;
}).map<SelectionRect>((SelectionRect? selectionRect) => selectionRect!).toList();
_textInputConnection!.setSelectionRects(rects);
}
}
void _updateSizeAndTransform() {
if (_hasInputConnection) {
final Size size = renderEditable.size;
final Matrix4 transform = renderEditable.getTransformTo(null);
_textInputConnection!.setEditableSizeAndTransform(size, transform);
_updateSelectionRects();
SchedulerBinding.instance.addPostFrameCallback((Duration _) => _updateSizeAndTransform());
} else if (_placeholderLocation != -1) {
removeTextPlaceholder();
}
}
// Sends the current composing rect to the iOS text input plugin via the text
// input channel. We need to keep sending the information even if no text is
// currently marked, as the information usually lags behind. The text input
// plugin needs to estimate the composing rect based on the latest caret rect,
// when the composing rect info didn't arrive in time.
void _updateComposingRectIfNeeded() {
final TextRange composingRange = _value.composing;
if (_hasInputConnection) {
assert(mounted);
Rect? composingRect = renderEditable.getRectForComposingRange(composingRange);
// Send the caret location instead if there's no marked text yet.
if (composingRect == null) {
assert(!composingRange.isValid || composingRange.isCollapsed);
final int offset = composingRange.isValid ? composingRange.start : 0;
composingRect = renderEditable.getLocalRectForCaret(TextPosition(offset: offset));
}
assert(composingRect != null);
_textInputConnection!.setComposingRect(composingRect);
SchedulerBinding.instance.addPostFrameCallback((Duration _) => _updateComposingRectIfNeeded());
}
}
void _updateCaretRectIfNeeded() {
if (_hasInputConnection) {
if (renderEditable.selection != null && renderEditable.selection!.isValid &&
renderEditable.selection!.isCollapsed) {
final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset);
final Rect caretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
_textInputConnection!.setCaretRect(caretRect);
}
SchedulerBinding.instance.addPostFrameCallback((Duration _) => _updateCaretRectIfNeeded());
}
}
TextDirection get _textDirection {
final TextDirection result = widget.textDirection ?? Directionality.of(context);
assert(result != null, '$runtimeType created without a textDirection and with no ambient Directionality.');
return result;
}
/// The renderer for this widget's descendant.
///
/// This property is typically used to notify the renderer of input gestures
/// when [RenderEditable.ignorePointer] is true.
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
@override
TextEditingValue get textEditingValue => _value;
double get _devicePixelRatio => MediaQuery.of(context).devicePixelRatio;
@override
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
? _value.selection != value.selection
: _value != value;
if (shouldShowCaret) {
_scheduleShowCaretOnScreen(withAnimation: true);
}
// Even if the value doesn't change, it may be necessary to focus and build
// the selection overlay. For example, this happens when right clicking an
// unfocused field that previously had a selection in the same spot.
if (value == textEditingValue) {
if (!widget.focusNode.hasFocus) {
widget.focusNode.requestFocus();
_createSelectionOverlay();
}
return;
}
_formatAndSetValue(value, cause, userInteraction: true);
}
@override
void bringIntoView(TextPosition position) {
final Rect localRect = renderEditable.getLocalRectForCaret(position);
final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect);
_scrollController.jumpTo(targetOffset.offset);
renderEditable.showOnScreen(rect: targetOffset.rect);
}
/// Shows the selection toolbar at the location of the current cursor.
///
/// Returns `false` if a toolbar couldn't be shown, such as when the toolbar
/// is already shown, or when no text selection currently exists.
@override
bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this
// we should not show a Flutter toolbar for the editable text elements.
if (kIsWeb) {
return false;
}
if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) {
return false;
}
_clipboardStatus?.update();
_selectionOverlay!.showToolbar();
return true;
}
@override
void hideToolbar([bool hideHandles = true]) {
if (hideHandles) {
// Hide the handles and the toolbar.
_selectionOverlay?.hide();
} else if (_selectionOverlay?.toolbarIsVisible ?? false) {
// Hide only the toolbar but not the handles.
_selectionOverlay?.hideToolbar();
}
}
/// Toggles the visibility of the toolbar.
void toggleToolbar() {
assert(_selectionOverlay != null);
if (_selectionOverlay!.toolbarIsVisible) {
hideToolbar();
} else {
showToolbar();
}
}
// Tracks the location a [_ScribblePlaceholder] should be rendered in the
// text.
//
// A value of -1 indicates there should be no placeholder, otherwise the
// value should be between 0 and the length of the text, inclusive.
int _placeholderLocation = -1;
@override
void insertTextPlaceholder(Size size) {
if (!widget.scribbleEnabled) {
return;
}
if (!widget.controller.selection.isValid) {
return;
}
setState(() {
_placeholderLocation = _value.text.length - widget.controller.selection.end;
});
}
@override
void removeTextPlaceholder() {
if (!widget.scribbleEnabled) {
return;
}
setState(() {
_placeholderLocation = -1;
});
}
@override
String get autofillId => 'EditableText-$hashCode';
@override
TextInputConfiguration get textInputConfiguration {
final List<String>? autofillHints = widget.autofillHints?.toList(growable: false);
final AutofillConfiguration autofillConfiguration = autofillHints != null
? AutofillConfiguration(
uniqueIdentifier: autofillId,
autofillHints: autofillHints,
currentEditingValue: currentTextEditingValue,
)
: AutofillConfiguration.disabled;
return TextInputConfiguration(
inputType: widget.keyboardType,
readOnly: widget.readOnly,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
smartDashesType: widget.smartDashesType,
smartQuotesType: widget.smartQuotesType,
enableSuggestions: widget.enableSuggestions,
enableInteractiveSelection: widget._userSelectionEnabled,
inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline
? TextInputAction.newline
: TextInputAction.done
),
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
autofillConfiguration: autofillConfiguration,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
);
}
@override
void autofill(TextEditingValue value) => updateEditingValue(value);
// null if no promptRect should be shown.
TextRange? _currentPromptRectRange;
@override
void showAutocorrectionPromptRect(int start, int end) {
setState(() {
_currentPromptRectRange = TextRange(start: start, end: end);
});
}
VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) {
return widget.selectionEnabled
&& copyEnabled
&& _hasFocus
&& (controls?.canCopy(this) ?? false)
? () => controls!.handleCopy(this)
: null;
}
VoidCallback? _semanticsOnCut(TextSelectionControls? controls) {
return widget.selectionEnabled
&& cutEnabled
&& _hasFocus
&& (controls?.canCut(this) ?? false)
? () => controls!.handleCut(this)
: null;
}
VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) {
return widget.selectionEnabled
&& pasteEnabled
&& _hasFocus
&& (controls?.canPaste(this) ?? false)
&& (_clipboardStatus == null || _clipboardStatus!.value == ClipboardStatus.pasteable)
? () => controls!.handlePaste(this)
: null;
}
// --------------------------- Text Editing Actions ---------------------------
_TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value) : _CharacterBoundary(_value);
return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward);
}
_TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary;
final _TextBoundary boundary;
if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value);
boundary = _DocumentBoundary(_value);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = _CharacterBoundary(textEditingValue);
// This isn't enough. Newline characters.
boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), _WordBoundary(renderEditable, textEditingValue));
}
final _MixedBoundary mixedBoundary = intent.forward
? _MixedBoundary(atomicTextBoundary, boundary)
: _MixedBoundary(boundary, atomicTextBoundary);
// Use a _MixedBoundary to make sure we don't leave invalid codepoints in
// the field after deletion.
return _CollapsedSelectionBoundary(mixedBoundary, intent.forward);
}
_TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary;
final _TextBoundary boundary;
if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value);
boundary = _DocumentBoundary(_value);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = _CharacterBoundary(textEditingValue);
boundary = _LineBreak(renderEditable, textEditingValue);
}
// The _MixedBoundary is to make sure we don't leave invalid code units in
// the field after deletion.
// `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary,
// since the document boundary is unique and the linebreak boundary is
// already caret-location based.
return intent.forward
? _MixedBoundary(_CollapsedSelectionBoundary(atomicTextBoundary, true), boundary)
: _MixedBoundary(boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false));
}
_TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => _DocumentBoundary(_value);
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction);
}
/// Transpose the characters immediately before and after the current
/// collapsed selection.
///
/// When the cursor is at the end of the text, transposes the last two
/// characters, if they exist.
///
/// When the cursor is at the start of the text, does nothing.
void _transposeCharacters(TransposeCharactersIntent intent) {
if (_value.text.characters.length <= 1
|| _value.selection == null
|| !_value.selection.isCollapsed
|| _value.selection.baseOffset == 0) {
return;
}
final String text = _value.text;
final TextSelection selection = _value.selection;
final bool atEnd = selection.baseOffset == text.length;
final CharacterRange transposing = CharacterRange.at(text, selection.baseOffset);
if (atEnd) {
transposing.moveBack(2);
} else {
transposing..moveBack()..expandNext();
}
assert(transposing.currentCharacters.length == 2);
userUpdateTextEditingValue(
TextEditingValue(
text: transposing.stringBefore
+ transposing.currentCharacters.last
+ transposing.currentCharacters.first
+ transposing.stringAfter,
selection: TextSelection.collapsed(
offset: transposing.stringBeforeLength + transposing.current.length,
),
),
SelectionChangedCause.keyboard,
);
}
late final Action<TransposeCharactersIntent> _transposeCharactersAction = CallbackAction<TransposeCharactersIntent>(onInvoke: _transposeCharacters);
void _replaceText(ReplaceTextIntent intent) {
final TextEditingValue oldValue = _value;
final TextEditingValue newValue = intent.currentTextEditingValue.replaced(
intent.replacementRange,
intent.replacementText,
);
userUpdateTextEditingValue(newValue, intent.cause);
// If there's no change in text and selection (e.g. when selecting and
// pasting identical text), the widget won't be rebuilt on value update.
// Handle this by calling _didChangeTextEditingValue() so caret and scroll
// updates can happen.
if (newValue == oldValue) {
_didChangeTextEditingValue();
}
}
late final Action<ReplaceTextIntent> _replaceTextAction = CallbackAction<ReplaceTextIntent>(onInvoke: _replaceText);
// Scrolls either to the beginning or end of the document depending on the
// intent's `forward` parameter.
void _scrollToDocumentBoundary(ScrollToDocumentBoundaryIntent intent) {
if (intent.forward) {
bringIntoView(TextPosition(offset: _value.text.length));
} else {
bringIntoView(const TextPosition(offset: 0));
}
}
void _updateSelection(UpdateSelectionIntent intent) {
bringIntoView(intent.newSelection.extent);
userUpdateTextEditingValue(
intent.currentTextEditingValue.copyWith(selection: intent.newSelection),
intent.cause,
);
}
late final Action<UpdateSelectionIntent> _updateSelectionAction = CallbackAction<UpdateSelectionIntent>(onInvoke: _updateSelection);
late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this);
void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) {
final _TextBoundary textBoundary = _documentBoundary(intent);
_expandSelection(intent.forward, textBoundary, true);
}
void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) {
final _TextBoundary textBoundary = _linebreak(intent);
_expandSelection(intent.forward, textBoundary);
}
void _expandSelection(bool forward, _TextBoundary textBoundary, [bool extentAtIndex = false]) {
final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
if (!textBoundarySelection.isValid) {
return;
}
final bool inOrder = textBoundarySelection.baseOffset <= textBoundarySelection.extentOffset;
final bool towardsExtent = forward == inOrder;
final TextPosition position = towardsExtent
? textBoundarySelection.extent
: textBoundarySelection.base;
final TextPosition newExtent = forward
? textBoundary.getTrailingTextBoundaryAt(position)
: textBoundary.getLeadingTextBoundaryAt(position);
final TextSelection newSelection = textBoundarySelection.expandTo(newExtent, textBoundarySelection.isCollapsed || extentAtIndex);
userUpdateTextEditingValue(
_value.copyWith(selection: newSelection),
SelectionChangedCause.keyboard,
);
bringIntoView(newSelection.extent);
}
Object? _hideToolbarIfVisible(DismissIntent intent) {
if (_selectionOverlay?.toolbarIsVisible ?? false) {
hideToolbar(false);
return null;
}
return Actions.invoke(context, intent);
}
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
ReplaceTextIntent: _replaceTextAction,
UpdateSelectionIntent: _updateSelectionAction,
DirectionalFocusIntent: DirectionalFocusAction.forTextField(),
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideToolbarIfVisible),
// Delete
DeleteCharacterIntent: _makeOverridable(_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary)),
DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction<DeleteToNextWordBoundaryIntent>(this, _nextWordBoundary)),
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)),
// Extend/Move Selection
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ExpandSelectionToDocumentBoundaryIntent>(onInvoke: _expandSelectionToDocumentBoundary)),
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_adjacentLineAction),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),
// Copy Paste
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))),
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
};
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
super.build(context); // See AutomaticKeepAliveClientMixin.
final TextSelectionControls? controls = widget.selectionControls;
return MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions(
actions: _actions,
child: _TextEditingHistory(
controller: widget.controller,
onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
},
child: Focus(
focusNode: widget.focusNode,
includeSemantics: false,
debugLabel: 'EditableText',
child: Scrollable(
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
restorationId: widget.restorationId,
// If a ScrollBehavior is not provided, only apply scrollbars when
// multiline. The overscroll indicator should not be applied in
// either case, glowing or stretching.
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
scrollbars: _isMultiline,
overscroll: false,
),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);
},
child: _Editable(
key: _editableKey,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: EditableText.debugDeterministicCursor
? ValueNotifier<bool>(widget.showCursor)
: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: widget.selectionColor,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
textWidthBasis: widget.textWidthBasis,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
offset: offset,
onCaretChanged: _handleCaretChanged,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorOffset: widget.cursorOffset ?? Offset.zero,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget._userSelectionEnabled,
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange,
promptRectColor: widget.autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior,
),
),
),
);
},
),
),
),
),
);
}
/// Builds [TextSpan] from current editing value.
///
/// By default makes text in composing range appear as underlined.
/// Descendants can override this method to customize appearance of text.
TextSpan buildTextSpan() {
if (widget.obscureText) {
String text = _value.text;
text = widget.obscuringCharacter * text.length;
// Reveal the latest character in an obscured field only on mobile.
// Newer verions of iOS (iOS 15+) no longer reveal the most recently
// entered character.
const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
TargetPlatform.android, TargetPlatform.fuchsia,
};
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
&& mobilePlatforms.contains(defaultTargetPlatform);
if (breiflyShowPassword) {
final int? o = _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null;
if (o != null && o >= 0 && o < text.length) {
text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1));
}
}
return TextSpan(style: widget.style, text: text);
}
if (_placeholderLocation >= 0 && _placeholderLocation <= _value.text.length) {
final List<_ScribblePlaceholder> placeholders = <_ScribblePlaceholder>[];
final int placeholderLocation = _value.text.length - _placeholderLocation;
if (_isMultiline) {
// The zero size placeholder here allows the line to break and keep the caret on the first line.
placeholders.add(const _ScribblePlaceholder(child: SizedBox(), size: Size.zero));
placeholders.add(_ScribblePlaceholder(child: const SizedBox(), size: Size(renderEditable.size.width, 0.0)));
} else {
placeholders.add(const _ScribblePlaceholder(child: SizedBox(), size: Size(100.0, 0.0)));
}
return TextSpan(style: widget.style, children: <InlineSpan>[
TextSpan(text: _value.text.substring(0, placeholderLocation)),
...placeholders,
TextSpan(text: _value.text.substring(placeholderLocation)),
],
);
}
// Read only mode should not paint text composing.
return widget.controller.buildTextSpan(
context: context,
style: widget.style,
withComposing: !widget.readOnly && _hasFocus,
);
}
}
class _Editable extends MultiChildRenderObjectWidget {
_Editable({
super.key,
required this.inlineSpan,
required this.value,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
this.cursorColor,
this.backgroundCursorColor,
required this.showCursor,
required this.forceLine,
required this.readOnly,
this.textHeightBehavior,
required this.textWidthBasis,
required this.hasFocus,
required this.maxLines,
this.minLines,
required this.expands,
this.strutStyle,
this.selectionColor,
required this.textScaleFactor,
required this.textAlign,
required this.textDirection,
this.locale,
required this.obscuringCharacter,
required this.obscureText,
required this.offset,
this.onCaretChanged,
this.rendererIgnoresPointer = false,
required this.cursorWidth,
this.cursorHeight,
this.cursorRadius,
required this.cursorOffset,
required this.paintCursorAboveText,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.enableInteractiveSelection = true,
required this.textSelectionDelegate,
required this.devicePixelRatio,
this.promptRectRange,
this.promptRectColor,
required this.clipBehavior,
}) : assert(textDirection != null),
assert(rendererIgnoresPointer != null),
super(children: _extractChildren(inlineSpan));
// Traverses the InlineSpan tree and depth-first collects the list of
// child widgets that are created in WidgetSpans.
static List<Widget> _extractChildren(InlineSpan span) {
final List<Widget> result = <Widget>[];
span.visitChildren((InlineSpan span) {
if (span is WidgetSpan) {
result.add(span.child);
}
return true;
});
return result;
}
final InlineSpan inlineSpan;
final TextEditingValue value;
final Color? cursorColor;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final Color? backgroundCursorColor;
final ValueNotifier<bool> showCursor;
final bool forceLine;
final bool readOnly;
final bool hasFocus;
final int? maxLines;
final int? minLines;
final bool expands;
final StrutStyle? strutStyle;
final Color? selectionColor;
final double textScaleFactor;
final TextAlign textAlign;
final TextDirection textDirection;
final Locale? locale;
final String obscuringCharacter;
final bool obscureText;
final TextHeightBehavior? textHeightBehavior;
final TextWidthBasis textWidthBasis;
final ViewportOffset offset;
final CaretChangedHandler? onCaretChanged;
final bool rendererIgnoresPointer;
final double cursorWidth;
final double? cursorHeight;
final Radius? cursorRadius;
final Offset cursorOffset;
final bool paintCursorAboveText;
final ui.BoxHeightStyle selectionHeightStyle;
final ui.BoxWidthStyle selectionWidthStyle;
final bool enableInteractiveSelection;
final TextSelectionDelegate textSelectionDelegate;
final double devicePixelRatio;
final TextRange? promptRectRange;
final Color? promptRectColor;
final Clip clipBehavior;
@override
RenderEditable createRenderObject(BuildContext context) {
return RenderEditable(
text: inlineSpan,
cursorColor: cursorColor,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
backgroundCursorColor: backgroundCursorColor,
showCursor: showCursor,
forceLine: forceLine,
readOnly: readOnly,
hasFocus: hasFocus,
maxLines: maxLines,
minLines: minLines,
expands: expands,
strutStyle: strutStyle,
selectionColor: selectionColor,
textScaleFactor: textScaleFactor,
textAlign: textAlign,
textDirection: textDirection,
locale: locale ?? Localizations.maybeLocaleOf(context),
selection: value.selection,
offset: offset,
onCaretChanged: onCaretChanged,
ignorePointer: rendererIgnoresPointer,
obscuringCharacter: obscuringCharacter,
obscureText: obscureText,
textHeightBehavior: textHeightBehavior,
textWidthBasis: textWidthBasis,
cursorWidth: cursorWidth,
cursorHeight: cursorHeight,
cursorRadius: cursorRadius,
cursorOffset: cursorOffset,
paintCursorAboveText: paintCursorAboveText,
selectionHeightStyle: selectionHeightStyle,
selectionWidthStyle: selectionWidthStyle,
enableInteractiveSelection: enableInteractiveSelection,
textSelectionDelegate: textSelectionDelegate,
devicePixelRatio: devicePixelRatio,
promptRectRange: promptRectRange,
promptRectColor: promptRectColor,
clipBehavior: clipBehavior,
);
}
@override
void updateRenderObject(BuildContext context, RenderEditable renderObject) {
renderObject
..text = inlineSpan
..cursorColor = cursorColor
..startHandleLayerLink = startHandleLayerLink
..endHandleLayerLink = endHandleLayerLink
..showCursor = showCursor
..forceLine = forceLine
..readOnly = readOnly
..hasFocus = hasFocus
..maxLines = maxLines
..minLines = minLines
..expands = expands
..strutStyle = strutStyle
..selectionColor = selectionColor
..textScaleFactor = textScaleFactor
..textAlign = textAlign
..textDirection = textDirection
..locale = locale ?? Localizations.maybeLocaleOf(context)
..selection = value.selection
..offset = offset
..onCaretChanged = onCaretChanged
..ignorePointer = rendererIgnoresPointer
..textHeightBehavior = textHeightBehavior
..textWidthBasis = textWidthBasis
..obscuringCharacter = obscuringCharacter
..obscureText = obscureText
..cursorWidth = cursorWidth
..cursorHeight = cursorHeight
..cursorRadius = cursorRadius
..cursorOffset = cursorOffset
..selectionHeightStyle = selectionHeightStyle
..selectionWidthStyle = selectionWidthStyle
..enableInteractiveSelection = enableInteractiveSelection
..textSelectionDelegate = textSelectionDelegate
..devicePixelRatio = devicePixelRatio
..paintCursorAboveText = paintCursorAboveText
..promptRectColor = promptRectColor
..clipBehavior = clipBehavior
..setPromptRectRange(promptRectRange);
}
}
class _ScribbleFocusable extends StatefulWidget {
const _ScribbleFocusable({
required this.child,
required this.focusNode,
required this.editableKey,
required this.updateSelectionRects,
required this.enabled,
});
final Widget child;
final FocusNode focusNode;
final GlobalKey editableKey;
final VoidCallback updateSelectionRects;
final bool enabled;
@override
_ScribbleFocusableState createState() => _ScribbleFocusableState();
}
class _ScribbleFocusableState extends State<_ScribbleFocusable> implements ScribbleClient {
_ScribbleFocusableState(): _elementIdentifier = (_nextElementIdentifier++).toString();
@override
void initState() {
super.initState();
if (widget.enabled) {
TextInput.registerScribbleElement(elementIdentifier, this);
}
}
@override
void didUpdateWidget(_ScribbleFocusable oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.enabled && widget.enabled) {
TextInput.registerScribbleElement(elementIdentifier, this);
}
if (oldWidget.enabled && !widget.enabled) {
TextInput.unregisterScribbleElement(elementIdentifier);
}
}
@override
void dispose() {
TextInput.unregisterScribbleElement(elementIdentifier);
super.dispose();
}
RenderEditable? get renderEditable => widget.editableKey.currentContext?.findRenderObject() as RenderEditable?;
static int _nextElementIdentifier = 1;
final String _elementIdentifier;
@override
String get elementIdentifier => _elementIdentifier;
@override
void onScribbleFocus(Offset offset) {
widget.focusNode.requestFocus();
renderEditable?.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble);
widget.updateSelectionRects();
}
@override
bool isInScribbleRect(Rect rect) {
final Rect calculatedBounds = bounds;
if (renderEditable?.readOnly ?? false) {
return false;
}
if (calculatedBounds == Rect.zero) {
return false;
}
if (!calculatedBounds.overlaps(rect)) {
return false;
}
final Rect intersection = calculatedBounds.intersect(rect);
final HitTestResult result = HitTestResult();
WidgetsBinding.instance.hitTest(result, intersection.center);
return result.path.any((HitTestEntry entry) => entry.target == renderEditable);
}
@override
Rect get bounds {
final RenderBox? box = context.findRenderObject() as RenderBox?;
if (box == null || !mounted || !box.attached) {
return Rect.zero;
}
final Matrix4 transform = box.getTransformTo(null);
return MatrixUtils.transformRect(transform, Rect.fromLTWH(0, 0, box.size.width, box.size.height));
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class _ScribblePlaceholder extends WidgetSpan {
const _ScribblePlaceholder({
required super.child,
super.alignment,
super.baseline,
required this.size,
}) : assert(child != null),
assert(baseline != null || !(
identical(alignment, ui.PlaceholderAlignment.aboveBaseline) ||
identical(alignment, ui.PlaceholderAlignment.belowBaseline) ||
identical(alignment, ui.PlaceholderAlignment.baseline)
));
/// The size of the span, used in place of adding a placeholder size to the [TextPainter].
final Size size;
@override
void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List<PlaceholderDimensions>? dimensions }) {
assert(debugAssertIsValid());
final bool hasStyle = style != null;
if (hasStyle) {
builder.pushStyle(style!.getTextStyle(textScaleFactor: textScaleFactor));
}
builder.addPlaceholder(
size.width,
size.height,
alignment,
scale: textScaleFactor,
);
if (hasStyle) {
builder.pop();
}
}
}
/// An interface for retriving the logical text boundary (left-closed-right-open)
/// at a given location in a document.
///
/// Depending on the implementation of the [_TextBoundary], the input
/// [TextPosition] can either point to a code unit, or a position between 2 code
/// units (which can be visually represented by the caret if the selection were
/// to collapse to that position).
///
/// For example, [_LineBreak] interprets the input [TextPosition] as a caret
/// location, since in Flutter the caret is generally painted between the
/// character the [TextPosition] points to and its previous character, and
/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most
/// other text boundaries however, interpret the input [TextPosition] as the
/// location of a code unit in the document, since it's easier to reason about
/// the text boundary given a code unit in the text.
///
/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based",
/// use the [_CollapsedSelectionBoundary] combinator.
abstract class _TextBoundary {
const _TextBoundary();
TextEditingValue get textEditingValue;
/// Returns the leading text boundary at the given location, inclusive.
TextPosition getLeadingTextBoundaryAt(TextPosition position);
/// Returns the trailing text boundary at the given location, exclusive.
TextPosition getTrailingTextBoundaryAt(TextPosition position);
TextRange getTextBoundaryAt(TextPosition position) {
return TextRange(
start: getLeadingTextBoundaryAt(position).offset,
end: getTrailingTextBoundaryAt(position).offset,
);
}
}
// ----------------------------- Text Boundaries -----------------------------
class _CodeUnitBoundary extends _TextBoundary {
const _CodeUnitBoundary(this.textEditingValue);
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => TextPosition(offset: position.offset);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) => TextPosition(offset: math.min(position.offset + 1, textEditingValue.text.length));
}
// The word modifier generally removes the word boundaries around white spaces
// (and newlines), IOW white spaces and some other punctuations are considered
// a part of the next word in the search direction.
class _WhitespaceBoundary extends _TextBoundary {
const _WhitespaceBoundary(this.textEditingValue);
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
for (int index = position.offset; index >= 0; index -= 1) {
if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
return TextPosition(offset: index);
}
}
return const TextPosition(offset: 0);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
for (int index = position.offset; index < textEditingValue.text.length; index += 1) {
if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
return TextPosition(offset: index + 1);
}
}
return TextPosition(offset: textEditingValue.text.length);
}
}
// Most apps delete the entire grapheme when the backspace key is pressed.
// Also always put the new caret location to character boundaries to avoid
// sending malformed UTF-16 code units to the paragraph builder.
class _CharacterBoundary extends _TextBoundary {
const _CharacterBoundary(this.textEditingValue);
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
return TextPosition(
offset: CharacterRange.at(textEditingValue.text, position.offset, endOffset).stringBeforeLength,
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
return TextPosition(
offset: textEditingValue.text.length - range.stringAfterLength,
);
}
@override
TextRange getTextBoundaryAt(TextPosition position) {
final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
return TextRange(
start: range.stringBeforeLength,
end: textEditingValue.text.length - range.stringAfterLength,
);
}
}
// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries.
class _WordBoundary extends _TextBoundary {
const _WordBoundary(this.textLayout, this.textEditingValue);
final TextLayoutMetrics textLayout;
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: textLayout.getWordBoundary(position).start,
// Word boundary seems to always report downstream on many platforms.
affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: textLayout.getWordBoundary(position).end,
// Word boundary seems to always report downstream on many platforms.
affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
);
}
}
// The linebreaks of the current text layout. The input [TextPosition]s are
// interpreted as caret locations because [TextPainter.getLineAtOffset] is
// text-affinity-aware.
class _LineBreak extends _TextBoundary {
const _LineBreak(
this.textLayout,
this.textEditingValue,
);
final TextLayoutMetrics textLayout;
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: textLayout.getLineAtOffset(position).start,
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: textLayout.getLineAtOffset(position).end,
affinity: TextAffinity.upstream,
);
}
}
// The document boundary is unique and is a constant function of the input
// position.
class _DocumentBoundary extends _TextBoundary {
const _DocumentBoundary(this.textEditingValue);
@override
final TextEditingValue textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: textEditingValue.text.length,
affinity: TextAffinity.upstream,
);
}
}
// ------------------------ Text Boundary Combinators ------------------------
// Expands the innerTextBoundary with outerTextBoundary.
class _ExpandedTextBoundary extends _TextBoundary {
_ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary);
final _TextBoundary innerTextBoundary;
final _TextBoundary outerTextBoundary;
@override
TextEditingValue get textEditingValue {
assert(innerTextBoundary.textEditingValue == outerTextBoundary.textEditingValue);
return innerTextBoundary.textEditingValue;
}
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return outerTextBoundary.getLeadingTextBoundaryAt(
innerTextBoundary.getLeadingTextBoundaryAt(position),
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return outerTextBoundary.getTrailingTextBoundaryAt(
innerTextBoundary.getTrailingTextBoundaryAt(position),
);
}
}
// Force the innerTextBoundary to interpret the input [TextPosition]s as caret
// locations instead of code unit positions.
//
// The innerTextBoundary must be a [_TextBoundary] that interprets the input
// [TextPosition]s as code unit positions.
class _CollapsedSelectionBoundary extends _TextBoundary {
_CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward);
final _TextBoundary innerTextBoundary;
final bool isForward;
@override
TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return isForward
? innerTextBoundary.getLeadingTextBoundaryAt(position)
: position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getLeadingTextBoundaryAt(TextPosition(offset: position.offset - 1));
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return isForward
? innerTextBoundary.getTrailingTextBoundaryAt(position)
: position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getTrailingTextBoundaryAt(TextPosition(offset: position.offset - 1));
}
}
// A _TextBoundary that creates a [TextRange] where its start is from the
// specified leading text boundary and its end is from the specified trailing
// text boundary.
class _MixedBoundary extends _TextBoundary {
_MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary);
final _TextBoundary leadingTextBoundary;
final _TextBoundary trailingTextBoundary;
@override
TextEditingValue get textEditingValue {
assert(leadingTextBoundary.textEditingValue == trailingTextBoundary.textEditingValue);
return leadingTextBoundary.textEditingValue;
}
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) => trailingTextBoundary.getTrailingTextBoundaryAt(position);
}
// ------------------------------- Text Actions -------------------------------
class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextAction<T> {
_DeleteTextAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state;
final _TextBoundary Function(T intent) getTextBoundariesForIntent;
TextRange _expandNonCollapsedRange(TextEditingValue value) {
final TextRange selection = value.selection;
assert(selection.isValid);
assert(!selection.isCollapsed);
final _TextBoundary atomicBoundary = state.widget.obscureText
? _CodeUnitBoundary(value)
: _CharacterBoundary(value);
return TextRange(
start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset,
end: atomicBoundary.getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)).offset,
);
}
@override
Object? invoke(T intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection;
assert(selection.isValid);
if (!selection.isCollapsed) {
return Actions.invoke(
context!,
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
);
}
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
if (!textBoundary.textEditingValue.selection.isValid) {
return null;
}
if (!textBoundary.textEditingValue.selection.isCollapsed) {
return Actions.invoke(
context!,
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(textBoundary.textEditingValue), SelectionChangedCause.keyboard),
);
}
return Actions.invoke(
context!,
ReplaceTextIntent(
textBoundary.textEditingValue,
'',
textBoundary.getTextBoundaryAt(textBoundary.textEditingValue.selection.base),
SelectionChangedCause.keyboard,
),
);
}
@override
bool get isActionEnabled => !state.widget.readOnly && state._value.selection.isValid;
}
class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionAction(
this.state,
this.ignoreNonCollapsedSelection,
this.getTextBoundariesForIntent,
);
final EditableTextState state;
final bool ignoreNonCollapsedSelection;
final _TextBoundary Function(T intent) getTextBoundariesForIntent;
static const int NEWLINE_CODE_UNIT = 10;
// Returns true iff the given position is at a wordwrap boundary in the
// upstream position.
bool _isAtWordwrapUpstream(TextPosition position) {
final TextPosition end = TextPosition(
offset: state.renderEditable.getLineAtOffset(position).end,
affinity: TextAffinity.upstream,
);
return end == position && end.offset != state.textEditingValue.text.length
&& state.textEditingValue.text.codeUnitAt(position.offset) != NEWLINE_CODE_UNIT;
}
// Returns true iff the given position at a wordwrap boundary in the
// downstream position.
bool _isAtWordwrapDownstream(TextPosition position) {
final TextPosition start = TextPosition(
offset: state.renderEditable.getLineAtOffset(position).start,
);
return start == position && start.offset != 0
&& state.textEditingValue.text.codeUnitAt(position.offset - 1) != NEWLINE_CODE_UNIT;
}
@override
Object? invoke(T intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection;
assert(selection.isValid);
final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled;
// Collapse to the logical start/end.
TextSelection collapse(TextSelection selection) {
assert(selection.isValid);
assert(!selection.isCollapsed);
return selection.copyWith(
baseOffset: intent.forward ? selection.end : selection.start,
extentOffset: intent.forward ? selection.end : selection.start,
);
}
if (!selection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, collapse(selection), SelectionChangedCause.keyboard),
);
}
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
if (!textBoundarySelection.isValid) {
return null;
}
if (!textBoundarySelection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, collapse(textBoundarySelection), SelectionChangedCause.keyboard),
);
}
TextPosition extent = textBoundarySelection.extent;
// If continuesAtWrap is true extent and is at the relevant wordwrap, then
// move it just to the other side of the wordwrap.
if (intent.continuesAtWrap) {
if (intent.forward && _isAtWordwrapUpstream(extent)) {
extent = TextPosition(
offset: extent.offset,
);
} else if (!intent.forward && _isAtWordwrapDownstream(extent)) {
extent = TextPosition(
offset: extent.offset,
affinity: TextAffinity.upstream,
);
}
}
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent);
final TextSelection newSelection = collapseSelection
? TextSelection.fromPosition(newExtent)
: textBoundarySelection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it.
if (!selection.isCollapsed && intent.collapseAtReversal
&& (selection.baseOffset < selection.extentOffset !=
newSelection.baseOffset < newSelection.extentOffset)) {
return Actions.invoke(
context!,
UpdateSelectionIntent(
state._value,
TextSelection.fromPosition(selection.base),
SelectionChangedCause.keyboard,
),
);
}
return Actions.invoke(
context!,
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
);
}
@override
bool get isActionEnabled => state._value.selection.isValid;
}
class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> {
_ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state;
final _TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
@override
Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection;
assert(selection.isValid);
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
if (!textBoundarySelection.isValid) {
return null;
}
final TextPosition extent = textBoundarySelection.extent;
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent);
final TextSelection newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * (textBoundarySelection.extentOffset - textBoundarySelection.baseOffset) < 0
? textBoundarySelection.copyWith(
extentOffset: textBoundarySelection.baseOffset,
affinity: textBoundarySelection.extentOffset > textBoundarySelection.baseOffset ? TextAffinity.downstream : TextAffinity.upstream,
)
: textBoundarySelection.extendTo(newExtent);
return Actions.invoke(
context!,
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
);
}
@override
bool get isActionEnabled => state.widget.selectionEnabled && state._value.selection.isValid;
}
class _UpdateTextSelectionToAdjacentLineAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionToAdjacentLineAction(this.state);
final EditableTextState state;
VerticalCaretMovementRun? _verticalMovementRun;
TextSelection? _runSelection;
void stopCurrentVerticalRunIfSelectionChanges() {
final TextSelection? runSelection = _runSelection;
if (runSelection == null) {
assert(_verticalMovementRun == null);
return;
}
_runSelection = state._value.selection;
final TextSelection currentSelection = state.widget.controller.selection;
final bool continueCurrentRun = currentSelection.isValid && currentSelection.isCollapsed
&& currentSelection.baseOffset == runSelection.baseOffset
&& currentSelection.extentOffset == runSelection.extentOffset;
if (!continueCurrentRun) {
_verticalMovementRun = null;
_runSelection = null;
}
}
@override
void invoke(T intent, [BuildContext? context]) {
assert(state._value.selection.isValid);
final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled;
final TextEditingValue value = state._textEditingValueforTextLayoutMetrics;
if (!value.selection.isValid) {
return;
}
if (_verticalMovementRun?.isValid == false) {
_verticalMovementRun = null;
_runSelection = null;
}
final VerticalCaretMovementRun currentRun = _verticalMovementRun
?? state.renderEditable.startVerticalCaretMovement(state.renderEditable.selection!.extent);
final bool shouldMove = intent.forward ? currentRun.moveNext() : currentRun.movePrevious();
final TextPosition newExtent = shouldMove
? currentRun.current
: (intent.forward ? TextPosition(offset: state._value.text.length) : const TextPosition(offset: 0));
final TextSelection newSelection = collapseSelection
? TextSelection.fromPosition(newExtent)
: value.selection.extendTo(newExtent);
Actions.invoke(
context!,
UpdateSelectionIntent(value, newSelection, SelectionChangedCause.keyboard),
);
if (state._value.selection == newSelection) {
_verticalMovementRun = currentRun;
_runSelection = newSelection;
}
}
@override
bool get isActionEnabled => state._value.selection.isValid;
}
class _SelectAllAction extends ContextAction<SelectAllTextIntent> {
_SelectAllAction(this.state);
final EditableTextState state;
@override
Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) {
return Actions.invoke(
context!,
UpdateSelectionIntent(
state._value,
TextSelection(baseOffset: 0, extentOffset: state._value.text.length),
intent.cause,
),
);
}
@override
bool get isActionEnabled => state.widget.selectionEnabled;
}
class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
_CopySelectionAction(this.state);
final EditableTextState state;
@override
void invoke(CopySelectionTextIntent intent, [BuildContext? context]) {
if (intent.collapseSelection) {
state.cutSelection(intent.cause);
} else {
state.copySelection(intent.cause);
}
}
@override
bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed;
}
/// A void function that takes a [TextEditingValue].
@visibleForTesting
typedef TextEditingValueCallback = void Function(TextEditingValue value);
/// Provides undo/redo capabilities for text editing.
///
/// Listens to [controller] as a [ValueNotifier] and saves relevant values for
/// undoing/redoing. The cadence at which values are saved is a best
/// approximation of the native behaviors of a hardware keyboard on Flutter's
/// desktop platforms, as there are subtle differences between each of these
/// platforms.
///
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
/// shortcut is triggered that would affect the state of the [controller].
class _TextEditingHistory extends StatefulWidget {
/// Creates an instance of [_TextEditingHistory].
const _TextEditingHistory({
required this.child,
required this.controller,
required this.onTriggered,
});
/// The child widget of [_TextEditingHistory].
final Widget child;
/// The [TextEditingController] to save the state of over time.
final TextEditingController controller;
/// Called when an undo or redo causes a state change.
///
/// If the state would still be the same before and after the undo/redo, this
/// will not be called. For example, receiving a redo when there is nothing
/// to redo will not call this method.
///
/// It is also not called when the controller is changed for reasons other
/// than undo/redo.
final TextEditingValueCallback onTriggered;
@override
State<_TextEditingHistory> createState() => _TextEditingHistoryState();
}
class _TextEditingHistoryState extends State<_TextEditingHistory> {
final _UndoStack<TextEditingValue> _stack = _UndoStack<TextEditingValue>();
late final _Throttled<TextEditingValue> _throttledPush;
Timer? _throttleTimer;
// This duration was chosen as a best fit for the behavior of Mac, Linux,
// and Windows undo/redo state save durations, but it is not perfect for any
// of them.
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
void _undo(UndoTextIntent intent) {
_update(_stack.undo());
}
void _redo(RedoTextIntent intent) {
_update(_stack.redo());
}
void _update(TextEditingValue? nextValue) {
if (nextValue == null) {
return;
}
if (nextValue.text == widget.controller.text) {
return;
}
widget.onTriggered(widget.controller.value.copyWith(
text: nextValue.text,
selection: nextValue.selection,
));
}
void _push() {
if (widget.controller.value == TextEditingValue.empty) {
return;
}
_throttleTimer = _throttledPush(widget.controller.value);
}
@override
void initState() {
super.initState();
_throttledPush = _throttle<TextEditingValue>(
duration: _kThrottleDuration,
function: _stack.push,
);
_push();
widget.controller.addListener(_push);
}
@override
void didUpdateWidget(_TextEditingHistory oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
_stack.clear();
oldWidget.controller.removeListener(_push);
widget.controller.addListener(_push);
}
}
@override
void dispose() {
widget.controller.removeListener(_push);
_throttleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> {
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undo)),
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redo)),
},
child: widget.child,
);
}
}
/// A data structure representing a chronological list of states that can be
/// undone and redone.
class _UndoStack<T> {
/// Creates an instance of [_UndoStack].
_UndoStack();
final List<T> _list = <T>[];
// The index of the current value, or null if the list is emtpy.
late int _index;
/// Returns the current value of the stack.
T? get currentValue => _list.isEmpty ? null : _list[_index];
/// Add a new state change to the stack.
///
/// Pushing identical objects will not create multiple entries.
void push(T value) {
if (_list.isEmpty) {
_index = 0;
_list.add(value);
return;
}
assert(_index < _list.length && _index >= 0);
if (value == currentValue) {
return;
}
// If anything has been undone in this stack, remove those irrelevant states
// before adding the new one.
if (_index != null && _index != _list.length - 1) {
_list.removeRange(_index + 1, _list.length);
}
_list.add(value);
_index = _list.length - 1;
}
/// Returns the current value after an undo operation.
///
/// An undo operation moves the current value to the previously pushed value,
/// if any.
///
/// Iff the stack is completely empty, then returns null.
T? undo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index != 0) {
_index = _index - 1;
}
return currentValue;
}
/// Returns the current value after a redo operation.
///
/// A redo operation moves the current value to the value that was last
/// undone, if any.
///
/// Iff the stack is completely empty, then returns null.
T? redo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index < _list.length - 1) {
_index = _index + 1;
}
return currentValue;
}
/// Remove everything from the stack.
void clear() {
_list.clear();
_index = -1;
}
@override
String toString() {
return '_UndoStack $_list';
}
}
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
/// Returns a _Throttled that will call through to the given function only a
/// maximum of once per duration.
///
/// Only works for functions that take exactly one argument and return void.
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
// If true, calls at the start of the timer.
bool leadingEdge = false,
}) {
Timer? timer;
bool calledDuringTimer = false;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null) {
calledDuringTimer = true;
return timer!;
}
if (leadingEdge) {
function(arg);
}
calledDuringTimer = false;
timer = Timer(duration, () {
if (!leadingEdge || calledDuringTimer) {
function(arg);
}
timer = null;
});
return timer!;
};
}