feature: embed web pages inside the app using <iframe> for web platforms
This commit is contained in:
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- show on boarding as a modal on large screens
|
||||
- limit player controller size
|
||||
- browse the web pages inside the app on web, instead of only linking to them
|
||||
- embed web pages inside the app on web
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ kotlin {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.webkit)
|
||||
}
|
||||
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
|
||||
webMain.dependencies {
|
||||
// DOM access (document, navigator) to embed the page in an <iframe>
|
||||
implementation(libs.kotlinx.browser)
|
||||
}
|
||||
wasmJsMain {
|
||||
// HTMLIFrameElement interop on Wasm requires this opt-in.
|
||||
languageSettings.optIn("kotlin.js.ExperimentalWasmJsInterop")
|
||||
}
|
||||
commonMain.dependencies {
|
||||
// Core modules
|
||||
implementation(projects.core.logging)
|
||||
|
||||
@@ -4,11 +4,12 @@ import androidx.compose.runtime.Composable
|
||||
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.
|
||||
* Embeds a live web page. Android and iOS use their native web view, the web uses an `<iframe>`
|
||||
* overlaying the Compose canvas, and all three honour [darkTheme] so the page matches the app's
|
||||
* light/dark appearance — as far as the page itself allows. Desktop, which has no web view to
|
||||
* embed, falls back to [WebViewUnavailable] and offers to open the page in the system browser.
|
||||
*
|
||||
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline
|
||||
* When [url] cannot be reached, the embedded views swap in [offlineHtml] — a bundled offline
|
||||
* snapshot — so the page still shows content. When it is `null` no fallback is loaded.
|
||||
*
|
||||
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// 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)
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package bzh.ajaury.chombev.web.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.HtmlElementView
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import org.w3c.dom.HTMLIFrameElement
|
||||
|
||||
/**
|
||||
* Scripts, forms and target=_blank links keep the embedded site usable; top-level navigation is
|
||||
* withheld, so a framed page cannot replace the app with itself.
|
||||
*/
|
||||
private const val IFRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
|
||||
/**
|
||||
* Embeds the page in an `<iframe>`. [HtmlElementView] overlays the element on the Compose canvas and
|
||||
* keeps it aligned with this composable's bounds, so the page is browsed inside the app rather than
|
||||
* in a separate tab.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
actual fun PlatformWebView(
|
||||
url: String,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// The element outlives recompositions, so its load listener has to read the latest callback.
|
||||
val currentOnLoadingStateChange by rememberUpdatedState(onLoadingStateChange)
|
||||
|
||||
HtmlElementView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
(document.createElement("iframe") as HTMLIFrameElement).apply {
|
||||
setAttribute("sandbox", IFRAME_SANDBOX)
|
||||
setAttribute("referrerpolicy", "no-referrer")
|
||||
style.border = "none"
|
||||
style.width = "100%"
|
||||
style.height = "100%"
|
||||
addEventListener("load") { currentOnLoadingStateChange(false) }
|
||||
}
|
||||
},
|
||||
// Re-applied on recomposition, but only the theme changes without reloading the page: `src`
|
||||
// is left alone unless it actually differs.
|
||||
update = { iframe ->
|
||||
// Propagating a colour scheme is all a cross-origin frame allows; a page with
|
||||
// `prefers-color-scheme` styles then matches the app.
|
||||
iframe.style.setProperty("color-scheme", if (darkTheme) "dark" else "light")
|
||||
|
||||
if (offlineHtml != null && !window.navigator.onLine) {
|
||||
// The snapshots use absolute URLs, so they need no base URL to render correctly.
|
||||
if (iframe.srcdoc != offlineHtml) iframe.srcdoc = offlineHtml
|
||||
} else if (iframe.src != url) {
|
||||
currentOnLoadingStateChange(true)
|
||||
iframe.src = url
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user