mirror of
https://github.com/flutter/flutter
synced 2024-10-13 11:42:54 +00:00
Cupertino text edit tooltip rework (#34095)
This commit is contained in:
parent
7472fad194
commit
2602119194
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -141,6 +141,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
|
|||
Widget buildToolbar(
|
||||
BuildContext context,
|
||||
Rect globalEditableRegion,
|
||||
double textLineHeight,
|
||||
Offset position,
|
||||
List<TextSelectionPoint> endpoints,
|
||||
TextSelectionDelegate delegate,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue