diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index f5dd5c0480..85e0dd1357 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -139,6 +139,9 @@ class CupertinoTextField extends StatefulWidget { /// this is a single-line text field and will scroll horizontally when /// overflown. [maxLines] must not be zero. /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// /// See also: /// /// * [minLines] @@ -167,6 +170,8 @@ class CupertinoTextField extends StatefulWidget { this.style, this.strutStyle, this.textAlign = TextAlign.start, + this.readOnly = false, + this.showCursor, this.autofocus = false, this.obscureText = false, this.autocorrect = true, @@ -190,6 +195,7 @@ class CupertinoTextField extends StatefulWidget { this.scrollController, this.scrollPhysics, }) : assert(textAlign != null), + assert(readOnly != null), assert(autofocus != null), assert(obscureText != null), assert(autocorrect != null), @@ -313,6 +319,12 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.textAlign} final TextAlign textAlign; + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + /// {@macro flutter.widgets.editableText.autofocus} final bool autofocus; @@ -489,6 +501,8 @@ class _CupertinoTextFieldState extends State with AutomaticK // For backwards-compatibility, we treat a null kind the same as touch. bool _shouldShowSelectionToolbar = true; + bool _showSelectionHandles = false; + @override void initState() { super.initState(); @@ -644,8 +658,11 @@ class _CupertinoTextFieldState extends State with AutomaticK if (cause == SelectionChangedCause.longPress) { _editableText?.bringIntoView(selection.base); } - if (_shouldShowSelectionHandles(cause)) { - _editableText?.showHandles(); + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); } } @@ -812,6 +829,9 @@ class _CupertinoTextFieldState extends State with AutomaticK child: EditableText( key: _editableTextKey, controller: controller, + readOnly: widget.readOnly, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, focusNode: _effectiveFocusNode, keyboardType: widget.keyboardType, textInputAction: widget.textInputAction, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index b9fbdc5908..66ea57698d 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -123,7 +123,10 @@ class TextField extends StatefulWidget { /// characters may be entered, and the error counter and divider will /// switch to the [decoration.errorStyle] when the limit is exceeded. /// - /// The [textAlign], [autofocus], [obscureText], [autocorrect], + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect], /// [maxLengthEnforced], [scrollPadding], [maxLines], and [maxLength] /// arguments must not be null. /// @@ -143,6 +146,8 @@ class TextField extends StatefulWidget { this.strutStyle, this.textAlign = TextAlign.start, this.textDirection, + this.readOnly = false, + this.showCursor, this.autofocus = false, this.obscureText = false, this.autocorrect = true, @@ -168,6 +173,7 @@ class TextField extends StatefulWidget { this.scrollController, this.scrollPhysics, }) : assert(textAlign != null), + assert(readOnly != null), assert(autofocus != null), assert(obscureText != null), assert(autocorrect != null), @@ -289,6 +295,12 @@ class TextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.expands} final bool expands; + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + /// If [maxLength] is set to this value, only the "current input length" /// part of the character counter is shown. static const int noMaxLength = -1; @@ -522,6 +534,8 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi bool _shouldShowSelectionToolbar = true; + bool _showSelectionHandles = false; + InputDecoration _getEffectiveDecoration() { final MaterialLocalizations localizations = MaterialLocalizations.of(context); final ThemeData themeData = Theme.of(context); @@ -606,6 +620,11 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi if (wasEnabled && !isEnabled) { _effectiveFocusNode.unfocus(); } + if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly) { + if(_effectiveController.selection.isCollapsed) { + _showSelectionHandles = !widget.readOnly; + } + } } @override @@ -629,6 +648,9 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi if (cause == SelectionChangedCause.keyboard) return false; + if (widget.readOnly && _effectiveController.selection.isCollapsed) + return false; + if (cause == SelectionChangedCause.longPress) return true; @@ -639,10 +661,11 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi } void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { - // iOS cursor doesn't move via a selection handle. The scroll happens - // directly from new text selection changes. - if (_shouldShowSelectionHandles(cause)) { - _editableText?.showHandles(); + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); } switch (Theme.of(context).platform) { @@ -927,6 +950,9 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi Widget child = RepaintBoundary( child: EditableText( key: _editableTextKey, + readOnly: widget.readOnly, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, controller: controller, focusNode: focusNode, keyboardType: widget.keyboardType, diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index 1362e5117a..25b886bddb 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -84,6 +84,8 @@ class TextFormField extends FormField { TextDirection textDirection, TextAlign textAlign = TextAlign.start, bool autofocus = false, + bool readOnly = false, + bool showCursor, bool obscureText = false, bool autocorrect = true, bool autovalidate = false, @@ -108,6 +110,7 @@ class TextFormField extends FormField { }) : assert(initialValue == null || controller == null), assert(textAlign != null), assert(autofocus != null), + assert(readOnly != null), assert(obscureText != null), assert(autocorrect != null), assert(autovalidate != null), @@ -149,6 +152,8 @@ class TextFormField extends FormField { textDirection: textDirection, textCapitalization: textCapitalization, autofocus: autofocus, + readOnly: readOnly, + showCursor: showCursor, obscureText: obscureText, autocorrect: autocorrect, maxLengthEnforced: maxLengthEnforced, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index bed0fb5579..8b91c6ff2b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -238,6 +238,13 @@ class RenderEditable extends RenderBox { /// 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; + /// The pixel ratio of the current device. /// /// Should be obtained by querying MediaQuery for the devicePixelRatio. diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 70c467e38a..e4141d4f10 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -585,6 +585,18 @@ abstract class TextSelectionDelegate { /// Brings the provided [TextPosition] into the visible area of the text /// input. void bringIntoView(TextPosition position); + + /// Whether cut is enabled, must not be null. + bool get cutEnabled => true; + + /// Whether copy is enabled, must not be null. + bool get copyEnabled => true; + + /// Whether paste is enabled, must not be null. + bool get pasteEnabled => true; + + /// Whether select all is enabled, must not be null. + bool get selectAllEnabled => true; } /// An interface to receive information from [TextInput]. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 7597c8a078..1c23e12a32 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -260,13 +260,17 @@ class EditableText extends StatefulWidget { /// [TextInputType.text] unless [maxLines] is greater than one, when it will /// default to [TextInputType.multiline]. /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// /// The [controller], [focusNode], [style], [cursorColor], [backgroundCursorColor], - /// [textAlign], [dragStartBehavior] and [rendererIgnoresPointer] arguments - /// must not be null. + /// [textAlign], [dragStartBehavior], [rendererIgnoresPointer] and [readOnly] + /// arguments must not be null. EditableText({ Key key, @required this.controller, @required this.focusNode, + this.readOnly = false, this.obscureText = false, this.autocorrect = true, @required this.style, @@ -281,6 +285,8 @@ class EditableText extends StatefulWidget { this.minLines, this.expands = false, this.autofocus = false, + bool showCursor, + this.showSelectionHandles = false, this.selectionColor, this.selectionControls, TextInputType keyboardType, @@ -308,6 +314,8 @@ class EditableText extends StatefulWidget { assert(focusNode != null), assert(obscureText != null), assert(autocorrect != null), + assert(showSelectionHandles != null), + assert(readOnly != null), assert(style != null), assert(cursorColor != null), assert(cursorOpacityAnimates != null), @@ -337,6 +345,7 @@ class EditableText extends StatefulWidget { ..addAll(inputFormatters ?? const Iterable.empty()) ) : inputFormatters, + showCursor = showCursor ?? !readOnly, super(key: key); /// Controls the text being edited. @@ -355,6 +364,38 @@ class EditableText extends StatefulWidget { /// {@endtemplate} final bool obscureText; + /// {@template flutter.widgets.editableText.readOnly} + /// Whether the text can be changed. + /// + /// When this is set to true, the text cannot be modified + /// by any shortcut or keyboard operation. The text is still selectable. + /// + /// Defaults to false. Must not be null. + /// {@endtemplate} + final bool readOnly; + + /// Whether to show selection handles. + /// + /// When a selection is active, there will be two handles at each side of + /// boundary, or one handle if the selection is collapsed. The handles can be + /// dragged to adjust the selection. + /// + /// See also: + /// + /// * [showCursor], which controls the visibility of the cursor.. + final bool showSelectionHandles; + + /// {@template flutter.widgets.editableText.showCursor} + /// Whether to show cursor. + /// + /// The cursor refers to the blinking caret when the [EditableText] is focused. + /// + /// See also: + /// + /// * [showSelectionHandles], which controls the visibility of the selection handles.. + /// {@endtemplate} + final bool showCursor; + /// {@template flutter.widgets.editableText.autocorrect} /// Whether to enable autocorrection. /// @@ -822,6 +863,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); + @override + bool get cutEnabled => !widget.readOnly; + + @override + bool get copyEnabled => true; + + @override + bool get pasteEnabled => !widget.readOnly; + + @override + bool get selectAllEnabled => true; + // State lifecycle: @override @@ -836,6 +889,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _cursorBlinkOpacityController.addListener(_onCursorColorTick); _floatingCursorResetController = AnimationController(vsync: this); _floatingCursorResetController.addListener(_onFloatingCursorResetTick); + _cursorVisibilityNotifier.value = widget.showCursor; } @override @@ -855,6 +909,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien widget.controller.addListener(_didChangeTextEditingValue); _updateRemoteEditingValueIfNeeded(); } + if (widget.controller.selection != oldWidget.controller.selection) { + _selectionOverlay?.update(_value); + } + _selectionOverlay?.handlesVisible = widget.showSelectionHandles; if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); _focusAttachment?.detach(); @@ -862,6 +920,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } + if (widget.readOnly) { + _closeInputConnectionIfNeeded(); + } else { + if (oldWidget.readOnly && _hasFocus) + _openInputConnection(); + } } @override @@ -886,6 +950,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void updateEditingValue(TextEditingValue value) { + // Since we still have to support keyboard select, this is the best place + // to disable text updating. + if (widget.readOnly) { + return; + } if (value.text != _value.text) { _hideSelectionOverlayIfNeeded(); _showCaretOnScreen(); @@ -1067,6 +1136,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached; void _openInputConnection() { + if (widget.readOnly) { + return; + } if (!_hasInputConnection) { final TextEditingValue localValue = _value; _lastKnownRemoteTextEditingValue = localValue; @@ -1156,7 +1228,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien dragStartBehavior: widget.dragStartBehavior, onSelectionHandleTapped: widget.onSelectionHandleTapped, ); - + _selectionOverlay.handlesVisible = widget.showSelectionHandles; + _selectionOverlay.showHandles(); if (widget.onSelectionChanged != null) widget.onSelectionChanged(selection, cause); } @@ -1239,7 +1312,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien void _onCursorColorTick() { renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); - _cursorVisibilityNotifier.value = _cursorBlinkOpacityController.value > 0; + _cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0; } /// Whether the blinking cursor is actually visible at this precise moment @@ -1409,26 +1482,20 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - /// Shows the handles at the location of the current selection. - void showHandles() { - assert(_selectionOverlay != null); - _selectionOverlay.showHandles(); - } - VoidCallback _semanticsOnCopy(TextSelectionControls controls) { - return widget.selectionEnabled && _hasFocus && controls?.canCopy(this) == true + return widget.selectionEnabled && copyEnabled && _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null; } VoidCallback _semanticsOnCut(TextSelectionControls controls) { - return widget.selectionEnabled && _hasFocus && controls?.canCut(this) == true + return widget.selectionEnabled && cutEnabled && _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null; } VoidCallback _semanticsOnPaste(TextSelectionControls controls) { - return widget.selectionEnabled &&_hasFocus && controls?.canPaste(this) == true + return widget.selectionEnabled && pasteEnabled &&_hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null; } @@ -1460,7 +1527,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien cursorColor: _cursorColor, backgroundCursorColor: widget.backgroundCursorColor, showCursor: EditableText.debugDeterministicCursor - ? ValueNotifier(true) + ? ValueNotifier(widget.showCursor) : _cursorVisibilityNotifier, hasFocus: _hasFocus, maxLines: widget.maxLines, @@ -1497,11 +1564,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// By default makes text in composing range appear as underlined. /// Descendants can override this method to customize appearance of text. TextSpan buildTextSpan() { - if (!widget.obscureText && _value.composing.isValid) { + // 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: [ diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index b65b4f9eea..7dfa5ee9a2 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -20,6 +20,7 @@ import 'gesture_detector.dart'; import 'overlay.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; +import 'visibility.dart'; export 'package:flutter/services.dart' show TextSelectionDelegate; @@ -130,7 +131,7 @@ abstract class TextSelectionControls { /// Subclasses can use this to decide if they should expose the cut /// functionality to the user. bool canCut(TextSelectionDelegate delegate) { - return !delegate.textEditingValue.selection.isCollapsed; + return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed; } /// Whether the current selection of the text field managed by the given @@ -141,7 +142,7 @@ abstract class TextSelectionControls { /// Subclasses can use this to decide if they should expose the copy /// functionality to the user. bool canCopy(TextSelectionDelegate delegate) { - return !delegate.textEditingValue.selection.isCollapsed; + return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed; } /// Whether the current [Clipboard] content can be pasted into the text field @@ -151,7 +152,7 @@ abstract class TextSelectionControls { /// functionality to the user. bool canPaste(TextSelectionDelegate delegate) { // TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254 - return true; + return delegate.pasteEnabled; } /// Whether the current selection of the text field managed by the given @@ -161,7 +162,7 @@ abstract class TextSelectionControls { /// Subclasses can use this to decide if they should expose the select all /// functionality to the user. bool canSelectAll(TextSelectionDelegate delegate) { - return delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; + return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; } /// Copy the current selection of the text field managed by the given @@ -267,11 +268,14 @@ class TextSelectionOverlay { @required this.layerLink, @required this.renderObject, this.selectionControls, + bool handlesVisible = false, this.selectionDelegate, this.dragStartBehavior = DragStartBehavior.start, this.onSelectionHandleTapped, }) : assert(value != null), assert(context != null), + assert(handlesVisible != null), + _handlesVisible = handlesVisible, _value = value { final OverlayState overlay = Overlay.of(context); assert(overlay != null, @@ -337,6 +341,10 @@ class TextSelectionOverlay { AnimationController _toolbarController; Animation get _toolbarOpacity => _toolbarController.view; + /// Retrieve current value. + @visibleForTesting + TextEditingValue get value => _value; + TextEditingValue _value; /// A pair of handles. If this is non-null, there are always 2, though the @@ -348,16 +356,58 @@ class TextSelectionOverlay { TextSelection get _selection => _value.selection; - /// Shows the handles by inserting them into the [context]'s overlay. + /// Whether selection handles are visible. + /// + /// Set to false if you want to hide the handles. Use this property to show or + /// hide the handle without rebuilding them. + /// + /// If this method is called while the [SchedulerBinding.schedulerPhase] is + /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or + /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed + /// until the post-frame callbacks phase. Otherwise the update is done + /// synchronously. This means that it is safe to call during builds, but also + /// that if you do call this during a build, the UI will not update until the + /// next frame (i.e. many milliseconds later). + /// + /// Defaults to false. + bool get handlesVisible => _handlesVisible; + bool _handlesVisible = false; + set handlesVisible(bool visible) { + assert(visible != null); + if (_handlesVisible == visible) + return; + _handlesVisible = visible; + // If we are in build state, it will be too late to update visibility. + // We will need to schedule the build in next frame. + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild); + } else { + _markNeedsBuild(); + } + } + + /// Builds the handles by inserting them into the [context]'s overlay. void showHandles() { assert(_handles == null); _handles = [ OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)), OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)), ]; + + + Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles); } + /// Destroys the handles by removing them from overlay. + void hideHandles() { + if (_handles != null) { + _handles[0].remove(); + _handles[1].remove(); + _handles = null; + } + } + /// Shows the toolbar by inserting it into the [context]'s overlay. void showToolbar() { assert(_toolbar == null); @@ -403,7 +453,7 @@ class TextSelectionOverlay { } /// Whether the handles are currently visible. - bool get handlesAreVisible => _handles != null; + bool get handlesAreVisible => _handles != null && handlesVisible; /// Whether the toolbar is currently visible. bool get toolbarIsVisible => _toolbar != null; @@ -440,16 +490,18 @@ class TextSelectionOverlay { if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || selectionControls == null) return Container(); // hide the second handle when collapsed - return _TextSelectionHandleOverlay( - onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); }, - onSelectionHandleTapped: onSelectionHandleTapped, - layerLink: layerLink, - renderObject: renderObject, - selection: _selection, - selectionControls: selectionControls, - position: position, - dragStartBehavior: dragStartBehavior, - ); + return Visibility( + visible: handlesVisible, + child: _TextSelectionHandleOverlay( + onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); }, + onSelectionHandleTapped: onSelectionHandleTapped, + layerLink: layerLink, + renderObject: renderObject, + selection: _selection, + selectionControls: selectionControls, + position: position, + dragStartBehavior: dragStartBehavior, + )); } Widget _buildToolbar(BuildContext context) { diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 030bc6da10..b77fe83cc4 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1151,6 +1151,43 @@ void main() { expect(text.style.fontWeight, FontWeight.w300); }); + testWidgets('Read only text field', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: [ + CupertinoTextField( + controller: controller, + readOnly: true, + ), + ], + ), + ), + ); + // Read only text field cannot open keyboard. + await tester.showKeyboard(find.byType(CupertinoTextField)); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.longPressAt( + tester.getTopRight(find.text('readonly')) + ); + + await tester.pump(); + + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsOneWidget); + + await tester.tap(find.text('Select All')); + await tester.pump(); + + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + }); + testWidgets('copy paste', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( @@ -2015,7 +2052,7 @@ void main() { expect(controller.selection.isCollapsed, isTrue); expect(controller.selection.baseOffset, 4); await tester.tapAt(ePos, pointer: 7); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 7); @@ -2035,7 +2072,6 @@ void main() { await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); - expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 5); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 525d665fd7..e3011ab1d3 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -56,6 +56,19 @@ class WidgetsLocalizationsDelegate extends LocalizationsDelegate>[ @@ -68,15 +81,7 @@ Widget overlay({ Widget child }) { data: const MediaQueryData(size: Size(800.0, 600.0)), child: Overlay( initialEntries: [ - OverlayEntry( - builder: (BuildContext context) { - return Center( - child: Material( - child: child, - ), - ); - }, - ), + entry ], ), ), @@ -473,7 +478,7 @@ void main() { expect(state.showToolbar(), true); // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. - await tester.pump(); + await tester.pumpAndSettle(); await tester.pump(const Duration(seconds: 1)); // Sanity check that the toolbar widget exists. @@ -813,6 +818,194 @@ void main() { await gesture.removePointer(); }); + testWidgets('Read only text field basic', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + readOnly: true, + ), + ) + ); + // Read only text field cannot open keyboard. + await tester.showKeyboard(find.byType(TextField)); + expect(tester.testTextInput.hasAnyClients, false); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + // Long press on the 'd' character of text 'readOnly' to show context menu. + const int dIndex = 3; + 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('Sawping controllers should update selection', (WidgetTester tester) async { + TextEditingController controller = TextEditingController(text: 'readonly'); + final OverlayEntry entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField( + controller: controller, + readOnly: true, + ), + ), + ); + }, + ); + await tester.pumpWidget(overlayWithEntry(entry)); + const int dIndex = 3; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pumpAndSettle(); + final EditableTextState state = tester.state(find.byType(EditableText)); + TextSelection currentOverlaySelection = + state.selectionOverlay.value.selection; + expect(currentOverlaySelection.baseOffset, 0); + expect(currentOverlaySelection.extentOffset, 8); + + // Update selection from [0 to 8] to [1 to 7]. + controller = TextEditingController.fromValue( + controller.value.copyWith(selection: const TextSelection( + baseOffset: 1, extentOffset: 7 + )) + ); + + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + + await tester.pump(); + currentOverlaySelection = state.selectionOverlay.value.selection; + expect(currentOverlaySelection.baseOffset, 1); + expect(currentOverlaySelection.extentOffset, 7); + }); + + testWidgets('Read only text should not compose', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController.fromValue( + const TextEditingValue( + text: 'readonly', + composing: TextRange(start: 0, end: 8) // Simulate text composing. + ) + ); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + readOnly: true, + ), + ) + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + // There should be no composing. + expect(renderEditable.isComposingText, false); + }); + + testWidgets('Dynamically switching between read only and not read only should hide or show collapse cursor', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + bool readOnly = true; + final OverlayEntry entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField( + controller: controller, + readOnly: readOnly, + ), + ), + ); + }, + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + + readOnly = false; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pumpAndSettle(); + expect(editableText.selectionOverlay.handlesAreVisible, isTrue); + + readOnly = true; + entry.markNeedsBuild(); + await tester.pumpAndSettle(); + expect(editableText.selectionOverlay.handlesAreVisible, isFalse); + }); + + testWidgets('Dynamically switching to read only should close input connection', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + bool readOnly = false; + final OverlayEntry entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField( + controller: controller, + readOnly: readOnly, + ), + ), + ); + }, + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, true); + + readOnly = true; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + }); + + testWidgets('Dynamically switching to non read only should open input connection', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + bool readOnly = true; + final OverlayEntry entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField( + controller: controller, + readOnly: readOnly, + ), + ), + ); + }, + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + + readOnly = false; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, true); + }); + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -3038,10 +3231,12 @@ void main() { await tester.enterText(find.byType(TextField), testValue); await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); await tester.tap(find.byType(TextField)); await tester.pumpAndSettle(); - sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown, SHIFT_ON + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown, SHIFT_ON expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); }); @@ -3084,17 +3279,19 @@ void main() { await tester.enterText(find.byType(TextField), testValue); await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); await tester.tap(find.byType(TextField)); await tester.pumpAndSettle(); - sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown await tester.pumpAndSettle(); expect(controller.selection.extentOffset - controller.selection.baseOffset, 11); - sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup await tester.pumpAndSettle(); - sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown await tester.pumpAndSettle(); expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); @@ -3150,6 +3347,25 @@ void main() { expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); }); + + testWidgets('Read only keyboard selection test', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'readonly'); + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + readOnly: true, + ), + ) + ); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown, SHIFT_ON + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }); }); const int _kXKeyCode = 52; @@ -3450,11 +3666,13 @@ void main() { await tester.enterText(find.byType(TextField).first, testValue); await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); await tester.tap(find.byType(TextField).first); await tester.pumpAndSettle(); for (int i = 0; i < 5; i += 1) { - sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown await tester.pumpAndSettle(); } @@ -3488,7 +3706,7 @@ void main() { ); for (int i = 0; i < 5; i += 1) { - sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown await tester.pumpAndSettle(); } @@ -3532,31 +3750,34 @@ void main() { ), ); - await tester.idle(); - await tester.tap(find.byType(TextField).first); const String testValue = 'a big house'; await tester.enterText(find.byType(TextField).first, testValue); + await tester.idle(); + await tester.pump(); + await tester.idle(); + await tester.tap(find.byType(TextField).first); await tester.pumpAndSettle(); for (int i = 0; i < 5; i += 1) { - sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + 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.enterText(find.byType(TextField).last, testValue); + await tester.idle(); + await tester.pump(); + await tester.idle(); await tester.tap(find.byType(TextField).last); - - await tester.enterText(find.byType(TextField).last, testValue); - await tester.pumpAndSettle(); for (int i = 0; i < 5; i += 1) { - sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + sendKeyEventWithCode(21, true, true, false); // LEFT_ARROW keydown await tester.pumpAndSettle(); } @@ -6338,6 +6559,7 @@ void main() { // Tap to position the cursor and show the selection handles. final Offset ePos = textOffsetToPosition(tester, 5); // Index of 'e'. await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); final EditableTextState editableText = tester.state(find.byType(EditableText)); expect(editableText.selectionOverlay.toolbarIsVisible, isFalse); diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index 686c4bc7c9..8fefc4482b 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -5,6 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; + +import '../rendering/mock_canvas.dart'; void main() { testWidgets('Passes textAlign to underlying TextField', (WidgetTester tester) async { @@ -213,4 +216,46 @@ void main() { expect(find.text('5 of 10'), findsOneWidget); }); + + testWidgets('readonly text form field will hide cursor by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + initialValue: 'readonly', + readOnly: true, + ), + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextFormField)); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.longPress(find.byType(TextFormField)); + await tester.pump(); + + // Context menu should not have paste. + expect(find.text('SELECT ALL'), findsOneWidget); + expect(find.text('PASTE'), findsNothing); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + // Make sure it does not paint caret for a period of time. + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + }); } diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart index 955ee15041..0d73155610 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -318,6 +318,34 @@ void main() { EditableText.debugDeterministicCursor = false; }); + testWidgets('Cursor does not show when showCursor set to false', (WidgetTester tester) async { + const Widget widget = MaterialApp( + home: Material( + child: TextField( + showCursor: false, + maxLines: 3, + ), + ), + ); + await tester.pumpWidget(widget); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + // Make sure it does not paint for a period of time. + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + }); + testWidgets('Cursor radius is 2.0 on iOS', (WidgetTester tester) async { final Widget widget = MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 5218a68ab3..ec9748b752 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1893,6 +1893,7 @@ void main() { child: SizedBox( width: 100, child: EditableText( + showSelectionHandles: true, controller: controller, focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android).black.subhead, @@ -2003,7 +2004,7 @@ void main() { throw TestFailure('HandlePositionInViewport can\'t be null.'); } } - + expect(state.selectionOverlay.handlesAreVisible, isTrue); testPosition(container[0].offset.dx, leftPosition); testPosition(container[1].offset.dx, rightPosition); } @@ -2011,7 +2012,6 @@ void main() { // Select the first word. Both handles should be visible. await tester.tapAt(const Offset(20, 10)); renderEditable.selectWord(cause: SelectionChangedCause.longPress); - state.showHandles(); await tester.pump(); await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true); @@ -2033,7 +2033,6 @@ void main() { // Now that the second word has been dragged fully into view, select it. await tester.tapAt(const Offset(80, 10)); renderEditable.selectWord(cause: SelectionChangedCause.longPress); - state.showHandles(); await tester.pump(); await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true); @@ -2060,6 +2059,7 @@ void main() { width: 100, child: EditableText( controller: controller, + showSelectionHandles: true, focusNode: FocusNode(), style: Typography(platform: TargetPlatform.android).black.subhead, cursorColor: Colors.blue, @@ -2078,7 +2078,6 @@ void main() { // Select the first word. Both handles should be visible. await tester.tapAt(const Offset(20, 10)); state.renderEditable.selectWord(cause: SelectionChangedCause.longPress); - state.showHandles(); await tester.pump(); final List container = find.byType(CompositedTransformFollower) @@ -2100,6 +2099,7 @@ void main() { 70.0 + kMinInteractiveSize, ), ); + expect(state.selectionOverlay.handlesAreVisible, isTrue); expect(controller.selection.base.offset, 0); expect(controller.selection.extent.offset, 5); }); @@ -2119,6 +2119,7 @@ void main() { child: SizedBox( width: 100, child: EditableText( + showSelectionHandles: true, controller: controller, focusNode: FocusNode(), style: Typography(platform: TargetPlatform.iOS).black.subhead, @@ -2228,7 +2229,7 @@ void main() { throw TestFailure('HandlePositionInViewport can\'t be null.'); } } - + expect(state.selectionOverlay.handlesAreVisible, isTrue); testPosition(container[0].offset.dx, leftPosition); testPosition(container[1].offset.dx, rightPosition); } @@ -2236,7 +2237,6 @@ void main() { // Select the first word. Both handles should be visible. await tester.tapAt(const Offset(20, 10)); renderEditable.selectWord(cause: SelectionChangedCause.longPress); - state.showHandles(); await tester.pump(); await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true); @@ -2258,7 +2258,6 @@ void main() { // Now that the second word has been dragged fully into view, select it. await tester.tapAt(const Offset(80, 10)); renderEditable.selectWord(cause: SelectionChangedCause.longPress); - state.showHandles(); await tester.pump(); await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true); diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 15472cbd69..9c663df4a0 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -58,6 +58,9 @@ class TestTextInput { bool get isRegistered => _isRegistered; bool _isRegistered = false; + /// Whether there are any active clients listening to text input. + bool get hasAnyClients => _client > 0; + int _client = 0; /// Arguments supplied to the TextInput.setClient method call.