From d963e4fe354612ebab093dc5202216325888fe34 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 30 May 2019 12:31:18 -0700 Subject: [PATCH] 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. --- .../flutter/lib/src/cupertino/text_field.dart | 112 ++++++++-------- .../lib/src/cupertino/text_selection.dart | 114 +++++++++------- .../lib/src/material/text_selection.dart | 41 ++++-- .../lib/src/widgets/text_selection.dart | 124 ++++++++++++++---- .../test/cupertino/text_field_test.dart | 43 ++++++ .../test/material/text_field_test.dart | 102 ++++++++++++-- .../test/widgets/editable_text_test.dart | 108 ++++++++++++--- 7 files changed, 469 insertions(+), 175 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 69061d40ea5..09ae55d9fae 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -786,47 +786,61 @@ class _CupertinoTextFieldState extends State 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 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), ), ), ), diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index 015e0dd9647..52393d525ed 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -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. diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index b50ff0cb335..31b3a4dc012 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index fd9660bcfbd..b65b4f9eead 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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: [ - 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 gestures = {}; - gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( - () => 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 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 { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index be24411c9a0..5218a68ab34 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1961,29 +1961,51 @@ void main() { // Check that the handles' positions are correct. - final List positioned = - find.byType(Positioned).evaluate().map((Element e) => e.widget).cast().toList(); + final List container = + find.byType(CompositedTransformFollower) + .evaluate() + .map((Element e) => e.widget) + .cast() + .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 = - find.byType(Positioned).evaluate().map((Element e) => e.widget).cast().toList(); - expect(positioned[0].left, 0.0); - expect(positioned[1].left, 70.0); + final List container = + find.byType(CompositedTransformFollower) + .evaluate() + .map((Element e) => e.widget) + .cast() + .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 = - find.byType(Positioned).evaluate().map((Element e) => e.widget).cast().toList(); + final List container = + find.byType(CompositedTransformFollower) + .evaluate() + .map((Element e) => e.widget) + .cast() + .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({