mirror of
https://github.com/flutter/flutter
synced 2024-10-13 19:52:53 +00:00
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:
parent
8d658d4fa2
commit
d963e4fe35
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Reference in a new issue