diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 2c224cf8db5..6b530136892 100644 --- a/bin/internal/goldens.version +++ b/bin/internal/goldens.version @@ -1 +1 @@ -6fc7ec65d51116c3f83acb5251e57e779af2ebbb +b530d67675a5aa9c5458b93019ce91e20ad88758 diff --git a/packages/flutter/lib/src/cupertino/button.dart b/packages/flutter/lib/src/cupertino/button.dart index c006f9fc357..9773651fa79 100644 --- a/packages/flutter/lib/src/cupertino/button.dart +++ b/packages/flutter/lib/src/cupertino/button.dart @@ -110,6 +110,11 @@ class CupertinoButton extends StatefulWidget { /// Defaults to round corners of 8 logical pixels. final BorderRadius borderRadius; + /// The shape of the button. + /// + /// Defaults to a super ellipse with +// final ShapeBorder shape; + final bool _filled; /// Whether the button is enabled or disabled. Buttons are disabled by default. To diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 9d333af19ed..39cda8988e9 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -35,6 +35,14 @@ const Color _kSelectionHighlightColor = Color(0x667FAACF); const Color _kInactiveTextColor = Color(0xFFC2C2C2); const Color _kDisabledBackground = Color(0xFFFAFAFA); +// An eyeballed value that moves the cursor slightly left of where it is +// rendered for text on Android so it's positioning more accurately matches the +// native iOS text cursor positioning. +// +// This value is in device pixels, not logical pixels as is typically used +// throughout the codebase. +const int _iOSHorizontalCursorOffsetPixels = -2; + /// Visibility of text field overlays based on the state of the current text entry. /// /// Used to toggle the visibility behavior of the optional decorating widgets @@ -163,7 +171,7 @@ class CupertinoTextField extends StatefulWidget { this.inputFormatters, this.enabled, this.cursorWidth = 2.0, - this.cursorRadius, + this.cursorRadius = const Radius.circular(2.0), this.cursorColor = CupertinoColors.activeBlue, this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), @@ -598,6 +606,7 @@ class _CupertinoTextFieldState extends State with AutomaticK final TextEditingController controller = _effectiveController; final List formatters = widget.inputFormatters ?? []; final bool enabled = widget.enabled ?? true; + final Offset cursorOffset = Offset(_iOSHorizontalCursorOffsetPixels / MediaQuery.of(context).devicePixelRatio, 0); if (widget.maxLength != null && widget.maxLengthEnforced) { formatters.add(LengthLimitingTextInputFormatter(widget.maxLength)); } @@ -631,6 +640,9 @@ class _CupertinoTextFieldState extends State with AutomaticK cursorWidth: widget.cursorWidth, cursorRadius: widget.cursorRadius, cursorColor: widget.cursorColor, + cursorOpacityAnimates: true, + cursorOffset: cursorOffset, + paintCursorAboveText: true, 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 4474229bada..a8a7b8c2647 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'debug.dart'; @@ -36,6 +37,14 @@ typedef InputCounterWidgetBuilder = Widget Function( } ); +// An eyeballed value that moves the cursor slightly left of where it is +// rendered for text on Android so it's positioning more accurately matches the +// native iOS text cursor positioning. +// +// This value is in device pixels, not logical pixels as is typically used +// throughout the codebase. +const int _iOSHorizontalCursorOffsetPixels = 2; + /// A material design text field. /// /// A text field lets the user enter text, either with hardware keyboard or with @@ -469,6 +478,14 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi && widget.decoration != null && widget.decoration.counterText == null; + Radius get _cursorRadius { + if (widget.cursorRadius != null) + return widget.cursorRadius; + if (Theme.of(context).platform == TargetPlatform.iOS) + return const Radius.circular(2.0); + return null; + } + InputDecoration _getEffectiveDecoration() { final MaterialLocalizations localizations = MaterialLocalizations.of(context); final ThemeData themeData = Theme.of(context); @@ -682,6 +699,22 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi @override bool get wantKeepAlive => _splashes != null && _splashes.isNotEmpty; + bool get _cursorOpacityAnimates => Theme.of(context).platform == TargetPlatform.iOS ? true : false; + + Offset get _getCursorOffset => Offset(_iOSHorizontalCursorOffsetPixels / MediaQuery.of(context).devicePixelRatio, 0); + + bool get _paintCursorAboveText => Theme.of(context).platform == TargetPlatform.iOS ? true : false; + + Color get _cursorColor { + if (widget.cursorColor == null) { + if (Theme.of(context).platform == TargetPlatform.iOS) + return CupertinoTheme.of(context).primaryColor; + else + return Theme.of(context).cursorColor; + } + return widget.cursorColor; + } + @override void deactivate() { if (_splashes != null) { @@ -704,7 +737,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi assert(debugCheckHasDirectionality(context)); assert( !(widget.style != null && widget.style.inherit == false && - (widget.style.fontSize == null || widget.style.textBaseline == null)), + (widget.style.fontSize == null || widget.style.textBaseline == null)), 'inherit false style must supply fontSize and textBaseline', ); @@ -755,8 +788,11 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi inputFormatters: formatters, rendererIgnoresPointer: true, cursorWidth: widget.cursorWidth, - cursorRadius: widget.cursorRadius, - cursorColor: widget.cursorColor ?? themeData.cursorColor, + cursorRadius: _cursorRadius, + cursorColor: _cursorColor, + cursorOpacityAnimates: _cursorOpacityAnimates, + cursorOffset: _getCursorOffset, + paintCursorAboveText: _paintCursorAboveText, backgroundCursorColor: CupertinoColors.inactiveGray, scrollPadding: widget.scrollPadding, keyboardAppearance: keyboardAppearance, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 95b20c2968e..054e763985a 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -21,7 +21,7 @@ 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); +const Offset _kFloatingCaretSizeIncrease = Offset(0.5, 1.0); // The corner radius of the floating cursor in pixels. const double _kFloatingCaretRadius = 1.0; @@ -149,8 +149,11 @@ class RenderEditable extends RenderBox { Locale locale, double cursorWidth = 1.0, Radius cursorRadius, + bool paintCursorAboveText = false, + Offset cursorOffset, + double devicePixelRatio = 1.0, bool enableInteractiveSelection, - EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(3, 6, 3, 6), + EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), @required this.textSelectionDelegate, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), @@ -158,9 +161,11 @@ class RenderEditable extends RenderBox { 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, @@ -178,8 +183,11 @@ class RenderEditable extends RenderBox { _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); @@ -208,6 +216,18 @@ class RenderEditable extends RenderBox { /// 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; @@ -719,6 +739,38 @@ class RenderEditable extends RenderBox { 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. + /// {@end template} + 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. + /// {@end template} + 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; @@ -732,7 +784,7 @@ class RenderEditable extends RenderBox { /// 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. + /// 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) { @@ -1055,7 +1107,12 @@ class RenderEditable extends RenderBox { _layoutText(constraints.maxWidth); final Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, _caretPrototype); // This rect is the same as _caretPrototype but without the vertical padding. - return Rect.fromLTWH(0.0, 0.0, cursorWidth, preferredLineHeight).shift(caretOffset + _paintOffset); + 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 @@ -1262,10 +1319,21 @@ class RenderEditable extends RenderBox { _textLayoutLastWidth = constraintWidth; } + /// 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 = Rect.fromLTWH(0.0, _kCaretHeightOffset, cursorWidth, preferredLineHeight - 2.0 * _kCaretHeightOffset); + _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 @@ -1283,15 +1351,30 @@ class RenderEditable extends RenderBox { 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); 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 = _floatingCursorOn ? backgroundCursorColor : _cursorColor; - final Rect caretRect = _caretPrototype.shift(caretOffset + effectiveOffset); + Rect caretRect = _caretPrototype.shift(caretOffset + effectiveOffset); + if (_cursorOffset != null) + caretRect = caretRect.shift(_cursorOffset); + + caretRect = caretRect.shift(_getPixelPerfectCursorOffset(caretRect)); if (cursorRadius == null) { canvas.drawRect(caretRect, paint); @@ -1334,7 +1417,8 @@ class RenderEditable extends RenderBox { assert(_textLayoutLastWidth == constraints.maxWidth); assert(_floatingCursorOn); - final Paint paint = Paint()..color = _cursorColor; + // 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; @@ -1344,10 +1428,13 @@ class RenderEditable extends RenderBox { 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 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); @@ -1424,15 +1511,24 @@ class RenderEditable extends RenderBox { void _paintContents(PaintingContext context, Offset offset) { assert(_textLayoutLastWidth == constraints.maxWidth); final Offset effectiveOffset = offset + _paintOffset; + + // On iOS, the cursor is painted over the text, on Android, it's painted + // under it. + if (paintCursorAboveText) + _textPainter.paint(context.canvas, effectiveOffset); + if (_selection != null && !_floatingCursorOn) { - if (_selection.isCollapsed && _showCursor.value && cursorColor != null) { + if (_selection.isCollapsed && cursorColor != null && _hasFocus) { _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 (!paintCursorAboveText) + _textPainter.paint(context.canvas, effectiveOffset); + if (_floatingCursorOn) { if (_resetFloatingCursorAnimationValue == null) _paintCaret(context.canvas, effectiveOffset, _floatingCursorTextPosition); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 6774f5e2d55..4a986e1dbb1 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -31,8 +32,15 @@ export 'package:flutter/rendering.dart' show SelectionChangedCause; /// (including the cursor location). typedef SelectionChangedCallback = void Function(TextSelection selection, SelectionChangedCause cause); +// The time it takes for the cursor to fade from fully opaque to fully +// transparent and vice versa. A full cursor blink, from transparent to opaque +// to transparent, is twice this duration. const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500); +// The time the cursor is static in opacity before animating to become +// transparent. +const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150); + // Number of cursor ticks during which the most recently entered character // is shown in an obscured text field. const int _kObscureShowLatestCharCursorTicks = 3; @@ -215,6 +223,9 @@ class EditableText extends StatefulWidget { this.rendererIgnoresPointer = false, this.cursorWidth = 2.0, this.cursorRadius, + this.cursorOpacityAnimates = false, + this.cursorOffset, + this.paintCursorAboveText = false, this.scrollPadding = const EdgeInsets.all(20.0), this.keyboardAppearance = Brightness.light, this.dragStartBehavior = DragStartBehavior.down, @@ -225,6 +236,8 @@ class EditableText extends StatefulWidget { assert(autocorrect != null), assert(style != null), assert(cursorColor != null), + assert(cursorOpacityAnimates != null), + assert(paintCursorAboveText != null), assert(backgroundCursorColor != null), assert(textAlign != null), assert(maxLines == null || maxLines > 0), @@ -471,6 +484,19 @@ class EditableText extends StatefulWidget { /// {@endtemplate} final Radius cursorRadius; + /// Whether the cursor will animate from fully transparent to fully opaque + /// during each cursor blink. + /// + /// By default, the cursor opacity will animate on iOS platforms and will not + /// animate on Android platforms. + final bool cursorOpacityAnimates; + + ///{@macro flutter.rendering.editable.cursorOffset} + final Offset cursorOffset; + + ///{@macro flutter.rendering.editable.paintCursorOnTop} + final bool paintCursorAboveText; + /// The appearance of the keyboard. /// /// This setting is only honored on iOS devices. @@ -546,9 +572,15 @@ class EditableTextState extends State with AutomaticKeepAliveClien TextSelectionOverlay _selectionOverlay; final ScrollController _scrollController = ScrollController(); + AnimationController _cursorBlinkOpacityController; + final LayerLink _layerLink = LayerLink(); bool _didAutoFocus = false; + // This value is an eyeball estimation of the time it takes for the iOS cursor + // to ease in and out. + static const Duration _fadeDuration = Duration(milliseconds: 250); + // 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); @@ -558,6 +590,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override bool get wantKeepAlive => widget.focusNode.hasFocus; + Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); + // State lifecycle: @override @@ -566,6 +600,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien widget.controller.addListener(_didChangeTextEditingValue); widget.focusNode.addListener(_handleFocusChanged); _scrollController.addListener(() { _selectionOverlay?.updateForScroll(); }); + _cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration); + _cursorBlinkOpacityController.addListener(_onCursorColorTick); _floatingCursorResetController = AnimationController(vsync: this); _floatingCursorResetController.addListener(_onFloatingCursorResetTick); } @@ -597,6 +633,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void dispose() { widget.controller.removeListener(_didChangeTextEditingValue); + _cursorBlinkOpacityController.removeListener(_onCursorColorTick); + _floatingCursorResetController.removeListener(_onFloatingCursorResetTick); _closeInputConnectionIfNeeded(); assert(!_hasInputConnection); _stopCursorTimer(); @@ -623,6 +661,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien } _lastKnownRemoteTextEditingValue = value; _formatAndSetValue(value); + + // To keep the cursor from blinking while typing, we want to restart the + // cursor timer every time a new character is typed. + _stopCursorTimer(resetCharTicks: false); + _startCursorTimer(); } @override @@ -696,7 +739,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } void _onFloatingCursorResetTick() { - final Offset finalPosition = renderEditable.getLocalRectForCaret(_lastTextPosition).center - _floatingCursorOffset; + final Offset finalPosition = renderEditable.getLocalRectForCaret(_lastTextPosition).centerLeft - _floatingCursorOffset; if (_floatingCursorResetController.isCompleted) { renderEditable.setFloatingCursor(FloatingCursorDragState.End, finalPosition, _lastTextPosition); if (_lastTextPosition.offset != renderEditable.selection.baseOffset) @@ -957,6 +1000,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien widget.onChanged(value.text); } + void _onCursorColorTick() { + renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); + } + /// Whether the blinking cursor is actually visible at this precise moment /// (it's hidden half the time, since it blinks). @visibleForTesting @@ -977,21 +1024,58 @@ class EditableTextState extends State with AutomaticKeepAliveClien void _cursorTick(Timer timer) { _showCursor.value = !_showCursor.value; - if (_obscureShowCharTicksPending > 0) { - setState(() { _obscureShowCharTicksPending--; }); + if (widget.cursorOpacityAnimates) { + // If we want to show the cursor, we will animate the opacity to the value + // of 1.0, and likewise if we want to make it disappear, to 0.0. An easing + // curve is used for the animation to mimic the aesthetics of the native + // iOS cursor. + // + // These values and curves have been obtained through eyeballing, so are + // likely not exactly the same as the values for native iOS. + final double toValue = _showCursor.value ? 1.0 : 0.0; + _cursorBlinkOpacityController.animateTo(toValue, curve: Curves.easeOut); + } else { + _cursorBlinkOpacityController.value = _showCursor.value ? 1.0 : 0.0; } + + if (_obscureShowCharTicksPending > 0) { + setState(() { + _obscureShowCharTicksPending--; + }); + } + } + + void _cursorWaitForStart(Timer timer) { + assert(_kCursorBlinkHalfPeriod > _fadeDuration); + _cursorTimer?.cancel(); + _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); } void _startCursorTimer() { _showCursor.value = true; - _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); + _cursorBlinkOpacityController.value = 1.0; + if (EditableText.debugDeterministicCursor) + return; + if (widget.cursorOpacityAnimates) { + _cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart); + } else { + _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); + } } - void _stopCursorTimer() { + void _stopCursorTimer({ bool resetCharTicks = true }) { _cursorTimer?.cancel(); _cursorTimer = null; _showCursor.value = false; - _obscureShowCharTicksPending = 0; + _cursorBlinkOpacityController.value = 0.0; + if (EditableText.debugDeterministicCursor) + return; + if (resetCharTicks) + _obscureShowCharTicksPending = 0; + if (widget.cursorOpacityAnimates) { + _cursorBlinkOpacityController.stop(); + _cursorBlinkOpacityController.value = 0.0; + } } void _startOrStopCursorTimerIfNeeded() { @@ -1047,6 +1131,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override TextEditingValue get textEditingValue => _value; + double get _devicePixelRatio => MediaQuery.of(context).devicePixelRatio ?? 1.0; + @override set textEditingValue(TextEditingValue value) { _selectionOverlay?.update(value); @@ -1104,7 +1190,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien key: _editableKey, textSpan: buildTextSpan(), value: _value, - cursorColor: widget.cursorColor, + cursorColor: _cursorColor, backgroundCursorColor: widget.backgroundCursorColor, showCursor: EditableText.debugDeterministicCursor ? ValueNotifier(true) : _showCursor, hasFocus: _hasFocus, @@ -1122,8 +1208,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien rendererIgnoresPointer: widget.rendererIgnoresPointer, cursorWidth: widget.cursorWidth, cursorRadius: widget.cursorRadius, + cursorOffset: widget.cursorOffset, + paintCursorAboveText: widget.paintCursorAboveText, enableInteractiveSelection: widget.enableInteractiveSelection, textSelectionDelegate: this, + devicePixelRatio: _devicePixelRatio, ), ), ); @@ -1188,8 +1277,11 @@ class _Editable extends LeafRenderObjectWidget { this.rendererIgnoresPointer = false, this.cursorWidth, this.cursorRadius, - this.enableInteractiveSelection, + this.cursorOffset, + this.enableInteractiveSelection = true, this.textSelectionDelegate, + this.paintCursorAboveText, + this.devicePixelRatio }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), super(key: key); @@ -1214,8 +1306,11 @@ class _Editable extends LeafRenderObjectWidget { final bool rendererIgnoresPointer; final double cursorWidth; final Radius cursorRadius; + final Offset cursorOffset; final bool enableInteractiveSelection; final TextSelectionDelegate textSelectionDelegate; + final double devicePixelRatio; + final bool paintCursorAboveText; @override RenderEditable createRenderObject(BuildContext context) { @@ -1239,8 +1334,11 @@ class _Editable extends LeafRenderObjectWidget { obscureText: obscureText, cursorWidth: cursorWidth, cursorRadius: cursorRadius, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, enableInteractiveSelection: enableInteractiveSelection, textSelectionDelegate: textSelectionDelegate, + devicePixelRatio: devicePixelRatio, ); } @@ -1265,6 +1363,9 @@ class _Editable extends LeafRenderObjectWidget { ..obscureText = obscureText ..cursorWidth = cursorWidth ..cursorRadius = cursorRadius - ..textSelectionDelegate = textSelectionDelegate; + ..cursorOffset = cursorOffset + ..textSelectionDelegate = textSelectionDelegate + ..devicePixelRatio = devicePixelRatio + ..paintCursorAboveText = paintCursorAboveText; } } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 873a6ec76a2..9df9a57a9c2 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; class MockClipboard { @@ -143,6 +144,72 @@ void main() { }, ); + testWidgets('iOS cursor has offset', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoTextField(), + ), + ); + + final EditableText editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.cursorOffset, const Offset(-2.0 / 3.0, 0)); + }); + + testWidgets('Cursor animates on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoTextField(), + ), + ); + + final Finder textFinder = find.byType(CupertinoTextField); + await tester.tap(textFinder); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 110); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 16); + await tester.pump(const Duration(milliseconds: 50)); + + expect(renderEditable.cursorColor.alpha, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Cursor radius is 2.0 on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoTextField(), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + + debugDefaultTargetPlatformOverride = null; + }); + testWidgets( 'can control text content via controller', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 3cc1293ee58..7d865f0dfbc 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -277,6 +277,65 @@ void main() { await checkCursorToggle(); }); + testWidgets('Cursor animates on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField(), + ), + ), + ); + + final Finder textFinder = find.byType(TextField); + await tester.tap(textFinder); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 110); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 16); + await tester.pump(const Duration(milliseconds: 50)); + + expect(renderEditable.cursorColor.alpha, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Cursor radius is 2.0 on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField(), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('cursor has expected defaults', (WidgetTester tester) async { await tester.pumpWidget( overlay( @@ -305,6 +364,7 @@ void main() { }); testWidgets('cursor layout has correct width', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; await tester.pumpWidget( overlay( child: const RepaintBoundary( @@ -321,9 +381,11 @@ void main() { find.byType(TextField), matchesGoldenFile('text_field_test.0.0.png'), ); + EditableText.debugDeterministicCursor = false; }, skip: !Platform.isLinux); testWidgets('cursor layout has correct radius', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; await tester.pumpWidget( overlay( child: const RepaintBoundary( @@ -341,6 +403,7 @@ void main() { find.byType(TextField), matchesGoldenFile('text_field_test.1.0.png'), ); + EditableText.debugDeterministicCursor = false; }, skip: !Platform.isLinux); testWidgets('obscureText control test', (WidgetTester tester) async { @@ -1469,7 +1532,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 0)).topLeft, ); - expect(topLeft.dx, equals(398.5)); + expect(topLeft.dx, equals(401.0)); await tester.enterText(find.byType(TextField), 'abcd'); await tester.pump(); @@ -1478,7 +1541,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, ); - expect(topLeft.dx, equals(398.5)); + expect(topLeft.dx, equals(401.0)); }); testWidgets('Can align to center within center', (WidgetTester tester) async { @@ -1501,7 +1564,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 0)).topLeft, ); - expect(topLeft.dx, equals(398.5)); + expect(topLeft.dx, equals(401.0)); await tester.enterText(find.byType(TextField), 'abcd'); await tester.pump(); @@ -1510,7 +1573,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, ); - expect(topLeft.dx, equals(398.5)); + expect(topLeft.dx, equals(401.0)); }); testWidgets('Controller can update server', (WidgetTester tester) async { @@ -1723,7 +1786,7 @@ void main() { scrollableState = tester.firstState(find.byType(Scrollable)); // For a horizontal input, scrolls to the exact position of the caret. - expect(scrollableState.position.pixels, equals(222.0)); + expect(scrollableState.position.pixels, equals(223.0)); }); testWidgets('Multiline text field scrolls the caret into view', (WidgetTester tester) async { @@ -3130,7 +3193,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 10)).topLeft, ); - expect(topLeft.dx, equals(701.0)); + expect(topLeft.dx, equals(701.6666870117188)); await tester.pumpWidget( const MaterialApp( @@ -3150,7 +3213,7 @@ void main() { editable.getLocalRectForCaret(const TextPosition(offset: 10)).topLeft, ); - expect(topLeft.dx, equals(160.0)); + expect(topLeft.dx, equals(160.6666717529297)); }); testWidgets('TextField semantics', (WidgetTester tester) async { diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 07232b25068..a3b05b9bf06 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -124,11 +124,14 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Material( - child: TextField( - controller: controller, - focusNode: focusNode, - style: textStyle, + home: Padding( + padding: const EdgeInsets.only(top: 0.25), + child: Material( + child: TextField( + controller: controller, + focusNode: focusNode, + style: textStyle, + ), ), ), ), @@ -144,16 +147,32 @@ void main() { 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)) + expect(editable, paints + ..rrect(rrect: RRect.fromRectAndRadius( + Rect.fromLTRB(464.6666564941406, -1.5833333730697632, 466.6666564941406, 16.41666603088379), + const Radius.circular(2.0)), + color: const Color(0xff8e8e93)) + ..rrect(rrect: RRect.fromRectAndRadius( + Rect.fromLTRB(465.1666564941406, -2.416666269302368, 468.1666564941406, 17.58333396911621), + const Radius.circular(1.0)), + color: const Color(0xbf2196f3)) ); // Moves the cursor right a few characters. - editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update, - offset: const Offset(-250, 20))); + 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)) + expect(find.byType(EditableText), paints + ..rrect(rrect: RRect.fromRectAndRadius( + Rect.fromLTRB(192.6666717529297, -1.5833333730697632, 194.6666717529297, 16.41666603088379), + const Radius.circular(2.0)), + color: const Color(0xff8e8e93)) + ..rrect(rrect: RRect.fromRectAndRadius( + Rect.fromLTRB(195.16665649414062, -2.416666269302368, 198.16665649414062, 17.58333396911621), + const Radius.circular(1.0)), + color: const Color(0xbf2196f3)) ); editableTextState.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End)); 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 19ae1b470ca..b0977213bc9 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 @@ -201,28 +201,33 @@ void main() { final TextEditingController textController = TextEditingController(); final PageController pageController = PageController(initialPage: 1); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: Material( - child: PageView( - controller: pageController, - children: [ - Container( - color: Colors.red, + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: PageView( + controller: pageController, + children: [ + Container( + color: Colors.red, + ), + Container( + child: TextField( + controller: textController, + ), + color: Colors.green, + ), + Container( + color: Colors.red, + ), + ], ), - Container( - child: TextField( - controller: textController, - ), - color: Colors.green, - ), - Container( - color: Colors.red, - ), - ], + ), ), ), - )); + ); await tester.showKeyboard(find.byType(EditableText)); await tester.pumpAndSettle(); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 4b59feb4ab1..19e14b0c08b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; import 'package:mockito/mockito.dart'; +import 'package:flutter/foundation.dart'; import 'semantics_tester.dart'; @@ -36,18 +37,21 @@ void main() { String serializedActionName, }) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - textInputAction: action, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + textInputAction: action, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -64,14 +68,17 @@ void main() { testWidgets('has expected defaults', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ); @@ -86,7 +93,9 @@ void main() { testWidgets('cursor has expected width and radius', (WidgetTester tester) async { - await tester.pumpWidget(Directionality( + await tester.pumpWidget( + MediaQuery(data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( textDirection: TextDirection.ltr, child: EditableText( backgroundCursorColor: Colors.grey, @@ -96,7 +105,7 @@ void main() { cursorColor: cursorColor, cursorWidth: 10.0, cursorRadius: const Radius.circular(2.0), - ))); + )))); final EditableText editableText = tester.firstWidget(find.byType(EditableText)); @@ -107,17 +116,20 @@ void main() { testWidgets('text keyboard is requested when maxLines is default', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -269,18 +281,21 @@ void main() { testWidgets('multiline keyboard is requested when set explicitly', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - keyboardType: TextInputType.multiline, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + keyboardType: TextInputType.multiline, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -299,18 +314,21 @@ void main() { testWidgets('Multiline keyboard with newline action is requested when maxLines = null', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - maxLines: null, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + maxLines: null, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -329,19 +347,22 @@ void main() { testWidgets('Text keyboard is requested when explicitly set and maxLines = null', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - maxLines: null, - keyboardType: TextInputType.text, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: null, + keyboardType: TextInputType.text, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -362,19 +383,22 @@ void main() { 'Correct keyboard is requested when set explicitly and maxLines > 1', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.phone, - maxLines: 3, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.phone, + maxLines: 3, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -394,18 +418,21 @@ void main() { testWidgets('multiline keyboard is requested when set implicitly', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - maxLines: 3, // Sets multiline keyboard implicitly. - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 3, // Sets multiline keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -425,18 +452,21 @@ void main() { testWidgets('single line inputs have correct default keyboard', (WidgetTester tester) async { await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - maxLines: 1, // Sets text keyboard implicitly. - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -538,6 +568,8 @@ void main() { await tester.tap(find.text('PASTE')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 600)); expect(changedValue, clipboardContent); @@ -590,6 +622,8 @@ void main() { await tester.tap(find.text('PASTE')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 600)); expect(changedValue, clipboardContent); @@ -807,6 +841,118 @@ void main() { // and onSubmission callbacks. }); + testWidgets('Cursor animates on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + const Widget widget = + MaterialApp( + home: Material( + child: TextField( + maxLines: 3, + ) + ), + ); + await tester.pumpWidget(widget); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 110); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 16); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(renderEditable.cursorColor.alpha, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Cursor does not animate on Android', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + const Widget widget = + MaterialApp( + home: Material( + child: TextField( + maxLines: 3, + ) + ), + ); + await tester.pumpWidget(widget); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 255); + + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 0); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable.cursorColor.alpha, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Cursor radius is 2.0 on iOS', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + const Widget widget = + MaterialApp( + home: Material( + child: TextField( + maxLines: 3, + ) + ), + ); + await tester.pumpWidget(widget); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + + debugDefaultTargetPlatformOverride = null; + }); + testWidgets( 'When "newline" action is called on a Editable text with maxLines != 1, onEditingComplete and onSubmitted callbacks are not invoked.', (WidgetTester tester) async { @@ -867,22 +1013,25 @@ testWidgets( return StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: EditableText( - backgroundCursorColor: Colors.grey, - key: editableTextKey, - controller: currentController, - focusNode: FocusNode(), - style: Typography(platform: TargetPlatform.android) - .black - .subhead, - cursorColor: Colors.blue, - selectionControls: materialTextSelectionControls, - keyboardType: TextInputType.text, - onChanged: (String value) {}, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: EditableText( + backgroundCursorColor: Colors.grey, + key: editableTextKey, + controller: currentController, + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android) + .black + .subhead, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + onChanged: (String value) {}, + ), ), ), ), @@ -926,17 +1075,20 @@ testWidgets( final SemanticsTester semantics = SemanticsTester(tester); await tester.pumpWidget( - Directionality( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -968,16 +1120,19 @@ testWidgets( controller.text = value1; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -1767,21 +1922,26 @@ testWidgets( final FocusNode focusNode = FocusNode(); controller.text = text; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScopeNode, - autofocus: true, - child: EditableText( - backgroundCursorColor: Colors.grey, - controller: controller, - focusNode: focusNode, - autofocus: true, - style: textStyle, - cursorColor: cursorColor, + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + autofocus: true, + style: textStyle, + cursorColor: cursorColor, + ), + ), ), ), - )); + ); expect(focusNode.hasFocus, true); expect(controller.selection.isCollapsed, true); @@ -1811,17 +1971,20 @@ testWidgets( 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, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -1864,17 +2027,20 @@ testWidgets( 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, + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), ), ), ), @@ -1936,7 +2102,7 @@ testWidgets( await tester.pumpAndSettle(); - expect(controller.selection.baseOffset, 11); + expect(controller.selection.baseOffset, 10); }); testWidgets('Formatters are skipped if text has not changed', (WidgetTester tester) async { @@ -1946,18 +2112,21 @@ testWidgets( return newValue; }); final TextEditingController controller = TextEditingController(); - final EditableText editableText = EditableText( - controller: controller, - backgroundCursorColor: Colors.red, - cursorColor: Colors.red, - focusNode: FocusNode(), - style: textStyle, - inputFormatters: [ - formatter, - ], - textDirection: TextDirection.ltr, + final MediaQuery mediaQuery = MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.red, + cursorColor: Colors.red, + focusNode: FocusNode(), + style: textStyle, + inputFormatters: [ + formatter, + ], + textDirection: TextDirection.ltr, + ), ); - await tester.pumpWidget(editableText); + await tester.pumpWidget(mediaQuery); final EditableTextState state = tester.firstState(find.byType(EditableText)); state.updateEditingValue(const TextEditingValue( text: 'a', @@ -1990,14 +2159,19 @@ testWidgets( final TextEditingController controller = TextEditingController(); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: EditableText( - controller: controller, - focusNode: FocusNode(), - style: Typography(platform: TargetPlatform.android).black.subhead, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, + MediaQuery( + data: const MediaQueryData( + devicePixelRatio: 1.0 + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: EditableText( + controller: controller, + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), ), ), ); @@ -2018,15 +2192,20 @@ testWidgets( final TextEditingController controller = TextEditingController(); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: EditableText( - controller: controller, - focusNode: FocusNode(), - style: Typography(platform: TargetPlatform.android).black.subhead, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - keyboardAppearance: Brightness.dark, + MediaQuery( + data: const MediaQueryData( + devicePixelRatio: 1.0 + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: EditableText( + controller: controller, + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + keyboardAppearance: Brightness.dark, + ), ), ), ); diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 02b5cbe2942..55606f17acf 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -11,14 +11,17 @@ void main() { String fieldValue; Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - key: formKey, - child: TextFormField( - onSaved: (String value) { fieldValue = value; }, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: TextFormField( + onSaved: (String value) { fieldValue = value; }, + ), ), ), ), @@ -45,13 +48,16 @@ void main() { String fieldValue; Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - child: TextField( - onChanged: (String value) { fieldValue = value; }, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: TextField( + onChanged: (String value) { fieldValue = value; }, + ), ), ), ), @@ -78,15 +84,18 @@ void main() { String errorText(String value) => value + '/error'; Widget builder(bool autovalidate) { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - key: formKey, - autovalidate: autovalidate, - child: TextFormField( - validator: errorText, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + autovalidate: autovalidate, + child: TextFormField( + validator: errorText, + ), ), ), ), @@ -129,22 +138,25 @@ void main() { String errorText(String input) => '${fieldKey.currentState.value}/error'; Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - key: formKey, - autovalidate: true, - child: ListView( - children: [ - TextFormField( - key: fieldKey, - ), - TextFormField( - validator: errorText, - ), - ], + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + autovalidate: true, + child: ListView( + children: [ + TextFormField( + key: fieldKey, + ), + TextFormField( + validator: errorText, + ), + ], + ), ), ), ), @@ -172,14 +184,17 @@ void main() { final GlobalKey> inputKey = GlobalKey>(); Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - child: TextFormField( - key: inputKey, - initialValue: 'hello', + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: TextFormField( + key: inputKey, + initialValue: 'hello', + ), ), ), ), @@ -212,14 +227,17 @@ void main() { final GlobalKey> inputKey = GlobalKey>(); Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - child: TextFormField( - key: inputKey, - controller: controller, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: TextFormField( + key: inputKey, + controller: controller, + ), ), ), ), @@ -254,16 +272,19 @@ void main() { final TextEditingController controller = TextEditingController(text: 'Plover'); Widget builder() { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - key: formKey, - child: TextFormField( - key: inputKey, - controller: controller, - // initialValue is 'Plover' + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: TextFormField( + key: inputKey, + controller: controller, + // initialValue is 'Plover' + ), ), ), ), @@ -301,14 +322,17 @@ void main() { return StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - child: TextFormField( - key: inputKey, - controller: currentController, + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: TextFormField( + key: inputKey, + controller: currentController, + ), ), ), ), @@ -396,16 +420,19 @@ void main() { String fieldValue; Widget builder(bool remove) { - return Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: Form( - key: formKey, - child: remove ? Container() : TextFormField( - autofocus: true, - onSaved: (String value) { fieldValue = value; }, - validator: (String value) { return value.isEmpty ? null : 'yes'; } + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: remove ? Container() : TextFormField( + autofocus: true, + onSaved: (String value) { fieldValue = value; }, + validator: (String value) { return value.isEmpty ? null : 'yes'; } + ), ), ), ), diff --git a/packages/flutter/test/widgets/physical_model_test.dart b/packages/flutter/test/widgets/physical_model_test.dart index b48ba07fb12..15423b2c759 100644 --- a/packages/flutter/test/widgets/physical_model_test.dart +++ b/packages/flutter/test/widgets/physical_model_test.dart @@ -6,14 +6,18 @@ import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('PhysicalModel - creates a physical model layer when it needs compositing', (WidgetTester tester) async { debugDisableShadows = false; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: PhysicalModel( - shape: BoxShape.rectangle, - color: Colors.grey, - shadowColor: Colors.red, - elevation: 1.0, - child: Material(child: TextField(controller: TextEditingController())), + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: PhysicalModel( + shape: BoxShape.rectangle, + color: Colors.grey, + shadowColor: Colors.red, + elevation: 1.0, + child: Material(child: TextField(controller: TextEditingController())), + ), ), ), );