Cupertino text edit tooltip rework (#34095)

This commit is contained in:
LongCatIsLooong 2019-06-18 18:29:10 -07:00 committed by GitHub
parent 7472fad194
commit 2602119194
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 736 additions and 231 deletions

View file

@ -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<Widget> items = <Widget>[];
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<Widget> menus = (arrowDirection == _ArrowDirection.down)
? <Widget>[
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,
]
: <Widget>[
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),
<Color>[const Color(0x00000000), const Color(0xFFFF00FF), const Color(0xFFFF00FF), const Color(0x00000000)],
<double>[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<TextSelectionPoint> 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<Widget> items = <Widget>[];
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),
),
);
}

View file

@ -141,6 +141,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,

View file

@ -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<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
@ -509,19 +513,29 @@ class TextSelectionOverlay {
return Container();
// Find the horizontal midpoint, just above the selected text.
final List<TextSelectionPoint> 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<TextSelectionPoint> 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,

View file

@ -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 = <String, dynamic>{
'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<dynamic, dynamic> matchState) {
final Rect bounds = item.getBounds();
final List<Matcher> matchers = <Matcher> [rectMatcher, topMatcher, leftMatcher, rightMatcher, bottomMatcher];
final List<dynamic> values = <dynamic> [bounds, bounds.top, bounds.left, bounds.right, bounds.bottom];
final Map<Matcher, dynamic> failedMatcher = <Matcher, dynamic> {};
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<dynamic, dynamic> matchState, bool verbose) {
final Description description = super.describeMismatch(item, mismatchDescription, matchState, verbose);
final Map<Matcher, dynamic> map = matchState['failedMatcher'];
final Iterable<String> descriptions = map.entries
.map<String>(
(MapEntry<Matcher, dynamic> 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 <Offset>[],
this.excludes = const <Offset>[],
}) : super();
final Iterable<Offset> includes;
final Iterable<Offset> excludes;
@override
bool matches(covariant Path item, Map<dynamic, dynamic> 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<dynamic, dynamic> 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<TextSelectionPoint> 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<Text>(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<EditableTextState>(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: <Offset> [
// Arrow should not point to the selection handle.
bottomLeftSelectionPosition.translate(0, 8 + 0.1),
],
includes: <Offset> [
// 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<String>.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<EditableTextState>(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: <Offset> [
// Arrow should not point to the selection handle.
bottomLeftSelectionPosition.translate(0, 8 + 0.1),
],
includes: <Offset> [
// 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<String>.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<EditableTextState>(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: <Offset> [
// 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<String>.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<EditableTextState>(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: <Offset> [
// 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<String>.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<EditableTextState>(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: <Offset> [
// 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),
),
),
);
});
});
}

View file

@ -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 {

View file

@ -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());
});

View file

@ -1108,7 +1108,8 @@ class _IsWithinDistance<T> 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<dynamic, dynamic> matchState, bool verbose) {
return super.describeMismatch(item, mismatchDescription, matchState, verbose)
..add('$item is not in the range of $value$epsilon).');
}
}
class _IsMethodCall extends Matcher {