ห่อ WebView ใน Compose

หากต้องการใช้ WebView ใน Jetpack Compose คุณต้องห่อด้วย AndroidView คู่มือนี้อธิบาย Use Case ที่พบบ่อยและวิธีรองรับ Use Case เหล่านี้ใน Compose

รวม WebView ไว้กับ AndroidView

หากต้องการใช้ WebView ใน Compose ให้ครอบด้วย AndroidView ดังนี้

@Composable
fun SimpleWebView(
    initialUrl: String,
    modifier: Modifier = Modifier
) {
    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { context ->
            WebView(context).apply {
                webViewClient = WebViewClient()
                settings.javaScriptEnabled = true
                loadUrl(initialUrl)			
            }
        }
    )
}

วิธีนี้ใช้ได้กับการแสดง URL อย่างง่ายภายในแอป แต่WebViewจัดการ วงจรสถานะที่ซับซ้อนซึ่งแยกจากวงจร Android View และวงจร Compose การผสานรวม Compose อาจทำให้เกิดWebViewสถานการณ์ที่ซับซ้อนซึ่งส่งผลให้เกิดข้อบกพร่องที่แก้ไขได้ยาก ส่วนต่อไปนี้ อธิบายกรณีการใช้งานที่อาจต้องมีการจัดการเฉพาะเพื่อรองรับฟีเจอร์เหล่านั้น

คงสถานะ WebView

การจัดการการเปลี่ยนแปลงการกำหนดค่าและการนำทางใน Compose เป็นเรื่องที่ท้าทายเนื่องจาก WebView เป็น View รุ่นเดิมที่เชื่อมโยงกับโฮสต์ Activity และไม่แนะนำให้ใช้อินสแตนซ์ของ View นานกว่าวงจรของ Activity

ดังนั้น วิธีมาตรฐานในการคงสถานะของ WebView คือการอนุญาตให้ทำลายและสร้างอินสแตนซ์ WebView ใหม่พร้อมกับ Activity คุณ สามารถคงประวัติการนำทางภายในและสถานะการเลื่อนไว้ด้วยตนเองได้โดยใช้ Bundle

@Composable
fun PersistentWebView(url: String) {
    val webViewStateBundle = rememberSaveable { Bundle() }

    AndroidView(
        factory = { context ->
            WebView(context).apply {
                webViewClient = WebViewClient()
                settings.javaScriptEnabled = true

                // Restore the state and history
                if (webViewStateBundle.containsKey("WEBVIEW_STATE")) {
                    restoreState(webViewStateBundle.getBundle("WEBVIEW_STATE")!!)
                } else {
                    loadUrl(url)
                }
            }
        },
        onRelease = { releasedWebView ->
            // Save navigation history before the instance is destroyed
            val bundle = Bundle()
            releasedWebView.saveState(bundle)
            webViewStateBundle.putBundle("WEBVIEW_STATE", bundle)
        },
        modifier = Modifier.fillMaxSize()
    )
}

จัดการการนำทางย้อนกลับ

เมื่อ WebView มีประวัติการนำทาง ท่าทางสัมผัสสำหรับย้อนกลับของระบบควรนำทาง ย้อนกลับภายใน WebView แทนที่จะออกจากหน้าจอ

ใช้ Compose BackHandler API เพื่อสกัดกั้นเหตุการณ์ย้อนกลับของระบบ และ เรียกใช้ฟังก์ชัน WebView goBack()

// ...
@Composable
fun BackNavigationDemoScreen(onBack: () -> Unit) {
    // Hold a reference to the WebView to check its history state
    var webViewReference by remember { mutableStateOf<WebView?>(null) }

    // Intercept the system back press if the WebView has history
    BackHandler(enabled = true) {
        val webView = webViewReference
        if (webView != null && webView.canGoBack()) {
            webView.goBack() // Go back in history
        } else {
            onBack() // Exit screen
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Back Navigation Demo") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
    ) { padding ->
        Column(modifier = Modifier.fillMaxSize().padding(padding)) {
            AndroidView(
                modifier = Modifier.fillMaxSize(),
                factory = { context ->
                    WebView(context).apply {
                        settings.javaScriptEnabled = true

                        // Keeps link navigations internal to the WebView instead of opening Chrome
                        webViewClient = WebViewClient() 

                        loadUrl("https://developer.android.com")
                        webViewReference = this
                    }
                },
                onRelease = {
                    webViewReference = null
                }
            )
        }
    }
}

การใช้งานนี้จะให้ลักษณะการทำงานของการนำทางแบบเบราว์เซอร์

การเลื่อนที่ฝังไว้

ระบบไม่รองรับการเลื่อนที่ซ้อนกันเมื่อใช้ WebView ในฟีเจอร์ช่วยเขียน เมื่อวาง WebView ภายในคอนเทนเนอร์ Compose ที่เลื่อนได้ เช่น LazyColumn WebView อาจใช้ท่าทางสัมผัสการเลื่อนทั้งหมด เนื่องจาก WebView อาศัยเครื่องมือการแสดงผลภายในของตัวเอง การซ้อนกับ LazyColumn จึงยังทำงานไม่ถูกต้องในขณะนี้

หากต้องการติดตามความคืบหน้าของการรองรับการเลื่อนอย่างเป็นทางการที่ซ้อนกันสำหรับ WebView โปรดดูปัญหานี้

เลย์เอาต์แบบไร้ขอบและส่วนที่เว้นไว้ในหน้าต่าง

เมื่อใช้เลย์เอาต์แบบขอบจรดขอบ WebView เนื้อหาอาจปรากฏใต้แถบระบบ เช่น แถบสถานะ คุณใช้ตัวปรับแต่ง windowInsetsPadding เพื่อ ย้ายทั้ง WebView ไปยังพื้นที่ปลอดภัยได้

@Composable
fun EdgeToEdgeDemo(url: String) {
    AndroidView(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(WindowInsets.systemBars),
        factory = { context ->
            WebView(context).apply {
                loadUrl(url)
            }
        }
    )
}

ดูข้อมูลเพิ่มเติมเกี่ยวกับระยะขอบได้ที่ทำความเข้าใจระยะขอบของหน้าต่างใน WebView

ซิงค์ธีมแอปกับเนื้อหา WebView

เมื่อแอปพลิเคชันเปลี่ยนระหว่างโหมดสว่างและโหมดมืด WebViewเนื้อหาจะ อัปเดตโดยอัตโนมัติได้โดยไม่ต้องโหลดหน้าเว็บซ้ำหากจัดการอย่างถูกต้อง

หากคุณเป็นเจ้าของเนื้อหาหน้าเว็บ ให้ซิงค์สีกับธีมของแอปโดย จัดการ Media Query prefers-color-scheme เพื่อให้หน้าเว็บปรับ ให้เข้ากับธีมที่เลือก

หากต้องการเปิดใช้องค์ประกอบเนทีฟ เช่น เมนูแบบเลื่อนลงและป๊อปอัป เพื่อตรวจหาและจับคู่ธีมแอป ให้ใช้ธีมสไตล์ DayNight กับ Activity.

<resources>

    <!-- ...
    <!-- Use a DayNight theme in your manifest to handle both modes automatically -->
    <style name="Theme.Webviewdemo.DayNight" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

@Composable
fun ThemeSyncDemo(onBack: () -> Unit) {
    val context = LocalContext.current
    AndroidView(
        modifier = Modifier.fillMaxSize(),
        factory = { _ ->
            WebView(context).apply {
                settings.javaScriptEnabled = true
                webViewClient = WebViewClient()
                val html = """
                            <html>
                            <head>
                                // ...


                                    @media (prefers-color-scheme: dark) {
                                        body {
                                            background-color: #212121;
                                            color: #ffffff;
                                        }
                                        select {
                                            border-color: #BB86FC;
                                            background: #212121;
                                            color: #ffffff;
                                        }
                                    }
                                </style>
                            </head>
                            // ...
                            </html>
                        """.trimIndent()
                loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
            }
        }
    )
} 

หากหน้าเว็บไม่มีธีมมืดหรือคุณไม่ได้เป็นเจ้าของเนื้อหาเว็บ การทำให้มืดแบบอัลกอริทึมอาจช่วยบังคับใช้ธีมมืดได้ เว็บไซต์สมัยใหม่ที่ มีโหมดมืดอยู่แล้วจะไม่สนใจอัลกอริทึมนี้และใช้สไตล์ในตัวของตัวเอง แทน

จัดการสิทธิ์ของเว็บใน Compose

เมื่อหน้าเว็บขอสิทธิ์เข้าถึงฮาร์ดแวร์หรือข้อมูล (เช่น กล้อง ไมโครโฟน หรือตำแหน่ง) WebView จะทริกเกอร์การเรียกกลับที่เฉพาะเจาะจงใน WebChromeClient คุณต้องจัดการการเรียกกลับเหล่านี้และตรวจสอบว่ามีการให้สิทธิ์รันไทม์ของ Android ที่เกี่ยวข้องแล้ว

จัดการสิทธิ์เข้าถึงกล้องและไมโครโฟน

เมื่อหน้าเว็บขอสิทธิ์เข้าถึงกล้องหรือไมโครโฟน (เช่น WebRTC หรือ การบันทึกวิดีโอ) WebViewจะโทรWebChromeClient.onPermissionRequest

อย่างไรก็ตาม ก่อนที่จะเรียกใช้ grant() คุณต้องขอสิทธิ์รันไทม์ของ Android ต่อไปนี้

  • Manifest.permission.CAMERA
  • Manifest.permission.RECORD_AUDIO

ก่อนอื่น ให้กำหนดตัวแฮนเดิลสิทธิ์สำหรับ WebView ที่ติดตามPermissionRequestที่ขอจาก WebView ดังนี้

class WebViewPermissionHandler(
    private val launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
) {
    var pendingRequest by mutableStateOf<PermissionRequest?>(null)
        private set

    fun handleRequest(request: PermissionRequest) {
        val isTrustedOrigin = request.origin.host == "www.trusted-domain.com" || request.origin.host == "app.local" // Always verify the origin before granting request


        if (!isTrustedOrigin) {
            Log.w("WebViewPermission", "Blocked and denied permission request from untrusted origin: ${request.origin.host}")
            request.deny()
            return
        }

        val androidPermissions = mutableListOf<String>()
        request.resources.forEach { resource ->
            when (resource) {
                PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPermissions.add(Manifest.permission.CAMERA)
                PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPermissions.add(Manifest.permission.RECORD_AUDIO)
            }
        }

        // Save the request and launch the Android system dialog
        pendingRequest = request
        launcher.launch(androidPermissions.toTypedArray())
    }

    fun onResult(results: Map<String, Boolean>) {
        val allGranted = results.values.all { it }
        Log.d("WebViewPermission", "Kotlin: All permissions granted? $allGranted")

        if (allGranted) {
            pendingRequest?.grant(arrayOf("/* list of permissions */"))
        } else {
            pendingRequest?.deny()
        }
        pendingRequest = null
    }
}

จากนั้นสร้าง Composable ที่จดจำ WebViewPermissionHandler ใช้ rememberLauncherForActivityResult เพื่อขอสิทธิ์

@Composable
fun rememberWebViewPermissionHandler(): WebViewPermissionHandler {
    val handlerState = remember { mutableStateOf<WebViewPermissionHandler?>(null) }
    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { results ->
        handlerState.value?.onResult(results)
    }
    return remember {
        WebViewPermissionHandler(launcher).also { handlerState.value = it }
    }
}

จัดการสิทธิ์จาก Callback ของ onPermissionRequest ซึ่งจะเปิดตัว ตัวเรียกใช้สิทธิ์:

@Composable
fun WebViewPermissionScreen() {
    val permissionHandler = rememberWebViewPermissionHandler()

    AndroidView(
        factory = { context ->
            WebView(context).apply {
                settings.javaScriptEnabled = true

                webChromeClient = object : WebChromeClient() {
                    override fun onPermissionRequest(request: PermissionRequest) {
                        // Simply delegate to the handler
                        permissionHandler.handleRequest(request)
                    }
                }

		   // load a web page that needs permissions
            }
        },
        modifier = Modifier.fillMaxSize()
    )
}

ทางเลือกแทน WebView ที่ฝัง

หากต้องการหลีกเลี่ยงการฝัง WebView Android มีตัวเลือกอื่นๆ สำหรับ การแสดงเนื้อหาเว็บ เช่น แท็บที่กำหนดเองของ Chrome ดูใช้เนื้อหาเว็บ ภายในแอป Android เพื่อทําความเข้าใจวิธีเลือกแนวทางที่ถูกต้อง สําหรับกรณีการใช้งานของคุณ (เช่น การเรียกดูหรือการตรวจสอบสิทธิ์)