To use a WebView in Jetpack Compose, you must wrap it in an AndroidView.
This guide explains common use cases and how to support them in Compose.
Wrap a WebView with AndroidView
To use a WebView in Compose, wrap it with an 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) } } ) }
This works for showing a simple URL within your app. However, WebView deals
with complex state lifecycles that are separate from the Android View
lifecycle and Compose lifecycle. Integrating Compose can introduce complex
WebView scenarios that result in difficult bugs. The following sections
describe use cases that may need specific handling to support those features.
Persist WebView state
Handling configuration changes and navigation in Compose is challenging because
WebView is a legacy View bound to its host Activity, and it is
not recommended that its instance outlive the Activity lifecycle.
Therefore, the standard way to persist a WebView's state is by allowing
WebView instances to be destroyed and recreated along with the Activity. You
can manually persist its internal navigation history and scroll state using a
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() ) }
Handle back navigation
When a WebView has navigation history, the system back gesture should navigate
backward within the WebView rather than exiting the screen.
Use the Compose BackHandler API to intercept the system back event, and
call the WebView goBack() function:
// ... @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.qushouna.asia") webViewReference = this } }, onRelease = { webViewReference = null } ) } } }
This implementation provides browser-style navigation behavior.
Nested scrolling
Nested scrolling is not easily supported when using WebView in Compose. When
placing a WebView inside a scrollable Compose container, such as a
LazyColumn, the WebView may consume all scroll gestures.
Since WebView relies on its own internal rendering engine, nesting it with
LazyColumn does not currently work properly.
To track the progress of official nested scrolling support for WebView, see
this issue.
Edge-to-edge layouts and window insets
When using edge-to-edge layouts, WebView content may appear underneath system
bars such as the status bar. You can use the windowInsetsPadding modifier to
push the entire WebView into the safe area:
@Composable fun EdgeToEdgeDemo(url: String) { AndroidView( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars), factory = { context -> WebView(context).apply { loadUrl(url) } } ) }
For more information on insets, see Understand window insets in WebView.
Synchronize app theme with WebView content
When the application switches between light and dark mode, WebView content can
update automatically without a page reload if handled correctly.
If you own the web page content, to synchronize colors with the app's theme,
handle the media query prefers-color-scheme to make sure your web page adapts
to the selected theme.
To enable native elements like dropdowns and popups to detect and match your app
theme, apply a DayNight style theme to your 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) } } ) }
If the web page does not have a dark theme, or if you don't own the web content, algorithmic darkening may help force a dark theme. Modern websites that already have dark mode ignore this algorithm and use their own built-in styles instead.
Handle web permissions in Compose
When a web page requests hardware or data access (for example, camera,
microphone, or location), WebView triggers specific callbacks in its
WebChromeClient. You must handle these callbacks and ensure corresponding
Android runtime permissions are granted.
Handle camera and microphone permissions
When a web page requests camera or microphone access (for example, WebRTC or
video recording), WebView calls WebChromeClient.onPermissionRequest.
However, before calling grant(), you must request the following Android
runtime permissions:
Manifest.permission.CAMERAManifest.permission.RECORD_AUDIO
First, define a permission handler for WebView that keeps track of the
PermissionRequest requested from 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 } }
Next, create a composable that remembers the WebViewPermissionHandler. Use
rememberLauncherForActivityResult to request permissions:
@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 } } }
Handle the permission from the onPermissionRequest callback. This launches the
permission launcher:
@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() ) }
Alternative to an embedded WebView
If you prefer to avoid embedding WebView, Android provides other options for
displaying web content, like Chrome Custom Tabs. See Use web content
within your Android app to understand how to choose the correct approach
for your use cases (like browsing or authentication).