new: add loading overlay for WebView widget while pages load

This commit is contained in:
2026-07-07 23:20:46 +02:00
parent bb10764178
commit b063dfaff6
9 changed files with 165 additions and 11 deletions
@@ -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)
@@ -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,
@@ -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,
)
@@ -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())
}
}
@@ -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())
}
}
}
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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)
}