Help us understand the problem. What is going on with this article?

TypeScript (Angular) で実装されたサイトを Android WebView(Kotlin) で動かす方法

はじめに

タイトルの通りなのですが、当初 TWA (Trusted Web Activity) を使って動かしていましたが、
「○○をタップしたときに××を表示したい」
という要件が出てきました。

調べてみたところ TWA では実装者側でハンドリングできることがほぼ無く…
(できるのは 起動時に表示するページぐらい で、あとはまるっと Chrome さんにおまかせ! といった感じだった)

WebView を使って動かすことになりました。

この記事では TypeScript で実装されたサイト (Angular フレームワークが使われている) を、WebView でひととおりの機能が動くようになるまでの道のりをまとめようと思います。

※ TWA でクリックイベントなどをハンドリングする方法があれば教えていただきたいです!

環境

macOS Mojave - 10.14.6
Android Studio - 3.5
Kotlin - 1.3.31

方法

ソースコード

まずはソースコードです。

Kotlin Android Extensions を使っています。
(Kotlin Android Extensions とは、 layout ファイルに Activity を定義することで findViewById をやらなくてよくなる便利な Plugin です。)

Activity

WebViewActivity.kt
class WebViewActivity : AppCompatActivity() {

    // <input type="file"> を反応させるために使用します
    private val INPUT_FILE_REQUEST_CODE = 101
    private var mFilePathCallback: ValueCallback<Array<Uri>>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_webview)

        // WebView の設定
        webView.apply {
            settings.apply {
                javaScriptEnabled = true
                builtInZoomControls = true
                displayZoomControls = false
                domStorageEnabled = true
                allowFileAccess = true
                allowContentAccess = true
                allowFileAccessFromFileURLs = true
                javaScriptCanOpenWindowsAutomatically = true
                databaseEnabled = true
                loadsImagesAutomatically = true
                loadWithOverviewMode = true
                useWideViewPort = true
                isFocusableInTouchMode = true
                setSupportMultipleWindows(true)
                setAppCacheEnabled(true)
                setSupportZoom(true)

                userAgentString += " TEST_USER_AGENT/" + BuildConfig.VERSION_NAME

                setOnKeyListener { view, keyCode, keyEvent ->
                    if (keyEvent.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack() == true) {
                        webView.goBack()
                        return@setOnKeyListener true
                    }
                    return@setOnKeyListener false
                }

                loadUrl("TypeScriptで実装されたサイトのURL")
            }
        }
        webView.webViewClient = CustomWebViewClient(this)
        webView.webChromeClient = CustomWebChromeClient(this)

        // Pull to Refresh したときに実行させる処理です
        swipeRefreshLayout.setOnRefreshListener {
            webView.reload()
            swipeRefreshLayout.isRefreshing = false
        }
    }

    // <input type="file"> をタップしてファイル選択後に実行される処理です
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == INPUT_FILE_REQUEST_CODE) {
            if (mFilePathCallback == null) {
                super.onActivityResult(requestCode, resultCode, data)
                return
            }
            var results: Array<Uri>? = null

            if (resultCode == Activity.RESULT_OK) {
                val dataString = data?.getDataString()
                if (dataString != null) {
                    results = arrayOf(Uri.parse(dataString))
                }
            }
            mFilePathCallback?.onReceiveValue(results)
            mFilePathCallback = null
        }
    }

    // WebViewClient のカスタムクラス
    private class CustomWebViewClient internal constructor(private val activity: WebViewActivity) :
        WebViewClient() {

        // shouldOverrideUrlLoading() では反応しません
        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)

            Log.d("TEST", "activity.webView.url:" + activity.webView.url)

            // *** ここでクリックイベントを補足して任意の処理を実行させる *** //
        }
    }

    // WebChromeClient のカスタムクラス
    private class CustomWebChromeClient internal constructor(private val activity: WebViewActivity) :
        WebChromeClient() {

        // JavaScript を動かすために必要です
        override fun onJsAlert(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            return false
        }

        // JavaScript を動かすために必要です
        override fun onJsConfirm(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            view ?: return true

            MaterialDialog(view.context).show {
                title(text = message)
                positiveButton {
                    result?.confirm()
                }
                negativeButton {
                    result?.cancel()
                }
                onCancel {
                    result?.cancel()
                }
            }
            return true
        }

        // JavaScript を動かすために必要です
        override fun onJsPrompt(
            view: WebView?,
            url: String?,
            message: String?,
            defaultValue: String?,
            result: JsPromptResult?
        ): Boolean {
            return false
        }

        // <input type="file"> を反応させるために必要です
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            if (activity.mFilePathCallback != null) {
                activity.mFilePathCallback?.onReceiveValue(null)
            }
            activity.mFilePathCallback = filePathCallback

            val intent = Intent(Intent.ACTION_GET_CONTENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type = "image/*"

            activity.startActivityForResult(
                intent,
                activity.INPUT_FILE_REQUEST_CODE
            )
            return true
        }

        // <a target="_blank"> を反応させるために必要です
        override fun onCreateWindow(
            view: WebView?,
            isDialog: Boolean,
            isUserGesture: Boolean,
            resultMsg: Message?
        ): Boolean {

            view ?: return false

            val href = view.handler.obtainMessage()
            view.requestFocusNodeHref(href)
            val url = href.data.getString("url")

            view.stopLoading()
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
            activity.startActivity(browserIntent)
            return true
        }

        // console.log を Logcat に表示させます
        override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
            if (consoleMessage != null) {
                Log.d(
                    "TEST",
                    "[" + consoleMessage.messageLevel() + "] " + consoleMessage.message() + " - " + consoleMessage.sourceId() + ":" + consoleMessage.lineNumber() + "行目"
                )
            }
            return super.onConsoleMessage(consoleMessage)
        }
    }
}

layout

つづいてレイアウトファイルです。

Pull to Refresh させるために SwipeRefreshLayout 内に WebView を設置しています。

activity_webview.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WebViewActivity">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <WebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>

なにをやっているか見ていく

WebView の設定をする

        webView.apply {
            settings.apply {
                javaScriptEnabled = true
                builtInZoomControls = true
                displayZoomControls = false
                domStorageEnabled = true
                allowFileAccess = true
                allowContentAccess = true
                allowFileAccessFromFileURLs = true
                javaScriptCanOpenWindowsAutomatically = true
                databaseEnabled = true
                loadsImagesAutomatically = true
                loadWithOverviewMode = true
                useWideViewPort = true
                isFocusableInTouchMode = true
                setSupportMultipleWindows(true)
                setAppCacheEnabled(true)
                setSupportZoom(true)

                userAgentString += " TEST_USER_AGENT/" + BuildConfig.VERSION_NAME

                setOnKeyListener { view, keyCode, keyEvent ->
                    if (keyEvent.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack() == true) {
                        webView.goBack()
                        return@setOnKeyListener true
                    }
                    return@setOnKeyListener false
                }

                loadUrl("TypeScriptで実装されたサイトのURL")
            }
        }
        webView.webViewClient = CustomWebViewClient(this)
        webView.webChromeClient = CustomWebChromeClient(this)

JavaScript、Storage などを許可、UserAgent の設定、カスタムしたWebViewClient、WebChromeClient のセットをします。

各設定項目は以下のとおりです。

項目 内容
javaScriptEnabled JavaScriptの実行を有効にするか
builtInZoomControls ズームを使用するか
displayZoomControls ズームを使用するときに、WebViewにズームコントロールを表示するか
domStorageEnabled DOMストレージを有効にするか
allowFileAccess WebView内のファイルアクセスを有効にするか
allowContentAccess WebView内のコンテンツURLアクセスを有効にするか
allowFileAccessFromFileURLs ファイルスキームURLのコンテキストで実行されているJavaScriptが他のファイルスキームURLのコンテンツにアクセスできるようにするか
(意味がよくわかっていない…)
javaScriptCanOpenWindowsAutomatically JavaScriptでウィンドウを自動的に開くか
databaseEnabled データベースストレージを有効にするか
loadsImagesAutomatically WebViewが画像リソースをロードするか
loadWithOverviewMode 画面幅に収まるようにロードするか
useWideViewPort HTMLメタタグの viewport を有効にするか
isFocusableInTouchMode 画面をタッチしたときにフォーカスあてるか?
setSupportMultipleWindow 複数のウィンドウをサポートするか
setAppCacheEnabled キャッシュを有効にするか
setSupportZoom ズームを有効にするか

公式ドキュメント WebViewSettings

WebView で設定できる項目がこんなにあるとは知りませんでした。。
中にはデフォルトが true でわざわざ設定しなくてもよかったものが含まれてましたね。。

UserAgent はコードを見たまんまですが、userAgentString += 〜 で設定しています。

setOnKeyListener〜 をおこなっているのは、端末の戻るボタンをタップした時に WebView 内のページをひとつ前のページに遷移させるためです。

後述する WebViewClient のカスタムクラス、WebChromeClient のカスタムクラスをセットして WebView の設定まわりはおしまいです。

Pull to Refresh できるようにする

        swipeRefreshLayout.setOnRefreshListener {
            webView.reload()
            swipeRefreshLayout.isRefreshing = false
        }
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WebViewActivity">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <WebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>

Pull to Refresh がなくても問題なく動作するのですが、実装してあげたほうが親切です。

swipeRefreshLayout.isRefreshing = false これがないと Pull to Refresh の
名称未設定.png
コイツが表示されたままになってしまうのでご注意を。

ぼくは FrameLayout 配下に SwipeRefreshLayout と WebView を配置してますが、「FrameLayoutいる?」と思ったので
試しに FrameLayout を削除して動かしてみましたがちゃんと動きました。

View の構造は浅い方が良いため FrameLayout は削除して良いかもしれません。

WebViewClient のカスタムクラス

タップイベントをハンドリングする

    // WebViewClient のカスタムクラス
    private class CustomWebViewClient internal constructor(private val activity: WebViewActivity) :
        WebViewClient() {

        // shouldOverrideUrlLoading() では反応しません
        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)

            Log.d("TEST", "activity.webView.url:" + activity.webView.url)

            // *** ここでクリックイベントを補足して任意の処理を実行させる *** //
        }
    }

ページ遷移時に submit が発生しないため、 shouldOverrideUrlLoading() ではハンドリングできませんでした。

代わりに doUpdateVisitedHistory() を使用します。

WebChromeClient のカスタムクラス

JavaScript が動くようにする

        // JavaScript を動かすために必要です
        override fun onJsAlert(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            return false
        }

        // JavaScript を動かすために必要です
        override fun onJsConfirm(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            view ?: return true

            MaterialDialog(view.context).show {
                title(text = message)
                positiveButton {
                    result?.confirm()
                }
                negativeButton {
                    result?.cancel()
                }
                onCancel {
                    result?.cancel()
                }
            }
            return true
        }

        // JavaScript を動かすために必要です
        override fun onJsPrompt(
            view: WebView?,
            url: String?,
            message: String?,
            defaultValue: String?,
            result: JsPromptResult?
        ): Boolean {
            return false
        }

JavaScript の alert()、confirm()、prompt() を動かすための設定になります。

onJsConfirm() 内の MaterialDialog〜 の処理がないと

スクリーンショット 2019-09-09 15.20.46.png

上記画像のような 「〜のページ:」 というタイトルが表示されてしまうため独自に表示しています。

ダイアログの表示には Material Dialogs を使用しています。

<input type="file"> が反応するようにする

WebViewActivity.kt
    // <input type="file"> を反応させるために使用します
    private val INPUT_FILE_REQUEST_CODE = 101
    private var mFilePathCallback: ValueCallback<Array<Uri>>? = null

    // <input type="file"> をタップしてファイル選択後に実行される処理です
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == INPUT_FILE_REQUEST_CODE) {
            if (mFilePathCallback == null) {
                super.onActivityResult(requestCode, resultCode, data)
                return
            }
            var results: Array<Uri>? = null

            if (resultCode == Activity.RESULT_OK) {
                val dataString = data?.getDataString()
                if (dataString != null) {
                    results = arrayOf(Uri.parse(dataString))
                }
            }
            mFilePathCallback?.onReceiveValue(results)
            mFilePathCallback = null
        }
    }
        // <input type="file"> を反応させるために必要です
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            if (activity.mFilePathCallback != null) {
                activity.mFilePathCallback?.onReceiveValue(null)
            }
            activity.mFilePathCallback = filePathCallback

            val intent = Intent(Intent.ACTION_GET_CONTENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type = "image/*"

            activity.startActivityForResult(
                intent,
                activity.INPUT_FILE_REQUEST_CODE
            )
            return true
        }

<input type="file"> のボタンがタップされると onShowFileChooser() が呼び出されます。

1. Activity に <input type="file"> のコールバック変数を定義する
2. `onShowFileChooser()` の引数にタップしたときにコールバックオブジェクトが渡ってくるので 1 で定義した変数にセットする
3. "image/*" を指定して ACTION_GET_CONTENT インテントを `startActivityForResult()` で呼び出す
4. onActivityResult() 内で選択されたファイルを取得しコールバック変数に渡して処理する

こんな感じだと思ってますが、正直このあたりの処理はよくわかってないです。。

<a target="_blank"> が反応するようにする

        // <a target="_blank"> を反応させるために必要です
        override fun onCreateWindow(
            view: WebView?,
            isDialog: Boolean,
            isUserGesture: Boolean,
            resultMsg: Message?
        ): Boolean {

            view ?: return false

            val href = view.handler.obtainMessage()
            view.requestFocusNodeHref(href)
            val url = href.data.getString("url")

            view.stopLoading()
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
            activity.startActivity(browserIntent)
            return true
        }

<a target="_blank"> のリンクがタップされると onCreateWindow() が呼び出されます。
タップされたリンクの URL を取得し標準ブラウザで表示することができます。

ブラウザを起動させずに WebView 内で表示させるには WebView の loadUrl() に取得した URL を渡せば良いです。

console.log を Logcat に表示させる

        // console.log を Logcat に表示させます
        override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
            if (consoleMessage != null) {
                Log.d(
                    "TEST",
                    "[" + consoleMessage.messageLevel() + "] " + consoleMessage.message() + " - " + consoleMessage.sourceId() + ":" + consoleMessage.lineNumber() + "行目"
                )
            }
            return super.onConsoleMessage(consoleMessage)
        }

onConsoleMessage() を継承して実装します。

↓ こんな感じで表示されます。

2019-09-09 16:25:54.312 14024-14024/android.app.package-name D/TEST: [WARNING] The key "viewport-fit" is not recognized and ignored. - TypeScriptで実装されたサイトのURL:10行目
2019-09-09 16:25:55.285 14024-14024/android.app.package-name D/TEST: [LOG] NGSW: Checked Update - TypeScriptで実装されたサイトのURL main.17d48fcf96a1908d9c7b.js:1行目
2019-09-09 16:25:55.536 14024-14024/android.app.package-name D/TEST: [LOG] AppModule boostraped! - TypeScriptで実装されたサイトのURL main.17d48fcf96a1908d9c7b.js:1行目

おわりに

Android の WebView は 「扱いにくい」 「表示が遅い」 といったイメージがあり、WebView で表示させるなら Chrome Custom Tabs を使うようにしていました。

ただ冒頭でも書いたとおりカスタマイズがあまりできません。

Chrome Custom Tabs の実装は難しくないし画面表示も早いので Web ページを表示させたいだけなら Chrome Custom Tabs で良いと思います。

WebView でもいろんな設定を施してあげるとそれなりに動くようになると知ることができました。
(それでも Chrome と動きが違う箇所は出てきちゃいますが。。)

参考

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away