Skip to content

Paste shows only when content on clipboard #54902

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 7, 2020

Conversation

justinmc
Copy link
Contributor

@justinmc justinmc commented Apr 15, 2020

Description

On native, the paste button in the text selection menu only shows when there is something to paste on the clipboard. This PR adds the same functionality for Flutter.

There was a long standing TODO in the code about making the canPaste method consider what's on the clipboard. I did not take this approach, because checking the clipboard is asynchronous and canPaste is used synchronously.

Instead, the text selection menus check the clipboard asynchronously when they're rendered and rerender if needed. The clipboard is also checked when the app is resumed. This results in the selection menu always being up to date, even if it's sometimes 1 frame late. The only way I can find to trick it is by clearing the clipboard while the selection menu is still visible using the command line. This shouldn't be possible in a production app, only while running an emulator.

Related Issues

#4355

Tests

  • Check the appearance of the Paste button when there is and is not content on the clipboard.

@justinmc justinmc added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels Apr 15, 2020
@justinmc justinmc self-assigned this Apr 15, 2020
@fluttergithubbot fluttergithubbot added f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. labels Apr 15, 2020
@goderbauer
Copy link
Member

goderbauer commented Apr 15, 2020

(haven't reviewed the code, but two questions from just reading the description)

  1. doesn't this cause visual glitches if for one frame we render the paste button and then remove it? Could we maybe just delay showing the toolbar until we know whether it's enabled or not?
  2. does this also disable the paste option in TalkBack's local context menu if the clipboard is empty? (Can't remember if this already worked before this change)

void _updateClipboardStatus() {
Clipboard.getData(Clipboard.kTextPlain).then((ClipboardData value) {
final bool isClipboardEmpty = value?.text == null || value.text.isEmpty;
if (isClipboardEmpty != _isClipboardEmpty) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check if the widget is still mounted when the future completes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good call.


@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use switch for enums per the style guide.

@override Widget build(BuildContext context) {
// Asynchronously check if the clipboard has changed, and trigger another
// build if so.
if (widget.handlePaste != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: build should be idempotent.

Copy link
Contributor Author

@justinmc justinmc Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I worried about this when I wrote it. I'm going to move it into didChangeDependencies, which is still probably not the best, but I think it's necessary in this case.

Edit: didChangeDependencies, not didUpdateWidget.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to do this in initState?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was worried about the menu getting out of date with the clipboard status, but it seems not to really happen in actual use cases, and it doesn't break any tests. I'll change it to initState.

@justinmc
Copy link
Contributor Author

@goderbauer There don't seem to be any visual glitches, and I think it's because of the fade-in animation. If I don't render the toolbar until I get the content from the clipboard, then many tests fail (over 100) because they're expecting a Paste button 1 frame after tapping. These failures seem fixable by changing pump topumpAndSettle in these cases. Let me know if think it's better to go with that and update the pumps, or if you have another idea.

About TalkBack, I just tried it and it seemed to work. TalkBack highlighted and spoke the visible toolbar buttons. When paste wasn't there, it wasn't usable.

@goderbauer
Copy link
Member

I would prefer updating the test. Now the tests seem just wrong: They are expecting a Paste button that may actually not become visible to the user.

@justinmc
Copy link
Contributor Author

@goderbauer @LongCatIsLooong Updated to only render when clipboard data is available. I changed many tests to use pumpAndSettle when looking for the selection menu. I agree that this makes more sense!

@@ -66,33 +70,88 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
);
}

// Asynchronously check if the clipboard has data and update
// _isClipboardEmpty if needed.
void _updateClipboardStatus() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should this check if widget.handlePaste is not null first, in case the information isn't needed at all?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 (see my comment above)

///
/// Subclasses can use this to decide if they should expose the paste
/// functionality to the user.
///
/// This does not consider the current contents of the clipboard.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could you elaborate a bit more on this? It confused me a little on the first read-through.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do.

void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_updateClipboardStatus();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to control-c when the toolbar is visible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I wasn't able to achieve this in the simulator, but maybe on desktop it will be possible, or on a mobile device with a physical keyboard attached.

I wish there was a Clipboard listener. I'm not sure how to handle this case if it is possible.

My macbook seems to close the native context menu if I ctrl-C, so maybe we can do that if it comes up on desktop.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

}

@override
Widget build(BuildContext context) {
// Don't render the menu until the state of the clipboard is known.
if (widget.handlePaste != null && _isClipboardEmpty == null) {
return const SizedBox(width: 0.0, height: 0.0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// Whether the user's system clipboard is empty as of the last check.
bool _isClipboardEmpty;

// Asynchronously check if theh clipboard has data and update
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Asynchronously check if theh clipboard has data and update
// Asynchronously check if the clipboard has data and update

if (isClipboardEmpty == _isClipboardEmpty) {
return;
}
_isClipboardEmpty = isClipboardEmpty;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move everything except for this line outside of the setState? (Only this line is actually setting state).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved everything inside because I was getting weird test failures when the widget would unmount, but I don't see anything like that now. I'll move it outside.

case AppLifecycleState.resumed:
_updateClipboardStatus();
break;
default:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we try to avoid default cases. That way, every time we add a new value to the enum the analyzer tells us all the places in the code base where we have to consider the new value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's smart, I didn't know that.

super.dispose();
}

@override Widget build(BuildContext context) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: stange formatting

WidgetsBinding.instance.addObserver(this);
// Asynchronously check if the clipboard has changed, and trigger another
// build if so.
_updateClipboardStatus();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we only do this if widget.handlePaste is set to something? (here and elsewhere where we call it)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(plus we'd need to do it in didUpdateWidget if it changes)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I'm going to change this to check if widget.handlePaste is set and do this in didUpdateWidget as well. You can actually cause a bug without this change by opening the menu with nothing copied, copying something while the menu is open, and then tapping Select All to make the menu rebuild. At least in the simulator it's possible.

@@ -66,33 +70,88 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
);
}

// Asynchronously check if the clipboard has data and update
// _isClipboardEmpty if needed.
void _updateClipboardStatus() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 (see my comment above)

@@ -49,7 +50,10 @@ class _TextSelectionToolbar extends StatefulWidget {
_TextSelectionToolbarState createState() => _TextSelectionToolbarState();
}

class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with TickerProviderStateMixin {
class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with TickerProviderStateMixin, WidgetsBindingObserver {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the comments I made on the Cupertino impl also apply here.

@justinmc
Copy link
Contributor Author

Code review changes made, thanks for the comments.

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You will have to list all other cases as no-ops.

@goderbauer
Copy link
Member

Did you have a chance to check how TalkBack behaves? I think it is showing a paste option in its local context menu. Does the operating system auto-hide that if there is nothing to paste? Or do we need to hide the this pate action from the text field in that case:

onPaste: _semanticsOnPaste(controls),

(Same question for pasting with iOS and VoiceOver).

@justinmc
Copy link
Contributor Author

@goderbauer I didn't realize there was a separate context menu for TalkBack! I thought you just meant how TalkBack read out our context menu. It seems that yes, the native TalkBack context menu does hide/show paste based on the content of the clipboard. I think I'll have to add this isClipboardEmpty state to EditableText unfortunately.

@goderbauer
Copy link
Member

It seems that yes, the native TalkBack context menu does hide/show paste based on the content of the clipboard.

Does it do that based on what's actually in the clipboard, or do we need to tell TalkBack to show/hide the option?

@justinmc
Copy link
Contributor Author

It looks like we need to tell it. If I comment out the onPaste handler in the Semantics widget in EditableText, then it doesn't show the paste option. Otherwise it does, even if nothing is on the clipboard.

@justinmc
Copy link
Contributor Author

@goderbauer I added the same clipboard checking logic to EditableText. Is there a nice way to abstract this code somehow?

@goderbauer
Copy link
Member

goderbauer commented Apr 27, 2020

Could we implement a class "ClipBoardStatus" which is a Listenable. The listenable notifies its listeners whenever the clipboard status changes (Textfield and the Toolbar would listen to that). It provides two getters: isPasteStatusPending and canPaste. The first one returns true while we wait for the Clipboard service to give us an answer. The second one returns true when there is data to paste in the clipboard. The class adds itself as a WidgetObserver when the first listener is added and removed its observer when the last listener is removed. This new class can then encapsulate all the logic that's currently duplicated in multiple places. You could even pass this object from the textfield to the Toolbar widget to avoid multiple instances. WDYT?

BTW, all names used in the paragraph above are just placeholders. Feel free to pick better names.

Comment on lines 137 to 142
case AppLifecycleState.detached:
break;
case AppLifecycleState.inactive:
break;
case AppLifecycleState.paused:
break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can be more compact.

Suggested change
case AppLifecycleState.detached:
break;
case AppLifecycleState.inactive:
break;
case AppLifecycleState.paused:
break;
case AppLifecycleState.detached:
case AppLifecycleState.detached:
case AppLifecycleState.paused:
// Nothing to do.

_updateClipboardStatus();
}
break;
case AppLifecycleState.detached:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here.

@skia-gold
Copy link

Gold has detected one or more untriaged digests on patchset 29.
Please triage them at https://flutter-gold.skia.org/search?issue=54902.

@fluttergithubbot
Copy link
Contributor

Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change).

Changes to golden files are considered breaking changes, so consult Handling Breaking Changes to proceed. While there are exceptions to this rule, if this patch modifies an existing golden file, it is probably not an exception. Only new golden file tests, or downstream changes like those from skia updates are considered non-breaking.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

Changes reported for pull request #54902 at sha 54a72e9ed0ec4f23ed5c2c15526b85c37f2f6246

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -1155,11 +1156,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll;

// Inform the widget that the value of clipboardStatus has changed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: move this comment also inside the set state to explain why the empty set state is OK here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed that, thanks.

@justinmc justinmc removed c: API break Backwards-incompatible API changes will affect goldens Changes to golden files labels May 7, 2020
@justinmc
Copy link
Contributor Author

justinmc commented May 7, 2020

Just had to squash all the commits in order to fix a failure.

@fluttergithubbot fluttergithubbot merged commit 053c388 into flutter:master May 7, 2020
@justinmc justinmc deleted the pasteable branch May 7, 2020 23:29
justinmc added a commit to justinmc/flutter that referenced this pull request May 8, 2020
jmagman pushed a commit that referenced this pull request May 8, 2020
justinmc added a commit to justinmc/flutter that referenced this pull request May 8, 2020
justinmc added a commit that referenced this pull request May 8, 2020
Unreverts #54902 with fixes for a failing integration test.
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jul 31, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
a: text input Entering text in a text field or keyboard related problems f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants