| // 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 'default_text_editing_shortcuts.dart'; |
| import 'focus_manager.dart'; |
| import 'focus_scope.dart'; |
| import 'focus_traversal.dart'; |
| import 'framework.dart'; |
| import 'localizations.dart'; |
| import 'magnifier.dart'; |
| import 'media_query.dart'; |
| import 'scroll_configuration.dart'; |
| import 'scroll_controller.dart'; |
| import 'scroll_physics.dart'; |
| import 'scrollable.dart'; |
| import 'shortcuts.dart'; |
| import 'spell_check.dart'; |
| import 'tap_region.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, SmartDashesType, SmartQuotesType, TextEditingValue, TextInputType, TextSelection; |
| |
| // Examples can assume: |
| // late BuildContext context; |
| |
| /// 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; |
| |
| /// 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. |
| final bool composingRegionOutOfRange = !value.isComposingRangeValid || !withComposing; |
| |
| if (composingRegionOutOfRange) { |
| 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 (the `\$` inserts |
| /// a dollar sign, the `$newText interpolates the `newText` variable): |
| /// |
| /// ```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, |
| this.onTapOutside, |
| 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, |
| this.spellCheckConfiguration, |
| this.magnifierConfiguration = TextMagnifierConfiguration.disabled, |
| }) : 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), |
| assert( |
| spellCheckConfiguration == null || |
| spellCheckConfiguration == const SpellCheckConfiguration.disabled() || |
| spellCheckConfiguration.misspelledTextStyle != null, |
| 'spellCheckConfiguration must specify a misspelledTextStyle if spell check behavior is desired', |
| ), |
| _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 |
| /// const 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 |
| /// const 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 |
| /// const TextField(maxLines: 2) |
| /// ``` |
| /// |
| /// Input whose height grows with content between a min and max. An infinite |
| /// max is possible with `maxLines: null`. |
| /// ```dart |
| /// const 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 |
| /// const 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 |
| /// const 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. |
| /// * [TextEditingController], which implements the [Listenable] interface |
| /// and notifies its listeners on [TextEditingValue] changes. |
| 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.onTapOutside} |
| /// Called for each tap that occurs outside of the[TextFieldTapRegion] group |
| /// when the text field is focused. |
| /// |
| /// If this is null, [FocusNode.unfocus] will be called on the [focusNode] for |
| /// this text field when a [PointerDownEvent] is received on another part of |
| /// the UI. However, it will not unfocus as a result of mobile application |
| /// touch events (which does not include mouse clicks), to conform with the |
| /// platform conventions. To change this behavior, a callback may be set here |
| /// that operates differently from the default. |
| /// |
| /// When adding additional controls to a text field (for example, a spinner, a |
| /// button that copies the selected text, or modifies formatting), it is |
| /// helpful if tapping on that control doesn't unfocus the text field. In |
| /// order for an external widget to be considered as part of the text field |
| /// for the purposes of tapping "outside" of the field, wrap the control in a |
| /// [TextFieldTapRegion]. |
| /// |
| /// The [PointerDownEvent] passed to the function is the event that caused the |
| /// notification. It is possible that the event may occur outside of the |
| /// immediate bounding box defined by the text field, although it will be |
| /// within the bounding box of a [TextFieldTapRegion] member. |
| /// {@endtemplate} |
| /// |
| /// {@tool dartpad} |
| /// This example shows how to use a `TextFieldTapRegion` to wrap a set of |
| /// "spinner" buttons that increment and decrement a value in the [TextField] |
| /// without causing the text field to lose keyboard focus. |
| /// |
| /// This example includes a generic `SpinnerField<T>` class that you can copy |
| /// into your own project and customize. |
| /// |
| /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// * [TapRegion] for how the region group is determined. |
| final TapRegionCallback? onTapOutside; |
| |
| /// {@template flutter.widgets.editableText.inputFormatters} |
| /// Optional input validation and formatting overrides. |
| /// |
| /// Formatters are run in the provided order when the user changes the text |
| /// this widget contains. When this parameter changes, the new formatters will |
| /// not be applied until the next time the user inserts or deletes text. |
| /// Similar to the [onChanged] callback, formatters don't run when the text is |
| /// changed programmatically via [controller]. |
| /// |
| /// See also: |
| /// |
| /// * [TextEditingController], which implements the [Listenable] interface |
| /// and notifies its listeners on [TextEditingValue] changes. |
| /// {@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; |
| |
| /// {@template flutter.widgets.EditableText.spellCheckConfiguration} |
| /// Configuration that details how spell check should be performed. |
| /// |
| /// Specifies the [SpellCheckService] used to spell check text input and the |
| /// [TextStyle] used to style text with misspelled words. |
| /// |
| /// If the [SpellCheckService] is left null, spell check is disabled by |
| /// default unless the [DefaultSpellCheckService] is supported, in which case |
| /// it is used. It is currently supported only on Android. |
| /// |
| /// If this configuration is left null, then spell check is disabled by default. |
| /// {@endtemplate} |
| final SpellCheckConfiguration? spellCheckConfiguration; |
| |
| /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro} |
| /// |
| /// {@macro flutter.widgets.magnifier.intro} |
| /// |
| /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} |
| final TextMagnifierConfiguration magnifierConfiguration; |
| |
| 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)); |
| properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); |
| } |
| } |
| |
| /// 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; |
| bool get _hasInputConnection => _textInputConnection?.attached ?? false; |
| |
| 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; |
| |
| late SpellCheckConfiguration _spellCheckConfiguration; |
| |
| /// Configuration that determines how spell check will be performed. |
| /// |
| /// If possible, this configuration will contain a default for the |
| /// [SpellCheckService] if it is not otherwise specified. |
| /// |
| /// See also: |
| /// * [DefaultSpellCheckService], the spell check service used by default. |
| @visibleForTesting |
| SpellCheckConfiguration get spellCheckConfiguration => _spellCheckConfiguration; |
| |
| /// Whether or not spell check is enabled. |
| /// |
| /// Spell check is enabled when a [SpellCheckConfiguration] has been specified |
| /// for the widget. |
| bool get spellCheckEnabled => _spellCheckConfiguration.spellCheckEnabled; |
| |
| /// The most up-to-date spell check results for text input. |
| /// |
| /// These results will be updated via calls to spell check through a |
| /// [SpellCheckService] and used by this widget to build the [TextSpan] tree |
| /// for text input and menus for replacement suggestions of misspelled words. |
| SpellCheckResults? _spellCheckResults; |
| |
| /// 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) { |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| bringIntoView(textEditingValue.selection.extent); |
| break; |
| case TargetPlatform.macOS: |
| case TargetPlatform.iOS: |
| break; |
| } |
| } |
| } |
| |
| /// Infers the [SpellCheckConfiguration] used to perform spell check. |
| /// |
| /// If spell check is enabled, this will try to infer a value for |
| /// the [SpellCheckService] if left unspecified. |
| static SpellCheckConfiguration _inferSpellCheckConfiguration(SpellCheckConfiguration? configuration) { |
| if (configuration == null || configuration == const SpellCheckConfiguration.disabled()) { |
| return const SpellCheckConfiguration.disabled(); |
| } |
| |
| SpellCheckService? spellCheckService = configuration.spellCheckService; |
| |
| assert( |
| spellCheckService != null |
| || WidgetsBinding.instance.platformDispatcher.nativeSpellCheckServiceDefined, |
| 'spellCheckService must be specified for this platform because no default service available', |
| ); |
| |
| spellCheckService = spellCheckService ?? DefaultSpellCheckService(); |
| |
| return configuration.copyWith(spellCheckService: spellCheckService); |
| } |
| |
| // State lifecycle: |
| |
| @override |
| void initState() { |
| super.initState(); |
| _clipboardStatus?.addListener(_onChangedClipboardStatus); |
| widget.controller.addListener(_didChangeTextEditingValue); |
| widget.focusNode.addListener(_handleFocusChanged); |
| _scrollController.addListener(_onEditableScroll); |
| _cursorVisibilityNotifier.value = widget.showCursor; |
| _spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration); |
| } |
| |
| // 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(_onEditableScroll); |
| _scrollController.addListener(_onEditableScroll); |
| } |
| |
| 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 (_checkNeedsAdjustAffinity(value)) { |
| value = value.copyWith(selection: value.selection.copyWith(affinity: _value.selection.affinity)); |
| } |
| |
| 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 { |
| // Only hide the toolbar overlay, the selection handle's visibility will be handled |
| // by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673 |
| hideToolbar(false); |
| _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(); |
| } |
| } |
| |
| bool _checkNeedsAdjustAffinity(TextEditingValue value) { |
| // Trust the engine affinity if the text changes or selection changes. |
| return value.text == _value.text && |
| value.selection.isCollapsed == _value.selection.isCollapsed && |
| value.selection.start == _value.selection.start && |
| value.selection.affinity != _value.selection.affinity; |
| } |
| |
| @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?.call(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(); |
| } |
| // Stop cursor blinking and making it visible. |
| _stopCursorBlink(resetCharTicks: false); |
| _cursorBlinkOpacityController.value = 1.0; |
| // 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: |
| // Resume cursor blinking. |
| _startCursorBlink(); |
| // 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); |
| } |
| |
| /// 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 didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { |
| if (_hasFocus && _hasInputConnection) { |
| oldControl?.hide(); |
| newControl?.show(); |
| } |
| } |
| |
| @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 _onEditableScroll() { |
| _selectionOverlay?.updateForScroll(); |
| _scribbleCacheKey = null; |
| } |
| |
| TextSelectionOverlay _createSelectionOverlay() { |
| final TextSelectionOverlay 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, |
| magnifierConfiguration: widget.magnifierConfiguration, |
| ); |
| |
| return selectionOverlay; |
| } |
| |
| @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) { |
| _selectionOverlay = _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!); |
| |
| final Rect rectToReveal; |
| final TextSelection selection = textEditingValue.selection; |
| if (selection.isCollapsed) { |
| rectToReveal = targetOffset.rect; |
| } else { |
| final List<Rect> selectionBoxes = renderEditable.getBoxesForSelection(selection); |
| rectToReveal = selection.baseOffset < selection.extentOffset ? |
| selectionBoxes.last : selectionBoxes.first; |
| } |
| |
| if (withAnimation) { |
| _scrollController.animateTo( |
| targetOffset.offset, |
| duration: _caretAnimationDuration, |
| curve: _caretAnimationCurve, |
| ); |
| renderEditable.showOnScreen( |
| rect: caretPadding.inflateRect(rectToReveal), |
| duration: _caretAnimationDuration, |
| curve: _caretAnimationCurve, |
| ); |
| } else { |
| _scrollController.jumpTo(targetOffset.offset); |
| if (_value.selection.isCollapsed) { |
| renderEditable.showOnScreen( |
| rect: caretPadding.inflateRect(rectToReveal), |
| ); |
| } |
| } |
| }); |
| } |
| |
| 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; |
| } |
| |
| Future<void> _performSpellCheck(final String text) async { |
| try { |
| final Locale? localeForSpellChecking = widget.locale ?? Localizations.maybeLocaleOf(context); |
| |
| assert( |
| localeForSpellChecking != null, |
| 'Locale must be specified in widget or Localization widget must be in scope', |
| ); |
| |
| final List<SuggestionSpan>? spellCheckResults = await |
| _spellCheckConfiguration |
| .spellCheckService! |
| .fetchSpellCheckSuggestions(localeForSpellChecking!, text); |
| |
| if (spellCheckResults == null) { |
| // The request to fetch spell check suggestions was canceled due to ongoing request. |
| return; |
| } |
| |
| _spellCheckResults = SpellCheckResults(text, spellCheckResults); |
| renderEditable.text = buildTextSpan(); |
| } catch (exception, stack) { |
| FlutterError.reportError(FlutterErrorDetails( |
| exception: exception, |
| stack: stack, |
| library: 'widgets', |
| context: ErrorDescription('while performing spell check'), |
| )); |
| } |
| } |
| |
| @pragma('vm:notify-debugger-on-exception') |
| void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) { |
| final TextEditingValue oldValue = _value; |
| final bool textChanged = oldValue.text != value.text; |
| final bool textCommitted = !oldValue.composing.isCollapsed && value.composing.isCollapsed; |
| final bool selectionChanged = oldValue.selection != value.selection; |
| |
| if (textChanged || textCommitted) { |
| // 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. |
| try { |
| value = widget.inputFormatters?.fold<TextEditingValue>( |
| value, |
| (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), |
| ) ?? value; |
| |
| if (spellCheckEnabled && value.text.isNotEmpty && _value.text != value.text) { |
| _performSpellCheck(value.text); |
| } |
| } 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); |
| } |
| final String currentText = _value.text; |
| if (oldValue.text != currentText) { |
| try { |
| widget.onChanged?.call(currentText); |
| } 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; |
| _cursorTimer?.cancel(); |
| _cursorTimer = null; |
| 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); |
| } |
| } else { |
| WidgetsBinding.instance.removeObserver(this); |
| setState(() { _currentPromptRectRange = null; }); |
| } |
| updateKeepAlive(); |
| } |
| |
| _ScribbleCacheKey? _scribbleCacheKey; |
| |
| void _updateSelectionRects({bool force = false}) { |
| if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) { |
| return; |
| } |
| |
| final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection; |
| if (scrollDirection != ScrollDirection.idle) { |
| return; |
| } |
| |
| final InlineSpan inlineSpan = renderEditable.text!; |
| final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey( |
| inlineSpan: inlineSpan, |
| textAlign: widget.textAlign, |
| textDirection: _textDirection, |
| textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), |
| textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), |
| locale: widget.locale, |
| structStyle: widget.strutStyle, |
| placeholder: _placeholderLocation, |
| size: renderEditable.size, |
| ); |
| |
| final RenderComparison comparison = force |
| ? RenderComparison.layout |
| : _scribbleCacheKey?.compare(newCacheKey) ?? RenderComparison.layout; |
| if (comparison.index < RenderComparison.layout.index) { |
| return; |
| } |
| _scribbleCacheKey = newCacheKey; |
| |
| final List<SelectionRect> rects = <SelectionRect>[]; |
| int graphemeStart = 0; |
| // Can't use _value.text here: the controller value could change between |
| // frames. |
| final String plainText = inlineSpan.toPlainText(includeSemanticsLabels: false); |
| final CharacterRange characterRange = CharacterRange(plainText); |
| while (characterRange.moveNext()) { |
| final int graphemeEnd = graphemeStart + characterRange.current.length; |
| final List<Rect> boxes = renderEditable.getBoxesForSelection( |
| TextSelection(baseOffset: graphemeStart, extentOffset: graphemeEnd), |
| ); |
| |
| final Rect? box = boxes.isEmpty ? null : boxes.first; |
| if (box != null) { |
| final Rect paintBounds = renderEditable.paintBounds; |
| // Stop early when characters are already below the bottom edge of the |
| // RenderEditable, regardless of its clipBehavior. |
| if (paintBounds.bottom <= box.top) { |
| break; |
| } |
| if (paintBounds.contains(box.topLeft) || paintBounds.contains(box.bottomRight)) { |
| rects.add(SelectionRect(position: graphemeStart, bounds: box)); |
| } |
| } |
| graphemeStart = graphemeEnd; |
| } |
| _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(); |
| _selectionOverlay = _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([bool hideHandles = true]) { |
| final TextSelectionOverlay selectionOverlay = _selectionOverlay ??= _createSelectionOverlay(); |
| |
| if (selectionOverlay.toolbarIsVisible) { |
| hideToolbar(hideHandles); |
| } else { |
| showToolbar(); |
| } |
| } |
| |
| /// Shows the magnifier at the position given by `positionToShow`, |
| /// if there is no magnifier visible. |
| /// |
| /// Updates the magnifier to the position given by `positionToShow`, |
| /// if there is a magnifier visible. |
| /// |
| /// Does nothing if a magnifier couldn't be shown, such as when the selection |
| /// overlay does not currently exist. |
| void showMagnifier(Offset positionToShow) { |
| if (_selectionOverlay == null) { |
| return; |
| } |
| |
| if (_selectionOverlay!.magnifierIsVisible) { |
| _selectionOverlay!.updateMagnifier(positionToShow); |
| } else { |
| _selectionOverlay!.showMagnifier(positionToShow); |
| } |
| } |
| |
| /// Hides the magnifier if it is visible. |
| void hideMagnifier({required bool shouldShowToolbar}) { |
| if (_selectionOverlay == null) { |
| return; |
| } |
| |
| if (_selectionOverlay!.magnifierIsVisible) { |
| _selectionOverlay!.hideMagnifier(shouldShowToolbar: shouldShowToolbar); |
| } |
| } |
| |
| // 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 |
| void performSelector(String selectorName) { |
| final Intent? intent = intentForMacOSSelector(selectorName); |
| |
| if (intent != null) { |
| final BuildContext? primaryContext = primaryFocus?.context; |
| if (primaryContext != null) { |
| Actions.invoke(primaryContext, intent); |
| } |
| } |
| } |
| |
| @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.text) : CharacterBoundary(_value.text); |
| return intent.forward ? PushTextPosition.forward + atomicTextBoundary : PushTextPosition.backward + atomicTextBoundary; |
| } |
| |
| TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { |
| final TextBoundary atomicTextBoundary; |
| final TextBoundary boundary; |
| |
| if (widget.obscureText) { |
| atomicTextBoundary = _CodeUnitBoundary(_value.text); |
| boundary = DocumentBoundary(_value.text); |
| } else { |
| final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; |
| atomicTextBoundary = CharacterBoundary(textEditingValue.text); |
| // This isn't enough. Newline characters. |
| boundary = WhitespaceBoundary(textEditingValue.text) + WordBoundary(renderEditable); |
| } |
| |
| 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 intent.forward ? PushTextPosition.forward + mixedBoundary : PushTextPosition.backward + mixedBoundary; |
| } |
| |
| TextBoundary _linebreak(DirectionalTextEditingIntent intent) { |
| final TextBoundary atomicTextBoundary; |
| final TextBoundary boundary; |
| |
| if (widget.obscureText) { |
| atomicTextBoundary = _CodeUnitBoundary(_value.text); |
| boundary = DocumentBoundary(_value.text); |
| } else { |
| final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; |
| atomicTextBoundary = CharacterBoundary(textEditingValue.text); |
| boundary = LineBreak(renderEditable); |
| } |
| |
| // 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. |
| final TextBoundary pushed = intent.forward |
| ? PushTextPosition.forward + atomicTextBoundary |
| : PushTextPosition.backward + atomicTextBoundary; |
| return intent.forward ? _MixedBoundary(pushed, boundary) : _MixedBoundary(boundary, pushed); |
| } |
| |
| TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => DocumentBoundary(_value.text); |
| |
| 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 = _value.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); |
| } |
| |
| |
| /// The default behavior used if [onTapOutside] is null. |
| /// |
| /// The `event` argument is the [PointerDownEvent] that caused the notification. |
| void _defaultOnTapOutside(PointerDownEvent event) { |
| /// The focus dropping behavior is only present on desktop platforms |
| /// and mobile browsers. |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.iOS: |
| case TargetPlatform.fuchsia: |
| // On mobile platforms, we don't unfocus on touch events unless they're |
| // in the web browser, but we do unfocus for all other kinds of events. |
| switch (event.kind) { |
| case ui.PointerDeviceKind.touch: |
| if (kIsWeb) { |
| widget.focusNode.unfocus(); |
| } |
| break; |
| case ui.PointerDeviceKind.mouse: |
| case ui.PointerDeviceKind.stylus: |
| case ui.PointerDeviceKind.invertedStylus: |
| case ui.PointerDeviceKind.unknown: |
| widget.focusNode.unfocus(); |
| break; |
| case ui.PointerDeviceKind.trackpad: |
| throw UnimplementedError('Unexpected pointer down event for trackpad'); |
| } |
| break; |
| case TargetPlatform.linux: |
| case TargetPlatform.macOS: |
| case TargetPlatform.windows: |
| widget.focusNode.unfocus(); |
| break; |
| } |
| } |
| |
| 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 TextFieldTapRegion( |
| onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside, |
| debugLabel: kReleaseMode ? null : 'EditableText', |
| child: 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: kReleaseMode ? null : '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.shrink(), size: Size.zero)); |
| placeholders.add(_ScribblePlaceholder(child: const SizedBox.shrink(), size: Size(renderEditable.size.width, 0.0))); |
| } else { |
| placeholders.add(const _ScribblePlaceholder(child: SizedBox.shrink(), 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)), |
| ], |
| ); |
| } |
| final bool spellCheckResultsReceived = spellCheckEnabled && _spellCheckResults != null; |
| final bool withComposing = !widget.readOnly && _hasFocus; |
| if (spellCheckResultsReceived) { |
| // 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. |
| assert(!_value.composing.isValid || !withComposing || _value.isComposingRangeValid); |
| |
| final bool composingRegionOutOfRange = !_value.isComposingRangeValid || !withComposing; |
| |
| return buildTextSpanWithSpellCheckSuggestions( |
| _value, |
| composingRegionOutOfRange, |
| widget.style, |
| _spellCheckConfiguration.misspelledTextStyle!, |
| _spellCheckResults!, |
| ); |
| } |
| |
| // Read only mode should not paint text composing. |
| return widget.controller.buildTextSpan( |
| context: context, |
| style: widget.style, |
| withComposing: withComposing, |
| ); |
| } |
| } |
| |
| 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 |
| ..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 |
| ..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); |
| } |
| } |
| |
| @immutable |
| class _ScribbleCacheKey { |
| const _ScribbleCacheKey({ |
| required this.inlineSpan, |
| required this.textAlign, |
| required this.textDirection, |
| required this.textScaleFactor, |
| required this.textHeightBehavior, |
| required this.locale, |
| required this.structStyle, |
| required this.placeholder, |
| required this.size, |
| }); |
| |
| final TextAlign textAlign; |
| final TextDirection textDirection; |
| final double textScaleFactor; |
| final TextHeightBehavior? textHeightBehavior; |
| final Locale? locale; |
| final StrutStyle structStyle; |
| final int placeholder; |
| final Size size; |
| final InlineSpan inlineSpan; |
| |
| RenderComparison compare(_ScribbleCacheKey other) { |
| if (identical(other, this)) { |
| return RenderComparison.identical; |
| } |
| final bool needsLayout = textAlign != other.textAlign |
| || textDirection != other.textDirection |
| || textScaleFactor != other.textScaleFactor |
| || (textHeightBehavior ?? const TextHeightBehavior()) != (other.textHeightBehavior ?? const TextHeightBehavior()) |
| || locale != other.locale |
| || structStyle != other.structStyle |
| || placeholder != other.placeholder |
| || size != other.size; |
| return needsLayout ? RenderComparison.layout : inlineSpan.compareTo(other.inlineSpan); |
| } |
| } |
| |
| 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(); |
| } |
| } |
| } |
| |
| /// A text boundary that uses code units as logical boundaries. |
| /// |
| /// This text boundary treats every character in input string as an utf-16 code |
| /// unit. This can be useful when handling text without any grapheme cluster, |
| /// e.g. the obscure string in [EditableText]. If you are handling text that may |
| /// include grapheme clusters, consider using [CharacterBoundary]. |
| class _CodeUnitBoundary extends TextBoundary { |
| const _CodeUnitBoundary(this._text); |
| |
| final String _text; |
| |
| @override |
| TextPosition getLeadingTextBoundaryAt(TextPosition position) { |
| if (position.offset <= 0) { |
| return const TextPosition(offset: 0); |
| } |
| if (position.offset > _text.length || |
| (position.offset == _text.length && position.affinity == TextAffinity.downstream)) { |
| return TextPosition(offset: _text.length, affinity: TextAffinity.upstream); |
| } |
| switch (position.affinity) { |
| case TextAffinity.upstream: |
| return TextPosition(offset: math.min(position.offset - 1, _text.length)); |
| case TextAffinity.downstream: |
| return TextPosition(offset: math.min(position.offset, _text.length)); |
| } |
| } |
| |
| @override |
| TextPosition getTrailingTextBoundaryAt(TextPosition position) { |
| if (position.offset < 0 || |
| (position.offset == 0 && position.affinity == TextAffinity.upstream)) { |
| return const TextPosition(offset: 0); |
| } |
| if (position.offset >= _text.length) { |
| return TextPosition(offset: _text.length, affinity: TextAffinity.upstream); |
| } |
| switch (position.affinity) { |
| case TextAffinity.upstream: |
| return TextPosition(offset: math.min(position.offset, _text.length), affinity: TextAffinity.upstream); |
| case TextAffinity.downstream: |
| return TextPosition(offset: math.min(position.offset + 1, _text.length), affinity: TextAffinity.upstream); |
| } |
| } |
| } |
| |
| // ------------------------ Text Boundary Combinators ------------------------ |
| |
| // 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 |
| 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.text) |
| : CharacterBoundary(value.text); |
| |
| 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 (!state._value.selection.isValid) { |
| return null; |
| } |
| if (!state._value.selection.isCollapsed) { |
| return Actions.invoke( |
| context!, |
| ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard), |
| ); |
| } |
| |
| return Actions.invoke( |
| context!, |
| ReplaceTextIntent( |
| state._value, |
| '', |
| textBoundary.getTextBoundaryAt(state._value.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 if 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); |
| |
| TextPosition extent = selection.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) |
| : selection.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(state._value, 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 = state._value.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(state._value, 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!; |
| }; |
| } |