diff --git a/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_constants.dart b/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_constants.dart index 701446f58d9..ebc396d1b17 100644 --- a/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_constants.dart +++ b/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_constants.dart @@ -10,3 +10,6 @@ const String normalTextFieldKeyValue = 'textFieldNormal'; /// The string supplied to the [ValueKey] for the password text field. const String passwordTextFieldKeyValue = 'passwordField'; + +/// The string supplied to the [ValueKey] for the page navigation back button. +const String backButtonKeyValue = 'back'; diff --git a/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_page.dart b/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_page.dart index 86af75172b3..dd738063b88 100644 --- a/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_page.dart +++ b/dev/integration_tests/android_semantics_testing/lib/src/tests/text_field_page.dart @@ -17,13 +17,14 @@ class TextFieldPage extends StatefulWidget { class _TextFieldPageState extends State { final TextEditingController _normalController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final Key backButtonKey = const ValueKey(backButtonKeyValue); final Key normalTextFieldKey = const ValueKey(normalTextFieldKeyValue); final Key passwordTextFieldKey = const ValueKey(passwordTextFieldKeyValue); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(leading: const BackButton(key: ValueKey('back'))), + appBar: AppBar(leading: BackButton(key: backButtonKey)), body: Material( child: Column(children: [ TextField( diff --git a/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart index ef760023fb3..2568e24ada2 100644 --- a/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart +++ b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart @@ -63,6 +63,38 @@ void main() { await driver.tap(find.text(textFieldRoute)); // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 await Future.delayed(const Duration(milliseconds: 500)); + + // The text selection menu and related semantics vary depending on if + // the clipboard contents are pasteable. Copy some text into the + // clipboard to make sure these tests always run with pasteable content + // in the clipboard. + // Ideally this should test the case where there is nothing on the + // clipboard as well, but there is no reliable way to clear the + // clipboard on Android devices. + final SerializableFinder normalTextField = find.descendant( + of: find.byValueKey(normalTextFieldKeyValue), + matching: find.byType('Semantics'), + firstMatchOnly: true, + ); + await driver.tap(normalTextField); + await Future.delayed(const Duration(milliseconds: 500)); + await driver.enterText('hello world'); + await Future.delayed(const Duration(milliseconds: 500)); + await driver.tap(normalTextField); + await Future.delayed(const Duration(milliseconds: 50)); + await driver.tap(normalTextField); + await Future.delayed(const Duration(milliseconds: 500)); + await driver.tap(find.text('SELECT ALL')); + await Future.delayed(const Duration(milliseconds: 500)); + await driver.tap(find.text('COPY')); + await Future.delayed(const Duration(milliseconds: 50)); + await driver.enterText(''); + await Future.delayed(const Duration(milliseconds: 500)); + // Go back to previous page and forward again to unfocus the field. + await driver.tap(find.byValueKey(backButtonKeyValue)); + await Future.delayed(const Duration(milliseconds: 500)); + await driver.tap(find.text(textFieldRoute)); + await Future.delayed(const Duration(milliseconds: 500)); }); test('TextField has correct Android semantics', () async { diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index 510a9ef8fb5..63e91d51b8a 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'button.dart'; import 'colors.dart'; @@ -62,6 +63,151 @@ const TextStyle _kToolbarButtonDisabledFontStyle = TextStyle( // Eyeballed value. const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0); +// Generates the child that's passed into CupertinoTextSelectionToolbar. +class _CupertinoTextSelectionToolbarWrapper extends StatefulWidget { + const _CupertinoTextSelectionToolbarWrapper({ + Key key, + this.arrowTipX, + this.barTopY, + this.clipboardStatus, + this.handleCut, + this.handleCopy, + this.handlePaste, + this.handleSelectAll, + this.isArrowPointingDown, + }) : super(key: key); + + final double arrowTipX; + final double barTopY; + final ClipboardStatusNotifier clipboardStatus; + final VoidCallback handleCut; + final VoidCallback handleCopy; + final VoidCallback handlePaste; + final VoidCallback handleSelectAll; + final bool isArrowPointingDown; + + @override + _CupertinoTextSelectionToolbarWrapperState createState() => _CupertinoTextSelectionToolbarWrapperState(); +} + +class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSelectionToolbarWrapper> { + ClipboardStatusNotifier _clipboardStatus; + + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + _clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier(); + _clipboardStatus.addListener(_onChangedClipboardStatus); + _clipboardStatus.update(); + } + + @override + void didUpdateWidget(_CupertinoTextSelectionToolbarWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) { + _clipboardStatus.removeListener(_onChangedClipboardStatus); + _clipboardStatus.dispose(); + _clipboardStatus = widget.clipboardStatus; + } else if (oldWidget.clipboardStatus != null) { + if (widget.clipboardStatus == null) { + _clipboardStatus = ClipboardStatusNotifier(); + _clipboardStatus.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } else if (widget.clipboardStatus != oldWidget.clipboardStatus) { + _clipboardStatus = widget.clipboardStatus; + _clipboardStatus.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } + } + if (widget.handlePaste != null) { + _clipboardStatus.update(); + } + } + + @override + void dispose() { + super.dispose(); + // When used in an Overlay, this can be disposed after its creator has + // already disposed _clipboardStatus. + if (!_clipboardStatus.disposed) { + _clipboardStatus.removeListener(_onChangedClipboardStatus); + if (widget.clipboardStatus == null) { + _clipboardStatus.dispose(); + } + } + } + + @override + Widget build(BuildContext context) { + // Don't render the menu until the state of the clipboard is known. + if (widget.handlePaste != null + && _clipboardStatus.value == ClipboardStatus.unknown) { + return const SizedBox(width: 0.0, height: 0.0); + } + + final List items = []; + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final EdgeInsets arrowPadding = widget.isArrowPointingDown + ? EdgeInsets.only(bottom: _kToolbarArrowSize.height) + : EdgeInsets.only(top: _kToolbarArrowSize.height); + final Widget onePhysicalPixelVerticalDivider = + SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); + + void addToolbarButton( + String text, + VoidCallback onPressed, + ) { + if (items.isNotEmpty) { + items.add(onePhysicalPixelVerticalDivider); + } + + items.add(CupertinoButton( + child: Text( + text, + overflow: TextOverflow.ellipsis, + style: _kToolbarButtonFontStyle, + ), + borderRadius: null, + color: _kToolbarBackgroundColor, + minSize: _kToolbarHeight, + onPressed: onPressed, + padding: _kToolbarButtonPadding.add(arrowPadding), + pressedOpacity: 0.7, + )); + } + + if (widget.handleCut != null) { + addToolbarButton(localizations.cutButtonLabel, widget.handleCut); + } + if (widget.handleCopy != null) { + addToolbarButton(localizations.copyButtonLabel, widget.handleCopy); + } + if (widget.handlePaste != null + && _clipboardStatus.value == ClipboardStatus.pasteable) { + addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste); + } + if (widget.handleSelectAll != null) { + addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll); + } + + return CupertinoTextSelectionToolbar._( + barTopY: widget.barTopY, + arrowTipX: widget.arrowTipX, + isArrowPointingDown: widget.isArrowPointingDown, + child: items.isEmpty ? null : _CupertinoTextSelectionToolbarContent( + isArrowPointingDown: widget.isArrowPointingDown, + children: items, + ), + ); + } +} + /// An iOS-style toolbar that appears in response to text selection. /// /// Typically displays buttons for text manipulation, e.g. copying and pasting text. @@ -312,6 +458,7 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { Offset position, List endpoints, TextSelectionDelegate delegate, + ClipboardStatusNotifier clipboardStatus, ) { assert(debugCheckHasMediaQuery(context)); final MediaQueryData mediaQuery = MediaQuery.of(context); @@ -338,49 +485,15 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ? endpoints.first.point.dy - textLineHeight - _kToolbarContentDistance - _kToolbarHeight : endpoints.last.point.dy + _kToolbarContentDistance; - final List items = []; - final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); - final EdgeInsets arrowPadding = isArrowPointingDown - ? EdgeInsets.only(bottom: _kToolbarArrowSize.height) - : EdgeInsets.only(top: _kToolbarArrowSize.height); - - void addToolbarButtonIfNeeded( - String text, - bool Function(TextSelectionDelegate) predicate, - void Function(TextSelectionDelegate) onPressed, - ) { - if (!predicate(delegate)) { - return; - } - - items.add(CupertinoButton( - child: Text( - text, - overflow: TextOverflow.ellipsis, - style: _kToolbarButtonFontStyle, - ), - color: _kToolbarBackgroundColor, - minSize: _kToolbarHeight, - padding: _kToolbarButtonPadding.add(arrowPadding), - borderRadius: null, - pressedOpacity: 0.7, - onPressed: () => onPressed(delegate), - )); - } - - addToolbarButtonIfNeeded(localizations.cutButtonLabel, canCut, handleCut); - addToolbarButtonIfNeeded(localizations.copyButtonLabel, canCopy, handleCopy); - addToolbarButtonIfNeeded(localizations.pasteButtonLabel, canPaste, handlePaste); - addToolbarButtonIfNeeded(localizations.selectAllButtonLabel, canSelectAll, handleSelectAll); - - return CupertinoTextSelectionToolbar._( - barTopY: localBarTopY + globalEditableRegion.top, + return _CupertinoTextSelectionToolbarWrapper( arrowTipX: arrowTipX, + barTopY: localBarTopY + globalEditableRegion.top, + clipboardStatus: clipboardStatus, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, isArrowPointingDown: isArrowPointingDown, - child: items.isEmpty ? null : _CupertinoTextSelectionToolbarContent( - isArrowPointingDown: isArrowPointingDown, - children: items, - ), ); } diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index b9c3a4520fc..f590ea6566b 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'debug.dart'; @@ -29,6 +30,7 @@ const double _kToolbarContentDistance = 8.0; /// Manages a copy/paste text selection toolbar. class _TextSelectionToolbar extends StatefulWidget { const _TextSelectionToolbar({ + this.clipboardStatus, Key key, this.handleCut, this.handleCopy, @@ -37,6 +39,7 @@ class _TextSelectionToolbar extends StatefulWidget { this.isAbove, }) : super(key: key); + final ClipboardStatusNotifier clipboardStatus; final VoidCallback handleCut; final VoidCallback handleCopy; final VoidCallback handlePaste; @@ -50,6 +53,8 @@ class _TextSelectionToolbar extends StatefulWidget { } class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with TickerProviderStateMixin { + ClipboardStatusNotifier _clipboardStatus; + // Whether or not the overflow menu is open. When it is closed, the menu // items that don't overflow are shown. When it is open, only the overflowing // menu items are shown. @@ -66,33 +71,93 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke ); } + // Close the menu and reset layout calculations, as in when the menu has + // changed and saved values are no longer relevant. This should be called in + // setState or another context where a rebuild is happening. + void _reset() { + // Change _TextSelectionToolbarContainer's key when the menu changes in + // order to cause it to rebuild. This lets it recalculate its + // saved width for the new set of children, and it prevents AnimatedSize + // from animating the size change. + _containerKey = UniqueKey(); + // If the menu items change, make sure the overflow menu is closed. This + // prevents an empty overflow menu. + _overflowOpen = false; + } + + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + _clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier(); + _clipboardStatus.addListener(_onChangedClipboardStatus); + _clipboardStatus.update(); + } + @override void didUpdateWidget(_TextSelectionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + // If the children are changing, the current page should be reset. if (((widget.handleCut == null) != (oldWidget.handleCut == null)) || ((widget.handleCopy == null) != (oldWidget.handleCopy == null)) || ((widget.handlePaste == null) != (oldWidget.handlePaste == null)) || ((widget.handleSelectAll == null) != (oldWidget.handleSelectAll == null))) { - // Change _TextSelectionToolbarContainer's key when the menu changes in - // order to cause it to rebuild. This lets it recalculate its - // saved width for the new set of children, and it prevents AnimatedSize - // from animating the size change. - _containerKey = UniqueKey(); - // If the menu items change, make sure the overflow menu is closed. This - // prevents an empty overflow menu. - _overflowOpen = false; + _reset(); + } + if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) { + _clipboardStatus.removeListener(_onChangedClipboardStatus); + _clipboardStatus.dispose(); + _clipboardStatus = widget.clipboardStatus; + } else if (oldWidget.clipboardStatus != null) { + if (widget.clipboardStatus == null) { + _clipboardStatus = ClipboardStatusNotifier(); + _clipboardStatus.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } else if (widget.clipboardStatus != oldWidget.clipboardStatus) { + _clipboardStatus = widget.clipboardStatus; + _clipboardStatus.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } + } + if (widget.handlePaste != null) { + _clipboardStatus.update(); + } + } + + @override + void dispose() { + super.dispose(); + // When used in an Overlay, this can be disposed after its creator has + // already disposed _clipboardStatus. + if (!_clipboardStatus.disposed) { + _clipboardStatus.removeListener(_onChangedClipboardStatus); + if (widget.clipboardStatus == null) { + _clipboardStatus.dispose(); + } } - super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { + // Don't render the menu until the state of the clipboard is known. + if (widget.handlePaste != null + && _clipboardStatus.value == ClipboardStatus.unknown) { + return const SizedBox(width: 0.0, height: 0.0); + } + final MaterialLocalizations localizations = MaterialLocalizations.of(context); final List items = [ if (widget.handleCut != null) _getItem(widget.handleCut, localizations.cutButtonLabel), if (widget.handleCopy != null) _getItem(widget.handleCopy, localizations.copyButtonLabel), - if (widget.handlePaste != null) + if (widget.handlePaste != null + && _clipboardStatus.value == ClipboardStatus.pasteable) _getItem(widget.handlePaste, localizations.pasteButtonLabel), if (widget.handleSelectAll != null) _getItem(widget.handleSelectAll, localizations.selectAllButtonLabel), @@ -103,7 +168,6 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke return const SizedBox(width: 0.0, height: 0.0); } - return _TextSelectionToolbarContainer( key: _containerKey, overflowOpen: _overflowOpen, @@ -527,6 +591,18 @@ class _TextSelectionToolbarItemsRenderBox extends RenderBox with ContainerRender } return false; } + + // Visit only the children that should be painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData as ToolbarItemsParentData; + if (childParentData.shouldPaint) { + visitor(renderObjectChild); + } + }); + } } /// Centers the toolbar around the given anchor, ensuring that it remains on @@ -630,6 +706,7 @@ class _MaterialTextSelectionControls extends TextSelectionControls { Offset selectionMidpoint, List endpoints, TextSelectionDelegate delegate, + ClipboardStatusNotifier clipboardStatus, ) { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); @@ -665,8 +742,9 @@ class _MaterialTextSelectionControls extends TextSelectionControls { fitsAbove, ), child: _TextSelectionToolbar( + clipboardStatus: clipboardStatus, handleCut: canCut(delegate) ? () => handleCut(delegate) : null, - handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null, handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, isAbove: fitsAbove, diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index ef0d5b14e19..081a371d478 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1144,6 +1144,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); final GlobalKey _editableKey = GlobalKey(); + final ClipboardStatusNotifier _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); TextInputConnection _textInputConnection; TextSelectionOverlay _selectionOverlay; @@ -1190,11 +1191,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override bool get selectAllEnabled => widget.toolbarOptions.selectAll; + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + // State lifecycle: @override void initState() { super.initState(); + _clipboardStatus?.addListener(_onChangedClipboardStatus); widget.controller.addListener(_didChangeTextEditingValue); _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); @@ -1268,6 +1276,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien ); } } + if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) { + _clipboardStatus?.update(); + } } @override @@ -1284,6 +1295,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien _selectionOverlay = null; _focusAttachment.detach(); widget.focusNode.removeListener(_handleFocusChanged); + WidgetsBinding.instance.removeObserver(this); + _clipboardStatus?.removeListener(_onChangedClipboardStatus); + _clipboardStatus?.dispose(); super.dispose(); } @@ -1610,6 +1624,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (widget.selectionControls != null) { _selectionOverlay = TextSelectionOverlay( + clipboardStatus: _clipboardStatus, context: context, value: _value, debugRequiredFor: widget, @@ -1980,7 +1995,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien VoidCallback _semanticsOnCopy(TextSelectionControls controls) { return widget.selectionEnabled && copyEnabled && _hasFocus && controls?.canCopy(this) == true - ? () => controls.handleCopy(this) + ? () => controls.handleCopy(this, _clipboardStatus) : null; } @@ -1991,7 +2006,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } VoidCallback _semanticsOnPaste(TextSelectionControls controls) { - return widget.selectionEnabled && pasteEnabled &&_hasFocus && controls?.canPaste(this) == true + return widget.selectionEnabled && pasteEnabled && _hasFocus && controls?.canPaste(this) == true && (_clipboardStatus == null || _clipboardStatus.value == ClipboardStatus.pasteable) ? () => controls.handlePaste(this) : null; } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 8d5bce0e3b2..754e92dbdd9 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -12,6 +12,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'basic.dart'; +import 'binding.dart'; import 'constants.dart'; import 'container.dart'; import 'editable_text.dart'; @@ -137,6 +138,7 @@ abstract class TextSelectionControls { Offset position, List endpoints, TextSelectionDelegate delegate, + ClipboardStatusNotifier clipboardStatus, ); /// Returns the size of the selection handle. @@ -165,13 +167,16 @@ abstract class TextSelectionControls { return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed; } - /// Whether the current [Clipboard] content can be pasted into the text field - /// managed by the given `delegate`. + /// Whether the text field managed by the given `delegate` supports pasting + /// from the clipboard. /// /// Subclasses can use this to decide if they should expose the paste /// functionality to the user. + /// + /// This does not consider the contents of the clipboard. Subclasses may want + /// to, for example, disallow pasting when the clipboard contains an empty + /// string. bool canPaste(TextSelectionDelegate delegate) { - // TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254 return delegate.pasteEnabled; } @@ -213,11 +218,12 @@ abstract class TextSelectionControls { /// /// This is called by subclasses when their copy affordance is activated by /// the user. - void handleCopy(TextSelectionDelegate delegate) { + void handleCopy(TextSelectionDelegate delegate, ClipboardStatusNotifier clipboardStatus) { final TextEditingValue value = delegate.textEditingValue; Clipboard.setData(ClipboardData( text: value.selection.textInside(value.text), )); + clipboardStatus?.update(); delegate.textEditingValue = TextEditingValue( text: value.text, selection: TextSelection.collapsed(offset: value.selection.end), @@ -294,6 +300,7 @@ class TextSelectionOverlay { this.selectionDelegate, this.dragStartBehavior = DragStartBehavior.start, this.onSelectionHandleTapped, + this.clipboardStatus, }) : assert(value != null), assert(context != null), assert(handlesVisible != null), @@ -365,6 +372,13 @@ class TextSelectionOverlay { /// {@endtemplate} final VoidCallback onSelectionHandleTapped; + /// Maintains the status of the clipboard for determining if its contents can + /// be pasted or not. + /// + /// Useful because the actual value of the clipboard can only be checked + /// asynchronously (see [Clipboard.getData]). + final ClipboardStatusNotifier clipboardStatus; + /// Controls the fade-in and fade-out animations for the toolbar and handles. static const Duration fadeDuration = Duration(milliseconds: 150); @@ -576,6 +590,7 @@ class TextSelectionOverlay { midpoint, endpoints, selectionDelegate, + clipboardStatus, ), ), ); @@ -1486,3 +1501,86 @@ class _TransparentTapGestureRecognizer extends TapGestureRecognizer { } } } + +/// A [ValueNotifier] whose [value] indicates whether the current contents of +/// the clipboard can be pasted. +/// +/// The contents of the clipboard can only be read asynchronously, via +/// [Clipboard.getData], so this maintains a value that can be used +/// synchronously. Call [update] to asynchronously update value if needed. +class ClipboardStatusNotifier extends ValueNotifier with WidgetsBindingObserver { + /// Create a new ClipboardStatusNotifier. + ClipboardStatusNotifier({ + ClipboardStatus value = ClipboardStatus.unknown, + }) : super(value); + + bool _disposed = false; + /// True iff this instance has been disposed. + bool get disposed => _disposed; + + /// Check the [Clipboard] and update [value] if needed. + void update() { + Clipboard.getData(Clipboard.kTextPlain).then((ClipboardData data) { + final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text.isNotEmpty + ? ClipboardStatus.pasteable + : ClipboardStatus.notPasteable; + if (_disposed || clipboardStatus == value) { + return; + } + value = clipboardStatus; + }); + } + + @override + void addListener(VoidCallback listener) { + if (!hasListeners) { + WidgetsBinding.instance.addObserver(this); + } + if (value == ClipboardStatus.unknown) { + update(); + } + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + WidgetsBinding.instance.removeObserver(this); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + update(); + break; + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + // Nothing to do. + } + } + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.removeObserver(this); + _disposed = true; + } +} + +/// An enumeration of the status of the content on the user's clipboard. +enum ClipboardStatus { + /// The clipboard content can be pasted, such as a String of nonzero length. + pasteable, + + /// The status of the clipboard is unknown. Since getting clipboard data is + /// asynchronous (see [Clipboard.getData]), this status often exists while + /// waiting to receive the clipboard contents for the first time. + unknown, + + /// The content on the clipboard is not pasteable, such as when it is empty. + notPasteable, +} diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 49fb244ea8f..07819634320 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -171,8 +171,11 @@ void main() { Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(0, -2); - setUp(() { + setUp(() async { EditableText.debugDeterministicCursor = false; + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); }); testWidgets( @@ -1545,7 +1548,7 @@ void main() { await tester.tapAt(textOffsetToPosition(tester, index)); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -1585,7 +1588,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Second tap selects the word around the cursor. expect( @@ -1621,7 +1624,7 @@ void main() { final TestGesture gesture = await tester.startGesture(textfieldStart + const Offset(150.0, 5.0)); // Hold the press. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); expect( controller.selection, @@ -1760,7 +1763,7 @@ void main() { final TestGesture gesture = await tester.startGesture(textfieldStart + const Offset(150.0, 5.0)); // Hold the press. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); // The obscured text is treated as one word, should select all expect( @@ -1846,7 +1849,7 @@ void main() { final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); await tester.longPressAt(textfieldStart + const Offset(50.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Collapsed cursor for iOS long press. expect( @@ -1946,7 +1949,7 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); // The selection isn't affected by the gesture lift. expect( @@ -2021,7 +2024,7 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); // The selection isn't affected by the gesture lift. expect( @@ -2076,7 +2079,7 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await tester.longPressAt(textfieldStart + const Offset(100.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Plain collapsed selection at the exact tap position. expect( @@ -2118,7 +2121,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap selection. expect( @@ -2155,7 +2158,7 @@ void main() { const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), ); await tester.tapAt(textfieldStart + const Offset(50.0, 5.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -2171,7 +2174,7 @@ void main() { const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), ); await tester.tapAt(textfieldStart + const Offset(100.0, 5.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -2186,7 +2189,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 5.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12), @@ -2230,7 +2233,7 @@ void main() { ); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); // Shows toolbar. expect(find.byType(CupertinoButton), findsNWidgets(3)); }); @@ -3842,7 +3845,7 @@ void main() { // Long press shows the selection menu. await tester.longPressAt(textOffsetToPosition(tester, 0)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Paste'), findsOneWidget); }, ); diff --git a/packages/flutter/test/cupertino/text_selection_test.dart b/packages/flutter/test/cupertino/text_selection_test.dart index 379a1cf5110..5f7288a04ce 100644 --- a/packages/flutter/test/cupertino/text_selection_test.dart +++ b/packages/flutter/test/cupertino/text_selection_test.dart @@ -8,9 +8,26 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../widgets/text.dart' show textOffsetToPosition; +class MockClipboard { + Object _clipboardData = { + 'text': null, + }; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return _clipboardData; + case 'Clipboard.setData': + _clipboardData = methodCall.arguments; + break; + } + } +} + class _LongCupertinoLocalizationsDelegate extends LocalizationsDelegate { const _LongCupertinoLocalizationsDelegate(); @@ -49,6 +66,9 @@ class _LongCupertinoLocalizations extends DefaultCupertinoLocalizations { const _LongCupertinoLocalizations longLocalizations = _LongCupertinoLocalizations(); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); + SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); // Returns true iff the button is visually enabled. bool appearsEnabled(WidgetTester tester, String text) { @@ -154,6 +174,55 @@ void main() { }); }); + testWidgets('Paste only appears when clipboard has contents', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: [ + CupertinoTextField( + controller: controller, + ), + ], + ), + ), + ); + + // Make sure the clipboard is empty to start. + await Clipboard.setData(const ClipboardData(text: '')); + + // Double tap to selet the first word. + const int index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // No Paste yet, because nothing has been copied. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + + // Tap copy to add something to the clipboard and close the menu. + await tester.tapAt(tester.getCenter(find.text('Copy'))); + await tester.pumpAndSettle(); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + + // Double tap to show the menu again. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Paste now shows. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + }, skip: isBrowser, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + group('Text selection menu overflow (iOS)', () { testWidgets('All menu items show when they fit.', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'abc def ghi'); @@ -181,7 +250,7 @@ void main() { // Long press on an empty space to show the selection menu. await tester.longPressAt(textOffsetToPosition(tester, 4)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); expect(find.text('Paste'), findsOneWidget); @@ -405,7 +474,7 @@ void main() { // Long press on an empty space to show the selection menu, with only the // paste button visible. await tester.longPressAt(textOffsetToPosition(tester, 4)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text(longLocalizations.cutButtonLabel), findsNothing); expect(find.text(longLocalizations.copyButtonLabel), findsNothing); expect(find.text(longLocalizations.pasteButtonLabel), findsOneWidget); diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index 947f5446176..dc47a0d24ed 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -3,12 +3,31 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; import 'feedback_tester.dart'; +class MockClipboard { + Object _clipboardData = { + 'text': null, + }; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return _clipboardData; + case 'Clipboard.setData': + _clipboardData = methodCall.arguments; + break; + } + } +} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); DateTime firstDate; DateTime lastDate; @@ -35,7 +54,7 @@ void main() { return tester.widget(find.byType(TextField)); } - setUp(() { + setUp(() async { firstDate = DateTime(2001, DateTime.january, 1); lastDate = DateTime(2031, DateTime.december, 31); initialDate = DateTime(2016, DateTime.january, 15); @@ -51,6 +70,15 @@ void main() { fieldHintText = null; fieldLabelText = null; helpText = null; + + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + SystemChannels.platform.setMockMethodCallHandler(null); }); Future prepareDatePicker(WidgetTester tester, Future callback(Future date)) async { @@ -1075,7 +1103,6 @@ void main() { semantics.dispose(); }); - }); group('Screen configurations', () { diff --git a/packages/flutter/test/material/search_test.dart b/packages/flutter/test/material/search_test.dart index d1c6e2dcce9..446120f8f12 100644 --- a/packages/flutter/test/material/search_test.dart +++ b/packages/flutter/test/material/search_test.dart @@ -9,7 +9,37 @@ import 'package:flutter_test/flutter_test.dart'; import '../widgets/semantics_tester.dart'; +class MockClipboard { + Object _clipboardData = { + 'text': null, + }; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return _clipboardData; + case 'Clipboard.setData': + _clipboardData = methodCall.arguments; + break; + } + } +} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); + + setUp(() async { + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + SystemChannels.platform.setMockMethodCallHandler(null); + }); + testWidgets('Can open and close search', (WidgetTester tester) async { final _TestSearchDelegate delegate = _TestSearchDelegate(); final List selectedResults = []; diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 2425222f7fc..eb53932ad79 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -142,8 +142,11 @@ void main() { kThreeLines + "\nFourth line won't display and ends at"; - setUp(() { + setUp(() async { debugResetSemanticsIdCounter(); + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); }); final Key textFieldKey = UniqueKey(); @@ -1045,7 +1048,7 @@ void main() { const int dIndex = 3; final Offset dPos = textOffsetToPosition(tester, dIndex); await tester.longPressAt(dPos); - await tester.pump(); + await tester.pumpAndSettle(); // Context menu should not have paste and cut. expect(find.text('COPY'), findsOneWidget); @@ -1783,6 +1786,8 @@ void main() { renderEditable, ); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + // Pump an extra frame to allow the selection menu to read the clipboard. + await tester.pump(); await tester.pump(); // Toolbar should fade in. Starting at 0% opacity. @@ -1893,7 +1898,7 @@ void main() { // Long press to select text. final Offset bPos = textOffsetToPosition(tester, 1); await tester.longPressAt(bPos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); // Should only have paste option when whole obscure text is selected. expect(find.text('PASTE'), findsOneWidget); @@ -1905,7 +1910,7 @@ void main() { final Offset iPos = textOffsetToPosition(tester, 10); final Offset slightRight = iPos + const Offset(30.0, 0.0); await tester.longPressAt(slightRight, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); // Should have paste and select all options when collapse. expect(find.text('PASTE'), findsOneWidget); @@ -5037,6 +5042,78 @@ void main() { semantics.dispose(); }); + testWidgets('When clipboard empty, no semantics paste option', (WidgetTester tester) async { + const String textInTextField = 'Hello'; + + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner; + final TextEditingController controller = TextEditingController() + ..text = textInTextField; + final Key key = UniqueKey(); + + // Clear the clipboard. + await Clipboard.setData(const ClipboardData(text: '')); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + ), + ), + ); + + const int inputFieldId = 1; + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: inputFieldId, + flags: [SemanticsFlag.isTextField], + actions: [SemanticsAction.tap], + value: textInTextField, + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, ignoreTransform: true, + )); + + semanticsOwner.performAction(inputFieldId, SemanticsAction.tap); + await tester.pump(); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: inputFieldId, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isFocused, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + // No paste option. + ], + value: textInTextField, + textDirection: TextDirection.ltr, + textSelection: const TextSelection( + baseOffset: textInTextField.length, + extentOffset: textInTextField.length, + ), + ), + ], + ), + ignoreRect: true, ignoreTransform: true, + )); + + semantics.dispose(); + }); + testWidgets('TextField throws when not descended from a Material widget', (WidgetTester tester) async { const Widget textField = TextField(); await tester.pumpWidget(textField); @@ -5798,7 +5875,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Second tap selects the word around the cursor. expect( @@ -5843,7 +5920,7 @@ void main() { const TextSelection.collapsed(offset: 9), ); await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Second tap selects the word around the cursor. expect( @@ -5894,7 +5971,7 @@ void main() { // Second tap selects the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -5926,14 +6003,14 @@ void main() { await tester.tapAt(textOffsetToPosition(tester, 0)); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(textOffsetToPosition(tester, 0)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); // Double tap again keeps the selection menu visible. await tester.tapAt(textOffsetToPosition(tester, 0)); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(textOffsetToPosition(tester, 0)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); }, ); @@ -5958,7 +6035,7 @@ void main() { // Long press shows the selection menu. await tester.longPressAt(textOffsetToPosition(tester, 0)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); // Long press again keeps the selection menu visible. @@ -5988,7 +6065,7 @@ void main() { // Long press shows the selection menu. await tester.longPress(find.byType(TextField)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); // Tap hides the selection menu. @@ -6023,7 +6100,7 @@ void main() { // Long press shows the selection menu. expect(find.text('PASTE'), findsNothing); await tester.longPress(find.byType(TextField)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); }, ); @@ -6053,7 +6130,7 @@ void main() { final TestGesture gesture = await tester.startGesture(textfieldStart + const Offset(150.0, 9.0)); // Hold the press. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); expect( controller.selection, @@ -6142,7 +6219,7 @@ void main() { final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Collapsed cursor for iOS long press. expect( @@ -6175,7 +6252,7 @@ void main() { final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, @@ -6278,7 +6355,7 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); // The selection isn't affected by the gesture lift. expect( @@ -6352,7 +6429,7 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); // The selection isn't affected by the gesture lift. expect( @@ -6409,7 +6486,7 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await tester.longPressAt(textfieldStart + const Offset(100.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Plain collapsed selection at the exact tap position. expect( @@ -6452,7 +6529,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap selection. expect( @@ -6487,7 +6564,7 @@ void main() { const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), ); await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -6503,7 +6580,7 @@ void main() { const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), ); await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -6518,7 +6595,7 @@ void main() { const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream), ); await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12), @@ -6610,7 +6687,7 @@ void main() { ); await gesture.up(); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.byType(CupertinoButton), findsNWidgets(3)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); @@ -7665,4 +7742,48 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('Web does not check the clipboard status', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + bool triedToReadClipboard = false; + SystemChannels.platform + .setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'Clipboard.getData') { + triedToReadClipboard = true; + } + return null; + }); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + // Double tap like when showing the text selection menu on Android/iOS. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + + if (kIsWeb) { + // The clipboard is not checked because it requires user permissions and + // web doesn't show a custom text selection menu. + expect(triedToReadClipboard, false); + } else { + // The clipboard is checked in order to decide if the content can be + // pasted. + expect(triedToReadClipboard, true); + } + }); } diff --git a/packages/flutter/test/material/text_selection_test.dart b/packages/flutter/test/material/text_selection_test.dart index 34a0fa59662..fe8a8a29409 100644 --- a/packages/flutter/test/material/text_selection_test.dart +++ b/packages/flutter/test/material/text_selection_test.dart @@ -5,10 +5,35 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition; +class MockClipboard { + Object _clipboardData = { + 'text': null, + }; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return _clipboardData; + case 'Clipboard.setData': + _clipboardData = methodCall.arguments; + break; + } + } +} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); + SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); + + setUp(() async { + await Clipboard.setData(const ClipboardData(text: 'clipboard data')); + }); + group('canSelectAll', () { Widget createEditableText({ Key key, @@ -104,7 +129,7 @@ void main() { expect(endpoints.length, 1); final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); await tester.tapAt(handlePos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('CUT'), findsNothing); expect(find.text('COPY'), findsNothing); expect(find.text('PASTE'), findsOneWidget); @@ -235,7 +260,7 @@ void main() { // Long press to show the menu. final Offset textOffset = textOffsetToPosition(tester, 1); await tester.longPressAt(textOffset); - await tester.pump(); + await tester.pumpAndSettle(); // The last two buttons are missing, and a more button is shown. expect(find.text('CUT'), findsOneWidget); @@ -301,7 +326,7 @@ void main() { // Long press to show the menu. final Offset textOffset = textOffsetToPosition(tester, 1); await tester.longPressAt(textOffset); - await tester.pump(); + await tester.pumpAndSettle(); // The last button is missing, and a more button is shown. expect(find.text('CUT'), findsOneWidget); @@ -413,7 +438,7 @@ void main() { // Long press to show the menu. await tester.longPressAt(textOffsetToPosition(tester, 1)); - await tester.pump(); + await tester.pumpAndSettle(); // The last button is missing, and a more button is shown. expect(find.text('CUT'), findsOneWidget); @@ -488,7 +513,7 @@ void main() { expect(endpoints.length, 1); final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); await tester.tapAt(handlePos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('CUT'), findsNothing); expect(find.text('COPY'), findsNothing); expect(find.text('PASTE'), findsOneWidget); @@ -556,4 +581,58 @@ void main() { ); }); }); + + testWidgets('Paste only appears when clipboard has contents', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [ + TextField( + controller: controller, + ), + ], + ), + ), + ), + ); + + // Make sure the clipboard is empty to start. + await Clipboard.setData(const ClipboardData(text: '')); + + // Double tap to selet the first word. + const int index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // No Paste yet, because nothing has been copied. + expect(find.text('PASTE'), findsNothing); + expect(find.text('COPY'), findsOneWidget); + expect(find.text('CUT'), findsOneWidget); + expect(find.text('SELECT ALL'), findsOneWidget); + + // Tap copy to add something to the clipboard and close the menu. + await tester.tapAt(tester.getCenter(find.text('COPY'))); + await tester.pumpAndSettle(); + expect(find.text('COPY'), findsNothing); + expect(find.text('CUT'), findsNothing); + expect(find.text('SELECT ALL'), findsNothing); + + // Double tap to show the menu again. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Paste now shows. + expect(find.text('COPY'), findsOneWidget); + expect(find.text('CUT'), findsOneWidget); + expect(find.text('PASTE'), findsOneWidget); + expect(find.text('SELECT ALL'), findsOneWidget); + }, skip: isBrowser); } diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart index 2e1144a91c1..45a1c21a277 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -19,6 +19,12 @@ const TextStyle textStyle = TextStyle(); const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); void main() { + setUp(() async { + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + testWidgets('cursor has expected width and radius', (WidgetTester tester) async { await tester.pumpWidget( MediaQuery(data: const MediaQueryData(devicePixelRatio: 1.0), @@ -39,7 +45,6 @@ void main() { expect(editableText.cursorRadius.x, 2.0); }); - testWidgets('cursor layout has correct width', (WidgetTester tester) async { final GlobalKey editableTextKey = GlobalKey(); @@ -132,7 +137,7 @@ void main() { final Finder textFinder = find.byKey(editableTextKey); await tester.longPress(textFinder); tester.state(textFinder).showToolbar(); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('PASTE')); await tester.pump(); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 409c46098bc..1093db82df0 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -48,9 +48,12 @@ void main() { final MockClipboard mockClipboard = MockClipboard(); SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall); - setUp(() { + setUp(() async { debugResetSemanticsIdCounter(); controller = TextEditingController(); + // Fill the clipboard so that the PASTE option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); }); tearDown(() { @@ -961,7 +964,7 @@ void main() { // Can't show the toolbar when there's no focus. expect(state.showToolbar(), false); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsNothing); // Can show the toolbar when focused even though there's no text. @@ -971,7 +974,7 @@ void main() { ); await tester.pump(); expect(state.showToolbar(), true); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); // Hide the menu again. @@ -983,7 +986,7 @@ void main() { controller.text = 'blah'; await tester.pump(); expect(state.showToolbar(), true); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); }, skip: isBrowser); @@ -1023,7 +1026,7 @@ void main() { // Should be able to show the toolbar. expect(state.showToolbar(), true); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('PASTE'), findsOneWidget); }); @@ -1192,7 +1195,7 @@ void main() { final Finder textFinder = find.byType(EditableText); await tester.longPress(textFinder); tester.state(textFinder).showToolbar(); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.text('PASTE')); await tester.pump(); @@ -2412,12 +2415,13 @@ void main() { controls = MockTextSelectionControls(); when(controls.buildHandle(any, any, any)).thenReturn(Container()); - when(controls.buildToolbar(any, any, any, any, any, any)) + when(controls.buildToolbar(any, any, any, any, any, any, any)) .thenReturn(Container()); }); testWidgets('are exposed', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); + addTearDown(semantics.dispose); when(controls.canCopy(any)).thenReturn(false); when(controls.canCut(any)).thenReturn(false); @@ -2457,6 +2461,7 @@ void main() { when(controls.canCopy(any)).thenReturn(false); when(controls.canPaste(any)).thenReturn(true); await _buildApp(controls, tester); + await tester.pumpAndSettle(); expect( semantics, includesNodeWith( @@ -2504,8 +2509,6 @@ void main() { ], ), ); - - semantics.dispose(); }); testWidgets('can copy/cut/paste with a11y', (WidgetTester tester) async { @@ -2564,7 +2567,7 @@ void main() { ); owner.performAction(expectedNodeId, SemanticsAction.copy); - verify(controls.handleCopy(any)).called(1); + verify(controls.handleCopy(any, any)).called(1); owner.performAction(expectedNodeId, SemanticsAction.cut); verify(controls.handleCut(any)).called(1);