Cómo unir una WebView en Compose

Para usar un WebView en Jetpack Compose, debes incluirlo en un AndroidView. En esta guía, se explican los casos de uso comunes y cómo admitirlos en Compose.

Cómo unir un WebView con AndroidView

Para usar un WebView en Compose, envuélvelo con un 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)			
            }
        }
    )
}

Esto funciona para mostrar una URL simple dentro de tu app. Sin embargo, WebView se ocupa de ciclos de vida de estados complejos que son independientes del ciclo de vida de Android View y del ciclo de vida de Compose. La integración de Compose puede generar situaciones WebView complejas que provoquen errores difíciles de corregir. En las siguientes secciones, se describen los casos de uso que pueden requerir un tratamiento específico para admitir esas funciones.

Cómo conservar el estado de WebView

Manejar los cambios de configuración y la navegación en Compose es un desafío porque WebView es un View heredado vinculado a su Activity host, y no se recomienda que su instancia sobreviva al ciclo de vida de Activity.

Por lo tanto, la forma estándar de conservar el estado de un WebView es permitir que las instancias de WebView se destruyan y se vuelvan a crear junto con el Activity. Puedes conservar manualmente su historial de navegación interno y el estado de desplazamiento con un 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()
    )
}

Cómo controlar la navegación hacia atrás

Cuando un WebView tiene un historial de navegación, el gesto de atrás del sistema debe navegar hacia atrás dentro del WebView en lugar de salir de la pantalla.

Usa la API de Compose BackHandler para interceptar el evento de sistema Atrás y llamar a la función 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
                }
            )
        }
    }
}

Esta implementación proporciona un comportamiento de navegación similar al de un navegador.

Desplazamiento anidado

El desplazamiento anidado no se admite fácilmente cuando se usa WebView en Compose. Cuando colocas un WebView dentro de un contenedor de Compose desplazable, como un LazyColumn, el WebView puede consumir todos los gestos de desplazamiento. Dado que WebView se basa en su propio motor de renderización interno, anidarlo con LazyColumn no funciona correctamente en este momento.

Para hacer un seguimiento del progreso de la compatibilidad oficial con el desplazamiento anidado para WebView, consulta este problema.

Diseños de borde a borde y ajustes de ventana

Cuando se usan diseños de borde a borde, el contenido de WebView puede aparecer debajo de las barras del sistema, como la barra de estado. Puedes usar el modificador windowInsetsPadding para insertar todo el WebView en el área segura:

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

Para obtener más información sobre las inserciones, consulta Información sobre las inserciones de ventanas en WebView.

Sincroniza el tema de la app con el contenido de WebView

Cuando la aplicación cambia entre el modo claro y el modo oscuro, el contenido de WebView se puede actualizar automáticamente sin que se vuelva a cargar la página si se controla correctamente.

Si eres propietario del contenido de la página web, para sincronizar los colores con el tema de la app, controla la consulta de medios prefers-color-scheme para asegurarte de que tu página web se adapte al tema seleccionado.

Para permitir que los elementos nativos, como los menús desplegables y las ventanas emergentes, detecten y coincidan con el tema de tu app, aplica un tema de estilo DayNight a tu 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)
            }
        }
    )
} 

Si la página web no tiene un tema oscuro o si no eres propietario del contenido web, el oscurecimiento algorítmico puede ayudar a forzar un tema oscuro. Los sitios web modernos que ya tienen el modo oscuro ignoran este algoritmo y, en su lugar, usan sus propios estilos integrados.

Cómo controlar los permisos web en Compose

Cuando una página web solicita acceso a hardware o datos (por ejemplo, cámara, micrófono o ubicación), WebView activa devoluciones de llamada específicas en su WebChromeClient. Debes controlar estas devoluciones de llamada y asegurarte de que se otorguen los permisos de tiempo de ejecución de Android correspondientes.

Cómo controlar los permisos de la cámara y el micrófono

Cuando una página web solicita acceso a la cámara o al micrófono (por ejemplo, WebRTC o grabación de video), WebView llama a WebChromeClient.onPermissionRequest.

Sin embargo, antes de llamar a grant(), debes solicitar los siguientes permisos de tiempo de ejecución de Android:

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

Primero, define un controlador de permisos para WebView que realice un seguimiento del PermissionRequest solicitado desde 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
    }
}

A continuación, crea un elemento componible que recuerde el WebViewPermissionHandler. Usa rememberLauncherForActivityResult para solicitar permisos:

@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 }
    }
}

Controla el permiso desde la devolución de llamada onPermissionRequest. Se iniciará el selector de permisos:

@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()
    )
}

Alternativa a un WebView incorporado

Si prefieres evitar la incorporación de WebView, Android proporciona otras opciones para mostrar contenido web, como las pestañas personalizadas de Chrome. Consulta Cómo usar contenido web en tu app para Android para comprender cómo elegir el enfoque correcto para tus casos de uso (como la navegación o la autenticación).