Skip to content

roywatson/cmp_webview

Repository files navigation

Compose Multiplatform: WebView

A minimal example of embedding a WebView in a Compose Multiplatform application targeting Android, iOS, and JVM desktop simultaneously.

Each platform has its own native web rendering engine — Android's WebView, iOS's WKWebView, and JCEF (the Chromium Embedded Framework) on the JVM desktop — and each comes with its own integration requirements. The goal here is to make those requirements as visible as possible by keeping everything else out of the way. There is no architecture framework, no dependency injection, no theming system beyond a standard MaterialTheme. What remains is the WebView integration itself, which is what the project is about.

The app is a minimal browser: a URL bar, back/forward/refresh buttons, and the WebView filling the remaining space. The default URL points to a Kotlin/Wasm Compose site, which happens to expose the trickiest compatibility issues on Android and iOS. Those workarounds are documented in the code where they appear.


Running the project

Clone the repository and open it in Android Studio or IntelliJ IDEA.

Android and iOS

Both targets run without any special setup beyond the standard Kotlin Multiplatform toolchain. For iOS, Xcode must be installed and a simulator or device must be available. Android Studio's device manager handles the Android side.

Desktop (JVM)

The desktop target uses JCEF — the Chromium Embedded Framework bundled with the JetBrains Runtime (JBR). JCEF is not part of a standard JDK. The app will not launch on a stock Oracle or OpenJDK JVM.

The build system searches for a JBR with JCEF support automatically, checking the standard locations where IntelliJ IDEA and Android Studio install their runtimes. If a suitable JBR is found, it is used automatically. If not, the desktop target will still compile but will throw an exception on startup when it attempts to initialise JCEF.

To run the desktop target:

./gradlew :desktopApp:run

If the build system cannot locate a JBR automatically, set the JBR_HOME environment variable to the root of a JBR installation that includes jmods/jcef.jmod.

To install the correct JBR, open File → Project Structure → SDKs in IntelliJ IDEA or Android Studio and download any JBR 17 variant that includes JCEF. The IDE's SDK manager lists these clearly.

As with the custom titlebar project, this is only a concern during development. When you package the application for distribution using ./gradlew :desktopApp:packageDmg (or packageMsi / packageDeb), the Compose desktop Gradle plugin bundles JBR into the distributable. End users receive a self-contained app — there is nothing for them to install.


Incorporating this into your project

The demo UI in App.kt — the URL bar and navigation buttons — is scaffolding. What you actually need is:

  • WebView.kt — the expect declaration of the WebView composable
  • WebViewNavigator.kt — the state class that bridges Compose state to native WebView commands
  • WebView.android.kt — the Android actual implementation
  • WebView.ios.kt — the iOS actual implementation
  • MainViewController.kt — the iOS entry point (if you do not already have one)
  • WebView.jvm.kt — the JVM desktop actual implementation

And the JCEF detection and extraction logic from both shared/build.gradle.kts and desktopApp/build.gradle.kts — specifically the findJbrHome() function and the ExtractModuleClasses task. These two files each contain their own copy of findJbrHome(). That duplication is intentional: keeping each build file self-contained avoids introducing a buildSrc module as a prerequisite for understanding the project. In your own project, extracting it to a shared location is the right call.

The usage pattern in your composable:

val navigator = rememberWebViewNavigator()

WebView(
    url = "https://your-url.com",
    navigator = navigator,
    modifier = Modifier.fillMaxSize()
)

// Drive navigation from anywhere in your UI:
Button(onClick = { navigator.goBack() }, enabled = navigator.canGoBack) { Text("Back") }
Button(onClick = { navigator.goForward() }, enabled = navigator.canGoForward) { Text("Forward") }
Button(onClick = { navigator.refresh() }) { Text("Refresh") }

// Observe the current URL:
Text(navigator.currentUrl)

No additional dependencies are required for Android or iOS — both use WebView APIs that ship with the platform. The INTERNET permission is required in AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

For the desktop target, add to desktopApp/build.gradle.kts:

implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)

The JCEF classes themselves are resolved at runtime from the JBR — no Maven dependency is needed.


What this demonstrates

The expect/actual pattern

WebView.kt declares a single expect composable:

@Composable
expect fun WebView(url: String, navigator: WebViewNavigator, modifier: Modifier = Modifier)

Each platform provides an actual implementation in its own source set. From the common code's perspective — and from your UI code's perspective — there is one WebView. The platform differences are completely contained within the actual files.

WebViewNavigator

Navigation commands (back, forward, refresh) and observable state (can-go-back, can-go-forward, current URL) need to flow between Compose and native WebView APIs that live outside the composition. WebViewNavigator handles this with Compose's own mutableStateOf:

  • canGoBack, canGoForward, and currentUrl are public readable state that the platform implementations write to after each page load. Your UI reads these directly.
  • goBack(), goForward(), and refresh() increment internal counter properties. Each platform implementation observes those counters in LaunchedEffect blocks and calls the native API when they change.

The counter approach is simpler than a Channel or SharedFlow for one-shot events and keeps the class free of coroutine dependencies while remaining fully observable by Compose. There are other valid approaches to this problem; this one keeps the state management visible and easy to follow.

Android implementation

JavaScript is enabled and domStorageEnabled is set to true — both are required for most modern web content. The "wv" marker is removed from the user agent string; many sites use this marker to detect embedded browsers and serve degraded or blocked responses.

The shouldOverrideUrlLoading override returns false unconditionally, telling Android to handle all URL loads within the WebView rather than handing them to the system browser. Depending on your requirements, you may want to intercept specific schemes or domains here instead.

Kotlin/Wasm compatibility: If your project does not host Kotlin/Wasm Compose content, you can remove the WEBGL_POLYFILL constant and the onPageStarted override entirely. If it does, two fixes are required:

  1. Android's WebView does not propagate the viewport height through the CSS html → body chain. Kotlin/Wasm Compose reads document.body.clientHeight for canvas sizing; without the fix the canvas is created at zero height and the screen is blank.
  2. Android's WebView disables the WEBGL_debug_renderer_info extension for privacy. Skiko (the graphics engine under Compose for Web) calls getParameter with that extension's constants during WebGL initialisation; without a polyfill, those calls return INVALID_ENUM and the render pipeline fails to start.

The polyfill is injected in onPageStarted — before any page script runs — and the initial loadUrl call is deferred until the first layout pass completes so that window.innerHeight is non-zero when the page's JavaScript executes.

SSL errors: The onReceivedSslError override calls handler.proceed(), which accepts all SSL certificates without validation. This is appropriate for development, testing, or endpoints whose certificates you control. In a production app you should inspect the error and call handler.cancel() for certificates you do not trust.

iOS implementation

WKWebView is embedded using UIKitView, the standard Compose Multiplatform mechanism for hosting a UIView inside a composable. The @Suppress("DEPRECATION") annotation covers the UIKitView import path — UIKitView moved from androidx.compose.ui.interop to androidx.compose.ui.viewinterop in CMP 1.7, and the new package's API requires wrapping interop options in UIKitInteropProperties. The old import remains functional and is the simpler option for a project of this scope.

Navigation state is synchronised using both didCommitNavigation and didFinishNavigation on the WKNavigationDelegate. Both are needed: didCommitNavigation fires when content first starts arriving, which covers back/forward navigations restored from the browser cache, while didFinishNavigation fires on full completion. Using only one of these leaves gaps in certain navigation scenarios.

SPA and pushState navigation: Standard WKNavigationDelegate callbacks do not fire for client-side navigation in single-page applications — when JavaScript calls history.pushState or history.replaceState, the URL changes but no native navigation event is raised. A small JavaScript snippet injected at document start wraps those history methods and the popstate event and posts a message back to Kotlin via WKScriptMessageHandler. The URL bar and navigation state are updated when that message arrives.

When the composable leaves the tree, a DisposableEffect removes the script message handler from the WKUserContentController. This is necessary because WKWebView retains a strong reference to registered message handlers; without removal, the handler and everything it captures would leak.

Desktop (JVM) implementation

JCEF is initialised lazily in a module-level val:

private val cefApp: CefApp by lazy { ... }

CEF initialisation is expensive and should happen exactly once. The lazy delegate ensures this regardless of how many WebView composables exist. The CefBrowser itself is created inside remember, so it lives exactly as long as the composable does.

The browser is hosted in a SwingPanel, which is Compose Multiplatform's mechanism for embedding Swing/AWT components. There is a subtle teardown ordering problem worth understanding: when the Compose window closes, the Skia rendering layer is disposed before the AWT component tree calls removeNotify() down to the JCEF browser panel. JCEF's cleanup path then triggers Container.remove()invalidate(), which propagates back up to Compose's interop layer — which is already gone. The fix is a JPanel subclass that overrides invalidate() and suppresses it once removeNotify() has started, breaking the propagation chain before it reaches Compose.

Navigation state is read from onLoadingStateChange and onLoadEnd on JCEF's CefLoadHandler. All Compose state writes are dispatched to the Swing event dispatch thread via SwingUtilities.invokeLater, since JCEF delivers its callbacks on its own internal thread.

On macOS, Chromium/JCEF may print startup warnings such as Unable to get gpu adapter, The device's GPU is not supported, or code-signing validation messages when running from Gradle or the IDE. These messages can appear even when the embedded browser is functioning normally. The code-signing warnings are typically a development-time artifact of running an unsigned JVM process and should disappear in a properly signed and notarized distributable.


Running the tests

The unit tests cover WebViewNavigator — the only class with logic that can be tested without a running native environment. The platform WebView implementations are thin wrappers around native APIs that require an Android emulator, iOS simulator, or JCEF desktop environment to exercise meaningfully.

./gradlew :shared:jvmTest

Project structure

shared/src/
  commonMain/
    App.kt                   # Demo UI — URL bar, nav buttons, WebView
    WebView.kt               # expect declaration
    WebViewNavigator.kt      # Navigation state and commands
  androidMain/
    WebView.android.kt       # AndroidView wrapping android.webkit.WebView
  iosMain/
    WebView.ios.kt           # UIKitView wrapping WKWebView
    MainViewController.kt    # iOS framework entry point
  jvmMain/
    WebView.jvm.kt           # SwingPanel wrapping a JCEF CefBrowser

androidApp/src/main/
  MainActivity.kt            # Android entry point

desktopApp/src/main/
  main.kt                    # Desktop entry point

Tech stack

Language Kotlin 2.3.21
UI framework Compose Multiplatform 1.11.0
Android min SDK 24 (Android 7.0)
Android target SDK 36
Web engine (Desktop) JCEF via JetBrains Runtime 17
JVM target 11

About

Before Roy Watson wrote a line of Android code, he was programming the propellant management system for NASA's Delta launch vehicles, building radiation detection software for Los Alamos and Sandia National Laboratories, and developing embedded vision systems for IBM's autonomous vehicle research. After 30+ years as a systems developer, he approaches Android differently than most.

Over the past 16 years he has specialized in Android, staying current with every major evolution of the platform: Kotlin, Jetpack Compose, and now Compose Multiplatform (KMP) for iOS, Desktop, and Web. Recent clients include the New York Public Library, Auddia, Bechtel, and Goodyear. When performance work requires dropping into C/C++, SIMD intrinsics, or custom memory management, he does not hand it off.

His personal published apps have accumulated 70,000+ Android downloads (4.5★) and 207,000+ iOS downloads (4.6★). He studied Physics and Mathematics at Purdue University and is a member of Mensa International.

This project is part of a series of public examples demonstrating current Android and Compose Multiplatform architecture and patterns. The series begins with Android Custom Theme and Compose Multiplatform Custom Theme, which build a complete custom theming system from first principles. Subsequent projects introduce Dependency Injection with Koin, DataStore Preferences, HTTP networking with Ktor, and Custom JVM Titlebars. This project adds cross-platform WebView support — a common requirement that involves more platform-specific complexity than it might appear from the outside.

Questions, corrections, or suggestions are welcome. Reach out through roywatson.app or directly at rwatson@roywatson.com.

Available for contract and full-time engagements — roywatson.app

roywatson.app · linkedin.com/in/roywatson3 · github.com/roywatson

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors