| // Copyright 2015 The Chromium 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:math' as math; |
| import 'dart:ui' as ui show TextBox, lerpDouble; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/semantics.dart'; |
| import 'package:flutter/services.dart'; |
| |
| import 'box.dart'; |
| import 'object.dart'; |
| import 'viewport_offset.dart'; |
| |
| const double _kCaretGap = 1.0; // pixels |
| const double _kCaretHeightOffset = 2.0; // pixels |
| |
| // The additional size on the x and y axis with which to expand the prototype |
| // cursor to render the floating cursor in pixels. |
| const Offset _kFloatingCaretSizeIncrease = Offset(0.5, 1.0); |
| |
| // The corner radius of the floating cursor in pixels. |
| const double _kFloatingCaretRadius = 1.0; |
| |
| /// Signature for the callback that reports when the user changes the selection |
| /// (including the cursor location). |
| /// |
| /// Used by [RenderEditable.onSelectionChanged]. |
| typedef SelectionChangedHandler = void Function(TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause); |
| |
| /// Indicates what triggered the change in selected text (including changes to |
| /// the cursor location). |
| enum SelectionChangedCause { |
| /// The user tapped on the text and that caused the selection (or the location |
| /// of the cursor) to change. |
| tap, |
| |
| /// The user tapped twice in quick succession on the text and that caused |
| /// the selection (or the location of the cursor) to change. |
| doubleTap, |
| |
| /// The user long-pressed the text and that caused the selection (or the |
| /// location of the cursor) to change. |
| longPress, |
| |
| /// The user force-pressed the text and that caused the selection (or the |
| /// location of the cursor) to change. |
| forcePress, |
| |
| /// The user used the keyboard to change the selection or the location of the |
| /// cursor. |
| /// |
| /// Keyboard-triggered selection changes may be caused by the IME as well as |
| /// by accessibility tools (e.g. TalkBack on Android). |
| keyboard, |
| |
| /// The user used the mouse to change the selection by dragging over a piece |
| /// of text. |
| drag, |
| } |
| |
| /// Signature for the callback that reports when the caret location changes. |
| /// |
| /// Used by [RenderEditable.onCaretChanged]. |
| typedef CaretChangedHandler = void Function(Rect caretRect); |
| |
| /// Represents the coordinates of the point in a selection, and the text |
| /// direction at that point, relative to top left of the [RenderEditable] that |
| /// holds the selection. |
| @immutable |
| class TextSelectionPoint { |
| /// Creates a description of a point in a text selection. |
| /// |
| /// The [point] argument must not be null. |
| const TextSelectionPoint(this.point, this.direction) |
| : assert(point != null); |
| |
| /// Coordinates of the lower left or lower right corner of the selection, |
| /// relative to the top left of the [RenderEditable] object. |
| final Offset point; |
| |
| /// Direction of the text at this edge of the selection. |
| final TextDirection direction; |
| |
| @override |
| String toString() { |
| switch (direction) { |
| case TextDirection.ltr: |
| return '$point-ltr'; |
| case TextDirection.rtl: |
| return '$point-rtl'; |
| } |
| return '$point'; |
| } |
| } |
| |
| /// Displays some text in a scrollable container with a potentially blinking |
| /// cursor and with gesture recognizers. |
| /// |
| /// This is the renderer for an editable text field. It does not directly |
| /// provide affordances for editing the text, but it does handle text selection |
| /// and manipulation of the text cursor. |
| /// |
| /// The [text] is displayed, scrolled by the given [offset], aligned according |
| /// to [textAlign]. The [maxLines] property controls whether the text displays |
| /// on one line or many. The [selection], if it is not collapsed, is painted in |
| /// the [selectionColor]. If it _is_ collapsed, then it represents the cursor |
| /// position. The cursor is shown while [showCursor] is true. It is painted in |
| /// the [cursorColor]. |
| /// |
| /// If, when the render object paints, the caret is found to have changed |
| /// location, [onCaretChanged] is called. |
| /// |
| /// The user may interact with the render object by tapping or long-pressing. |
| /// When the user does so, the selection is updated, and [onSelectionChanged] is |
| /// called. |
| /// |
| /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value |
| /// to actually blink the cursor, and other features not mentioned above are the |
| /// responsibility of higher layers and not handled by this object. |
| class RenderEditable extends RenderBox { |
| /// Creates a render object that implements the visual aspects of a text field. |
| /// |
| /// The [textAlign] argument must not be null. It defaults to [TextAlign.start]. |
| /// |
| /// The [textDirection] argument must not be null. |
| /// |
| /// If [showCursor] is not specified, then it defaults to hiding the cursor. |
| /// |
| /// The [maxLines] property can be set to null to remove the restriction on |
| /// the number of lines. By default, it is 1, meaning this is a single-line |
| /// text field. If it is not null, it must be greater than zero. |
| /// |
| /// The [offset] is required and must not be null. You can use [new |
| /// ViewportOffset.zero] if you have no need for scrolling. |
| RenderEditable({ |
| TextSpan text, |
| @required TextDirection textDirection, |
| TextAlign textAlign = TextAlign.start, |
| Color cursorColor, |
| Color backgroundCursorColor, |
| ValueNotifier<bool> showCursor, |
| bool hasFocus, |
| int maxLines = 1, |
| int minLines, |
| bool expands = false, |
| StrutStyle strutStyle, |
| Color selectionColor, |
| double textScaleFactor = 1.0, |
| TextSelection selection, |
| @required ViewportOffset offset, |
| this.onSelectionChanged, |
| this.onCaretChanged, |
| this.ignorePointer = false, |
| bool obscureText = false, |
| Locale locale, |
| double cursorWidth = 1.0, |
| Radius cursorRadius, |
| bool paintCursorAboveText = false, |
| Offset cursorOffset, |
| double devicePixelRatio = 1.0, |
| bool enableInteractiveSelection, |
| EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), |
| @required this.textSelectionDelegate, |
| }) : assert(textAlign != null), |
| assert(textDirection != null, 'RenderEditable created without a textDirection.'), |
| 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(textScaleFactor != null), |
| assert(offset != null), |
| assert(ignorePointer != null), |
| assert(paintCursorAboveText != null), |
| assert(obscureText != null), |
| assert(textSelectionDelegate != null), |
| assert(cursorWidth != null && cursorWidth >= 0.0), |
| assert(devicePixelRatio != null), |
| _textPainter = TextPainter( |
| text: text, |
| textAlign: textAlign, |
| textDirection: textDirection, |
| textScaleFactor: textScaleFactor, |
| locale: locale, |
| strutStyle: strutStyle, |
| ), |
| _cursorColor = cursorColor, |
| _backgroundCursorColor = backgroundCursorColor, |
| _showCursor = showCursor ?? ValueNotifier<bool>(false), |
| _maxLines = maxLines, |
| _minLines = minLines, |
| _expands = expands, |
| _selectionColor = selectionColor, |
| _selection = selection, |
| _offset = offset, |
| _cursorWidth = cursorWidth, |
| _cursorRadius = cursorRadius, |
| _paintCursorOnTop = paintCursorAboveText, |
| _cursorOffset = cursorOffset, |
| _floatingCursorAddedMargin = floatingCursorAddedMargin, |
| _enableInteractiveSelection = enableInteractiveSelection, |
| _devicePixelRatio = devicePixelRatio, |
| _obscureText = obscureText { |
| assert(_showCursor != null); |
| assert(!_showCursor.value || cursorColor != null); |
| this.hasFocus = hasFocus ?? false; |
| _tap = TapGestureRecognizer(debugOwner: this) |
| ..onTapDown = _handleTapDown |
| ..onTap = _handleTap; |
| _longPress = LongPressGestureRecognizer(debugOwner: this) |
| ..onLongPress = _handleLongPress; |
| } |
| |
| /// Character used to obscure text if [obscureText] is true. |
| static const String obscuringCharacter = '•'; |
| |
| /// Called when the selection changes. |
| SelectionChangedHandler onSelectionChanged; |
| |
| double _textLayoutLastWidth; |
| |
| /// Called during the paint phase when the caret location changes. |
| CaretChangedHandler onCaretChanged; |
| |
| /// If true [handleEvent] does nothing and it's assumed that this |
| /// renderer will be notified of input gestures via [handleTapDown], |
| /// [handleTap], [handleDoubleTap], and [handleLongPress]. |
| /// |
| /// The default value of this property is false. |
| bool ignorePointer; |
| |
| /// The pixel ratio of the current device. |
| /// |
| /// Should be obtained by querying MediaQuery for the devicePixelRatio. |
| double get devicePixelRatio => _devicePixelRatio; |
| double _devicePixelRatio; |
| set devicePixelRatio(double value) { |
| if (devicePixelRatio == value) |
| return; |
| _devicePixelRatio = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// Whether to hide the text being edited (e.g., for passwords). |
| bool get obscureText => _obscureText; |
| bool _obscureText; |
| set obscureText(bool value) { |
| if (_obscureText == value) |
| return; |
| _obscureText = value; |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// The object that controls the text selection, used by this render object |
| /// for implementing cut, copy, and paste keyboard shortcuts. |
| /// |
| /// It must not be null. It will make cut, copy and paste functionality work |
| /// with the most recently set [TextSelectionDelegate]. |
| TextSelectionDelegate textSelectionDelegate; |
| |
| Rect _lastCaretRect; |
| |
| /// Track whether position of the start of the selected text is within the viewport. |
| /// |
| /// For example, if the text contains "Hello World", and the user selects |
| /// "Hello", then scrolls so only "World" is visible, this will become false. |
| /// If the user scrolls back so that the "H" is visible again, this will |
| /// become true. |
| /// |
| /// This bool indicates whether the text is scrolled so that the handle is |
| /// inside the text field viewport, as opposed to whether it is actually |
| /// visible on the screen. |
| ValueListenable<bool> get selectionStartInViewport => _selectionStartInViewport; |
| final ValueNotifier<bool> _selectionStartInViewport = ValueNotifier<bool>(true); |
| |
| /// Track whether position of the end of the selected text is within the viewport. |
| /// |
| /// For example, if the text contains "Hello World", and the user selects |
| /// "World", then scrolls so only "Hello" is visible, this will become |
| /// 'false'. If the user scrolls back so that the "d" is visible again, this |
| /// will become 'true'. |
| /// |
| /// This bool indicates whether the text is scrolled so that the handle is |
| /// inside the text field viewport, as opposed to whether it is actually |
| /// visible on the screen. |
| ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport; |
| final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true); |
| |
| void _updateSelectionExtentsVisibility(Offset effectiveOffset) { |
| final Rect visibleRegion = Offset.zero & size; |
| |
| final Offset startOffset = _textPainter.getOffsetForCaret( |
| TextPosition(offset: _selection.start, affinity: _selection.affinity), |
| Rect.zero, |
| ); |
| // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 |
| // Check if the selection is visible with an approximation because a |
| // difference between rounded and unrounded values causes the caret to be |
| // reported as having a slightly (< 0.5) negative y offset. This rounding |
| // happens in paragraph.cc's layout and TextPainer's |
| // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and |
| // this can be changed to be a strict check instead of an approximation. |
| const double visibleRegionSlop = 0.5; |
| _selectionStartInViewport.value = visibleRegion |
| .inflate(visibleRegionSlop) |
| .contains(startOffset + effectiveOffset); |
| |
| final Offset endOffset = _textPainter.getOffsetForCaret( |
| TextPosition(offset: _selection.end, affinity: _selection.affinity), |
| Rect.zero, |
| ); |
| _selectionEndInViewport.value = visibleRegion |
| .inflate(visibleRegionSlop) |
| .contains(endOffset + effectiveOffset); |
| } |
| |
| static const int _kLeftArrowCode = 21; |
| static const int _kRightArrowCode = 22; |
| static const int _kUpArrowCode = 19; |
| static const int _kDownArrowCode = 20; |
| static const int _kXKeyCode = 52; |
| static const int _kCKeyCode = 31; |
| static const int _kVKeyCode = 50; |
| static const int _kAKeyCode = 29; |
| static const int _kDelKeyCode = 112; |
| |
| // The extent offset of the current selection |
| int _extentOffset = -1; |
| |
| // The base offset of the current selection |
| int _baseOffset = -1; |
| |
| // Holds the last location the user selected in the case that he selects all |
| // the way to the end or beginning of the field. |
| int _previousCursorLocation = -1; |
| |
| // Whether we should reset the location of the cursor in the case the user |
| // selects all the way to the end or the beginning of a field. |
| bool _resetCursor = false; |
| |
| static const int _kShiftMask = 1; // https://developer.android.com/reference/android/view/KeyEvent.html#META_SHIFT_ON |
| static const int _kControlMask = 1 << 12; // https://developer.android.com/reference/android/view/KeyEvent.html#META_CTRL_ON |
| |
| // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). |
| void _handleKeyEvent(RawKeyEvent keyEvent) { |
| // Only handle key events on Android. |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| break; |
| case TargetPlatform.iOS: |
| case TargetPlatform.fuchsia: |
| return; |
| } |
| |
| if (keyEvent is RawKeyUpEvent) |
| return; |
| |
| final RawKeyEventDataAndroid rawAndroidEvent = keyEvent.data; |
| final int pressedKeyCode = rawAndroidEvent.keyCode; |
| final int pressedKeyMetaState = rawAndroidEvent.metaState; |
| |
| if (selection.isCollapsed) { |
| _extentOffset = selection.extentOffset; |
| _baseOffset = selection.baseOffset; |
| } |
| |
| // Update current key states |
| final bool shift = pressedKeyMetaState & _kShiftMask > 0; |
| final bool ctrl = pressedKeyMetaState & _kControlMask > 0; |
| |
| final bool rightArrow = pressedKeyCode == _kRightArrowCode; |
| final bool leftArrow = pressedKeyCode == _kLeftArrowCode; |
| final bool upArrow = pressedKeyCode == _kUpArrowCode; |
| final bool downArrow = pressedKeyCode == _kDownArrowCode; |
| final bool arrow = leftArrow || rightArrow || upArrow || downArrow; |
| final bool aKey = pressedKeyCode == _kAKeyCode; |
| final bool xKey = pressedKeyCode == _kXKeyCode; |
| final bool vKey = pressedKeyCode == _kVKeyCode; |
| final bool cKey = pressedKeyCode == _kCKeyCode; |
| final bool del = pressedKeyCode == _kDelKeyCode; |
| |
| // We will only move select or more the caret if an arrow is pressed |
| if (arrow) { |
| int newOffset = _extentOffset; |
| |
| // Because the user can use multiple keys to change how he selects |
| // the new offset variable is threaded through these four functions |
| // and potentially changes after each one. |
| if (ctrl) |
| newOffset = _handleControl(rightArrow, leftArrow, ctrl, newOffset); |
| newOffset = _handleHorizontalArrows(rightArrow, leftArrow, shift, newOffset); |
| if (downArrow || upArrow) |
| newOffset = _handleVerticalArrows(upArrow, downArrow, shift, newOffset); |
| newOffset = _handleShift(rightArrow, leftArrow, shift, newOffset); |
| |
| _extentOffset = newOffset; |
| } else if (ctrl && (xKey || vKey || cKey || aKey)) { |
| // _handleShortcuts depends on being started in the same stack invocation as the _handleKeyEvent method |
| _handleShortcuts(pressedKeyCode); |
| } |
| if (del) |
| _handleDelete(); |
| } |
| |
| // Handles full word traversal using control. |
| int _handleControl(bool rightArrow, bool leftArrow, bool ctrl, int newOffset) { |
| // If control is pressed, we will decide which way to look for a word |
| // based on which arrow is pressed. |
| if (leftArrow && _extentOffset > 2) { |
| final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset - 2)); |
| newOffset = textSelection.baseOffset + 1; |
| } else if (rightArrow && _extentOffset < text.text.length - 2) { |
| final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset + 1)); |
| newOffset = textSelection.extentOffset - 1; |
| } |
| return newOffset; |
| } |
| |
| int _handleHorizontalArrows(bool rightArrow, bool leftArrow, bool shift, int newOffset) { |
| // Set the new offset to be +/- 1 depending on which arrow is pressed |
| // If shift is down, we also want to update the previous cursor location |
| if (rightArrow && _extentOffset < text.text.length) { |
| newOffset += 1; |
| if (shift) |
| _previousCursorLocation += 1; |
| } |
| if (leftArrow && _extentOffset > 0) { |
| newOffset -= 1; |
| if (shift) |
| _previousCursorLocation -= 1; |
| } |
| return newOffset; |
| } |
| |
| // Handles moving the cursor vertically as well as taking care of the |
| // case where the user moves the cursor to the end or beginning of the text |
| // and then back up or down. |
| int _handleVerticalArrows(bool upArrow, bool downArrow, bool shift, int newOffset) { |
| // The caret offset gives a location in the upper left hand corner of |
| // the caret so the middle of the line above is a half line above that |
| // point and the line below is 1.5 lines below that point. |
| final double plh = _textPainter.preferredLineHeight; |
| final double verticalOffset = upArrow ? -0.5 * plh : 1.5 * plh; |
| |
| final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: _extentOffset), _caretPrototype); |
| final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); |
| final TextPosition position = _textPainter.getPositionForOffset(caretOffsetTranslated); |
| |
| // To account for the possibility where the user vertically highlights |
| // all the way to the top or bottom of the text, we hold the previous |
| // cursor location. This allows us to restore to this position in the |
| // case that the user wants to unhighlight some text. |
| if (position.offset == _extentOffset) { |
| if (downArrow) |
| newOffset = text.text.length; |
| else if (upArrow) |
| newOffset = 0; |
| _resetCursor = shift; |
| } else if (_resetCursor && shift) { |
| newOffset = _previousCursorLocation; |
| _resetCursor = false; |
| } else { |
| newOffset = position.offset; |
| _previousCursorLocation = newOffset; |
| } |
| return newOffset; |
| } |
| |
| // Handles the selection of text or removal of the selection and placing |
| // of the caret. |
| int _handleShift(bool rightArrow, bool leftArrow, bool shift, int newOffset) { |
| if (onSelectionChanged == null) |
| return newOffset; |
| // In the text_selection class, a TextSelection is defined such that the |
| // base offset is always less than the extent offset. |
| if (shift) { |
| if (_baseOffset < newOffset) { |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: _baseOffset, |
| extentOffset: newOffset, |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| } else { |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: newOffset, |
| extentOffset: _baseOffset, |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| } |
| } else { |
| // We want to put the cursor at the correct location depending on which |
| // arrow is used while there is a selection. |
| if (!selection.isCollapsed) { |
| if (leftArrow) |
| newOffset = _baseOffset < _extentOffset ? _baseOffset : _extentOffset; |
| else if (rightArrow) |
| newOffset = _baseOffset > _extentOffset ? _baseOffset : _extentOffset; |
| } |
| onSelectionChanged( |
| TextSelection.fromPosition( |
| TextPosition( |
| offset: newOffset |
| ) |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| } |
| return newOffset; |
| } |
| |
| // Handles shortcut functionality including cut, copy, paste and select all |
| // using control + (X, C, V, A). |
| Future<void> _handleShortcuts(int pressedKeyCode) async { |
| switch (pressedKeyCode) { |
| case _kCKeyCode: |
| if (!selection.isCollapsed) { |
| Clipboard.setData( |
| ClipboardData(text: selection.textInside(text.text))); |
| } |
| break; |
| case _kXKeyCode: |
| if (!selection.isCollapsed) { |
| Clipboard.setData( |
| ClipboardData(text: selection.textInside(text.text))); |
| textSelectionDelegate.textEditingValue = TextEditingValue( |
| text: selection.textBefore(text.text) |
| + selection.textAfter(text.text), |
| selection: TextSelection.collapsed(offset: selection.start), |
| ); |
| } |
| break; |
| case _kVKeyCode: |
| // Snapshot the input before using `await`. |
| // See https://github.com/flutter/flutter/issues/11427 |
| final TextEditingValue value = textSelectionDelegate.textEditingValue; |
| final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain); |
| if (data != null) { |
| textSelectionDelegate.textEditingValue = TextEditingValue( |
| text: value.selection.textBefore(value.text) |
| + data.text |
| + value.selection.textAfter(value.text), |
| selection: TextSelection.collapsed( |
| offset: value.selection.start + data.text.length |
| ), |
| ); |
| } |
| break; |
| case _kAKeyCode: |
| _baseOffset = 0; |
| _extentOffset = textSelectionDelegate.textEditingValue.text.length; |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: 0, |
| extentOffset: textSelectionDelegate.textEditingValue.text.length, |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| break; |
| default: |
| assert(false); |
| } |
| } |
| |
| void _handleDelete() { |
| if (selection.textAfter(text.text).isNotEmpty) { |
| textSelectionDelegate.textEditingValue = TextEditingValue( |
| text: selection.textBefore(text.text) |
| + selection.textAfter(text.text).substring(1), |
| selection: TextSelection.collapsed(offset: selection.start), |
| ); |
| } else { |
| textSelectionDelegate.textEditingValue = TextEditingValue( |
| text: selection.textBefore(text.text), |
| selection: TextSelection.collapsed(offset: selection.start), |
| ); |
| } |
| } |
| |
| /// Marks the render object as needing to be laid out again and have its text |
| /// metrics recomputed. |
| /// |
| /// Implies [markNeedsLayout]. |
| @protected |
| void markNeedsTextLayout() { |
| _textLayoutLastWidth = null; |
| markNeedsLayout(); |
| } |
| |
| /// The text to display. |
| TextSpan get text => _textPainter.text; |
| final TextPainter _textPainter; |
| set text(TextSpan value) { |
| if (_textPainter.text == value) |
| return; |
| _textPainter.text = value; |
| markNeedsTextLayout(); |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// How the text should be aligned horizontally. |
| /// |
| /// This must not be null. |
| TextAlign get textAlign => _textPainter.textAlign; |
| set textAlign(TextAlign value) { |
| assert(value != null); |
| if (_textPainter.textAlign == value) |
| return; |
| _textPainter.textAlign = value; |
| markNeedsPaint(); |
| } |
| |
| /// The directionality of the text. |
| /// |
| /// This decides how the [TextAlign.start], [TextAlign.end], and |
| /// [TextAlign.justify] values of [textAlign] 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. |
| /// |
| /// This must not be null. |
| TextDirection get textDirection => _textPainter.textDirection; |
| set textDirection(TextDirection value) { |
| assert(value != null); |
| if (_textPainter.textDirection == value) |
| return; |
| _textPainter.textDirection = value; |
| markNeedsTextLayout(); |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// Used by this renderer's internal [TextPainter] to select a locale-specific |
| /// font. |
| /// |
| /// In some cases the same Unicode character may be rendered differently depending |
| /// on the locale. For example the '骨' character is rendered differently in |
| /// the Chinese and Japanese locales. In these cases the [locale] may be used |
| /// to select a locale-specific font. |
| /// |
| /// If this value is null, a system-dependent algorithm is used to select |
| /// the font. |
| Locale get locale => _textPainter.locale; |
| set locale(Locale value) { |
| if (_textPainter.locale == value) |
| return; |
| _textPainter.locale = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// The [StrutStyle] used by the renderer's internal [TextPainter] to |
| /// determine the strut to use. |
| StrutStyle get strutStyle => _textPainter.strutStyle; |
| set strutStyle(StrutStyle value) { |
| if (_textPainter.strutStyle == value) |
| return; |
| _textPainter.strutStyle = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// The color to use when painting the cursor. |
| Color get cursorColor => _cursorColor; |
| Color _cursorColor; |
| set cursorColor(Color value) { |
| if (_cursorColor == value) |
| return; |
| _cursorColor = value; |
| markNeedsPaint(); |
| } |
| |
| /// The color to use when painting the cursor aligned to the text while |
| /// rendering the floating cursor. |
| /// |
| /// The default is light grey. |
| Color get backgroundCursorColor => _backgroundCursorColor; |
| Color _backgroundCursorColor; |
| set backgroundCursorColor(Color value) { |
| if (backgroundCursorColor == value) |
| return; |
| _backgroundCursorColor = value; |
| markNeedsPaint(); |
| } |
| |
| /// Whether to paint the cursor. |
| ValueNotifier<bool> get showCursor => _showCursor; |
| ValueNotifier<bool> _showCursor; |
| set showCursor(ValueNotifier<bool> value) { |
| assert(value != null); |
| if (_showCursor == value) |
| return; |
| if (attached) |
| _showCursor.removeListener(markNeedsPaint); |
| _showCursor = value; |
| if (attached) |
| _showCursor.addListener(markNeedsPaint); |
| markNeedsPaint(); |
| } |
| |
| /// Whether the editable is currently focused. |
| bool get hasFocus => _hasFocus; |
| bool _hasFocus = false; |
| bool _listenerAttached = false; |
| set hasFocus(bool value) { |
| assert(value != null); |
| if (_hasFocus == value) |
| return; |
| _hasFocus = value; |
| if (_hasFocus) { |
| assert(!_listenerAttached); |
| RawKeyboard.instance.addListener(_handleKeyEvent); |
| _listenerAttached = true; |
| } else { |
| assert(_listenerAttached); |
| RawKeyboard.instance.removeListener(_handleKeyEvent); |
| _listenerAttached = false; |
| } |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// The maximum number of lines for the text to span, wrapping if necessary. |
| /// |
| /// If this is 1 (the default), the text will not wrap, but will extend |
| /// indefinitely instead. |
| /// |
| /// If this is null, there is no limit to the number of lines. |
| /// |
| /// When this is not null, the intrinsic height of the render object is the |
| /// height of one line of text multiplied by this value. In other words, this |
| /// also controls the height of the actual editing widget. |
| int get maxLines => _maxLines; |
| int _maxLines; |
| /// The value may be null. If it is not null, then it must be greater than zero. |
| set maxLines(int value) { |
| assert(value == null || value > 0); |
| if (maxLines == value) |
| return; |
| _maxLines = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// {@macro flutter.widgets.editableText.minLines} |
| int get minLines => _minLines; |
| int _minLines; |
| /// The value may be null. If it is not null, then it must be greater than zero. |
| set minLines(int value) { |
| assert(value == null || value > 0); |
| if (minLines == value) |
| return; |
| _minLines = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// {@macro flutter.widgets.editableText.expands} |
| bool get expands => _expands; |
| bool _expands; |
| set expands(bool value) { |
| assert(value != null); |
| if (expands == value) |
| return; |
| _expands = value; |
| markNeedsTextLayout(); |
| } |
| |
| /// The color to use when painting the selection. |
| Color get selectionColor => _selectionColor; |
| Color _selectionColor; |
| set selectionColor(Color value) { |
| if (_selectionColor == value) |
| return; |
| _selectionColor = value; |
| markNeedsPaint(); |
| } |
| |
| /// 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. |
| double get textScaleFactor => _textPainter.textScaleFactor; |
| set textScaleFactor(double value) { |
| assert(value != null); |
| if (_textPainter.textScaleFactor == value) |
| return; |
| _textPainter.textScaleFactor = value; |
| markNeedsTextLayout(); |
| } |
| |
| List<ui.TextBox> _selectionRects; |
| |
| /// The region of text that is selected, if any. |
| TextSelection get selection => _selection; |
| TextSelection _selection; |
| set selection(TextSelection value) { |
| if (_selection == value) |
| return; |
| _selection = value; |
| _selectionRects = null; |
| markNeedsPaint(); |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// The offset at which the text should be painted. |
| /// |
| /// If the text content is larger than the editable line itself, the editable |
| /// line clips the text. This property controls which part of the text is |
| /// visible by shifting the text by the given offset before clipping. |
| ViewportOffset get offset => _offset; |
| ViewportOffset _offset; |
| set offset(ViewportOffset value) { |
| assert(value != null); |
| if (_offset == value) |
| return; |
| if (attached) |
| _offset.removeListener(markNeedsPaint); |
| _offset = value; |
| if (attached) |
| _offset.addListener(markNeedsPaint); |
| markNeedsLayout(); |
| } |
| |
| /// How thick the cursor will be. |
| double get cursorWidth => _cursorWidth; |
| double _cursorWidth = 1.0; |
| set cursorWidth(double value) { |
| if (_cursorWidth == value) |
| return; |
| _cursorWidth = value; |
| markNeedsLayout(); |
| } |
| |
| /// {@template flutter.rendering.editable.paintCursorOnTop} |
| /// If the cursor should be painted on top of the text or underneath it. |
| /// |
| /// By default, the cursor should be painted on top for iOS platforms and |
| /// underneath for Android platforms. |
| /// {@endtemplate} |
| bool get paintCursorAboveText => _paintCursorOnTop; |
| bool _paintCursorOnTop; |
| set paintCursorAboveText(bool value) { |
| if (_paintCursorOnTop == value) |
| return; |
| _paintCursorOnTop = value; |
| markNeedsLayout(); |
| } |
| |
| /// {@template flutter.rendering.editable.cursorOffset} |
| /// The offset that is used, in pixels, when painting the cursor on screen. |
| /// |
| /// By default, the cursor position should be set to an offset of |
| /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android |
| /// platforms. The origin from where the offset is applied to is the arbitrary |
| /// location where the cursor ends up being rendered from by default. |
| /// {@endtemplate} |
| Offset get cursorOffset => _cursorOffset; |
| Offset _cursorOffset; |
| set cursorOffset(Offset value) { |
| if (_cursorOffset == value) |
| return; |
| _cursorOffset = value; |
| markNeedsLayout(); |
| } |
| |
| /// How rounded the corners of the cursor should be. |
| Radius get cursorRadius => _cursorRadius; |
| Radius _cursorRadius; |
| set cursorRadius(Radius value) { |
| if (_cursorRadius == value) |
| return; |
| _cursorRadius = value; |
| markNeedsPaint(); |
| } |
| |
| /// The padding applied to text field. Used to determine the bounds when |
| /// moving the floating cursor. |
| /// |
| /// Defaults to a padding with left, top and right set to 4, bottom to 5. |
| EdgeInsets get floatingCursorAddedMargin => _floatingCursorAddedMargin; |
| EdgeInsets _floatingCursorAddedMargin; |
| set floatingCursorAddedMargin(EdgeInsets value) { |
| if (_floatingCursorAddedMargin == value) |
| return; |
| _floatingCursorAddedMargin = value; |
| markNeedsPaint(); |
| } |
| |
| bool _floatingCursorOn = false; |
| Offset _floatingCursorOffset; |
| TextPosition _floatingCursorTextPosition; |
| |
| /// If false, [describeSemanticsConfiguration] will not set the |
| /// configuration's cursor motion or set selection callbacks. |
| /// |
| /// True by default. |
| bool get enableInteractiveSelection => _enableInteractiveSelection; |
| bool _enableInteractiveSelection; |
| set enableInteractiveSelection(bool value) { |
| if (_enableInteractiveSelection == value) |
| return; |
| _enableInteractiveSelection = value; |
| markNeedsTextLayout(); |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// {@template flutter.rendering.editable.selectionEnabled} |
| /// True if interactive selection is enabled based on the values of |
| /// [enableInteractiveSelection] and [obscureText]. |
| /// |
| /// By default [enableInteractiveSelection] is null, obscureText is false, |
| /// and this method returns true. |
| /// If [enableInteractiveSelection] is null and obscureText is true, then this |
| /// method returns false. This is the common case for password fields. |
| /// If [enableInteractiveSelection] is non-null then its value is returned. An |
| /// app might set it to true to enable interactive selection for a password |
| /// field, or to false to unconditionally disable interactive selection. |
| /// {@endtemplate} |
| bool get selectionEnabled { |
| return enableInteractiveSelection ?? !obscureText; |
| } |
| |
| @override |
| void describeSemanticsConfiguration(SemanticsConfiguration config) { |
| super.describeSemanticsConfiguration(config); |
| |
| config |
| ..value = obscureText |
| ? obscuringCharacter * text.toPlainText().length |
| : text.toPlainText() |
| ..isObscured = obscureText |
| ..textDirection = textDirection |
| ..isFocused = hasFocus |
| ..isTextField = true; |
| |
| if (hasFocus && selectionEnabled) |
| config.onSetSelection = _handleSetSelection; |
| |
| if (selectionEnabled && _selection?.isValid == true) { |
| config.textSelection = _selection; |
| if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) { |
| config |
| ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord |
| ..onMoveCursorBackwardByCharacter = _handleMoveCursorBackwardByCharacter; |
| } |
| if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) { |
| config |
| ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord |
| ..onMoveCursorForwardByCharacter = _handleMoveCursorForwardByCharacter; |
| } |
| } |
| } |
| |
| void _handleSetSelection(TextSelection selection) { |
| onSelectionChanged(selection, this, SelectionChangedCause.keyboard); |
| } |
| |
| void _handleMoveCursorForwardByCharacter(bool extentSelection) { |
| final int extentOffset = _textPainter.getOffsetAfter(_selection.extentOffset); |
| if (extentOffset == null) |
| return; |
| final int baseOffset = !extentSelection ? extentOffset : _selection.baseOffset; |
| onSelectionChanged( |
| TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), this, SelectionChangedCause.keyboard, |
| ); |
| } |
| |
| void _handleMoveCursorBackwardByCharacter(bool extentSelection) { |
| final int extentOffset = _textPainter.getOffsetBefore(_selection.extentOffset); |
| if (extentOffset == null) |
| return; |
| final int baseOffset = !extentSelection ? extentOffset : _selection.baseOffset; |
| onSelectionChanged( |
| TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), this, SelectionChangedCause.keyboard, |
| ); |
| } |
| |
| void _handleMoveCursorForwardByWord(bool extentSelection) { |
| final TextRange currentWord = _textPainter.getWordBoundary(_selection.extent); |
| if (currentWord == null) |
| return; |
| final TextRange nextWord = _getNextWord(currentWord.end); |
| if (nextWord == null) |
| return; |
| final int baseOffset = extentSelection ? _selection.baseOffset : nextWord.start; |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: baseOffset, |
| extentOffset: nextWord.start, |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| } |
| |
| void _handleMoveCursorBackwardByWord(bool extentSelection) { |
| final TextRange currentWord = _textPainter.getWordBoundary(_selection.extent); |
| if (currentWord == null) |
| return; |
| final TextRange previousWord = _getPreviousWord(currentWord.start - 1); |
| if (previousWord == null) |
| return; |
| final int baseOffset = extentSelection ? _selection.baseOffset : previousWord.start; |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: baseOffset, |
| extentOffset: previousWord.start, |
| ), |
| this, |
| SelectionChangedCause.keyboard, |
| ); |
| } |
| |
| TextRange _getNextWord(int offset) { |
| while (true) { |
| final TextRange range = _textPainter.getWordBoundary(TextPosition(offset: offset)); |
| if (range == null || !range.isValid || range.isCollapsed) |
| return null; |
| if (!_onlyWhitespace(range)) |
| return range; |
| offset = range.end; |
| } |
| } |
| |
| TextRange _getPreviousWord(int offset) { |
| while (offset >= 0) { |
| final TextRange range = _textPainter.getWordBoundary(TextPosition(offset: offset)); |
| if (range == null || !range.isValid || range.isCollapsed) |
| return null; |
| if (!_onlyWhitespace(range)) |
| return range; |
| offset = range.start - 1; |
| } |
| return null; |
| } |
| |
| // Check if the given text range only contains white space or separator |
| // characters. |
| // |
| // newline characters from ascii and separators from the |
| // [unicode separator category](https://www.compart.com/en/unicode/category/Zs) |
| // TODO(jonahwilliams): replace when we expose this ICU information. |
| bool _onlyWhitespace(TextRange range) { |
| for (int i = range.start; i < range.end; i++) { |
| final int codeUnit = text.codeUnitAt(i); |
| switch (codeUnit) { |
| case 0x9: // horizontal tab |
| case 0xA: // line feed |
| case 0xB: // vertical tab |
| case 0xC: // form feed |
| case 0xD: // carriage return |
| case 0x1C: // file separator |
| case 0x1D: // group separator |
| case 0x1E: // record separator |
| case 0x1F: // unit separator |
| case 0x20: // space |
| case 0xA0: // no-break space |
| case 0x1680: // ogham space mark |
| case 0x2000: // en quad |
| case 0x2001: // em quad |
| case 0x2002: // en space |
| case 0x2003: // em space |
| case 0x2004: // three-per-em space |
| case 0x2005: // four-er-em space |
| case 0x2006: // six-per-em space |
| case 0x2007: // figure space |
| case 0x2008: // punctuation space |
| case 0x2009: // thin space |
| case 0x200A: // hair space |
| case 0x202F: // narrow no-break space |
| case 0x205F: // medium mathematical space |
| case 0x3000: // ideographic space |
| break; |
| default: |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| @override |
| void attach(PipelineOwner owner) { |
| super.attach(owner); |
| _offset.addListener(markNeedsPaint); |
| _showCursor.addListener(markNeedsPaint); |
| } |
| |
| @override |
| void detach() { |
| _offset.removeListener(markNeedsPaint); |
| _showCursor.removeListener(markNeedsPaint); |
| if (_listenerAttached) |
| RawKeyboard.instance.removeListener(_handleKeyEvent); |
| super.detach(); |
| } |
| |
| bool get _isMultiline => maxLines != 1; |
| |
| Axis get _viewportAxis => _isMultiline ? Axis.vertical : Axis.horizontal; |
| |
| Offset get _paintOffset { |
| switch (_viewportAxis) { |
| case Axis.horizontal: |
| return Offset(-offset.pixels, 0.0); |
| case Axis.vertical: |
| return Offset(0.0, -offset.pixels); |
| } |
| return null; |
| } |
| |
| double get _viewportExtent { |
| assert(hasSize); |
| switch (_viewportAxis) { |
| case Axis.horizontal: |
| return size.width; |
| case Axis.vertical: |
| return size.height; |
| } |
| return null; |
| } |
| |
| double _getMaxScrollExtent(Size contentSize) { |
| assert(hasSize); |
| switch (_viewportAxis) { |
| case Axis.horizontal: |
| return math.max(0.0, contentSize.width - size.width); |
| case Axis.vertical: |
| return math.max(0.0, contentSize.height - size.height); |
| } |
| return null; |
| } |
| |
| double _maxScrollExtent = 0; |
| |
| // We need to check the paint offset here because during animation, the start of |
| // the text may position outside the visible region even when the text fits. |
| bool get _hasVisualOverflow => _maxScrollExtent > 0 || _paintOffset != Offset.zero; |
| |
| /// Returns the local coordinates of the endpoints of the given selection. |
| /// |
| /// If the selection is collapsed (and therefore occupies a single point), the |
| /// returned list is of length one. Otherwise, the selection is not collapsed |
| /// and the returned list is of length two. In this case, however, the two |
| /// points might actually be co-located (e.g., because of a bidirectional |
| /// selection that contains some text but whose ends meet in the middle). |
| /// |
| /// See also: |
| /// |
| /// * [getLocalRectForCaret], which is the equivalent but for |
| /// a [TextPosition] rather than a [TextSelection]. |
| List<TextSelectionPoint> getEndpointsForSelection(TextSelection selection) { |
| assert(constraints != null); |
| _layoutText(constraints.maxWidth); |
| |
| final Offset paintOffset = _paintOffset; |
| |
| if (selection.isCollapsed) { |
| // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. |
| final Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype); |
| final Offset start = Offset(0.0, preferredLineHeight) + caretOffset + paintOffset; |
| return <TextSelectionPoint>[TextSelectionPoint(start, null)]; |
| } else { |
| final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection); |
| final Offset start = Offset(boxes.first.start, boxes.first.bottom) + paintOffset; |
| final Offset end = Offset(boxes.last.end, boxes.last.bottom) + paintOffset; |
| return <TextSelectionPoint>[ |
| TextSelectionPoint(start, boxes.first.direction), |
| TextSelectionPoint(end, boxes.last.direction), |
| ]; |
| } |
| } |
| |
| /// Returns the position in the text for the given global coordinate. |
| /// |
| /// See also: |
| /// |
| /// * [getLocalRectForCaret], which is the reverse operation, taking |
| /// a [TextPosition] and returning a [Rect]. |
| /// * [TextPainter.getPositionForOffset], which is the equivalent method |
| /// for a [TextPainter] object. |
| TextPosition getPositionForPoint(Offset globalPosition) { |
| _layoutText(constraints.maxWidth); |
| globalPosition += -_paintOffset; |
| return _textPainter.getPositionForOffset(globalToLocal(globalPosition)); |
| } |
| |
| /// Returns the [Rect] in local coordinates for the caret at the given text |
| /// position. |
| /// |
| /// See also: |
| /// |
| /// * [getPositionForPoint], which is the reverse operation, taking |
| /// an [Offset] in global coordinates and returning a [TextPosition]. |
| /// * [getEndpointsForSelection], which is the equivalent but for |
| /// a selection rather than a particular text position. |
| /// * [TextPainter.getOffsetForCaret], the equivalent method for a |
| /// [TextPainter] object. |
| Rect getLocalRectForCaret(TextPosition caretPosition) { |
| _layoutText(constraints.maxWidth); |
| final Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, _caretPrototype); |
| // This rect is the same as _caretPrototype but without the vertical padding. |
| Rect rect = Rect.fromLTWH(0.0, 0.0, cursorWidth, preferredLineHeight).shift(caretOffset + _paintOffset); |
| // Add additional cursor offset (generally only if on iOS). |
| if (_cursorOffset != null) |
| rect = rect.shift(_cursorOffset); |
| |
| return rect.shift(_getPixelPerfectCursorOffset(rect)); |
| } |
| |
| @override |
| double computeMinIntrinsicWidth(double height) { |
| _layoutText(double.infinity); |
| return _textPainter.minIntrinsicWidth; |
| } |
| |
| @override |
| double computeMaxIntrinsicWidth(double height) { |
| _layoutText(double.infinity); |
| return _textPainter.maxIntrinsicWidth + cursorWidth; |
| } |
| |
| /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. |
| /// This does not required the layout to be updated. |
| double get preferredLineHeight => _textPainter.preferredLineHeight; |
| |
| double _preferredHeight(double width) { |
| // Lock height to maxLines if needed |
| final bool lockedMax = maxLines != null && minLines == null; |
| final bool lockedBoth = minLines != null && minLines == maxLines; |
| final bool singleLine = maxLines == 1; |
| if (singleLine || lockedMax || lockedBoth) { |
| return preferredLineHeight * maxLines; |
| } |
| |
| // Clamp height to minLines or maxLines if needed |
| final bool minLimited = minLines != null && minLines > 1; |
| final bool maxLimited = maxLines != null; |
| if (minLimited || maxLimited) { |
| _layoutText(width); |
| if (minLimited && _textPainter.height < preferredLineHeight * minLines) { |
| return preferredLineHeight * minLines; |
| } |
| if (maxLimited && _textPainter.height > preferredLineHeight * maxLines) { |
| return preferredLineHeight * maxLines; |
| } |
| } |
| |
| // Set the height based on the content |
| if (width == double.infinity) { |
| final String text = _textPainter.text.toPlainText(); |
| int lines = 1; |
| for (int index = 0; index < text.length; index += 1) { |
| if (text.codeUnitAt(index) == 0x0A) // count explicit line breaks |
| lines += 1; |
| } |
| return preferredLineHeight * lines; |
| } |
| _layoutText(width); |
| return math.max(preferredLineHeight, _textPainter.height); |
| } |
| |
| @override |
| double computeMinIntrinsicHeight(double width) { |
| return _preferredHeight(width); |
| } |
| |
| @override |
| double computeMaxIntrinsicHeight(double width) { |
| return _preferredHeight(width); |
| } |
| |
| @override |
| double computeDistanceToActualBaseline(TextBaseline baseline) { |
| _layoutText(constraints.maxWidth); |
| return _textPainter.computeDistanceToActualBaseline(baseline); |
| } |
| |
| @override |
| bool hitTestSelf(Offset position) => true; |
| |
| TapGestureRecognizer _tap; |
| LongPressGestureRecognizer _longPress; |
| |
| @override |
| void handleEvent(PointerEvent event, BoxHitTestEntry entry) { |
| if (ignorePointer) |
| return; |
| assert(debugHandleEvent(event, entry)); |
| if (event is PointerDownEvent && onSelectionChanged != null) { |
| _tap.addPointer(event); |
| _longPress.addPointer(event); |
| } |
| } |
| |
| Offset _lastTapDownPosition; |
| |
| /// If [ignorePointer] is false (the default) then this method is called by |
| /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] |
| /// callback. |
| /// |
| /// When [ignorePointer] is true, an ancestor widget must respond to tap |
| /// down events by calling this method. |
| void handleTapDown(TapDownDetails details) { |
| _lastTapDownPosition = details.globalPosition; |
| } |
| void _handleTapDown(TapDownDetails details) { |
| assert(!ignorePointer); |
| handleTapDown(details); |
| } |
| |
| /// If [ignorePointer] is false (the default) then this method is called by |
| /// the internal gesture recognizer's [TapGestureRecognizer.onTap] |
| /// callback. |
| /// |
| /// When [ignorePointer] is true, an ancestor widget must respond to tap |
| /// events by calling this method. |
| void handleTap() { |
| selectPosition(cause: SelectionChangedCause.tap); |
| } |
| void _handleTap() { |
| assert(!ignorePointer); |
| handleTap(); |
| } |
| |
| /// If [ignorePointer] is false (the default) then this method is called by |
| /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap] |
| /// callback. |
| /// |
| /// When [ignorePointer] is true, an ancestor widget must respond to double |
| /// tap events by calling this method. |
| void handleDoubleTap() { |
| selectWord(cause: SelectionChangedCause.doubleTap); |
| } |
| |
| /// If [ignorePointer] is false (the default) then this method is called by |
| /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress] |
| /// callback. |
| /// |
| /// When [ignorePointer] is true, an ancestor widget must respond to long |
| /// press events by calling this method. |
| void handleLongPress() { |
| selectWord(cause: SelectionChangedCause.longPress); |
| } |
| void _handleLongPress() { |
| assert(!ignorePointer); |
| handleLongPress(); |
| } |
| |
| /// Move selection to the location of the last tap down. |
| /// |
| /// {@template flutter.rendering.editable.select} |
| /// This method is mainly used to translate user inputs in global positions |
| /// into a [TextSelection]. When used in conjunction with a [EditableText], |
| /// the selection change is fed back into [TextEditingController.selection]. |
| /// |
| /// If you have a [TextEditingController], it's generally easier to |
| /// programmatically manipulate its `value` or `selection` directly. |
| /// {@endtemplate} |
| void selectPosition({ @required SelectionChangedCause cause }) { |
| selectPositionAt(from: _lastTapDownPosition, cause: cause); |
| } |
| |
| /// Select text between the global positions [from] and [to]. |
| void selectPositionAt({ @required Offset from, Offset to, @required SelectionChangedCause cause }) { |
| assert(cause != null); |
| assert(from != null); |
| _layoutText(constraints.maxWidth); |
| if (onSelectionChanged != null) { |
| final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); |
| final TextPosition toPosition = to == null |
| ? null |
| : _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)); |
| |
| int baseOffset = fromPosition.offset; |
| int extentOffset = fromPosition.offset; |
| if (toPosition != null) { |
| baseOffset = math.min(fromPosition.offset, toPosition.offset); |
| extentOffset = math.max(fromPosition.offset, toPosition.offset); |
| } |
| |
| final TextSelection newSelection = TextSelection( |
| baseOffset: baseOffset, |
| extentOffset: extentOffset, |
| affinity: fromPosition.affinity, |
| ); |
| // Call [onSelectionChanged] only when the selection actually changed. |
| if (newSelection != _selection) { |
| onSelectionChanged(newSelection, this, cause); |
| } |
| } |
| } |
| |
| /// Select a word around the location of the last tap down. |
| /// |
| /// {@macro flutter.rendering.editable.select} |
| void selectWord({ @required SelectionChangedCause cause }) { |
| selectWordsInRange(from: _lastTapDownPosition, cause: cause); |
| } |
| |
| /// Selects the set words of a paragraph in a given range of global positions. |
| /// |
| /// The first and last endpoints of the selection will always be at the |
| /// beginning and end of a word respectively. |
| /// |
| /// {@macro flutter.rendering.editable.select} |
| void selectWordsInRange({ @required Offset from, Offset to, @required SelectionChangedCause cause }) { |
| assert(cause != null); |
| assert(from != null); |
| _layoutText(constraints.maxWidth); |
| if (onSelectionChanged != null) { |
| final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); |
| final TextSelection firstWord = _selectWordAtOffset(firstPosition); |
| final TextSelection lastWord = to == null ? |
| firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); |
| |
| onSelectionChanged( |
| TextSelection( |
| baseOffset: firstWord.base.offset, |
| extentOffset: lastWord.extent.offset, |
| affinity: firstWord.affinity, |
| ), this, cause, |
| ); |
| } |
| } |
| |
| /// Move the selection to the beginning or end of a word. |
| /// |
| /// {@macro flutter.rendering.editable.select} |
| void selectWordEdge({ @required SelectionChangedCause cause }) { |
| assert(cause != null); |
| _layoutText(constraints.maxWidth); |
| assert(_lastTapDownPosition != null); |
| if (onSelectionChanged != null) { |
| final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition - _paintOffset)); |
| final TextRange word = _textPainter.getWordBoundary(position); |
| if (position.offset - word.start <= 1) { |
| onSelectionChanged( |
| TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream), |
| this, |
| cause, |
| ); |
| } else { |
| onSelectionChanged( |
| TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream), |
| this, |
| cause, |
| ); |
| } |
| } |
| } |
| |
| TextSelection _selectWordAtOffset(TextPosition position) { |
| assert(_textLayoutLastWidth == constraints.maxWidth); |
| final TextRange word = _textPainter.getWordBoundary(position); |
| // When long-pressing past the end of the text, we want a collapsed cursor. |
| if (position.offset >= word.end) |
| return TextSelection.fromPosition(position); |
| return TextSelection(baseOffset: word.start, extentOffset: word.end); |
| } |
| |
| Rect _caretPrototype; |
| |
| void _layoutText(double constraintWidth) { |
| assert(constraintWidth != null); |
| if (_textLayoutLastWidth == constraintWidth) |
| return; |
| final double caretMargin = _kCaretGap + cursorWidth; |
| final double availableWidth = math.max(0.0, constraintWidth - caretMargin); |
| final double maxWidth = _isMultiline ? availableWidth : double.infinity; |
| _textPainter.layout(minWidth: availableWidth, maxWidth: maxWidth); |
| _textLayoutLastWidth = constraintWidth; |
| } |
| |
| // TODO(garyq): This is no longer producing the highest-fidelity caret |
| // heights for Android, especially when non-alphabetic languages |
| // are involved. The current implementation overrides the height set |
| // here with the full measured height of the text on Android which looks |
| // superior (subjectively and in terms of fidelity) in _paintCaret. We |
| // should rework this properly to once again match the platform. The constant |
| // _kCaretHeightOffset scales poorly for small font sizes. |
| // |
| /// On iOS, the cursor is taller than the the cursor on Android. The height |
| /// of the cursor for iOS is approximate and obtained through an eyeball |
| /// comparison. |
| Rect get _getCaretPrototype { |
| switch(defaultTargetPlatform){ |
| case TargetPlatform.iOS: |
| return Rect.fromLTWH(0.0, -_kCaretHeightOffset + .5, cursorWidth, preferredLineHeight + 2); |
| default: |
| return Rect.fromLTWH(0.0, _kCaretHeightOffset, cursorWidth, preferredLineHeight - 2.0 * _kCaretHeightOffset); |
| } |
| } |
| @override |
| void performLayout() { |
| _layoutText(constraints.maxWidth); |
| _caretPrototype = _getCaretPrototype; |
| _selectionRects = null; |
| // We grab _textPainter.size here because assigning to `size` on the next |
| // line will trigger us to validate our intrinsic sizes, which will change |
| // _textPainter's layout because the intrinsic size calculations are |
| // destructive, which would mean we would get different results if we later |
| // used properties on _textPainter in this method. |
| // Other _textPainter state like didExceedMaxLines will also be affected, |
| // though we currently don't use those here. |
| // See also RenderParagraph which has a similar issue. |
| final Size textPainterSize = _textPainter.size; |
| size = Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight(constraints.maxWidth))); |
| final Size contentSize = Size(textPainterSize.width + _kCaretGap + cursorWidth, textPainterSize.height); |
| _maxScrollExtent = _getMaxScrollExtent(contentSize); |
| offset.applyViewportDimension(_viewportExtent); |
| offset.applyContentDimensions(0.0, _maxScrollExtent); |
| } |
| |
| Offset _getPixelPerfectCursorOffset(Rect caretRect) { |
| final Offset caretPosition = localToGlobal(caretRect.topLeft); |
| final double pixelMultiple = 1.0 / _devicePixelRatio; |
| final int quotientX = (caretPosition.dx / pixelMultiple).round(); |
| final int quotientY = (caretPosition.dy / pixelMultiple).round(); |
| final double pixelPerfectOffsetX = quotientX * pixelMultiple - caretPosition.dx; |
| final double pixelPerfectOffsetY = quotientY * pixelMultiple - caretPosition.dy; |
| return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); |
| } |
| |
| void _paintCaret(Canvas canvas, Offset effectiveOffset, TextPosition textPosition) { |
| assert(_textLayoutLastWidth == constraints.maxWidth); |
| |
| // If the floating cursor is enabled, the text cursor's color is [backgroundCursorColor] while |
| // the floating cursor's color is _cursorColor; |
| final Paint paint = Paint() |
| ..color = _floatingCursorOn ? backgroundCursorColor : _cursorColor; |
| final Offset caretOffset = _textPainter.getOffsetForCaret(textPosition, _caretPrototype) + effectiveOffset; |
| Rect caretRect = _caretPrototype.shift(caretOffset); |
| if (_cursorOffset != null) |
| caretRect = caretRect.shift(_cursorOffset); |
| |
| // Override the height to take the full height of the glyph at the TextPosition |
| // when not on iOS. iOS has special handling that creates a taller caret. |
| // TODO(garyq): See the TODO for _getCaretPrototype. |
| if (defaultTargetPlatform != TargetPlatform.iOS && _textPainter.getFullHeightForCaret(textPosition, _caretPrototype) != null) { |
| caretRect = Rect.fromLTWH( |
| caretRect.left, |
| // Offset by _kCaretHeightOffset to counteract the same value added in |
| // _getCaretPrototype. This prevents this from scaling poorly for small |
| // font sizes. |
| caretRect.top - _kCaretHeightOffset, |
| caretRect.width, |
| _textPainter.getFullHeightForCaret(textPosition, _caretPrototype), |
| ); |
| } |
| |
| caretRect = caretRect.shift(_getPixelPerfectCursorOffset(caretRect)); |
| |
| if (cursorRadius == null) { |
| canvas.drawRect(caretRect, paint); |
| } else { |
| final RRect caretRRect = RRect.fromRectAndRadius(caretRect, cursorRadius); |
| canvas.drawRRect(caretRRect, paint); |
| } |
| |
| if (caretRect != _lastCaretRect) { |
| _lastCaretRect = caretRect; |
| if (onCaretChanged != null) |
| onCaretChanged(caretRect); |
| } |
| } |
| |
| /// Sets the screen position of the floating cursor and the text position |
| /// closest to the cursor. |
| void setFloatingCursor(FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, { double resetLerpValue }) { |
| assert(state != null); |
| assert(boundedOffset != null); |
| assert(lastTextPosition != null); |
| if (state == FloatingCursorDragState.Start) { |
| _relativeOrigin = const Offset(0, 0); |
| _previousOffset = null; |
| _resetOriginOnBottom = false; |
| _resetOriginOnTop = false; |
| _resetOriginOnRight = false; |
| _resetOriginOnBottom = false; |
| } |
| _floatingCursorOn = state != FloatingCursorDragState.End; |
| _resetFloatingCursorAnimationValue = resetLerpValue; |
| if (_floatingCursorOn) { |
| _floatingCursorOffset = boundedOffset; |
| _floatingCursorTextPosition = lastTextPosition; |
| } |
| markNeedsPaint(); |
| } |
| |
| void _paintFloatingCaret(Canvas canvas, Offset effectiveOffset) { |
| assert(_textLayoutLastWidth == constraints.maxWidth); |
| assert(_floatingCursorOn); |
| |
| // We always want the floating cursor to render at full opacity. |
| final Paint paint = Paint()..color = _cursorColor.withOpacity(0.75); |
| |
| double sizeAdjustmentX = _kFloatingCaretSizeIncrease.dx; |
| double sizeAdjustmentY = _kFloatingCaretSizeIncrease.dy; |
| |
| if (_resetFloatingCursorAnimationValue != null) { |
| sizeAdjustmentX = ui.lerpDouble(sizeAdjustmentX, 0, _resetFloatingCursorAnimationValue); |
| sizeAdjustmentY = ui.lerpDouble(sizeAdjustmentY, 0, _resetFloatingCursorAnimationValue); |
| } |
| |
| final Rect floatingCaretPrototype = Rect.fromLTRB( |
| _caretPrototype.left - sizeAdjustmentX, |
| _caretPrototype.top - sizeAdjustmentY, |
| _caretPrototype.right + sizeAdjustmentX, |
| _caretPrototype.bottom + sizeAdjustmentY, |
| ); |
| |
| final Rect caretRect = floatingCaretPrototype.shift(effectiveOffset); |
| const Radius floatingCursorRadius = Radius.circular(_kFloatingCaretRadius); |
| final RRect caretRRect = RRect.fromRectAndRadius(caretRect, floatingCursorRadius); |
| canvas.drawRRect(caretRRect, paint); |
| } |
| |
| // The relative origin in relation to the distance the user has theoretically |
| // dragged the floating cursor offscreen. This value is used to account for the |
| // difference in the rendering position and the raw offset value. |
| Offset _relativeOrigin = const Offset(0, 0); |
| Offset _previousOffset; |
| bool _resetOriginOnLeft = false; |
| bool _resetOriginOnRight = false; |
| bool _resetOriginOnTop = false; |
| bool _resetOriginOnBottom = false; |
| double _resetFloatingCursorAnimationValue; |
| |
| /// Returns the position within the text field closest to the raw cursor offset. |
| Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset) { |
| Offset deltaPosition = const Offset(0, 0); |
| final double topBound = -floatingCursorAddedMargin.top; |
| final double bottomBound = _textPainter.height - preferredLineHeight + floatingCursorAddedMargin.bottom; |
| final double leftBound = -floatingCursorAddedMargin.left; |
| final double rightBound = _textPainter.width + floatingCursorAddedMargin.right; |
| |
| if (_previousOffset != null) |
| deltaPosition = rawCursorOffset - _previousOffset; |
| |
| // If the raw cursor offset has gone off an edge, we want to reset the relative |
| // origin of the dragging when the user drags back into the field. |
| if (_resetOriginOnLeft && deltaPosition.dx > 0) { |
| _relativeOrigin = Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); |
| _resetOriginOnLeft = false; |
| } else if (_resetOriginOnRight && deltaPosition.dx < 0) { |
| _relativeOrigin = Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); |
| _resetOriginOnRight = false; |
| } |
| if (_resetOriginOnTop && deltaPosition.dy > 0) { |
| _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); |
| _resetOriginOnTop = false; |
| } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { |
| _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); |
| _resetOriginOnBottom = false; |
| } |
| |
| final double currentX = rawCursorOffset.dx - _relativeOrigin.dx; |
| final double currentY = rawCursorOffset.dy - _relativeOrigin.dy; |
| final double adjustedX = math.min(math.max(currentX, leftBound), rightBound); |
| final double adjustedY = math.min(math.max(currentY, topBound), bottomBound); |
| final Offset adjustedOffset = Offset(adjustedX, adjustedY); |
| |
| if (currentX < leftBound && deltaPosition.dx < 0) |
| _resetOriginOnLeft = true; |
| else if (currentX > rightBound && deltaPosition.dx > 0) |
| _resetOriginOnRight = true; |
| if (currentY < topBound && deltaPosition.dy < 0) |
| _resetOriginOnTop = true; |
| else if (currentY > bottomBound && deltaPosition.dy > 0) |
| _resetOriginOnBottom = true; |
| |
| _previousOffset = rawCursorOffset; |
| |
| return adjustedOffset; |
| } |
| |
| void _paintSelection(Canvas canvas, Offset effectiveOffset) { |
| assert(_textLayoutLastWidth == constraints.maxWidth); |
| assert(_selectionRects != null); |
| final Paint paint = Paint()..color = _selectionColor; |
| for (ui.TextBox box in _selectionRects) |
| canvas.drawRect(box.toRect().shift(effectiveOffset), paint); |
| } |
| |
| void _paintContents(PaintingContext context, Offset offset) { |
| assert(_textLayoutLastWidth == constraints.maxWidth); |
| final Offset effectiveOffset = offset + _paintOffset; |
| |
| bool showSelection = false; |
| bool showCaret = false; |
| |
| if (_selection != null && !_floatingCursorOn) { |
| if (_selection.isCollapsed && _showCursor.value && cursorColor != null) |
| showCaret = true; |
| else if (!_selection.isCollapsed && _selectionColor != null) |
| showSelection = true; |
| _updateSelectionExtentsVisibility(effectiveOffset); |
| } |
| |
| if (showSelection) { |
| _selectionRects ??= _textPainter.getBoxesForSelection(_selection); |
| _paintSelection(context.canvas, effectiveOffset); |
| } |
| |
| // On iOS, the cursor is painted over the text, on Android, it's painted |
| // under it. |
| if (paintCursorAboveText) |
| _textPainter.paint(context.canvas, effectiveOffset); |
| |
| if (showCaret) |
| _paintCaret(context.canvas, effectiveOffset, _selection.extent); |
| |
| if (!paintCursorAboveText) |
| _textPainter.paint(context.canvas, effectiveOffset); |
| |
| if (_floatingCursorOn) { |
| if (_resetFloatingCursorAnimationValue == null) |
| _paintCaret(context.canvas, effectiveOffset, _floatingCursorTextPosition); |
| _paintFloatingCaret(context.canvas, _floatingCursorOffset); |
| } |
| } |
| |
| @override |
| void paint(PaintingContext context, Offset offset) { |
| _layoutText(constraints.maxWidth); |
| if (_hasVisualOverflow) |
| context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents); |
| else |
| _paintContents(context, offset); |
| } |
| |
| @override |
| Rect describeApproximatePaintClip(RenderObject child) => _hasVisualOverflow ? Offset.zero & size : null; |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor)); |
| properties.add(DiagnosticsProperty<ValueNotifier<bool>>('showCursor', showCursor)); |
| properties.add(IntProperty('maxLines', maxLines)); |
| properties.add(IntProperty('minLines', minLines)); |
| properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); |
| properties.add(DiagnosticsProperty<Color>('selectionColor', selectionColor)); |
| properties.add(DoubleProperty('textScaleFactor', textScaleFactor)); |
| properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null)); |
| properties.add(DiagnosticsProperty<TextSelection>('selection', selection)); |
| properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset)); |
| } |
| |
| @override |
| List<DiagnosticsNode> debugDescribeChildren() { |
| return <DiagnosticsNode>[ |
| text.toDiagnosticsNode( |
| name: 'text', |
| style: DiagnosticsTreeStyle.transition, |
| ), |
| ]; |
| } |
| } |