new: add loading overlay for WebView widget while pages load
This commit is contained in:
@@ -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)
|
||||
|
||||
+33
-1
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user