Skip to content

Conversation

@Rexios80
Copy link
Member

@Rexios80 Rexios80 commented Apr 11, 2024

Spiritual continuation of #126835


Right now, copying text in a SelectionArea copies it with no separation. Since SelectionArea is very useful with vertical lists of Text widgets this is not ideal. This PR adds a copyInterceptor field to SelectionArea so that developers can add custom copy behavior to SelectionArea widgets.

Fixes #104548

I added a new test for this

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is test-exempt.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. labels Apr 11, 2024
@Rexios80
Copy link
Member Author

@chunhtai Let's see if we like this one

@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems f: scrolling Viewports, list views, slivers, etc. d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels Apr 11, 2024
@justinmc justinmc self-requested a review April 11, 2024 22:27
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Just a quick review with some questions and ideas. I think I'm inclined to like this approach at a high level. Thanks @Rexios80 for jumping in and getting the conversation started with this PR.

Comment on lines 11 to 13
abstract class CopyInterceptor {
/// Creates a [CopyInterceptor].
const CopyInterceptor();
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering if this needs to be a class instead of just a function, or otherwise if this should be a non-abstract class that takes the intercept function as a parameter. Just details to consider.

Copy link
Member Author

Choose a reason for hiding this comment

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

I originally had it as just a function, but it quickly became a mess. Having this as a class has the added benefit of letting us have const factory constructors.

Copy link
Member Author

Choose a reason for hiding this comment

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

Also is the _InlineCopyInterceptor basically what you're talking about?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah thanks, and I guess it's made public with CopyInterceptor.inline 👍

Comment on lines 2226 to 2250
final StringBuffer buffer = StringBuffer();
for (final SelectedContent selection in selections) {
buffer.write(selection.plainText);
}
return SelectedContent(
plainText: buffer.toString(),
);
return SelectedContent(plainText: copyInterceptor.intercept(selections));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this all there is to how we currently do copy? If so, I kind of like this approach at a high level. Just give users the ability to use their own function at this point, so they can pretty much do whatever they want here.

text: data,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
copyInterceptor: copyInterceptor,
Copy link
Contributor

Choose a reason for hiding this comment

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

I have slight reservations about adding this parameter to every relevant widget. I guess it's not a lot of them, though, and I don't have a better alternative off the top of my head.


@override
String intercept(List<SelectedContent> selections) => _interceptInline(selections);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What if a user wants to do something like this: every Row widget copies in a special way, and every other widget copies in a different way? Row is just an example here, could be any specific widget. I'm wondering whether this approach will give users this kind of flexibility or not.

Copy link
Member Author

Choose a reason for hiding this comment

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

This PR adds the ability to nest SelectionAreas so there is no reason you couldn't have whatever behavior you want

Copy link
Contributor

@Renzo-Olivares Renzo-Olivares left a comment

Choose a reason for hiding this comment

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

Thank you for the contribution @Rexios80!

Just some small comments and thoughts about this approach.

  1. SelectionArea/SelectableRegion can now be nested.
    My main concern is that SelectionArea/SelectableRegion includes logic for gesture handling and managing selection overlays which will not be used in the case of nesting. It also forces the user to use nested SelectionAreas if they want to accomplish a space separated row inside of a vertical List.
  child: SelectionArea(
    child: Container(
      height: 600.0,
      width: 600.0,
      child: ListView(
        children: <Widget>[
          SelectionArea(
            copyInterceptor: CopyInterceptor.space,
            child: Row(
              children: <Widget>[
                const Text(
                  'Text widget 1',
                ),
                const Text(
                  'Text widget 2',
                ),
                const Text(
                  'Text widget 3',
                ),
                const Text(
                  'Text widget 4',
                ),
              ],
            ),
          ),
        ],
      ),
    ),
  ),

instead I would expect something like

child: SelectionArea(
  child: Container(
    height: 600.0,
    width: 600.0,
    child: ListView(
      children: <Widget>[
        SelectionContainer(
          copyInterceptor: CopyInterceptor.space,
          child: Row(
            children: <Widget>[
              const Text(
                'Text widget 1',
              ),
              const Text(
                'Text widget 2',
              ),
              const Text(
                'Text widget 3',
              ),
              const Text(
                'Text widget 4',
              ),
            ],
          ),
        ),
      ],
    ),
  ),
),

The SelectionContainer has a required SelectionContainerDelegate field so it may also be appropriate to make CopyInterceptor a part of MultiSelectableSelectionContainerDelegate.

@protected
CopyInterceptor get copyInterceptor => _defaultCopyInterceptor;

@Rexios80
Copy link
Member Author

MultiSelectableSelectionContainerDelegate is abstract and requiring users to create their own delegate class for that behavior seems overly complicated. Maybe we need a new intermediate widget to handle it?

@Rexios80
Copy link
Member Author

Rexios80 commented Apr 12, 2024

Something like this maybe?

Removed for readability

@Rexios80
Copy link
Member Author

Well that code didn't work and these delegates seem overly complicated to design. Can we just reuse the _SelectableRegionContainerDelegate?

class SelectionInterceptor extends StatelessWidget {
  const SelectionInterceptor({
    super.key,
    required this.copyInterceptor,
    required this.child,
  });

  final CopyInterceptor copyInterceptor;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return SelectionContainer(
      registrar: SelectionContainer.maybeOf(context),
      delegate: _SelectableRegionContainerDelegate(copyInterceptor: copyInterceptor),
      child: child
    );
  }
}

@Renzo-Olivares
Copy link
Contributor

@Rexios80 I agree, I think having to create a delegate right now is complicated. We can make it easier for users to create a SelectionContainerDelegate if we re-used _SelectableRegionContainerDelegate and made it public.

Then they can do the following, without an intermediate widget:

SelectionContainer(
  delegate: MySpacesCopyInterceptorSelectionContainerDelegate(),
  child: Row(
    children: <Widget>[
      const Text(
        'Text widget 1',
      ),
      const Text(
        'Text widget 2',
      ),
      const Text(
        'Text widget 3',
      ),
      const Text(
        'Text widget 4',
      ),
    ],
  ),
),
class MySpacesCopyInterceptorSelectionContainerDelegate extends _SelectableRegionContainerDelegate {
    @override
    CopyIterator get copyIterator => CopyIterator.spaces;
}

What do you think?

@Rexios80
Copy link
Member Author

I think having users create any class at all is too much work to achieve this behavior. If the SelectableRegionContainerDelegate is public couldn't they just use it directly instead of subclassing?

@Renzo-Olivares
Copy link
Contributor

I think having users create any class at all is too much work to achieve this behavior. If the SelectableRegionContainerDelegate is public couldn't they just use it directly instead of subclassing?

I think your code in this post works #146625 (comment) , but I don't think we need an intermediate widget if we are making SelectableRegionContainerDelegate public.

My other concern is similar to https://github.com/flutter/flutter/pull/146625/files#r1563001791 . For Text, exposing copyIterator may sometimes produce unexpected behaviors because a Text widgets RenderParagraph will internally split its text into multiple Selectables in the case when WidgetSpans are involved. In that case i'm not sure how useful exposing copyIterator is here.

I think the expected behavior of copyIterator on SelectionArea/SelectableRegion can also be unclear when you have a complex layout with many widgets that may be selectable. This is because a selectable may be either a single Selectable, or a SelectionContainer that may manage multiple Selectables, so it's not clear what widgets copyIterator is actually affecting when declared under a SelectionArea/SelectableRegion. This has me leaning towards keeping this functionality configurable through wrapping a desired subtree with a SelectionContainer and a configured delegate.

@Rexios80
Copy link
Member Author

If that's the case then we can just create a delegate such as this

class CopyInterceptorContainerDelegate extends _SelectableRegionContainerDelegate {
  CopyInterceptorContainerDelegate({
    required this.interceptor,
  });

  final CopyInterceptor interceptor;

  @override
  SelectedContent? getSelectedContent() {
    final List<SelectedContent> selections = <SelectedContent>[];
    for (final Selectable selectable in selectables) {
      final SelectedContent? data = selectable.getSelectedContent();
      if (data != null) {
        selections.add(data);
      }
    }
    if (selections.isEmpty) {
      return null;
    }
    return SelectedContent(plainText: interceptor.intercept(selections));
  }
}

and revert the rest of the changes in this PR. My concern with doing it this way is user discoverability of this feature. With a copyInterceptor field discoverability is easy and we can even provide default behavior.

@Renzo-Olivares
Copy link
Contributor

@Rexios80 I understand your concern about discoverability but I'm a little hesitant to add copyIterator to widgets were the behavior might be ambiguous. I think we should still keep the defaults you have defined in this PR as they are useful. The defaults for Text and Scrollable are reasonable, though I am unsure about having a default for SelectionArea/SelectableRegion other than none, what do you think?

We should also make it easier for users to create a SelectionContainerDelegate so they can easily wrap subtrees with a SelectionContainer with custom handling. If someone wanted to change the "global" copyIterator like this PR does in SelectionArea.copyIterator, one would just need to wrap the child given to SelectionArea with a SelectionContainer with their custom delegate.

I think we could always add a copyIterator field to these widgets later if we see a need for it.

@Rexios80
Copy link
Member Author

@Renzo-Olivares I agree that having the default separator as none for SelectionArea/SelectableRegion makes more sense, however I'm not sure I agree with removing the copyInterceptor constructor parameter entirely. Removing that parameter would require users to always use 2 widgets to create a SelectionArea with proper copy support unless the child widget is one of the ones we are adding copy interceptors to in this PR.

I also agree that it makes more sense to use SelectionContainers for nested interceptors instead of nesting SelectionAreas, but I'm still considering having a separate widget so it's easier to discover/use.

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

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

I have not looked into the code but I can't see a way to really apply a setting pass another selection container.

For example if you do this

SelectionContainer(
copyInterceptor: myInterceptor,
child: Column(children: ...)
);

if at some point, we decided to add a selection container as part of Column for whatever reason, the myInterceptor will not work for children of the Column. I think we need something that can penetrate through SelectionContainer.

One way I can think of is through InheritedWidget. However, I am not sure how the API will look like.

this.restorationId,
this.scrollBehavior,
this.clipBehavior = Clip.hardEdge,
this.copyInterceptor,
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not ideal, we will have to drill this down for every scrollable subclass or scrollview.

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you have any examples? Since MultiSelectableSelectionContainerDelegate forces you to implement the copyInterceptor parameter every current implementation of MultiSelectableSelectionContainerDelegate now has this parameter.

Copy link
Contributor

Choose a reason for hiding this comment

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

I meant you also have CustomScrollView, ListView, PageView, TabView, GridView...etc are all using Scrollable. if we do it this way, we have to add this copyInterceptor to all of them. and if we add more parameter, we will add to all of them as well.

I also not a fan of adding selection related API to Widgets. it will be confusing for people who do not use SelectionArea.

@Rexios80
Copy link
Member Author

Rexios80 commented May 1, 2024

I'm not sure we would need to "penetrate" SelectionContainers. If we were to add a SelectionContainer to a widget that would need a copyInterceptor, its delegate would extend the MultiSelectableSelectionContainerDelegate which forces you to implement the copyInterceptor parameter.

@chunhtai
Copy link
Contributor

chunhtai commented May 1, 2024

I'm not sure we would need to "penetrate" SelectionContainers. If we were to add a SelectionContainer to a widget that would need a copyInterceptor, its delegate would extend the MultiSelectableSelectionContainerDelegate which forces you to implement the copyInterceptor parameter.

What I meant is if we add a SelectionContainer for a inside a widget, it will override all copyinterceptor above.

My Idea is we can do it through inheritedwidget, so that customer would have full control over the copy. One Idea is to add it to DefaultSelectionStyle

@Rexios80
Copy link
Member Author

@chunhtai I made a proof of concept for using a widget for this. Please let me know what you think.

@Piinks
Copy link
Contributor

Piinks commented May 22, 2024

Hey @Rexios80 thanks for the updates. It looks like there are some failing checks, can you take a look?

@Rexios80
Copy link
Member Author

@Piinks I've already rewritten this PR 4 times. I don't want to put effort into fixing CI if we don't like the idea.

@Rexios80
Copy link
Member Author

@chunhtai do you have any thoughts?

@chunhtai chunhtai self-requested a review May 28, 2024 16:08
child: const Column(
children: <Widget>[
Text('How are you?'),
CopyInterceptor(
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if the copyInterceptor functionality should go under DefaultSelectionStyle. Then the user can wrap their subtree with DefaultSelectionStyle.merge(copyInterceptor: CopyIntercept.space) to override only the copyInterceptor.

Overall, I like this approach since it is able to propagate the copyInterceptor down a subtree. I'm just weary about adding a new widget to support this kind of functionality since it feels like this should be part of some overall selection configuration.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hi @Rexios80, wanted to follow up on this comment. Do you have any thoughts on this approach? I think the SelectionContainer would be the one inheriting the copyInterceptor from its nearest DefaultSelectionStyle.

Copy link
Member Author

Choose a reason for hiding this comment

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

How will that work for defaults on a per-widget basis? Would they usa a DefaultSelectionStyle in their build method unless there is a parent DefaultSelectionStyle?

Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand correctly, a descendant widget of a DefaultSelectionStyle would use DefaultSelectionStyle.of(context) to inherit any ancestor configuration, or use a local DefaultSelectionStyle to override any ancestor configuration.

Given SelectableRegion it should have a DefaultSelectionStyle.merge in its build method defining a default (probably newline). And then descendant widgets can override this. Let me know if this is what you were asking.

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

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

Yes I think this overall is better than having it on the selectioncontainerdelegate, but I also agree that this can be something in the DefaultSelectionStyle as well

@Piinks
Copy link
Contributor

Piinks commented Jun 20, 2024

(Triage) Hi @Rexios80 what is the status of this PR?

@Rexios80
Copy link
Member Author

@Piinks to move on I need to know what method we want to use to accomplish this. If someone could point me in the right direction I can rewrite this (hopefully) one last time.

@Rexios80
Copy link
Member Author

Going along with what @chunhtai and @Renzo-Olivares suggested, I'm probably going to rewrite this PR using DefaultSelectionStyle to hold the copyInterceptor.

I just haven't had time yet. If anyone has an objection to this approach or would like to implement it themselves please comment.

@Rexios80
Copy link
Member Author

I will make a new PR when I have a chance to make the new implementation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos f: material design flutter/packages/flutter/material repository. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Copy text in SelectionArea is not like copying text from HTML

5 participants