【SwiftUI/JetpackCompose】Webビューをつくる　~比較で覚える宣言的モバイルUI~

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装

そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。

今回は**Webビュー編**です。

:::note warn
なお、見た目をSwiftUI寄りにしがちです(そっちしか知らないだけ...)
:::

# 対応関係

【SwiftUI】WKWebViewをラップして使う
【JetpackCompose】AndroidViewのWebViewを使う

# SwiftUI
SwiftUIでは、Web用のビューコンポーネントが用意されていないため、WebKitのWKWebViewをラップして使うというのが、現時点(20221002)での定番であるようでした。

## Webの表示
WKWebViewをUIViewRepresentableでラップしたビューを作ります。
<details><summary>最小限のコードはこちら</summary>

```swift: WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let urlString: String
    
    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        let url = URL(string: urlString)!
        let request = URLRequest(url: url)
        uiView.load(request)
    }
}
```
</details>

単に表示するだけでなく、スワイプでの戻る動作やURLごとに処理を分けるといったことをさせるには、Coordinatorを使ってUIViewからの情報を渡す必要があります。それを追加したものが以下になります。

<details><summary>応用的なコードはこちら</summary>

```swift:WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let urlString: String
    
    class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
        var parent: WebView
        
        init(_ parent: WebView) {
            self.parent = parent
        }
        
        // "target="_blank""が設定されたリンクも開けるようにする(任意)
        func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
            if navigationAction.targetFrame == nil {
                webView.load(navigationAction.request)
            }
            return nil
        }
        
        // URLごとに処理を制御する(任意)
        func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void) {
            if let url = navigationAction.request.url?.absoluteString {
                if (url.hasPrefix("https://apps.apple.com/")) {
                    guard let appStoreLink = URL(string: url) else {
                        return
                    }
                    UIApplication.shared.open(appStoreLink, options: [:], completionHandler: { (succes) in
                    })
                    decisionHandler(WKNavigationActionPolicy.cancel)
                } else if (url.hasPrefix("http")) {
                    decisionHandler(WKNavigationActionPolicy.allow)
                } else {
                    decisionHandler(WKNavigationActionPolicy.cancel)
                }
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.uiDelegate = context.coordinator
        uiView.navigationDelegate = context.coordinator
        
        // スワイプでの画面遷移
        uiView.allowsBackForwardNavigationGestures = true
        
        let url = URL(string: urlString)!
        let request = URLRequest(url: url)
        uiView.load(request)
    }
}
```

</details>

## 具体例
URLの文字列を引数にとって、以下のように使用します。
```swift: WebViewExample.swift
import SwiftUI

struct WebViewExample: View {
    var body: some View {
        WebView(urlString: "https://abc.xyz/")
    }
}
```

## 実行結果
無事にWebページが表示されました。
![swiftui_webview](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/1849869e-7ced-e64b-dc41-38ea725898b6.png)


# Jetpack Compose
AndroidViewという、従来型のView要素をComposeで扱えるようにするコンポーザブルがあり、それでWebViewを表示するということでした。

## Webの表示
AndroidViewのWebViewを使って、汎用的なコンポーザブルを作ります。
<details><summary>最小限のコードはこちら</summary>

```kotlin: ComposeWebView.kt
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView

@Composable
fun ComposeWebView(
    modifier: Modifier = Modifier,
    url: String
) {
    AndroidView(
        modifier = modifier,
        factory = {
            WebView(it)
        },
        update = { webView ->
            webView.webViewClient = WebViewClient()
            webView.loadUrl(url)
        }
    )
}
```
</details>

戻るボタンの操作や、進行バーの表示、JavaScriptへの対応なども追加したい場合は、いろいろ追記する必要があります。以下がそれを追加した実装です。

<details><summary>応用的なコードはこちら</summary>

```kotlin:ComposeWebView.kt
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch

@Composable
fun ComposeWebView(
    modifier: Modifier = Modifier,
    url:String,
    onBack: (webView:WebView?) -> Unit = {},
    initSettings: (webSettings: WebSettings?) -> Unit = {},
    onProgressChange: (progress:Int)->Unit = {},
    onReceivedError: (error: WebResourceError?) -> Unit = {},
){
    // プログレスバーを表示したいときに使う
    val webViewChromeClient = object:WebChromeClient(){
        override fun onProgressChanged(view: WebView?, newProgress: Int) {
            onProgressChange(newProgress)
            super.onProgressChanged(view, newProgress)
        }
    }
    // 画面表示や通信に使う
    val webViewClient = object: WebViewClient(){
        override fun onPageStarted(view: WebView?, url: String?,
                                   favicon: Bitmap?) {
            super.onPageStarted(view, url, favicon)
            onProgressChange(-1)
        }
        override fun onPageFinished(view: WebView?, url: String?) {
            super.onPageFinished(view, url)
            onProgressChange(100)
        }
        override fun shouldOverrideUrlLoading(
            view: WebView?,
            request: WebResourceRequest?
        ): Boolean {
            if(null == request?.url) return false
            val showOverrideUrl = request.url.toString()
            try {
                if (!showOverrideUrl.startsWith("http://")
                    && !showOverrideUrl.startsWith("https://")) {
                    Intent(Intent.ACTION_VIEW, Uri.parse(showOverrideUrl)).apply {
                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                        view?.context?.applicationContext?.startActivity(this)
                    }
                    return true
                }
            } catch (e:Exception){
                return true
            }
            return super.shouldOverrideUrlLoading(view, request)
        }

        override fun onReceivedError(
            view: WebView?,
            request: WebResourceRequest?,
            error: WebResourceError?
        ) {
            super.onReceivedError(view, request, error)
            onReceivedError(error)
        }
    }

    var webView:WebView? = null
    val coroutineScope = rememberCoroutineScope()

    AndroidView(modifier = modifier,factory = { ctx ->
        WebView(ctx).apply {
            this.webViewClient = webViewClient
            this.webChromeClient = webViewChromeClient
            initSettings(this.settings)
            // JavaScriptの使用の許可
            this.settings.javaScriptEnabled = true
            webView = this
            loadUrl(url)
        }
    })

    BackHandler {
        coroutineScope.launch {
            onBack(webView)
        }
    }
}
```
</details>

なお、Previewだと[WebView.settingsにアクセスできない](https://github.com/google/accompanist/issues/1326#:~:text=view%20classes%20aren%27t%20supported%20as%20part%20of%20preview)ので、応用的なコードの場合にPreviewではエラーが出ますが、ビルドには問題ありません。


## 具体例

Webページを表示するには、まず```AndroidManifest.xml```にインターネットの使用の許可を追記します。applicationタグの直前に書きました。
参考：[パーミッション項目一覧](https://developer.android.com/reference/android/Manifest.permission.html)
```xml: AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
```

続いて、Webビューを呼び出すコンポーザブルをつくります。
```kotlin: WebViewExample.kt
@Composable
fun WebViewExample(modifier: Modifier = Modifier) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        ComposeWebView(url = "https://abc.xyz/")
    }
}
```

## 実行結果

こちらでも、無事にWebページが表示されました。
![jetpack_compose_webview](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/69d9fea0-c1ef-d2fa-95b9-824100e4c01f.png)


# まとめ
**Webビュー**
対応関係を再掲します。
【SwiftUI】WKWebViewをラップして使う
【JetpackCompose】AndroidViewのWebViewを使う

どちらの場合でも、従来型のWebビューをラップして使用する方法が定番のようでした。ゆくゆくはSwiftUIネイティブ、JetpackComposeネイティブなWebビューが登場するかもしれません。そうしたらもっと楽に実装できるので楽しみです。

# 参考
【SwiftUI】
[wiii_naさんの記事](https://qiita.com/wiii_na/items/36123cf901839a8038e2)
[sh0さんの記事](https://zenn.dev/dd_sho/articles/f5bbee06133063)
[yururiworkさんの記事](https://www.yururiwork.net/archives/1550)
[hfoasi8fje3さんの記事](https://www.hfoasi8fje3.work/entry/2020/12/29/%E3%80%90SwiftUI%E3%80%91WKWebView%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B)
[How do I implement a WebView (WebKit) in SwiftUI? (Apple Developer Forum)](https://developer.apple.com/forums/thread/117348)

【Jetpack Compose】
[katzさんの記事](https://zenn.dev/kaleidot725/articles/2021-11-13-jc-webview-detaiils)
[TheMelodyさんのGist](https://gist.github.com/TheMelody/6ec3fcba6d57f544465651acc480ff39)
[Permissions webview android (StackOverflow)](https://stackoverflow.com/questions/51090531/permissions-webview-android)
[WebViewClient・WebChromeClientの違いついて(TechForContributionさん)
](https://techforcontribution.hatenablog.com/entry/2017/04/19/201759)[How to implement javascript interface with Webview, when using Jetpack Compose? (StackOverflow)](https://stackoverflow.com/questions/69372880/how-to-implement-javascript-interface-with-webview-when-using-jetpack-compose)