Adds support for floating cursor (#25384)
* Adds support for floating cursor.
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index 6aa1ad8..71c6a81 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -672,6 +672,7 @@
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor,
+ backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
),
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 03ec238..5a11121 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -622,6 +622,7 @@
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor,
+ backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: widget.enableInteractiveSelection,
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 603f028..cb55f70 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -5,7 +5,7 @@
//ignore: Remove this once Google catches up with dev.4 Dart.
import 'dart:async';
import 'dart:math' as math;
-import 'dart:ui' as ui show TextBox;
+import 'dart:ui' as ui show TextBox, lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
@@ -19,6 +19,13 @@
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, 2.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).
///
@@ -129,6 +136,7 @@
@required TextDirection textDirection,
TextAlign textAlign = TextAlign.start,
Color cursorColor,
+ Color backgroundCursorColor,
ValueNotifier<bool> showCursor,
bool hasFocus,
int maxLines = 1,
@@ -143,6 +151,7 @@
Locale locale,
double cursorWidth = 1.0,
Radius cursorRadius,
+ EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(3, 6, 3, 6),
bool enableInteractiveSelection = true,
@required this.textSelectionDelegate,
}) : assert(textAlign != null),
@@ -162,6 +171,7 @@
locale: locale,
),
_cursorColor = cursorColor,
+ _backgroundCursorColor = backgroundCursorColor,
_showCursor = showCursor ?? ValueNotifier<bool>(false),
_hasFocus = hasFocus ?? false,
_maxLines = maxLines,
@@ -170,6 +180,7 @@
_offset = offset,
_cursorWidth = cursorWidth,
_cursorRadius = cursorRadius,
+ _floatingCursorAddedMargin = floatingCursorAddedMargin,
_enableInteractiveSelection = enableInteractiveSelection,
_obscureText = obscureText {
assert(_showCursor != null);
@@ -566,6 +577,19 @@
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;
@@ -701,6 +725,23 @@
markNeedsPaint();
}
+ /// The padding applied to text field. Used to determine the bounds when
+ /// moving the floating cursor.
+ ///
+ /// Defaults to a padding with left, right set to 3 and top, bottom to 6.
+ 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.
///
@@ -1205,11 +1246,13 @@
offset.applyContentDimensions(0.0, _maxScrollExtent);
}
- void _paintCaret(Canvas canvas, Offset effectiveOffset) {
+ void _paintCaret(Canvas canvas, Offset effectiveOffset, TextPosition textPosition) {
assert(_textLayoutLastWidth == constraints.maxWidth);
- final Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype);
+ final Offset caretOffset = _textPainter.getOffsetForCaret(textPosition, _caretPrototype);
+ // 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 = _cursorColor;
+ ..color = _floatingCursorOn ? backgroundCursorColor : _cursorColor;
final Rect caretRect = _caretPrototype.shift(caretOffset + effectiveOffset);
@@ -1227,6 +1270,112 @@
}
}
+ /// 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);
+
+ final Paint paint = Paint()..color = _cursorColor;
+
+ 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);
@@ -1238,17 +1387,20 @@
void _paintContents(PaintingContext context, Offset offset) {
assert(_textLayoutLastWidth == constraints.maxWidth);
final Offset effectiveOffset = offset + _paintOffset;
-
- if (_selection != null) {
+ if (_selection != null && !_floatingCursorOn) {
if (_selection.isCollapsed && _showCursor.value && cursorColor != null) {
- _paintCaret(context.canvas, effectiveOffset);
+ _paintCaret(context.canvas, effectiveOffset, _selection.extent);
} else if (!_selection.isCollapsed && _selectionColor != null) {
_selectionRects ??= _textPainter.getBoxesForSelection(_selection);
_paintSelection(context.canvas, effectiveOffset);
}
}
-
_textPainter.paint(context.canvas, effectiveOffset);
+ if (_floatingCursorOn) {
+ if (_resetFloatingCursorAnimationValue == null)
+ _paintCaret(context.canvas, effectiveOffset, _floatingCursorTextPosition);
+ _paintFloatingCaret(context.canvas, _floatingCursorOffset);
+ }
}
@override
diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart
index 36b7d81..160c5c7 100644
--- a/packages/flutter/lib/src/services/text_input.dart
+++ b/packages/flutter/lib/src/services/text_input.dart
@@ -4,7 +4,7 @@
import 'dart:async';
import 'dart:io' show Platform;
-import 'dart:ui' show TextAffinity, hashValues;
+import 'dart:ui' show TextAffinity, hashValues, Offset;
import 'package:flutter/foundation.dart';
@@ -439,6 +439,40 @@
return null;
}
+/// A floating cursor state the user has induced by force pressing an iOS
+/// keyboard.
+enum FloatingCursorDragState {
+ /// A user has just activated a floating cursor.
+ Start,
+
+ /// A user is dragging a floating cursor.
+ Update,
+
+ /// A user has lifted their finger off the screen after using a floating
+ /// cursor.
+ End,
+}
+
+/// The current state and position of the floating cursor.
+class RawFloatingCursorPoint {
+ /// Creates information for setting the position and state of a floating
+ /// cursor.
+ ///
+ /// [state] must not be null and [offset] must not be null if the state is
+ /// [FloatingCursorDragState.Update].
+ RawFloatingCursorPoint({
+ this.offset,
+ @required this.state,
+ }) : assert(state != null),
+ assert(state == FloatingCursorDragState.Update ? offset != null : true);
+
+ /// The raw position of the floating cursor as determined by the iOS sdk.
+ final Offset offset;
+
+ /// The state of the floating cursor.
+ final FloatingCursorDragState state;
+}
+
/// The current text, selection, and composing state for editing a run of text.
@immutable
class TextEditingValue {
@@ -566,6 +600,9 @@
/// Requests that this client perform the given action.
void performAction(TextInputAction action);
+
+ /// Updates the floating cursor position and state.
+ void updateFloatingCursor(RawFloatingCursorPoint point);
}
/// An interface for interacting with a text input control.
@@ -648,6 +685,26 @@
throw FlutterError('Unknown text input action: $action');
}
+FloatingCursorDragState _toTextCursorAction(String state) {
+ switch (state) {
+ case 'FloatingCursorDragState.start':
+ return FloatingCursorDragState.Start;
+ case 'FloatingCursorDragState.update':
+ return FloatingCursorDragState.Update;
+ case 'FloatingCursorDragState.end':
+ return FloatingCursorDragState.End;
+ }
+ throw FlutterError('Unknown text cursor action: $state');
+}
+
+RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, dynamic> encoded) {
+ assert(state != null, 'You must provide a state to set a new editing point.');
+ assert(encoded['X'] != null, 'You must provide a value for the horizontal location of the floating cursor.');
+ assert(encoded['Y'] != null, 'You must provide a value for the vertical location of the floating cursor.');
+ final Offset offset = state == FloatingCursorDragState.Update ? Offset(encoded['X'], encoded['Y']) : const Offset(0, 0);
+ return RawFloatingCursorPoint(offset: offset, state: state);
+}
+
class _TextInputClientHandler {
_TextInputClientHandler() {
SystemChannels.textInput.setMethodCallHandler(_handleTextInputInvocation);
@@ -671,6 +728,9 @@
case 'TextInputClient.performAction':
_currentConnection._client.performAction(_toTextInputAction(args[1]));
break;
+ case 'TextInputClient.updateFloatingCursor':
+ _currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
+ break;
default:
throw MissingPluginException();
}
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index e4ed2b0..b5dade4 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -21,6 +21,7 @@
import 'scroll_physics.dart';
import 'scrollable.dart';
import 'text_selection.dart';
+import 'ticker_provider.dart';
export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType;
export 'package:flutter/rendering.dart' show SelectionChangedCause;
@@ -182,9 +183,9 @@
/// [TextInputType.text] unless [maxLines] is greater than one, when it will
/// default to [TextInputType.multiline].
///
- /// The [controller], [focusNode], [style], [cursorColor], [textAlign],
- /// [rendererIgnoresPointer], and [enableInteractiveSelection] arguments must
- /// not be null.
+ /// The [controller], [focusNode], [style], [cursorColor], [backgroundCursorColor],
+ /// [textAlign], [rendererIgnoresPointer], and [enableInteractiveSelection]
+ /// arguments must not be null.
EditableText({
Key key,
@required this.controller,
@@ -193,6 +194,7 @@
this.autocorrect = true,
@required this.style,
@required this.cursorColor,
+ @required this.backgroundCursorColor,
this.textAlign = TextAlign.start,
this.textDirection,
this.locale,
@@ -221,6 +223,7 @@
assert(autocorrect != null),
assert(style != null),
assert(cursorColor != null),
+ assert(backgroundCursorColor != null),
assert(textAlign != null),
assert(maxLines == null || maxLines > 0),
assert(autofocus != null),
@@ -324,6 +327,13 @@
/// Cannot be null.
final Color cursorColor;
+ /// 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 for the text to span, wrapping if necessary.
///
@@ -491,7 +501,7 @@
}
/// State for a [EditableText].
-class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver implements TextInputClient, TextSelectionDelegate {
+class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText> implements TextInputClient, TextSelectionDelegate {
Timer _cursorTimer;
final ValueNotifier<bool> _showCursor = ValueNotifier<bool>(false);
final GlobalKey _editableKey = GlobalKey();
@@ -503,6 +513,12 @@
final LayerLink _layerLink = LayerLink();
bool _didAutoFocus = false;
+ // 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;
+
@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
@@ -514,6 +530,8 @@
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(() { _selectionOverlay?.updateForScroll(); });
+ _floatingCursorResetController = AnimationController(vsync: this);
+ _floatingCursorResetController.addListener(_onFloatingCursorResetTick);
}
@override
@@ -595,6 +613,72 @@
}
}
+ // 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 first update 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) {
+ switch(point.state){
+ case FloatingCursorDragState.Start:
+ final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection.baseOffset);
+ _startCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
+ renderEditable.setFloatingCursor(point.state, _startCaretRect.center - _floatingCursorOffset, currentTextPosition);
+ break;
+ case FloatingCursorDragState.Update:
+ // We want to send in points that are centered around a (0,0) origin, so we cache the
+ // position on the first update call.
+ if (_pointOffsetOrigin != null) {
+ 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);
+ } else {
+ _pointOffsetOrigin = point.offset;
+ }
+ break;
+ case FloatingCursorDragState.End:
+ _floatingCursorResetController.value = 0.0;
+ _floatingCursorResetController.animateTo(1.0, duration: _floatingCursorResetTime, curve: Curves.decelerate);
+ break;
+ }
+ }
+
+ void _onFloatingCursorResetTick() {
+ final Offset finalPosition = renderEditable.getLocalRectForCaret(_lastTextPosition).center - _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), renderEditable, SelectionChangedCause.tap);
+ _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);
+ }
+ }
+
void _finalizeEditing(bool shouldUnfocus) {
// Take any actions necessary now that the user has completed editing.
if (widget.onEditingComplete != null) {
@@ -969,6 +1053,7 @@
textSpan: buildTextSpan(),
value: _value,
cursorColor: widget.cursorColor,
+ backgroundCursorColor: widget.backgroundCursorColor,
showCursor: EditableText.debugDeterministicCursor ? ValueNotifier<bool>(true) : _showCursor,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
@@ -1034,6 +1119,7 @@
this.textSpan,
this.value,
this.cursorColor,
+ this.backgroundCursorColor,
this.showCursor,
this.hasFocus,
this.maxLines,
@@ -1060,6 +1146,7 @@
final TextSpan textSpan;
final TextEditingValue value;
final Color cursorColor;
+ final Color backgroundCursorColor;
final ValueNotifier<bool> showCursor;
final bool hasFocus;
final int maxLines;
@@ -1084,6 +1171,7 @@
return RenderEditable(
text: textSpan,
cursorColor: cursorColor,
+ backgroundCursorColor: backgroundCursorColor,
showCursor: showCursor,
hasFocus: hasFocus,
maxLines: maxLines,
diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart
index c682d7b..100d0b2 100644
--- a/packages/flutter/test/rendering/editable_test.dart
+++ b/packages/flutter/test/rendering/editable_test.dart
@@ -1,10 +1,12 @@
-// Copyright 2017 The Chromium Authors. All rights reserved.
+ // Copyright 2017 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 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/foundation.dart';
import '../rendering/mock_canvas.dart';
import '../rendering/recording_canvas.dart';
@@ -24,6 +26,10 @@
}
void main() {
+
+ final TextEditingController controller = TextEditingController();
+ const TextStyle textStyle = TextStyle();
+
test('editable intrinsics', () {
final TextSelectionDelegate delegate = FakeEditableTextState();
final RenderEditable editable = RenderEditable(
@@ -91,4 +97,68 @@
paints..clipRect(rect: Rect.fromLTRB(0.0, 0.0, 1000.0, 10.0))
);
});
+
+ RenderEditable findRenderEditable(WidgetTester tester) {
+ final RenderObject root = tester.renderObject(find.byType(EditableText));
+ expect(root, isNotNull);
+
+ RenderEditable renderEditable;
+ void recursiveFinder(RenderObject child) {
+ if (child is RenderEditable) {
+ renderEditable = child;
+ return;
+ }
+ child.visitChildren(recursiveFinder);
+ }
+ root.visitChildren(recursiveFinder);
+ expect(renderEditable, isNotNull);
+ return renderEditable;
+ }
+
+ testWidgets('Floating cursor is painted', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ const String text = 'hello world this is fun and cool and awesome!';
+ controller.text = text;
+ final FocusNode focusNode = FocusNode();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: TextField(
+ controller: controller,
+ focusNode: focusNode,
+ style: textStyle,
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byType(EditableText));
+ final RenderEditable editable = findRenderEditable(tester);
+ editable.selection = const TextSelection(baseOffset: 29, extentOffset: 29);
+
+ final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(20, 20)));
+ await tester.pump();
+
+ expect(find.byType(EditableText), paints..rrect(
+ rrect: RRect.fromRectAndRadius(Rect.fromLTRB(464.5, 0, 467.5, 16.0), const Radius.circular(1.0)), color: const Color(0xff4285f4))
+ );
+
+ // Moves the cursor right a few characters.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-250, 20)));
+
+ expect(find.byType(EditableText), paints..rrect(
+ rrect: RRect.fromRectAndRadius(Rect.fromLTRB(194.5, 0, 197.5, 16.0), const Radius.circular(1.0)), color: const Color(0xff4285f4))
+ );
+
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End));
+
+ await tester.pumpAndSettle();
+
+ debugDefaultTargetPlatformOverride = null;
+ });
}
diff --git a/packages/flutter/test/rendering/mock_canvas.dart b/packages/flutter/test/rendering/mock_canvas.dart
index 172801f..5956dc8 100644
--- a/packages/flutter/test/rendering/mock_canvas.dart
+++ b/packages/flutter/test/rendering/mock_canvas.dart
@@ -1024,16 +1024,49 @@
);
}
-class _RRectPaintPredicate extends _OneParameterPaintPredicate<RRect> {
- _RRectPaintPredicate({ RRect rrect, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super(
+class _RRectPaintPredicate extends _DrawCommandPaintPredicate {
+ _RRectPaintPredicate({ this.rrect, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super(
#drawRRect,
'a rounded rectangle',
- expected: rrect,
+ 2,
+ 1,
color: color,
strokeWidth: strokeWidth,
hasMaskFilter: hasMaskFilter,
- style: style,
+ style: style
);
+
+ final RRect rrect;
+
+ @override
+ void verifyArguments(List<dynamic> arguments) {
+ super.verifyArguments(arguments);
+ const double eps = .0001;
+ final RRect actual = arguments[0];
+ if (rrect != null &&
+ ((actual.left - rrect.left).abs() > eps ||
+ (actual.right - rrect.right).abs() > eps ||
+ (actual.top - rrect.top).abs() > eps ||
+ (actual.bottom - rrect.bottom).abs() > eps ||
+ (actual.blRadiusX - rrect.blRadiusX).abs() > eps ||
+ (actual.blRadiusY - rrect.blRadiusY).abs() > eps ||
+ (actual.brRadiusX - rrect.brRadiusX).abs() > eps ||
+ (actual.brRadiusY - rrect.brRadiusY).abs() > eps ||
+ (actual.tlRadiusX - rrect.tlRadiusX).abs() > eps ||
+ (actual.tlRadiusY - rrect.tlRadiusY).abs() > eps ||
+ (actual.trRadiusX - rrect.trRadiusX).abs() > eps ||
+ (actual.trRadiusY - rrect.trRadiusY).abs() > eps)) {
+ throw 'It called $methodName with RRect, $actual, which was not exactly the expected RRect ($rrect).';
+ }
+ }
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ if (rrect != null) {
+ description.add('RRect: $rrect');
+ }
+ }
}
class _DRRectPaintPredicate extends _TwoParameterPaintPredicate<RRect, RRect> {
diff --git a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart
index 63be25b..19ae1b4 100644
--- a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart
+++ b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart
@@ -26,6 +26,7 @@
controller: scrollController,
children: <Widget>[
EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -67,6 +68,7 @@
height: 200.0,
),
EditableText(
+ backgroundCursorColor: Colors.grey,
scrollPadding: const EdgeInsets.all(50.0),
controller: controller,
focusNode: focusNode,
@@ -112,6 +114,7 @@
height: 350.0,
),
EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -161,6 +164,7 @@
height: 350.0,
),
EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -247,6 +251,7 @@
controller: scrollController,
children: <Widget>[
EditableText(
+ backgroundCursorColor: Colors.grey,
maxLines: null, // multi-line
controller: controller,
focusNode: focusNode,
@@ -302,6 +307,7 @@
height: 200.0,
),
EditableText(
+ backgroundCursorColor: Colors.grey,
scrollPadding: const EdgeInsets.only(bottom: 300.0),
controller: controller,
focusNode: focusNode,
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index f4a1b86..85ae629 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -42,6 +42,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
textInputAction: action,
@@ -67,6 +68,7 @@
textDirection: TextDirection.ltr,
child: EditableText(
controller: controller,
+ backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
@@ -87,6 +89,7 @@
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -111,6 +114,7 @@
autofocus: true,
child: EditableText(
controller: controller,
+ backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
@@ -272,6 +276,7 @@
autofocus: true,
child: EditableText(
controller: controller,
+ backgroundCursorColor: Colors.grey,
focusNode: focusNode,
keyboardType: TextInputType.multiline,
style: textStyle,
@@ -301,6 +306,7 @@
autofocus: true,
child: EditableText(
controller: controller,
+ backgroundCursorColor: Colors.grey,
focusNode: focusNode,
maxLines: null,
style: textStyle,
@@ -329,6 +335,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
maxLines: null,
@@ -361,6 +368,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.phone,
@@ -392,6 +400,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
maxLines: 3, // Sets multiline keyboard implicitly.
@@ -422,6 +431,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
@@ -451,6 +461,7 @@
String changedValue;
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: FocusNode(),
@@ -494,6 +505,7 @@
home: RepaintBoundary(
key: const ValueKey<int>(1),
child: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: FocusNode(),
@@ -544,6 +556,7 @@
home: RepaintBoundary(
key: const ValueKey<int>(1),
child: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: FocusNode(),
@@ -594,6 +607,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -628,6 +642,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -669,6 +684,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -713,6 +729,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -757,6 +774,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -801,6 +819,7 @@
final Widget widget = MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
@@ -853,6 +872,7 @@
child: Center(
child: Material(
child: EditableText(
+ backgroundCursorColor: Colors.grey,
key: editableTextKey,
controller: currentController,
focusNode: FocusNode(),
@@ -912,6 +932,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -952,6 +973,7 @@
child: FocusScope(
node: focusScopeNode,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -992,6 +1014,7 @@
await tester.pumpWidget(
MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
selectionControls: materialTextSelectionControls,
focusNode: focusNode,
@@ -1034,6 +1057,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1109,6 +1133,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1198,6 +1223,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1297,6 +1323,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1395,6 +1422,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1490,6 +1518,7 @@
await tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
obscureText: true,
controller: controller,
focusNode: focusNode,
@@ -1536,6 +1565,7 @@
MockTextSelectionControls controls, WidgetTester tester) {
return tester.pumpWidget(MaterialApp(
home: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1743,6 +1773,7 @@
node: focusScopeNode,
autofocus: true,
child: EditableText(
+ backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
autofocus: true,
@@ -1756,6 +1787,157 @@
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, text.length);
});
+
+ RenderEditable findRenderEditable(WidgetTester tester) {
+ final RenderObject root = tester.renderObject(find.byType(EditableText));
+ expect(root, isNotNull);
+
+ RenderEditable renderEditable;
+ void recursiveFinder(RenderObject child) {
+ if (child is RenderEditable) {
+ renderEditable = child;
+ return;
+ }
+ child.visitChildren(recursiveFinder);
+ }
+ root.visitChildren(recursiveFinder);
+ expect(renderEditable, isNotNull);
+ return renderEditable;
+ }
+
+ testWidgets('Updating the floating cursor correctly moves the cursor', (WidgetTester tester) async {
+ const String text = 'hello world this is fun and cool and awesome!';
+ controller.text = text;
+ final FocusNode focusNode = FocusNode();
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: FocusScope(
+ node: focusScopeNode,
+ autofocus: true,
+ child: EditableText(
+ backgroundCursorColor: Colors.grey,
+ controller: controller,
+ focusNode: focusNode,
+ style: textStyle,
+ cursorColor: cursorColor,
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byType(EditableText));
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ renderEditable.selection = const TextSelection(baseOffset: 29, extentOffset: 29);
+
+ expect(controller.selection.baseOffset, 29);
+
+ final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start));
+
+ expect(controller.selection.baseOffset, 29);
+
+ // Sets the origin.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(20, 20)));
+
+ expect(controller.selection.baseOffset, 29);
+
+ // Moves the cursor right a few characters.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-250, 20)));
+
+ // But we have not yet set the offset because the user is not done placing the cursor.
+ expect(controller.selection.baseOffset, 29);
+
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End));
+
+ await tester.pumpAndSettle();
+ // The cursor has been set.
+ expect(controller.selection.baseOffset, 10);
+ });
+
+ testWidgets('Cursor gets placed correctly after going out of bounds', (WidgetTester tester) async {
+ const String text = 'hello world this is fun and cool and awesome!';
+ controller.text = text;
+ final FocusNode focusNode = FocusNode();
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: FocusScope(
+ node: focusScopeNode,
+ autofocus: true,
+ child: EditableText(
+ backgroundCursorColor: Colors.grey,
+ controller: controller,
+ focusNode: focusNode,
+ style: textStyle,
+ cursorColor: cursorColor,
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byType(EditableText));
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ renderEditable.selection = const TextSelection(baseOffset: 29, extentOffset: 29);
+
+ expect(controller.selection.baseOffset, 29);
+
+ final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start));
+
+ expect(controller.selection.baseOffset, 29);
+
+ // Sets the origin.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(20, 20)));
+
+ expect(controller.selection.baseOffset, 29);
+
+ // Moves the cursor super far right
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(2090, 20)));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(2100, 20)));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(2090, 20)));
+
+ // After peaking the cursor, we move in the opposite direction.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(1400, 20)));
+
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End));
+
+ await tester.pumpAndSettle();
+ // The cursor has been set.
+ expect(controller.selection.baseOffset, 8);
+
+ // Go in the other direction.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start));
+ // Sets the origin.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(20, 20)));
+
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-5000, 20)));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-5010, 20)));
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-5000, 20)));
+
+ // Move back in the opposite direction only a few hundred.
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update,
+ offset: const Offset(-4850, 20)));
+
+ editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End));
+
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.baseOffset, 11);
+ });
}
class MockTextSelectionControls extends Mock implements TextSelectionControls {}
@@ -1769,6 +1951,7 @@
}) : super(
controller: controller,
cursorColor: cursorColor,
+ backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: style,
);