ห่อ WebView ใน Compose

หากต้องการใช้ WebView ใน Jetpack Compose คุณต้องรวมไว้ใน AndroidView คู่มือนี้อธิบายกรณีการใช้งานที่พบบ่อยและวิธีรองรับกรณีการใช้งานเหล่านั้นใน 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 และไม่แนะนำให้อินสแตนซ์มีอายุการใช้งานนานกว่าวงจรของ 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 ใน Compose เมื่อวาง 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 จะอัปเดตโดยอัตโนมัติได้โดยไม่ต้องโหลดหน้าซ้ำหากจัดการอย่างถูกต้อง

หากคุณเป็นเจ้าของเนื้อหาหน้าเว็บ ให้จัดการการค้นหาสื่อ 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 จะทริกเกอร์ Callback ที่เฉพาะเจาะจงใน WebChromeClient คุณต้องจัดการ Callback เหล่านี้และตรวจสอบว่ามีการให้สิทธิ์รันไทม์ของ 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 เพื่อทำความเข้าใจวิธีเลือกแนวทางที่ถูกต้องสำหรับกรณีการใช้งานของคุณ (เช่น การเรียกดูหรือการตรวจสอบสิทธิ์)

แหล่งข้อมูลเพิ่มเติม