From 4f4050bf4746e42f19303dd2d1ef6e9819c4367b Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Thu, 18 Oct 2018 09:14:27 -0700 Subject: [PATCH] Support for disabling interactive TextField caret and selection (#22924) Make it possible to disable TextField's default handlers for tap and long press. If enableInteractiveSelection is false then taps no longer move the text caret and long-press no longer selects text and shows the cut/copy/paste menu. Accessibility is similarly limited. --- .../flutter/lib/src/material/text_field.dart | 25 +++-- .../lib/src/material/text_form_field.dart | 3 + .../flutter/lib/src/rendering/editable.dart | 25 ++++- .../lib/src/widgets/editable_text.dart | 47 +++++++- .../test/material/text_field_test.dart | 101 ++++++++++++++++++ 5 files changed, 186 insertions(+), 15 deletions(-) diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 044011a45d..2aabd0159d 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -88,8 +88,9 @@ 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], and [autocorrect] arguments - /// must not be null. + /// The [textAlign], [autofocus], [obscureText], [autocorrect], + /// [maxLengthEnforced], [scrollPadding], [maxLines], [maxLength], + /// and [enableInteractiveSelection] arguments must not be null. /// /// See also: /// @@ -121,6 +122,7 @@ class TextField extends StatefulWidget { this.cursorColor, this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), + this.enableInteractiveSelection = true, }) : assert(textAlign != null), assert(autofocus != null), assert(obscureText != null), @@ -130,6 +132,7 @@ class TextField extends StatefulWidget { assert(maxLines == null || maxLines > 0), assert(maxLength == null || maxLength > 0), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + assert(enableInteractiveSelection != null), super(key: key); /// Controls the text being edited. @@ -343,6 +346,9 @@ class TextField extends StatefulWidget { /// Defaults to EdgeInserts.all(20.0). final EdgeInsets scrollPadding; + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + @override _TextFieldState createState() => _TextFieldState(); @@ -487,7 +493,8 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi } void _handleTap() { - _renderEditable.handleTap(); + if (widget.enableInteractiveSelection) + _renderEditable.handleTap(); _requestKeyboard(); _confirmCurrentSplash(); } @@ -497,7 +504,8 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi } void _handleLongPress() { - _renderEditable.handleLongPress(); + if (widget.enableInteractiveSelection) + _renderEditable.handleLongPress(); _confirmCurrentSplash(); } @@ -567,9 +575,11 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi autocorrect: widget.autocorrect, maxLines: widget.maxLines, selectionColor: themeData.textSelectionColor, - selectionControls: themeData.platform == TargetPlatform.iOS - ? cupertinoTextSelectionControls - : materialTextSelectionControls, + selectionControls: widget.enableInteractiveSelection + ? (themeData.platform == TargetPlatform.iOS + ? cupertinoTextSelectionControls + : materialTextSelectionControls) + : null, onChanged: widget.onChanged, onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, @@ -581,6 +591,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor, scrollPadding: widget.scrollPadding, keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: widget.enableInteractiveSelection, ), ); diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index bf2834a2be..7934d4adf9 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -74,6 +74,7 @@ class TextFormField extends FormField { bool enabled = true, Brightness keyboardAppearance, EdgeInsets scrollPadding = const EdgeInsets.all(20.0), + bool enableInteractiveSelection = true, }) : assert(initialValue == null || controller == null), assert(textAlign != null), assert(autofocus != null), @@ -84,6 +85,7 @@ class TextFormField extends FormField { assert(scrollPadding != null), assert(maxLines == null || maxLines > 0), assert(maxLength == null || maxLength > 0), + assert(enableInteractiveSelection != null), super( key: key, initialValue: controller != null ? controller.text : (initialValue ?? ''), @@ -117,6 +119,7 @@ class TextFormField extends FormField { enabled: enabled, scrollPadding: scrollPadding, keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: enableInteractiveSelection, ); }, ); diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 7c3d8410e4..551d379396 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -118,6 +118,8 @@ class RenderEditable extends RenderBox { /// /// The [offset] is required and must not be null. You can use [new /// ViewportOffset.zero] if you have no need for scrolling. + /// + /// The [enableInteractiveSelection] argument must not be null. RenderEditable({ TextSpan text, @required TextDirection textDirection, @@ -137,6 +139,7 @@ class RenderEditable extends RenderBox { Locale locale, double cursorWidth = 1.0, Radius cursorRadius, + bool enableInteractiveSelection = true, @required this.textSelectionDelegate, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), @@ -145,8 +148,9 @@ class RenderEditable extends RenderBox { assert(offset != null), assert(ignorePointer != null), assert(obscureText != null), + assert(enableInteractiveSelection != null), assert(textSelectionDelegate != null), - _textPainter = TextPainter( + _textPainter = TextPainter( text: text, textAlign: textAlign, textDirection: textDirection, @@ -162,6 +166,7 @@ class RenderEditable extends RenderBox { _offset = offset, _cursorWidth = cursorWidth, _cursorRadius = cursorRadius, + _enableInteractiveSelection = enableInteractiveSelection, _obscureText = obscureText { assert(_showCursor != null); assert(!_showCursor.value || cursorColor != null); @@ -692,6 +697,20 @@ class RenderEditable extends RenderBox { markNeedsPaint(); } + /// If false, [describeSemanticsConfiguration] will not set the + /// configuration's cursor motion or set selection callbacks. + /// + /// True by default. + bool get enableInteractiveSelection => _enableInteractiveSelection; + bool _enableInteractiveSelection; + set enableInteractiveSelection(bool value) { + if (_enableInteractiveSelection == value) + return; + _enableInteractiveSelection = value; + markNeedsTextLayout(); + markNeedsSemanticsUpdate(); + } + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); @@ -705,10 +724,10 @@ class RenderEditable extends RenderBox { ..isFocused = hasFocus ..isTextField = true; - if (hasFocus) + if (hasFocus && enableInteractiveSelection) config.onSetSelection = _handleSetSelection; - if (_selection?.isValid == true) { + if (enableInteractiveSelection && _selection?.isValid == true) { config.textSelection = _selection; if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) { config diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index c374cc4735..da88e15cda 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -183,7 +183,8 @@ class EditableText extends StatefulWidget { /// default to [TextInputType.multiline]. /// /// The [controller], [focusNode], [style], [cursorColor], [textAlign], - /// and [rendererIgnoresPointer], arguments must not be null. + /// [rendererIgnoresPointer], and [enableInteractiveSelection] arguments must + /// not be null. EditableText({ Key key, @required this.controller, @@ -213,6 +214,7 @@ class EditableText extends StatefulWidget { this.cursorRadius, this.scrollPadding = const EdgeInsets.all(20.0), this.keyboardAppearance = Brightness.light, + this.enableInteractiveSelection = true, }) : assert(controller != null), assert(focusNode != null), assert(obscureText != null), @@ -224,6 +226,7 @@ class EditableText extends StatefulWidget { assert(autofocus != null), assert(rendererIgnoresPointer != null), assert(scrollPadding != null), + assert(enableInteractiveSelection != null), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), inputFormatters = maxLines == 1 ? ( @@ -399,6 +402,17 @@ class EditableText extends StatefulWidget { /// Defaults to EdgeInserts.all(20.0). final EdgeInsets scrollPadding; + /// {@template flutter.widgets.editableText.enableInteractiveSelection} + /// If true, then long-pressing this TextField will select text and show the + /// cut/copy/paste menu, and tapping will move the text caret. + /// + /// True by default. + /// + /// If false, most of the accessibility support for selecting text, copy + /// and paste, and moving the caret will be disabled. + /// {@endtemplate} + final bool enableInteractiveSelection; + /// Setting this property to true makes the cursor stop blinking and stay visible on the screen continually. /// This property is most useful for testing purposes. /// @@ -864,12 +878,30 @@ class EditableTextState extends State with AutomaticKeepAliveClien _selectionOverlay?.hide(); } + VoidCallback _semanticsOnCopy(TextSelectionControls controls) { + return widget.enableInteractiveSelection && _hasFocus && controls?.canCopy(this) == true + ? () => controls.handleCopy(this) + : null; + } + + VoidCallback _semanticsOnCut(TextSelectionControls controls) { + return widget.enableInteractiveSelection && _hasFocus && controls?.canCut(this) == true + ? () => controls.handleCut(this) + : null; + } + + VoidCallback _semanticsOnPaste(TextSelectionControls controls) { + return widget.enableInteractiveSelection &&_hasFocus && controls?.canPaste(this) == true + ? () => controls.handlePaste(this) + : null; + } + @override Widget build(BuildContext context) { FocusScope.of(context).reparentIfNeeded(widget.focusNode); super.build(context); // See AutomaticKeepAliveClientMixin. - final TextSelectionControls controls = widget.selectionControls; + final TextSelectionControls controls = widget.selectionControls; return Scrollable( excludeFromSemantics: true, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, @@ -879,9 +911,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien return CompositedTransformTarget( link: _layerLink, child: Semantics( - onCopy: _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null, - onCut: _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null, - onPaste: _hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null, + onCopy: _semanticsOnCopy(controls), + onCut: _semanticsOnCut(controls), + onPaste: _semanticsOnPaste(controls), child: _Editable( key: _editableKey, textSpan: buildTextSpan(), @@ -903,6 +935,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien rendererIgnoresPointer: widget.rendererIgnoresPointer, cursorWidth: widget.cursorWidth, cursorRadius: widget.cursorRadius, + enableInteractiveSelection: widget.enableInteractiveSelection, textSelectionDelegate: this, ), ), @@ -967,9 +1000,11 @@ class _Editable extends LeafRenderObjectWidget { this.rendererIgnoresPointer = false, this.cursorWidth, this.cursorRadius, + this.enableInteractiveSelection = true, this.textSelectionDelegate, }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), + assert(enableInteractiveSelection != null), super(key: key); final TextSpan textSpan; @@ -991,6 +1026,7 @@ class _Editable extends LeafRenderObjectWidget { final bool rendererIgnoresPointer; final double cursorWidth; final Radius cursorRadius; + final bool enableInteractiveSelection; final TextSelectionDelegate textSelectionDelegate; @override @@ -1014,6 +1050,7 @@ class _Editable extends LeafRenderObjectWidget { obscureText: obscureText, cursorWidth: cursorWidth, cursorRadius: cursorRadius, + enableInteractiveSelection: enableInteractiveSelection, textSelectionDelegate: textSelectionDelegate, ); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 2a324841bd..5439ab00a7 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -404,6 +404,34 @@ void main() { expect(controller.selection.extentOffset, tapIndex); }); + testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + enableInteractiveSelection: false, + ), + ) + ); + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap would ordinarily reposition the caret. + final int tapIndex = testValue.indexOf('e'); + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + }); + testWidgets('Can long press to select', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -434,6 +462,37 @@ void main() { expect(controller.selection.extentOffset, testValue.indexOf('f')+1); }); + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + enableInteractiveSelection: false, + ), + ) + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + }); + testWidgets('Can drag handles to change selection', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -2530,6 +2589,48 @@ void main() { semantics.dispose(); }); + testWidgets('TextField semantics, enableInteractiveSelection = false', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final TextEditingController controller = TextEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + enableInteractiveSelection: false, + ), + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + // Absent the following because enableInteractiveSelection: false + // SemanticsAction.moveCursorBackwardByCharacter, + // SemanticsAction.moveCursorBackwardByWord, + // SemanticsAction.setSelection, + // SemanticsAction.paste, + ], + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + testWidgets('TextField semantics for selections', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); final TextEditingController controller = TextEditingController()