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.
This commit is contained in:
parent
217bcfe261
commit
3fd737c8f7
@ -6,7 +6,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'button.dart';
|
||||
|
||||
@ -59,16 +58,12 @@ class _TextSelectionToolbarNotchPainter extends CustomPainter {
|
||||
class _TextSelectionToolbar extends StatelessWidget {
|
||||
const _TextSelectionToolbar({
|
||||
Key key,
|
||||
this.delegate,
|
||||
this.handleCut,
|
||||
this.handleCopy,
|
||||
this.handlePaste,
|
||||
this.handleSelectAll,
|
||||
}) : super(key: key);
|
||||
|
||||
final TextSelectionDelegate delegate;
|
||||
TextEditingValue get value => delegate.textEditingValue;
|
||||
|
||||
final VoidCallback handleCut;
|
||||
final VoidCallback handleCopy;
|
||||
final VoidCallback handlePaste;
|
||||
@ -80,20 +75,24 @@ class _TextSelectionToolbar extends StatelessWidget {
|
||||
final Widget onePhysicalPixelVerticalDivider =
|
||||
new SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
|
||||
|
||||
if (!value.selection.isCollapsed) {
|
||||
if (handleCut != null)
|
||||
items.add(_buildToolbarButton('Cut', handleCut));
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
|
||||
if (handleCopy != null) {
|
||||
if (items.isNotEmpty)
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
items.add(_buildToolbarButton('Copy', handleCopy));
|
||||
}
|
||||
|
||||
// TODO(https://github.com/flutter/flutter/issues/11254):
|
||||
// This should probably be grayed-out if there is nothing to paste.
|
||||
if (items.isNotEmpty)
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
items.add(_buildToolbarButton('Paste', handlePaste));
|
||||
if (handlePaste != null) {
|
||||
if (items.isNotEmpty)
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
items.add(_buildToolbarButton('Paste', handlePaste));
|
||||
}
|
||||
|
||||
if (value.text.isNotEmpty && value.selection.isCollapsed) {
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
if (handleSelectAll != null) {
|
||||
if (items.isNotEmpty)
|
||||
items.add(onePhysicalPixelVerticalDivider);
|
||||
items.add(_buildToolbarButton('Select All', handleSelectAll));
|
||||
}
|
||||
|
||||
@ -236,11 +235,10 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
|
||||
position,
|
||||
),
|
||||
child: new _TextSelectionToolbar(
|
||||
delegate: delegate,
|
||||
handleCut: () => handleCut(delegate),
|
||||
handleCopy: () => handleCopy(delegate),
|
||||
handlePaste: () => handlePaste(delegate),
|
||||
handleSelectAll: () => handleSelectAll(delegate),
|
||||
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
|
||||
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
|
||||
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
|
||||
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
@ -6,7 +6,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'flat_button.dart';
|
||||
import 'material.dart';
|
||||
@ -22,16 +21,12 @@ const double _kToolbarScreenPadding = 8.0;
|
||||
class _TextSelectionToolbar extends StatelessWidget {
|
||||
const _TextSelectionToolbar({
|
||||
Key key,
|
||||
this.delegate,
|
||||
this.handleCut,
|
||||
this.handleCopy,
|
||||
this.handlePaste,
|
||||
this.handleSelectAll,
|
||||
}) : super(key: key);
|
||||
|
||||
final TextSelectionDelegate delegate;
|
||||
TextEditingValue get value => delegate.textEditingValue;
|
||||
|
||||
final VoidCallback handleCut;
|
||||
final VoidCallback handleCopy;
|
||||
final VoidCallback handlePaste;
|
||||
@ -42,20 +37,14 @@ class _TextSelectionToolbar extends StatelessWidget {
|
||||
final List<Widget> items = <Widget>[];
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
|
||||
if (!value.selection.isCollapsed) {
|
||||
if (handleCut != null)
|
||||
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.pasteButtonLabel),
|
||||
// TODO(https://github.com/flutter/flutter/issues/11254):
|
||||
// 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));
|
||||
}
|
||||
if (handlePaste != null)
|
||||
items.add(new FlatButton(child: new Text(localizations.pasteButtonLabel), onPressed: handlePaste,));
|
||||
if (handleSelectAll != null)
|
||||
items.add(new FlatButton(child: new Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll));
|
||||
|
||||
return new Material(
|
||||
elevation: 1.0,
|
||||
@ -152,11 +141,10 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
|
||||
position,
|
||||
),
|
||||
child: new _TextSelectionToolbar(
|
||||
delegate: delegate,
|
||||
handleCut: () => handleCut(delegate),
|
||||
handleCopy: () => handleCopy(delegate),
|
||||
handlePaste: () => handlePaste(delegate),
|
||||
handleSelectAll: () => handleSelectAll(delegate),
|
||||
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
|
||||
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
|
||||
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
|
||||
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
@ -308,7 +308,7 @@ class EditableText extends StatefulWidget {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false);
|
||||
final GlobalKey _editableKey = new GlobalKey();
|
||||
@ -516,8 +516,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
debugRequiredFor: widget,
|
||||
layerLink: _layerLink,
|
||||
renderObject: renderObject,
|
||||
onSelectionOverlayChanged: _handleSelectionOverlayChanged,
|
||||
selectionControls: widget.selectionControls,
|
||||
selectionDelegate: this,
|
||||
);
|
||||
final bool longPress = cause == SelectionChangedCause.longPress;
|
||||
if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress))
|
||||
@ -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;
|
||||
|
||||
void _handleCaretChanged(Rect caretRect) {
|
||||
@ -643,10 +637,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
/// when [ignorePointer] is true. See [RenderEditable.ignorePointer].
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
FocusScope.of(context).reparentIfNeeded(widget.focusNode);
|
||||
super.build(context); // See AutomaticKeepAliveClientMixin.
|
||||
final TextSelectionControls controls = widget.selectionControls;
|
||||
return new Scrollable(
|
||||
excludeFromSemantics: true,
|
||||
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
||||
@ -655,25 +669,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
viewportBuilder: (BuildContext context, ViewportOffset offset) {
|
||||
return new CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: new _Editable(
|
||||
key: _editableKey,
|
||||
value: _value,
|
||||
style: widget.style,
|
||||
cursorColor: widget.cursorColor,
|
||||
showCursor: _showCursor,
|
||||
hasFocus: _hasFocus,
|
||||
maxLines: widget.maxLines,
|
||||
selectionColor: widget.selectionColor,
|
||||
textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0,
|
||||
textAlign: widget.textAlign,
|
||||
textDirection: _textDirection,
|
||||
obscureText: widget.obscureText,
|
||||
obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null,
|
||||
autocorrect: widget.autocorrect,
|
||||
offset: offset,
|
||||
onSelectionChanged: _handleSelectionChanged,
|
||||
onCaretChanged: _handleCaretChanged,
|
||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||
child: new 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,
|
||||
child: new _Editable(
|
||||
key: _editableKey,
|
||||
value: _value,
|
||||
style: widget.style,
|
||||
cursorColor: widget.cursorColor,
|
||||
showCursor: _showCursor,
|
||||
hasFocus: _hasFocus,
|
||||
maxLines: widget.maxLines,
|
||||
selectionColor: widget.selectionColor,
|
||||
textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0,
|
||||
textAlign: widget.textAlign,
|
||||
textDirection: _textDirection,
|
||||
obscureText: widget.obscureText,
|
||||
obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null,
|
||||
autocorrect: widget.autocorrect,
|
||||
offset: offset,
|
||||
onSelectionChanged: _handleSelectionChanged,
|
||||
onCaretChanged: _handleCaretChanged,
|
||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -72,6 +72,10 @@ abstract class TextSelectionDelegate {
|
||||
|
||||
/// Hides the text selection toolbar.
|
||||
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
|
||||
@ -93,6 +97,49 @@ abstract class TextSelectionControls {
|
||||
/// Returns the size of the selection handle.
|
||||
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
|
||||
/// `delegate` to the [Clipboard]. Then, remove the selected text from the
|
||||
/// text field and hide the toolbar.
|
||||
@ -111,6 +158,7 @@ abstract class TextSelectionControls {
|
||||
offset: value.selection.start
|
||||
),
|
||||
);
|
||||
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
|
||||
delegate.hideToolbar();
|
||||
}
|
||||
|
||||
@ -129,6 +177,7 @@ abstract class TextSelectionControls {
|
||||
text: value.text,
|
||||
selection: new TextSelection.collapsed(offset: value.selection.end),
|
||||
);
|
||||
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
|
||||
delegate.hideToolbar();
|
||||
}
|
||||
|
||||
@ -156,6 +205,7 @@ abstract class TextSelectionControls {
|
||||
),
|
||||
);
|
||||
}
|
||||
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
|
||||
delegate.hideToolbar();
|
||||
}
|
||||
|
||||
@ -174,6 +224,7 @@ abstract class TextSelectionControls {
|
||||
extentOffset: delegate.textEditingValue.text.length
|
||||
),
|
||||
);
|
||||
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +232,7 @@ abstract class TextSelectionControls {
|
||||
///
|
||||
/// The selection handles are displayed in the [Overlay] that most closely
|
||||
/// encloses the given [BuildContext].
|
||||
class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
class TextSelectionOverlay {
|
||||
/// Creates an object that manages overly entries for selection handles.
|
||||
///
|
||||
/// The [context] must not be null and must have an [Overlay] as an ancestor.
|
||||
@ -191,8 +242,8 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
this.debugRequiredFor,
|
||||
@required this.layerLink,
|
||||
@required this.renderObject,
|
||||
this.onSelectionOverlayChanged,
|
||||
this.selectionControls,
|
||||
this.selectionDelegate,
|
||||
}): assert(value != null),
|
||||
assert(context != null),
|
||||
_value = value {
|
||||
@ -220,15 +271,13 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
/// The editable line in which the selected text is being displayed.
|
||||
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.
|
||||
final TextSelectionControls selectionControls;
|
||||
|
||||
/// The delegate for manipulating the current selection in the owning
|
||||
/// text field.
|
||||
final TextSelectionDelegate selectionDelegate;
|
||||
|
||||
/// Controls the fade-in animations.
|
||||
static const Duration _kFadeDuration = const Duration(milliseconds: 150);
|
||||
AnimationController _handleController;
|
||||
@ -372,24 +421,23 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: -editingRegion.topLeft,
|
||||
child: selectionControls.buildToolbar(context, editingRegion, midpoint, this),
|
||||
child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
|
||||
Rect caretRect;
|
||||
TextPosition textPosition;
|
||||
switch (position) {
|
||||
case _TextSelectionHandlePosition.start:
|
||||
caretRect = renderObject.getLocalRectForCaret(newSelection.base);
|
||||
textPosition = newSelection.base;
|
||||
break;
|
||||
case _TextSelectionHandlePosition.end:
|
||||
caretRect = renderObject.getLocalRectForCaret(newSelection.extent);
|
||||
textPosition =newSelection.extent;
|
||||
break;
|
||||
}
|
||||
update(_value.copyWith(selection: newSelection, composing: TextRange.empty));
|
||||
if (onSelectionOverlayChanged != null)
|
||||
onSelectionOverlayChanged(_value, caretRect);
|
||||
selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty);
|
||||
selectionDelegate.bringIntoView(textPosition);
|
||||
}
|
||||
|
||||
void _handleSelectionHandleTapped() {
|
||||
@ -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.
|
||||
|
@ -1813,6 +1813,7 @@ void main() {
|
||||
SemanticsAction.tap,
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -1837,6 +1838,7 @@ void main() {
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.moveCursorForwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -1861,6 +1863,7 @@ void main() {
|
||||
SemanticsAction.tap,
|
||||
SemanticsAction.moveCursorForwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -1919,6 +1922,7 @@ void main() {
|
||||
SemanticsAction.tap,
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -1943,6 +1947,9 @@ void main() {
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.moveCursorForwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
SemanticsAction.cut,
|
||||
SemanticsAction.copy,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -1989,6 +1996,7 @@ void main() {
|
||||
SemanticsAction.tap,
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
@ -2032,6 +2040,9 @@ void main() {
|
||||
SemanticsAction.tap,
|
||||
SemanticsAction.moveCursorBackwardByCharacter,
|
||||
SemanticsAction.setSelection,
|
||||
SemanticsAction.paste,
|
||||
SemanticsAction.cut,
|
||||
SemanticsAction.copy,
|
||||
],
|
||||
flags: <SemanticsFlag>[
|
||||
SemanticsFlag.isTextField,
|
||||
|
@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'semantics_tester.dart';
|
||||
|
||||
@ -19,6 +20,10 @@ void main() {
|
||||
final TextStyle textStyle = const TextStyle();
|
||||
final Color cursorColor = const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
|
||||
|
||||
setUp(() {
|
||||
debugResetSemanticsIdCounter();
|
||||
});
|
||||
|
||||
testWidgets('has expected defaults', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
@ -347,14 +352,14 @@ void main() {
|
||||
final EditableTextState textState = tester.state(find.byType(EditableText));
|
||||
|
||||
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.
|
||||
render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render, SelectionChangedCause.keyboard);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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 {
|
||||
@ -565,4 +570,151 @@ void main() {
|
||||
|
||||
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 {
|
||||
if (!ignoreTransform && transform != 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) {
|
||||
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;
|
||||
if (children.length != childrenCount)
|
||||
|
Loading…
x
Reference in New Issue
Block a user