[Flutter] Platform Viewsを利用した簡易WebViewプラグインの作り方

# はじめに

Flutterの`PlatformView`機能を利用したView系のプラグインの作り方について解説します。
解説用の題材として、`TextView`と`WebView`を用いました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/d330fd00-a31f-6575-d24c-4a49a9dfdaf8.png" width="240">

## Platform Viewsとは？

通常、FlutterはFlutterのフレームワークで用意されているウィジェットしか利用することが出来ません (ウィジェット一覧は[Widget catalog](https://flutter.dev/docs/development/ui/widgets)) が、PlatformView機能を利用するとプラットフォーム (Android/iOS) 固有のViewをFlutterのウィジェットとして利用することが出来ます。

Flutterフレームワークは、この`PlatformView`機能をAndroid, iOS向けに使い易いAPIにして機能を提供してくれています。それがそれぞれ`AndroidView`と`UiKitView`です。基本的に開発者はこの機能を利用します。ソースコードは[ここ](https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/platform_view.dart)です。

ただし、表示を行うだけであれば上記を利用すればOKですが、Platform側とやりとりを行うには独自で通信部分を作成する必要があり、その時にメインで利用するのが`MethodChannel`です。`MethodChannel`については[Flutter MethodChannel APIの使い方](https://qiita.com/kurun_pan/items/db6c8fa94bbfb5c0c8d7)を併せて参照してください。

### Plaftorm Viewsの仕組み

OpenGLやVulkan等のグラフィックスの知識がある方は理解し易いかもしれませんが、プラットフォーム側で仮想画面 (サーフェス) に描画し、その描画データをFlutter側で取得し、Flutter側のレンダリングによりFlutter側の画面と合成し、最終的な画面を生成しています。

以下、Android版のPlatformViewのイメージ図です。
<img width="722" alt="スクリーンショット 2020-02-15 14.31.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/6fa02a65-d17d-f822-47c0-90a9f2e44801.png">

iOS側は実装を追いかけていませんが、[Flutterの最後の秘宝、Platform Viewの実装を調べに行く](https://qiita.com/kikuchy/items/397b7664ad32656748bf)が参考になると思います。おそらくiOSでもAndroidと同じようなことをやっていると思います。

## 基本的なプラグインの作成の流れ (View系の話に限定)

 1. 【プラットフォーム側】 PlatformViewを継承したプラグイン用のViewを作成
 2. 【プラットフォーム側】 PlatformViewFactoryを継承したクラスで上記のViewを生成
 3. 【プラットフォーム側】 FlutterPluginを継承したプラグイン登録クラス内でChannel登録
 4. 【プラットフォーム側】 MainActivityで上記プラグインクラスを登録
 5. 【Flutter側】 プラグイン対象Viewの独自ウィジェットを作成し、その中でAndroidViewウィジェットを生成
 6. 【Flutter側】 必要があれば、上記の独自ウィジェットの中でMethodChannelを利用して相互呼び出し
 7. 【Flutter側】 上記の独自ウィジェットを画面のレイアウトに組み込む

Flutter Framework内の実装的な部分では、[System Channelsのplatform_view](https://qiita.com/kurun_pan/items/c0c881f7a3f95ecb3f9d#platform_views)を利用しています。リンク先にQiitaの記事を書いていますので、参考にしてください。

## プラットフォーム (Android) 側のコード

プラットフォーム側について解説します。

### 1. Flutter用のWebViewプラグインView作成

`io.flutter.plugin.platform.PlatformView`を継承したFlutterWebViewクラスを作成します。

内部では`WebView`をインスタンスし、MethodChannelでイベントが発生した時にその引数のデータ (URL) を開くというシンプルなサンプルです。

`WebView`は`AndroidX`を利用していますが、詳細は[こちら](https://qiita.com/kurun_pan/items/955685ce3cf2eb7d9672)を参照してください。同じくMethodChannelについても[こちら](https://qiita.com/kurun_pan/items/db6c8fa94bbfb5c0c8d7)にまとめていますので、参照して下さい。

MethodChannelの注意点としては、各PlatformView事にFlutter側からint側のidが振られるため、それを意識したチャンネル名を指定する必要があることです。

```kotlin:FlutterWebView.kt
package com.example.flutter_platform_view_app

import android.content.Context
import android.view.View
import android.webkit.WebView
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.platform.PlatformView

class FlutterWebView internal constructor(context: Context?, messenger: BinaryMessenger?, id: Int)
    : PlatformView, MethodCallHandler {

    private val webView: WebView
    private val methodChannel: MethodChannel

    init {
        webView = WebView(context)
        webView.apply {
            settings.apply {
                // enable Javascript
                javaScriptEnabled = true
                setSupportZoom(true)
                builtInZoomControls = true
                displayZoomControls = false // no zoom button
                loadWithOverviewMode = true
                useWideViewPort = true
                domStorageEnabled = true
            }
        }

        methodChannel = MethodChannel(messenger, "plugins.kurun.views/webview_$id")
        methodChannel.setMethodCallHandler(this)
    }

    @Override
    override fun getView(): View {
        return webView
    }

    @Override
    override fun onMethodCall(methodCall: MethodCall, result: MethodChannel.Result) {
        when (methodCall.method) {
            "setUrl" -> setUrl(methodCall, result)
            else -> result.notImplemented()
        }
    }

    private fun setUrl(methodCall: MethodCall, result: MethodChannel.Result) {
        val url = methodCall.arguments as String
        webView.loadUrl(url)
        result.success(null)
    }

    @Override
    override fun dispose() {
        webView.destroy()
    }
}
```

### 2. PlatformViewFactoryクラスの作成

`PlatformViewFactory`クラスを継承したWebVewFactoryクラスを用意します。
PlatformViewを利用する場合、必ずこのPlatformViewFactoryを用意する必要があります。この中で先ほど作成したFluterWebViewをインスタンスします。

```kotlin:WebViewFactory.kt
package com.example.flutter_platform_view_app

import android.content.Context
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory

class WebViewFactory(private val messenger: BinaryMessenger) :
        PlatformViewFactory(StandardMessageCodec.INSTANCE) {

    override fun create(context: Context, id: Int, o: Any?): PlatformView {
        return FlutterWebView(context, messenger, id)
    }
}
```

### 3. FlutterPluginクラスの作成

`io.flutter.embedding.engine.plugins.FlutterPlugin`を継承したWebViewPluginクラスを用意します。このクラスを最終的にAndroidのTopからプラグインとして登録することになります。

```kotlin:WebViewPlugin.kt
package com.example.flutter_platform_view_app

import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin

class WebViewPlugin : FlutterPlugin {

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        flutterPluginBinding.platformViewRegistry
                .registerViewFactory(
                        "plugins.kurun.views/webview",
                        WebViewFactory(flutterPluginBinding.binaryMessenger)
                )
    }

    override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    }
}
```

### 4. 作成したプラグインの登録

最後にプラグインを登録して完了です。
今回はMainActivityからWebViewPluginを登録します。

```kotlin:MainActivity.kt
package com.example.flutter_platform_view_app

import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.GeneratedPluginRegistrant

class MainActivity: FlutterActivity() {
    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)
        flutterEngine.plugins.add(WebViewPlugin())
    }
}
```

## Flutter (Dart) 側のコード

次にFlutterのDart側の対応について解説します。

### 5. WebViewウィジェットの作成

WebViewクラスを作成します。ポイントはAndroidViewを利用して先ほど用意したプラットフォーム側のWebViewプラグイン名を指定する点です。
AndroidViewのソースコードは[こちら](https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/platform_view.dart)

サンプルコードを見ていただければ分かると思いますが、非常に簡単に利用が出来ます。

```dart:web_view.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

typedef void WebViewCreatedCallback(WebViewController controller);

class WebView extends StatefulWidget {
  const WebView({
    Key key,
    this.onWebViewViewCreated,
  }) : super(key: key);

  final WebViewCreatedCallback onWebViewViewCreated;

  @override
  State<StatefulWidget> createState() => _WebViewState();
}

class _WebViewState extends State<WebView> {
  @override
  Widget build(BuildContext context) {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(
        viewType: 'plugins.kurun.views/webview',
        onPlatformViewCreated: _onPlatformViewCreated,
      );
    }
    return Text(
        '$defaultTargetPlatform is not supported!');
  }

  void _onPlatformViewCreated(int id) {
    if (widget.onWebViewViewCreated == null)
      return;
    widget.onWebViewViewCreated(new WebViewController._(id));
  }
}
```
### 6. MethodChannelでのプラットフォーム側の呼び出し (データ送信)

サンプルコードは、`onPlatformViewCreated`のコールバックのタイミングでプラットフォーム側のWebViewプラグインのMethodChannelを作成し、main.dart側から任意のURLを送信し出来るようにしました。

```dart:web_view.dart
class WebViewController {
  WebViewController._(int id)
      : _channel = new MethodChannel('plugins.kurun.views/webview_$id');

  final MethodChannel _channel;

  Future<void> setUrl(String url) async {
    assert(url != null);
    return _channel.invokeMethod('setUrl', url);
  }
}
```

### 7. 画面の作成

先ほど作成したWebViewウィジェットを好きに配置すれば完成です！
簡単ですよね？

※TextViewプラグインについては解説していませんが、以下のサンプルコードには出てきます…。基本的な作りはWebViewプラグインと同じです！

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:flutter_platform_view_app/text_view.dart';
import 'package:flutter_platform_view_app/web_view.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter PlatformView API',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter PlatformView Example'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text(widget.title)),
        body: Column(children: [
          Center(
              child: Container(
                  padding: EdgeInsets.symmetric(vertical: 10.0),
                  width: 200.0,
                  height: 40.0,
                  child: TextView(
                    onTextViewCreated: _onTextViewCreated,
                  )
              )
          ),
          Expanded(
              flex: 3,
              child: WebView(
                    onWebViewViewCreated: _onWebViewCreated,
              )
          )
        ])
    );
  }

  void _onTextViewCreated(TextViewController controller) {
    controller.setText('Android TextView and WebView example.');
  }

  void _onWebViewCreated(WebViewController controller) {
    controller.setUrl('https://www.google.co.jp/');
  }
}
```

## サンプルコード一式

上記で解説したソースコードのプロジェクトファイル一式を以下にUPしています。
Android環境であればそのまま動作するため、参考にしてください。
https://github.com/Kurun-pan/flutter-platformview-example

## 参考文献

 * [Experimental: Create Flutter Plugin](https://github.com/flutter/flutter/wiki/Experimental:-Create-Flutter-Plugin)
 * [Flutter PlatformView: How to create Widgets from Native Views](https://medium.com/flutter-community/flutter-platformview-how-to-create-flutter-widgets-from-native-views-366e378115b6)