はじめに
タイトルの通りなのですが、当初 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
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 を設置しています。
<?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 | ズームを有効にするか |
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 の
コイツが表示されたままになってしまうのでご注意を。
ぼくは 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〜
の処理がないと

上記画像のような 「〜のページ:」 というタイトルが表示されてしまうため独自に表示しています。
ダイアログの表示には Material Dialogs を使用しています。
<input type="file">
が反応するようにする
// <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 と動きが違う箇所は出てきちゃいますが。。)