Skip to content

Conversation

@justinmc
Copy link
Contributor

@justinmc justinmc commented May 3, 2023

On Android, you can use Linkify to turn URLs in some text into tappable links. Swift can also do this with its link property on NSAttributedString.

This PR attempts to bring this functionality to Flutter.

API

In order to avoid an explicitly dependency on url_launcher, the onTap handler is left up to the user. Otherwise, the API aims to handle the case of URLs by default as easily as possible, while also being as flexible as possible in non-default cases.

Urls (default)

LinkedText(
  text: 'Check out flutter.dev.',
  onTap: (String urlString) {
    Uri uri = Uri.parse(urlString);
    if (uri.host.isEmpty) {
      uri = Uri.parse('https://$uriString');
    }
    if (!await launchUrl(uri)) {
      throw 'Could not launch $urlString.';
    }
  },
)

Screenshot from 2023-05-03 17-16-36

Twitter handles

LinkedText(
  text: 'Check out @FlutterDev on Twitter for the latest.',
  regExp: RegExp(r'@[a-zA-Z0-9]{4,15}'),
  onTap: (String urlString) {
    final String handleWithoutAt = linkText.substring(1);
    Uri uri = Uri.parse('https://www.twitter.com/$handleWithoutAt');
    if (!await launchUrl(uri)) {
      throw 'Could not launch $uri.';
    }
  },
)

Screenshot from 2023-05-03 17-10-26

Both Urls and Twitter handles at the same time

LinkedText.textLinkers(
  text: text,
  textLinkers: <TextLinker>[
    TextLinker(
      rangesFinder: TextLinker.urlRangesFinder,
      linkBuilder: InlineLinkedText.getDefaultLinkBuilder(_onTapUrl),
    ),
    TextLinker(
      rangesFinder: TextLinker.rangesFinderFromRegExp(RegExp(r'@[a-zA-Z0-9]{4,15}')),
      linkBuilder: (String linkText) {
        return InlineLink(
          text: linkText,
          style: const TextStyle(
            color: Color(0xff00aaaa),
          ),
          onTap: _onTapTwitterHandle,
        );
      },
    ),
  ],
),

Screenshot from 2023-05-03 17-03-16

TextSpan trees

LinkedText.spans(
  onTap: _onTapUrl,
  children: <InlineSpan>[
    TextSpan(
      text: 'Check out fl',
      style: DefaultTextStyle.of(context).style,
      children: const <InlineSpan>[
        TextSpan(
          text: 'u',
          children: <InlineSpan>[
            TextSpan(
              style: TextStyle(
                fontWeight: FontWeight.w800,
              ),
              text: 'tt',
            ),
            TextSpan(
              text: 'er',
            ),
          ],
        ),
      ],
    ),
    const TextSpan(
      text: '.dev.',
    ),
  ],
),

Screenshot from 2023-05-24 10-14-12

Reference

Fixes #40505

@justinmc justinmc self-assigned this May 3, 2023
@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label May 3, 2023
@guidezpl
Copy link
Member

guidezpl commented May 3, 2023

Nice. I wonder if TextLink would be a better name, and more discoverable.

@justinmc
Copy link
Contributor Author

justinmc commented May 3, 2023

@guidezpl Thank you, I was hoping a better name would come to me! I'll change it to TextLink.

@flutter-dashboard flutter-dashboard bot added 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 documentation c: contributor-productivity Team-specific productivity, code health, technical debt. labels May 4, 2023
@justinmc
Copy link
Contributor Author

justinmc commented May 4, 2023

@guidezpl I went back and forth but I've currently got it named LinkedText. There is an inline version called InlineLinkedText. I'm still up for suggestions and might still change things around, though.

final RangesFinder rangesFinder;

// TODO(justinmc): Consider revising this regexp.
static final RegExp _urlRegExp = RegExp(r'((http|https|ftp):\/\/)?([a-zA-Z\-]*\.)?[a-zA-Z0-9\-]*\.[a-zA-Z]*');
Copy link

@Reprevise Reprevise May 4, 2023

Choose a reason for hiding this comment

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

Here's a better RegEx to use as this allows users to have port numbers, paths, query params, and fragments in the URL.

Here are some URLs that match my updated RegEx:

http://example.com/
https://www.example.org/
ftp://subdomain.example.net
example.com
subdomain.example.io
www.example123.co.uk
http://example.com:8080/
https://www.example.com/path/to/resource
http://www.example.com/index.php?query=test#fragment
https://subdomain.example.io:8443/resource/file.html?search=query#result

And the RegEx itself:

((https?|ftp):\/\/)?(([a-zA-Z\-]*\.)?[a-zA-Z0-9\-]+)\.[a-zA-Z]+(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?

Copy link

Choose a reason for hiding this comment

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

@Reprevise "example." return true with your Regex

Copy link

Choose a reason for hiding this comment

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

example.

Copy link

Choose a reason for hiding this comment

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

@justinmc Your regex also fails in this case

Choose a reason for hiding this comment

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

regex has been updated

Choose a reason for hiding this comment

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

Fine by me, though it does look weird seeing the ftp:// not highlighted but that will be the case for other protocols too so 🤷🏻

Copy link
Contributor Author

@justinmc justinmc May 9, 2023

Choose a reason for hiding this comment

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

Here's my attempt at fixing the "ftp://" thing:

(((https?:\/\/)(([a-zA-Z0-9\-]*\.)*[a-zA-Z0-9\-]+(\.[a-zA-Z]+)+))|((?<!\/|\.|[a-zA-Z0-9\-])((([a-zA-Z0-9\-]*\.)*[a-zA-Z0-9\-]+(\.[a-zA-Z]+)+))))(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?

Now any URL with a scheme that isn't http or https won't be highlighted at all:

Screenshot from 2023-05-09 10-09-11

I had to duplicate part of the regex, so if anyone knows a better way to express that let me know.

My list of test URLs
'www.example123.co.uk\nasdf://subdomain.example.net\nsubdomain.example.net\nftp.subdomain.example.net\nhttp://subdomain.example.net\nhttps://subdomain.example.net\nhttp://example.com/\nhttps://www.example.org/\nftp://subdomain.example.net\nexample.com\nsubdomain.example.io\nwww.example123.co.uk\nhttp://example.com:8080/\nhttps://www.example.com/path/to/resource\nhttp://www.example.com/index.php?query=test#fragment\nhttps://subdomain.example.io:8443/resource/file.html?search=query#result\n"example.com"\n\'example.com\'\n(example.com)\nsubsub.www.example.com\nhttps://subsub.www.example.com'

Choose a reason for hiding this comment

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

Hey @justinmc, here's a simpler regex that maintains the functionality:

(?<![\/\.a-zA-Z0-9-])((https?:\/\/)?(([a-zA-Z0-9-]*\.)*[a-zA-Z0-9-]+(\.[a-zA-Z]+)+))(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?

It also matches your list exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you! Just added it and it works perfectly. Sorry I've been working on supporting TextSpans so I missed this for a while.

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 tweaked the regex again. Now it explicitly ignores email addresses.

Screenshot from 2023-05-31 15-06-07

My list of test URLs
'www.example123.co.uk\nasdf://subdomain.example.net\nsubdomain.example.net\nftp.subdomain.example.net\nhttp://subdomain.example.net\nhttps://subdomain.example.net\nhttp://example.com/\nhttps://www.example.org/\nftp://subdomain.example.net\nexample.com\nsubdomain.example.io\nwww.example123.co.uk\nhttp://example.com:8080/\nhttps://www.example.com/path/to/resource\nhttp://www.example.com/index.php?query=test#fragment\nhttps://subdomain.example.io:8443/resource/file.html?search=query#result\n"example.com"\n\'example.com\'\n(example.com)\nsubsub.www.example.com\nhttps://subsub.www.example.com\n[email protected]\n[email protected]'

@guidezpl
Copy link
Member

guidezpl commented May 4, 2023

@guidezpl I went back and forth but I've currently got it named LinkedText. There is an inline version called InlineLinkedText. I'm still up for suggestions and might still change things around, though.

Ah I see now how this works on the entire text, that makes sense.

/// the given [RegExp].
InlineLinkedText.textLinkers({
super.style,
required String text,
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 also support textspan as input?

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 think you're right and it should. People are going to have existing span trees in which they want to highlight links. I'm going to think about that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of just textit's now possible to passspans` instead 👍 .

@justinmc justinmc requested a review from chunhtai September 8, 2023 16:38
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.

Just some concerns around text linker API, the other part LGTM

/// [InlineLinkBuilder], so it's the responsibility of the caller to do so.
/// See [TextSpan.recognizer] for more.
TextLinker({
required this.textRangesFinder,
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 imagining looking up matches in a large Map or some data structure

Even so the regex should be enough?

Is supporting linking without a regex part of the use cases we want to support, it seems to me at that point what we are doing is helping people parse textspan into a smaller pieces given a text range. but the return data (displayString and linkString) does not seem useful....

@justinmc justinmc requested a review from chunhtai September 11, 2023 17:10
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.

LGTM

@justinmc
Copy link
Contributor Author

I don't think anything actually failed in the Google tests, but I've rerun them just in case.

@justinmc justinmc merged commit 4db47db into flutter:master Sep 14, 2023
@justinmc justinmc deleted the linkify branch September 14, 2023 03:40
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Sep 14, 2023
auto-submit bot pushed a commit to flutter/packages that referenced this pull request Sep 14, 2023
flutter/flutter@61b890b...58ba6c2

2023-09-14 [email protected] Roll Packages from 06cd9e9 to 275b76c (1 revision) (flutter/flutter#134734)
2023-09-14 [email protected] Update plugin_ffi generated file to match FFIgen 9.0.0 (flutter/flutter#134614)
2023-09-14 [email protected] LinkedText (Linkify) (flutter/flutter#125927)
2023-09-14 [email protected] _DayPicker should build days using separate stetefull widget _Day. (flutter/flutter#134607)
2023-09-13 [email protected] Remove `Path.combine` call from `CupertionoTextSelectionToolbar` (flutter/flutter#134369)
2023-09-13 [email protected] Update KeepAlive.debugTypicalAncestorWidgetClass (flutter/flutter#133498)
2023-09-13 [email protected] Fix null check crash by ReorderableList (flutter/flutter#132153)
2023-09-13 [email protected] Roll Flutter Engine from 154d6fd601a3 to cd90cc8469fb (3 revisions) (flutter/flutter#134691)
2023-09-13 49699333+dependabot[bot]@users.noreply.github.com Bump github/codeql-action from 2.21.5 to 2.21.6 (flutter/flutter#134692)
2023-09-13 [email protected] Roll Flutter Engine from b71b366e3de3 to 154d6fd601a3 (6 revisions) (flutter/flutter#134683)
2023-09-13 [email protected] [flutter_tools] Run ShutdownHooks when handling signals (flutter/flutter#134590)
2023-09-13 [email protected] Dispose routes in navigator when throwing exception (flutter/flutter#134596)
2023-09-13 [email protected] [framework] reduce ink sparkle uniform count. (flutter/flutter#133897)
2023-09-13 [email protected] Roll Flutter Engine from 5e671d5c90f9 to b71b366e3de3 (4 revisions) (flutter/flutter#134676)
2023-09-13 [email protected] Set the CONFIGURATION_BUILD_DIR in generated xcconfig when debugging core device (flutter/flutter#134493)
2023-09-13 [email protected] Bump gradle heap size limit in *everywhere* (flutter/flutter#134665)

If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC [email protected],[email protected],[email protected] on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
@Hixie
Copy link
Contributor

Hixie commented Sep 15, 2023

I'm concerned that this PR makes it easier to make links but does not make them work on web, where links are most common and where not matching the platform is the most disruptive and where we have a solution for making links that have high fidelity with the core platform.

Would it make sense to make this a package instead? It does seem like functionality that does not need to be in the core framework, and our general policy is that we should put things in packages if possible. In particular, would it make sense to put this in url_launcher?

@justinmc
Copy link
Contributor Author

This works on web just like any other platform, unless I'm missing something. Is the concern with the need to use url_launcher?

output

Using LinkedText like this:

import 'package:url_launcher/url_launcher.dart';

...

LinkedText(
  text: 'example.com',
  onTapUri: (Uri uri) async {
    if (!await launchUrl(uri)) {
      throw 'Could not launch $uri';
    }
  },
),

What is the existing high fidelity solution? I assumed that people were using things like TextSpan.recognizer + url_launcher (see StackOverflow). Using TextSpan.recognizer is tricky due to needing to manually manage a TapGestureRecognizer.

My concern with putting this inside of url_launcher is that devs might want to do something other than launch a URL when tapped. Maybe do something in-app when a user taps on a username or something like that? I'm just guessing though.

I would definitely be down to move this to its own package if you think that's best.

XilaiZhang added a commit that referenced this pull request Sep 18, 2023
auto-submit bot pushed a commit that referenced this pull request Sep 18, 2023
Reverts #125927

context: b/300804374

Looks like a g3 fix might involve changing the names of widget on the customer app, and I am not sure if that would be the right approach forward. Putting up a revert to be safe for now.
@Hixie
Copy link
Contributor

Hixie commented Sep 18, 2023

if i right click on the link, do i get the browser's menu?

@justinmc
Copy link
Contributor Author

Yes, but no link options like "Open link in new tab". Do you know if we have a way to do that on the web now? Maybe this is a good opportunity to automatically wire that up for people if it's possible.

@Hixie
Copy link
Contributor

Hixie commented Sep 19, 2023

I believe the Link widget in url_opener does that. cc @yjbanov

Having it in url_opener wouldn't require that the developer use it to open a URL, it could still allow a callback. The idea would just be to default to the "right thing" for links.

@LongCatIsLooong
Copy link
Contributor

(It looks like there's an ongoing discussion here and on discord. Please let me know when the PR is ready to reland and I'll get the g3fix CL ready).

@justinmc
Copy link
Contributor Author

Ah I see (Link docs). LinkedText does seem to work with Link on web and the other platforms (example below), but I would have to rethink this as part of url_launcher to make that super slick by default.

LinkedText + Link example
LinkedText.textLinkers(
  text: 'Hello https://www.example.com world.', // spans seem to work too.
  textLinkers: <TextLinker>[
    TextLinker(
      regExp: LinkedText.defaultUriRegExp,
      linkBuilder: (String displayString, String linkString) {
        return WidgetSpan(
          child: Link(
            uri: Uri.parse(linkString),
            builder: (BuildContext context, FollowLink? followLink) {
              return Text.rich(
                TextSpan(
                  style: const TextStyle(color: Colors.blue),
                  text: displayString,
                  // Omitting lifecycle management for this recognizer for brevity.
                  recognizer: TapGestureRecognizer()..onTap = followLink,
                ),
              );
            },
          ),
        );
      },
    ),
  ],
),

If that's the best option here then I'll probably have to revisit this in January when I return from leave.

Mairramer pushed a commit to Mairramer/flutter that referenced this pull request Oct 10, 2023
New LinkedText widget and TextLinker class for easily adding hyperlinks to text.
Mairramer pushed a commit to Mairramer/flutter that referenced this pull request Oct 10, 2023
Reverts flutter#125927

context: b/300804374

Looks like a g3 fix might involve changing the names of widget on the customer app, and I am not sure if that would be the right approach forward. Putting up a revert to be safe for now.
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Nov 15, 2023
@benthillerkus
Copy link
Contributor

Maybe it can just be part of material?
That way it's inside of a package, there's some justification for automatic styling and users won't have to go through the trouble of finding and installing the correct package on pub.

@justinmc
Copy link
Contributor Author

@benthillerkus I'm convinced that LinkedText needs to use url_launcher's Link class internally in order to be successful, in which case I can't put it in the framework without also bringing in url_launcher. So my plan is to land it inside of url_launcher (flutter/packages#5862). The reason I need url_launcher is that it's hard to build a good link on Flutter web without using url_launcher's Link (my attempt in this PR did not support right clicking => open in new tab).

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 c: contributor-productivity Team-specific productivity, code health, technical debt. customer: chalk (g3) d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] Auto hyperlink links in a Text widget

9 participants