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/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 {
|
|||||||
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,19 +75,23 @@ 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));
|
||||||
|
|
||||||
|
if (handleCopy != null) {
|
||||||
|
if (items.isNotEmpty)
|
||||||
items.add(onePhysicalPixelVerticalDivider);
|
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) {
|
||||||
|
if (items.isNotEmpty)
|
||||||
items.add(onePhysicalPixelVerticalDivider);
|
items.add(onePhysicalPixelVerticalDivider);
|
||||||
items.add(_buildToolbarButton('Select All', handleSelectAll));
|
items.add(_buildToolbarButton('Select All', handleSelectAll));
|
||||||
}
|
}
|
||||||
@ -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;
|
|||||||
|
|
||||||
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;
|
|||||||
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 {
|
|||||||
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):
|
|
||||||
// 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));
|
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 {
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
|||||||
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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|||||||
/// 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,6 +669,10 @@ 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 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(
|
child: new _Editable(
|
||||||
key: _editableKey,
|
key: _editableKey,
|
||||||
value: _value,
|
value: _value,
|
||||||
@ -675,6 +693,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
onCaretChanged: _handleCaretChanged,
|
onCaretChanged: _handleCaretChanged,
|
||||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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 {
|
|||||||
/// 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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
|
||||||
delegate.hideToolbar();
|
delegate.hideToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 {
|
|||||||
///
|
///
|
||||||
/// 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 {
|
|||||||
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 {
|
|||||||
/// 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 {
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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';
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
|
|
||||||
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 {
|
|||||||
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user