From b063dfaff6f8dca976b15472d00e89cd946e984f Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 7 Jul 2026 23:20:46 +0200 Subject: [PATCH] new: add loading overlay for WebView widget while pages load --- .../chombev/web/ui/PlatformWebView.android.kt | 3 +- .../web/ui/WebClientDelegatingExtLinks.kt | 34 ++++++++++- .../ajaury/chombev/web/ui/PlatformWebView.kt | 4 ++ .../ajaury/chombev/web/ui/WebViewLoader.kt | 35 ++++++++++++ .../ajaury/chombev/web/ui/WebViewScreen.kt | 29 ++++++++-- .../chombev/web/ui/PlatformWebView.ios.kt | 56 +++++++++++++++++++ .../chombev/web/ui/PlatformWebView.js.kt | 5 +- .../chombev/web/ui/PlatformWebView.jvm.kt | 5 +- .../chombev/web/ui/PlatformWebView.wasmJs.kt | 5 +- 9 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewLoader.kt diff --git a/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.android.kt b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.android.kt index 992566f..2dd9364 100644 --- a/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.android.kt +++ b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.android.kt @@ -16,6 +16,7 @@ import androidx.webkit.WebViewFeature actual fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit, modifier: Modifier, ) { AndroidView( @@ -23,7 +24,7 @@ actual fun PlatformWebView( factory = { context -> WebView(context).apply { // Keep navigation inside the embedded view instead of launching an external browser. - webViewClient = WebClientDelegatingExtLinks() + webViewClient = WebClientDelegatingExtLinks(onLoadingStateChange = onLoadingStateChange) settings.javaScriptEnabled = false settings.domStorageEnabled = true applyDarkTheme(darkTheme) diff --git a/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/WebClientDelegatingExtLinks.kt b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/WebClientDelegatingExtLinks.kt index 222f3ff..2a3e31c 100644 --- a/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/WebClientDelegatingExtLinks.kt +++ b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/web/ui/WebClientDelegatingExtLinks.kt @@ -1,11 +1,43 @@ package bzh.ajaury.chombev.web.ui import android.content.Intent +import android.graphics.Bitmap +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -class WebClientDelegatingExtLinks : WebViewClient() { +/** + * Keeps the initial page inside the embedded web view but opens any link the user taps in the system + * browser. Reports page load start/finish through [onLoadingStateChange] so the UI can show a loader. + */ +class WebClientDelegatingExtLinks( + private val onLoadingStateChange: (Boolean) -> Unit = {}, +) : WebViewClient() { + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + onLoadingStateChange(true) + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + onLoadingStateChange(false) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + // Stop the loader even if the page failed, so the user isn't stuck on the spinner. + onLoadingStateChange(false) + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.kt index c35b473..214203f 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.kt @@ -7,10 +7,14 @@ import androidx.compose.ui.Modifier * Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline, * honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to * [WebViewUnavailable], which offers to open the page in the system browser. + * + * [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a + * loader. Fallback platforms report `false` immediately since there is nothing to load. */ @Composable expect fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewLoader.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewLoader.kt new file mode 100644 index 0000000..1462ca4 --- /dev/null +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewLoader.kt @@ -0,0 +1,35 @@ +package bzh.ajaury.chombev.web.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import bzh.ajaury.chombev.theme.ChomBevTheme + +/** + * Full-surface loading overlay shown on top of the embedded web view while a page is loading. The + * opaque [MaterialTheme.colorScheme.surface] background hides the blank web view until the page is + * ready, so the transition reads cleanly in both light and dark themes. + */ +@Composable +internal fun WebViewLoader(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +private fun WebViewLoaderPreview() { + ChomBevTheme { + WebViewLoader(modifier = Modifier.fillMaxSize()) + } +} diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewScreen.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewScreen.kt index 74a443b..58387bb 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewScreen.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/web/ui/WebViewScreen.kt @@ -1,16 +1,22 @@ package bzh.ajaury.chombev.web.ui import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import bzh.ajaury.chombev.navigation.WebPage /** - * Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. The - * surrounding top and bottom bars are provided by the app-level scaffold. + * Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. A + * [WebViewLoader] covers the page while it loads. The surrounding top and bottom bars are provided by + * the app-level scaffold. */ @Composable fun WebViewScreen( @@ -18,11 +24,22 @@ fun WebViewScreen( contentPadding: PaddingValues, darkTheme: Boolean = isSystemInDarkTheme(), ) { - PlatformWebView( - url = page.url, - darkTheme = darkTheme, + // Start in the loading state; each distinct page loads afresh. + var isLoading by remember(page) { mutableStateOf(true) } + + Box( modifier = Modifier .padding(contentPadding) .fillMaxSize(), - ) + ) { + PlatformWebView( + url = page.url, + darkTheme = darkTheme, + onLoadingStateChange = { isLoading = it }, + modifier = Modifier.fillMaxSize(), + ) + if (isLoading) { + WebViewLoader(modifier = Modifier.fillMaxSize()) + } + } } diff --git a/shared/src/iosMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.ios.kt b/shared/src/iosMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.ios.kt index a7eb575..a331fc0 100644 --- a/shared/src/iosMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.ios.kt +++ b/shared/src/iosMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.ios.kt @@ -1,25 +1,36 @@ package bzh.ajaury.chombev.web.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitView import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSError import platform.Foundation.NSURL import platform.Foundation.NSURLRequest import platform.UIKit.UIUserInterfaceStyle import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleDark import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleLight +import platform.WebKit.WKNavigation +import platform.WebKit.WKNavigationDelegateProtocol import platform.WebKit.WKWebView import platform.WebKit.WKWebViewConfiguration +import platform.darwin.NSObject @OptIn(ExperimentalForeignApi::class) @Composable actual fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit, modifier: Modifier, ) { + // WKWebView holds its navigationDelegate weakly, so keep a strong reference across recompositions. + val navigationDelegate = remember { WebViewNavigationDelegate() } + navigationDelegate.onLoadingStateChange = onLoadingStateChange + UIKitView( modifier = modifier, factory = { @@ -27,6 +38,7 @@ actual fun PlatformWebView( frame = CGRectZero.readValue(), configuration = WKWebViewConfiguration(), ).apply { + navigationDelegate = navigationDelegate setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle()) NSURL.URLWithString(url)?.let { nsUrl -> loadRequest(NSURLRequest(uRL = nsUrl)) @@ -40,4 +52,48 @@ actual fun PlatformWebView( ) } +/** + * Forwards WKWebView load lifecycle events to [onLoadingStateChange] so the UI can show a loader + * while a page is loading and hide it once loading finishes (or fails). + */ +private class WebViewNavigationDelegate : + NSObject(), + WKNavigationDelegateProtocol { + var onLoadingStateChange: (Boolean) -> Unit = {} + + override fun webView( + webView: WKWebView, + didStartProvisionalNavigation: WKNavigation?, + ) { + onLoadingStateChange(true) + } + + // Shares the (WKWebView, WKNavigation?) Kotlin signature with didStartProvisionalNavigation above. + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didFinishNavigation: WKNavigation?, + ) { + onLoadingStateChange(false) + } + + override fun webView( + webView: WKWebView, + didFailNavigation: WKNavigation?, + withError: NSError, + ) { + onLoadingStateChange(false) + } + + // Shares the (WKWebView, WKNavigation?, NSError) Kotlin signature with didFailNavigation above. + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didFailProvisionalNavigation: WKNavigation?, + withError: NSError, + ) { + onLoadingStateChange(false) + } +} + private fun Boolean.toInterfaceStyle(): UIUserInterfaceStyle = if (this) UIUserInterfaceStyleDark else UIUserInterfaceStyleLight diff --git a/shared/src/jsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.js.kt b/shared/src/jsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.js.kt index 83cd4ca..f1fbd4c 100644 --- a/shared/src/jsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.js.kt +++ b/shared/src/jsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.js.kt @@ -1,14 +1,17 @@ package bzh.ajaury.chombev.web.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @Composable actual fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit, modifier: Modifier, ) { - // The Compose canvas can't host an iframe cleanly; open the page in the browser instead. + // The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser. + LaunchedEffect(Unit) { onLoadingStateChange(false) } WebViewUnavailable(url = url, modifier = modifier) } diff --git a/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.jvm.kt b/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.jvm.kt index 897a98d..d3a656b 100644 --- a/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.jvm.kt +++ b/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.jvm.kt @@ -1,14 +1,17 @@ package bzh.ajaury.chombev.web.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @Composable actual fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit, modifier: Modifier, ) { - // Desktop has no bundled web view; offer to open the page in the system browser instead. + // Desktop has no bundled web view; nothing loads, so report done and offer the system browser. + LaunchedEffect(Unit) { onLoadingStateChange(false) } WebViewUnavailable(url = url, modifier = modifier) } diff --git a/shared/src/wasmJsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.wasmJs.kt b/shared/src/wasmJsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.wasmJs.kt index 83cd4ca..f1fbd4c 100644 --- a/shared/src/wasmJsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.wasmJs.kt +++ b/shared/src/wasmJsMain/kotlin/bzh/ajaury/chombev/web/ui/PlatformWebView.wasmJs.kt @@ -1,14 +1,17 @@ package bzh.ajaury.chombev.web.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @Composable actual fun PlatformWebView( url: String, darkTheme: Boolean, + onLoadingStateChange: (Boolean) -> Unit, modifier: Modifier, ) { - // The Compose canvas can't host an iframe cleanly; open the page in the browser instead. + // The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser. + LaunchedEffect(Unit) { onLoadingStateChange(false) } WebViewUnavailable(url = url, modifier = modifier) }