[web] Implement Link for web (#3155)
diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md
index 456d458..e7abd13 100644
--- a/packages/url_launcher/url_launcher_web/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md
@@ -1,3 +1,7 @@
+# 0.1.5
+
+- Added the web implementation of the Link widget.
+
# 0.1.4+2
- Move `lib/third_party` to `lib/src/third_party`.
diff --git a/packages/url_launcher/url_launcher_web/analysis_options.yaml b/packages/url_launcher/url_launcher_web/analysis_options.yaml
new file mode 100644
index 0000000..443b165
--- /dev/null
+++ b/packages/url_launcher/url_launcher_web/analysis_options.yaml
@@ -0,0 +1,10 @@
+# This is a temporary file to allow us to unblock the flutter/plugins repo CI.
+# It disables some of lints that were disabled inline. Disabling lints inline
+# is no longer possible, so this file is required.
+# TODO(ditman) https://github.com/flutter/flutter/issues/55000 (clean this up)
+
+include: ../../../analysis_options.yaml
+
+analyzer:
+ errors:
+ undefined_prefixed_name: ignore
diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart
new file mode 100644
index 0000000..e8a6d68
--- /dev/null
+++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart
@@ -0,0 +1,295 @@
+// Copyright 2017 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:html' as html;
+import 'dart:js_util';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+
+import 'package:url_launcher_platform_interface/link.dart';
+
+/// The unique identifier for the view type to be used for link platform views.
+const String linkViewType = '__url_launcher::link';
+
+/// The name of the property used to set the viewId on the DOM element.
+const String linkViewIdProperty = '__url_launcher::link::viewId';
+
+/// Signature for a function that takes a unique [id] and creates an HTML element.
+typedef HtmlViewFactory = html.Element Function(int viewId);
+
+/// Factory that returns the link DOM element for each unique view id.
+HtmlViewFactory get linkViewFactory => LinkViewController._viewFactory;
+
+/// The delegate for building the [Link] widget on the web.
+///
+/// It uses a platform view to render an anchor element in the DOM.
+class WebLinkDelegate extends StatefulWidget {
+ /// Creates a delegate for the given [link].
+ const WebLinkDelegate(this.link);
+
+ /// Information about the link built by the app.
+ final LinkInfo link;
+
+ @override
+ WebLinkDelegateState createState() => WebLinkDelegateState();
+}
+
+/// The link delegate used on the web platform.
+///
+/// For external URIs, it lets the browser do its thing. For app route names, it
+/// pushes the route name to the framework.
+class WebLinkDelegateState extends State<WebLinkDelegate> {
+ LinkViewController _controller;
+
+ @override
+ void didUpdateWidget(WebLinkDelegate oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.link.uri != oldWidget.link.uri) {
+ _controller?.setUri(widget.link.uri);
+ }
+ if (widget.link.target != oldWidget.link.target) {
+ _controller?.setTarget(widget.link.target);
+ }
+ }
+
+ Future<void> _followLink() {
+ LinkViewController.registerHitTest(_controller);
+ return Future<void>.value();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: <Widget>[
+ widget.link.builder(
+ context,
+ widget.link.isDisabled ? null : _followLink,
+ ),
+ Positioned.fill(
+ child: PlatformViewLink(
+ viewType: linkViewType,
+ onCreatePlatformView: (PlatformViewCreationParams params) {
+ _controller = LinkViewController.fromParams(params, context);
+ return _controller
+ ..setUri(widget.link.uri)
+ ..setTarget(widget.link.target);
+ },
+ surfaceFactory:
+ (BuildContext context, PlatformViewController controller) {
+ return PlatformViewSurface(
+ controller: controller,
+ gestureRecognizers:
+ Set<Factory<OneSequenceGestureRecognizer>>(),
+ hitTestBehavior: PlatformViewHitTestBehavior.transparent,
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// Controls link views.
+class LinkViewController extends PlatformViewController {
+ /// Creates a [LinkViewController] instance with the unique [viewId].
+ LinkViewController(this.viewId, this.context) {
+ if (_instances.isEmpty) {
+ // This is the first controller being created, attach the global click
+ // listener.
+ _clickSubscription = html.window.onClick.listen(_onGlobalClick);
+ }
+ _instances[viewId] = this;
+ }
+
+ /// Creates and initializes a [LinkViewController] instance with the given
+ /// platform view [params].
+ factory LinkViewController.fromParams(
+ PlatformViewCreationParams params,
+ BuildContext context,
+ ) {
+ final int viewId = params.id;
+ final LinkViewController controller = LinkViewController(viewId, context);
+ controller._initialize().then((_) {
+ params.onPlatformViewCreated(viewId);
+ });
+ return controller;
+ }
+
+ static Map<int, LinkViewController> _instances = <int, LinkViewController>{};
+
+ static html.Element _viewFactory(int viewId) {
+ return _instances[viewId]?._element;
+ }
+
+ static int _hitTestedViewId;
+
+ static StreamSubscription _clickSubscription;
+
+ static void _onGlobalClick(html.MouseEvent event) {
+ final int viewId = getViewIdFromTarget(event);
+ _instances[viewId]?._onDomClick(event);
+ // After the DOM click event has been received, clean up the hit test state
+ // so we can start fresh on the next click.
+ unregisterHitTest();
+ }
+
+ /// Call this method to indicate that a hit test has been registered for the
+ /// given [controller].
+ ///
+ /// The [onClick] callback is invoked when the anchor element receives a
+ /// `click` from the browser.
+ static void registerHitTest(LinkViewController controller) {
+ _hitTestedViewId = controller.viewId;
+ }
+
+ /// Removes all information about previously registered hit tests.
+ static void unregisterHitTest() {
+ _hitTestedViewId = null;
+ }
+
+ @override
+ final int viewId;
+
+ /// The context of the [Link] widget that created this controller.
+ final BuildContext context;
+
+ html.Element _element;
+ bool get _isInitialized => _element != null;
+
+ Future<void> _initialize() async {
+ _element = html.Element.tag('a');
+ setProperty(_element, linkViewIdProperty, viewId);
+ _element.style
+ ..opacity = '0'
+ ..display = 'block'
+ ..cursor = 'unset';
+
+ // This is recommended on MDN:
+ // - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target
+ _element.setAttribute('rel', 'noreferrer noopener');
+
+ final Map<String, dynamic> args = <String, dynamic>{
+ 'id': viewId,
+ 'viewType': linkViewType,
+ };
+ await SystemChannels.platform_views.invokeMethod<void>('create', args);
+ }
+
+ void _onDomClick(html.MouseEvent event) {
+ final bool isHitTested = _hitTestedViewId == viewId;
+ if (!isHitTested) {
+ // There was no hit test registered for this click. This means the click
+ // landed on the anchor element but not on the underlying widget. In this
+ // case, we prevent the browser from following the click.
+ event.preventDefault();
+ return;
+ }
+
+ if (_uri.hasScheme) {
+ // External links will be handled by the browser, so we don't have to do
+ // anything.
+ return;
+ }
+
+ // A uri that doesn't have a scheme is an internal route name. In this
+ // case, we push it via Flutter's navigation system instead of letting the
+ // browser handle it.
+ event.preventDefault();
+ final String routeName = _uri.toString();
+ pushRouteNameToFramework(context, routeName);
+ }
+
+ Uri _uri;
+
+ /// Set the [Uri] value for this link.
+ void setUri(Uri uri) {
+ assert(_isInitialized);
+ _uri = uri;
+ if (uri == null) {
+ _element.removeAttribute('href');
+ } else {
+ _element.setAttribute('href', uri.toString());
+ }
+ }
+
+ /// Set the [LinkTarget] value for this link.
+ void setTarget(LinkTarget target) {
+ assert(_isInitialized);
+ _element.setAttribute('target', _getHtmlTarget(target));
+ }
+
+ String _getHtmlTarget(LinkTarget target) {
+ switch (target) {
+ case LinkTarget.defaultTarget:
+ case LinkTarget.self:
+ return '_self';
+ case LinkTarget.blank:
+ return '_blank';
+ default:
+ throw Exception('Unknown LinkTarget value $target.');
+ }
+ }
+
+ @override
+ Future<void> clearFocus() async {
+ // Currently this does nothing on Flutter Web.
+ // TODO(het): Implement this. See https://github.com/flutter/flutter/issues/39496
+ }
+
+ @override
+ Future<void> dispatchPointerEvent(PointerEvent event) async {
+ // We do not dispatch pointer events to HTML views because they may contain
+ // cross-origin iframes, which only accept user-generated events.
+ }
+
+ @override
+ Future<void> dispose() async {
+ if (_isInitialized) {
+ assert(_instances[viewId] == this);
+ _instances.remove(viewId);
+ if (_instances.isEmpty) {
+ await _clickSubscription.cancel();
+ }
+ await SystemChannels.platform_views.invokeMethod<void>('dispose', viewId);
+ }
+ }
+}
+
+/// Finds the view id of the DOM element targeted by the [event].
+int getViewIdFromTarget(html.Event event) {
+ final html.Element linkElement = getLinkElementFromTarget(event);
+ if (linkElement != null) {
+ return getProperty(linkElement, linkViewIdProperty);
+ }
+ return null;
+}
+
+/// Finds the targeted DOM element by the [event].
+///
+/// It handles the case where the target element is inside a shadow DOM too.
+html.Element getLinkElementFromTarget(html.Event event) {
+ final html.Element target = event.target;
+ if (isLinkElement(target)) {
+ return target;
+ }
+ if (target.shadowRoot != null) {
+ final html.Element child = target.shadowRoot.lastChild;
+ if (isLinkElement(child)) {
+ return child;
+ }
+ }
+ return null;
+}
+
+/// Checks if the given [element] is a link that was created by
+/// [LinkViewController].
+bool isLinkElement(html.Element element) {
+ return element.tagName == 'A' && hasProperty(element, linkViewIdProperty);
+}
diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart
index 093e06a..e7367b3 100644
--- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart
+++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart
@@ -4,11 +4,15 @@
import 'dart:async';
import 'dart:html' as html;
+// ignore: undefined_shown_name
+import 'dart:ui' as ui show platformViewRegistry;
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:meta/meta.dart';
+import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
+import 'src/link.dart';
import 'src/third_party/platform_detect/browser.dart';
const _safariTargetTopSchemes = {
@@ -43,6 +47,12 @@
/// Registers this class as the default instance of [UrlLauncherPlatform].
static void registerWith(Registrar registrar) {
UrlLauncherPlatform.instance = UrlLauncherPlugin();
+ ui.platformViewRegistry.registerViewFactory(linkViewType, linkViewFactory);
+ }
+
+ @override
+ LinkDelegate get linkDelegate {
+ return (LinkInfo linkInfo) => WebLinkDelegate(linkInfo);
}
/// Opens the given [url] in the specified [webOnlyWindowName].
diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml
index 957b257..7ae84cd 100644
--- a/packages/url_launcher/url_launcher_web/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_web/pubspec.yaml
@@ -4,7 +4,7 @@
# 0.1.y+z is compatible with 1.0.0, if you land a breaking change bump
# the version to 2.0.0.
# See more details: https://github.com/flutter/flutter/wiki/Package-migration-to-1.0.0
-version: 0.1.4+2
+version: 0.1.5
flutter:
plugin:
@@ -14,7 +14,7 @@
fileName: url_launcher_web.dart
dependencies:
- url_launcher_platform_interface: ^1.0.8
+ url_launcher_platform_interface: ^1.0.9
flutter:
sdk: flutter
flutter_web_plugins:
diff --git a/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart b/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart
index d0dd6e3..4d10344 100644
--- a/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart
+++ b/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart
@@ -3,8 +3,12 @@
// found in the LICENSE file.
import 'dart:html' as html;
+import 'dart:js_util';
+import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_web/url_launcher_web.dart';
+import 'package:url_launcher_web/src/link.dart';
import 'package:mockito/mockito.dart';
import 'package:integration_test/integration_test.dart';
@@ -228,4 +232,88 @@
});
});
});
+
+ group('link', () {
+ testWidgets('creates anchor with correct attributes',
+ (WidgetTester tester) async {
+ final Uri uri = Uri.parse('http://foobar/example?q=1');
+ await tester.pumpWidget(Directionality(
+ textDirection: TextDirection.ltr,
+ child: WebLinkDelegate(TestLinkInfo(
+ uri: uri,
+ target: LinkTarget.blank,
+ builder: (BuildContext context, FollowLink followLink) {
+ return Container(width: 100, height: 100);
+ },
+ )),
+ ));
+ // Platform view creation happens asynchronously.
+ await tester.pumpAndSettle();
+
+ final html.Element anchor = _findSingleAnchor();
+ expect(anchor.getAttribute('href'), uri.toString());
+ expect(anchor.getAttribute('target'), '_blank');
+
+ final Uri uri2 = Uri.parse('http://foobar2/example?q=2');
+ await tester.pumpWidget(Directionality(
+ textDirection: TextDirection.ltr,
+ child: WebLinkDelegate(TestLinkInfo(
+ uri: uri2,
+ target: LinkTarget.self,
+ builder: (BuildContext context, FollowLink followLink) {
+ return Container(width: 100, height: 100);
+ },
+ )),
+ ));
+ await tester.pumpAndSettle();
+
+ // Check that the same anchor has been updated.
+ expect(anchor.getAttribute('href'), uri2.toString());
+ expect(anchor.getAttribute('target'), '_self');
+ });
+ });
+}
+
+html.Element _findSingleAnchor() {
+ final List<html.Element> foundAnchors = <html.Element>[];
+ for (final html.Element anchor in html.document.querySelectorAll('a')) {
+ if (hasProperty(anchor, linkViewIdProperty)) {
+ foundAnchors.add(anchor);
+ }
+ }
+
+ // Search inside platform views with shadow roots as well.
+ for (final html.Element platformView
+ in html.document.querySelectorAll('flt-platform-view')) {
+ final html.ShadowRoot shadowRoot = platformView.shadowRoot;
+ if (shadowRoot != null) {
+ for (final html.Element anchor in shadowRoot.querySelectorAll('a')) {
+ if (hasProperty(anchor, linkViewIdProperty)) {
+ foundAnchors.add(anchor);
+ }
+ }
+ }
+ }
+
+ return foundAnchors.single;
+}
+
+class TestLinkInfo extends LinkInfo {
+ @override
+ final LinkWidgetBuilder builder;
+
+ @override
+ final Uri uri;
+
+ @override
+ final LinkTarget target;
+
+ @override
+ bool get isDisabled => uri == null;
+
+ TestLinkInfo({
+ @required this.uri,
+ @required this.target,
+ @required this.builder,
+ });
}
diff --git a/script/incremental_build.sh b/script/incremental_build.sh
index 30c166b..8e9cf34 100755
--- a/script/incremental_build.sh
+++ b/script/incremental_build.sh
@@ -24,6 +24,7 @@
"camera"
"video_player/video_player_web"
"google_maps_flutter/google_maps_flutter_web"
+ "url_launcher/url_launcher_web"
)
# Comma-separated string of the list above
readonly CUSTOM_FLAG=$(IFS=, ; echo "${CUSTOM_ANALYSIS_PLUGINS[*]}")