implement selectable text (#34019)
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index d6589e5..edeaa36 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -93,6 +93,7 @@
export 'src/material/scaffold.dart';
export 'src/material/scrollbar.dart';
export 'src/material/search.dart';
+export 'src/material/selectable_text.dart';
export 'src/material/shadows.dart';
export 'src/material/slider.dart';
export 'src/material/slider_theme.dart';
diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart
new file mode 100644
index 0000000..64d6aef
--- /dev/null
+++ b/packages/flutter/lib/src/material/selectable_text.dart
@@ -0,0 +1,580 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+
+import 'feedback.dart';
+import 'text_selection.dart';
+import 'theme.dart';
+
+/// An eyeballed value that moves the cursor slightly left of where it is
+/// rendered for text on Android so its positioning more accurately matches the
+/// native iOS text cursor positioning.
+///
+/// This value is in device pixels, not logical pixels as is typically used
+/// throughout the codebase.
+const int iOSHorizontalOffset = -2;
+
+class _TextSpanEditingController extends TextEditingController {
+ _TextSpanEditingController({@required TextSpan textSpan}):
+ assert(textSpan != null),
+ _textSpan = textSpan,
+ super(text: textSpan.toPlainText());
+
+ final TextSpan _textSpan;
+
+ @override
+ TextSpan buildTextSpan({TextStyle style ,bool withComposing}) {
+ // TODO(chunhtai): Implement composing.
+ return TextSpan(
+ style: style,
+ children: <TextSpan>[_textSpan],
+ );
+ }
+
+ @override
+ set text(String newText) {
+ // TODO(chunhtai): Implement value editing.
+ }
+}
+
+class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
+ _SelectableTextSelectionGestureDetectorBuilder({
+ @required _SelectableTextState state
+ }) : _state = state,
+ super(delegate: state);
+
+ final _SelectableTextState _state;
+
+ @override
+ void onForcePressStart(ForcePressDetails details) {
+ super.onForcePressStart(details);
+ if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
+ editableText.showToolbar();
+ }
+ }
+
+ @override
+ void onForcePressEnd(ForcePressDetails details) {
+ // Not required.
+ }
+
+ @override
+ void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
+ if (delegate.selectionEnabled) {
+ switch (Theme.of(_state.context).platform) {
+ case TargetPlatform.iOS:
+ renderEditable.selectPositionAt(
+ from: details.globalPosition,
+ cause: SelectionChangedCause.longPress,
+ );
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ renderEditable.selectWordsInRange(
+ from: details.globalPosition - details.offsetFromOrigin,
+ to: details.globalPosition,
+ cause: SelectionChangedCause.longPress,
+ );
+ break;
+ }
+ }
+ }
+
+ @override
+ void onSingleTapUp(TapUpDetails details) {
+ editableText.hideToolbar();
+ if (delegate.selectionEnabled) {
+ switch (Theme.of(_state.context).platform) {
+ case TargetPlatform.iOS:
+ renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ renderEditable.selectPosition(cause: SelectionChangedCause.tap);
+ break;
+ }
+ }
+ if (_state.widget.onTap != null)
+ _state.widget.onTap();
+ }
+
+ @override
+ void onSingleLongTapStart(LongPressStartDetails details) {
+ if (delegate.selectionEnabled) {
+ switch (Theme.of(_state.context).platform) {
+ case TargetPlatform.iOS:
+ renderEditable.selectPositionAt(
+ from: details.globalPosition,
+ cause: SelectionChangedCause.longPress,
+ );
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ Feedback.forLongPress(_state.context);
+ break;
+ }
+ }
+ }
+}
+
+/// A run of selectable text with a single style.
+///
+/// The [SelectableText] widget displays a string of text with a single style.
+/// The string might break across multiple lines or might all be displayed on
+/// the same line depending on the layout constraints.
+///
+/// The [style] argument is optional. When omitted, the text will use the style
+/// from the closest enclosing [DefaultTextStyle]. If the given style's
+/// [TextStyle.inherit] property is true (the default), the given style will
+/// be merged with the closest enclosing [DefaultTextStyle]. This merging
+/// behavior is useful, for example, to make the text bold while using the
+/// default font family and size.
+///
+/// {@tool sample}
+///
+/// ```dart
+/// SelectableText(
+/// 'Hello! How are you?',
+/// textAlign: TextAlign.center,
+/// style: TextStyle(fontWeight: FontWeight.bold),
+/// )
+/// ```
+/// {@end-tool}
+///
+/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can
+/// display a paragraph with differently styled [TextSpan]s. The sample
+/// that follows displays "Hello beautiful world" with different styles
+/// for each word.
+///
+/// {@tool sample}
+///
+/// ```dart
+/// const SelectableText.rich(
+/// TextSpan(
+/// text: 'Hello', // default text style
+/// children: <TextSpan>[
+/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)),
+/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)),
+/// ],
+/// ),
+/// )
+/// ```
+/// {@end-tool}
+///
+/// ## Interactivity
+///
+/// To make [SelectableText] react to touch events, use callback [onTap] to achieve
+/// the desired behavior.
+///
+/// See also:
+///
+/// * [Text], which is the non selectable version of this widget.
+/// * [TextField], which is the editable version of this widget.
+class SelectableText extends StatefulWidget {
+ /// Creates a selectable text widget.
+ ///
+ /// If the [style] argument is null, the text will use the style from the
+ /// closest enclosing [DefaultTextStyle].
+ ///
+ /// The [data] parameter must not be null.
+ const SelectableText(
+ this.data, {
+ Key key,
+ this.focusNode,
+ this.style,
+ this.strutStyle,
+ this.textAlign,
+ this.textDirection,
+ this.showCursor = false,
+ this.autofocus = false,
+ this.maxLines,
+ this.cursorWidth = 2.0,
+ this.cursorRadius,
+ this.cursorColor,
+ this.dragStartBehavior = DragStartBehavior.start,
+ this.enableInteractiveSelection = true,
+ this.onTap,
+ this.scrollPhysics,
+ this.textWidthBasis,
+ }) : assert(showCursor != null),
+ assert(autofocus != null),
+ assert(dragStartBehavior != null),
+ assert(maxLines == null || maxLines > 0),
+ assert(
+ data != null,
+ 'A non-null String must be provided to a SelectableText widget.',
+ ),
+ textSpan = null,
+ super(key: key);
+
+ /// Creates a selectable text widget with a [TextSpan].
+ ///
+ /// The [textSpan] parameter must not be null and only contain [TextSpan] in
+ /// [textSpan.children]. Other type of [InlineSpan] is not allowed.
+ const SelectableText.rich(
+ this.textSpan, {
+ Key key,
+ this.focusNode,
+ this.style,
+ this.strutStyle,
+ this.textAlign,
+ this.textDirection,
+ this.showCursor = false,
+ this.autofocus = false,
+ this.maxLines,
+ this.cursorWidth = 2.0,
+ this.cursorRadius,
+ this.cursorColor,
+ this.dragStartBehavior = DragStartBehavior.start,
+ this.enableInteractiveSelection = true,
+ this.onTap,
+ this.scrollPhysics,
+ this.textWidthBasis,
+ }) : assert(showCursor != null),
+ assert(autofocus != null),
+ assert(dragStartBehavior != null),
+ assert(maxLines == null || maxLines > 0),
+ assert(
+ textSpan != null,
+ 'A non-null TextSpan must be provided to a SelectableText.rich widget.',
+ ),
+ data = null,
+ super(key: key);
+
+ /// The text to display.
+ ///
+ /// This will be null if a [textSpan] is provided instead.
+ final String data;
+
+ /// The text to display as a [TextSpan].
+ ///
+ /// This will be null if [data] is provided instead.
+ final TextSpan textSpan;
+
+ /// Defines the focus for this widget.
+ ///
+ /// Text is only selectable when widget is focused.
+ ///
+ /// The [focusNode] is a long-lived object that's typically managed by a
+ /// [StatefulWidget] parent. See [FocusNode] for more information.
+ ///
+ /// To give the focus to this widget, provide a [focusNode] and then
+ /// use the current [FocusScope] to request the focus:
+ ///
+ /// ```dart
+ /// FocusScope.of(context).requestFocus(myFocusNode);
+ /// ```
+ ///
+ /// This happens automatically when the widget is tapped.
+ ///
+ /// To be notified when the widget gains or loses the focus, add a listener
+ /// to the [focusNode]:
+ ///
+ /// ```dart
+ /// focusNode.addListener(() { print(myFocusNode.hasFocus); });
+ /// ```
+ ///
+ /// If null, this widget will create its own [FocusNode].
+ final FocusNode focusNode;
+
+ /// The style to use for the text.
+ ///
+ /// If null, defaults [DefaultTextStyle] of context.
+ final TextStyle style;
+
+ /// {@macro flutter.widgets.editableText.strutStyle}
+ final StrutStyle strutStyle;
+
+ /// {@macro flutter.widgets.editableText.textAlign}
+ final TextAlign textAlign;
+
+ /// {@macro flutter.widgets.editableText.textDirection}
+ final TextDirection textDirection;
+
+ /// {@macro flutter.widgets.editableText.autofocus}
+ final bool autofocus;
+
+ /// {@macro flutter.widgets.editableText.maxLines}
+ final int maxLines;
+
+ /// {@macro flutter.widgets.editableText.showCursor}
+ final bool showCursor;
+
+ /// {@macro flutter.widgets.editableText.cursorWidth}
+ final double cursorWidth;
+
+ /// {@macro flutter.widgets.editableText.cursorRadius}
+ final Radius cursorRadius;
+
+ /// The color to use when painting the cursor.
+ ///
+ /// Defaults to the theme's `cursorColor` when null.
+ final Color cursorColor;
+
+ /// {@macro flutter.widgets.editableText.enableInteractiveSelection}
+ final bool enableInteractiveSelection;
+
+ /// {@macro flutter.widgets.scrollable.dragStartBehavior}
+ final DragStartBehavior dragStartBehavior;
+
+ /// {@macro flutter.rendering.editable.selectionEnabled}
+ bool get selectionEnabled {
+ return enableInteractiveSelection;
+ }
+
+ /// Called when the user taps on this selectable text.
+ ///
+ /// The selectable text builds a [GestureDetector] to handle input events like tap,
+ /// to trigger focus requests, to move the caret, adjust the selection, etc.
+ /// Handling some of those events by wrapping the selectable text with a competing
+ /// GestureDetector is problematic.
+ ///
+ /// To unconditionally handle taps, without interfering with the selectable text's
+ /// internal gesture detector, provide this callback.
+ ///
+ /// To be notified when the text field gains or loses the focus, provide a
+ /// [focusNode] and add a listener to that.
+ ///
+ /// To listen to arbitrary pointer events without competing with the
+ /// selectable text's internal gesture detector, use a [Listener].
+ final GestureTapCallback onTap;
+
+ /// {@macro flutter.widgets.edtiableText.scrollPhysics}
+ final ScrollPhysics scrollPhysics;
+
+ /// {@macro flutter.dart:ui.text.TextWidthBasis}
+ final TextWidthBasis textWidthBasis;
+
+ @override
+ _SelectableTextState createState() => _SelectableTextState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
+ properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
+ properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+ properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+ properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
+ properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+ properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+ properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
+ properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
+ properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
+ properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
+ properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
+ properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
+ }
+}
+
+class _SelectableTextState extends State<SelectableText> with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate {
+ EditableTextState get _editableText => editableTextKey.currentState;
+
+ _TextSpanEditingController _controller;
+
+ FocusNode _focusNode;
+ FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
+
+ bool _showSelectionHandles = false;
+
+ _SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
+
+ // API for TextSelectionGestureDetectorBuilderDelegate.
+ @override
+ bool forcePressEnabled;
+
+ @override
+ final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
+
+ @override
+ bool get selectionEnabled => widget.selectionEnabled;
+ // End of API for TextSelectionGestureDetectorBuilderDelegate.
+
+ @override
+ void initState() {
+ super.initState();
+ _selectionGestureDetectorBuilder = _SelectableTextSelectionGestureDetectorBuilder(state: this);
+ _controller = _TextSpanEditingController(
+ textSpan: widget.textSpan ?? TextSpan(text: widget.data)
+ );
+ }
+
+ @override
+ void didUpdateWidget(SelectableText oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) {
+ _controller = _TextSpanEditingController(
+ textSpan: widget.textSpan ?? TextSpan(text: widget.data)
+ );
+ }
+ if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
+ _showSelectionHandles = false;
+ }
+ }
+
+ @override
+ void dispose() {
+ _focusNode?.dispose();
+ super.dispose();
+ }
+
+ void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
+ final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
+ if (willShowSelectionHandles != _showSelectionHandles) {
+ setState(() {
+ _showSelectionHandles = willShowSelectionHandles;
+ });
+ }
+
+ switch (Theme.of(context).platform) {
+ case TargetPlatform.iOS:
+ if (cause == SelectionChangedCause.longPress) {
+ _editableText?.bringIntoView(selection.base);
+ }
+ return;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ // Do nothing.
+ }
+ }
+
+ /// Toggle the toolbar when a selection handle is tapped.
+ void _handleSelectionHandleTapped() {
+ if (_controller.selection.isCollapsed) {
+ _editableText.toggleToolbar();
+ }
+ }
+
+ bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
+ // When the text field is activated by something that doesn't trigger the
+ // selection overlay, we shouldn't show the handles either.
+ if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar)
+ return false;
+
+ if (_controller.selection.isCollapsed)
+ return false;
+
+ if (cause == SelectionChangedCause.keyboard)
+ return false;
+
+ if (cause == SelectionChangedCause.longPress)
+ return true;
+
+ if (_controller.text.isNotEmpty)
+ return true;
+
+ return false;
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context); // See AutomaticKeepAliveClientMixin.
+ assert(() {
+ return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan);
+ }(), 'SelectableText only supports TextSpan; Other type of InlineSpan is not allowed');
+ assert(debugCheckHasMediaQuery(context));
+ assert(debugCheckHasDirectionality(context));
+ assert(
+ !(widget.style != null && widget.style.inherit == false &&
+ (widget.style.fontSize == null || widget.style.textBaseline == null)),
+ 'inherit false style must supply fontSize and textBaseline',
+ );
+
+ final ThemeData themeData = Theme.of(context);
+ final FocusNode focusNode = _effectiveFocusNode;
+
+ TextSelectionControls textSelectionControls;
+ bool paintCursorAboveText;
+ bool cursorOpacityAnimates;
+ Offset cursorOffset;
+ Color cursorColor = widget.cursorColor;
+ Radius cursorRadius = widget.cursorRadius;
+
+ switch (themeData.platform) {
+ case TargetPlatform.iOS:
+ forcePressEnabled = true;
+ textSelectionControls = cupertinoTextSelectionControls;
+ paintCursorAboveText = true;
+ cursorOpacityAnimates = true;
+ cursorColor ??= CupertinoTheme.of(context).primaryColor;
+ cursorRadius ??= const Radius.circular(2.0);
+ cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
+ break;
+
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ forcePressEnabled = false;
+ textSelectionControls = materialTextSelectionControls;
+ paintCursorAboveText = false;
+ cursorOpacityAnimates = false;
+ cursorColor ??= themeData.cursorColor;
+ break;
+ }
+
+ final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
+ TextStyle effectiveTextStyle = widget.style;
+ if (widget.style == null || widget.style.inherit)
+ effectiveTextStyle = defaultTextStyle.style.merge(widget.style);
+ if (MediaQuery.boldTextOverride(context))
+ effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold));
+ final Widget child = RepaintBoundary(
+ child: EditableText(
+ key: editableTextKey,
+ style: effectiveTextStyle,
+ readOnly: true,
+ textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
+ showSelectionHandles: _showSelectionHandles,
+ showCursor: widget.showCursor,
+ controller: _controller,
+ focusNode: focusNode,
+ strutStyle: widget.strutStyle ?? StrutStyle.disabled,
+ textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
+ textDirection: widget.textDirection,
+ autofocus: widget.autofocus,
+ forceLine: false,
+ maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
+ selectionColor: themeData.textSelectionColor,
+ selectionControls: widget.selectionEnabled ? textSelectionControls : null,
+ onSelectionChanged: _handleSelectionChanged,
+ onSelectionHandleTapped: _handleSelectionHandleTapped,
+ rendererIgnoresPointer: true,
+ cursorWidth: widget.cursorWidth,
+ cursorRadius: cursorRadius,
+ cursorColor: cursorColor,
+ cursorOpacityAnimates: cursorOpacityAnimates,
+ cursorOffset: cursorOffset,
+ paintCursorAboveText: paintCursorAboveText,
+ backgroundCursorColor: CupertinoColors.inactiveGray,
+ enableInteractiveSelection: widget.enableInteractiveSelection,
+ dragStartBehavior: widget.dragStartBehavior,
+ scrollPhysics: widget.scrollPhysics,
+ ),
+ );
+
+ return Semantics(
+ onTap: () {
+ if (!_controller.selection.isValid)
+ _controller.selection = TextSelection.collapsed(offset: _controller.text.length);
+ _effectiveFocusNode.requestFocus();
+ },
+ onLongPress: () {
+ _effectiveFocusNode.requestFocus();
+ },
+ child: _selectionGestureDetectorBuilder.buildGestureDetector(
+ behavior: HitTestBehavior.translucent,
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index ffb715b..88fee77 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -17,6 +17,7 @@
import 'input_decorator.dart';
import 'material.dart';
import 'material_localizations.dart';
+import 'selectable_text.dart' show iOSHorizontalOffset;
import 'text_selection.dart';
import 'theme.dart';
@@ -932,14 +933,7 @@
cursorOpacityAnimates = true;
cursorColor ??= CupertinoTheme.of(context).primaryColor;
cursorRadius ??= const Radius.circular(2.0);
- // An eyeballed value that moves the cursor slightly left of where it is
- // rendered for text on Android so its positioning more accurately matches the
- // native iOS text cursor positioning.
- //
- // This value is in device pixels, not logical pixels as is typically used
- // throughout the codebase.
- const int _iOSHorizontalOffset = -2;
- cursorOffset = Offset(_iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
+ cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index b10e43a..e17a63a 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -652,7 +652,7 @@
final double caretEnd = box.end;
final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd;
- return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom);
+ return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom);
}
return null;
}
@@ -694,7 +694,7 @@
final TextBox box = boxes.last;
final double caretStart = box.start;
final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart;
- return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom);
+ return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom);
}
return null;
}
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 2519549..a4ab81a 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -157,6 +157,9 @@
this.onSelectionChanged,
this.onCaretChanged,
this.ignorePointer = false,
+ bool readOnly = false,
+ bool forceLine = true,
+ TextWidthBasis textWidthBasis = TextWidthBasis.parent,
bool obscureText = false,
Locale locale,
double cursorWidth = 1.0,
@@ -185,11 +188,14 @@
assert(textScaleFactor != null),
assert(offset != null),
assert(ignorePointer != null),
+ assert(textWidthBasis != null),
assert(paintCursorAboveText != null),
assert(obscureText != null),
assert(textSelectionDelegate != null),
assert(cursorWidth != null && cursorWidth >= 0.0),
- assert(devicePixelRatio != null),
+ assert(readOnly != null),
+ assert(forceLine != null),
+ assert(devicePixelRatio != null),
_textPainter = TextPainter(
text: text,
textAlign: textAlign,
@@ -197,6 +203,7 @@
textScaleFactor: textScaleFactor,
locale: locale,
strutStyle: strutStyle,
+ textWidthBasis: textWidthBasis,
),
_cursorColor = cursorColor,
_backgroundCursorColor = backgroundCursorColor,
@@ -216,7 +223,9 @@
_devicePixelRatio = devicePixelRatio,
_startHandleLayerLink = startHandleLayerLink,
_endHandleLayerLink = endHandleLayerLink,
- _obscureText = obscureText {
+ _obscureText = obscureText,
+ _readOnly = readOnly,
+ _forceLine = forceLine {
assert(_showCursor != null);
assert(!_showCursor.value || cursorColor != null);
this.hasFocus = hasFocus ?? false;
@@ -245,12 +254,15 @@
/// The default value of this property is false.
bool ignorePointer;
- /// Whether text is composed.
- ///
- /// Text is composed when user selects it for editing. The [TextSpan] will have
- /// children with composing effect and leave text property to be null.
- @visibleForTesting
- bool get isComposingText => text.text == null;
+ /// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis}
+ TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
+ set textWidthBasis(TextWidthBasis value) {
+ assert(value != null);
+ if (_textPainter.textWidthBasis == value)
+ return;
+ _textPainter.textWidthBasis = value;
+ markNeedsTextLayout();
+ }
/// The pixel ratio of the current device.
///
@@ -444,7 +456,7 @@
if (leftArrow && _extentOffset > 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset - 2));
newOffset = textSelection.baseOffset + 1;
- } else if (rightArrow && _extentOffset < text.text.length - 2) {
+ } else if (rightArrow && _extentOffset < text.toPlainText().length - 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset + 1));
newOffset = textSelection.extentOffset - 1;
}
@@ -487,7 +499,7 @@
// case that the user wants to unhighlight some text.
if (position.offset == _extentOffset) {
if (downArrow)
- newOffset = text.text.length;
+ newOffset = text.toPlainText().length;
else if (upArrow)
newOffset = 0;
_resetCursor = shift;
@@ -554,16 +566,16 @@
case _kCKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
- ClipboardData(text: selection.textInside(text.text)));
+ ClipboardData(text: selection.textInside(text.toPlainText())));
}
break;
case _kXKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
- ClipboardData(text: selection.textInside(text.text)));
+ ClipboardData(text: selection.textInside(text.toPlainText())));
textSelectionDelegate.textEditingValue = TextEditingValue(
- text: selection.textBefore(text.text)
- + selection.textAfter(text.text),
+ text: selection.textBefore(text.toPlainText())
+ + selection.textAfter(text.toPlainText()),
selection: TextSelection.collapsed(offset: selection.start),
);
}
@@ -601,15 +613,15 @@
}
void _handleDelete() {
- if (selection.textAfter(text.text).isNotEmpty) {
+ if (selection.textAfter(text.toPlainText()).isNotEmpty) {
textSelectionDelegate.textEditingValue = TextEditingValue(
- text: selection.textBefore(text.text)
- + selection.textAfter(text.text).substring(1),
+ text: selection.textBefore(text.toPlainText())
+ + selection.textAfter(text.toPlainText()).substring(1),
selection: TextSelection.collapsed(offset: selection.start),
);
} else {
textSelectionDelegate.textEditingValue = TextEditingValue(
- text: selection.textBefore(text.text),
+ text: selection.textBefore(text.toPlainText()),
selection: TextSelection.collapsed(offset: selection.start),
);
}
@@ -758,6 +770,28 @@
markNeedsSemanticsUpdate();
}
+ /// Whether this rendering object will take a full line regardless the text width.
+ bool get forceLine => _forceLine;
+ bool _forceLine = false;
+ set forceLine(bool value) {
+ assert(value != null);
+ if (_forceLine == value)
+ return;
+ _forceLine = value;
+ markNeedsLayout();
+ }
+
+ /// Whether this rendering object is read only.
+ bool get readOnly => _readOnly;
+ bool _readOnly = false;
+ set readOnly(bool value) {
+ assert(value != null);
+ if (_readOnly == value)
+ return;
+ _readOnly = value;
+ markNeedsSemanticsUpdate();
+ }
+
/// The maximum number of lines for the text to span, wrapping if necessary.
///
/// If this is 1 (the default), the text will not wrap, but will extend
@@ -983,6 +1017,8 @@
return enableInteractiveSelection ?? !obscureText;
}
+ double get _caretMargin => _kCaretGap + cursorWidth;
+
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
@@ -995,7 +1031,8 @@
..isMultiline = _isMultiline
..textDirection = textDirection
..isFocused = hasFocus
- ..isTextField = true;
+ ..isTextField = true
+ ..isReadOnly = readOnly;
if (hasFocus && selectionEnabled)
config.onSetSelection = _handleSetSelection;
@@ -1526,10 +1563,12 @@
assert(constraintWidth != null);
if (_textLayoutLastWidth == constraintWidth)
return;
- final double caretMargin = _kCaretGap + cursorWidth;
- final double availableWidth = math.max(0.0, constraintWidth - caretMargin);
+ final double availableWidth = math.max(0.0, constraintWidth - _caretMargin);
final double maxWidth = _isMultiline ? availableWidth : double.infinity;
- _textPainter.layout(minWidth: availableWidth, maxWidth: maxWidth);
+ _textPainter.layout(
+ minWidth: forceLine ? availableWidth : 0,
+ maxWidth: maxWidth,
+ );
_textLayoutLastWidth = constraintWidth;
}
@@ -1566,8 +1605,10 @@
// though we currently don't use those here.
// See also RenderParagraph which has a similar issue.
final Size textPainterSize = _textPainter.size;
- size = Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
- final Size contentSize = Size(textPainterSize.width + _kCaretGap + cursorWidth, textPainterSize.height);
+ final double width = forceLine ? constraints.maxWidth : constraints
+ .constrainWidth(_textPainter.size.width + _caretMargin);
+ size = Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
+ final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height);
_maxScrollExtent = _getMaxScrollExtent(contentSize);
offset.applyViewportDimension(_viewportExtent);
offset.applyContentDimensions(0.0, _maxScrollExtent);
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 468b0a8..c821bb2 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -150,6 +150,29 @@
);
}
+ /// Builds [TextSpan] from current editing value.
+ ///
+ /// By default makes text in composing range appear as underlined.
+ /// Descendants can override this method to customize appearance of text.
+ TextSpan buildTextSpan({TextStyle style , bool withComposing}) {
+ if (!value.composing.isValid || !withComposing) {
+ return TextSpan(style: style, text: text);
+ }
+ final TextStyle composingStyle = style.merge(
+ const TextStyle(decoration: TextDecoration.underline),
+ );
+ return TextSpan(
+ style: style,
+ children: <TextSpan>[
+ TextSpan(text: value.composing.textBefore(value.text)),
+ TextSpan(
+ style: composingStyle,
+ text: value.composing.textInside(value.text),
+ ),
+ TextSpan(text: value.composing.textAfter(value.text)),
+ ]);
+ }
+
/// The currently selected [text].
///
/// If the selection is collapsed, then this property gives the offset of the
@@ -288,6 +311,8 @@
this.maxLines = 1,
this.minLines,
this.expands = false,
+ this.forceLine = true,
+ this.textWidthBasis = TextWidthBasis.parent,
this.autofocus = false,
bool showCursor,
this.showSelectionHandles = false,
@@ -320,6 +345,7 @@
assert(autocorrect != null),
assert(showSelectionHandles != null),
assert(readOnly != null),
+ assert(forceLine != null),
assert(style != null),
assert(cursorColor != null),
assert(cursorOpacityAnimates != null),
@@ -368,6 +394,9 @@
/// {@endtemplate}
final bool obscureText;
+ /// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis}
+ final TextWidthBasis textWidthBasis;
+
/// {@template flutter.widgets.editableText.readOnly}
/// Whether the text can be changed.
///
@@ -378,6 +407,18 @@
/// {@endtemplate}
final bool readOnly;
+ /// Whether the text will take the full width regardless of the text width.
+ ///
+ /// When this is set to false, the width will be based on text width, which
+ /// will also be affected by [textWidthBasis].
+ ///
+ /// Defaults to true. Must not be null.
+ ///
+ /// See also:
+ ///
+ /// * [textWidthBasis], which controls the calculation of text width.
+ final bool forceLine;
+
/// Whether to show selection handles.
///
/// When a selection is active, there will be two handles at each side of
@@ -396,7 +437,7 @@
///
/// See also:
///
- /// * [showSelectionHandles], which controls the visibility of the selection handles..
+ /// * [showSelectionHandles], which controls the visibility of the selection handles.
/// {@endtemplate}
final bool showCursor;
@@ -1622,6 +1663,8 @@
showCursor: EditableText.debugDeterministicCursor
? ValueNotifier<bool>(widget.showCursor)
: _cursorVisibilityNotifier,
+ forceLine: widget.forceLine,
+ readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
@@ -1632,6 +1675,7 @@
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
+ textWidthBasis: widget.textWidthBasis,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
offset: offset,
@@ -1657,32 +1701,20 @@
/// By default makes text in composing range appear as underlined.
/// Descendants can override this method to customize appearance of text.
TextSpan buildTextSpan() {
- // Read only mode should not paint text composing.
- if (!widget.obscureText && _value.composing.isValid && !widget.readOnly) {
- final TextStyle composingStyle = widget.style.merge(
- const TextStyle(decoration: TextDecoration.underline),
- );
- return TextSpan(
- style: widget.style,
- children: <TextSpan>[
- TextSpan(text: _value.composing.textBefore(_value.text)),
- TextSpan(
- style: composingStyle,
- text: _value.composing.textInside(_value.text),
- ),
- TextSpan(text: _value.composing.textAfter(_value.text)),
- ]);
- }
-
- String text = _value.text;
if (widget.obscureText) {
+ String text = _value.text;
text = RenderEditable.obscuringCharacter * text.length;
final int o =
_obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null;
if (o != null && o >= 0 && o < text.length)
text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1));
+ return TextSpan(style: widget.style, text: text);
}
- return TextSpan(style: widget.style, text: text);
+ // Read only mode should not paint text composing.
+ return widget.controller.buildTextSpan(
+ style: widget.style,
+ withComposing: !widget.readOnly,
+ );
}
}
@@ -1696,6 +1728,9 @@
this.cursorColor,
this.backgroundCursorColor,
this.showCursor,
+ this.forceLine,
+ this.readOnly,
+ this.textWidthBasis,
this.hasFocus,
this.maxLines,
this.minLines,
@@ -1730,6 +1765,8 @@
final LayerLink endHandleLayerLink;
final Color backgroundCursorColor;
final ValueNotifier<bool> showCursor;
+ final bool forceLine;
+ final bool readOnly;
final bool hasFocus;
final int maxLines;
final int minLines;
@@ -1741,6 +1778,7 @@
final TextDirection textDirection;
final Locale locale;
final bool obscureText;
+ final TextWidthBasis textWidthBasis;
final bool autocorrect;
final ViewportOffset offset;
final SelectionChangedHandler onSelectionChanged;
@@ -1763,6 +1801,8 @@
endHandleLayerLink: endHandleLayerLink,
backgroundCursorColor: backgroundCursorColor,
showCursor: showCursor,
+ forceLine: forceLine,
+ readOnly: readOnly,
hasFocus: hasFocus,
maxLines: maxLines,
minLines: minLines,
@@ -1779,6 +1819,7 @@
onCaretChanged: onCaretChanged,
ignorePointer: rendererIgnoresPointer,
obscureText: obscureText,
+ textWidthBasis: textWidthBasis,
cursorWidth: cursorWidth,
cursorRadius: cursorRadius,
cursorOffset: cursorOffset,
@@ -1797,6 +1838,8 @@
..startHandleLayerLink = startHandleLayerLink
..endHandleLayerLink = endHandleLayerLink
..showCursor = showCursor
+ ..forceLine = forceLine
+ ..readOnly = readOnly
..hasFocus = hasFocus
..maxLines = maxLines
..minLines = minLines
@@ -1812,6 +1855,7 @@
..onSelectionChanged = onSelectionChanged
..onCaretChanged = onCaretChanged
..ignorePointer = rendererIgnoresPointer
+ ..textWidthBasis = textWidthBasis
..obscureText = obscureText
..cursorWidth = cursorWidth
..cursorRadius = cursorRadius
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index a432de8..d0f5de1 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -973,7 +973,7 @@
final RenderEditable renderEditable = findRenderEditable(tester);
// There should be no composing.
- expect(renderEditable.isComposingText, false);
+ expect(renderEditable.text, TextSpan(text:'readonly', style: renderEditable.text.style));
});
testWidgets('Dynamically switching between read only and not read only should hide or show collapse cursor', (WidgetTester tester) async {
@@ -3231,6 +3231,30 @@
semantics.dispose();
});
+ testWidgets('Read only TextField identifies as read only text field in semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: TextField(
+ maxLength: 10,
+ readOnly: true,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ includesNodeWith(flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isReadOnly])
+ );
+
+ semantics.dispose();
+ });
+
void sendFakeKeyEvent(Map<String, dynamic> data) {
defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.keyEvent.name,
diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart
new file mode 100644
index 0000000..6168acd
--- /dev/null
+++ b/packages/flutter/test/widgets/selectable_text_test.dart
@@ -0,0 +1,3722 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
+
+import '../widgets/semantics_tester.dart';
+
+class MockClipboard {
+ Object _clipboardData = <String, dynamic>{
+ 'text': null,
+ };
+
+ Future<dynamic> handleMethodCall(MethodCall methodCall) async {
+ switch (methodCall.method) {
+ case 'Clipboard.getData':
+ return _clipboardData;
+ case 'Clipboard.setData':
+ _clipboardData = methodCall.arguments;
+ break;
+ }
+ }
+}
+
+class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
+ @override
+ bool isSupported(Locale locale) => true;
+
+ @override
+ Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale);
+
+ @override
+ bool shouldReload(MaterialLocalizationsDelegate old) => false;
+}
+
+class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> {
+ @override
+ bool isSupported(Locale locale) => true;
+
+ @override
+ Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale);
+
+ @override
+ bool shouldReload(WidgetsLocalizationsDelegate old) => false;
+}
+
+Widget overlay({ Widget child }) {
+ final OverlayEntry entry = OverlayEntry(
+ builder: (BuildContext context) {
+ return Center(
+ child: Material(
+ child: child,
+ ),
+ );
+ },
+ );
+ return overlayWithEntry(entry);
+}
+
+Widget overlayWithEntry(OverlayEntry entry) {
+ return Localizations(
+ locale: const Locale('en', 'US'),
+ delegates: <LocalizationsDelegate<dynamic>>[
+ WidgetsLocalizationsDelegate(),
+ MaterialLocalizationsDelegate(),
+ ],
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(size: Size(800.0, 600.0)),
+ child: Overlay(
+ initialEntries: <OverlayEntry>[
+ entry
+ ],
+ ),
+ ),
+ ),
+ );
+}
+
+Widget boilerplate({ Widget child }) {
+ return Localizations(
+ locale: const Locale('en', 'US'),
+ delegates: <LocalizationsDelegate<dynamic>>[
+ WidgetsLocalizationsDelegate(),
+ MaterialLocalizationsDelegate(),
+ ],
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(size: Size(800.0, 600.0)),
+ child: Center(
+ child: Material(
+ child: child,
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+Future<void> skipPastScrollingAnimation(WidgetTester tester) async {
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200));
+}
+
+double getOpacity(WidgetTester tester, Finder finder) {
+ return tester.widget<FadeTransition>(
+ find.ancestor(
+ of: finder,
+ matching: find.byType(FadeTransition),
+ )
+ ).opacity.value;
+}
+
+void main() {
+ final MockClipboard mockClipboard = MockClipboard();
+ SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
+
+ const String kThreeLines =
+ 'First line of text is\n'
+ 'Second line goes until\n'
+ 'Third line of stuff';
+ const String kMoreThanFourLines =
+ kThreeLines +
+ '\nFourth line won\'t display and ends at';
+
+ // Returns the first RenderEditable.
+ RenderEditable findRenderEditable(WidgetTester tester) {
+ final RenderObject root = tester.renderObject(find.byType(EditableText));
+ expect(root, isNotNull);
+
+ RenderEditable renderEditable;
+ void recursiveFinder(RenderObject child) {
+ if (child is RenderEditable) {
+ renderEditable = child;
+ return;
+ }
+ child.visitChildren(recursiveFinder);
+ }
+ root.visitChildren(recursiveFinder);
+ expect(renderEditable, isNotNull);
+ return renderEditable;
+ }
+
+ List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
+ return points.map<TextSelectionPoint>((TextSelectionPoint point) {
+ return TextSelectionPoint(
+ box.localToGlobal(point.point),
+ point.direction,
+ );
+ }).toList();
+ }
+
+ Offset textOffsetToPosition(WidgetTester tester, int offset) {
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(
+ TextSelection.collapsed(offset: offset),
+ ),
+ renderEditable,
+ );
+ expect(endpoints.length, 1);
+ return endpoints[0].point + const Offset(0.0, -2.0);
+ }
+
+ setUp(() {
+ debugResetSemanticsIdCounter();
+ });
+
+ Widget selectableTextBuilder({
+ String text = '',
+ int maxLines = 1,
+ }) {
+ return boilerplate(
+ child: SelectableText(
+ text,
+ style: const TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: maxLines,
+ ),
+ );
+ }
+
+ testWidgets('has expected defaults', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MediaQuery(
+ data: MediaQueryData(devicePixelRatio: 1.0),
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: SelectableText('selectable text'),
+ ),
+ ),
+ );
+
+ final SelectableText selectableText =
+ tester.firstWidget(find.byType(SelectableText));
+ expect(selectableText.showCursor, false);
+ expect(selectableText.autofocus, false);
+ expect(selectableText.dragStartBehavior, DragStartBehavior.start);
+ expect(selectableText.cursorWidth, 2.0);
+ expect(selectableText.enableInteractiveSelection, true);
+ });
+
+ testWidgets('Rich selectable text has expected defaults', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MediaQuery(
+ data: MediaQueryData(devicePixelRatio: 1.0),
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: SelectableText.rich(
+ TextSpan(
+ text: 'First line!',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'Roboto'
+ ),
+ children: <TextSpan>[
+ TextSpan(
+ text: 'Second line!\n',
+ style: TextStyle(
+ fontSize: 30,
+ fontFamily: 'Roboto',
+ ),
+ ),
+ TextSpan(
+ text: 'Third line!\n',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'Roboto',
+ ),
+ ),
+ ],
+ )
+ ),
+ ),
+ ),
+ );
+
+ final SelectableText selectableText =
+ tester.firstWidget(find.byType(SelectableText));
+ expect(selectableText.showCursor, false);
+ expect(selectableText.autofocus, false);
+ expect(selectableText.dragStartBehavior, DragStartBehavior.start);
+ expect(selectableText.cursorWidth, 2.0);
+ expect(selectableText.enableInteractiveSelection, true);
+ });
+
+ testWidgets('Rich selectable text only support TextSpan', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MediaQuery(
+ data: MediaQueryData(devicePixelRatio: 1.0),
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: SelectableText.rich(
+ TextSpan(
+ text: 'First line!',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'Roboto'
+ ),
+ children: <InlineSpan>[
+ WidgetSpan(
+ child: SizedBox(
+ width: 120,
+ height: 50,
+ child: Card(
+ child: Center(
+ child: Text('Hello World!')
+ )
+ ),
+ )
+ ),
+ TextSpan(
+ text: 'Third line!\n',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'Roboto',
+ ),
+ ),
+ ],
+ )
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isAssertionError);
+ });
+
+ testWidgets('no text keyboard when widget is focused', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('selectable text'),
+ )
+ );
+ await tester.tap(find.byType(SelectableText));
+ await tester.idle();
+ expect(tester.testTextInput.hasAnyClients, false);
+ });
+
+ testWidgets('Selectable Text has adaptive size', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ boilerplate(
+ child: const SelectableText('s'),
+ )
+ );
+
+ RenderBox findSelectableTextBox() => tester.renderObject(find.byType(SelectableText));
+
+ final RenderBox textBox = findSelectableTextBox();
+ expect(textBox.size, const Size(17.0, 14.0));
+
+ await tester.pumpWidget(
+ boilerplate(
+ child: const SelectableText('very very long'),
+ )
+ );
+
+ final RenderBox longtextBox = findSelectableTextBox();
+ expect(longtextBox.size, const Size(199.0, 14.0));
+ });
+
+ testWidgets('can switch between textWidthBasis', (WidgetTester tester) async {
+ RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText));
+ const String text = 'I can face roll keyboardkeyboardaszzaaaaszzaaaaszzaaaaszzaaaa';
+ await tester.pumpWidget(
+ boilerplate(
+ child: const SelectableText(
+ text,
+ textWidthBasis: TextWidthBasis.parent
+ ),
+ )
+ );
+ RenderBox textBox = findTextBox();
+ expect(textBox.size, const Size(800.0, 28.0));
+
+ await tester.pumpWidget(
+ boilerplate(
+ child: const SelectableText(
+ text,
+ textWidthBasis: TextWidthBasis.longestLine
+ ),
+ )
+ );
+ textBox = findTextBox();
+ expect(textBox.size, const Size(633.0, 28.0));
+ });
+
+ testWidgets('Cursor blinks when showCursor is true', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ 'some text',
+ showCursor: true,
+ ),
+ ),
+ );
+ await tester.tap(find.byType(SelectableText));
+ await tester.idle();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+
+ // Check that the cursor visibility toggles after each blink interval.
+ final bool initialShowCursor = editableText.cursorCurrentlyVisible;
+ await tester.pump(editableText.cursorBlinkInterval);
+ expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
+ await tester.pump(editableText.cursorBlinkInterval);
+ expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
+ await tester.pump(editableText.cursorBlinkInterval ~/ 10);
+ expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
+ await tester.pump(editableText.cursorBlinkInterval);
+ expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
+ await tester.pump(editableText.cursorBlinkInterval);
+ expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
+ });
+
+ testWidgets('selectable text selection toolbar renders correctly inside opacity', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Container(
+ width: 100,
+ height: 100,
+ child: const Opacity(
+ opacity: 0.5,
+ child: SelectableText('selectable text'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar.
+ final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
+ state.renderEditable.selectWordsInRange(from: const Offset(0, 0), cause: SelectionChangedCause.tap);
+
+ expect(state.showToolbar(), true);
+
+ // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible.
+ await tester.pumpAndSettle();
+ await tester.pump(const Duration(seconds: 1));
+
+ expect(find.text('SELECT ALL'), findsOneWidget);
+ });
+
+ testWidgets('Caret position is updated on tap', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('abc def ghi'),
+ )
+ );
+ final EditableText editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.controller.selection.baseOffset, -1);
+ expect(editableText.controller.selection.extentOffset, -1);
+
+ // Tap to reposition the caret.
+ const int tapIndex = 4;
+ final Offset ePos = textOffsetToPosition(tester, tapIndex);
+ await tester.tapAt(ePos);
+ await tester.pump();
+
+ expect(editableText.controller.selection.baseOffset, tapIndex);
+ expect(editableText.controller.selection.extentOffset, tapIndex);
+ });
+
+ testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ 'abc def ghi',
+ enableInteractiveSelection: false,
+ ),
+ )
+ );
+ final EditableText editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.controller.selection.baseOffset, -1);
+ expect(editableText.controller.selection.extentOffset, -1);
+
+ // Tap would ordinarily reposition the caret.
+ const int tapIndex = 4;
+ final Offset ePos = textOffsetToPosition(tester, tapIndex);
+ await tester.tapAt(ePos);
+ await tester.pump();
+
+ expect(editableText.controller.selection.baseOffset, -1);
+ expect(editableText.controller.selection.extentOffset, -1);
+ });
+
+ testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ 'abc def ghi',
+ enableInteractiveSelection: false,
+ ),
+ )
+ );
+ final EditableText editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.controller.selection.baseOffset, -1);
+ expect(editableText.controller.selection.extentOffset, -1);
+
+ // Long press the 'e' to select 'def'.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.up();
+ await tester.pump();
+
+ expect(editableText.controller.selection.isCollapsed, true);
+ expect(editableText.controller.selection.baseOffset, -1);
+ expect(editableText.controller.selection.extentOffset, -1);
+ });
+
+ testWidgets('Can long press to select', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('abc def ghi'),
+ )
+ );
+
+ final EditableText editableText = tester.widget(find.byType(EditableText));
+
+ expect(editableText.controller.selection.isCollapsed, true);
+
+ // Long press the 'e' to select 'def'.
+ const int tapIndex = 5;
+ final Offset ePos = textOffsetToPosition(tester, tapIndex);
+ await tester.longPressAt(ePos);
+ await tester.pump();
+
+ // 'def' is selected.
+ expect(editableText.controller.selection.baseOffset, 4);
+ expect(editableText.controller.selection.extentOffset, 7);
+
+ // Tapping elsewhere immediately collapses and moves the cursor.
+ await tester.tapAt(textOffsetToPosition(tester, 9));
+ await tester.pump();
+
+ expect(editableText.controller.selection.isCollapsed, true);
+ expect(editableText.controller.selection.baseOffset, 9);
+ });
+
+ testWidgets('Slight movements in longpress don\'t hide/show handles', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('abc def ghi'),
+ )
+ );
+ // Long press the 'e' to select 'def', but don't release the gesture.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
+ await tester.pump(const Duration(seconds: 2));
+ await tester.pumpAndSettle();
+
+ // Handles are shown
+ final Finder fadeFinder = find.byType(FadeTransition);
+ expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar
+ FadeTransition handle = tester.widget(fadeFinder.at(0));
+ expect(handle.opacity.value, equals(1.0));
+
+ // Move the gesture very slightly
+ await gesture.moveBy(const Offset(1.0, 1.0));
+ await tester.pump(TextSelectionOverlay.fadeDuration * 0.5);
+ handle = tester.widget(fadeFinder.at(0));
+
+ // The handle should still be fully opaque.
+ expect(handle.opacity.value, equals(1.0));
+ });
+
+ testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('abc def ghi'),
+ )
+ );
+
+ final EditableText editableText = tester.widget(find.byType(EditableText));
+
+ // Long press the 'e' using a mouse device.
+ const int eIndex = 5;
+ final Offset ePos = textOffsetToPosition(tester, eIndex);
+ final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.up();
+ await tester.pump();
+
+ // The cursor is placed just like a regular tap.
+ expect(editableText.controller.selection.baseOffset, eIndex);
+ expect(editableText.controller.selection.extentOffset, eIndex);
+
+ await gesture.removePointer();
+ });
+
+ testWidgets('selectable text basic', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText('selectable'),
+ )
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ // selectable text cannot open keyboard.
+ await tester.showKeyboard(find.byType(SelectableText));
+ expect(tester.testTextInput.hasAnyClients, false);
+ await skipPastScrollingAnimation(tester);
+
+ expect(editableTextWidget.controller.selection.isCollapsed, true);
+
+ await tester.tap(find.byType(SelectableText));
+ await tester.pump();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ // Collapse selection should not paint.
+ expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
+ // Long press on the 't' character of text 'selectable' to show context menu.
+ const int dIndex = 5;
+ final Offset dPos = textOffsetToPosition(tester, dIndex);
+ await tester.longPressAt(dPos);
+ await tester.pump();
+
+ // Context menu should not have paste and cut.
+ expect(find.text('COPY'), findsOneWidget);
+ expect(find.text('PASTE'), findsNothing);
+ expect(find.text('CUT'), findsNothing);
+ });
+
+ testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final Offset gPos = textOffsetToPosition(tester, 8);
+
+ final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
+ await tester.pump();
+ await gesture.moveTo(gPos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.baseOffset, 5);
+ expect(controller.selection.extentOffset, 8);
+
+ await gesture.removePointer();
+ });
+
+ testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0)
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ int selectionChangedCount = 0;
+
+ controller.addListener(() {
+ selectionChangedCount++;
+ });
+
+ final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'.
+ final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'.
+ final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'.
+
+ // Drag from 'c' to 'g'.
+ final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse);
+ await tester.pump();
+ await gesture.moveTo(gPos);
+ await tester.pumpAndSettle();
+
+ expect(selectionChangedCount, isNonZero);
+ selectionChangedCount = 0;
+ expect(controller.selection.baseOffset, 2);
+ expect(controller.selection.extentOffset, 8);
+
+ // Tiny movement shouldn't cause text selection to change.
+ await gesture.moveTo(gPos + const Offset(4.0, 0.0));
+ await tester.pumpAndSettle();
+ expect(selectionChangedCount, 0);
+
+ // Now a text selection change will occur after a significant movement.
+ await gesture.moveTo(hPos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(selectionChangedCount, 1);
+ expect(controller.selection.baseOffset, 2);
+ expect(controller.selection.extentOffset, 9);
+
+ await gesture.removePointer();
+ });
+
+ testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final Offset gPos = textOffsetToPosition(tester, 8);
+
+ final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse);
+ await tester.pump();
+ await gesture.moveTo(ePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.baseOffset, 5);
+ expect(controller.selection.extentOffset, 8);
+
+ await gesture.removePointer();
+ });
+
+ testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final Offset gPos = textOffsetToPosition(tester,8);
+
+ final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.moveTo(gPos);
+ await tester.pump();
+ await gesture.up();
+
+ expect(controller.selection.baseOffset, 5);
+ expect(controller.selection.extentOffset,8);
+
+ await gesture.removePointer();
+ });
+
+ testWidgets('Can drag handles to change selection', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Long press the 'e' to select 'def'.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.up();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
+
+ final TextSelection selection = controller.selection;
+ expect(selection.baseOffset, 4);
+ expect(selection.extentOffset, 7);
+
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(selection),
+ renderEditable,
+ );
+ expect(endpoints.length, 2);
+
+ // Drag the right handle 2 letters to the right.
+ // 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, 11);
+ gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump();
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 4);
+ 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, 0);
+ gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump();
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 0);
+ expect(controller.selection.extentOffset, 11);
+ });
+
+ testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'abc def ghi',
+ dragStartBehavior: DragStartBehavior.down,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Long press the 'e' to select 'def'.
+ 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();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
+
+ final TextSelection selection = controller.selection;
+ expect(selection.baseOffset, 4);
+ expect(selection.extentOffset, 7);
+
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(selection),
+ renderEditable,
+ );
+ expect(endpoints.length, 2);
+
+ // 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(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);
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 4);
+ expect(controller.selection.extentOffset, 5);
+
+ newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'.
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 4);
+ // The selection doesn't move beyond the left handle. There's always at
+ // least 1 char selected.
+ expect(controller.selection.extentOffset, 5);
+ });
+
+ testWidgets('Can use selection toolbar', (WidgetTester tester) async {
+ const String testValue = 'abc def ghi';
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(
+ testValue,
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // 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
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
+ // 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
+
+ // SELECT ALL should select all the text.
+ await tester.tap(find.text('SELECT ALL'));
+ await tester.pump();
+ expect(controller.selection.baseOffset, 0);
+ expect(controller.selection.extentOffset, testValue.length);
+
+ // COPY should reset the selection.
+ await tester.tap(find.text('COPY'));
+ await skipPastScrollingAnimation(tester);
+ expect(controller.selection.isCollapsed, true);
+ });
+
+ testWidgets('Selectable height with maxLine', (WidgetTester tester) async {
+ await tester.pumpWidget(selectableTextBuilder());
+
+ RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText));
+
+ final RenderBox textBox = findTextBox();
+ final Size emptyInputSize = textBox.size;
+
+ await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.'));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, emptyInputSize.height);
+
+ // Even when entering multiline text, SelectableText doesn't grow. It's a single
+ // line input.
+ await tester.pumpWidget(selectableTextBuilder(text: kThreeLines));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, emptyInputSize.height);
+
+ // maxLines: 3 makes the SelectableText 3 lines tall
+ await tester.pumpWidget(selectableTextBuilder(maxLines: 3));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, greaterThan(emptyInputSize.height));
+
+ final Size threeLineInputSize = textBox.size;
+
+ // Filling with 3 lines of text stays the same size
+ await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: 3));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, threeLineInputSize.height);
+
+ // An extra line won't increase the size because we max at 3.
+ await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 3));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, threeLineInputSize.height);
+
+ // But now it will... but it will max at four
+ await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 4));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size.height, greaterThan(threeLineInputSize.height));
+
+ final Size fourLineInputSize = textBox.size;
+
+ // Now it won't max out until the end
+ await tester.pumpWidget(selectableTextBuilder(maxLines: null));
+ expect(findTextBox(), equals(textBox));
+ expect(textBox.size, equals(emptyInputSize));
+ await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: null));
+ expect(textBox.size.height, equals(threeLineInputSize.height));
+ await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: null));
+ expect(textBox.size.height, greaterThan(fourLineInputSize.height));
+ });
+
+ testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async {
+ const String testValue = kThreeLines;
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ testValue,
+ dragStartBehavior: DragStartBehavior.down,
+ style: TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: 3,
+ strutStyle: StrutStyle.disabled,
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Check that the text spans multiple lines.
+ final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First'));
+ final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
+ final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
+ final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst'));
+
+ expect(firstPos.dx, 24.5);
+ expect(secondPos.dx, 24.5);
+ expect(thirdPos.dx, 24.5);
+ expect(middleStringPos.dx, 58.5);
+ expect(firstPos.dx, secondPos.dx);
+ expect(firstPos.dx, thirdPos.dx);
+ expect(firstPos.dy, lessThan(secondPos.dy));
+ expect(secondPos.dy, lessThan(thirdPos.dy));
+
+ // Long press the 'n' in 'until' to select the word.
+ final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1);
+ TestGesture gesture = await tester.startGesture(untilPos, pointer: 7);
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.up();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
+
+ expect(controller.selection.baseOffset, 39);
+ expect(controller.selection.extentOffset, 44);
+
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
+ expect(endpoints.length, 2);
+
+ // Drag the right handle to the third line, just after 'Third'.
+ Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
+ Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Third') + 5);
+ gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump();
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 39);
+ expect(controller.selection.extentOffset, 50);
+
+ // Drag the left handle to the first line, just after 'First'.
+ handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
+ newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5);
+ gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump();
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 5);
+ expect(controller.selection.extentOffset, 50);
+ await tester.tap(find.text('COPY'));
+ await tester.pump();
+ expect(controller.selection.isCollapsed, true);
+ });
+
+ testWidgets('Can scroll multiline input', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ kMoreThanFourLines,
+ dragStartBehavior: DragStartBehavior.down,
+ style: TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: 2,
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+ RenderBox findInputBox() => tester.renderObject(find.byType(SelectableText));
+ final RenderBox inputBox = findInputBox();
+
+ // Check that the last line of text is not displayed.
+ final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
+ final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
+ expect(firstPos.dx, 0.0);
+ expect(fourthPos.dx, 0.0);
+ expect(firstPos.dx, fourthPos.dx);
+ expect(firstPos.dy, lessThan(fourthPos.dy));
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse);
+
+ TestGesture gesture = await tester.startGesture(firstPos, pointer: 7);
+ await tester.pump();
+ await gesture.moveBy(const Offset(0.0, -1000.0));
+ await tester.pump(const Duration(seconds: 1));
+ // Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329
+ // (No idea why this is necessary, but the bug wouldn't repro without it.)
+ await gesture.moveBy(const Offset(0.0, -1000.0));
+ await tester.pump(const Duration(seconds: 1));
+ await gesture.up();
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 1));
+
+ // Now the first line is scrolled up, and the fourth line is visible.
+ Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
+ Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
+
+ expect(newFirstPos.dy, lessThan(firstPos.dy));
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse);
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue);
+
+ // Now try scrolling by dragging the selection handle.
+ // Long press the middle of the word "won't" in the fourth line.
+ final Offset selectedWordPos = textOffsetToPosition(
+ tester,
+ kMoreThanFourLines.indexOf('Fourth line') + 14,
+ );
+
+ gesture = await tester.startGesture(selectedWordPos, pointer: 7);
+ await tester.pump(const Duration(seconds: 1));
+ await gesture.up();
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 1));
+
+ expect(controller.selection.base.offset, 77);
+ expect(controller.selection.extent.offset, 82);
+ // Sanity check for the word selected is the intended one.
+ expect(
+ controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset),
+ "won't",
+ );
+
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
+ expect(endpoints.length, 2);
+
+ // Drag the left handle to the first line, just after 'First'.
+ final Offset handlePos = endpoints[0].point + const Offset(-1, 1);
+ final Offset newHandlePos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First') + 5);
+ gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump(const Duration(seconds: 1));
+ await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0));
+ await tester.pump(const Duration(seconds: 1));
+ await gesture.up();
+ await tester.pump(const Duration(seconds: 1));
+
+ // The text should have scrolled up with the handle to keep the active
+ // cursor visible, back to its original position.
+ newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
+ newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
+ expect(newFirstPos.dy, firstPos.dy);
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue);
+ expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse);
+ });
+
+ testWidgets('Can align to center', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: Container(
+ width: 300.0,
+ child: const SelectableText(
+ 'abcd',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable editable = findRenderEditable(tester);
+
+ final Offset topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
+ );
+
+ expect(topLeft.dx, equals(278.0));
+ });
+
+ testWidgets('Can align to center within center', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: Container(
+ width: 300.0,
+ child: const Center(
+ child: SelectableText(
+ 'abcd',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable editable = findRenderEditable(tester);
+
+ final Offset topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
+ );
+
+ expect(topLeft.dx, equals(399.0));
+ });
+
+ testWidgets('Selectable text drops selection when losing focus', (WidgetTester tester) async {
+ final Key key1 = UniqueKey();
+ final Key key2 = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: Column(
+ children: <Widget>[
+ SelectableText(
+ 'text 1',
+ key: key1,
+ ),
+ SelectableText(
+ 'text 2',
+ key: key2
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key1));
+ await tester.pump();
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+ controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3);
+ await tester.pump();
+ expect(controller.selection, isNot(equals(TextRange.empty)));
+
+ await tester.tap(find.byKey(key2));
+ await tester.pump();
+ expect(controller.selection, equals(TextRange.empty));
+ });
+
+ testWidgets('Selectable text identifies as text field in semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText('some text'),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ includesNodeWith(
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isMultiline,
+ ]
+ )
+ );
+
+ semantics.dispose();
+ });
+
+ void sendFakeKeyEvent(Map<String, dynamic> data) {
+ defaultBinaryMessenger.handlePlatformMessage(
+ SystemChannels.keyEvent.name,
+ SystemChannels.keyEvent.codec.encodeMessage(data),
+ (ByteData data) { },
+ );
+ }
+
+ void sendKeyEventWithCode(int code, bool down, bool shiftDown, bool ctrlDown) {
+
+ int metaState = shiftDown ? 1 : 0;
+ if (ctrlDown)
+ metaState |= 1 << 12;
+
+ sendFakeKeyEvent(<String, dynamic>{
+ 'type': down ? 'keydown' : 'keyup',
+ 'keymap': 'android',
+ 'keyCode': code,
+ 'hidUsage': 0x04,
+ 'codePoint': 0x64,
+ 'metaState': metaState,
+ });
+ }
+
+ group('Keyboard Tests', () {
+ TextEditingController controller;
+
+ Future<void> setupWidget(WidgetTester tester, String text) async {
+ final FocusNode focusNode = FocusNode();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: null,
+ child: SelectableText(
+ text,
+ maxLines: 3,
+ strutStyle: StrutStyle.disabled,
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.tap(find.byType(SelectableText));
+ await tester.pumpAndSettle();
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ controller = editableTextWidget.controller;
+ }
+
+ testWidgets('Shift test 1', (WidgetTester tester) async {
+ await setupWidget(tester, 'a big house');
+
+ sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown, SHIFT_ON
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
+ });
+
+ testWidgets('Shift test 2', (WidgetTester tester) async {
+ await setupWidget(tester, 'abcdefghi');
+
+ controller.selection = const TextSelection.collapsed(offset: 3);
+ await tester.pump();
+
+ sendKeyEventWithCode(22, true, true, false);
+ await tester.pumpAndSettle();
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
+ });
+
+ testWidgets('Control Shift test', (WidgetTester tester) async {
+ await setupWidget(tester, 'their big house');
+
+ sendKeyEventWithCode(21, true, true, true); // LEFT_ARROW keydown SHIFT_ON, CONTROL_ON
+
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 5);
+ });
+
+ testWidgets('Down and up test', (WidgetTester tester) async {
+ await setupWidget(tester, 'a big house');
+
+ sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 11);
+
+ sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 0);
+ });
+
+ testWidgets('Down and up test 2', (WidgetTester tester) async {
+ await setupWidget(tester, 'a big house\njumped over a mouse\nOne more line yay');
+
+ controller.selection = const TextSelection.collapsed(offset: 0);
+ await tester.pump();
+
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
+ await tester.pumpAndSettle();
+ }
+ sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 12);
+
+ sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 32);
+
+ sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 12);
+
+ sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 0);
+
+ sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.extentOffset - controller.selection.baseOffset, 5);
+ });
+ });
+
+ const int _kCKeyCode = 31;
+ const int _kAKeyCode = 29;
+
+ testWidgets('Copy test', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+
+ String clipboardContent = '';
+ SystemChannels.platform
+ .setMockMethodCallHandler((MethodCall methodCall) async {
+ if (methodCall.method == 'Clipboard.setData')
+ clipboardContent = methodCall.arguments['text'];
+ else if (methodCall.method == 'Clipboard.getData')
+ return <String, dynamic>{'text': clipboardContent};
+ return null;
+ });
+ const String testValue = 'a big house\njumped over a mouse';
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: null,
+ child: const SelectableText(
+ testValue,
+ maxLines: 3,
+ ),
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+ focusNode.requestFocus();
+ await tester.pump();
+
+ await tester.tap(find.byType(SelectableText));
+ await tester.pumpAndSettle();
+
+ controller.selection = const TextSelection.collapsed(offset: 0);
+ await tester.pump();
+
+ // Select the first 5 characters
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown shift
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
+ await tester.pumpAndSettle();
+ }
+
+ // Copy them
+ sendKeyEventWithCode(_kCKeyCode, true, false, true); // keydown control
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(_kCKeyCode, false, false, false); // keyup control
+ await tester.pumpAndSettle();
+
+ expect(clipboardContent, 'a big');
+
+ sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
+ await tester.pumpAndSettle();
+ });
+
+ testWidgets('Select all test', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+ const String testValue = 'a big house\njumped over a mouse';
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: null,
+ child: const SelectableText(
+ testValue,
+ maxLines: 3,
+ ),
+ ),
+ ),
+ ),
+ );
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+ focusNode.requestFocus();
+ await tester.pump();
+
+ await tester.tap(find.byType(SelectableText));
+ await tester.pumpAndSettle();
+
+ // Select All
+ sendKeyEventWithCode(_kAKeyCode, true, false, true); // keydown control A
+ await tester.pumpAndSettle();
+ sendKeyEventWithCode(_kAKeyCode, false, false, true); // keyup control A
+ await tester.pumpAndSettle();
+
+ expect(controller.selection.baseOffset, 0);
+ expect(controller.selection.extentOffset, 31);
+ });
+
+ testWidgets('Changing positions of selectable text', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+ final List<RawKeyEvent> events = <RawKeyEvent>[];
+
+ final Key key1 = UniqueKey();
+ final Key key2 = UniqueKey();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home:
+ Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: events.add,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: <Widget>[
+ SelectableText(
+ 'a big house',
+ key: key1,
+ maxLines: 3,
+ ),
+ SelectableText(
+ 'another big house',
+ key: key2,
+ maxLines: 3,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ TextEditingController c1 = editableTextWidget.controller;
+
+ await tester.tap(find.byType(EditableText).first);
+ await tester.pumpAndSettle();
+
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown
+ await tester.pumpAndSettle();
+ }
+
+ expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home:
+ Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: events.add,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: <Widget>[
+ SelectableText(
+ 'another big house',
+ key: key2,
+ maxLines: 3,
+ ),
+ SelectableText(
+ 'a big house',
+ key: key1,
+ maxLines: 3,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown
+ await tester.pumpAndSettle();
+ }
+
+ editableTextWidget = tester.widget(find.byType(EditableText).last);
+ c1 = editableTextWidget.controller;
+
+ expect(c1.selection.extentOffset - c1.selection.baseOffset, 10);
+ });
+
+
+ testWidgets('Changing focus test', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+ final List<RawKeyEvent> events = <RawKeyEvent>[];
+
+ final Key key1 = UniqueKey();
+ final Key key2 = UniqueKey();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home:
+ Material(
+ child: RawKeyboardListener(
+ focusNode: focusNode,
+ onKey: events.add,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: <Widget>[
+ SelectableText(
+ 'a big house',
+ key: key1,
+ maxLines: 3,
+ ),
+ SelectableText(
+ 'another big house',
+ key: key2,
+ maxLines: 3,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget1 = tester.widget(find.byType(EditableText).first);
+ final TextEditingController c1 = editableTextWidget1.controller;
+
+ final EditableText editableTextWidget2 = tester.widget(find.byType(EditableText).last);
+ final TextEditingController c2 = editableTextWidget2.controller;
+
+ await tester.tap(find.byType(SelectableText).first);
+ await tester.pumpAndSettle();
+
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown
+ await tester.pumpAndSettle();
+ }
+
+ expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
+ expect(c2.selection.extentOffset - c2.selection.baseOffset, 0);
+
+ await tester.tap(find.byType(SelectableText).last);
+ await tester.pumpAndSettle();
+
+ for (int i = 0; i < 5; i += 1) {
+ sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown
+ await tester.pumpAndSettle();
+ }
+
+ expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
+ expect(c2.selection.extentOffset - c2.selection.baseOffset, 5);
+ });
+
+ testWidgets('Caret works when maxLines is null', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: const SelectableText(
+ 'x',
+ maxLines: null,
+ ),
+ )
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(controller.selection.baseOffset, -1);
+
+ // Tap the selection handle to bring up the "paste / select all" menu.
+ await tester.tapAt(textOffsetToPosition(tester, 0));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is
+
+ // Confirm that the selection was updated.
+ expect(controller.selection.baseOffset, 0);
+ });
+
+ testWidgets('SelectableText baseline alignment no-strut', (WidgetTester tester) async {
+ final Key keyA = UniqueKey();
+ final Key keyB = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: <Widget>[
+ Expanded(
+ child: SelectableText(
+ 'A',
+ key: keyA,
+ style: const TextStyle(fontSize: 10.0),
+ strutStyle: StrutStyle.disabled,
+ ),
+ ),
+ const Text(
+ 'abc',
+ style: TextStyle(fontSize: 20.0),
+ ),
+ Expanded(
+ child: SelectableText(
+ 'B',
+ key: keyB,
+ style: const TextStyle(fontSize: 30.0),
+ strutStyle: StrutStyle.disabled,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ // The Ahem font extends 0.2 * fontSize below the baseline.
+ // So the three row elements line up like this:
+ //
+ // A abc B
+ // --------- baseline
+ // 2 4 6 space below the baseline = 0.2 * fontSize
+ // --------- rowBottomY
+
+ final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy;
+ expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001));
+ expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001));
+ expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY);
+ });
+
+ testWidgets('SelectableText baseline alignment', (WidgetTester tester) async {
+ final Key keyA = UniqueKey();
+ final Key keyB = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: <Widget>[
+ Expanded(
+ child: SelectableText(
+ 'A',
+ key: keyA,
+ style: const TextStyle(fontSize: 10.0),
+ ),
+ ),
+ const Text(
+ 'abc',
+ style: TextStyle(fontSize: 20.0),
+ ),
+ Expanded(
+ child: SelectableText(
+ 'B',
+ key: keyB,
+ style: const TextStyle(fontSize: 30.0),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ // The Ahem font extends 0.2 * fontSize below the baseline.
+ // So the three row elements line up like this:
+ //
+ // A abc B
+ // --------- baseline
+ // 2 4 6 space below the baseline = 0.2 * fontSize
+ // --------- rowBottomY
+
+ final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy;
+ expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001));
+ expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001));
+ expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY);
+ });
+
+ testWidgets('SelectableText semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final Key key = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ 'Guten Tag',
+ key: key,
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ textDirection: TextDirection.ltr,
+ value: 'Guten Tag',
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isMultiline,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ await tester.tap(find.byKey(key));
+ await tester.pump();
+
+ controller.selection = const TextSelection.collapsed(offset: 9);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ textDirection: TextDirection.ltr,
+ value: 'Guten Tag',
+ textSelection: const TextSelection.collapsed(offset: 9),
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ controller.selection = const TextSelection.collapsed(offset: 4);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ textDirection: TextDirection.ltr,
+ textSelection: const TextSelection.collapsed(offset: 4),
+ value: 'Guten Tag',
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorForwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.moveCursorForwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ controller.selection = const TextSelection.collapsed(offset: 0);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ textDirection: TextDirection.ltr,
+ textSelection: const TextSelection.collapsed(offset: 0),
+ value: 'Guten Tag',
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorForwardByCharacter,
+ SemanticsAction.moveCursorForwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ semantics.dispose();
+ });
+
+ testWidgets('SelectableText semantics, enableInteractiveSelection = false', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final Key key = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ 'Guten Tag',
+ key: key,
+ enableInteractiveSelection: false,
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key));
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ value: 'Guten Tag',
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ // Absent the following because enableInteractiveSelection: false
+ // SemanticsAction.moveCursorBackwardByCharacter,
+ // SemanticsAction.moveCursorBackwardByWord,
+ // SemanticsAction.setSelection,
+ // SemanticsAction.paste,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ // SelectableText act like a text widget when enableInteractiveSelection
+ // is false. It will not respond to any pointer event.
+ // SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ semantics.dispose();
+ });
+
+ testWidgets('SelectableText semantics for selections', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final Key key = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ 'Hello',
+ key: key,
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ value: 'Hello',
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ // Focus the selectable text
+ await tester.tap(find.byKey(key));
+ await tester.pump();
+
+ controller.selection = const TextSelection.collapsed(offset: 5);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ value: 'Hello',
+ textSelection: const TextSelection.collapsed(offset: 5),
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: 1,
+ value: 'Hello',
+ textSelection: const TextSelection(baseOffset: 5, extentOffset: 3),
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorForwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.moveCursorForwardByWord,
+ SemanticsAction.setSelection,
+ SemanticsAction.copy,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ semantics.dispose();
+ });
+
+ testWidgets('SelectableText change selection with semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
+ final Key key = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ 'Hello',
+ key: key,
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Focus the selectable text
+ await tester.tap(find.byKey(key));
+ await tester.pump();
+
+ controller.selection = const TextSelection(baseOffset: 5, extentOffset: 5);
+ await tester.pump();
+
+ const int inputFieldId = 1;
+
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: inputFieldId,
+ value: 'Hello',
+ textSelection: const TextSelection.collapsed(offset: 5),
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ // move cursor back once
+ semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+ 'base': 4,
+ 'extent': 4,
+ });
+ await tester.pump();
+ expect(controller.selection, const TextSelection.collapsed(offset: 4));
+
+ // move cursor to front
+ semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+ 'base': 0,
+ 'extent': 0,
+ });
+ await tester.pump();
+ expect(controller.selection, const TextSelection.collapsed(offset: 0));
+
+ // select all
+ semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
+ 'base': 0,
+ 'extent': 5,
+ });
+ await tester.pump();
+ expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
+ expect(semantics, hasSemantics(TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ id: inputFieldId,
+ value: 'Hello',
+ textSelection: const TextSelection(baseOffset: 0, extentOffset: 5),
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.setSelection,
+ SemanticsAction.copy,
+ ],
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ ),
+ ],
+ ), ignoreTransform: true, ignoreRect: true));
+
+ semantics.dispose();
+ });
+
+ testWidgets('Can activate SelectableText with explicit controller via semantics', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/17801
+
+ const String testValue = 'Hello';
+
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
+ final Key key = UniqueKey();
+
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ testValue,
+ key: key,
+ ),
+ ),
+ );
+
+ const int inputFieldId = 1;
+
+ expect(semantics, hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: inputFieldId,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ ],
+ actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.longPress],
+ value: testValue,
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreRect: true, ignoreTransform: true,
+ ));
+
+ semanticsOwner.performAction(inputFieldId, SemanticsAction.tap);
+ await tester.pump();
+
+ expect(semantics, hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: inputFieldId,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isMultiline,
+ SemanticsFlag.isFocused,
+ ],
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ SemanticsAction.longPress,
+ SemanticsAction.moveCursorBackwardByCharacter,
+ SemanticsAction.moveCursorBackwardByWord,
+ SemanticsAction.setSelection,
+ ],
+ value: testValue,
+ textDirection: TextDirection.ltr,
+ textSelection: const TextSelection(
+ baseOffset: testValue.length,
+ extentOffset: testValue.length,
+ ),
+ ),
+ ],
+ ),
+ ignoreRect: true, ignoreTransform: true,
+ ));
+
+ semantics.dispose();
+ });
+
+ testWidgets('SelectableText throws when not descended from a MediaQuery widget', (WidgetTester tester) async {
+ const Widget selectableText = SelectableText('something');
+ await tester.pumpWidget(selectableText);
+ final dynamic exception = tester.takeException();
+ expect(exception, isFlutterError);
+ expect(exception.toString(), startsWith('No MediaQuery widget found.\nSelectableText widgets require a MediaQuery widget ancestor.'));
+ });
+
+ testWidgets('onTap is called upon tap', (WidgetTester tester) async {
+ int tapCount = 0;
+ await tester.pumpWidget(
+ overlay(
+ child: SelectableText(
+ 'something',
+ onTap: () {
+ tapCount += 1;
+ },
+ ),
+ ),
+ );
+
+ expect(tapCount, 0);
+ await tester.tap(find.byType(SelectableText));
+ // Wait a bit so they're all single taps and not double taps.
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.tap(find.byType(SelectableText));
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.tap(find.byType(SelectableText));
+ await tester.pump(const Duration(milliseconds: 300));
+ expect(tapCount, 3);
+ });
+
+ testWidgets('SelectableText style is merged with default text style', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/23994
+ final TextStyle defaultStyle = TextStyle(
+ color: Colors.blue[500],
+ );
+ Widget buildFrame(TextStyle style) {
+ return MaterialApp(
+ home: Material(
+ child: DefaultTextStyle (
+ style: defaultStyle,
+ child: Center(
+ child: SelectableText(
+ 'something',
+ style: style,
+ ),
+ ),
+ )
+ ),
+ );
+ }
+
+ // Empty TextStyle is overridden by theme
+ await tester.pumpWidget(buildFrame(const TextStyle()));
+ EditableText editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.style.color, defaultStyle.color);
+ expect(editableText.style.background, defaultStyle.background);
+ expect(editableText.style.shadows, defaultStyle.shadows);
+ expect(editableText.style.decoration, defaultStyle.decoration);
+ expect(editableText.style.locale, defaultStyle.locale);
+ expect(editableText.style.wordSpacing, defaultStyle.wordSpacing);
+
+ // Properties set on TextStyle override theme
+ const Color setColor = Colors.red;
+ await tester.pumpWidget(buildFrame(const TextStyle(color: setColor)));
+ editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.style.color, setColor);
+
+ // inherit: false causes nothing to be merged in from theme
+ await tester.pumpWidget(buildFrame(const TextStyle(
+ fontSize: 24.0,
+ textBaseline: TextBaseline.alphabetic,
+ inherit: false,
+ )));
+ editableText = tester.widget(find.byType(EditableText));
+ expect(editableText.style.color, isNull);
+ });
+
+ testWidgets('style enforces required fields', (WidgetTester tester) async {
+ Widget buildFrame(TextStyle style) {
+ return MaterialApp(
+ home: Material(
+ child: SelectableText(
+ 'something',
+ style: style,
+ ),
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildFrame(const TextStyle(
+ inherit: false,
+ fontSize: 12.0,
+ textBaseline: TextBaseline.alphabetic,
+ )));
+ expect(tester.takeException(), isNull);
+
+ // With inherit not set to false, will pickup required fields from theme
+ await tester.pumpWidget(buildFrame(const TextStyle(
+ fontSize: 12.0,
+ )));
+ expect(tester.takeException(), isNull);
+
+ await tester.pumpWidget(buildFrame(const TextStyle(
+ inherit: false,
+ fontSize: 12.0,
+ )));
+ expect(tester.takeException(), isNotNull);
+ });
+
+ testWidgets(
+ 'tap moves cursor to the edge of the word it tapped on (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+ // We moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ );
+
+ // But don't trigger the toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'tap moves cursor to the position tapped (Android)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // We moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
+ );
+
+ // But don't trigger the toolbar.
+ expect(find.byType(FlatButton), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'two slow taps do not trigger a word selection (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Plain collapsed selection.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ );
+
+ // No toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'double tap selects word and first tap of double tap moves cursor (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump();
+
+ // Second tap selects the word around the cursor.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+
+ // Selected text shows 1 toolbar buttons.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets(
+ 'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump();
+
+ // Second tap selects the word around the cursor.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+
+ // Selected text shows 2 toolbar buttons: copy, select all
+ expect(find.byType(FlatButton), findsNWidgets(2));
+ },
+ );
+
+ testWidgets(
+ 'double tap on top of cursor also selects word (Android)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ // 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));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ 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 2 toolbar buttons: copy, select all
+ expect(find.byType(FlatButton), findsNWidgets(2));
+ },
+ );
+
+ testWidgets(
+ 'double tap hold selects word (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ final TestGesture gesture =
+ await tester.startGesture(selectableTextStart + const Offset(150.0, 5.0));
+ // Hold the press.
+ await tester.pump(const Duration(milliseconds: 500));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+
+ // Selected text shows 1 toolbar buttons.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+
+ await gesture.up();
+ await tester.pump();
+
+ // Still selected.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+ // The toolbar is still showing.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets(
+ 'tap after a double tap select is not affected (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0));
+ await tester.pump();
+
+ // Plain collapsed selection at the edge of first word. In iOS 12, the
+ // the first tap after a double tap ends up putting the cursor at where
+ // you tapped instead of the edge like every other single tap. This is
+ // likely a bug in iOS 12 and not present in other versions.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7),
+ );
+
+ // No toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'long press moves cursor to the exact long press position and shows toolbar (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Collapsed cursor for iOS long press.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
+ );
+
+ // Collapsed toolbar shows 2 buttons.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets(
+ 'long press selects word and shows toolbar (Android)',
+ (WidgetTester tester) async {
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 0, extentOffset: 7),
+ );
+
+ // Collapsed toolbar shows 2 buttons: copy, select all
+ expect(find.byType(FlatButton), findsNWidgets(2));
+ },
+ );
+
+ testWidgets(
+ 'long press tap cannot initiate a double tap (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // We ended up moving the cursor to the edge of the same word and dismissed
+ // the toolbar.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ );
+
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'long press drag moves the cursor under the drag and shows toolbar on lift (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ final TestGesture gesture =
+ await tester.startGesture(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Long press on iOS shows collapsed selection cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
+ );
+ // Cursor move doesn't trigger a toolbar initially.
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await gesture.moveBy(const Offset(50, 0));
+ await tester.pump();
+
+ // The selection position is now moved with the drag.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7, affinity: TextAffinity.downstream),
+ );
+ // Still no toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await gesture.moveBy(const Offset(50, 0));
+ await tester.pump();
+
+ // The selection position is now moved with the drag.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
+ );
+ // Still no toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await gesture.up();
+ await tester.pump();
+
+ // The selection isn't affected by the gesture lift.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
+ );
+ // The toolbar now shows up.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets('long press drag can edge scroll (iOS)', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
+ maxLines: 1,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable renderEditable = findRenderEditable(tester);
+
+ List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection(
+ const TextSelection.collapsed(offset: 66), // Last character's position.
+ );
+
+ expect(lastCharEndpoint.length, 1);
+ // Just testing the test and making sure that the last character is off
+ // the right side of the screen.
+ expect(lastCharEndpoint[0].point.dx, 924.0);
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ final TestGesture gesture =
+ await tester.startGesture(selectableTextStart + const Offset(300, 5));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 21),
+ );
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await gesture.moveBy(const Offset(600, 0));
+ // To the edge of the screen basically.
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 64, affinity: TextAffinity.downstream),
+ );
+ // Keep moving out.
+ await gesture.moveBy(const Offset(1, 0));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
+ );
+ await gesture.moveBy(const Offset(1, 0));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
+ ); // We're at the edge now.
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await gesture.up();
+ await tester.pump();
+
+ // The selection isn't affected by the gesture lift.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
+ );
+ // The toolbar now shows up.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+
+ lastCharEndpoint = renderEditable.getEndpointsForSelection(
+ const TextSelection.collapsed(offset: 66), // Last character's position.
+ );
+
+ expect(lastCharEndpoint.length, 1);
+ // The last character is now on screen near the right edge.
+ expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1));
+
+ final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection(
+ const TextSelection.collapsed(offset: 0), // First character's position.
+ );
+ expect(firstCharEndpoint.length, 1);
+ // The first character is now offscreen to the left.
+ expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-125, epsilon: 1));
+ });
+
+ testWidgets(
+ 'long tap after a double tap select is not affected (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // First tap moved the cursor to the beginning of the second word.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.longPressAt(selectableTextStart + const Offset(100.0, 5.0));
+ await tester.pump();
+
+ // Plain collapsed selection at the exact tap position.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7),
+ );
+
+ // Long press toolbar.
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+//convert
+ testWidgets(
+ 'double tap after a long tap is not affected (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump();
+
+ // Double tap selection.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets(
+ 'double tap chains work (iOS)',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: Center(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 0, extentOffset: 7),
+ );
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+
+ // Double tap selecting the same word somewhere else is fine.
+ await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 0, extentOffset: 7),
+ );
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ // First tap moved the cursor.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+ await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
+ await tester.pump(const Duration(milliseconds: 50));
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ },
+ );
+
+ testWidgets('force press does not select a word on (android)', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ );
+
+ final Offset offset = tester.getTopLeft(find.byType(SelectableText)) + const Offset(150.0, 5.0);
+
+ const int pointerValue = 1;
+ final TestGesture gesture = await tester.createGesture();
+ await gesture.downWithCustomEvent(
+ offset,
+ PointerDownEvent(
+ pointer: pointerValue,
+ position: offset,
+ pressure: 0.0,
+ pressureMax: 6.0,
+ pressureMin: 0.0,
+ ),
+ );
+ await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: offset + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // We don't want this gesture to select any word on Android.
+ expect(controller.selection, const TextSelection.collapsed(offset: -1));
+
+ await gesture.up();
+ await tester.pump();
+ expect(find.byType(FlatButton), findsNothing);
+ });
+
+ testWidgets('force press selects word (iOS)', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ const int pointerValue = 1;
+ final Offset offset = selectableTextStart + const Offset(150.0, 5.0);
+ final TestGesture gesture = await tester.createGesture();
+ await gesture.downWithCustomEvent(
+ offset,
+ PointerDownEvent(
+ pointer: pointerValue,
+ position: offset,
+ pressure: 0.0,
+ pressureMax: 6.0,
+ pressureMin: 0.0,
+ ),
+ );
+
+ await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // We expect the force press to select a word at the given location.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 8, extentOffset: 12),
+ );
+
+ await gesture.up();
+ await tester.pump();
+ expect(find.byType(CupertinoButton), findsNWidgets(1));
+ });
+
+ testWidgets('tap on non-force-press-supported devices work (iOS)', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: const Material(
+ child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
+ ),
+ ),
+ );
+
+ final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
+
+ const int pointerValue = 1;
+ final Offset offset = selectableTextStart + const Offset(150.0, 5.0);
+ final TestGesture gesture = await tester.createGesture();
+ await gesture.downWithCustomEvent(
+ offset,
+ PointerDownEvent(
+ pointer: pointerValue,
+ position: offset,
+ // iPhone 6 and below report 0 across the board.
+ pressure: 0,
+ pressureMax: 0,
+ pressureMin: 0,
+ ),
+ );
+
+ await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
+ await gesture.up();
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // The event should fallback to a normal tap and move the cursor.
+ // Single taps selects the edge of the word.
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
+ );
+
+ await tester.pump();
+ // Single taps shouldn't trigger the toolbar.
+ expect(find.byType(CupertinoButton), findsNothing);
+ });
+
+ testWidgets('default SelectableText debugFillProperties', (WidgetTester tester) async {
+ final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
+
+ const SelectableText('something').debugFillProperties(builder);
+
+ final List<String> description = builder.properties
+ .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
+ .map((DiagnosticsNode node) => node.toString()).toList();
+
+ expect(description, <String>['data: something']);
+ });
+
+ testWidgets('SelectableText implements debugFillProperties', (WidgetTester tester) async {
+ final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
+
+// properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
+// properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
+// properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+// properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+// properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
+// properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+// properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+// properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
+// properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
+// properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
+// properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
+// properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
+// properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
+ // Not checking controller, inputFormatters, focusNode
+ const SelectableText(
+ 'something',
+ style: TextStyle(color: Color(0xff00ff00)),
+ textAlign: TextAlign.end,
+ textDirection: TextDirection.ltr,
+ autofocus: true,
+ showCursor: true,
+ maxLines: 10,
+ cursorWidth: 1.0,
+ cursorRadius: Radius.zero,
+ cursorColor: Color(0xff00ff00),
+ scrollPhysics: ClampingScrollPhysics(),
+ enableInteractiveSelection: false,
+ ).debugFillProperties(builder);
+
+ final List<String> description = builder.properties
+ .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
+ .map((DiagnosticsNode node) => node.toString()).toList();
+
+ expect(description, <String>[
+ 'data: something',
+ 'style: TextStyle(inherit: true, color: Color(0xff00ff00))',
+ 'autofocus: true',
+ 'showCursor: true',
+ 'maxLines: 10',
+ 'textAlign: end',
+ 'textDirection: ltr',
+ 'cursorWidth: 1.0',
+ 'cursorRadius: Radius.circular(0.0)',
+ 'cursorColor: Color(0xff00ff00)',
+ 'selection disabled',
+ 'scrollPhysics: ClampingScrollPhysics',
+ ]);
+ });
+
+ testWidgets(
+ 'strut basic single line',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText('something'),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // This is the height of the decoration (24) plus the metrics from the default
+ // TextStyle of the theme (16).
+ const Size(129.0, 14.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut TextStyle increases height',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ style: TextStyle(fontSize: 20),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // Strut should inherit the TextStyle.fontSize by default and produce the
+ // same height as if it were disabled.
+ const Size(183.0, 20.0),
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ style: TextStyle(fontSize: 20),
+ strutStyle: StrutStyle.disabled,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // The height here should match the previous version with strut enabled.
+ const Size(183.0, 20.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut basic multi line',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ maxLines: 6,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ const Size(129.0, 84.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut no force small strut',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ maxLines: 6,
+ strutStyle: StrutStyle(
+ // The small strut is overtaken by the larger
+ // TextStyle fontSize.
+ fontSize: 5,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // When the strut's height is smaller than TextStyle's and forceStrutHeight
+ // is disabled, then the TextStyle takes precedence. Should be the same height
+ // as 'strut basic multi line'.
+ const Size(129.0, 84.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut no force large strut',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ maxLines: 6,
+ strutStyle: StrutStyle(
+ fontSize: 25,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // When the strut's height is larger than TextStyle's and forceStrutHeight
+ // is disabled, then the StrutStyle takes precedence.
+ const Size(129.0, 150.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut height override',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ maxLines: 3,
+ strutStyle: StrutStyle(
+ fontSize: 8,
+ forceStrutHeight: true,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // The smaller font size of strut make the field shorter than normal.
+ const Size(129.0, 24.0),
+ );
+ },
+ );
+
+ testWidgets(
+ 'strut forces field taller',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.android),
+ home: const Material(
+ child: Center(
+ child: SelectableText(
+ 'something',
+ maxLines: 3,
+ style: TextStyle(fontSize: 10),
+ strutStyle: StrutStyle(
+ fontSize: 18,
+ forceStrutHeight: true,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byType(SelectableText)),
+ // When the strut fontSize is larger than a provided TextStyle, the
+ // the strut's height takes precedence.
+ const Size(93.0, 54.0),
+ );
+ },
+ );
+
+ testWidgets('Caret center position', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: Container(
+ width: 300.0,
+ child: const SelectableText(
+ 'abcd',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable editable = findRenderEditable(tester);
+
+ Offset topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft,
+ );
+ expect(topLeft.dx, equals(306));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft,
+ );
+ expect(topLeft.dx, equals(292));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
+ );
+ expect(topLeft.dx, equals(278));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft,
+ );
+ expect(topLeft.dx, equals(264));
+ });
+
+ testWidgets('Caret indexes into trailing whitespace center align', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ overlay(
+ child: Container(
+ width: 300.0,
+ child: const SelectableText(
+ 'abcd ',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable editable = findRenderEditable(tester);
+
+ Offset topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 7)).topLeft,
+ );
+ expect(topLeft.dx, equals(362));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 8)).topLeft,
+ );
+ // Caret is capped at text length.
+ expect(topLeft.dx, equals(362));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft,
+ );
+ expect(topLeft.dx, equals(334));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft,
+ );
+ expect(topLeft.dx, equals(320));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
+ );
+ expect(topLeft.dx, equals(306));
+
+ topLeft = editable.localToGlobal(
+ editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft,
+ );
+ expect(topLeft.dx, equals(292));
+ });
+
+ testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async {
+ const String testText = 'lorem ipsum';
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(testText),
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ final RenderEditable renderEditable = state.renderEditable;
+
+ await tester.tapAt(const Offset(20, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pumpAndSettle();
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ // On Android, an empty app contains a single FadeTransition. The following
+ // two are the left and right text selection handles, respectively.
+ expect(transitions.length, 3);
+ final FadeTransition left = transitions[1];
+ final FadeTransition right = transitions[2];
+
+ expect(left.opacity.value, equals(1.0));
+ expect(right.opacity.value, equals(1.0));
+ });
+
+ testWidgets('iOS selection handles are rendered and not faded away', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ const String testText = 'lorem ipsum';
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText(testText),
+ ),
+ ),
+ );
+
+ final RenderEditable renderEditable =
+ tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
+
+ await tester.tapAt(const Offset(20, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pumpAndSettle();
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ expect(transitions.length, 2);
+ final FadeTransition left = transitions[0];
+ final FadeTransition right = transitions[1];
+
+ expect(left.opacity.value, equals(1.0));
+ expect(right.opacity.value, equals(1.0));
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('abc def ghi'),
+ ),
+ ),
+ );
+
+ // Long press at 'e' in 'def'.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ await tester.longPressAt(ePos);
+ await tester.pumpAndSettle();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
+ expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
+ });
+
+ testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('abc def ghi'),
+ ),
+ ),
+ );
+
+ // Double tap at 'e' in 'def'.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ await tester.tapAt(ePos);
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tapAt(ePos);
+ await tester.pump();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
+ expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
+ });
+
+ testWidgets(
+ 'Mouse tap does not show handles nor toolbar',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('abc def ghi'),
+ ),
+ ),
+ );
+
+ // Long press to trigger the selectable text.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final TestGesture gesture = await tester.startGesture(
+ ePos,
+ pointer: 7,
+ kind: PointerDeviceKind.mouse,
+ );
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
+ expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
+
+ await gesture.removePointer();
+ },
+ );
+
+ testWidgets(
+ 'Mouse long press does not show handles nor toolbar',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('abc def ghi'),
+ ),
+ ),
+ );
+
+ // Long press to trigger the selectable text.
+ final Offset ePos = textOffsetToPosition(tester, 5);
+ final TestGesture gesture = await tester.startGesture(
+ ePos,
+ pointer: 7,
+ kind: PointerDeviceKind.mouse,
+ );
+ await tester.pump(const Duration(seconds: 2));
+ await gesture.up();
+ await tester.pump();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
+ expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
+
+ await gesture.removePointer();
+ },
+ );
+
+ testWidgets(
+ 'Mouse double tap does not show handles nor toolbar',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: SelectableText('abc def ghi'),
+ ),
+ ),
+ );
+
+ // Double tap to trigger the selectable text.
+ final Offset selectableTextPos = tester.getCenter(find.byType(SelectableText));
+ final TestGesture gesture = await tester.startGesture(
+ selectableTextPos,
+ pointer: 7,
+ kind: PointerDeviceKind.mouse,
+ );
+ await tester.pump(const Duration(milliseconds: 50));
+ await gesture.up();
+ await tester.pump();
+ await gesture.down(selectableTextPos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ final EditableTextState editableText = tester.state(find.byType(EditableText));
+ expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
+ expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
+
+ await gesture.removePointer();
+ },
+ );
+}