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.
This commit is contained in:
Justin McCandless 2019-05-30 12:31:18 -07:00 committed by GitHub
parent 8d658d4fa2
commit d963e4fe35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 469 additions and 175 deletions

View file

@ -786,47 +786,61 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
final Brightness keyboardAppearance = widget.keyboardAppearance ?? themeData.brightness;
final Color cursorColor = widget.cursorColor ?? themeData.primaryColor;
final Widget paddedEditable = Padding(
padding: widget.padding,
child: RepaintBoundary(
child: EditableText(
key: _editableTextKey,
controller: controller,
focusNode: _effectiveFocusNode,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: textStyle,
strutStyle: widget.strutStyle,
textAlign: widget.textAlign,
autofocus: widget.autofocus,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
selectionColor: _kSelectionHighlightColor,
selectionControls: widget.selectionEnabled
? cupertinoTextSelectionControls : null,
onChanged: widget.onChanged,
onSelectionChanged: _handleSelectionChanged,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
inputFormatters: formatters,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorColor: cursorColor,
cursorOpacityAnimates: true,
cursorOffset: cursorOffset,
paintCursorAboveText: true,
backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
dragStartBehavior: widget.dragStartBehavior,
scrollController: widget.scrollController,
scrollPhysics: widget.scrollPhysics,
enableInteractiveSelection: widget.enableInteractiveSelection,
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,
child: RepaintBoundary(
child: EditableText(
key: _editableTextKey,
controller: controller,
focusNode: _effectiveFocusNode,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: textStyle,
strutStyle: widget.strutStyle,
textAlign: widget.textAlign,
autofocus: widget.autofocus,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
selectionColor: _kSelectionHighlightColor,
selectionControls: widget.selectionEnabled
? cupertinoTextSelectionControls : null,
onChanged: widget.onChanged,
onSelectionChanged: _handleSelectionChanged,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
inputFormatters: formatters,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorColor: cursorColor,
cursorOpacityAnimates: true,
cursorOffset: cursorOffset,
paintCursorAboveText: true,
backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
dragStartBehavior: widget.dragStartBehavior,
scrollController: widget.scrollController,
scrollPhysics: widget.scrollPhysics,
enableInteractiveSelection: widget.enableInteractiveSelection,
),
),
),
);
@ -849,21 +863,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
: CupertinoTheme.of(context).brightness == Brightness.light
? _kDisabledBackground
: 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),
),
),
),

View file

@ -11,9 +11,6 @@ import 'button.dart';
import 'colors.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
// viewport.
const double _kToolbarScreenPadding = 8.0;
@ -25,10 +22,8 @@ const Color _kToolbarDividerColor = Color(0xFFB9B9B9);
// application's theme color.
const Color _kHandlesColor = Color(0xFF136FE0);
// This offset is used to determine the center of the selection during a drag.
// It's slightly below the center of the text so the finger isn't entirely
// covering the text being selected.
const Size _kSelectionOffset = Size(20.0, 30.0);
const double _kSelectionHandleOverlap = 1.5;
const double _kSelectionHandleRadius = 5.5;
const Size _kToolbarTriangleSize = Size(18.0, 9.0);
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0);
const BorderRadius _kToolbarBorderRadius = BorderRadius.all(Radius.circular(7.5));
@ -229,40 +224,46 @@ class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
}
/// 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 {
_TextSelectionHandlePainter({this.origin});
final Offset origin;
const _TextSelectionHandlePainter();
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = _kHandlesColor
..strokeWidth = 2.0;
// Draw circle below the origin that slightly overlaps the bar.
canvas.drawCircle(origin.translate(0.0, 4.0), 5.5, paint);
// Draw up from origin leaving 10 pixels of margin on top.
canvas.drawCircle(
const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
_kSelectionHandleRadius,
paint,
);
// Draw line so it slightly overlaps the circle.
canvas.drawLine(
origin,
origin.translate(
0.0,
-(size.height - 2.0 * _kHandlesPadding),
const Offset(
_kSelectionHandleRadius,
2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
),
Offset(
_kSelectionHandleRadius,
size.height,
),
paint,
);
}
@override
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => origin != oldPainter.origin;
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => false;
}
class _CupertinoTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Cupertino handle.
@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.
@override
@ -319,22 +320,12 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
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
// padding in every direction that will constitute the selection drag area.
final Size desiredSize = Size(
2.0 * _kHandlesPadding,
textLineHeight + 2.0 * _kHandlesPadding,
);
final Size desiredSize = getHandleSize(textLineHeight);
final Widget handle = SizedBox.fromSize(
size: desiredSize,
child: CustomPaint(
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),
),
child: const CustomPaint(
painter: _TextSelectionHandlePainter(),
),
);
@ -342,27 +333,54 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
// baseline. We transform the handle such that the SizedBox is superimposed
// on top of the text selection endpoints.
switch (type) {
case TextSelectionHandleType.left: // The left handle is upside down on iOS.
return Transform(
transform: Matrix4.rotationZ(math.pi)
..translate(-_kHandlesPadding, -_kHandlesPadding),
child: handle,
);
case TextSelectionHandleType.left:
return handle;
case TextSelectionHandleType.right:
// Right handle is a vertical mirror of the left.
return Transform(
transform: Matrix4.translationValues(
-_kHandlesPadding,
-(textLineHeight + _kHandlesPadding),
0.0,
),
transform: Matrix4.identity()
..translate(desiredSize.width / 2, desiredSize.height / 2)
..rotateZ(math.pi)
..translate(-desiredSize.width / 2, -desiredSize.height / 2),
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();
}
assert(type != 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.

View file

@ -127,8 +127,9 @@ class _TextSelectionHandlePainter extends CustomPainter {
}
class _MaterialTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Material handle.
@override
Size handleSize = const Size(_kHandleSize, _kHandleSize);
Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style copy/paste text selection toolbar.
@override
@ -179,15 +180,12 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
/// Builder for material-style text selection handles.
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final Widget handle = Padding(
padding: const EdgeInsets.only(right: 26.0, bottom: 26.0),
child: SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: Theme.of(context).textSelectionHandleColor
),
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: Theme.of(context).textSelectionHandleColor
),
),
);
@ -197,15 +195,15 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
// straight up or up-right depending on the handle type.
switch (type) {
case TextSelectionHandleType.left: // points up-right
return Transform(
transform: Matrix4.rotationZ(math.pi / 2.0),
return Transform.rotate(
angle: math.pi / 2.0,
child: handle,
);
case TextSelectionHandleType.right: // points up-left
return handle;
case TextSelectionHandleType.collapsed: // points up
return Transform(
transform: Matrix4.rotationZ(math.pi / 4.0),
return Transform.rotate(
angle: math.pi / 4.0,
child: handle,
);
}
@ -213,6 +211,21 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
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
bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless

View file

@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
@ -94,6 +95,11 @@ abstract class TextSelectionControls {
/// selection position.
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.
///
/// Typically displays buttons for copying and pasting text.
@ -113,7 +119,7 @@ abstract class TextSelectionControls {
);
/// 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
/// `delegate` can be removed from the text field and placed into the
@ -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
extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin {
Offset _dragPosition;
@ -574,7 +585,10 @@ class _TextSelectionHandleOverlayState
}
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) {
@ -639,31 +653,61 @@ class _TextSelectionHandleOverlayState
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(
link: widget.layerLink,
offset: interactiveRect.topLeft,
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
child: GestureDetector(
dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: Stack(
// Always let the selection handles draw outside of the conceptual
// box where (0,0) is the top left corner of the RenderEditable.
overflow: Overflow.visible,
children: <Widget>[
Positioned(
left: point.dx,
top: point.dy,
child: widget.selectionControls.buildHandle(
context,
type,
widget.renderObject.preferredLineHeight,
),
child: Container(
alignment: Alignment.topLeft,
width: interactiveRect.width,
height: interactiveRect.height,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: Padding(
padding: EdgeInsets.only(
left: padding.left,
top: padding.top,
right: padding.right,
bottom: padding.bottom,
),
],
child: widget.selectionControls.buildHandle(
context,
type,
widget.renderObject.preferredLineHeight,
),
),
),
),
),
@ -944,9 +988,12 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
// Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector
// can receive the same tap events that a selection handle placed visually
// on top of it also receives.
gestures[_TransparentTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
() => _TransparentTapGestureRecognizer(debugOwner: this),
(_TransparentTapGestureRecognizer instance) {
instance
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
@ -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);
}
}
}

View file

@ -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(
'double tap selects word and first tap of double tap moves cursor',
(WidgetTester tester) async {

View file

@ -733,6 +733,13 @@ void main() {
// 'def' is selected.
expect(controller.selection.baseOffset, testValue.indexOf('d'));
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 {
@ -1032,7 +1039,7 @@ void main() {
// We use a small offset because the endpoint is on the very corner
// of the handle.
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);
await tester.pump();
await gesture.moveTo(newHandlePos);
@ -1041,11 +1048,11 @@ void main() {
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 9);
expect(controller.selection.extentOffset, 11);
// Drag the left handle 2 letters to the left.
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);
await tester.pump();
await gesture.moveTo(newHandlePos);
@ -1053,8 +1060,8 @@ void main() {
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 9);
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 11);
});
testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
@ -1074,7 +1081,7 @@ void main() {
await skipPastScrollingAnimation(tester);
// 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);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
@ -1095,8 +1102,8 @@ void main() {
// Drag the right handle until there's only 1 char selected.
// We use a small offset because the endpoint is on the very corner
// of the handle.
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
Offset newHandlePos = textOffsetToPosition(tester, 5); // Position of 'e'.
final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0);
Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'.
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
@ -1105,7 +1112,7 @@ void main() {
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 5);
newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'.
newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'.
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
@ -1141,7 +1148,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection),
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(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
@ -1159,7 +1169,11 @@ void main() {
// Tap again to bring back the 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
// 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);
endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
@ -1168,6 +1182,9 @@ void main() {
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
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('e'));
// PASTE right before the 'e'.
await tester.tap(find.text('PASTE'));
@ -1207,7 +1224,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection),
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(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
@ -1240,7 +1260,10 @@ void main() {
renderEditable.getEndpointsForSelection(controller.selection),
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(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
@ -1269,7 +1292,8 @@ void main() {
// 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
// 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 List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
@ -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(
'double tap hold selects word (iOS)',
(WidgetTester tester) async {

View file

@ -1961,29 +1961,51 @@ void main() {
// Check that the handles' positions are correct.
final List<Positioned> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
final List<CompositedTransformFollower> container =
find.byType(CompositedTransformFollower)
.evaluate()
.map((Element e) => e.widget)
.cast<CompositedTransformFollower>()
.toList();
final Size viewport = renderEditable.size;
void testPosition(double pos, HandlePositionInViewport expected) {
switch (expected) {
case HandlePositionInViewport.leftEdge:
expect(pos, equals(0.0));
expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
0 + kMinInteractiveSize,
),
);
break;
case HandlePositionInViewport.rightEdge:
expect(pos, equals(viewport.width));
expect(
pos,
inExclusiveRange(
viewport.width - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break;
case HandlePositionInViewport.within:
expect(pos, inExclusiveRange(0.0, viewport.width));
expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break;
default:
throw TestFailure('HandlePositionInViewport can\'t be null.');
}
}
testPosition(positioned[0].left, leftPosition);
testPosition(positioned[1].left, rightPosition);
testPosition(container[0].offset.dx, leftPosition);
testPosition(container[1].offset.dx, rightPosition);
}
// Select the first word. Both handles should be visible.
@ -2058,10 +2080,26 @@ void main() {
state.renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
final List<Positioned> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
expect(positioned[0].left, 0.0);
expect(positioned[1].left, 70.0);
final List<CompositedTransformFollower> container =
find.byType(CompositedTransformFollower)
.evaluate()
.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.extent.offset, 5);
});
@ -2148,29 +2186,51 @@ void main() {
// Check that the handles' positions are correct.
final List<Positioned> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
final List<CompositedTransformFollower> container =
find.byType(CompositedTransformFollower)
.evaluate()
.map((Element e) => e.widget)
.cast<CompositedTransformFollower>()
.toList();
final Size viewport = renderEditable.size;
void testPosition(double pos, HandlePositionInViewport expected) {
switch (expected) {
case HandlePositionInViewport.leftEdge:
expect(pos, equals(0.0));
expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
0 + kMinInteractiveSize,
),
);
break;
case HandlePositionInViewport.rightEdge:
expect(pos, equals(viewport.width));
expect(
pos,
inExclusiveRange(
viewport.width - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break;
case HandlePositionInViewport.within:
expect(pos, inExclusiveRange(0.0, viewport.width));
expect(
pos,
inExclusiveRange(
0 - kMinInteractiveSize,
viewport.width + kMinInteractiveSize,
),
);
break;
default:
throw TestFailure('HandlePositionInViewport can\'t be null.');
}
}
testPosition(positioned[1].left, leftPosition);
testPosition(positioned[2].left, rightPosition);
testPosition(container[0].offset.dx, leftPosition);
testPosition(container[1].offset.dx, rightPosition);
}
// Select the first word. Both handles should be visible.
@ -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 {
CustomStyleEditableText({