Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 83 additions & 25 deletions packages/flutter/lib/src/cupertino/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,33 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle(
color: CupertinoColors.white,
);

/// The direction of the triangle attached to the toolbar.
///
/// 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, 0.0)
..lineTo(_kToolbarTriangleSize.width / 2, triangleBottomY)
..lineTo(0.0, _kToolbarTriangleSize.height)
..lineTo(-(_kToolbarTriangleSize.width / 2), 0.0)
..lineTo(-(_kToolbarTriangleSize.width / 2), triangleBottomY)
..close();
canvas.drawPath(triangle, paint);
}
Expand All @@ -68,12 +84,14 @@ class _TextSelectionToolbar extends StatelessWidget {
this.handleCopy,
this.handlePaste,
this.handleSelectAll,
this.arrowDirection,
}) : super(key: key);

final VoidCallback handleCut;
final VoidCallback handleCopy;
final VoidCallback handlePaste;
final VoidCallback handleSelectAll;
final _ArrowDirection arrowDirection;

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -103,35 +121,47 @@ class _TextSelectionToolbar extends StatelessWidget {
items.add(_buildToolbarButton(localizations.selectAllButtonLabel, handleSelectAll));
}

const Widget padding = Padding(padding: EdgeInsets.only(bottom: 10.0));

final Widget triangle = SizedBox.fromSize(
size: _kToolbarTriangleSize,
child: CustomPaint(
painter: _TextSelectionToolbarNotchPainter(),
painter: _TextSelectionToolbarNotchPainter(arrowDirection),
),
);

return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ClipRRect(
final Widget toolbar = ClipRRect(
borderRadius: _kToolbarBorderRadius,
child: DecoratedBox(
decoration: BoxDecoration(
color: _kToolbarDividerColor,
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),
),
// Add a hairline border with the button color to avoid
// antialiasing artifacts.
border: Border.all(color: _kToolbarBackgroundColor, width: 0),
),
// 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,
const Padding(padding: EdgeInsets.only(bottom: 10.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,
);
}

Expand Down Expand Up @@ -236,21 +266,49 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {

/// Builder for iOS-style copy/paste text selection toolbar.
@override
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate) {
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
) {
assert(debugCheckHasMediaQuery(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;

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);

return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size,
globalEditableRegion,
position,
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,
),
),
);
Expand Down
31 changes: 28 additions & 3 deletions packages/flutter/lib/src/material/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import 'material_localizations.dart';
import 'theme.dart';

const double _kHandleSize = 22.0;

// Minimal padding from all edges of the selection toolbar to all edges of the
// viewport.
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;

/// Manages a copy/paste text selection toolbar.
class _TextSelectionToolbar extends StatelessWidget {
Expand Down Expand Up @@ -50,7 +52,7 @@ class _TextSelectionToolbar extends StatelessWidget {
return Material(
elevation: 1.0,
child: Container(
height: 44.0,
height: _kToolbarHeight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
Expand Down Expand Up @@ -130,16 +132,39 @@ class _MaterialTextSelectionControls extends TextSelectionControls {

/// Builder for material-style copy/paste text selection toolbar.
@override
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate) {
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
) {
assert(debugCheckHasMediaQuery(context));
assert(debugCheckHasMaterialLocalizations(context));

// The toolbar should appear below the TextField
// when there is not enough space above the TextField to show it.
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 availableHeight
= globalEditableRegion.top - MediaQuery.of(context).padding.top - _kToolbarScreenPadding;
final double y = (availableHeight < _kToolbarHeight)
? startTextSelectionPoint.point.dy + globalEditableRegion.height + _kToolbarHeight + _kToolbarScreenPadding
: startTextSelectionPoint.point.dy - globalEditableRegion.height;
final Offset preciseMidpoint = Offset(x, y);

return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size,
globalEditableRegion,
position,
preciseMidpoint,
),
child: _TextSelectionToolbar(
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
Expand Down
23 changes: 21 additions & 2 deletions packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,20 @@ abstract class TextSelectionControls {
/// Builds a toolbar near a text selection.
///
/// Typically displays buttons for copying and pasting text.
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate);
///
/// [globalEditableRegion] is the TextField size of the global coordinate system
/// in logical pixels.
///
/// 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,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
);

/// Returns the size of the selection handle.
Size get handleSize;
Expand Down Expand Up @@ -439,7 +452,13 @@ class TextSelectionOverlay {
link: layerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate),
child: selectionControls.buildToolbar(
context,
editingRegion,
midpoint,
endpoints,
selectionDelegate,
),
),
);
}
Expand Down
70 changes: 70 additions & 0 deletions packages/flutter/test/cupertino/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2042,6 +2042,76 @@ void main() {
},
);

testWidgets(
'Check the toolbar appears below the TextField when there is not enough space above the TextField to show it',
(WidgetTester tester) async {
// This is a regression test for
// https://github.com/flutter/flutter/issues/29808
const String testValue = 'abc def ghi';
final TextEditingController controller = TextEditingController();

await tester.pumpWidget(
CupertinoApp(
home: Container(
padding: const EdgeInsets.all(30),
child: CupertinoTextField(
controller: controller,
),
),
),
);

await tester.enterText(find.byType(CupertinoTextField), testValue);
// Tap the selection handle to bring up the "paste / select all" menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
RenderEditable renderEditable = findRenderEditable(tester);
List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero

// Verify the selection toolbar position
Offset toolbarTopLeft = tester.getTopLeft(find.text('Paste'));
Offset textFieldTopLeft = tester.getTopLeft(find.byType(CupertinoTextField));
expect(textFieldTopLeft.dy, lessThan(toolbarTopLeft.dy));

await tester.pumpWidget(
CupertinoApp(
home: Container(
padding: const EdgeInsets.all(150),
child: CupertinoTextField(
controller: controller,
),
),
),
);

await tester.enterText(find.byType(CupertinoTextField), testValue);
// Tap the selection handle to bring up the "paste / select all" menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
renderEditable = findRenderEditable(tester);
endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero

// Verify the selection toolbar position
toolbarTopLeft = tester.getTopLeft(find.text('Paste'));
textFieldTopLeft = tester.getTopLeft(find.byType(CupertinoTextField));
expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy));
}
);

testWidgets('text field respects keyboardAppearance from theme', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async {
Expand Down
Loading