From 2602119194868609f1c7a3050a112d652af461a8 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 18 Jun 2019 18:29:10 -0700 Subject: [PATCH] Cupertino text edit tooltip rework (#34095) --- .../lib/src/cupertino/text_selection.dart | 472 ++++++++++-------- .../lib/src/material/text_selection.dart | 1 + .../lib/src/widgets/text_selection.dart | 29 +- .../test/cupertino/text_field_test.dart | 444 +++++++++++++++- .../test/material/text_field_test.dart | 10 +- .../test/widgets/editable_text_test.dart | 2 +- packages/flutter_test/lib/src/matchers.dart | 9 +- 7 files changed, 736 insertions(+), 231 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index a4db8dbb776..6f4f681ede3 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; @@ -11,219 +12,238 @@ import 'button.dart'; import 'colors.dart'; import 'localizations.dart'; -// Minimal padding from all edges of the selection toolbar to all edges of the -// viewport. -const double _kToolbarScreenPadding = 8.0; -const double _kToolbarHeight = 36.0; - -const Color _kToolbarBackgroundColor = Color(0xFF2E2E2E); -const Color _kToolbarDividerColor = Color(0xFFB9B9B9); // Read off from the output on iOS 12. This color does not vary with the // application's theme color. const Color _kHandlesColor = Color(0xFF136FE0); - const double _kSelectionHandleOverlap = 1.5; const double _kSelectionHandleRadius = 5.5; -const Size _kToolbarTriangleSize = Size(18.0, 9.0); + +// Minimal padding from all edges of the selection toolbar to all edges of the +// screen. +const double _kToolbarScreenPadding = 8.0; +// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the +// screen. Eyeballed value. +const double _kArrowScreenPadding = 26.0; + +// Vertical distance between the tip of the arrow and the line of text the arrow +// is pointing to. The value used here is eyeballed. +const double _kToolbarContentDistance = 8.0; +// Values derived from https://developer.apple.com/design/resources/. +// 92% Opacity ~= 0xEB + +// The height of the toolbar, including the arrow. +const double _kToolbarHeight = 43.0; +const Color _kToolbarBackgroundColor = Color(0xEB202020); +const Color _kToolbarDividerColor = Color(0xFF808080); +const Size _kToolbarArrowSize = Size(14.0, 7.0); const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0); -const BorderRadius _kToolbarBorderRadius = BorderRadius.all(Radius.circular(7.5)); +const Radius _kToolbarBorderRadius = Radius.circular(8); const TextStyle _kToolbarButtonFontStyle = TextStyle( inherit: false, fontSize: 14.0, - letterSpacing: -0.11, - fontWeight: FontWeight.w300, + letterSpacing: -0.15, + fontWeight: FontWeight.w400, color: CupertinoColors.white, ); -/// The direction of the triangle attached to the toolbar. +/// An iOS-style toolbar that appears in response to text selection. /// -/// Defaults to showing the triangle downwards if sufficient space is available -/// to show the toolbar above the text field. Otherwise, the toolbar will -/// appear below the text field and the triangle's direction will be [up]. -enum _ArrowDirection { up, down } - -/// Paints a triangle below the toolbar. -class _TextSelectionToolbarNotchPainter extends CustomPainter { - const _TextSelectionToolbarNotchPainter( - this.arrowDirection - ) : assert (arrowDirection != null); - - final _ArrowDirection arrowDirection; - - @override - void paint(Canvas canvas, Size size) { - final Paint paint = Paint() - ..color = _kToolbarBackgroundColor - ..style = PaintingStyle.fill; - final double triangleBottomY = (arrowDirection == _ArrowDirection.down) - ? 0.0 - : _kToolbarTriangleSize.height; - final Path triangle = Path() - ..lineTo(_kToolbarTriangleSize.width / 2, triangleBottomY) - ..lineTo(0.0, _kToolbarTriangleSize.height) - ..lineTo(-(_kToolbarTriangleSize.width / 2), triangleBottomY) - ..close(); - canvas.drawPath(triangle, paint); - } - - @override - bool shouldRepaint(_TextSelectionToolbarNotchPainter oldPainter) => false; -} - -/// Manages a copy/paste text selection toolbar. -class _TextSelectionToolbar extends StatelessWidget { - const _TextSelectionToolbar({ +/// Typically displays buttons for text manipulation, e.g. copying and pasting text. +/// +/// See also: +/// +/// * [TextSelectionControls.buildToolbar], where [CupertinoTextSelectionToolbar] +/// will be used to build an iOS-style toolbar. +@visibleForTesting +class CupertinoTextSelectionToolbar extends SingleChildRenderObjectWidget { + const CupertinoTextSelectionToolbar._({ Key key, - this.handleCut, - this.handleCopy, - this.handlePaste, - this.handleSelectAll, - this.arrowDirection, - }) : super(key: key); + double barTopY, + double arrowTipX, + bool isArrowPointingDown, + Widget child, + }) : _barTopY = barTopY, + _arrowTipX = arrowTipX, + _isArrowPointingDown = isArrowPointingDown, + super(key: key, child: child); - final VoidCallback handleCut; - final VoidCallback handleCopy; - final VoidCallback handlePaste; - final VoidCallback handleSelectAll; - final _ArrowDirection arrowDirection; + // The y-coordinate of toolbar's top edge, in global coordinate system. + final double _barTopY; + + // The y-coordinate of the tip of the arrow, in global coordinate system. + final double _arrowTipX; + + // Whether the arrow should point down and be attached to the bottom + // of the toolbar, or point up and be attached to the top of the toolbar. + final bool _isArrowPointingDown; @override - Widget build(BuildContext context) { - final List items = []; - final Widget onePhysicalPixelVerticalDivider = - SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); - final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + _ToolbarRenderBox createRenderObject(BuildContext context) => _ToolbarRenderBox(_barTopY, _arrowTipX, _isArrowPointingDown, null); - if (handleCut != null) - items.add(_buildToolbarButton(localizations.cutButtonLabel, handleCut)); - - if (handleCopy != null) { - if (items.isNotEmpty) - items.add(onePhysicalPixelVerticalDivider); - items.add(_buildToolbarButton(localizations.copyButtonLabel, handleCopy)); - } - - if (handlePaste != null) { - if (items.isNotEmpty) - items.add(onePhysicalPixelVerticalDivider); - items.add(_buildToolbarButton(localizations.pasteButtonLabel, handlePaste)); - } - - if (handleSelectAll != null) { - if (items.isNotEmpty) - items.add(onePhysicalPixelVerticalDivider); - items.add(_buildToolbarButton(localizations.selectAllButtonLabel, handleSelectAll)); - } - // If there is no option available, build an empty widget. - if (items.isEmpty) { - return Container(width: 0.0, height: 0.0); - } - - const Widget padding = Padding(padding: EdgeInsets.only(bottom: 10.0)); - - final Widget triangle = SizedBox.fromSize( - size: _kToolbarTriangleSize, - child: CustomPaint( - painter: _TextSelectionToolbarNotchPainter(arrowDirection), - ), - ); - - final Widget toolbar = ClipRRect( - borderRadius: _kToolbarBorderRadius, - child: DecoratedBox( - decoration: BoxDecoration( - color: _kToolbarDividerColor, - borderRadius: _kToolbarBorderRadius, - // Add a hairline border with the button color to avoid - // antialiasing artifacts. - border: Border.all(color: _kToolbarBackgroundColor, width: 0), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: items), - ), - ); - - final List menus = (arrowDirection == _ArrowDirection.down) - ? [ - toolbar, - // TODO(xster): Position the triangle based on the layout delegate, and - // avoid letting the triangle line up with any dividers. - // https://github.com/flutter/flutter/issues/11274 - triangle, - padding, - ] - : [ - padding, - triangle, - toolbar, - ]; - - return Column( - mainAxisSize: MainAxisSize.min, - children: menus, - ); - } - - /// Builds a themed [CupertinoButton] for the toolbar. - CupertinoButton _buildToolbarButton(String text, VoidCallback onPressed) { - return CupertinoButton( - child: Text(text, style: _kToolbarButtonFontStyle), - color: _kToolbarBackgroundColor, - minSize: _kToolbarHeight, - padding: _kToolbarButtonPadding, - borderRadius: null, - pressedOpacity: 0.7, - onPressed: onPressed, - ); + @override + void updateRenderObject(BuildContext context, _ToolbarRenderBox renderObject) { + renderObject + ..barTopY = _barTopY + ..arrowTipX = _arrowTipX + ..isArrowPointingDown = _isArrowPointingDown; } } -/// Centers the toolbar around the given position, ensuring that it remains on -/// screen. -class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate { - _TextSelectionToolbarLayout(this.screenSize, this.globalEditableRegion, this.position); +class _ToolbarParentData extends BoxParentData { + // The x offset from the tip of the arrow to the center of the toolbar. + // Positive if the tip of the arrow has a larger x-coordinate than the + // center of the toolbar. + double arrowXOffsetFromCenter; + @override + String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter'; +} - /// The size of the screen at the time that the toolbar was last laid out. - final Size screenSize; +class _ToolbarRenderBox extends RenderShiftedBox { + _ToolbarRenderBox( + this._barTopY, + this._arrowTipX, + this._isArrowPointingDown, + RenderBox child, + ) : super(child); - /// Size and position of the editing region at the time the toolbar was last - /// laid out, in global coordinates. - final Rect globalEditableRegion; - - /// Anchor position of the toolbar, relative to the top left of the - /// [globalEditableRegion]. - final Offset position; @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - return constraints.loosen(); + bool get isRepaintBoundary => true; + + double _barTopY; + set barTopY(double value) { + if (_barTopY == value) { + return; + } + _barTopY = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + double _arrowTipX; + set arrowTipX(double value) { + if (_arrowTipX == value) { + return; + } + _arrowTipX = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + bool _isArrowPointingDown; + set isArrowPointingDown(bool value) { + if (_isArrowPointingDown == value) { + return; + } + _isArrowPointingDown = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + final BoxConstraints heightConstraint = const BoxConstraints.tightFor(height: _kToolbarHeight); + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! _ToolbarParentData) { + child.parentData = _ToolbarParentData(); + } } @override - Offset getPositionForChild(Size size, Size childSize) { - final Offset globalPosition = globalEditableRegion.topLeft + position; + void performLayout() { + size = constraints.biggest; - double x = globalPosition.dx - childSize.width / 2.0; - double y = globalPosition.dy - childSize.height; + if (child == null) { + return; + } + final BoxConstraints enforcedConstraint = constraints + .deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding)) + .loosen(); - if (x < _kToolbarScreenPadding) - x = _kToolbarScreenPadding; - else if (x + childSize.width > screenSize.width - _kToolbarScreenPadding) - x = screenSize.width - childSize.width - _kToolbarScreenPadding; + child.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,); + final _ToolbarParentData childParentData = child.parentData; - if (y < _kToolbarScreenPadding) - y = _kToolbarScreenPadding; - else if (y + childSize.height > screenSize.height - _kToolbarScreenPadding) - y = screenSize.height - childSize.height - _kToolbarScreenPadding; + final Offset localTopCenter = globalToLocal(Offset(_arrowTipX, _barTopY)); - return Offset(x, y); + // The local x-coordinate of the center of the toolbar. + final double lowerBound = child.size.width/2 + _kToolbarScreenPadding; + final double upperBound = size.width - child.size.width/2 - _kToolbarScreenPadding; + final double adjustedCenterX = localTopCenter.dx.clamp(lowerBound, upperBound); + + childParentData.offset = Offset(adjustedCenterX - child.size.width / 2, localTopCenter.dy); + childParentData.arrowXOffsetFromCenter = localTopCenter.dx - adjustedCenterX; + } + + // The path is described in the toolbar's coordinate system. + Path _clipPath() { + final _ToolbarParentData childParentData = child.parentData; + final Path rrect = Path() + ..addRRect( + RRect.fromRectAndRadius( + Offset(0, _isArrowPointingDown ? 0 : _kToolbarArrowSize.height,) + & Size(child.size.width, child.size.height - _kToolbarArrowSize.height), + _kToolbarBorderRadius, + ), + ); + + final double arrowTipX = child.size.width / 2 + childParentData.arrowXOffsetFromCenter; + + final double arrowBottomY = _isArrowPointingDown + ? child.size.height - _kToolbarArrowSize.height + : _kToolbarArrowSize.height; + + final double arrowTipY = _isArrowPointingDown ? child.size.height : 0; + + final Path arrow = Path() + ..moveTo(arrowTipX, arrowTipY) + ..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBottomY) + ..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBottomY) + ..close(); + + return Path.combine(PathOperation.union, rrect, arrow); } @override - bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) { - return screenSize != oldDelegate.screenSize - || globalEditableRegion != oldDelegate.globalEditableRegion - || position != oldDelegate.position; + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + + final _ToolbarParentData childParentData = child.parentData; + context.pushClipPath( + needsCompositing, + offset + childParentData.offset, + Offset.zero & child.size, + _clipPath(), + (PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child, innerOffset), + ); + } + + Paint _debugPaint; + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + assert(() { + if (child == null) { + return true; + } + + _debugPaint ??= Paint() + ..shader = ui.Gradient.linear( + const Offset(0.0, 0.0), + const Offset(10.0, 10.0), + [const Color(0x00000000), const Color(0xFFFF00FF), const Color(0xFFFF00FF), const Color(0x00000000)], + [0.25, 0.25, 0.75, 0.75], + TileMode.repeated, + ) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + final _ToolbarParentData childParentData = child.parentData; + context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint); + return true; + }()); } } @@ -274,47 +294,79 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { Widget buildToolbar( BuildContext context, Rect globalEditableRegion, + double textLineHeight, Offset position, List endpoints, TextSelectionDelegate delegate, ) { assert(debugCheckHasMediaQuery(context)); + final MediaQueryData mediaQuery = MediaQuery.of(context); - // The toolbar should appear below the TextField - // when there is not enough space above the TextField to show it. - final double availableHeight - = globalEditableRegion.top - MediaQuery.of(context).padding.top - _kToolbarScreenPadding; - final _ArrowDirection direction = (availableHeight > _kToolbarHeight) - ? _ArrowDirection.down - : _ArrowDirection.up; + // The toolbar should appear below the TextField when there is not enough + // space above the TextField to show it, assuming there's always enough space + // at the bottom in this case. + final bool isArrowPointingDown = + mediaQuery.padding.top + + _kToolbarScreenPadding + + _kToolbarHeight + + _kToolbarContentDistance <= globalEditableRegion.top + endpoints.first.point.dy - textLineHeight; - final TextSelectionPoint startTextSelectionPoint = endpoints[0]; - final TextSelectionPoint endTextSelectionPoint = (endpoints.length > 1) - ? endpoints[1] - : null; - final double x = (endTextSelectionPoint == null) - ? startTextSelectionPoint.point.dx - : (startTextSelectionPoint.point.dx + endTextSelectionPoint.point.dx) / 2.0; - final double y = (direction == _ArrowDirection.up) - ? startTextSelectionPoint.point.dy + globalEditableRegion.height + _kToolbarHeight - : startTextSelectionPoint.point.dy - globalEditableRegion.height; - final Offset preciseMidpoint = Offset(x, y); + final double arrowTipX = (position.dx + globalEditableRegion.left).clamp( + _kArrowScreenPadding + mediaQuery.padding.left, + mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding, + ); - return ConstrainedBox( - constraints: BoxConstraints.tight(globalEditableRegion.size), - child: CustomSingleChildLayout( - delegate: _TextSelectionToolbarLayout( - MediaQuery.of(context).size, - globalEditableRegion, - preciseMidpoint, - ), - child: _TextSelectionToolbar( - handleCut: canCut(delegate) ? () => handleCut(delegate) : null, - handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, - handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, - handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, - arrowDirection: direction, - ), + // The y-coordinate has to be calculated instead of directly quoting postion.dy, + // since the caller (TextSelectionOverlay._buildToolbar) does not know whether + // the toolbar is going to be facing up or down. + final double localBarTopY = isArrowPointingDown + ? endpoints.first.point.dy - textLineHeight - _kToolbarContentDistance - _kToolbarHeight + : endpoints.last.point.dy + _kToolbarContentDistance; + + final List items = []; + final Widget onePhysicalPixelVerticalDivider = + SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final EdgeInsets arrowPadding = isArrowPointingDown + ? EdgeInsets.only(bottom: _kToolbarArrowSize.height) + : EdgeInsets.only(top: _kToolbarArrowSize.height); + + void addToolbarButtonIfNeeded( + String text, + bool Function(TextSelectionDelegate) predicate, + void Function(TextSelectionDelegate) onPressed + ) { + if (!predicate(delegate)) { + return; + } + + if (items.isNotEmpty) { + items.add(onePhysicalPixelVerticalDivider); + } + + items.add(CupertinoButton( + child: Text(text, style: _kToolbarButtonFontStyle), + color: _kToolbarBackgroundColor, + minSize: _kToolbarHeight, + padding: _kToolbarButtonPadding.add(arrowPadding), + borderRadius: null, + pressedOpacity: 0.7, + onPressed: () => onPressed(delegate), + )); + } + + addToolbarButtonIfNeeded(localizations.cutButtonLabel, canCut, handleCut); + addToolbarButtonIfNeeded(localizations.copyButtonLabel, canCopy, handleCopy); + addToolbarButtonIfNeeded(localizations.pasteButtonLabel, canPaste, handlePaste); + addToolbarButtonIfNeeded(localizations.selectAllButtonLabel, canSelectAll, handleSelectAll); + + return CupertinoTextSelectionToolbar._( + barTopY: localBarTopY + globalEditableRegion.top, + arrowTipX: arrowTipX, + isArrowPointingDown: isArrowPointingDown, + child: items.isEmpty ? null : DecoratedBox( + decoration: const BoxDecoration(color: _kToolbarDividerColor), + child: Row(mainAxisSize: MainAxisSize.min, children: items), ), ); } diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index f3601b136cc..a9fb6ddda36 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -141,6 +141,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls { Widget buildToolbar( BuildContext context, Rect globalEditableRegion, + double textLineHeight, Offset position, List endpoints, TextSelectionDelegate delegate, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 7dfa5ee9a25..7f04a6b15fc 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -108,12 +108,16 @@ abstract class TextSelectionControls { /// [globalEditableRegion] is the TextField size of the global coordinate system /// in logical pixels. /// + /// [textLineHeight] is the `preferredLineHeight` of the [RenderEditable] we + /// are building a toolbar for. + /// /// The [position] is a general calculation midpoint parameter of the toolbar. /// If you want more detailed position information, can use [endpoints] /// to calculate it. Widget buildToolbar( BuildContext context, Rect globalEditableRegion, + double textLineHeight, Offset position, List endpoints, TextSelectionDelegate delegate, @@ -509,19 +513,29 @@ class TextSelectionOverlay { return Container(); // Find the horizontal midpoint, just above the selected text. - final List endpoints = renderObject.getEndpointsForSelection(_selection); - final Offset midpoint = Offset( - (endpoints.length == 1) ? - endpoints[0].point.dx : - (endpoints[0].point.dx + endpoints[1].point.dx) / 2.0, - endpoints[0].point.dy - renderObject.preferredLineHeight, - ); + final List endpoints = + renderObject.getEndpointsForSelection(_selection); final Rect editingRegion = Rect.fromPoints( renderObject.localToGlobal(Offset.zero), renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)), ); + final bool isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > + renderObject.preferredLineHeight / 2; + + // If the selected text spans more than 1 line, horizontally center the toolbar. + // Derived from both iOS and Android. + final double midX = isMultiline + ? editingRegion.width / 2 + : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; + + final Offset midpoint = Offset( + midX, + // The y-coordinate won't be made use of most likely. + endpoints[0].point.dy - renderObject.preferredLineHeight, + ); + return FadeTransition( opacity: _toolbarOpacity, child: CompositedTransformFollower( @@ -531,6 +545,7 @@ class TextSelectionOverlay { child: selectionControls.buildToolbar( context, editingRegion, + renderObject.preferredLineHeight, midpoint, endpoints, selectionDelegate, diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 9480731381c..c3f3152c356 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -11,6 +11,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind; import 'package:flutter_test/flutter_test.dart'; +import '../rendering/mock_canvas.dart'; + class MockClipboard { Object _clipboardData = { 'text': null, @@ -27,6 +29,101 @@ class MockClipboard { } } +class PathBoundsMatcher extends Matcher { + const PathBoundsMatcher({ + this.rectMatcher, + this.topMatcher, + this.leftMatcher, + this.rightMatcher, + this.bottomMatcher, + }) : super(); + + final Matcher rectMatcher; + final Matcher topMatcher; + final Matcher leftMatcher; + final Matcher rightMatcher; + final Matcher bottomMatcher; + + @override + bool matches(covariant Path item, Map matchState) { + final Rect bounds = item.getBounds(); + + final List matchers = [rectMatcher, topMatcher, leftMatcher, rightMatcher, bottomMatcher]; + final List values = [bounds, bounds.top, bounds.left, bounds.right, bounds.bottom]; + final Map failedMatcher = {}; + + for(int idx = 0; idx < matchers.length; idx++) { + if (!(matchers[idx]?.matches(values[idx], matchState) != false)) { + failedMatcher[matchers[idx]] = values[idx]; + } + } + + matchState['failedMatcher'] = failedMatcher; + return failedMatcher.isEmpty; + } + + @override + Description describe(Description description) => description.add('The actual Rect does not match'); + + @override + Description describeMismatch(covariant Path item, Description mismatchDescription, Map matchState, bool verbose) { + final Description description = super.describeMismatch(item, mismatchDescription, matchState, verbose); + final Map map = matchState['failedMatcher']; + final Iterable descriptions = map.entries + .map( + (MapEntry entry) => entry.key.describeMismatch(entry.value, StringDescription(), matchState, verbose).toString() + ); + + // description is guaranteed to be non-null. + return description + ..add('mismatch Rect: ${item.getBounds()}') + .addAll(': ', ', ', '. ', descriptions); + } +} + +class PathPointsMatcher extends Matcher { + const PathPointsMatcher({ + this.includes = const [], + this.excludes = const [], + }) : super(); + + final Iterable includes; + final Iterable excludes; + + @override + bool matches(covariant Path item, Map matchState) { + final Offset notIncluded = includes.firstWhere((Offset offset) => !item.contains(offset), orElse: () => null); + final Offset notExcluded = excludes.firstWhere(item.contains, orElse: () => null); + + matchState['notIncluded'] = notIncluded; + matchState['notExcluded'] = notExcluded; + return (notIncluded ?? notExcluded) == null; + } + + @override + Description describe(Description description) => description.add('must include these points $includes and must not include $excludes'); + + @override + Description describeMismatch(covariant Path item, Description mismatchDescription, Map matchState, bool verbose) { + final Offset notIncluded = matchState['notIncluded']; + final Offset notExcluded = matchState['notExcluded']; + final Description desc = super.describeMismatch(item, mismatchDescription, matchState, verbose); + + if ((notExcluded ?? notIncluded) != null) { + desc.add('Within the bounds of the path ${item.getBounds()}: '); + } + + if (notIncluded != null) { + desc.add('$notIncluded is not included. '); + } + if (notExcluded != null) { + desc.add('$notExcluded is not excluded. '); + } + return desc; + } +} + + void main() { final MockClipboard mockClipboard = MockClipboard(); SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); @@ -58,7 +155,7 @@ void main() { }).toList(); } - Offset textOffsetToPosition(WidgetTester tester, int offset) { + Offset textOffsetToBottomLeftPosition(WidgetTester tester, int offset) { final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = globalize( renderEditable.getEndpointsForSelection( @@ -67,9 +164,16 @@ void main() { renderEditable, ); expect(endpoints.length, 1); - return endpoints[0].point + const Offset(0.0, -2.0); + return endpoints[0].point; } + Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(0, -2); + + setUp(() { + EditableText.debugDeterministicCursor = false; + }); + + testWidgets( 'takes available space horizontally and takes intrinsic space vertically no-strut', (WidgetTester tester) async { @@ -1121,8 +1225,8 @@ void main() { Text text = tester.widget(find.text('Paste')); expect(text.style.color, CupertinoColors.white); expect(text.style.fontSize, 14); - expect(text.style.letterSpacing, -0.11); - expect(text.style.fontWeight, FontWeight.w300); + expect(text.style.letterSpacing, -0.15); + expect(text.style.fontWeight, FontWeight.w400); // Change the theme. await tester.pumpWidget( @@ -1153,8 +1257,8 @@ void main() { // The toolbar buttons' text are still the same style. expect(text.style.color, CupertinoColors.white); expect(text.style.fontSize, 14); - expect(text.style.letterSpacing, -0.11); - expect(text.style.fontWeight, FontWeight.w300); + expect(text.style.letterSpacing, -0.15); + expect(text.style.fontWeight, FontWeight.w400); }); testWidgets('Read only text field', (WidgetTester tester) async { @@ -2688,4 +2792,332 @@ void main() { skip: !isLinux, ); }); + + group('Text selection toolbar', () { + testWidgets('Collapsed selection works', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + tester.binding.window.physicalSizeTestValue = const Size(400, 400); + tester.binding.window.devicePixelRatioTestValue = 1; + TextEditingController controller; + EditableTextState state; + Offset bottomLeftSelectionPosition; + + controller = TextEditingController(text: 'a'); + // Top left collapsed selection. The toolbar should flip vertically, and + // the arrow should not point exactly to the caret because the caret is + // too close to the left. + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + controller: controller, + maxLines: null, + ), + ), + ), + ), + ), + ); + + state = tester.state(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + state.renderEditable.selectPositionAt(from: textOffsetToPosition(tester, 0), cause: SelectionChangedCause.tap); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition(tester, 0); + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + excludes: [ + // Arrow should not point to the selection handle. + bottomLeftSelectionPosition.translate(0, 8 + 0.1), + ], + includes: [ + // Expected center of the arrow. + Offset(26.0, bottomLeftSelectionPosition.dy + 8 + 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + leftMatcher: moreOrLessEquals(8), + rightMatcher: lessThanOrEqualTo(400 - 8), + bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 43, epsilon: 0.01), + ), + ), + ); + + // Top Right collapsed selection. The toolbar should flip vertically, and + // the arrow should not point exactly to the caret because the caret is + // too close to the right. + controller = TextEditingController(text: List.filled(200, 'a').join()); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.topRight, + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + controller: controller, + maxLines: null, + ), + ), + ), + ), + ), + ); + + state = tester.state(find.byType(EditableText)); + state.renderEditable.selectPositionAt( + from: tester.getTopRight(find.byType(CupertinoApp)), + cause: SelectionChangedCause.tap + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + // -1 because we want to reach the end of the line, not the start of a new line. + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition(tester, state.renderEditable.selection.baseOffset - 1); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + excludes: [ + // Arrow should not point to the selection handle. + bottomLeftSelectionPosition.translate(0, 8 + 0.1), + ], + includes: [ + // Expected center of the arrow. + Offset(400 - 26.0, bottomLeftSelectionPosition.dy + 8 + 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + rightMatcher: moreOrLessEquals(400.0 - 8), + bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 43, epsilon: 0.01), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + + // Normal centered collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the caret. + controller = TextEditingController(text: List.filled(200, 'a').join()); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + controller: controller, + maxLines: null, + ), + ), + ), + ), + ), + ); + + state = tester.state(find.byType(EditableText)); + state.renderEditable.selectPositionAt( + from: tester.getCenter(find.byType(EditableText)), + cause: SelectionChangedCause.tap + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition(tester, state.renderEditable.selection.baseOffset); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: [ + // Expected center of the arrow. + bottomLeftSelectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy - 8 - lineHeight, epsilon: 0.01), + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + + testWidgets('selecting multiple words works', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + tester.binding.window.physicalSizeTestValue = const Size(400, 400); + tester.binding.window.devicePixelRatioTestValue = 1; + TextEditingController controller; + EditableTextState state; + + // Normal multiword collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the caret. + controller = TextEditingController(text: List.filled(20, 'a').join(' ')); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + controller: controller, + maxLines: null, + ), + ), + ), + ), + ), + ); + + state = tester.state(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 4), + cause: SelectionChangedCause.tap + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + final Offset selectionPosition = (textOffsetToBottomLeftPosition(tester, 0) + textOffsetToBottomLeftPosition(tester, 4)) / 2; + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: [ + // Expected center of the arrow. + selectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01), + topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + + testWidgets('selecting multiline works', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + tester.binding.window.physicalSizeTestValue = const Size(400, 400); + tester.binding.window.devicePixelRatioTestValue = 1; + TextEditingController controller; + EditableTextState state; + + // Normal multiline collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the horizontal center of the text field. + controller = TextEditingController(text: List.filled(20, 'a a ').join('\n')); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + controller: controller, + maxLines: null, + ), + ), + ), + ), + ), + ); + + state = tester.state(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 10), + cause: SelectionChangedCause.tap + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + final Offset selectionPosition = Offset( + // Toolbar should be centered. + 200, + textOffsetToBottomLeftPosition(tester, 0).dy, + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: [ + // Expected center of the arrow. + selectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01), + topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + }); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 3c1a30f3c0c..0605c0a8527 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -15,6 +15,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind; +import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; @@ -864,7 +865,7 @@ void main() { expect(find.text('CUT'), findsNothing); }); - testWidgets('text field build empty tool bar when no options available ios', (WidgetTester tester) async { + testWidgets('does not paint tool bar when no options available on ios', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), @@ -882,11 +883,8 @@ void main() { await tester.tap(find.byType(TextField)); // Wait for context menu to be built. await tester.pumpAndSettle(); - final RenderBox container = tester.renderObject(find.descendant( - of: find.byType(FadeTransition), - matching: find.byType(Container), - )); - expect(container.size, Size.zero); + + expect(find.byType(CupertinoTextSelectionToolbar), paintsNothing); }); testWidgets('text field build empty tool bar when no options available android', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index edd41b39c77..b319efa5ccb 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1546,7 +1546,7 @@ void main() { controls = MockTextSelectionControls(); when(controls.buildHandle(any, any, any)).thenReturn(Container()); - when(controls.buildToolbar(any, any, any, any, any)) + when(controls.buildToolbar(any, any, any, any, any, any)) .thenReturn(Container()); }); diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 17d7dcab9a4..fc3efb1d43f 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -1108,7 +1108,8 @@ class _IsWithinDistance extends Matcher { } class _MoreOrLessEquals extends Matcher { - const _MoreOrLessEquals(this.value, this.epsilon); + const _MoreOrLessEquals(this.value, this.epsilon) + : assert(epsilon >= 0); final double value; final double epsilon; @@ -1125,6 +1126,12 @@ class _MoreOrLessEquals extends Matcher { @override Description describe(Description description) => description.add('$value (±$epsilon)'); + + @override + Description describeMismatch(Object item, Description mismatchDescription, Map matchState, bool verbose) { + return super.describeMismatch(item, mismatchDescription, matchState, verbose) + ..add('$item is not in the range of $value (±$epsilon).'); + } } class _IsMethodCall extends Matcher {