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.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
@ -11,219 +12,238 @@ import 'button.dart';
|
||||||
import 'colors.dart';
|
import 'colors.dart';
|
||||||
import 'localizations.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
|
// Read off from the output on iOS 12. This color does not vary with the
|
||||||
// application's theme color.
|
// application's theme color.
|
||||||
const Color _kHandlesColor = Color(0xFF136FE0);
|
const Color _kHandlesColor = Color(0xFF136FE0);
|
||||||
|
|
||||||
const double _kSelectionHandleOverlap = 1.5;
|
const double _kSelectionHandleOverlap = 1.5;
|
||||||
const double _kSelectionHandleRadius = 5.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 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(
|
const TextStyle _kToolbarButtonFontStyle = TextStyle(
|
||||||
inherit: false,
|
inherit: false,
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
letterSpacing: -0.11,
|
letterSpacing: -0.15,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w400,
|
||||||
color: CupertinoColors.white,
|
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
|
/// Typically displays buttons for text manipulation, e.g. copying and pasting text.
|
||||||
/// 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].
|
/// See also:
|
||||||
enum _ArrowDirection { up, down }
|
///
|
||||||
|
/// * [TextSelectionControls.buildToolbar], where [CupertinoTextSelectionToolbar]
|
||||||
/// Paints a triangle below the toolbar.
|
/// will be used to build an iOS-style toolbar.
|
||||||
class _TextSelectionToolbarNotchPainter extends CustomPainter {
|
@visibleForTesting
|
||||||
const _TextSelectionToolbarNotchPainter(
|
class CupertinoTextSelectionToolbar extends SingleChildRenderObjectWidget {
|
||||||
this.arrowDirection
|
const CupertinoTextSelectionToolbar._({
|
||||||
) : 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({
|
|
||||||
Key key,
|
Key key,
|
||||||
this.handleCut,
|
double barTopY,
|
||||||
this.handleCopy,
|
double arrowTipX,
|
||||||
this.handlePaste,
|
bool isArrowPointingDown,
|
||||||
this.handleSelectAll,
|
Widget child,
|
||||||
this.arrowDirection,
|
}) : _barTopY = barTopY,
|
||||||
}) : super(key: key);
|
_arrowTipX = arrowTipX,
|
||||||
|
_isArrowPointingDown = isArrowPointingDown,
|
||||||
|
super(key: key, child: child);
|
||||||
|
|
||||||
final VoidCallback handleCut;
|
// The y-coordinate of toolbar's top edge, in global coordinate system.
|
||||||
final VoidCallback handleCopy;
|
final double _barTopY;
|
||||||
final VoidCallback handlePaste;
|
|
||||||
final VoidCallback handleSelectAll;
|
// The y-coordinate of the tip of the arrow, in global coordinate system.
|
||||||
final _ArrowDirection arrowDirection;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
_ToolbarRenderBox createRenderObject(BuildContext context) => _ToolbarRenderBox(_barTopY, _arrowTipX, _isArrowPointingDown, null);
|
||||||
final List<Widget> items = <Widget>[];
|
|
||||||
final Widget onePhysicalPixelVerticalDivider =
|
|
||||||
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
|
|
||||||
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
|
|
||||||
|
|
||||||
if (handleCut != null)
|
@override
|
||||||
items.add(_buildToolbarButton(localizations.cutButtonLabel, handleCut));
|
void updateRenderObject(BuildContext context, _ToolbarRenderBox renderObject) {
|
||||||
|
renderObject
|
||||||
if (handleCopy != null) {
|
..barTopY = _barTopY
|
||||||
if (items.isNotEmpty)
|
..arrowTipX = _arrowTipX
|
||||||
items.add(onePhysicalPixelVerticalDivider);
|
..isArrowPointingDown = _isArrowPointingDown;
|
||||||
items.add(_buildToolbarButton(localizations.copyButtonLabel, handleCopy));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handlePaste != null) {
|
class _ToolbarParentData extends BoxParentData {
|
||||||
if (items.isNotEmpty)
|
// The x offset from the tip of the arrow to the center of the toolbar.
|
||||||
items.add(onePhysicalPixelVerticalDivider);
|
// Positive if the tip of the arrow has a larger x-coordinate than the
|
||||||
items.add(_buildToolbarButton(localizations.pasteButtonLabel, handlePaste));
|
// center of the toolbar.
|
||||||
|
double arrowXOffsetFromCenter;
|
||||||
|
@override
|
||||||
|
String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handleSelectAll != null) {
|
class _ToolbarRenderBox extends RenderShiftedBox {
|
||||||
if (items.isNotEmpty)
|
_ToolbarRenderBox(
|
||||||
items.add(onePhysicalPixelVerticalDivider);
|
this._barTopY,
|
||||||
items.add(_buildToolbarButton(localizations.selectAllButtonLabel, handleSelectAll));
|
this._arrowTipX,
|
||||||
|
this._isArrowPointingDown,
|
||||||
|
RenderBox child,
|
||||||
|
) : super(child);
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isRepaintBoundary => true;
|
||||||
|
|
||||||
|
double _barTopY;
|
||||||
|
set barTopY(double value) {
|
||||||
|
if (_barTopY == value) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// If there is no option available, build an empty widget.
|
_barTopY = value;
|
||||||
if (items.isEmpty) {
|
markNeedsLayout();
|
||||||
return Container(width: 0.0, height: 0.0);
|
markNeedsSemanticsUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
const Widget padding = Padding(padding: EdgeInsets.only(bottom: 10.0));
|
double _arrowTipX;
|
||||||
|
set arrowTipX(double value) {
|
||||||
|
if (_arrowTipX == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_arrowTipX = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
markNeedsSemanticsUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
final Widget triangle = SizedBox.fromSize(
|
bool _isArrowPointingDown;
|
||||||
size: _kToolbarTriangleSize,
|
set isArrowPointingDown(bool value) {
|
||||||
child: CustomPaint(
|
if (_isArrowPointingDown == value) {
|
||||||
painter: _TextSelectionToolbarNotchPainter(arrowDirection),
|
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
|
||||||
|
void performLayout() {
|
||||||
|
size = constraints.biggest;
|
||||||
|
|
||||||
|
if (child == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final BoxConstraints enforcedConstraint = constraints
|
||||||
|
.deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding))
|
||||||
|
.loosen();
|
||||||
|
|
||||||
|
child.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,);
|
||||||
|
final _ToolbarParentData childParentData = child.parentData;
|
||||||
|
|
||||||
|
final Offset localTopCenter = globalToLocal(Offset(_arrowTipX, _barTopY));
|
||||||
|
|
||||||
|
// 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 Widget toolbar = ClipRRect(
|
final double arrowTipX = child.size.width / 2 + childParentData.arrowXOffsetFromCenter;
|
||||||
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)
|
final double arrowBottomY = _isArrowPointingDown
|
||||||
? <Widget>[
|
? child.size.height - _kToolbarArrowSize.height
|
||||||
toolbar,
|
: _kToolbarArrowSize.height;
|
||||||
// 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(
|
final double arrowTipY = _isArrowPointingDown ? child.size.height : 0;
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: menus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a themed [CupertinoButton] for the toolbar.
|
final Path arrow = Path()
|
||||||
CupertinoButton _buildToolbarButton(String text, VoidCallback onPressed) {
|
..moveTo(arrowTipX, arrowTipY)
|
||||||
return CupertinoButton(
|
..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBottomY)
|
||||||
child: Text(text, style: _kToolbarButtonFontStyle),
|
..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBottomY)
|
||||||
color: _kToolbarBackgroundColor,
|
..close();
|
||||||
minSize: _kToolbarHeight,
|
|
||||||
padding: _kToolbarButtonPadding,
|
|
||||||
borderRadius: null,
|
|
||||||
pressedOpacity: 0.7,
|
|
||||||
onPressed: onPressed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Centers the toolbar around the given position, ensuring that it remains on
|
return Path.combine(PathOperation.union, rrect, arrow);
|
||||||
/// screen.
|
|
||||||
class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
|
|
||||||
_TextSelectionToolbarLayout(this.screenSize, this.globalEditableRegion, this.position);
|
|
||||||
|
|
||||||
/// The size of the screen at the time that the toolbar was last laid out.
|
|
||||||
final Size screenSize;
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Offset getPositionForChild(Size size, Size childSize) {
|
void paint(PaintingContext context, Offset offset) {
|
||||||
final Offset globalPosition = globalEditableRegion.topLeft + position;
|
if (child == null) {
|
||||||
|
return;
|
||||||
double x = globalPosition.dx - childSize.width / 2.0;
|
|
||||||
double y = globalPosition.dy - childSize.height;
|
|
||||||
|
|
||||||
if (x < _kToolbarScreenPadding)
|
|
||||||
x = _kToolbarScreenPadding;
|
|
||||||
else if (x + childSize.width > screenSize.width - _kToolbarScreenPadding)
|
|
||||||
x = screenSize.width - childSize.width - _kToolbarScreenPadding;
|
|
||||||
|
|
||||||
if (y < _kToolbarScreenPadding)
|
|
||||||
y = _kToolbarScreenPadding;
|
|
||||||
else if (y + childSize.height > screenSize.height - _kToolbarScreenPadding)
|
|
||||||
y = screenSize.height - childSize.height - _kToolbarScreenPadding;
|
|
||||||
|
|
||||||
return Offset(x, y);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) {
|
void debugPaintSize(PaintingContext context, Offset offset) {
|
||||||
return screenSize != oldDelegate.screenSize
|
assert(() {
|
||||||
|| globalEditableRegion != oldDelegate.globalEditableRegion
|
if (child == null) {
|
||||||
|| position != oldDelegate.position;
|
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(
|
Widget buildToolbar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Rect globalEditableRegion,
|
Rect globalEditableRegion,
|
||||||
|
double textLineHeight,
|
||||||
Offset position,
|
Offset position,
|
||||||
List<TextSelectionPoint> endpoints,
|
List<TextSelectionPoint> endpoints,
|
||||||
TextSelectionDelegate delegate,
|
TextSelectionDelegate delegate,
|
||||||
) {
|
) {
|
||||||
assert(debugCheckHasMediaQuery(context));
|
assert(debugCheckHasMediaQuery(context));
|
||||||
|
final MediaQueryData mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
// The toolbar should appear below the TextField
|
// The toolbar should appear below the TextField when there is not enough
|
||||||
// when there is not enough space above the TextField to show it.
|
// space above the TextField to show it, assuming there's always enough space
|
||||||
final double availableHeight
|
// at the bottom in this case.
|
||||||
= globalEditableRegion.top - MediaQuery.of(context).padding.top - _kToolbarScreenPadding;
|
final bool isArrowPointingDown =
|
||||||
final _ArrowDirection direction = (availableHeight > _kToolbarHeight)
|
mediaQuery.padding.top
|
||||||
? _ArrowDirection.down
|
+ _kToolbarScreenPadding
|
||||||
: _ArrowDirection.up;
|
+ _kToolbarHeight
|
||||||
|
+ _kToolbarContentDistance <= globalEditableRegion.top + endpoints.first.point.dy - textLineHeight;
|
||||||
|
|
||||||
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
|
final double arrowTipX = (position.dx + globalEditableRegion.left).clamp(
|
||||||
final TextSelectionPoint endTextSelectionPoint = (endpoints.length > 1)
|
_kArrowScreenPadding + mediaQuery.padding.left,
|
||||||
? endpoints[1]
|
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
|
||||||
: 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);
|
|
||||||
|
|
||||||
return ConstrainedBox(
|
// The y-coordinate has to be calculated instead of directly quoting postion.dy,
|
||||||
constraints: BoxConstraints.tight(globalEditableRegion.size),
|
// since the caller (TextSelectionOverlay._buildToolbar) does not know whether
|
||||||
child: CustomSingleChildLayout(
|
// the toolbar is going to be facing up or down.
|
||||||
delegate: _TextSelectionToolbarLayout(
|
final double localBarTopY = isArrowPointingDown
|
||||||
MediaQuery.of(context).size,
|
? endpoints.first.point.dy - textLineHeight - _kToolbarContentDistance - _kToolbarHeight
|
||||||
globalEditableRegion,
|
: endpoints.last.point.dy + _kToolbarContentDistance;
|
||||||
preciseMidpoint,
|
|
||||||
),
|
final List<Widget> items = <Widget>[];
|
||||||
child: _TextSelectionToolbar(
|
final Widget onePhysicalPixelVerticalDivider =
|
||||||
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
|
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
|
||||||
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
|
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
|
||||||
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
|
final EdgeInsets arrowPadding = isArrowPointingDown
|
||||||
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
|
? EdgeInsets.only(bottom: _kToolbarArrowSize.height)
|
||||||
arrowDirection: direction,
|
: 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(
|
Widget buildToolbar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Rect globalEditableRegion,
|
Rect globalEditableRegion,
|
||||||
|
double textLineHeight,
|
||||||
Offset position,
|
Offset position,
|
||||||
List<TextSelectionPoint> endpoints,
|
List<TextSelectionPoint> endpoints,
|
||||||
TextSelectionDelegate delegate,
|
TextSelectionDelegate delegate,
|
||||||
|
|
|
@ -108,12 +108,16 @@ abstract class TextSelectionControls {
|
||||||
/// [globalEditableRegion] is the TextField size of the global coordinate system
|
/// [globalEditableRegion] is the TextField size of the global coordinate system
|
||||||
/// in logical pixels.
|
/// 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.
|
/// The [position] is a general calculation midpoint parameter of the toolbar.
|
||||||
/// If you want more detailed position information, can use [endpoints]
|
/// If you want more detailed position information, can use [endpoints]
|
||||||
/// to calculate it.
|
/// to calculate it.
|
||||||
Widget buildToolbar(
|
Widget buildToolbar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Rect globalEditableRegion,
|
Rect globalEditableRegion,
|
||||||
|
double textLineHeight,
|
||||||
Offset position,
|
Offset position,
|
||||||
List<TextSelectionPoint> endpoints,
|
List<TextSelectionPoint> endpoints,
|
||||||
TextSelectionDelegate delegate,
|
TextSelectionDelegate delegate,
|
||||||
|
@ -509,19 +513,29 @@ class TextSelectionOverlay {
|
||||||
return Container();
|
return Container();
|
||||||
|
|
||||||
// Find the horizontal midpoint, just above the selected text.
|
// Find the horizontal midpoint, just above the selected text.
|
||||||
final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection);
|
final List<TextSelectionPoint> endpoints =
|
||||||
final Offset midpoint = Offset(
|
renderObject.getEndpointsForSelection(_selection);
|
||||||
(endpoints.length == 1) ?
|
|
||||||
endpoints[0].point.dx :
|
|
||||||
(endpoints[0].point.dx + endpoints[1].point.dx) / 2.0,
|
|
||||||
endpoints[0].point.dy - renderObject.preferredLineHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
final Rect editingRegion = Rect.fromPoints(
|
final Rect editingRegion = Rect.fromPoints(
|
||||||
renderObject.localToGlobal(Offset.zero),
|
renderObject.localToGlobal(Offset.zero),
|
||||||
renderObject.localToGlobal(renderObject.size.bottomRight(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(
|
return FadeTransition(
|
||||||
opacity: _toolbarOpacity,
|
opacity: _toolbarOpacity,
|
||||||
child: CompositedTransformFollower(
|
child: CompositedTransformFollower(
|
||||||
|
@ -531,6 +545,7 @@ class TextSelectionOverlay {
|
||||||
child: selectionControls.buildToolbar(
|
child: selectionControls.buildToolbar(
|
||||||
context,
|
context,
|
||||||
editingRegion,
|
editingRegion,
|
||||||
|
renderObject.preferredLineHeight,
|
||||||
midpoint,
|
midpoint,
|
||||||
endpoints,
|
endpoints,
|
||||||
selectionDelegate,
|
selectionDelegate,
|
||||||
|
|
|
@ -11,6 +11,8 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
|
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../rendering/mock_canvas.dart';
|
||||||
|
|
||||||
class MockClipboard {
|
class MockClipboard {
|
||||||
Object _clipboardData = <String, dynamic>{
|
Object _clipboardData = <String, dynamic>{
|
||||||
'text': null,
|
'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() {
|
void main() {
|
||||||
final MockClipboard mockClipboard = MockClipboard();
|
final MockClipboard mockClipboard = MockClipboard();
|
||||||
SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
|
SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
|
||||||
|
@ -58,7 +155,7 @@ void main() {
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Offset textOffsetToPosition(WidgetTester tester, int offset) {
|
Offset textOffsetToBottomLeftPosition(WidgetTester tester, int offset) {
|
||||||
final RenderEditable renderEditable = findRenderEditable(tester);
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
||||||
final List<TextSelectionPoint> endpoints = globalize(
|
final List<TextSelectionPoint> endpoints = globalize(
|
||||||
renderEditable.getEndpointsForSelection(
|
renderEditable.getEndpointsForSelection(
|
||||||
|
@ -67,9 +164,16 @@ void main() {
|
||||||
renderEditable,
|
renderEditable,
|
||||||
);
|
);
|
||||||
expect(endpoints.length, 1);
|
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(
|
testWidgets(
|
||||||
'takes available space horizontally and takes intrinsic space vertically no-strut',
|
'takes available space horizontally and takes intrinsic space vertically no-strut',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
|
@ -1121,8 +1225,8 @@ void main() {
|
||||||
Text text = tester.widget<Text>(find.text('Paste'));
|
Text text = tester.widget<Text>(find.text('Paste'));
|
||||||
expect(text.style.color, CupertinoColors.white);
|
expect(text.style.color, CupertinoColors.white);
|
||||||
expect(text.style.fontSize, 14);
|
expect(text.style.fontSize, 14);
|
||||||
expect(text.style.letterSpacing, -0.11);
|
expect(text.style.letterSpacing, -0.15);
|
||||||
expect(text.style.fontWeight, FontWeight.w300);
|
expect(text.style.fontWeight, FontWeight.w400);
|
||||||
|
|
||||||
// Change the theme.
|
// Change the theme.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
@ -1153,8 +1257,8 @@ void main() {
|
||||||
// The toolbar buttons' text are still the same style.
|
// The toolbar buttons' text are still the same style.
|
||||||
expect(text.style.color, CupertinoColors.white);
|
expect(text.style.color, CupertinoColors.white);
|
||||||
expect(text.style.fontSize, 14);
|
expect(text.style.fontSize, 14);
|
||||||
expect(text.style.letterSpacing, -0.11);
|
expect(text.style.letterSpacing, -0.15);
|
||||||
expect(text.style.fontWeight, FontWeight.w300);
|
expect(text.style.fontWeight, FontWeight.w400);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Read only text field', (WidgetTester tester) async {
|
testWidgets('Read only text field', (WidgetTester tester) async {
|
||||||
|
@ -2688,4 +2792,332 @@ void main() {
|
||||||
skip: !isLinux,
|
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/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
|
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
|
||||||
|
|
||||||
|
import '../rendering/mock_canvas.dart';
|
||||||
import '../widgets/semantics_tester.dart';
|
import '../widgets/semantics_tester.dart';
|
||||||
import 'feedback_tester.dart';
|
import 'feedback_tester.dart';
|
||||||
|
|
||||||
|
@ -864,7 +865,7 @@ void main() {
|
||||||
expect(find.text('CUT'), findsNothing);
|
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(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: ThemeData(platform: TargetPlatform.iOS),
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
||||||
|
@ -882,11 +883,8 @@ void main() {
|
||||||
await tester.tap(find.byType(TextField));
|
await tester.tap(find.byType(TextField));
|
||||||
// Wait for context menu to be built.
|
// Wait for context menu to be built.
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final RenderBox container = tester.renderObject(find.descendant(
|
|
||||||
of: find.byType(FadeTransition),
|
expect(find.byType(CupertinoTextSelectionToolbar), paintsNothing);
|
||||||
matching: find.byType(Container),
|
|
||||||
));
|
|
||||||
expect(container.size, Size.zero);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('text field build empty tool bar when no options available android', (WidgetTester tester) async {
|
testWidgets('text field build empty tool bar when no options available android', (WidgetTester tester) async {
|
||||||
|
|
|
@ -1546,7 +1546,7 @@ void main() {
|
||||||
|
|
||||||
controls = MockTextSelectionControls();
|
controls = MockTextSelectionControls();
|
||||||
when(controls.buildHandle(any, any, any)).thenReturn(Container());
|
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());
|
.thenReturn(Container());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1108,7 +1108,8 @@ class _IsWithinDistance<T> extends Matcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MoreOrLessEquals 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 value;
|
||||||
final double epsilon;
|
final double epsilon;
|
||||||
|
@ -1125,6 +1126,12 @@ class _MoreOrLessEquals extends Matcher {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Description describe(Description description) => description.add('$value (±$epsilon)');
|
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 {
|
class _IsMethodCall extends Matcher {
|
||||||
|
|
Loading…
Reference in a new issue