Unverified Commit 3fd737c8 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

a11y on Android: copy/cut/paste (#14343)

With a little refactor and unification of how availability of copy/cut/paste is determined across platforms.

Minor remaining issue: https://github.com/flutter/flutter/issues/14331.

Fixes https://github.com/flutter/flutter/issues/13501.
parent 217bcfe2
...@@ -6,7 +6,6 @@ import 'dart:math' as math; ...@@ -6,7 +6,6 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'button.dart'; import 'button.dart';
...@@ -59,16 +58,12 @@ class _TextSelectionToolbarNotchPainter extends CustomPainter { ...@@ -59,16 +58,12 @@ class _TextSelectionToolbarNotchPainter extends CustomPainter {
class _TextSelectionToolbar extends StatelessWidget { class _TextSelectionToolbar extends StatelessWidget {
const _TextSelectionToolbar({ const _TextSelectionToolbar({
Key key, Key key,
this.delegate,
this.handleCut, this.handleCut,
this.handleCopy, this.handleCopy,
this.handlePaste, this.handlePaste,
this.handleSelectAll, this.handleSelectAll,
}) : super(key: key); }) : super(key: key);
final TextSelectionDelegate delegate;
TextEditingValue get value => delegate.textEditingValue;
final VoidCallback handleCut; final VoidCallback handleCut;
final VoidCallback handleCopy; final VoidCallback handleCopy;
final VoidCallback handlePaste; final VoidCallback handlePaste;
...@@ -80,20 +75,24 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -80,20 +75,24 @@ class _TextSelectionToolbar extends StatelessWidget {
final Widget onePhysicalPixelVerticalDivider = final Widget onePhysicalPixelVerticalDivider =
new SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); new SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
if (!value.selection.isCollapsed) { if (handleCut != null)
items.add(_buildToolbarButton('Cut', handleCut)); items.add(_buildToolbarButton('Cut', handleCut));
items.add(onePhysicalPixelVerticalDivider);
if (handleCopy != null) {
if (items.isNotEmpty)
items.add(onePhysicalPixelVerticalDivider);
items.add(_buildToolbarButton('Copy', handleCopy)); items.add(_buildToolbarButton('Copy', handleCopy));
} }
// TODO(https://github.com/flutter/flutter/issues/11254): if (handlePaste != null) {
// This should probably be grayed-out if there is nothing to paste. if (items.isNotEmpty)
if (items.isNotEmpty) items.add(onePhysicalPixelVerticalDivider);
items.add(onePhysicalPixelVerticalDivider); items.add(_buildToolbarButton('Paste', handlePaste));
items.add(_buildToolbarButton('Paste', handlePaste)); }
if (value.text.isNotEmpty && value.selection.isCollapsed) { if (handleSelectAll != null) {
items.add(onePhysicalPixelVerticalDivider); if (items.isNotEmpty)
items.add(onePhysicalPixelVerticalDivider);
items.add(_buildToolbarButton('Select All', handleSelectAll)); items.add(_buildToolbarButton('Select All', handleSelectAll));
} }
...@@ -236,11 +235,10 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -236,11 +235,10 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
position, position,
), ),
child: new _TextSelectionToolbar( child: new _TextSelectionToolbar(
delegate: delegate, handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCut: () => handleCut(delegate), handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
handleCopy: () => handleCopy(delegate), handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handlePaste: () => handlePaste(delegate), handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
handleSelectAll: () => handleSelectAll(delegate),
), ),
) )
); );
......
...@@ -6,7 +6,6 @@ import 'dart:math' as math; ...@@ -6,7 +6,6 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'flat_button.dart'; import 'flat_button.dart';
import 'material.dart'; import 'material.dart';
...@@ -22,16 +21,12 @@ const double _kToolbarScreenPadding = 8.0; ...@@ -22,16 +21,12 @@ const double _kToolbarScreenPadding = 8.0;
class _TextSelectionToolbar extends StatelessWidget { class _TextSelectionToolbar extends StatelessWidget {
const _TextSelectionToolbar({ const _TextSelectionToolbar({
Key key, Key key,
this.delegate,
this.handleCut, this.handleCut,
this.handleCopy, this.handleCopy,
this.handlePaste, this.handlePaste,
this.handleSelectAll, this.handleSelectAll,
}) : super(key: key); }) : super(key: key);
final TextSelectionDelegate delegate;
TextEditingValue get value => delegate.textEditingValue;
final VoidCallback handleCut; final VoidCallback handleCut;
final VoidCallback handleCopy; final VoidCallback handleCopy;
final VoidCallback handlePaste; final VoidCallback handlePaste;
...@@ -42,20 +37,14 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -42,20 +37,14 @@ class _TextSelectionToolbar extends StatelessWidget {
final List<Widget> items = <Widget>[]; final List<Widget> items = <Widget>[];
final MaterialLocalizations localizations = MaterialLocalizations.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context);
if (!value.selection.isCollapsed) { if (handleCut != null)
items.add(new FlatButton(child: new Text(localizations.cutButtonLabel), onPressed: handleCut)); items.add(new FlatButton(child: new Text(localizations.cutButtonLabel), onPressed: handleCut));
if (handleCopy != null)
items.add(new FlatButton(child: new Text(localizations.copyButtonLabel), onPressed: handleCopy)); items.add(new FlatButton(child: new Text(localizations.copyButtonLabel), onPressed: handleCopy));
} if (handlePaste != null)
items.add(new FlatButton( items.add(new FlatButton(child: new Text(localizations.pasteButtonLabel), onPressed: handlePaste,));
child: new Text(localizations.pasteButtonLabel), if (handleSelectAll != null)
// TODO(https://github.com/flutter/flutter/issues/11254): items.add(new FlatButton(child: new Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll));
// This should probably be grayed-out if there is nothing to paste.
onPressed: handlePaste,
));
if (value.text.isNotEmpty) {
if (value.selection.isCollapsed)
items.add(new FlatButton(child: new Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll));
}
return new Material( return new Material(
elevation: 1.0, elevation: 1.0,
...@@ -152,11 +141,10 @@ class _MaterialTextSelectionControls extends TextSelectionControls { ...@@ -152,11 +141,10 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
position, position,
), ),
child: new _TextSelectionToolbar( child: new _TextSelectionToolbar(
delegate: delegate, handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCut: () => handleCut(delegate), handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
handleCopy: () => handleCopy(delegate), handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handlePaste: () => handlePaste(delegate), handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
handleSelectAll: () => handleSelectAll(delegate),
), ),
) )
); );
......
...@@ -308,7 +308,7 @@ class EditableText extends StatefulWidget { ...@@ -308,7 +308,7 @@ class EditableText extends StatefulWidget {
} }
/// State for a [EditableText]. /// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin implements TextInputClient { class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin implements TextInputClient, TextSelectionDelegate {
Timer _cursorTimer; Timer _cursorTimer;
final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false); final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false);
final GlobalKey _editableKey = new GlobalKey(); final GlobalKey _editableKey = new GlobalKey();
...@@ -516,8 +516,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -516,8 +516,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
debugRequiredFor: widget, debugRequiredFor: widget,
layerLink: _layerLink, layerLink: _layerLink,
renderObject: renderObject, renderObject: renderObject,
onSelectionOverlayChanged: _handleSelectionOverlayChanged,
selectionControls: widget.selectionControls, selectionControls: widget.selectionControls,
selectionDelegate: this,
); );
final bool longPress = cause == SelectionChangedCause.longPress; final bool longPress = cause == SelectionChangedCause.longPress;
if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress)) if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress))
...@@ -529,12 +529,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -529,12 +529,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
void _handleSelectionOverlayChanged(TextEditingValue value, Rect caretRect) {
assert(!value.composing.isValid); // composing range must be empty while selecting.
_formatAndSetValue(value);
_scrollController.jumpTo(_getScrollOffsetForCaret(caretRect));
}
bool _textChangedSinceLastCaretUpdate = false; bool _textChangedSinceLastCaretUpdate = false;
void _handleCaretChanged(Rect caretRect) { void _handleCaretChanged(Rect caretRect) {
...@@ -643,10 +637,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -643,10 +637,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// when [ignorePointer] is true. See [RenderEditable.ignorePointer]. /// when [ignorePointer] is true. See [RenderEditable.ignorePointer].
RenderEditable get renderEditable => _editableKey.currentContext.findRenderObject(); RenderEditable get renderEditable => _editableKey.currentContext.findRenderObject();
@override
TextEditingValue get textEditingValue => _value;
@override
set textEditingValue(TextEditingValue value) {
_selectionOverlay?.update(value);
_formatAndSetValue(value);
}
@override
void bringIntoView(TextPosition position) {
_scrollController.jumpTo(_getScrollOffsetForCaret(renderEditable.getLocalRectForCaret(position)));
}
@override
void hideToolbar() {
_selectionOverlay?.hide();
}
@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;
return new Scrollable( return new Scrollable(
excludeFromSemantics: true, excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
...@@ -655,25 +669,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -655,25 +669,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
viewportBuilder: (BuildContext context, ViewportOffset offset) { viewportBuilder: (BuildContext context, ViewportOffset offset) {
return new CompositedTransformTarget( return new CompositedTransformTarget(
link: _layerLink, link: _layerLink,
child: new _Editable( child: new Semantics(
key: _editableKey, onCopy: _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null,
value: _value, onCut: _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null,
style: widget.style, onPaste: _hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null,
cursorColor: widget.cursorColor, child: new _Editable(
showCursor: _showCursor, key: _editableKey,
hasFocus: _hasFocus, value: _value,
maxLines: widget.maxLines, style: widget.style,
selectionColor: widget.selectionColor, cursorColor: widget.cursorColor,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0, showCursor: _showCursor,
textAlign: widget.textAlign, hasFocus: _hasFocus,
textDirection: _textDirection, maxLines: widget.maxLines,
obscureText: widget.obscureText, selectionColor: widget.selectionColor,
obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null, textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0,
autocorrect: widget.autocorrect, textAlign: widget.textAlign,
offset: offset, textDirection: _textDirection,
onSelectionChanged: _handleSelectionChanged, obscureText: widget.obscureText,
onCaretChanged: _handleCaretChanged, obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null,
rendererIgnoresPointer: widget.rendererIgnoresPointer, autocorrect: widget.autocorrect,
offset: offset,
onSelectionChanged: _handleSelectionChanged,
onCaretChanged: _handleCaretChanged,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
),
), ),
); );
}, },
......
...@@ -72,6 +72,10 @@ abstract class TextSelectionDelegate { ...@@ -72,6 +72,10 @@ abstract class TextSelectionDelegate {
/// Hides the text selection toolbar. /// Hides the text selection toolbar.
void hideToolbar(); void hideToolbar();
/// Brings the provided [TextPosition] into the visible area of the text
/// input.
void bringIntoView(TextPosition position);
} }
/// An interface for building the selection UI, to be provided by the /// An interface for building the selection UI, to be provided by the
...@@ -93,6 +97,49 @@ abstract class TextSelectionControls { ...@@ -93,6 +97,49 @@ abstract class TextSelectionControls {
/// Returns the size of the selection handle. /// Returns the size of the selection handle.
Size get handleSize; Size get handleSize;
/// Whether the current selection of the text field managed by the given
/// `delegate` can be removed from the text field and placed into the
/// [Clipboard].
///
/// By default, false is returned when nothing is selected in the text field.
///
/// 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;
}
/// Whether the current selection of the text field managed by the given
/// `delegate` can be copied to the [Clipboard].
///
/// By default, false is returned when nothing is selected in the text field.
///
/// 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;
}
/// Whether the current [Clipboard] content can be pasted into the text field
/// managed by the given `delegate`.
///
/// Subclasses can use this to decide if they should expose the paste
/// 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;
}
/// Whether the the current selection of the text field managed by the given
/// `delegate` can be extended to include the entire content of the text
/// field.
///
/// 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;
}
/// Copy the current selection of the text field managed by the given /// Copy the current selection of the text field managed by the given
/// `delegate` to the [Clipboard]. Then, remove the selected text from the /// `delegate` to the [Clipboard]. Then, remove the selected text from the
/// text field and hide the toolbar. /// text field and hide the toolbar.
...@@ -111,6 +158,7 @@ abstract class TextSelectionControls { ...@@ -111,6 +158,7 @@ abstract class TextSelectionControls {
offset: value.selection.start offset: value.selection.start
), ),
); );
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar(); delegate.hideToolbar();
} }
...@@ -129,6 +177,7 @@ abstract class TextSelectionControls { ...@@ -129,6 +177,7 @@ abstract class TextSelectionControls {
text: value.text, text: value.text,
selection: new TextSelection.collapsed(offset: value.selection.end), selection: new TextSelection.collapsed(offset: value.selection.end),
); );
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar(); delegate.hideToolbar();
} }
...@@ -156,6 +205,7 @@ abstract class TextSelectionControls { ...@@ -156,6 +205,7 @@ abstract class TextSelectionControls {
), ),
); );
} }
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar(); delegate.hideToolbar();
} }
...@@ -174,6 +224,7 @@ abstract class TextSelectionControls { ...@@ -174,6 +224,7 @@ abstract class TextSelectionControls {
extentOffset: delegate.textEditingValue.text.length extentOffset: delegate.textEditingValue.text.length
), ),
); );
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
} }
} }
...@@ -181,7 +232,7 @@ abstract class TextSelectionControls { ...@@ -181,7 +232,7 @@ abstract class TextSelectionControls {
/// ///
/// The selection handles are displayed in the [Overlay] that most closely /// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext]. /// encloses the given [BuildContext].
class TextSelectionOverlay implements TextSelectionDelegate { class TextSelectionOverlay {
/// Creates an object that manages overly entries for selection handles. /// Creates an object that manages overly entries for selection handles.
/// ///
/// The [context] must not be null and must have an [Overlay] as an ancestor. /// The [context] must not be null and must have an [Overlay] as an ancestor.
...@@ -191,8 +242,8 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -191,8 +242,8 @@ class TextSelectionOverlay implements TextSelectionDelegate {
this.debugRequiredFor, this.debugRequiredFor,
@required this.layerLink, @required this.layerLink,
@required this.renderObject, @required this.renderObject,
this.onSelectionOverlayChanged,
this.selectionControls, this.selectionControls,
this.selectionDelegate,
}): assert(value != null), }): assert(value != null),
assert(context != null), assert(context != null),
_value = value { _value = value {
...@@ -220,15 +271,13 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -220,15 +271,13 @@ class TextSelectionOverlay implements TextSelectionDelegate {
/// The editable line in which the selected text is being displayed. /// The editable line in which the selected text is being displayed.
final RenderEditable renderObject; final RenderEditable renderObject;
/// Called when the the selection changes.
///
/// For example, if the use drags one of the selection handles, this function
/// will be called with a new input value with an updated selection.
final TextSelectionOverlayChanged onSelectionOverlayChanged;
/// Builds text selection handles and toolbar. /// Builds text selection handles and toolbar.
final TextSelectionControls selectionControls; final TextSelectionControls selectionControls;
/// The delegate for manipulating the current selection in the owning
/// text field.
final TextSelectionDelegate selectionDelegate;
/// Controls the fade-in animations. /// Controls the fade-in animations.
static const Duration _kFadeDuration = const Duration(milliseconds: 150); static const Duration _kFadeDuration = const Duration(milliseconds: 150);
AnimationController _handleController; AnimationController _handleController;
...@@ -372,24 +421,23 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -372,24 +421,23 @@ class TextSelectionOverlay implements TextSelectionDelegate {
link: layerLink, link: layerLink,
showWhenUnlinked: false, showWhenUnlinked: false,
offset: -editingRegion.topLeft, offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(context, editingRegion, midpoint, this), child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate),
), ),
); );
} }
void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) { void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
Rect caretRect; TextPosition textPosition;
switch (position) { switch (position) {
case _TextSelectionHandlePosition.start: case _TextSelectionHandlePosition.start:
caretRect = renderObject.getLocalRectForCaret(newSelection.base); textPosition = newSelection.base;
break; break;
case _TextSelectionHandlePosition.end: case _TextSelectionHandlePosition.end:
caretRect = renderObject.getLocalRectForCaret(newSelection.extent); textPosition =newSelection.extent;
break; break;
} }
update(_value.copyWith(selection: newSelection, composing: TextRange.empty)); selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty);
if (onSelectionOverlayChanged != null) selectionDelegate.bringIntoView(textPosition);
onSelectionOverlayChanged(_value, caretRect);
} }
void _handleSelectionHandleTapped() { void _handleSelectionHandleTapped() {
...@@ -402,23 +450,6 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -402,23 +450,6 @@ class TextSelectionOverlay implements TextSelectionDelegate {
} }
} }
} }
@override
TextEditingValue get textEditingValue => _value;
@override
set textEditingValue(TextEditingValue newValue) {
update(newValue);
if (onSelectionOverlayChanged != null) {
final Rect caretRect = renderObject.getLocalRectForCaret(newValue.selection.extent);
onSelectionOverlayChanged(newValue, caretRect);
}
}
@override
void hideToolbar() {
hide();
}
} }
/// This widget represents a single draggable text selection handle. /// This widget represents a single draggable text selection handle.
......
...@@ -1813,6 +1813,7 @@ void main() { ...@@ -1813,6 +1813,7 @@ void main() {
SemanticsAction.tap, SemanticsAction.tap,
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -1837,6 +1838,7 @@ void main() { ...@@ -1837,6 +1838,7 @@ void main() {
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -1861,6 +1863,7 @@ void main() { ...@@ -1861,6 +1863,7 @@ void main() {
SemanticsAction.tap, SemanticsAction.tap,
SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -1919,6 +1922,7 @@ void main() { ...@@ -1919,6 +1922,7 @@ void main() {
SemanticsAction.tap, SemanticsAction.tap,
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -1943,6 +1947,9 @@ void main() { ...@@ -1943,6 +1947,9 @@ void main() {
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.moveCursorForwardByCharacter, SemanticsAction.moveCursorForwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
SemanticsAction.cut,
SemanticsAction.copy,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -1989,6 +1996,7 @@ void main() { ...@@ -1989,6 +1996,7 @@ void main() {
SemanticsAction.tap, SemanticsAction.tap,
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
...@@ -2032,6 +2040,9 @@ void main() { ...@@ -2032,6 +2040,9 @@ void main() {
SemanticsAction.tap, SemanticsAction.tap,
SemanticsAction.moveCursorBackwardByCharacter, SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection, SemanticsAction.setSelection,
SemanticsAction.paste,
SemanticsAction.cut,
SemanticsAction.copy,
], ],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isTextField, SemanticsFlag.isTextField,
......
...@@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mockito/mockito.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
...@@ -19,6 +20,10 @@ void main() { ...@@ -19,6 +20,10 @@ void main() {
final TextStyle textStyle = const TextStyle(); final TextStyle textStyle = const TextStyle();
final Color cursorColor = const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); final Color cursorColor = const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
setUp(() {
debugResetSemanticsIdCounter();
});
testWidgets('has expected defaults', (WidgetTester tester) async { testWidgets('has expected defaults', (WidgetTester tester) async {
await tester.pumpWidget(new Directionality( await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
...@@ -347,14 +352,14 @@ void main() { ...@@ -347,14 +352,14 @@ void main() {
final EditableTextState textState = tester.state(find.byType(EditableText)); final EditableTextState textState = tester.state(find.byType(EditableText));
expect(textState.selectionOverlay.handlesAreVisible, isTrue); expect(textState.selectionOverlay.handlesAreVisible, isTrue);
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 4)); expect(textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 4));
// Simulate selection change via keyboard and expect handles to disappear. // Simulate selection change via keyboard and expect handles to disappear.
render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render, SelectionChangedCause.keyboard); render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render, SelectionChangedCause.keyboard);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(textState.selectionOverlay.handlesAreVisible, isFalse); expect(textState.selectionOverlay.handlesAreVisible, isFalse);
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); expect(textState.selectionOverlay.selectionDelegate.textEditingValue.selection, const TextSelection.collapsed(offset: 10));
}); });
testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async { testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async {
...@@ -565,4 +570,151 @@ void main() { ...@@ -565,4 +570,151 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
group('a11y copy/cut/paste', () {
Future<Null> _buildApp(MockTextSelectionControls controls, WidgetTester tester) {
return tester.pumpWidget(new MaterialApp(
home: new EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: controls,
),
));
}
MockTextSelectionControls controls;
setUp(() {
controller.text = 'test';
controller.selection = new TextSelection.collapsed(offset: controller.text.length);
controls = new MockTextSelectionControls();
when(controls.buildHandle(any, any, any)).thenReturn(new Container());
when(controls.buildToolbar(any, any, any, any)).thenReturn(new Container());
});
testWidgets('are exposed', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
when(controls.canCopy(any)).thenReturn(false);
when(controls.canCut(any)).thenReturn(false);
when(controls.canPaste(any)).thenReturn(false);
await _buildApp(controls, tester);
await tester.tap(find.byType(EditableText));
await tester.pump();
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
],
));
when(controls.canCopy(any)).thenReturn(true);
await _buildApp(controls, tester);
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.copy,
],
));
when(controls.canCopy(any)).thenReturn(false);
when(controls.canPaste(any)).thenReturn(true);
await _buildApp(controls, tester);
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.paste,
],
));
when(controls.canPaste(any)).thenReturn(false);
when(controls.canCut(any)).thenReturn(true);
await _buildApp(controls, tester);
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.cut,
],
));
when(controls.canCopy(any)).thenReturn(true);
when(controls.canCut(any)).thenReturn(true);
when(controls.canPaste(any)).thenReturn(true);
await _buildApp(controls, tester);
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.cut,
SemanticsAction.copy,
SemanticsAction.paste,
],
));
semantics.dispose();
});
testWidgets('can copy/cut/paste with a11y', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
when(controls.canCopy(any)).thenReturn(true);
when(controls.canCut(any)).thenReturn(true);
when(controls.canPaste(any)).thenReturn(true);
await _buildApp(controls, tester);
await tester.tap(find.byType(EditableText));
await tester.pump();
final SemanticsOwner owner = tester.binding.pipelineOwner.semanticsOwner;
const int expectedNodeId = 3;
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: expectedNodeId,
flags: <SemanticsFlag>[
SemanticsFlag.isTextField,
SemanticsFlag.isFocused
],
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.copy,
SemanticsAction.cut,
SemanticsAction.paste
],
value: 'test',
textSelection: new TextSelection.collapsed(offset: controller.text.length),
textDirection: TextDirection.ltr,
),
],
), ignoreRect: true, ignoreTransform: true));
owner.performAction(expectedNodeId, SemanticsAction.copy);
verify(controls.handleCopy(any)).called(1);
owner.performAction(expectedNodeId, SemanticsAction.cut);
verify(controls.handleCut(any)).called(1);
owner.performAction(expectedNodeId, SemanticsAction.paste);
verify(controls.handlePaste(any)).called(1);
semantics.dispose();
});
});
} }
class MockTextSelectionControls extends Mock implements TextSelectionControls {}
...@@ -257,7 +257,7 @@ class TestSemantics { ...@@ -257,7 +257,7 @@ class TestSemantics {
if (!ignoreTransform && transform != nodeData.transform) if (!ignoreTransform && transform != nodeData.transform)
return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.'); return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.');
if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) { if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) {
return fail('expected node id $id to have textDirection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].'); return fail('expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].');
} }
final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount;
if (children.length != childrenCount) if (children.length != childrenCount)
......
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