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( actual fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
AndroidView( AndroidView(
@@ -23,7 +24,7 @@ actual fun PlatformWebView(
factory = { context -> factory = { context ->
WebView(context).apply { WebView(context).apply {
// Keep navigation inside the embedded view instead of launching an external browser. // Keep navigation inside the embedded view instead of launching an external browser.
webViewClient = WebClientDelegatingExtLinks() webViewClient = WebClientDelegatingExtLinks(onLoadingStateChange = onLoadingStateChange)
settings.javaScriptEnabled = false settings.javaScriptEnabled = false
settings.domStorageEnabled = true settings.domStorageEnabled = true
applyDarkTheme(darkTheme) applyDarkTheme(darkTheme)
@@ -1,11 +1,43 @@
package bzh.ajaury.chombev.web.ui package bzh.ajaury.chombev.web.ui
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient 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( override fun shouldOverrideUrlLoading(
view: WebView, view: WebView,
request: WebResourceRequest, 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, * 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 * 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. * [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 @Composable
expect fun PlatformWebView( expect fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier, 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 package bzh.ajaury.chombev.web.ui
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable 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 androidx.compose.ui.Modifier
import bzh.ajaury.chombev.navigation.WebPage import bzh.ajaury.chombev.navigation.WebPage
/** /**
* Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. The * Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. A
* surrounding top and bottom bars are provided by the app-level scaffold. * [WebViewLoader] covers the page while it loads. The surrounding top and bottom bars are provided by
* the app-level scaffold.
*/ */
@Composable @Composable
fun WebViewScreen( fun WebViewScreen(
@@ -18,11 +24,22 @@ fun WebViewScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
) { ) {
PlatformWebView( // Start in the loading state; each distinct page loads afresh.
url = page.url, var isLoading by remember(page) { mutableStateOf(true) }
darkTheme = darkTheme,
Box(
modifier = Modifier modifier = Modifier
.padding(contentPadding) .padding(contentPadding)
.fillMaxSize(), .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 package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView import androidx.compose.ui.interop.UIKitView
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import platform.CoreGraphics.CGRectZero import platform.CoreGraphics.CGRectZero
import platform.Foundation.NSError
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.Foundation.NSURLRequest import platform.Foundation.NSURLRequest
import platform.UIKit.UIUserInterfaceStyle import platform.UIKit.UIUserInterfaceStyle
import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleDark import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleDark
import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleLight import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleLight
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKWebView import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration import platform.WebKit.WKWebViewConfiguration
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
@Composable @Composable
actual fun PlatformWebView( actual fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
// WKWebView holds its navigationDelegate weakly, so keep a strong reference across recompositions.
val navigationDelegate = remember { WebViewNavigationDelegate() }
navigationDelegate.onLoadingStateChange = onLoadingStateChange
UIKitView( UIKitView(
modifier = modifier, modifier = modifier,
factory = { factory = {
@@ -27,6 +38,7 @@ actual fun PlatformWebView(
frame = CGRectZero.readValue(), frame = CGRectZero.readValue(),
configuration = WKWebViewConfiguration(), configuration = WKWebViewConfiguration(),
).apply { ).apply {
navigationDelegate = navigationDelegate
setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle()) setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle())
NSURL.URLWithString(url)?.let { nsUrl -> NSURL.URLWithString(url)?.let { nsUrl ->
loadRequest(NSURLRequest(uRL = 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 private fun Boolean.toInterfaceStyle(): UIUserInterfaceStyle = if (this) UIUserInterfaceStyleDark else UIUserInterfaceStyleLight
@@ -1,14 +1,17 @@
package bzh.ajaury.chombev.web.ui package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@Composable @Composable
actual fun PlatformWebView( actual fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier, 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) WebViewUnavailable(url = url, modifier = modifier)
} }
@@ -1,14 +1,17 @@
package bzh.ajaury.chombev.web.ui package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@Composable @Composable
actual fun PlatformWebView( actual fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier, 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) WebViewUnavailable(url = url, modifier = modifier)
} }
@@ -1,14 +1,17 @@
package bzh.ajaury.chombev.web.ui package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@Composable @Composable
actual fun PlatformWebView( actual fun PlatformWebView(
url: String, url: String,
darkTheme: Boolean, darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier, 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) WebViewUnavailable(url = url, modifier = modifier)
} }