A powerful, flexible, and feature-rich WebView wrapper for Jetpack Compose and Compose Multiplatform (Android, iOS, Desktop, Web).
Supports Android, iOS, Desktop (JVM), and Web (JS) with a unified API.
- Documentation
- Features
- Requirements
- Installation
- Quick Start
- Usage
- API Reference
- Sample App
- Contributing
- License
📘 Full Documentation: https://parkwoocheol.github.io/compose-webview/
Visit the documentation site for comprehensive guides, API references, and advanced usage examples.
- Multiplatform Support: Supports Android, iOS, Desktop (CEF), and Web (JS).
- Jetpack Compose Integration: Seamlessly integrates native WebViews with Compose UI.
- Advanced JSBridge (Mobile Focused): A highly productive bridge for Android & iOS.
- Promise-based: JavaScript calls return Promises, allowing
awaitsyntax (no callback hell). - Type-Safe: Built-in Kotlinx Serialization support automatically converts JSON to Kotlin data classes.
- Event Bus: Bi-directional event system (
emit/on) for real-time communication.
- Promise-based: JavaScript calls return Promises, allowing
- State Management: Reactive state handling for URL, loading progress, and navigation.
- Flexible API: Full control over
WebViewClient,WebChromeClient, and WebView settings with convenient composable functions (rememberWebViewClient,rememberWebChromeClient). - Lifecycle Management: Automatically handles
onResume,onPause, and cleanup. - Network Request Interception: Intercept and provide custom responses for specific URL schemes (Android & iOS).
- Dark Mode Support: Automatic theme synchronization with system or manual control (Android & iOS).
- Enhanced Cookie Management: Support for removing cookies for specific URLs (Android & iOS).
- Find on Page: Built-in support for searching text within pages with result callbacks (Android & iOS).
- Custom Context Menu: Override default Android action mode with custom callbacks.
- Loading & Error States: Built-in state management for loading indicators and error handling.
- Back Navigation: Integrated with Compose
BackHandlerfor seamless back navigation (Android) and native swipe gestures (iOS). - File Upload: Full support for file upload functionality (Android & iOS).
- Android API 24+
- iOS 14.0+
- Desktop (JVM) 11+
- Web (JS)
- Jetpack Compose / Compose Multiplatform 1.9.3+
- Kotlin 2.2.0+
| Platform | Implementation | Status | Note |
|---|---|---|---|
| Android | AndroidView (WebView) |
✅ Stable | Full feature support |
| iOS | UIKitView (WKWebView) |
✅ Stable | Full feature support (Seamless JS Bridge) |
| Desktop | SwingPanel (CEF via JCEF) |
🚧 Experimental | WIP: JCEF integration is in progress. Basic browsing works, but advanced features are still being tested. |
| Web (JS) | Iframe (DOM overlay) |
🚧 Experimental | WIP: Uses DOM overlay to stay compatible with Compose UI (Canvas). CORS/browser policy limitations apply. |
| Web (WASM) | Iframe (DOM) |
🚧 Experimental | WIP: Uses iframe with dynamic positioning. Same-origin policy restrictions apply. |
Web(JS) uses imperative DOM overlay instead of Compose HTML DOM nodes to avoid runtime Applier conflicts in Compose UI(Canvas) apps.
If you see IllegalAccessError with sun.awt, sun.lwawt, or sun.lwawt.macosx while running JCEF on macOS, add JVM module options:
compose.desktop {
application {
jvmArgs += listOf(
"--add-exports=java.desktop/sun.awt=ALL-UNNAMED",
"--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--add-exports=java.desktop/sun.lwawt=ALL-UNNAMED",
"--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED",
"--add-exports=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
"--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
)
}
}Click to expand detailed API support by platform
| API | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
loadUrl() |
✅ | ✅ | ✅ | ✅ | Headers: Android/iOS only |
loadHtml() |
✅ | ✅ | ✅ | Web: CORS restrictions | |
postUrl() |
✅ | ✅ | ❌ | ❌ | Desktop/Web not supported |
evaluateJavascript() |
✅ | ✅ | ✅ | Web: CORS restricted | |
navigateBack/Forward |
✅ | ✅ | ✅ | Web: CORS restricted | |
reload() / stopLoading() |
✅ | ✅ | ✅ | Web: CORS restricted |
| API | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
zoomIn/Out/By() |
✅ | ❌ | ✅ | ❌ | iOS: Pinch-to-zoom only |
scrollTo/By() |
✅ | ✅ | ❌ | ✅ | Desktop not supported |
pageUp/Down() |
✅ | ❌ | Via scrollBy on iOS/Web | ||
findAllAsync() |
✅ | ❌ | ❌ | ❌ | iOS/Web/Desktop not supported |
clearCache/History() |
✅ | ❌ | ❌ | ❌ | Android only |
| Feature | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
LoadingState |
✅ | ✅ | All states supported on Android/iOS | ||
LoadingState.Loading(progress) |
✅ | ✅ | ❌ | ❌ | iOS: 100ms polling |
ScrollPosition |
✅ | ✅ | ❌ | Real-time (Android), Polling (iOS), CORS (Web) | |
WebViewError |
✅ | ✅ | Typed error categories | ||
jsDialogState |
✅ | ✅ | ❌ | ❌ | Alert/Confirm/Prompt |
customViewState |
✅ | ❌ | ❌ | Android: custom view view hierarchy, iOS: native fullscreen signal |
| Setting | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
interceptedSchemes |
✅ | ✅ | ❌ | ❌ | URL schemes to intercept (iOS registration required) |
darkMode |
✅ | ✅ | ❌ | ❌ | DARK, LIGHT, or AUTO theme support |
userAgent |
✅ | ✅ | ✅ | ❌ | Custom user agent string |
javaScriptEnabled |
✅ | ✅* | ✅ | ❌ | *iOS: Always enabled |
domStorageEnabled |
✅ | ✅ | ❌ | Limited on Desktop | |
cacheMode |
✅ | ❌ | Full support on Android | ||
supportZoom |
✅ | ✅ | ❌ | **iOS: Pinch-to-zoom only | |
mediaPlaybackRequiresUserAction |
✅ | ✅ | ❌ | Autoplay control |
| Callback | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
onPageStarted |
✅ | ✅ | ✅ | ❌ | Navigation started |
onPageFinished |
✅ | ✅ | ✅ | ✅ | Navigation completed |
onProgressChanged |
✅ | ✅ | ❌ | ❌ | iOS: 100ms polling |
onReceivedError |
✅ | ✅ | ❌ | Typed error information | |
shouldInterceptRequest |
✅ | ✅ | ❌ | ❌ | Handle custom request interception |
onConsoleMessage |
✅ | ✅ | ❌ | ❌ | JavaScript console debugging |
shouldOverrideUrlLoading |
✅ | ✅ | ✅ | ❌ | Custom URL handling |
| JS Dialogs (Alert/Confirm/Prompt) | ✅ | ✅ | ❌ | ❌ | Custom dialog UI |
| Custom View (Fullscreen) | ✅ | ❌ | ❌ | Android: custom view injection, iOS: native fullscreen events | |
| File Upload | ✅ | ✅ | ❌ | ❌ | Native file picker |
| Download Handling | ✅ | ❌ | ❌ | onDownloadStart callback |
| Feature | Android | iOS | Desktop | Web | Notes |
|---|---|---|---|---|---|
register() handler |
✅ | ✅ | ✅ | ✅ | Kotlin ↔ JavaScript calls |
emit() events |
✅ | ✅ | Event bus system | ||
| Promise-based response | ✅ | ✅ | Async/await support | ||
| Type-safe serialization | ✅ | ✅ | ✅ | ✅ | Kotlinx Serialization |
Legend: ✅ Full Support |
This library has a slightly different focus compared to other WebView libraries (like KevinnZou/compose-webview-multiplatform), primarily targeting Mobile productivity.
We focused heavily on making the interaction between Kotlin and JavaScript as seamless as possible on Android & iOS.
- Promise-based JSBridge: Enables using
awaitin JavaScript to call Native functions and get results directly, avoiding callback hell. - Flexible Serialization: Supports Kotlinx Serialization out-of-the-box, while the
BridgeSerializerinterface allows plugging in Gson, Moshi, or any other JSON library. - Type Safety: Allows passing complex data objects with full type safety.
-
Mobile (Android/iOS): Stable and Feature-Rich. Recommended for production.
-
Desktop & Web: Currently Experimental (WIP).
- While we have implemented basic controls and JSBridge for these platforms, they are not as battle-tested as the mobile targets.
- For stable Desktop or Web requirements, KevinnZou's library is the better choice.
This library is available on Maven Central for universal access without authentication starting from v1.6.0.
Important: Future versions (v1.6.1+) will be available only on Maven Central. The current version v1.6.0 is available on all repositories but uses different group IDs:
- JitPack/GitHub Packages:
com.github.parkwoocheol:compose-webview:1.6.0- Maven Central:
io.github.parkwoocheol:compose-webview:1.6.0← RecommendedMigration Note: If you're upgrading from v1.5.x or earlier, see the Migration Guide below.
Add Maven Central to settings.gradle.kts (or settings.gradle):
Kotlin DSL (settings.gradle.kts):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Groovy DSL (settings.gradle):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add the dependency to your commonMain source set (for KMP) or app dependencies.
Kotlin DSL (build.gradle.kts):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.parkwoocheol:compose-webview:<version>")
}
}
}Groovy DSL (build.gradle):
kotlin {
sourceSets {
commonMain {
dependencies {
implementation 'io.github.parkwoocheol:compose-webview:<version>'
}
}
}
}When you add the dependency:
implementation("io.github.parkwoocheol:compose-webview:<version>")The Kotlin Multiplatform Gradle plugin automatically selects the correct platform-specific artifact for each target:
| Platform | Artifact ID | Maven Coordinates |
|---|---|---|
| Android | compose-webview-android |
io.github.parkwoocheol:compose-webview-android:<version> |
| iOS (arm64) | compose-webview-iosarm64 |
io.github.parkwoocheol:compose-webview-iosarm64:<version> |
| iOS (x64) | compose-webview-iosx64 |
io.github.parkwoocheol:compose-webview-iosx64:<version> |
| iOS (Simulator arm64) | compose-webview-iossimulatorarm64 |
io.github.parkwoocheol:compose-webview-iossimulatorarm64:<version> |
| Desktop (JVM) | compose-webview-desktop |
io.github.parkwoocheol:compose-webview-desktop:<version> |
| Web (JS) | compose-webview-js |
io.github.parkwoocheol:compose-webview-js:<version> |
| Web (WASM) | compose-webview-wasmjs |
io.github.parkwoocheol:compose-webview-wasmjs:<version> |
| Metadata | compose-webview |
io.github.parkwoocheol:compose-webview:<version> |
Note: You don't need to specify platform-specific artifacts manually. Just use
compose-webviewand Gradle resolves the correct artifact automatically.
If you previously used this library from JitPack or GitHub Packages, here's how to migrate:
| Version | Repository | Group ID | Coordinates |
|---|---|---|---|
| v1.5.x and earlier | JitPack, GitHub Packages | com.github.parkwoocheol |
com.github.parkwoocheol:compose-webview:1.5.x |
| v1.6.0 (current) | JitPack, GitHub Packages | com.github.parkwoocheol |
com.github.parkwoocheol:compose-webview:1.6.0 |
| v1.6.0 (current) | Maven Central | io.github.parkwoocheol |
io.github.parkwoocheol:compose-webview:1.6.0 |
| v1.6.1+ (future) | Maven Central only | io.github.parkwoocheol |
io.github.parkwoocheol:compose-webview:1.6.1+ |
Note: v1.6.0 uses different group IDs depending on the repository. Same version, different coordinates.
Step 1: Upgrade to v1.6.0 (test compatibility, keep existing repository)
// settings.gradle.kts - Keep existing repository
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") } // or GitHub Packages
}
// build.gradle.kts - Upgrade version only, keep group ID
implementation("com.github.parkwoocheol:compose-webview:1.6.0")Build and test your app.
Step 2: Switch to Maven Central (change group ID)
// settings.gradle.kts - Keep both temporarily
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") } // will remove soon
}
// build.gradle.kts - Change group ID to io.github
implementation("io.github.parkwoocheol:compose-webview:1.6.0")Verify it downloads from Maven Central.
Step 3: Clean up (remove old repositories)
// settings.gradle.kts - Remove old repositories
repositories {
google()
mavenCentral() // All you need!
}
// build.gradle.kts - Same as Step 2
implementation("io.github.parkwoocheol:compose-webview:1.6.0")Remove authentication (GitHub tokens, etc.).
For direct migration:
// settings.gradle.kts
repositories {
google()
mavenCentral()
}
// build.gradle.kts
// Before: implementation("com.github.parkwoocheol:compose-webview:1.5.1")
implementation("io.github.parkwoocheol:compose-webview:1.6.0")Note: Old versions remain available on JitPack/GitHub Packages for backward compatibility.
@Composable
fun MyWebViewScreen() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val controller = rememberWebViewController()
ComposeWebView(
url = "https://example.com",
modifier = Modifier.fillMaxSize(),
onCreated = { webView ->
webView.settings.javaScriptEnabled = true
}
)
}The library provides two ways to create and remember the WebViewState:
Uses rememberSaveable to preserve the WebView state (URL, scroll position, history, etc.) across configuration changes (e.g., screen rotation).
val state = rememberSaveableWebViewState(url = "https://google.com")
// or for HTML data
val state = rememberSaveableWebViewStateWithData(data = htmlContent)Uses remember to hold the state. The WebView will be reloaded and state lost on configuration changes. Use this if you want to avoid TransactionTooLargeException with large HTML data or don't need state persistence.
val state = rememberWebViewState(url = "https://google.com")
// or for HTML data
val state = rememberWebViewStateWithData(data = htmlContent)Create a simple WebView with minimal configuration:
@Composable
fun SimpleWebView() {
ComposeWebView(
url = "https://google.com",
modifier = Modifier.fillMaxSize(),
onCreated = { webView ->
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
loadWithOverviewMode = true
useWideViewPort = true
}
}
)
}Monitor and control WebView state using WebViewController:
@Composable
fun WebViewWithState() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val controller = rememberWebViewController()
Column(modifier = Modifier.fillMaxSize()) {
// Show loading indicator
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = { loadingState.progress },
modifier = Modifier.fillMaxWidth()
)
}
// Display current URL
Text(text = "Current URL: ${state.lastLoadedUrl}")
// Navigation controls
Row {
Button(
onClick = { controller.navigateBack() },
enabled = controller.canGoBack
) {
Text("Back")
}
Button(
onClick = { controller.navigateForward() },
enabled = controller.canGoForward
) {
Text("Forward")
}
Button(onClick = { controller.reload() }) {
Text("Reload")
}
}
ComposeWebView(
state = state,
controller = controller,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
onCreated = { it.settings.javaScriptEnabled = true }
)
}
}Communicate between Kotlin and JavaScript using type-safe, promise-based API.
@Serializable
data class User(val name: String, val age: Int)
@Serializable
data class UserResponse(val success: Boolean, val message: String)
@Serializable
data class Location(val latitude: Double, val longitude: Double)@Composable
fun WebViewWithJsBridge() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val bridge = rememberWebViewJsBridge()
LaunchedEffect(bridge) {
// Handle "updateUser" call from JavaScript
bridge.register<User, UserResponse>("updateUser") { user ->
println("Updating user: ${user.name}, age: ${user.age}")
// Perform some operation
UserResponse(success = true, message = "User updated successfully")
}
// Handle "getLocation" call
bridge.register<Unit, Location>("getLocation") { _ ->
// Return current location
Location(latitude = 37.7749, longitude = -122.4194)
}
// Handle void calls (no return value)
bridge.register<String, Unit>("log") { message ->
println("JavaScript log: $message")
}
// Handle nullable input payloads
bridge.registerNullable<User, UserResponse>("updateUserMaybe") { userOrNull ->
if (userOrNull == null) {
UserResponse(success = false, message = "Missing user payload")
} else {
UserResponse(success = true, message = "Updated ${userOrNull.name}")
}
}
// Suspend handlers — async operations before returning to JS
bridge.register<SearchQuery, SearchResult>("search") { query ->
val results = api.search(query.term) // suspend call
SearchResult(items = results)
}
}
ComposeWebView(
state = state,
jsBridge = bridge,
modifier = Modifier.fillMaxSize()
)
}// The default bridge object is 'window.AppBridge'
// Call with typed response
async function updateUser() {
try {
const response = await window.AppBridge.call('updateUser', {
name: "Park",
age: 30
});
console.log("Success:", response.message);
} catch (error) {
console.error("Failed:", error);
}
}
// Call without parameters
async function getLocation() {
try {
// You can also omit the second argument:
// const location = await window.AppBridge.call('getLocation');
const location = await window.AppBridge.call('getLocation', null);
console.log(`Lat: ${location.latitude}, Lng: ${location.longitude}`);
} catch (error) {
console.error("Failed to get location:", error);
}
}
// Call without return value
async function logMessage() {
try {
await window.AppBridge.call('log', "Hello from JavaScript!");
} catch (error) {
console.error("Failed to log:", error);
}
}register<T, R> null input rules:
TisUnit:call("method")andcall("method", null)are both accepted.- Other non-null input types:
nullthrows an input type error.
Use registerNullable<T, R> when a payload type may be null from JavaScript.
Change the global JavaScript object name:
val bridge = rememberWebViewJsBridge(
jsObjectName = "MyBridge" // Access via window.MyBridge in JavaScript
)ComposeWebView automatically handles lifecycle events:
@Composable
fun WebViewWithLifecycle() {
val state = rememberSaveableWebViewState(url = "https://example.com")
// Lifecycle events are handled automatically:
// - onResume() when composable enters composition
// - onPause() when composable leaves composition
// - Cleanup when disposed
ComposeWebView(
state = state,
modifier = Modifier.fillMaxSize()
)
}Handle WebView errors and display custom error UI:
@Composable
fun WebViewWithErrorHandling() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val controller = rememberWebViewController()
// Configure client to handle errors
val client = rememberWebViewClient {
onReceivedError { view, request, error ->
// Handle error
Log.e("WebView", "Error loading ${request?.url}: ${error?.description}")
}
}
ComposeWebView(
state = state,
controller = controller,
client = client,
modifier = Modifier.fillMaxSize(),
errorContent = { errors ->
// Custom error UI
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Failed to load page",
style = MaterialTheme.typography.headlineMedium
)
errors.firstOrNull()?.let { error ->
Text(
text = error.description,
style = MaterialTheme.typography.bodyMedium
)
}
Button(onClick = { controller.reload() }) {
Text("Retry")
}
}
}
}
)
}Configure WebView behavior using WebViewSettings:
@Composable
fun CustomWebView() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val settings = WebViewSettings(
userAgent = "MyApp/1.0",
javaScriptEnabled = true,
domStorageEnabled = true,
cacheMode = CacheMode.CACHE_ELSE_NETWORK,
supportZoom = true,
loadWithOverviewMode = true,
useWideViewPort = true,
mediaPlaybackRequiresUserAction = false,
allowFileAccess = false,
allowContentAccess = false,
allowFileAccessFromFileURLs = false,
allowUniversalAccessFromFileURLs = false
)
ComposeWebView(
state = state,
settings = settings,
modifier = Modifier.fillMaxSize(),
onCreated = { webView ->
// Additional platform-specific configuration
},
onDispose = { webView ->
// Custom cleanup logic
}
)
}Track and respond to scroll position changes:
@Composable
fun ScrollTrackingWebView() {
val state = rememberSaveableWebViewState(url = "https://example.com")
val controller = rememberWebViewController()
var showScrollToTop by remember { mutableStateOf(false) }
// Observe scroll position changes
LaunchedEffect(state.scrollPosition) {
val (x, y) = state.scrollPosition
showScrollToTop = y > 500 // Show button when scrolled down 500px
}
Box(modifier = Modifier.fillMaxSize()) {
ComposeWebView(
state = state,
controller = controller,
modifier = Modifier.fillMaxSize()
)
// Floating "Back to Top" button
if (showScrollToTop) {
FloatingActionButton(
onClick = {
controller.scrollTo(0, 0)
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.ArrowUpward, "Scroll to top")
}
}
}
}Capture JavaScript console messages for debugging:
@Composable
fun DebuggableWebView() {
val state = rememberSaveableWebViewState(url = "https://example.com")
// Configure chrome client to handle console messages
val chromeClient = rememberWebChromeClient {
onConsoleMessage { webView, message ->
when (message.level) {
ConsoleMessageLevel.ERROR -> {
Log.e("WebView", "[${message.sourceId}:${message.lineNumber}] ${message.message}")
}
ConsoleMessageLevel.WARNING -> {
Log.w("WebView", message.message)
}
ConsoleMessageLevel.LOG -> {
Log.d("WebView", message.message)
}
else -> {
Log.v("WebView", message.message)
}
}
false // Return true to suppress default logging
}
}
ComposeWebView(
state = state,
chromeClient = chromeClient,
modifier = Modifier.fillMaxSize()
)
}Configure WebView client events using rememberWebViewClient and rememberWebChromeClient:
@Composable
fun WebViewWithClientConfiguration() {
val state = rememberSaveableWebViewState(url = "https://example.com")
// Configure WebViewClient
val client = rememberWebViewClient {
onPageStarted { view, url, favicon ->
println("Page started loading: $url")
}
onPageFinished { view, url ->
println("Page finished loading: $url")
}
onReceivedError { view, request, error ->
println("Error loading page: ${error?.description}")
}
shouldOverrideUrlLoading { view, request ->
// Return true to block navigation
request?.url?.startsWith("myapp://") == true
}
}
// Configure WebChromeClient
val chromeClient = rememberWebChromeClient {
onProgressChanged { view, progress ->
println("Loading progress: $progress%")
}
onConsoleMessage { view, message ->
println("[Console ${message.level}] ${message.message}")
false // Return true to suppress default handling
}
onPermissionRequest { request ->
// Handle permission requests (platform-specific)
}
}
ComposeWebView(
state = state,
client = client,
chromeClient = chromeClient,
modifier = Modifier.fillMaxSize()
)
}You can also chain handlers:
@Composable
fun ChainedHandlersExample() {
val client = rememberWebViewClient()
.onPageStarted { view, url, favicon -> /* ... */ }
.onPageFinished { view, url -> /* ... */ }
.onReceivedError { view, request, error -> /* ... */ }
val chromeClient = rememberWebChromeClient()
.onProgressChanged { view, progress -> /* ... */ }
.onConsoleMessage { view, message -> false }
ComposeWebView(
state = rememberSaveableWebViewState(url = "https://example.com"),
client = client,
chromeClient = chromeClient,
modifier = Modifier.fillMaxSize()
)
}For more advanced scenarios, you can still extend the client classes directly:
@Composable
fun AdvancedClientExample() {
val client = remember {
object : ComposeWebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Full control with direct override
view?.evaluateJavascript("console.log('Custom injection')")
}
}
}
ComposeWebView(
state = rememberSaveableWebViewState(url = "https://example.com"),
client = client,
modifier = Modifier.fillMaxSize()
)
}WebViewClient:
onPageStarted(handler: (WebView?, String?, PlatformBitmap?) -> Unit)onPageFinished(handler: (WebView?, String?) -> Unit)onReceivedError(handler: (WebView?, PlatformWebResourceRequest?, PlatformWebResourceError?) -> Unit)shouldInterceptRequest(handler: (WebView?, PlatformWebResourceRequest?) -> PlatformWebResourceResponse?)shouldOverrideUrlLoading(handler: (WebView?, PlatformWebResourceRequest?) -> Boolean)
WebChromeClient:
onProgressChanged(handler: (WebView?, Int) -> Unit)onConsoleMessage(handler: (WebView?, ConsoleMessage) -> Boolean)onPermissionRequest(handler: (PlatformPermissionRequest) -> Unit)(Platform-specific)
Main composable function for displaying WebView.
@Composable
fun ComposeWebView(
url: String,
modifier: Modifier = Modifier,
settings: WebViewSettings = WebViewSettings.Default,
releaseStrategy: WebViewReleaseStrategy = WebViewReleaseStrategy.DestroyOnRelease,
controller: WebViewController = rememberWebViewController(),
javaScriptInterfaces: Map<String, Any> = emptyMap(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: ComposeWebViewClient = remember { ComposeWebViewClient() },
chromeClient: ComposeWebChromeClient = remember { ComposeWebChromeClient() },
factory: ((PlatformContext) -> WebView)? = null,
loadingContent: @Composable () -> Unit = {},
errorContent: @Composable (List<WebViewError>) -> Unit = {},
jsAlertContent: @Composable (JsDialogState.Alert) -> Unit = {},
jsConfirmContent: @Composable (JsDialogState.Confirm) -> Unit = {},
jsPromptContent: @Composable (JsDialogState.Prompt) -> Unit = {},
customViewContent: (@Composable (CustomViewState) -> Unit)? = null,
onDownloadStart: ((String, String, String, String, Long) -> Unit)? = null,
onFindResultReceived: ((Int, Int, Boolean) -> Unit)? = null,
onStartActionMode: ((WebView, PlatformActionModeCallback?) -> PlatformActionModeCallback?)? = null,
)
// Alternative overload with state
@Composable
fun ComposeWebView(
state: WebViewState,
modifier: Modifier = Modifier,
settings: WebViewSettings = WebViewSettings.Default,
releaseStrategy: WebViewReleaseStrategy = WebViewReleaseStrategy.DestroyOnRelease,
controller: WebViewController = rememberWebViewController(),
javaScriptInterfaces: Map<String, Any> = emptyMap(),
jsBridge: WebViewJsBridge? = null,
// ... other parameters same as above
)Holds the state of the WebView.
class WebViewState(
webContent: WebContent
) {
var lastLoadedUrl: String?
var content: WebContent
val loadingState: LoadingState
val isLoading: Boolean
var pageTitle: String?
var pageIcon: PlatformBitmap?
var scrollPosition: ScrollPosition
val errorsForCurrentRequest: SnapshotStateList<WebViewError>
var jsDialogState: JsDialogState?
var customViewState: CustomViewState?
var webView: WebView?
var bundle: PlatformBundle?
}Controls WebView navigation and operations.
class WebViewController {
val canGoBack: Boolean
val canGoForward: Boolean
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap())
fun loadHtml(
html: String,
baseUrl: String? = null,
mimeType: String? = null,
encoding: String? = "UTF-8",
historyUrl: String? = null
)
fun postUrl(url: String, postData: ByteArray)
fun evaluateJavascript(script: String, callback: ((String) -> Unit)? = null)
fun navigateBack()
fun navigateForward()
fun reload()
fun stopLoading()
fun zoomBy(zoomFactor: Float)
fun zoomIn()
fun zoomOut()
fun findAllAsync(find: String)
fun findNext(forward: Boolean)
fun clearMatches()
fun clearCache()
fun clearHistory()
fun clearSslPreferences()
fun clearFormData()
fun pageUp(top: Boolean)
fun pageDown(bottom: Boolean)
fun scrollTo(x: Int, y: Int)
fun scrollBy(x: Int, y: Int)
fun saveWebArchive(filename: String)
}Manages JavaScript-Kotlin communication.
class WebViewJsBridge(
serializer: BridgeSerializer? = null,
val jsObjectName: String = "AppBridge",
private val nativeInterfaceName: String = "AppBridgeNative"
) {
// Register a typed handler for calls from JavaScript.
// Accepts both regular and suspend lambdas.
// Null input is only accepted when T is Unit.
inline fun <reified T : Any, reified R : Any> register(
method: String,
noinline handler: suspend (T) -> R
)
// Register a no-argument handler
inline fun <reified R : Any> register(
method: String,
noinline handler: suspend () -> R
)
// Register a handler for nullable payload input
inline fun <reified T : Any, reified R : Any> registerNullable(
method: String,
noinline handler: suspend (T?) -> R
)
// Emit an event to JavaScript
inline fun <reified T> emit(eventName: String, data: T)
// Unregister a handler
fun unregister(method: String)
}Handles navigation and page lifecycle events. Can be configured using extension functions.
expect open class ComposeWebViewClient() {
open var webViewState: WebViewState?
open var webViewController: WebViewController?
open fun onPageStarted(view: WebView?, url: String?, favicon: PlatformBitmap?)
open fun onPageFinished(view: WebView?, url: String?)
open fun onReceivedError(view: WebView?, request: PlatformWebResourceRequest?, error: PlatformWebResourceError?)
open fun shouldOverrideUrlLoading(view: WebView?, request: PlatformWebResourceRequest?): Boolean
}Extension Functions for Configuration:
// Configure callbacks using chainable extension functions
fun ComposeWebViewClient.onPageStarted(
handler: (WebView?, String?, PlatformBitmap?) -> Unit
): ComposeWebViewClient
fun ComposeWebViewClient.onPageFinished(
handler: (WebView?, String?) -> Unit
): ComposeWebViewClient
fun ComposeWebViewClient.onReceivedError(
handler: (WebView?, PlatformWebResourceRequest?, PlatformWebResourceError?) -> Unit
): ComposeWebViewClient
fun ComposeWebViewClient.shouldOverrideUrlLoading(
handler: (WebView?, PlatformWebResourceRequest?) -> Boolean
): ComposeWebViewClientHandles UI events like progress updates and console messages. Can be configured using extension functions.
expect open class ComposeWebChromeClient() {
open fun onProgressChanged(view: WebView?, newProgress: Int)
open fun onConsoleMessage(view: WebView?, message: ConsoleMessage): Boolean
}Extension Functions for Configuration:
// Configure callbacks using chainable extension functions
fun ComposeWebChromeClient.onProgressChanged(
handler: (WebView?, Int) -> Unit
): ComposeWebChromeClient
fun ComposeWebChromeClient.onConsoleMessage(
handler: (WebView?, ConsoleMessage) -> Boolean
): ComposeWebChromeClient
fun ComposeWebChromeClient.onPermissionRequest(
handler: (PlatformPermissionRequest) -> Unit
): ComposeWebChromeClient@Composable
fun rememberWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap()
): WebViewState
@Composable
fun rememberSaveableWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap()
): WebViewState
@Composable
fun rememberWebViewStateWithData(
data: String,
baseUrl: String? = null,
encoding: String = "utf-8",
mimeType: String = "text/html",
historyUrl: String? = null
): WebViewState
@Composable
fun rememberSaveableWebViewStateWithData(
data: String,
baseUrl: String? = null,
encoding: String = "utf-8",
mimeType: String = "text/html",
historyUrl: String? = null
): WebViewState
@Composable
fun rememberWebViewController(): WebViewController
@Composable
fun rememberWebViewClient(
block: (ComposeWebViewClient.() -> Unit)? = null
): ComposeWebViewClient
@Composable
fun rememberWebChromeClient(
block: (ComposeWebChromeClient.() -> Unit)? = null
): ComposeWebChromeClient
@Composable
fun rememberWebViewJsBridge(
serializer: BridgeSerializer? = null,
jsObjectName: String = "AppBridge",
nativeInterfaceName: String = "AppBridgeNative"
): WebViewJsBridgeImplement BridgeSerializer to use custom JSON libraries like Gson or Moshi:
import kotlin.reflect.KType
interface BridgeSerializer {
fun encode(data: Any?, type: KType): String
fun <T> decode(json: String, type: KType): T
}
// Example skeleton for a custom serializer
class CustomBridgeSerializer : BridgeSerializer {
override fun encode(data: Any?, type: KType): String {
TODO("Serialize data with your JSON library using type")
}
override fun <T> decode(json: String, type: KType): T {
TODO("Deserialize json with your JSON library using type")
}
}
// Usage
val bridge = rememberWebViewJsBridge(
serializer = CustomBridgeSerializer()
)The sample/ directory contains runnable targets for every platform:
sample/shared: the Compose Multiplatform UI/features showcased below.sample/androidApp: Android wrapper (Gradle module).sample/iosApp: Xcode project that embeds the shared UI.sample/desktopApp: Compose Desktop entry point.sample/wasmApp: Compose WASM/browser runner.
All sample targets use the same feature screens:
-
Basic Browser (
BasicBrowserScreen)- Standard WebView with navigation controls (Back, Forward, Reload).
- URL input field with loading progress indicator.
-
Transient vs Saved State (
TransientBrowserScreen)- Demonstrates the difference between
rememberWebViewState(transient) andrememberSaveableWebViewState(persisted). - Rotate the device to see the transient state reset while the saved state persists.
- Demonstrates the difference between
-
HTML & JS Interaction (
HtmlJsScreen)- Bi-directional Communication: Send data from Kotlin to JS and vice-versa.
- Command Center: Trigger Native events from UI and see JS logs in real-time.
- Promise-based API: Call Native functions from JS using
await.
-
Fullscreen Video (
FullscreenVideoScreen)- Native fullscreen video support (e.g., YouTube).
- Handles orientation changes and UI overlay automatically.
- iOS uses the system fullscreen player; the Compose state lets you hide your chrome when it appears.
-
Custom Client (
CustomClientScreen)- Configure WebView settings (JS, DOM Storage, Zoom) dynamically.
- Inject custom
WebViewClientto intercept URLs (e.g., blocking specific domains).
- Android:
./gradlew :sample:androidApp:installDebug - Desktop:
./gradlew :sample:desktopApp:run - Web/Wasm:
./gradlew :sample:wasmApp:wasmJsBrowserDevelopmentRun(open the printed URL in a browser).- Note: WASM target is enabled by default. If you encounter build issues, you can disable it by adding
-PENABLE_WASM=falseto any Gradle command.
- Note: WASM target is enabled by default. If you encounter build issues, you can disable it by adding
- iOS: Open
sample/iosApp/iosApp.xcodeprojin Xcode and run theiosAppscheme.
We welcome contributions from the community! Whether it's reporting bugs, suggesting features, improving docs, or submitting code.
- Fork the repository
- Create your branch (
git checkout -b your-branch-name) - Make your changes
- Test with the sample app
- Push and open a Pull Request
See CONTRIBUTING.md for more details.
Release history is maintained in CHANGELOG.md.
This project is licensed under the MIT License - see the LICENSE file for details.