Xamarin.Forms の WebView で JavaScript 連携を行う(with iOS/Android共通化)

## やりたい事

Xamarin.Forms 製アプリの WebView に表示した Webページから、ネイティブ(C#)で時間のかかる処理を行い、結果を JavaScript に通知したい。
JavaScript のコードは Android/iOS で共通にしたい。

具体的には、次のような JavaScript コードの ``heavyAdd(num)`` を実行した時に、ネイティブ側で処理を行い、結果を ``onResult(res)`` で受信したい。

```javascript
function addAsync() {  
  MyCalc.onResult = function (res) {
    var label = document.getElementById("result");
    label.innerHTML = 'MyCalc.onResult - ' + res;
  };
  MyCalc.heavyAdd(98);
}
```

## できた！

* 前提 - Xamarin.Forms 3.4.x が必要

### 共通

**sample.html**

```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <script>
    function addAsync() {
      
      MyCalc.onResult = function (res) {
        var label = document.getElementById("result");
        label.innerHTML = 'MyCalc.onResult - ' + res;
      };

      MyCalc.heavyAdd(98);
    }
  </script>
</head>
<body>
  <h1>WebView−JavaScript連携サンプル</h1>
  <p><button onclick="addAsync();">計算実行</button></p>
  <label id="result"></label>
</body>
</html>
```

ローカルPC にある ``sample.html`` は、 Webサーバー([npm serve とか](http://rennnosukesann.hatenablog.com/entry/2018/03/19/233245))を立てて、 [ngrok](https://qiita.com/mininobu/items/b45dbc70faedf30f484e) を使って外部公開するのが便利ですね。

**MainPage.xaml**

```xml
<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:local="clr-namespace:WebViewSample" x:Class="WebViewSample.MainPage">
    <StackLayout Orientation="Vertical">
        <WebView x:Name="webView" 
                 Source="https://xxxx.ngrok.io/sample.html"
                 VerticalOptions="FillAndExpand"/>
    </StackLayout>
</ContentPage>
```

### Android側

**Android の CustomWebViewRenderer.cs**

```csharp
using System;
using System.Threading.Tasks;
using Android.Content;
using Java.Interop;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;

[assembly: ExportRenderer(typeof(WebView), typeof(WebViewSample.Droid.CustomWebViewRenderer))]

namespace WebViewSample.Droid
{
    public class CustomWebViewRenderer : Xamarin.Forms.Platform.Android.WebViewRenderer
    {
        public CustomWebViewRenderer(Context context) : base(context) { }

        protected override void OnElementChanged(ElementChangedEventArgs<WebView> e)
        {
            base.OnElementChanged(e);
            Control.AddJavascriptInterface(new JavaScriptHandler(Control), "MyCalc");
        }
    }

    class JavaScriptHandler : Java.Lang.Object
    {
        private readonly Android.Webkit.WebView webView;

        public JavaScriptHandler(Android.Webkit.WebView webView)
        {
            this.webView = webView;
        }

        [Export]
        [Android.Webkit.JavascriptInterface]
        async public void heavyAdd(int num)
        {
            await Task.Delay(1000);
            var result = num * 2;

            // メインスレッドから呼ばないとエラー
            this.webView.Post(() => 
            {
                this.webView.LoadUrl($"javascript:MyCalc.onResult({result});");
            });
        }
    }
}
```

* [[Android][Kotlin]JavaScriptと相互通信 - Qiita](https://qiita.com/usayuki/items/aebd4e3ab791b7b008ca)

を参考に、ネイティブのやり方をカスタムレンダラーで。
Android の方はまだ単純で ``AddJavascriptInterface()`` の第2引数がクラス名に、``JavascriptInterface`` 属性を付けたメソッドが JavaScript のメソッド名になる。
結果の通知は ``this.webView.LoadUrl($"javascript:MyCalc.onResult(xx);`` で。

### iOS側

**iOS の CustomWebViewRenderer.cs**

```csharp
using System;
using System.Threading.Tasks;
using Foundation;
using WebKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

[assembly: ExportRenderer(typeof(WebView), typeof(WebViewSample.iOS.CustomWebViewRenderer))]

namespace WebViewSample.iOS
{
    public class CustomWebViewRenderer : Xamarin.Forms.Platform.iOS.WkWebViewRenderer, IWKScriptMessageHandler
    {
        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);

            var webView = this.NativeView as WKWebView;

            // JavaScript から呼び出すハンドラを追加。
            webView.Configuration.UserContentController.AddScriptMessageHandler(this, "MyHeavyAdd");

            // JavaScript 側で MyCalc.heavyAdd(n) が呼ばれた時に window.webkit.messageHandlers.xxx を呼ぶようにする。
            var script =
                "MyCalc = {};" +
                "MyCalc.heavyAdd = function (num) { window.webkit.messageHandlers.MyHeavyAdd.postMessage(num); };";
            webView.Configuration.UserContentController.AddUserScript(new WKUserScript(
                new NSString(script), WKUserScriptInjectionTime.AtDocumentStart, true));
        }

        async void IWKScriptMessageHandler.DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
        {
            if (message.Name == "MyHeavyAdd") 
            {
                // 時間のかかる処理
                await Task.Delay(1000);
                var result = (message.Body as NSNumber).Int32Value * 2;

                // 結果を通知
                var webView = this.NativeView as WKWebView;
                webView.EvaluateJavaScript($"MyCalc.onResult({result});", null);
            }
        }
    }
}
```

* [[iOS][Swift]JavaScriptと相互通信 - Qiita](https://qiita.com/usayuki/items/6979d0d6f7d8f5b302ad)

を参考にカスタムレンダラーで実装。

ポイント1。Xamarin.Forms 3.4から? WebView の実装が ``WKWebView`` になった模様。それまでは(少なくとも Xamarin.Forms 3.1 では) UIWebView だった。
Xamarin.Forms 3.4 でないと [``Xamarin.Forms.Platform.iOS.WkWebViewRenderer``](https://github.com/xamarin/Xamarin.Forms/blob/3.4.0/Xamarin.Forms.Platform.iOS/Renderers/WkWebViewRenderer.cs) が存在しないため使えない。

ポイント２。iOS で JavaScript からネイティブの処理を呼ぶには ``window.webkit.messageHandlers.xxxx.postMessage()`` を使わなければならないが、これでは Android 側と共通化できないので、``AddUserScript`` で ``window.webkit.〜`` を ``MyCalc.heavyAdd`` にマップしている。

ポイント３。JavaScript からの呼び出しに反応するのは ``IWKScriptMessageHandler`` インターフェース。

## こんな感じ

![image.png](https://qiita-image-store.s3.amazonaws.com/0/8227/488b5e50-3059-0897-0ea8-9d51b9592957.png)

端的に言うと、Android と異なる iOS の JavaScript→ネイティブ呼び出しを、AddUserScript で同じAPIにラップしたよーというお話でした。