Unverified Commit d963e4fe authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Text selection handles are sometimes not interactive (#31852)

The text selection handles now feel a lot more responsive, and their implementation was cleaned up a bit.
parent 8d658d4f
...@@ -786,7 +786,20 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -786,7 +786,20 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
final Brightness keyboardAppearance = widget.keyboardAppearance ?? themeData.brightness; final Brightness keyboardAppearance = widget.keyboardAppearance ?? themeData.brightness;
final Color cursorColor = widget.cursorColor ?? themeData.primaryColor; final Color cursorColor = widget.cursorColor ?? themeData.primaryColor;
final Widget paddedEditable = Padding( final Widget paddedEditable = TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onForcePressStart: _handleForcePressStarted,
onForcePressEnd: _handleForcePressEnded,
onSingleTapUp: _handleSingleTapUp,
onSingleLongTapStart: _handleSingleLongTapStart,
onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate,
onSingleLongTapEnd: _handleSingleLongTapEnd,
onDoubleTapDown: _handleDoubleTapDown,
onDragSelectionStart: _handleMouseDragSelectionStart,
onDragSelectionUpdate: _handleMouseDragSelectionUpdate,
onDragSelectionEnd: _handleMouseDragSelectionEnd,
behavior: HitTestBehavior.translucent,
child: Padding(
padding: widget.padding, padding: widget.padding,
child: RepaintBoundary( child: RepaintBoundary(
child: EditableText( child: EditableText(
...@@ -829,6 +842,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -829,6 +842,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
enableInteractiveSelection: widget.enableInteractiveSelection, enableInteractiveSelection: widget.enableInteractiveSelection,
), ),
), ),
),
); );
return Semantics( return Semantics(
...@@ -849,24 +863,10 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -849,24 +863,10 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
: CupertinoTheme.of(context).brightness == Brightness.light : CupertinoTheme.of(context).brightness == Brightness.light
? _kDisabledBackground ? _kDisabledBackground
: CupertinoColors.darkBackgroundGray, : CupertinoColors.darkBackgroundGray,
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onForcePressStart: _handleForcePressStarted,
onForcePressEnd: _handleForcePressEnded,
onSingleTapUp: _handleSingleTapUp,
onSingleLongTapStart: _handleSingleLongTapStart,
onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate,
onSingleLongTapEnd: _handleSingleLongTapEnd,
onDoubleTapDown: _handleDoubleTapDown,
onDragSelectionStart: _handleMouseDragSelectionStart,
onDragSelectionUpdate: _handleMouseDragSelectionUpdate,
onDragSelectionEnd: _handleMouseDragSelectionEnd,
behavior: HitTestBehavior.translucent,
child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle), child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle),
), ),
), ),
), ),
),
); );
} }
} }
...@@ -11,9 +11,6 @@ import 'button.dart'; ...@@ -11,9 +11,6 @@ import 'button.dart';
import 'colors.dart'; import 'colors.dart';
import 'localizations.dart'; import 'localizations.dart';
// Padding around the line at the edge of the text selection that has 0 width and
// the height of the text font.
const double _kHandlesPadding = 18.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;
...@@ -25,10 +22,8 @@ const Color _kToolbarDividerColor = Color(0xFFB9B9B9); ...@@ -25,10 +22,8 @@ const Color _kToolbarDividerColor = Color(0xFFB9B9B9);
// application's theme color. // application's theme color.
const Color _kHandlesColor = Color(0xFF136FE0); const Color _kHandlesColor = Color(0xFF136FE0);
// This offset is used to determine the center of the selection during a drag. const double _kSelectionHandleOverlap = 1.5;
// It's slightly below the center of the text so the finger isn't entirely const double _kSelectionHandleRadius = 5.5;
// covering the text being selected.
const Size _kSelectionOffset = Size(20.0, 30.0);
const Size _kToolbarTriangleSize = Size(18.0, 9.0); const Size _kToolbarTriangleSize = Size(18.0, 9.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 BorderRadius _kToolbarBorderRadius = BorderRadius.all(Radius.circular(7.5));
...@@ -229,40 +224,46 @@ class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate { ...@@ -229,40 +224,46 @@ class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
} }
/// Draws a single text selection handle with a bar and a ball. /// Draws a single text selection handle with a bar and a ball.
///
/// Draws from a point of origin somewhere inside the size of the painter
/// such that the ball is below the point of origin and the bar is above the
/// point of origin.
class _TextSelectionHandlePainter extends CustomPainter { class _TextSelectionHandlePainter extends CustomPainter {
_TextSelectionHandlePainter({this.origin}); const _TextSelectionHandlePainter();
final Offset origin;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final Paint paint = Paint() final Paint paint = Paint()
..color = _kHandlesColor ..color = _kHandlesColor
..strokeWidth = 2.0; ..strokeWidth = 2.0;
// Draw circle below the origin that slightly overlaps the bar. canvas.drawCircle(
canvas.drawCircle(origin.translate(0.0, 4.0), 5.5, paint); const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
// Draw up from origin leaving 10 pixels of margin on top. _kSelectionHandleRadius,
paint,
);
// Draw line so it slightly overlaps the circle.
canvas.drawLine( canvas.drawLine(
origin, const Offset(
origin.translate( _kSelectionHandleRadius,
0.0, 2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
-(size.height - 2.0 * _kHandlesPadding), ),
Offset(
_kSelectionHandleRadius,
size.height,
), ),
paint, paint,
); );
} }
@override @override
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => origin != oldPainter.origin; bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => false;
} }
class _CupertinoTextSelectionControls extends TextSelectionControls { class _CupertinoTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Cupertino handle.
@override @override
Size handleSize = _kSelectionOffset; // Used for drag selection offset. Size getHandleSize(double textLineHeight) {
return Size(
_kSelectionHandleRadius * 2,
textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
);
}
/// Builder for iOS-style copy/paste text selection toolbar. /// Builder for iOS-style copy/paste text selection toolbar.
@override @override
...@@ -319,22 +320,12 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -319,22 +320,12 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight) {
// We want a size that's a vertical line the height of the text plus a 18.0 // We want a size that's a vertical line the height of the text plus a 18.0
// padding in every direction that will constitute the selection drag area. // padding in every direction that will constitute the selection drag area.
final Size desiredSize = Size( final Size desiredSize = getHandleSize(textLineHeight);
2.0 * _kHandlesPadding,
textLineHeight + 2.0 * _kHandlesPadding,
);
final Widget handle = SizedBox.fromSize( final Widget handle = SizedBox.fromSize(
size: desiredSize, size: desiredSize,
child: CustomPaint( child: const CustomPaint(
painter: _TextSelectionHandlePainter( painter: _TextSelectionHandlePainter(),
// We give the painter a point of origin that's at the bottom baseline
// of the selection cursor position.
//
// We give it in the form of an offset from the top left of the
// SizedBox.
origin: Offset(_kHandlesPadding, textLineHeight + _kHandlesPadding),
),
), ),
); );
...@@ -342,27 +333,54 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -342,27 +333,54 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
// baseline. We transform the handle such that the SizedBox is superimposed // baseline. We transform the handle such that the SizedBox is superimposed
// on top of the text selection endpoints. // on top of the text selection endpoints.
switch (type) { switch (type) {
case TextSelectionHandleType.left: // The left handle is upside down on iOS. case TextSelectionHandleType.left:
return Transform( return handle;
transform: Matrix4.rotationZ(math.pi)
..translate(-_kHandlesPadding, -_kHandlesPadding),
child: handle,
);
case TextSelectionHandleType.right: case TextSelectionHandleType.right:
// Right handle is a vertical mirror of the left.
return Transform( return Transform(
transform: Matrix4.translationValues( transform: Matrix4.identity()
-_kHandlesPadding, ..translate(desiredSize.width / 2, desiredSize.height / 2)
-(textLineHeight + _kHandlesPadding), ..rotateZ(math.pi)
0.0, ..translate(-desiredSize.width / 2, -desiredSize.height / 2),
),
child: handle, child: handle,
); );
case TextSelectionHandleType.collapsed: // iOS doesn't draw anything for collapsed selections. // iOS doesn't draw anything for collapsed selections.
case TextSelectionHandleType.collapsed:
return Container(); return Container();
} }
assert(type != null); assert(type != null);
return null; return null;
} }
/// Gets anchor for cupertino-style text selection handles.
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
final Size handleSize = getHandleSize(textLineHeight);
switch (type) {
// The circle is at the top for the left handle, and the anchor point is
// all the way at the bottom of the line.
case TextSelectionHandleType.left:
return Offset(
handleSize.width / 2,
handleSize.height,
);
// The right handle is vertically flipped, and the anchor point is near
// the top of the circle to give slight overlap.
case TextSelectionHandleType.right:
return Offset(
handleSize.width / 2,
handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
);
// A collapsed handle anchors itself so that it's centered.
default:
return Offset(
handleSize.width / 2,
textLineHeight + (handleSize.height - textLineHeight) / 2,
);
}
}
} }
/// Text selection controls that follows iOS design conventions. /// Text selection controls that follows iOS design conventions.
......
...@@ -127,8 +127,9 @@ class _TextSelectionHandlePainter extends CustomPainter { ...@@ -127,8 +127,9 @@ class _TextSelectionHandlePainter extends CustomPainter {
} }
class _MaterialTextSelectionControls extends TextSelectionControls { class _MaterialTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Material handle.
@override @override
Size handleSize = const Size(_kHandleSize, _kHandleSize); Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style copy/paste text selection toolbar. /// Builder for material-style copy/paste text selection toolbar.
@override @override
...@@ -179,9 +180,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -179,9 +180,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
/// Builder for material-style text selection handles. /// Builder for material-style text selection handles.
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final Widget handle = Padding( final Widget handle = SizedBox(
padding: const EdgeInsets.only(right: 26.0, bottom: 26.0),
child: SizedBox(
width: _kHandleSize, width: _kHandleSize,
height: _kHandleSize, height: _kHandleSize,
child: CustomPaint( child: CustomPaint(
...@@ -189,7 +188,6 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -189,7 +188,6 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
color: Theme.of(context).textSelectionHandleColor color: Theme.of(context).textSelectionHandleColor
), ),
), ),
),
); );
// [handle] is a circle, with a rectangle in the top left quadrant of that // [handle] is a circle, with a rectangle in the top left quadrant of that
...@@ -197,15 +195,15 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -197,15 +195,15 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
// straight up or up-right depending on the handle type. // straight up or up-right depending on the handle type.
switch (type) { switch (type) {
case TextSelectionHandleType.left: // points up-right case TextSelectionHandleType.left: // points up-right
return Transform( return Transform.rotate(
transform: Matrix4.rotationZ(math.pi / 2.0), angle: math.pi / 2.0,
child: handle, child: handle,
); );
case TextSelectionHandleType.right: // points up-left case TextSelectionHandleType.right: // points up-left
return handle; return handle;
case TextSelectionHandleType.collapsed: // points up case TextSelectionHandleType.collapsed: // points up
return Transform( return Transform.rotate(
transform: Matrix4.rotationZ(math.pi / 4.0), angle: math.pi / 4.0,
child: handle, child: handle,
); );
} }
...@@ -213,6 +211,21 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -213,6 +211,21 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
return null; return null;
} }
/// Gets anchor for material-style text selection handles.
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
switch (type) {
case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0);
case TextSelectionHandleType.right:
return Offset.zero;
default:
return const Offset(_kHandleSize / 2, -4);
}
}
@override @override
bool canSelectAll(TextSelectionDelegate delegate) { bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless // Android allows SelectAll when selection is not collapsed, unless
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop; import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
...@@ -94,6 +95,11 @@ abstract class TextSelectionControls { ...@@ -94,6 +95,11 @@ abstract class TextSelectionControls {
/// selection position. /// selection position.
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight); Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight);
/// Get the anchor point of the handle relative to itself. The anchor point is
/// the point that is aligned with a specific point in the text. A handle
/// often visually "points to" that location.
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight);
/// 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.
...@@ -113,7 +119,7 @@ abstract class TextSelectionControls { ...@@ -113,7 +119,7 @@ abstract class TextSelectionControls {
); );
/// Returns the size of the selection handle. /// Returns the size of the selection handle.
Size get handleSize; Size getHandleSize(double textLineHeight);
/// Whether the current selection of the text field managed by the given /// Whether the current selection of the text field managed by the given
/// `delegate` can be removed from the text field and placed into the /// `delegate` can be removed from the text field and placed into the
...@@ -533,6 +539,11 @@ class _TextSelectionHandleOverlay extends StatefulWidget { ...@@ -533,6 +539,11 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
} }
} }
/// The minimum size that a widget should be in order to be easily interacted
/// with by the user.
@visibleForTesting
const double kMinInteractiveSize = 48.0;
class _TextSelectionHandleOverlayState class _TextSelectionHandleOverlayState
extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin { extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin {
Offset _dragPosition; Offset _dragPosition;
...@@ -574,7 +585,10 @@ class _TextSelectionHandleOverlayState ...@@ -574,7 +585,10 @@ class _TextSelectionHandleOverlayState
} }
void _handleDragStart(DragStartDetails details) { void _handleDragStart(DragStartDetails details) {
_dragPosition = details.globalPosition + Offset(0.0, -widget.selectionControls.handleSize.height); final Size handleSize = widget.selectionControls.getHandleSize(
widget.renderObject.preferredLineHeight,
);
_dragPosition = details.globalPosition + Offset(0.0, -handleSize.height);
} }
void _handleDragUpdate(DragUpdateDetails details) { void _handleDragUpdate(DragUpdateDetails details) {
...@@ -639,31 +653,61 @@ class _TextSelectionHandleOverlayState ...@@ -639,31 +653,61 @@ class _TextSelectionHandleOverlayState
point.dy.clamp(0.0, viewport.height), point.dy.clamp(0.0, viewport.height),
); );
final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
type,
widget.renderObject.preferredLineHeight,
);
final Size handleSize = widget.selectionControls.getHandleSize(
widget.renderObject.preferredLineHeight,
);
final Rect handleRect = Rect.fromLTWH(
// Put handleAnchor on top of point
point.dx - handleAnchor.dx,
point.dy - handleAnchor.dy,
handleSize.width,
handleSize.height,
);
// Make sure the GestureDetector is big enough to be easily interactive.
final Rect interactiveRect = handleRect.expandToInclude(
Rect.fromCircle(center: handleRect.center, radius: kMinInteractiveSize / 2),
);
final RelativeRect padding = RelativeRect.fromLTRB(
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
);
return CompositedTransformFollower( return CompositedTransformFollower(
link: widget.layerLink, link: widget.layerLink,
offset: interactiveRect.topLeft,
showWhenUnlinked: false, showWhenUnlinked: false,
child: FadeTransition( child: FadeTransition(
opacity: _opacity, opacity: _opacity,
child: Container(
alignment: Alignment.topLeft,
width: interactiveRect.width,
height: interactiveRect.height,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent,
dragStartBehavior: widget.dragStartBehavior, dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart, onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate, onPanUpdate: _handleDragUpdate,
onTap: _handleTap, onTap: _handleTap,
child: Stack( child: Padding(
// Always let the selection handles draw outside of the conceptual padding: EdgeInsets.only(
// box where (0,0) is the top left corner of the RenderEditable. left: padding.left,
overflow: Overflow.visible, top: padding.top,
children: <Widget>[ right: padding.right,
Positioned( bottom: padding.bottom,
left: point.dx, ),
top: point.dy,
child: widget.selectionControls.buildHandle( child: widget.selectionControls.buildHandle(
context, context,
type, type,
widget.renderObject.preferredLineHeight, widget.renderObject.preferredLineHeight,
), ),
), ),
],
), ),
), ),
), ),
...@@ -944,9 +988,12 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec ...@@ -944,9 +988,12 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( // Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector
() => TapGestureRecognizer(debugOwner: this), // can receive the same tap events that a selection handle placed visually
(TapGestureRecognizer instance) { // on top of it also receives.
gestures[_TransparentTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
() => _TransparentTapGestureRecognizer(debugOwner: this),
(_TransparentTapGestureRecognizer instance) {
instance instance
..onTapDown = _handleTapDown ..onTapDown = _handleTapDown
..onTapUp = _handleTapUp ..onTapUp = _handleTapUp
...@@ -1006,3 +1053,32 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec ...@@ -1006,3 +1053,32 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
); );
} }
} }
// A TapGestureRecognizer which allows other GestureRecognizers to win in the
// GestureArena. This means both _TransparentTapGestureRecognizer and other
// GestureRecognizers can handle the same event.
//
// This enables proper handling of events on both the selection handle and the
// underlying input, since there is significant overlap between the two given
// the handle's padded hit area. For example, the selection handle needs to
// handle single taps on itself, but double taps need to be handled by the
// underlying input.
class _TransparentTapGestureRecognizer extends TapGestureRecognizer {
_TransparentTapGestureRecognizer({
Object debugOwner,
}) : super(debugOwner: debugOwner);
@override
void rejectGesture(int pointer) {
// Accept new gestures that another recognizer has already won.
// Specifically, this needs to accept taps on the text selection handle on
// behalf of the text field in order to handle double tap to select. It must
// not accept other gestures like longpresses and drags that end outside of
// the text field.
if (state == GestureRecognizerState.ready) {
acceptGesture(pointer);
} else {
super.rejectGesture(pointer);
}
}
}
...@@ -1269,6 +1269,49 @@ void main() { ...@@ -1269,6 +1269,49 @@ void main() {
}, },
); );
testWidgets(
'double tap selects word and first tap of double tap moves cursor',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
),
),
),
);
// Long press to put the cursor after the "w".
const int index = 3;
final TestGesture gesture =
await tester.startGesture(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 500));
await gesture.up();
await tester.pump();
expect(
controller.selection,
const TextSelection.collapsed(offset: index),
);
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
testWidgets( testWidgets(
'double tap selects word and first tap of double tap moves cursor', 'double tap selects word and first tap of double tap moves cursor',
(WidgetTester tester) async { (WidgetTester tester) async {
......
...@@ -733,6 +733,13 @@ void main() { ...@@ -733,6 +733,13 @@ void main() {
// 'def' is selected. // 'def' is selected.
expect(controller.selection.baseOffset, testValue.indexOf('d')); expect(controller.selection.baseOffset, testValue.indexOf('d'));
expect(controller.selection.extentOffset, testValue.indexOf('f')+1); expect(controller.selection.extentOffset, testValue.indexOf('f')+1);
// Tapping elsewhere immediately collapses and moves the cursor.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('h')));
await tester.pump();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('h'));
}); });
testWidgets('Slight movements in longpress don\'t hide/show handles', (WidgetTester tester) async { testWidgets('Slight movements in longpress don\'t hide/show handles', (WidgetTester tester) async {
...@@ -1032,7 +1039,7 @@ void main() { ...@@ -1032,7 +1039,7 @@ void main() {
// We use a small offset because the endpoint is on the very corner // We use a small offset because the endpoint is on the very corner
// of the handle. // of the handle.
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
Offset newHandlePos = textOffsetToPosition(tester, 9); // Position of 'h'. Offset newHandlePos = textOffsetToPosition(tester, testValue.length);
gesture = await tester.startGesture(handlePos, pointer: 7); gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump(); await tester.pump();
await gesture.moveTo(newHandlePos); await gesture.moveTo(newHandlePos);
...@@ -1041,11 +1048,11 @@ void main() { ...@@ -1041,11 +1048,11 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection.baseOffset, 4); expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 9); expect(controller.selection.extentOffset, 11);
// Drag the left handle 2 letters to the left. // Drag the left handle 2 letters to the left.
handlePos = endpoints[0].point + const Offset(-1.0, 1.0); handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'. newHandlePos = textOffsetToPosition(tester, 0);
gesture = await tester.startGesture(handlePos, pointer: 7); gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump(); await tester.pump();
await gesture.moveTo(newHandlePos); await gesture.moveTo(newHandlePos);
...@@ -1053,8 +1060,8 @@ void main() { ...@@ -1053,8 +1060,8 @@ void main() {
await gesture.up(); await gesture.up();
await tester.pump(); await tester.pump();
expect(controller.selection.baseOffset, 2); expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 9); expect(controller.selection.extentOffset, 11);
}); });
testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
...@@ -1074,7 +1081,7 @@ void main() { ...@@ -1074,7 +1081,7 @@ void main() {
await skipPastScrollingAnimation(tester); await skipPastScrollingAnimation(tester);
// Long press the 'e' to select 'def'. // Long press the 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, 5); // Position of 'e'. final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'.
TestGesture gesture = await tester.startGesture(ePos, pointer: 7); TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(seconds: 2));
await gesture.up(); await gesture.up();
...@@ -1095,8 +1102,8 @@ void main() { ...@@ -1095,8 +1102,8 @@ void main() {
// Drag the right handle until there's only 1 char selected. // Drag the right handle until there's only 1 char selected.
// We use a small offset because the endpoint is on the very corner // We use a small offset because the endpoint is on the very corner
// of the handle. // of the handle.
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0);
Offset newHandlePos = textOffsetToPosition(tester, 5); // Position of 'e'. Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'.
gesture = await tester.startGesture(handlePos, pointer: 7); gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump(); await tester.pump();
await gesture.moveTo(newHandlePos); await gesture.moveTo(newHandlePos);
...@@ -1105,7 +1112,7 @@ void main() { ...@@ -1105,7 +1112,7 @@ void main() {
expect(controller.selection.baseOffset, 4); expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 5); expect(controller.selection.extentOffset, 5);
newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'. newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'.
await gesture.moveTo(newHandlePos); await gesture.moveTo(newHandlePos);
await tester.pump(); await tester.pump();
await gesture.up(); await gesture.up();
...@@ -1141,7 +1148,10 @@ void main() { ...@@ -1141,7 +1148,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection), renderEditable.getEndpointsForSelection(controller.selection),
renderEditable, renderEditable,
); );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); // Tapping on the part of the handle's GestureDetector where it overlaps
// with the text itself does not show the menu, so add a small vertical
// offset to tap below the text.
await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
...@@ -1159,7 +1169,11 @@ void main() { ...@@ -1159,7 +1169,11 @@ void main() {
// Tap again to bring back the menu. // Tap again to bring back the menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero // Allow time for handle to appear and double tap to time out.
await tester.pump(const Duration(milliseconds: 300));
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('e'));
renderEditable = findRenderEditable(tester); renderEditable = findRenderEditable(tester);
endpoints = globalize( endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection), renderEditable.getEndpointsForSelection(controller.selection),
...@@ -1168,6 +1182,9 @@ void main() { ...@@ -1168,6 +1182,9 @@ void main() {
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('e'));
// PASTE right before the 'e'. // PASTE right before the 'e'.
await tester.tap(find.text('PASTE')); await tester.tap(find.text('PASTE'));
...@@ -1207,7 +1224,10 @@ void main() { ...@@ -1207,7 +1224,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection), renderEditable.getEndpointsForSelection(controller.selection),
renderEditable, renderEditable,
); );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); // Tapping on the part of the handle's GestureDetector where it overlaps
// with the text itself does not show the menu, so add a small vertical
// offset to tap below the text.
await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
...@@ -1240,7 +1260,10 @@ void main() { ...@@ -1240,7 +1260,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection), renderEditable.getEndpointsForSelection(controller.selection),
renderEditable, renderEditable,
); );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); // Tapping on the part of the handle's GestureDetector where it overlaps
// with the text itself does not show the menu, so add a small vertical
// offset to tap below the text.
await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
...@@ -1269,7 +1292,8 @@ void main() { ...@@ -1269,7 +1292,8 @@ void main() {
// Tap the selection handle to bring up the "paste / select all" menu. // Tap the selection handle to bring up the "paste / select all" menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero // Allow time for the handle to appear and for a double tap to time out.
await tester.pump(const Duration(milliseconds: 600));
final RenderEditable renderEditable = findRenderEditable(tester); final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize( final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection), renderEditable.getEndpointsForSelection(controller.selection),
...@@ -4926,6 +4950,56 @@ void main() { ...@@ -4926,6 +4950,56 @@ void main() {
}, },
); );
testWidgets(
'double tap on top of cursor also selects word (Android)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
// Tap to put the cursor after the "w".
const int index = 3;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.selection,
const TextSelection.collapsed(offset: index),
);
// Double tap on the same location.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
// First tap doesn't change the selection
expect(
controller.selection,
const TextSelection.collapsed(offset: index),
);
// Second tap selects the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Selected text shows 4 toolbar buttons: cut, copy, paste, select all
expect(find.byType(FlatButton), findsNWidgets(4));
},
);
testWidgets( testWidgets(
'double tap hold selects word (iOS)', 'double tap hold selects word (iOS)',
(WidgetTester tester) async { (WidgetTester tester) async {
......
...@@ -1961,29 +1961,51 @@ void main() { ...@@ -1961,29 +1961,51 @@ void main() {
// Check that the handles' positions are correct. // Check that the handles' positions are correct.
final List<Positioned> positioned = final List<CompositedTransformFollower> container =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList(); find.byType(CompositedTransformFollower)
.evaluate()
.map((Element e) => e.widget)
.cast<CompositedTransformFollower>()
.toList();
final Size viewport = renderEditable.size; final Size viewport = renderEditable.size;
void testPosition(double pos, HandlePositionInViewport expected) { void testPosition(double pos, HandlePositionInViewport expected) {
switch (expected) { switch (expected) {
case HandlePositionInViewport.leftEdge: case HandlePositionInViewport.leftEdge:
expect(pos, equals(0.0)); expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
0 + kMinInteractiveSize,
),
);
break; break;
case HandlePositionInViewport.rightEdge: case HandlePositionInViewport.rightEdge:
expect(pos, equals(viewport.width)); expect(
pos,
inExclusiveRange(
viewport.width - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break; break;
case HandlePositionInViewport.within: case HandlePositionInViewport.within:
expect(pos, inExclusiveRange(0.0, viewport.width)); expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break; break;
default: default:
throw TestFailure('HandlePositionInViewport can\'t be null.'); throw TestFailure('HandlePositionInViewport can\'t be null.');
} }
} }
testPosition(positioned[0].left, leftPosition); testPosition(container[0].offset.dx, leftPosition);
testPosition(positioned[1].left, rightPosition); testPosition(container[1].offset.dx, rightPosition);
} }
// Select the first word. Both handles should be visible. // Select the first word. Both handles should be visible.
...@@ -2058,10 +2080,26 @@ void main() { ...@@ -2058,10 +2080,26 @@ void main() {
state.renderEditable.selectWord(cause: SelectionChangedCause.longPress); state.renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles(); state.showHandles();
await tester.pump(); await tester.pump();
final List<Positioned> positioned = final List<CompositedTransformFollower> container =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList(); find.byType(CompositedTransformFollower)
expect(positioned[0].left, 0.0); .evaluate()
expect(positioned[1].left, 70.0); .map((Element e) => e.widget)
.cast<CompositedTransformFollower>()
.toList();
expect(
container[0].offset.dx,
inExclusiveRange(
-kMinInteractiveSize,
kMinInteractiveSize,
),
);
expect(
container[1].offset.dx,
inExclusiveRange(
70.0 - kMinInteractiveSize,
70.0 + kMinInteractiveSize,
),
);
expect(controller.selection.base.offset, 0); expect(controller.selection.base.offset, 0);
expect(controller.selection.extent.offset, 5); expect(controller.selection.extent.offset, 5);
}); });
...@@ -2148,29 +2186,51 @@ void main() { ...@@ -2148,29 +2186,51 @@ void main() {
// Check that the handles' positions are correct. // Check that the handles' positions are correct.
final List<Positioned> positioned = final List<CompositedTransformFollower> container =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList(); find.byType(CompositedTransformFollower)
.evaluate()
.map((Element e) => e.widget)
.cast<CompositedTransformFollower>()
.toList();
final Size viewport = renderEditable.size; final Size viewport = renderEditable.size;
void testPosition(double pos, HandlePositionInViewport expected) { void testPosition(double pos, HandlePositionInViewport expected) {
switch (expected) { switch (expected) {
case HandlePositionInViewport.leftEdge: case HandlePositionInViewport.leftEdge:
expect(pos, equals(0.0)); expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
0 + kMinInteractiveSize,
),
);
break; break;
case HandlePositionInViewport.rightEdge: case HandlePositionInViewport.rightEdge:
expect(pos, equals(viewport.width)); expect(
pos,
inExclusiveRange(
viewport.width - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break; break;
case HandlePositionInViewport.within: case HandlePositionInViewport.within:
expect(pos, inExclusiveRange(0.0, viewport.width)); expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break; break;
default: default:
throw TestFailure('HandlePositionInViewport can\'t be null.'); throw TestFailure('HandlePositionInViewport can\'t be null.');
} }
} }
testPosition(positioned[1].left, leftPosition); testPosition(container[0].offset.dx, leftPosition);
testPosition(positioned[2].left, rightPosition); testPosition(container[1].offset.dx, rightPosition);
} }
// Select the first word. Both handles should be visible. // Select the first word. Both handles should be visible.
...@@ -2216,7 +2276,17 @@ void main() { ...@@ -2216,7 +2276,17 @@ void main() {
}); });
} }
class MockTextSelectionControls extends Mock implements TextSelectionControls {} class MockTextSelectionControls extends Mock implements TextSelectionControls {
@override
Size getHandleSize(double textLineHeight) {
return Size.zero;
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
return Offset.zero;
}
}
class CustomStyleEditableText extends EditableText { class CustomStyleEditableText extends EditableText {
CustomStyleEditableText({ CustomStyleEditableText({
......
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