Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【Flutter】webview_flutterで独自WebViewerを作る

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を作成されようとしている方の参考になりましたら幸いです。

参考