webview_flutterはアプリ内で独自のWebView表示をしたい場合にまず第一候補として上がってくるライブラリかと思います。 本稿では、webview_flutterとhooks_riverpodを使用し、アプリ内に独自のWebViewerをMVVMの形で作成する方法を記載します。
下記環境を使用しています。
[✓] Flutter (Channel stable, 1.22.6, on Mac OS X 10.15.7 19H2 darwin-x64, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 30.0.0) [✓] Xcode - develop for iOS and macOS (Xcode 12.3) [✓] Android Studio (version 4.0) [✓] VS Code (version 1.53.2) webview_flutter: 1.0.7
実装
WebView
Widget側の実装です。初期読み込み用のURLはrouteのargumentsで受け取っています。
初期化処理はuseEffectsで実施しています。 また、WebViewの更新処理はviewmodelに移譲しています。
読み込み中、前の画面に戻れるかどうか、次の画面に進めるかどうかなどの状態はviewModel側で管理し、 widget側ではviewModel側の状態をviewに反映するのみとなっています。
import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'web_view_model.dart'; class WebPage extends HookWidget { @override Widget build(BuildContext context) { // 初期に読み込むurlはrouteのargumentsで受け取り final initialUrl = ModalRoute .of(context) .settings .arguments as Uri; final webViewModel = useProvider(webViewModelProvider); // titleをviewmodelから受け取り final title = useProvider(webViewModelProvider.state.select((s) => s.pageTitle)); // useEffectで初期化処理を実行 useEffect(() { // Androidは下記のようにすることが推奨されている if (Platform.isAndroid) { WebView.platform = SurfaceAndroidWebView(); } webViewModel.initState(); return; }, []); return Scaffold( appBar: AppBar( title: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, ), // AppBarに更新、戻る、進むを集約していますが、適宜好きな場所に配置してください actions: const [ _RefreshButton(), _GoBackButton(), _GoForwardButton(), ], ), body: SafeArea( // WebViewの更新処理を全てviewmodel側に流しています child: WebView( navigationDelegate: webViewModel.onNavigationDelegate, onPageStarted: webViewModel.onPageStarted, onPageFinished: webViewModel.onPageFinished, javascriptMode: JavascriptMode.disabled, gestureNavigationEnabled: true, // WebView生成時にurlを流しています onWebViewCreated: (webViewController) async => webViewModel.onWebViewCreated( webViewController: webViewController, initialUrl: initialUrl.toString()), ), ), ); } } // ボタンはそれぞれHookWidgetで別クラスとして定義しています class _RefreshButton extends HookWidget { const _RefreshButton(); @override Widget build(BuildContext context) { final isLoading = useProvider(webViewModelProvider.state.select((s) => s.isLoading)); final webViewModel = useProvider(webViewModelProvider); // 読み込み中はボタンの色を変えるとともに、押下できないようにしています。 return IconButton( icon: Icon( Icons.refresh, color: !isLoading ? Colors.white : Colors.white.withOpacity(0.4), ), onPressed: !isLoading ? webViewModel.onRefreshButtonTapped : null, ); } } class _GoForwardButton extends HookWidget { const _GoForwardButton(); @override Widget build(BuildContext context) { final canGoForward = useProvider(webViewModelProvider.state.select((s) => s.canGoForward)); final webViewModel = useProvider(webViewModelProvider); // 戻れない場合はボタンの色を変えるとともに、押下できないようにしています。 return IconButton( icon: Icon( Icons.arrow_forward, color: canGoForward ? Colors.white : Colors.white.withOpacity(0.4), ), enableFeedback: canGoForward, onPressed: canGoForward ? webViewModel.onGoForwardButtonTapped : null); } } class _GoBackButton extends HookWidget { const _GoBackButton(); @override Widget build(BuildContext context) { final canGoBack = useProvider(webViewModelProvider.state.select((s) => s.canGoBack)); final webViewModel = useProvider(webViewModelProvider); // 進めない場合はボタンの色を変えるとともに、押下できないようにしています。 return IconButton( icon: Icon( Icons.arrow_back, color: canGoBack ? Colors.white : Colors.white.withOpacity(0.4), ), enableFeedback: canGoBack, onPressed: canGoBack ? webViewModel.onGoBackButtonTapped : null); } }
ViewModel
webViewControllerを保持し、読み込み中か、ページのタイトル、前・次のページに移動できるかの状態管理を行います。 また、ページ遷移時や初期化時に行いたい処理を記述します。
webViewに必要な状態だけでなく、AccessTokenの取得や特定のURLの際に行いたい処理などweb viewerに必要な処理を本クラスで集約します。 結果としてwebView側は本クラスの影として機能します。
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'web_view_model.freezed.dart'; final webViewModelProvider = StateNotifierProvider.autoDispose((ref) => WebViewModel()); // viewmodelの状態はfreezedで定義しています。 // 状態としては、 // 読み込み中、タイトル、次のページに進めるか、前のページに戻れるかの4つです。 @freezed abstract class WebState with _$WebState { const factory WebState({ @Default(true) bool isLoading, @Default('') String pageTitle, @Default(false) bool canGoBack, @Default(false) bool canGoForward, }) = _WebState; } class WebViewModel extends StateNotifier<WebState> { WebViewModel() : super(const WebState()); Future<bool> initState() async { // 初期化処理が必要な場合はここで行うと良いかと思います。 } WebViewController _webViewController; // 特定のURLの際にアプリ側で行いたい処理がある場合はここでハンドリングします NavigationDecision onNavigationDelegate(NavigationRequest request) { return NavigationDecision.navigate; } Future<void> onWebViewCreated( {WebViewController webViewController, String initialUrl}) async { // urlの読み込みにaccess tokenなどによる認証が必要な場合はここで処理を行えばよいかと思います // final accessToken = 'access_token'; // final headers = {'Authorization': 'Bearer $accessToken'}; // await webViewController.loadUrl(initialUrl, headers: headers); _webViewController = webViewController; await webViewController.loadUrl(initialUrl); } void onPageStarted(String url) { state = state.copyWith(isLoading: true); } Future<void> onPageFinished(String url) async { final title = await _webViewController.getTitle(); final canGoBack = await _webViewController.canGoBack(); final canGoForward = await _webViewController.canGoForward(); state = state.copyWith( isLoading: false, pageTitle: title, canGoBack: canGoBack, canGoForward: canGoForward); } void onRefreshButtonTapped() { if (!state.isLoading) { _webViewController.reload(); } } void onGoBackButtonTapped() { if (state.canGoBack && !state.isLoading) { _webViewController.goBack(); } } void onGoForwardButtonTapped() { if (state.canGoForward && !state.isLoading) { _webViewController.goForward(); } } }
まとめ
Webとアプリを柔軟に行き来させたり、特定のwebの処理をアプリ側で行いたいと言った場合に独自実装したweb viewerが必要になります。 例えば、アプリの課金ページをLPとして作成し、柔軟に変更可能な状態にした上でLPの課金ボタン押下でIAP課金処理を行いたいと言ったユースケースがありえます。 本稿の内容がFlutterで独自web viewerを作成されようとしている方の参考になりましたら幸いです。