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(
|
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)
|
||||||
|
|||||||
+33
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user