Unverified Commit 4f4050bf authored by Hans Muller's avatar Hans Muller Committed by GitHub

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.
parent 5d7938d6
...@@ -88,8 +88,9 @@ class TextField extends StatefulWidget { ...@@ -88,8 +88,9 @@ class TextField extends StatefulWidget {
/// characters may be entered, and the error counter and divider will /// characters may be entered, and the error counter and divider will
/// switch to the [decoration.errorStyle] when the limit is exceeded. /// switch to the [decoration.errorStyle] when the limit is exceeded.
/// ///
/// The [textAlign], [autofocus], [obscureText], and [autocorrect] arguments /// The [textAlign], [autofocus], [obscureText], [autocorrect],
/// must not be null. /// [maxLengthEnforced], [scrollPadding], [maxLines], [maxLength],
/// and [enableInteractiveSelection] arguments must not be null.
/// ///
/// See also: /// See also:
/// ///
...@@ -121,6 +122,7 @@ class TextField extends StatefulWidget { ...@@ -121,6 +122,7 @@ class TextField extends StatefulWidget {
this.cursorColor, this.cursorColor,
this.keyboardAppearance, this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0), this.scrollPadding = const EdgeInsets.all(20.0),
this.enableInteractiveSelection = true,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(autofocus != null), assert(autofocus != null),
assert(obscureText != null), assert(obscureText != null),
...@@ -130,6 +132,7 @@ class TextField extends StatefulWidget { ...@@ -130,6 +132,7 @@ class TextField extends StatefulWidget {
assert(maxLines == null || maxLines > 0), assert(maxLines == null || maxLines > 0),
assert(maxLength == null || maxLength > 0), assert(maxLength == null || maxLength > 0),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
assert(enableInteractiveSelection != null),
super(key: key); super(key: key);
/// Controls the text being edited. /// Controls the text being edited.
...@@ -343,6 +346,9 @@ class TextField extends StatefulWidget { ...@@ -343,6 +346,9 @@ class TextField extends StatefulWidget {
/// Defaults to EdgeInserts.all(20.0). /// Defaults to EdgeInserts.all(20.0).
final EdgeInsets scrollPadding; final EdgeInsets scrollPadding;
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
final bool enableInteractiveSelection;
@override @override
_TextFieldState createState() => _TextFieldState(); _TextFieldState createState() => _TextFieldState();
...@@ -487,7 +493,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -487,7 +493,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
} }
void _handleTap() { void _handleTap() {
_renderEditable.handleTap(); if (widget.enableInteractiveSelection)
_renderEditable.handleTap();
_requestKeyboard(); _requestKeyboard();
_confirmCurrentSplash(); _confirmCurrentSplash();
} }
...@@ -497,7 +504,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -497,7 +504,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
} }
void _handleLongPress() { void _handleLongPress() {
_renderEditable.handleLongPress(); if (widget.enableInteractiveSelection)
_renderEditable.handleLongPress();
_confirmCurrentSplash(); _confirmCurrentSplash();
} }
...@@ -567,9 +575,11 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -567,9 +575,11 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
autocorrect: widget.autocorrect, autocorrect: widget.autocorrect,
maxLines: widget.maxLines, maxLines: widget.maxLines,
selectionColor: themeData.textSelectionColor, selectionColor: themeData.textSelectionColor,
selectionControls: themeData.platform == TargetPlatform.iOS selectionControls: widget.enableInteractiveSelection
? cupertinoTextSelectionControls ? (themeData.platform == TargetPlatform.iOS
: materialTextSelectionControls, ? cupertinoTextSelectionControls
: materialTextSelectionControls)
: null,
onChanged: widget.onChanged, onChanged: widget.onChanged,
onEditingComplete: widget.onEditingComplete, onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
...@@ -581,6 +591,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -581,6 +591,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor, cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor,
scrollPadding: widget.scrollPadding, scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance, keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: widget.enableInteractiveSelection,
), ),
); );
......
...@@ -74,6 +74,7 @@ class TextFormField extends FormField<String> { ...@@ -74,6 +74,7 @@ class TextFormField extends FormField<String> {
bool enabled = true, bool enabled = true,
Brightness keyboardAppearance, Brightness keyboardAppearance,
EdgeInsets scrollPadding = const EdgeInsets.all(20.0), EdgeInsets scrollPadding = const EdgeInsets.all(20.0),
bool enableInteractiveSelection = true,
}) : assert(initialValue == null || controller == null), }) : assert(initialValue == null || controller == null),
assert(textAlign != null), assert(textAlign != null),
assert(autofocus != null), assert(autofocus != null),
...@@ -84,6 +85,7 @@ class TextFormField extends FormField<String> { ...@@ -84,6 +85,7 @@ class TextFormField extends FormField<String> {
assert(scrollPadding != null), assert(scrollPadding != null),
assert(maxLines == null || maxLines > 0), assert(maxLines == null || maxLines > 0),
assert(maxLength == null || maxLength > 0), assert(maxLength == null || maxLength > 0),
assert(enableInteractiveSelection != null),
super( super(
key: key, key: key,
initialValue: controller != null ? controller.text : (initialValue ?? ''), initialValue: controller != null ? controller.text : (initialValue ?? ''),
...@@ -117,6 +119,7 @@ class TextFormField extends FormField<String> { ...@@ -117,6 +119,7 @@ class TextFormField extends FormField<String> {
enabled: enabled, enabled: enabled,
scrollPadding: scrollPadding, scrollPadding: scrollPadding,
keyboardAppearance: keyboardAppearance, keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: enableInteractiveSelection,
); );
}, },
); );
......
...@@ -118,6 +118,8 @@ class RenderEditable extends RenderBox { ...@@ -118,6 +118,8 @@ class RenderEditable extends RenderBox {
/// ///
/// The [offset] is required and must not be null. You can use [new /// The [offset] is required and must not be null. You can use [new
/// ViewportOffset.zero] if you have no need for scrolling. /// ViewportOffset.zero] if you have no need for scrolling.
///
/// The [enableInteractiveSelection] argument must not be null.
RenderEditable({ RenderEditable({
TextSpan text, TextSpan text,
@required TextDirection textDirection, @required TextDirection textDirection,
...@@ -137,6 +139,7 @@ class RenderEditable extends RenderBox { ...@@ -137,6 +139,7 @@ class RenderEditable extends RenderBox {
Locale locale, Locale locale,
double cursorWidth = 1.0, double cursorWidth = 1.0,
Radius cursorRadius, Radius cursorRadius,
bool enableInteractiveSelection = true,
@required this.textSelectionDelegate, @required this.textSelectionDelegate,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(textDirection != null, 'RenderEditable created without a textDirection.'), assert(textDirection != null, 'RenderEditable created without a textDirection.'),
...@@ -145,8 +148,9 @@ class RenderEditable extends RenderBox { ...@@ -145,8 +148,9 @@ class RenderEditable extends RenderBox {
assert(offset != null), assert(offset != null),
assert(ignorePointer != null), assert(ignorePointer != null),
assert(obscureText != null), assert(obscureText != null),
assert(enableInteractiveSelection != null),
assert(textSelectionDelegate != null), assert(textSelectionDelegate != null),
_textPainter = TextPainter( _textPainter = TextPainter(
text: text, text: text,
textAlign: textAlign, textAlign: textAlign,
textDirection: textDirection, textDirection: textDirection,
...@@ -162,6 +166,7 @@ class RenderEditable extends RenderBox { ...@@ -162,6 +166,7 @@ class RenderEditable extends RenderBox {
_offset = offset, _offset = offset,
_cursorWidth = cursorWidth, _cursorWidth = cursorWidth,
_cursorRadius = cursorRadius, _cursorRadius = cursorRadius,
_enableInteractiveSelection = enableInteractiveSelection,
_obscureText = obscureText { _obscureText = obscureText {
assert(_showCursor != null); assert(_showCursor != null);
assert(!_showCursor.value || cursorColor != null); assert(!_showCursor.value || cursorColor != null);
...@@ -692,6 +697,20 @@ class RenderEditable extends RenderBox { ...@@ -692,6 +697,20 @@ class RenderEditable extends RenderBox {
markNeedsPaint(); 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 @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config); super.describeSemanticsConfiguration(config);
...@@ -705,10 +724,10 @@ class RenderEditable extends RenderBox { ...@@ -705,10 +724,10 @@ class RenderEditable extends RenderBox {
..isFocused = hasFocus ..isFocused = hasFocus
..isTextField = true; ..isTextField = true;
if (hasFocus) if (hasFocus && enableInteractiveSelection)
config.onSetSelection = _handleSetSelection; config.onSetSelection = _handleSetSelection;
if (_selection?.isValid == true) { if (enableInteractiveSelection && _selection?.isValid == true) {
config.textSelection = _selection; config.textSelection = _selection;
if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) { if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) {
config config
......
...@@ -183,7 +183,8 @@ class EditableText extends StatefulWidget { ...@@ -183,7 +183,8 @@ class EditableText extends StatefulWidget {
/// default to [TextInputType.multiline]. /// default to [TextInputType.multiline].
/// ///
/// The [controller], [focusNode], [style], [cursorColor], [textAlign], /// The [controller], [focusNode], [style], [cursorColor], [textAlign],
/// and [rendererIgnoresPointer], arguments must not be null. /// [rendererIgnoresPointer], and [enableInteractiveSelection] arguments must
/// not be null.
EditableText({ EditableText({
Key key, Key key,
@required this.controller, @required this.controller,
...@@ -213,6 +214,7 @@ class EditableText extends StatefulWidget { ...@@ -213,6 +214,7 @@ class EditableText extends StatefulWidget {
this.cursorRadius, this.cursorRadius,
this.scrollPadding = const EdgeInsets.all(20.0), this.scrollPadding = const EdgeInsets.all(20.0),
this.keyboardAppearance = Brightness.light, this.keyboardAppearance = Brightness.light,
this.enableInteractiveSelection = true,
}) : assert(controller != null), }) : assert(controller != null),
assert(focusNode != null), assert(focusNode != null),
assert(obscureText != null), assert(obscureText != null),
...@@ -224,6 +226,7 @@ class EditableText extends StatefulWidget { ...@@ -224,6 +226,7 @@ class EditableText extends StatefulWidget {
assert(autofocus != null), assert(autofocus != null),
assert(rendererIgnoresPointer != null), assert(rendererIgnoresPointer != null),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(enableInteractiveSelection != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
inputFormatters = maxLines == 1 inputFormatters = maxLines == 1
? ( ? (
...@@ -399,6 +402,17 @@ class EditableText extends StatefulWidget { ...@@ -399,6 +402,17 @@ class EditableText extends StatefulWidget {
/// Defaults to EdgeInserts.all(20.0). /// Defaults to EdgeInserts.all(20.0).
final EdgeInsets scrollPadding; 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. /// 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. /// This property is most useful for testing purposes.
/// ///
...@@ -864,12 +878,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -864,12 +878,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_selectionOverlay?.hide(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
FocusScope.of(context).reparentIfNeeded(widget.focusNode); FocusScope.of(context).reparentIfNeeded(widget.focusNode);
super.build(context); // See AutomaticKeepAliveClientMixin. super.build(context); // See AutomaticKeepAliveClientMixin.
final TextSelectionControls controls = widget.selectionControls;
final TextSelectionControls controls = widget.selectionControls;
return Scrollable( return Scrollable(
excludeFromSemantics: true, excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
...@@ -879,9 +911,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -879,9 +911,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return CompositedTransformTarget( return CompositedTransformTarget(
link: _layerLink, link: _layerLink,
child: Semantics( child: Semantics(
onCopy: _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null, onCopy: _semanticsOnCopy(controls),
onCut: _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null, onCut: _semanticsOnCut(controls),
onPaste: _hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null, onPaste: _semanticsOnPaste(controls),
child: _Editable( child: _Editable(
key: _editableKey, key: _editableKey,
textSpan: buildTextSpan(), textSpan: buildTextSpan(),
...@@ -903,6 +935,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -903,6 +935,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
rendererIgnoresPointer: widget.rendererIgnoresPointer, rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth, cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius, cursorRadius: widget.cursorRadius,
enableInteractiveSelection: widget.enableInteractiveSelection,
textSelectionDelegate: this, textSelectionDelegate: this,
), ),
), ),
...@@ -967,9 +1000,11 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -967,9 +1000,11 @@ class _Editable extends LeafRenderObjectWidget {
this.rendererIgnoresPointer = false, this.rendererIgnoresPointer = false,
this.cursorWidth, this.cursorWidth,
this.cursorRadius, this.cursorRadius,
this.enableInteractiveSelection = true,
this.textSelectionDelegate, this.textSelectionDelegate,
}) : assert(textDirection != null), }) : assert(textDirection != null),
assert(rendererIgnoresPointer != null), assert(rendererIgnoresPointer != null),
assert(enableInteractiveSelection != null),
super(key: key); super(key: key);
final TextSpan textSpan; final TextSpan textSpan;
...@@ -991,6 +1026,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -991,6 +1026,7 @@ class _Editable extends LeafRenderObjectWidget {
final bool rendererIgnoresPointer; final bool rendererIgnoresPointer;
final double cursorWidth; final double cursorWidth;
final Radius cursorRadius; final Radius cursorRadius;
final bool enableInteractiveSelection;
final TextSelectionDelegate textSelectionDelegate; final TextSelectionDelegate textSelectionDelegate;
@override @override
...@@ -1014,6 +1050,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -1014,6 +1050,7 @@ class _Editable extends LeafRenderObjectWidget {
obscureText: obscureText, obscureText: obscureText,
cursorWidth: cursorWidth, cursorWidth: cursorWidth,
cursorRadius: cursorRadius, cursorRadius: cursorRadius,
enableInteractiveSelection: enableInteractiveSelection,
textSelectionDelegate: textSelectionDelegate, textSelectionDelegate: textSelectionDelegate,
); );
} }
......
...@@ -404,6 +404,34 @@ void main() { ...@@ -404,6 +404,34 @@ void main() {
expect(controller.selection.extentOffset, tapIndex); 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 { testWidgets('Can long press to select', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
...@@ -434,6 +462,37 @@ void main() { ...@@ -434,6 +462,37 @@ void main() {
expect(controller.selection.extentOffset, testValue.indexOf('f')+1); 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 { testWidgets('Can drag handles to change selection', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
...@@ -2530,6 +2589,48 @@ void main() { ...@@ -2530,6 +2589,48 @@ void main() {
semantics.dispose(); 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 { testWidgets('TextField semantics for selections', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
final TextEditingController controller = TextEditingController() final TextEditingController controller = TextEditingController()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment