Commit 57d66664 authored by yaheng's avatar yaheng Committed by LongCatIsLooong

Fix text selection toolbar appearing under obstructions (#29809)

parent cc7ec6d6
...@@ -41,17 +41,33 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle( ...@@ -41,17 +41,33 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle(
color: CupertinoColors.white, 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. /// Paints a triangle below the toolbar.
class _TextSelectionToolbarNotchPainter extends CustomPainter { class _TextSelectionToolbarNotchPainter extends CustomPainter {
const _TextSelectionToolbarNotchPainter(
this.arrowDirection
) : assert (arrowDirection != null);
final _ArrowDirection arrowDirection;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final Paint paint = Paint() final Paint paint = Paint()
..color = _kToolbarBackgroundColor ..color = _kToolbarBackgroundColor
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
final double triangleBottomY = (arrowDirection == _ArrowDirection.down)
? 0.0
: _kToolbarTriangleSize.height;
final Path triangle = Path() final Path triangle = Path()
..lineTo(_kToolbarTriangleSize.width / 2, 0.0) ..lineTo(_kToolbarTriangleSize.width / 2, triangleBottomY)
..lineTo(0.0, _kToolbarTriangleSize.height) ..lineTo(0.0, _kToolbarTriangleSize.height)
..lineTo(-(_kToolbarTriangleSize.width / 2), 0.0) ..lineTo(-(_kToolbarTriangleSize.width / 2), triangleBottomY)
..close(); ..close();
canvas.drawPath(triangle, paint); canvas.drawPath(triangle, paint);
} }
...@@ -68,12 +84,14 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -68,12 +84,14 @@ class _TextSelectionToolbar extends StatelessWidget {
this.handleCopy, this.handleCopy,
this.handlePaste, this.handlePaste,
this.handleSelectAll, this.handleSelectAll,
this.arrowDirection,
}) : super(key: key); }) : super(key: key);
final VoidCallback handleCut; final VoidCallback handleCut;
final VoidCallback handleCopy; final VoidCallback handleCopy;
final VoidCallback handlePaste; final VoidCallback handlePaste;
final VoidCallback handleSelectAll; final VoidCallback handleSelectAll;
final _ArrowDirection arrowDirection;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -103,35 +121,47 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -103,35 +121,47 @@ class _TextSelectionToolbar extends StatelessWidget {
items.add(_buildToolbarButton(localizations.selectAllButtonLabel, handleSelectAll)); items.add(_buildToolbarButton(localizations.selectAllButtonLabel, handleSelectAll));
} }
const Widget padding = Padding(padding: EdgeInsets.only(bottom: 10.0));
final Widget triangle = SizedBox.fromSize( final Widget triangle = SizedBox.fromSize(
size: _kToolbarTriangleSize, size: _kToolbarTriangleSize,
child: CustomPaint( child: CustomPaint(
painter: _TextSelectionToolbarNotchPainter(), painter: _TextSelectionToolbarNotchPainter(arrowDirection),
), ),
); );
return Column( final Widget toolbar = ClipRRect(
mainAxisSize: MainAxisSize.min, borderRadius: _kToolbarBorderRadius,
children: <Widget>[ child: DecoratedBox(
ClipRRect( decoration: BoxDecoration(
color: _kToolbarDividerColor,
borderRadius: _kToolbarBorderRadius, borderRadius: _kToolbarBorderRadius,
child: DecoratedBox( // Add a hairline border with the button color to avoid
decoration: BoxDecoration( // antialiasing artifacts.
color: _kToolbarDividerColor, border: Border.all(color: _kToolbarBackgroundColor, width: 0),
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),
),
), ),
// TODO(xster): Position the triangle based on the layout delegate, and child: Row(mainAxisSize: MainAxisSize.min, children: items),
// 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)), 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,
); );
} }
...@@ -236,21 +266,49 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -236,21 +266,49 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
/// Builder for iOS-style copy/paste text selection toolbar. /// Builder for iOS-style copy/paste text selection toolbar.
@override @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(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( return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size), constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout( child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout( delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size, MediaQuery.of(context).size,
globalEditableRegion, globalEditableRegion,
position, preciseMidpoint,
), ),
child: _TextSelectionToolbar( child: _TextSelectionToolbar(
handleCut: canCut(delegate) ? () => handleCut(delegate) : null, handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
arrowDirection: direction,
), ),
), ),
); );
......
...@@ -14,9 +14,11 @@ import 'material_localizations.dart'; ...@@ -14,9 +14,11 @@ import 'material_localizations.dart';
import 'theme.dart'; import 'theme.dart';
const double _kHandleSize = 22.0; const double _kHandleSize = 22.0;
// Minimal padding from all edges of the selection toolbar to all edges of the // Minimal padding from all edges of the selection toolbar to all edges of the
// viewport. // viewport.
const double _kToolbarScreenPadding = 8.0; const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
/// Manages a copy/paste text selection toolbar. /// Manages a copy/paste text selection toolbar.
class _TextSelectionToolbar extends StatelessWidget { class _TextSelectionToolbar extends StatelessWidget {
...@@ -50,7 +52,7 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -50,7 +52,7 @@ class _TextSelectionToolbar extends StatelessWidget {
return Material( return Material(
elevation: 1.0, elevation: 1.0,
child: Container( child: Container(
height: 44.0, height: _kToolbarHeight,
child: Row(mainAxisSize: MainAxisSize.min, children: items), child: Row(mainAxisSize: MainAxisSize.min, children: items),
), ),
); );
...@@ -130,16 +132,39 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -130,16 +132,39 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
/// Builder for material-style copy/paste text selection toolbar. /// Builder for material-style copy/paste text selection toolbar.
@override @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(debugCheckHasMediaQuery(context));
assert(debugCheckHasMaterialLocalizations(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( return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size), constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout( child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout( delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size, MediaQuery.of(context).size,
globalEditableRegion, globalEditableRegion,
position, preciseMidpoint,
), ),
child: _TextSelectionToolbar( child: _TextSelectionToolbar(
handleCut: canCut(delegate) ? () => handleCut(delegate) : null, handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
......
...@@ -97,7 +97,20 @@ abstract class TextSelectionControls { ...@@ -97,7 +97,20 @@ abstract class TextSelectionControls {
/// Builds a toolbar near a text selection. /// Builds a toolbar near a text selection.
/// ///
/// Typically displays buttons for copying and pasting text. /// 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. /// Returns the size of the selection handle.
Size get handleSize; Size get handleSize;
...@@ -439,7 +452,13 @@ class TextSelectionOverlay { ...@@ -439,7 +452,13 @@ class TextSelectionOverlay {
link: layerLink, link: layerLink,
showWhenUnlinked: false, showWhenUnlinked: false,
offset: -editingRegion.topLeft, offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate), child: selectionControls.buildToolbar(
context,
editingRegion,
midpoint,
endpoints,
selectionDelegate,
),
), ),
); );
} }
......
...@@ -2042,6 +2042,76 @@ void main() { ...@@ -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 { testWidgets('text field respects keyboardAppearance from theme', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[]; final List<MethodCall> log = <MethodCall>[];
SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async {
......
...@@ -1126,6 +1126,82 @@ void main() { ...@@ -1126,6 +1126,82 @@ void main() {
expect(controller.text, 'abc d${testValue}ef ghi'); expect(controller.text, 'abc d${testValue}ef ghi');
}); });
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
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(30.0),
child: TextField(
controller: controller,
),
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// 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('SELECT ALL'));
Offset textFieldTopLeft = tester.getTopLeft(find.byType(TextField));
expect(textFieldTopLeft.dy, lessThan(toolbarTopLeft.dy));
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(150.0),
child: TextField(
controller: controller,
),
),
),
),
);
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// 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('SELECT ALL'));
textFieldTopLeft = tester.getTopLeft(find.byType(TextField));
expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy));
}
);
testWidgets('Selection toolbar fades in', (WidgetTester tester) async { testWidgets('Selection toolbar fades in', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
......
...@@ -1588,7 +1588,7 @@ void main() { ...@@ -1588,7 +1588,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)) when(controls.buildToolbar(any, any, any, any, any))
.thenReturn(Container()); .thenReturn(Container());
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment