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.
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 044011a..2aabd01 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -88,8 +88,9 @@
/// 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 @@
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 @@
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 @@
/// 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 @@
}
void _handleTap() {
- _renderEditable.handleTap();
+ if (widget.enableInteractiveSelection)
+ _renderEditable.handleTap();
_requestKeyboard();
_confirmCurrentSplash();
}
@@ -497,7 +504,8 @@
}
void _handleLongPress() {
- _renderEditable.handleLongPress();
+ if (widget.enableInteractiveSelection)
+ _renderEditable.handleLongPress();
_confirmCurrentSplash();
}
@@ -567,9 +575,11 @@
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 @@
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 bf2834a..7934d4a 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 @@
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 @@
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 @@
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 7c3d841..551d379 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -118,6 +118,8 @@
///
/// 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 @@
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 @@
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 @@
_offset = offset,
_cursorWidth = cursorWidth,
_cursorRadius = cursorRadius,
+ _enableInteractiveSelection = enableInteractiveSelection,
_obscureText = obscureText {
assert(_showCursor != null);
assert(!_showCursor.value || cursorColor != null);
@@ -692,6 +697,20 @@
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 @@
..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 c374cc4..da88e15 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -183,7 +183,8 @@
/// 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 @@
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 @@
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 @@
/// 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 @@
_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 @@
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 @@
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
+ enableInteractiveSelection: widget.enableInteractiveSelection,
textSelectionDelegate: this,
),
),
@@ -967,9 +1000,11 @@
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 @@
final bool rendererIgnoresPointer;
final double cursorWidth;
final Radius cursorRadius;
+ final bool enableInteractiveSelection;
final TextSelectionDelegate textSelectionDelegate;
@override
@@ -1014,6 +1050,7 @@
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 2a32484..5439ab0 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -404,6 +404,34 @@
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 @@
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 @@
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>[
+ TestSemantics.rootChild(
+ id: 1,
+ textDirection: TextDirection.ltr,
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ // Absent the following because enableInteractiveSelection: false
+ // SemanticsAction.moveCursorBackwardByCharacter,
+ // SemanticsAction.moveCursorBackwardByWord,
+ // SemanticsAction.setSelection,
+ // SemanticsAction.paste,
+ ],
+ flags: <SemanticsFlag>[
+ 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()