diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java index fe4482c154b0..fef2258d5dd8 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -70,6 +70,13 @@ public boolean shouldOverrideUrlLoading(WebView view, String url) { return true; } + @Override + public void onPageFinished(WebView view, String url) { + Map args = new HashMap<>(); + args.put("url", url); + methodChannel.invokeMethod("onPageFinished", args); + } + private void notifyOnNavigationRequest( String url, Map headers, WebView webview, boolean isMainFrame) { HashMap args = new HashMap<>(); diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index 7d6ce10a56ad..0ec572956df7 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -43,7 +43,7 @@ class WebViewExample extends StatelessWidget { // to allow calling Scaffold.of(context) so we can show a snackbar. body: Builder(builder: (BuildContext context) { return WebView( - initialUrl: 'https://flutter.io', + initialUrl: 'https://flutter.dev', javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController webViewController) { _controller.complete(webViewController); @@ -61,6 +61,9 @@ class WebViewExample extends StatelessWidget { print('allowing navigation to $request'); return NavigationDecision.navigate; }, + onPageFinished: (String url) { + print('Page finished loading: $url'); + }, ); }), floatingActionButton: favoriteButton(), diff --git a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m index 3638ddcda117..abcca0a5e8a9 100644 --- a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m +++ b/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m @@ -56,4 +56,7 @@ - (void)webView:(WKWebView*)webView }]; } +- (void)webView:(WKWebView*)webView didFinishNavigation:(WKNavigation*)navigation { + [_methodChannel invokeMethod:@"onPageFinished" arguments:@{@"url" : webView.URL.absoluteString}]; +} @end diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index 8833cb4f9a61..96a626b51f10 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -66,6 +66,9 @@ enum NavigationDecision { /// See also: [WebView.navigationDelegate]. typedef NavigationDecision NavigationDelegate(NavigationRequest navigation); +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9]*\$'); /// A named channel for receiving messaged from JavaScript code running inside a web view. @@ -113,6 +116,7 @@ class WebView extends StatefulWidget { this.javascriptChannels, this.navigationDelegate, this.gestureRecognizers, + this.onPageFinished, }) : assert(javascriptMode != null), super(key: key); @@ -189,6 +193,18 @@ class WebView extends StatefulWidget { /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. final NavigationDelegate navigationDelegate; + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any Javascript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback onPageFinished; + @override State createState() => _WebViewState(); } @@ -264,6 +280,7 @@ class _WebViewState extends State { _WebSettings.fromWidget(widget), widget.javascriptChannels, widget.navigationDelegate, + widget.onPageFinished, ); _controller.complete(controller); if (widget.onWebViewCreated != null) { @@ -363,6 +380,7 @@ class WebViewController { this._settings, Set javascriptChannels, this._navigationDelegate, + this._onPageFinished, ) : _channel = MethodChannel('plugins.flutter.io/webview_$id') { _updateJavascriptChannelsFromSet(javascriptChannels); _channel.setMethodCallHandler(_onMethodCall); @@ -374,6 +392,8 @@ class WebViewController { _WebSettings _settings; + final PageFinishedCallback _onPageFinished; + // Maps a channel name to a channel. Map _javascriptChannels = {}; @@ -391,13 +411,18 @@ class WebViewController { url: call.arguments['url'], isForMainFrame: call.arguments['isForMainFrame'], ); - // _navigationDelegate can be null if the widget was rebuilt with no // navigation delegate after a navigation happened and just before we // got the navigationRequest message. final bool allowNavigation = _navigationDelegate == null || _navigationDelegate(request) == NavigationDecision.navigate; return allowNavigation; + case 'onPageFinished': + if (_onPageFinished != null) { + _onPageFinished(call.arguments['url']); + } + + return null; } throw MissingPluginException( '${call.method} was invoked but has no handler'); @@ -560,6 +585,10 @@ class WebViewController { /// /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the /// evaluated expression is not supported as described above. + /// + /// When evaluating Javascript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the Javascript + /// embedded in the main frame HTML has been loaded. Future evaluateJavascript(String javascriptString) async { if (_settings.javascriptMode == JavascriptMode.disabled) { throw FlutterError( diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart index 8635875ab7a6..b1e45d557b36 100644 --- a/packages/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/test/webview_flutter_test.dart @@ -576,6 +576,40 @@ void main() { expect(ttsMessagesReceived, ['Hello', 'World']); }); + group('$PageFinishedCallback', () { + testWidgets('onPageFinished is not null', (WidgetTester tester) async { + String returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageFinished: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageFinishedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + + testWidgets('onPageFinished is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onPageFinished: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + // The platform side will always invoke a call for onPageFinished. This is + // to test that it does not crash on a null callback. + platformWebView.fakeOnPageFinishedCallback(); + }); + }); + group('navigationDelegate', () { testWidgets('hasNavigationDelegate', (WidgetTester tester) async { await tester.pumpWidget(const WebView( @@ -750,6 +784,21 @@ class FakePlatformWebView { }); } + void fakeOnPageFinishedCallback() { + final StandardMethodCodec codec = const StandardMethodCodec(); + + final ByteData data = codec.encodeMethodCall(MethodCall( + 'onPageFinished', + {'url': currentUrl}, + )); + + BinaryMessages.handlePlatformMessage( + channel.name, + data, + (ByteData data) {}, + ); + } + void _loadUrl(String url) { history = history.sublist(0, currentPosition + 1); history.add(url);