Xamarin.FormsでAndroid向けTwitterアプリケーションを開発したときに困ったこと ~WebView編~

N.Mです．

最近はAndroid用のアプリケーション，"TLExtension"を作っていました．AndroidでTwitter用のブラウザを見つつ，タブ操作で簡単に今まで作ってきたTwitter用ツールも利用できるアプリケーションです．自分用に作ったので，アプリ自体の公開はしていませんが，ひな型部分のソースコードはGitHubにも公開しました．

[TLExtension](https://github.com/HexagramNM/TLExtension)

このアプリはXamarin.Formsを利用して作っているのですが，今回の記事はこれを作る際に困ったこととその解決策をまとめたものとなっております．
（主にAndroidアプリをXamarin.Formsで開発する際に，こんな機能はどう作ればいいのかといった内容になります。）

~~結局，Xamarin.Formsにあるプラットフォーム共通の機能じゃ足りないので，Xamarin.Androidでの実装が必要になるわけですが...~~

長くなってしまったので，2回に分けてお送りします．今回はWebView周りの話です．
（次回は画像や動画などの処理に関する[メディア編](https://qiita.com/HexagramNM/items/90a0f03e0d3d455d0d40)です。）

# WebView内に表示されたHTML取得

参考：[https://github.com/xamarin/xamarin-forms-samples/tree/master/CustomRenderers/HybridWebView/Droid](https://github.com/xamarin/xamarin-forms-samples/tree/master/CustomRenderers/HybridWebView/Droid)

CoreTweetでREST APIの認証を取得する際に，[過去の記事](https://qiita.com/HexagramNM/items/6cd192cabcb666dafe0b)でも触れたように，PIN番号の入力を自動化しようとすると，HTMLからPIN番号を抽出し，C#側で使えるようにする必要があります．また，Twitter内でのページ遷移（ホーム画面から個々のツイート画面への遷移など）では，WebView.Sourceに格納されるURLが変化しないためか，Navigatingなどのイベントも反応しません．TwitterのページのHTMLソースにはそのページのURLがあるので，これを取得すれば，遷移したかどうかがわかります．こんな感じで，なにかとC#側でHTMLを取得する必要があります．これを実現するには以下のような手順でjavascriptとC#を連携できるようにする必要があります．

① `Xamarin.Forms.WebView`クラスを継承して，自作の`WebView`クラスを作ります．（自分のリポジトリでは，`TLExtensionWebView`というクラス名にしています．）

② Android側のプロジェクトで`WebViewRenderer`クラスを作成します．（`Xamarin.Forms.Platform.Android.WebViewRenderer`，自分のリポジトリでは`TLExtensionWebViweRenderer`というクラス名にしています．）この`WebViewRenderer`と1で作った`WebView`を以下のようにして連携させます．このように連携すると，`WebViewRender.Element`に連携した`WebView`が格納されます．

```C#
//以下の文章をWebViewRenderクラスを宣言しているところの直前に記述します．
[assembly: ExportRenderer(typeof(TLExtensionWebView), typeof(TLExtensionWebViewRenderer))]
```

③ javascriptから呼び出せるメソッドを格納した`JSBridge`クラス（`Java.Lang.Object`を継承）を作ります．コード自体はリポジトリ内の`JSBridge.cs`を参考にしてほしいですが，クラス内のメソッドの前に以下のように記述することで，javascript内でC#のそのメソッドを呼び出せるようになります．`Export`の中に書いた文字列が，javascript内でのメソッド名になります．

```C#
//以下の文章をjavascriptで呼び出したい，JSBridgeのメソッドの直前に記述します．
[JavascriptInterface]
[Export("invokeAction")]
```

④ `WebViewRenderer`クラスの`OnElementChanged`メソッドに以下を追加します．

```C#
if (e.OldElement != null)
{
    Control.RemoveJavascriptInterface("jsBridge");
}
if (e.NewElement != null)
{    
    Control.AddJavascriptInterface(new JSBridge(this), "jsBridge");
}
```

⑤ これで，`Xamarin.Forms.WebView`の`Eval`メソッドや`Android.Webkit.WebView`の`EvaluateJavascript`メソッドに`javascript: jsBridge.invokeAction();`を渡すと，`JSBridge`クラスのメソッドが呼び出されます（`jsBridge`は④の`AddJavascriptInterface`で登録した名前，`invokeAction`は③の`Export`に登録した名前にです．）．`JSBridge`クラスのメソッドの引数として文字列を渡せるようにしておけば，`javascript: jsBridge.invokeAction(document.documentElement.outerHTML);`で，そのページのHTMLをC#に取得することができます．

⑥ `Xamarin.Forms`の`WebView`でこの取得したHTMLを使用するならば，`WebViewRenderer`で`JSBridge`からデータを受け取り，`WebViewRenderer.Element`にそのデータを渡します．


# 動画の全画面表示

参考：[https://github.com/mhaggag/XFAndroidFullScreenWebView](https://github.com/mhaggag/XFAndroidFullScreenWebView)

デフォルトだと，Twitterの動画で全画面表示を押しても全画面になりません（全画面表示になったという通知が`Xamarin.Forms`側までいかないためだと思っています）．全画面表示になったかは，`Xamarin.Forms.Platform.Android.FormsWebChromeClient`クラスを使えば検知できるので，ここから`Xamarin.Forms`側まで通知を伝えていきます．

① フルスクリーンに入った時のイベント用の`EventArgs`を作ります．（自分のリポジトリでは`TLExtensionWebChromeClient.cs`にあります．）

```C#
public class EnterFullScreenRequestedEventArgs : EventArgs
{
    public View View { get; }
    public EnterFullScreenRequestedEventArgs(View view)
    {
        View = view;
    }
}
```

② `FormsWebChromeClient`クラスを作ります（自分のリポジトリでは`TLExtensionWebChromeClient`）．全画面表示になると`OnShowCustomView`が，全画面表示が終了すると`OnHideCustomView`が呼び出されます．

```C#
public class TLExtensionWebChromeClient : FormsWebChromeClient
{
    public event EventHandler<EnterFullScreenRequestedEventArgs> EnterFullScreenRequested;

    public event EventHandler ExitFullScreenRequested;

    public override void OnHideCustomView()
    {
        ExitFullScreenRequested?.Invoke(this, EventArgs.Empty);
    }

    public override void OnShowCustomView(View view, ICustomViewCallback callback)
    {
        EnterFullScreenRequested?.Invoke(this, new EnterFullScreenRequestedEventArgs(view));
    }
}
```

③ `Xamarin.Forms`側の`WebView`クラスに以下のフルスクリーンになった時，解除されたときのアクションを追加します．

```C#
public static readonly BindableProperty EnterFullScreenCmmandProperty =
            BindableProperty.Create(
            propertyName: "EnterFullScreenCommand",
            returnType: typeof(ICommand),
            declaringType: typeof(TLExtensionWebView),
            defaultValue: new Command(async (view) => await DefaultEnterAsync((View)view))
            );

public ICommand EnterFullScreenCommand
{
    get => (ICommand)GetValue(EnterFullScreenCmmandProperty);
    set => SetValue(EnterFullScreenCmmandProperty, value);
}

public static readonly BindableProperty ExitFullScreenCmmandProperty =
            BindableProperty.Create(
            propertyName: "ExitFullScreenCommand",
            returnType: typeof(ICommand),
            declaringType: typeof(TLExtensionWebView),
            defaultValue: new Command(async (view) => await DefaultExitAsync())
            );

public ICommand ExitFullScreenCommand
{
    get => (ICommand)GetValue(ExitFullScreenCmmandProperty);
    set => SetValue(ExitFullScreenCmmandProperty, value);
}

//フルスクリーンを実現するためのメソッド
private static async Task DefaultEnterAsync(View view)
{
    var page = new ContentPage
    {
        Content = view
    };
    await Application.Current.MainPage.Navigation.PushModalAsync(page);
}

private static async Task DefaultExitAsync()
{
    await Application.Current.MainPage.Navigation.PopModalAsync();
}
//フルスクリーンを実現するためのメソッド　ここまで
```

④ `WebViewRenderer`クラスに②で作った`FormsWebChromeClient`を登録します．`FormsWebChromeClient`で全画面表示の通知が発生したら，③で実装した`DefaultEnterAsync`や`DefaultExitAsync`が呼び出されるように，処理を繋げます．（以下はフルスクリーン対応に必要な部分のみ抜き出しています．）

```C#
public class TLExtensionWebViewRenderer : WebViewRenderer
{
    private TLExtensionWebView _webView;
    private TLExtensionWebChromeClient webClient;

    public TLExtensionWebViewRenderer(Context context) : base(context)
    {
        webClient = new TLExtensionWebChromeClient();
        webClient.EnterFullScreenRequested += OnEnterFullScreenRequested;
        webClient.ExitFullScreenRequested += OnExitFullScreenRequested;
    }

    protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.WebView> e)
        {
        base.OnElementChanged(e);

        if (e.NewElement != null)
        {
            Control.SetWebChromeClient(webClient);
        }
        _webView = (TLExtensionWebView)e.NewElement;            
    }

    //フルスクリーンにするための追加メソッド
    protected override FormsWebChromeClient GetFormsWebChromeClient()
    {
        return webClient;
    }

    private void OnEnterFullScreenRequested(object sender, EnterFullScreenRequestedEventArgs eventArgs)
    {
        if (_webView.EnterFullScreenCommand != null && _webView.EnterFullScreenCommand.CanExecute(null))
        {
            _webView.EnterFullScreenCommand.Execute(eventArgs.View.ToView());
        }
    }

    private void OnExitFullScreenRequested(object sender, EventArgs eventArgs)
    {
        if (_webView.ExitFullScreenCommand != null && _webView.ExitFullScreenCommand.CanExecute(null))
        {
            _webView.ExitFullScreenCommand.Execute(null);
        }
    }
}
```

全画面表示になるときは

`TLExtensionWebChromeClient.OnShowCustomView`→`TLExtensionWebViewRenderer.OnEnterFullScreenRequsted`→`TLExtensionWebView.DefaultEnterAsync`の順で

全画面表示が解除されるときは

`TLExtensionWebChromeClient.OnHideCustomView`→`TLExtensionWebViewRenderer.OnExitFullScreenRequsted`→`TLExtensionWebView.DefaultExitAsync`の順で

呼び出され，`Xamarin.Forms`側まで通知が行くようになり，全画面表示に対応できるようになります．


# ソフトウェアキーボード表示時のWebViewの縮小

参考：[https://qiita.com/amay077/items/6fcdec829a96bc604532](https://qiita.com/amay077/items/6fcdec829a96bc604532)

デフォルトのWebViewでTwitterのリプライをしようとすると，ソフトウェアキーボードが邪魔で自分が書いているツイートが見えなくなります．

ひとまず，`Xamarin.Android`側の`MainActivity`の`OnCreate`メソッドに以下を入れれば解決します．

```C#
//appにはMainActivityでロードするAppクラスのオブジェクトを入れておく．

app.On<Xamarin.Forms.PlatformConfiguration.Android>().
    UseWindowSoftInputModeAdjust(WindowSoftInputModeAdjust.Resize);
```

しかし，検索で文字を打ち，サジェストを選択したときに，キーボードは閉じるがWebViewが縮んだままになるケースがありました．この場合は後述するリロードに，いったんWebViewを非表示にしてから，表示する処理を加えると，1,2回のリロードでもとに戻るようになりました．（自分用のアプリなので，ひとまずこれで妥協．）


# Twitter外のページに遷移したときなどに，Chromeなどの別ブラウザで開く

参考：[https://itblogdsi.blog.fc2.com/blog-entry-171.html](https://itblogdsi.blog.fc2.com/blog-entry-171.html)

`WebView`で遷移する際に，Chromeなどの別ブラウザで開く際には，`Xamarin.Android`側で別ブラウザを開くためのIntentを呼び出す必要があるみたいです．上記の記事が分かりやすいので，詳細はそちらをご覧ください．

`Xamarin.Android`側の`OnPageStarted`で，遷移開始時に処理をする場合は，`OnPageStarted`は2回呼ばれることがあります．最初の`OnPageStarted`でブラウザを開き，`OnPageFinished`が呼ばれるまではブラウザを開かないようにする必要もあるようです．（参考：[https://qiita.com/kikuchy/items/d0f5599a883e5e9350df](https://qiita.com/kikuchy/items/d0f5599a883e5e9350df)）

また，ページ遷移する際に`WebView`側もページ遷移しているので，`WebView.GoBack()`で戻らないと，遷移先のページが`WebView`に表示されたままになってしまいます．


# WebViewへのjavascript実行

`WebView.Eval`でjavascriptのスクリプトを入れれば，実行できますが，どうやら1つの文しか処理できないようです．複数文を処理する場合は，それらを1つの関数にまとめて一度`WebView.Eval`で登録した後，2回目の`WebView.Eval`でその関数を呼び出す必要があるみたいです．

また，HTMLが更新されると登録されていた関数も消えてしまうのと，Twitterではページ表示後もHTMLの更新が頻繁に発生するため，javascriptの関数を使用するタイミングで，その都度`WebView.Eval`で関数を登録する必要があります．


# WebViewのリロード

最近の`Xamarin.Forms.WebView`には`reload`メソッドがあるみたいですが，自分が使用していた`Xamarin.Forms`が古かったためか（バージョンは3.0.0.561731），`reload`メソッドはありませんでした．

この場合は，`WebView`と連携している`WebViewRenderer`の`Control.Reload()`を呼ぶ必要があります．リロードボタンを作る際は`Xamarin.Forms.WebView`側に`Action`型の変数を用意しておき，`WebViewRenderer`でその変数に`Control.Reload()`を呼ぶアクションを登録する必要があります．リロードボタンのイベントで登録したアクションを発動します．

```C#
//WebViewRednererのOnElementChanged内
//reloadActionはWebView側の変数

_webView = (TLExtensionWebView)e.NewElement;
_webView.reloadAction = new Action(() => { Control.Reload(); });
```

# WebViewの履歴削除

REST APIの認証が完了してから，認証ページに戻らないように，WebViewの履歴を消す処理も実装しました．しかし，履歴削除のメソッドは現在の`Xamarin.Forms.WebView`にも内容です．リロードの時と同じように，`WebView`と連携している`WebViewRenderer`の`Control.ClearHistory()`を呼ぶ必要があります．


# （おまけ）TabbedPageのスライド禁止

Twitterでは，画像表示時にその画像が複数枚ある場合は，横スライドで画像を切り替えます．`Xamarin.Forms.TabbedPage`を使っている場合，`TabbedPage`のタブ切替えのための横スライドと重なり，デフォルトだと正常に画像を切り替えることができなくなります．

`TabbedPage`の横スライドによるタブ切替えを禁止するには，以下を呼び出します．

```C#
//thisTabbedPageはTabbedPageのオブジェクト
//falseをtrueにすると，スライドによるタブ切替えができるようになる．

thisTabbedPage.On<Xamarin.Forms.PlatformConfiguration.Android>().SetIsSwipePagingEnabled(false);

```

# まとめ

`Xamarin.Forms`で開発してても，`WebView`に少し複雑な機能を追加しようとするとAndroid側の実装が必要になってしまうようです．`Xamarin.Forms.WebView`と連携している`Xamarin.Forms.Platform.Android.WebViewRenderer`から，Android固有の実装を追加する場合が多いので，`webView`を用いるAndroidアプリケーションを開発する場合は，この`WebViewRenderer`にお世話になる機会も多くなるかと思います．