KMPwright

Compose

Host an automated WebView in Jetpack Compose with the kmpwright-compose module, or share one WebView across Android and iOS in Compose Multiplatform with AndroidView and UIKitView.

import com.bernaferrari.kmpwright.compose.KmpwrightWebView
import com.bernaferrari.kmpwright.compose.rememberKmpwrightController

@Composable
fun Checkout() {
    val controller = rememberKmpwrightController()
    val scope = rememberCoroutineScope()

    Column {
        Text(controller.title ?: "Loading…")
        if (controller.isLoading) LinearProgressIndicator(Modifier.fillMaxWidth())
        Button(onClick = {
            scope.launch {
                val page = controller.page ?: return@launch
                page.getByLabel("Email").fill("me@example.com")
                page.getByRole("button", name = "Continue").click()
            }
        }) { Text("Autofill") }

        KmpwrightWebView(
            controller = controller,
            modifier = Modifier.fillMaxSize(),
            startUrl = "https://example.com/checkout",
        )
    }
}

kmpwright-compose is an Android-only Jetpack Compose library. It wraps an Android WebView in AndroidView, connects a Browser to it, and exposes the page and its navigation state as Compose state. For Compose Multiplatform (Android and iOS from shared code), host the WebView yourself as shown in Compose Multiplatform.

Installation

// build.gradle.kts (Android app module)
dependencies {
    implementation("io.github.bernaferrari:kmpwright-compose:<version>")
}

The module depends on kmpwright-core through api, so you don't add core separately. It needs minSdk 26 and pulls in Compose UI and activity-compose (for BackHandler). See Installation for the current version.

KmpwrightWebView

@Composable
fun KmpwrightWebView(
    controller: KmpwrightWebViewController,
    modifier: Modifier = Modifier,
    startUrl: String? = null,
    captureBackPresses: Boolean = true,
    onCreated: (WebView) -> Unit = {}
)
ParameterDefaultMeaning
controllerState holder from rememberKmpwrightController().
modifierModifierApplied to the AndroidView.
startUrlnullLoaded once per attached WebView, and again if it changes. null leaves the page blank. Failures go to controller.lastError.
captureBackPressestrueWhile the WebView can go back, the system back gesture navigates its history instead of leaving the screen.
onCreated{}Called with the new WebView before KMPwright attaches. Clients you install here keep receiving every callback, because KMPwright delegates to them.

The composable creates the WebView with JavaScript and DOM storage enabled, file and content access disabled, and MIXED_CONTENT_NEVER_ALLOW. Change settings in onCreated if you need to:

KmpwrightWebView(
    controller = controller,
    onCreated = { webView ->
        webView.settings.textZoom = 100
        webView.webViewClient = MyWebViewClient()   // still receives callbacks
    },
)

When the composable leaves the composition or its controller is replaced, the old controller detaches, its Browser is disposed and its WebView is destroyed. page, url and title become null, and history/loading flags reset. Previously retained pages reject new operations because they are closed.

The startUrl navigation belongs to the composition. Changing it cancels the old wait before starting the new one; removing the view cancels the navigation too. Use controller.goto() inside your own coroutine when a workflow needs to await the navigation result.

If the WebView's renderer process crashes or is killed, the controller sets lastError, and the composable creates a new WebView with a new Page. Code that held the old controller.page must read it again.

rememberKmpwrightController

@Composable
fun rememberKmpwrightController(config: KmpwrightConfig = KmpwrightConfig()): KmpwrightWebViewController

config is read once. Changing it later doesn't recreate the controller. config.userAgent is applied to the WebView when it attaches. The controller is kept with remember, so it doesn't survive configuration changes such as rotation, unless your activity handles them itself.

KmpwrightWebViewController

Use every member on the main thread. Properties are Compose snapshot state, so reading them in a composable recomposes it when they change.

MemberTypeMeaning
pagePage?The attached page. null until the WebView attaches and after it is released. Use it for the full Page API.
urlString?Committed URL.
titleString?Document title.
isLoadingBooleanA navigation is in progress.
canGoBackBooleanHistory has a previous entry.
canGoForwardBooleanHistory has a next entry.
lastErrorThrowable?Last failure of a fire-and-forget call, or a renderer crash.
clearError()UnitSets lastError to null.
goto(url)suspend, NavigationResultSame as page.goto(url). Throws KmpwrightException if no WebView is attached.
evaluate(script)suspend, StringSame as page.evaluate(script).value: strings as is, other values as JSON.
loadUrl(url)UnitFire-and-forget goto. A result with ok = false is reported through lastError.
reload()UnitFire-and-forget reload.
goBack()UnitFire-and-forget history back.
goForward()UnitFire-and-forget history forward.

The state comes from page.androidWebViewState(), a StateFlow<AndroidWebViewState> in kmpwright-core that also reports progress and rendererGone. Collect it yourself if you host a WebView without this module.

// Toolbar driven by controller state
Row {
    IconButton(onClick = controller::goBack, enabled = controller.canGoBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null) }
    IconButton(onClick = controller::goForward, enabled = controller.canGoForward) { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) }
    IconButton(onClick = controller::reload) { Icon(Icons.Default.Refresh, null) }
    Text(controller.url.orEmpty(), maxLines = 1)
}
controller.lastError?.let { error ->
    Snackbar(action = { TextButton(onClick = controller::clearError) { Text("Dismiss") } }) { Text(error.message.orEmpty()) }
}

KmpwrightDebugOverlay

Draws rectangles over the WebView, for example element boxes from locator.boundingBox().

var boxes by remember { mutableStateOf(emptyList<Rectangle>()) }

Box(Modifier.fillMaxSize()) {
    KmpwrightWebView(controller, Modifier.fillMaxSize())
    KmpwrightDebugOverlay(highlights = boxes, modifier = Modifier.fillMaxSize())
}

LaunchedEffect(controller.page) {
    val page = controller.page ?: return@LaunchedEffect
    boxes = page.getByRole("button").all().mapNotNull { it.boundingBox() }
}
ParameterDefaultMeaning
highlightsRectangles in CSS pixels, relative to the viewport.
modifierModifierGive it the same size and position as the WebView so both share an origin.
cssPixelScale1fdp per CSS pixel. 1 for pages with width=device-width and no pinch zoom. Otherwise multiply by the page zoom (visualViewport.scale).
colorColor.RedStroke color. The fill is the same color at 25% alpha.

Boxes don't follow scrolling on their own. Measure again after the page scrolls.

Compose Multiplatform

kmpwright-compose doesn't target iOS. In a Compose Multiplatform app, create the native web view in platform code, wrap it in a PlatformContext, and show it with AndroidView or UIKitView. The sample app (kmpwright-example-shared) does this, and the code below follows it.

// commonMain
@Composable expect fun rememberPlatformContext(): PlatformContext
expect fun disposePlatformContext(context: PlatformContext)
@Composable expect fun WebViewHost(platformContext: PlatformContext, modifier: Modifier = Modifier)

@Composable
fun BrowserScreen() {
    val platformContext = rememberPlatformContext()
    val browser = remember(platformContext) { Browser(platformContext) }
    val page = remember(browser) { browser.page() }
    DisposableEffect(browser) {
        onDispose {
            browser.dispose()                       // non-suspending teardown for onDispose
            disposePlatformContext(platformContext)
        }
    }
    LaunchedEffect(page) { page.goto("https://example.com") }
    WebViewHost(platformContext, Modifier.fillMaxSize())
}
// androidMain
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun rememberPlatformContext(): PlatformContext {
    val context = LocalContext.current
    return remember(context) {
        val webView = WebView(context).apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            settings.allowFileAccess = false
            settings.allowContentAccess = false
            settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
        }
        AndroidPlatformContext(context = context, webView = webView)
    }
}

actual fun disposePlatformContext(context: PlatformContext) {
    (context as AndroidPlatformContext).webView?.apply {
        (parent as? ViewGroup)?.removeView(this)
        destroy()
    }
}

@Composable
actual fun WebViewHost(platformContext: PlatformContext, modifier: Modifier) {
    val webView = requireNotNull((platformContext as AndroidPlatformContext).webView)
    AndroidView(
        modifier = modifier,
        factory = {
            // The same WebView can move between layouts; detach it first.
            (webView.parent as? ViewGroup)?.removeView(webView)
            webView
        },
        onRelease = { (it.parent as? ViewGroup)?.removeView(it) },
    )
}

Things to keep in mind:

  • One web view, one Browser. The web view is created once and lives as long as the composition (remember). The layout only reparents it. That way the page, its history and its automation state survive layout changes, such as moving between phone and tablet layouts.
  • Construct on the main thread. On Android, Browser(AndroidPlatformContext(...)) must be created on the main thread. Composition already runs there.
  • Dispose from onDispose with dispose(). Browser.dispose() and Page.dispose() don't suspend, so they are safe in onDispose. Use close() when you can suspend.
  • Storage on iOS. WKWebView() uses the default persistent website data store. If you pass no web view and no data store, KMPwright uses a non-persistent store. See Storage.
  • Native layout wins. The host layout sizes the web view, so setViewportSize can be overridden by your Compose layout. See Emulation.

On this page