mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-14 16:27:06 +05:30
feat(web): init
This commit is contained in:
parent
6084e5bb30
commit
5bcfe0a85c
13 changed files with 685 additions and 7 deletions
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="VectorPath"
|
id="VectorPath"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -11,6 +11,7 @@ junit = "5.10.1"
|
||||||
konvert = "2.4.0"
|
konvert = "2.4.0"
|
||||||
kotlin = "1.9.21"
|
kotlin = "1.9.21"
|
||||||
kotlinResult = "1.1.18"
|
kotlinResult = "1.1.18"
|
||||||
|
lifecycle = "2.7.0-rc01"
|
||||||
retrofit = "2.9.0"
|
retrofit = "2.9.0"
|
||||||
richtext = "0.17.0"
|
richtext = "0.17.0"
|
||||||
sentry-sdk = "7.0.0"
|
sentry-sdk = "7.0.0"
|
||||||
|
@ -36,7 +37,10 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
|
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
|
||||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
|
||||||
androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1"
|
androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1"
|
||||||
|
androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"
|
androidx-lifecycle-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"
|
||||||
androidx-navigation-compose = "androidx.navigation:navigation-compose:2.7.5"
|
androidx-navigation-compose = "androidx.navigation:navigation-compose:2.7.5"
|
||||||
androidx-paging-compose = "androidx.paging:paging-compose:3.2.1"
|
androidx-paging-compose = "androidx.paging:paging-compose:3.2.1"
|
||||||
|
@ -73,6 +77,7 @@ junit-legacy = "junit:junit:4.13.2"
|
||||||
konvert-api = { module = "io.mcarle:konvert-api", version.ref = "konvert" }
|
konvert-api = { module = "io.mcarle:konvert-api", version.ref = "konvert" }
|
||||||
konvert-processor = { module = "io.mcarle:konvert", version.ref = "konvert" }
|
konvert-processor = { module = "io.mcarle:konvert", version.ref = "konvert" }
|
||||||
kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6"
|
kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6"
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" }
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.0-alpha14" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha14)" variant="all" version="8.3.0-alpha14">
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
|
@ -163,4 +163,5 @@ include(
|
||||||
"database:core",
|
"database:core",
|
||||||
"database:impl",
|
"database:impl",
|
||||||
"model",
|
"model",
|
||||||
|
"web",
|
||||||
)
|
)
|
||||||
|
|
29
web/build.gradle.kts
Normal file
29
web/build.gradle.kts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Harsh Shandilya.
|
||||||
|
* Use of this source code is governed by an MIT-style
|
||||||
|
* license that can be found in the LICENSE file or at
|
||||||
|
* https://opensource.org/licenses/MIT.
|
||||||
|
*/
|
||||||
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.msfjarvis.claw.android-library")
|
||||||
|
id("dev.msfjarvis.claw.kotlin-android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.google.accompanist.web"
|
||||||
|
buildFeatures.compose = true
|
||||||
|
composeOptions.kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
||||||
|
kotlin.explicitApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.core)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.ui.util)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
}
|
4
web/lint-baseline.xml
Normal file
4
web/lint-baseline.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<issues format="6" by="lint 8.3.0-alpha16" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha16)" variant="all" version="8.3.0-alpha16">
|
||||||
|
|
||||||
|
</issues>
|
7
web/src/main/AndroidManifest.xml
Normal file
7
web/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<!--
|
||||||
|
~ Copyright © 2021-2023 Harsh Shandilya.
|
||||||
|
~ Use of this source code is governed by an MIT-style
|
||||||
|
~ license that can be found in the LICENSE file or at
|
||||||
|
~ https://opensource.org/licenses/MIT.
|
||||||
|
-->
|
||||||
|
<manifest />
|
632
web/src/main/java/com/google/accompanist/web/WebView.kt
Normal file
632
web/src/main/java/com/google/accompanist/web/WebView.kt
Normal file
|
@ -0,0 +1,632 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Harsh Shandilya.
|
||||||
|
* Use of this source code is governed by an MIT-style
|
||||||
|
* license that can be found in the LICENSE file or at
|
||||||
|
* https://opensource.org/licenses/MIT.
|
||||||
|
*/
|
||||||
|
package com.google.accompanist.web
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.google.accompanist.web.LoadingState.Finished
|
||||||
|
import com.google.accompanist.web.LoadingState.Loading
|
||||||
|
import dev.msfjarvis.claw.core.coroutines.DefaultDispatcherProvider
|
||||||
|
import dev.msfjarvis.claw.core.coroutines.DispatcherProvider
|
||||||
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||||
|
*
|
||||||
|
* If you require more customisation you are most likely better rolling your own and using this
|
||||||
|
* wrapper as an example.
|
||||||
|
*
|
||||||
|
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it is
|
||||||
|
* incorrectly sizing, use the layoutParams composable function instead.
|
||||||
|
*
|
||||||
|
* @param state The webview state holder where the Uri to load is defined.
|
||||||
|
* @param modifier A compose modifier
|
||||||
|
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||||
|
* the WebView back.
|
||||||
|
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||||
|
* navigation from outside the composable.
|
||||||
|
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||||
|
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will
|
||||||
|
* be subsequently overwritten after this lambda is called.
|
||||||
|
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved if
|
||||||
|
* you need to save and restore state in this WebView.
|
||||||
|
* @param client Provides access to WebViewClient via subclassing
|
||||||
|
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||||
|
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun WebView(
|
||||||
|
state: WebViewState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
captureBackPresses: Boolean = true,
|
||||||
|
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||||
|
onCreated: (WebView) -> Unit = {},
|
||||||
|
onDispose: (WebView) -> Unit = {},
|
||||||
|
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||||
|
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||||
|
factory: ((Context) -> WebView)? = null,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(modifier) {
|
||||||
|
// WebView changes it's layout strategy based on
|
||||||
|
// it's layoutParams. We convert from Compose Modifier to
|
||||||
|
// layout params here.
|
||||||
|
val width =
|
||||||
|
if (constraints.hasFixedWidth) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
|
||||||
|
val height =
|
||||||
|
if (constraints.hasFixedHeight) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
|
||||||
|
|
||||||
|
val layoutParams = FrameLayout.LayoutParams(width, height)
|
||||||
|
|
||||||
|
WebView(
|
||||||
|
state,
|
||||||
|
layoutParams,
|
||||||
|
Modifier,
|
||||||
|
captureBackPresses,
|
||||||
|
navigator,
|
||||||
|
onCreated,
|
||||||
|
onDispose,
|
||||||
|
client,
|
||||||
|
chromeClient,
|
||||||
|
factory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||||
|
*
|
||||||
|
* If you require more customisation you are most likely better rolling your own and using this
|
||||||
|
* wrapper as an example.
|
||||||
|
*
|
||||||
|
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it is
|
||||||
|
* incorrectly sizing, use the layoutParams composable function instead.
|
||||||
|
*
|
||||||
|
* @param state The webview state holder where the Uri to load is defined.
|
||||||
|
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
|
||||||
|
* @param modifier A compose modifier
|
||||||
|
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||||
|
* the WebView back.
|
||||||
|
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||||
|
* navigation from outside the composable.
|
||||||
|
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||||
|
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will
|
||||||
|
* be subsequently overwritten after this lambda is called.
|
||||||
|
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved if
|
||||||
|
* you need to save and restore state in this WebView.
|
||||||
|
* @param client Provides access to WebViewClient via subclassing
|
||||||
|
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||||
|
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun WebView(
|
||||||
|
state: WebViewState,
|
||||||
|
layoutParams: FrameLayout.LayoutParams,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
captureBackPresses: Boolean = true,
|
||||||
|
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||||
|
onCreated: (WebView) -> Unit = {},
|
||||||
|
onDispose: (WebView) -> Unit = {},
|
||||||
|
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||||
|
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||||
|
factory: ((Context) -> WebView)? = null,
|
||||||
|
) {
|
||||||
|
val webView = state.webView
|
||||||
|
|
||||||
|
BackHandler(captureBackPresses && navigator.canGoBack) { webView?.goBack() }
|
||||||
|
|
||||||
|
webView?.let { wv ->
|
||||||
|
LaunchedEffect(wv, navigator) { with(navigator) { wv.handleNavigationEvents() } }
|
||||||
|
|
||||||
|
LaunchedEffect(wv, state) {
|
||||||
|
snapshotFlow { state.content }
|
||||||
|
.collect { content ->
|
||||||
|
when (content) {
|
||||||
|
is WebContent.Url -> {
|
||||||
|
wv.loadUrl(content.url, content.additionalHttpHeaders)
|
||||||
|
}
|
||||||
|
is WebContent.Data -> {
|
||||||
|
wv.loadDataWithBaseURL(
|
||||||
|
content.baseUrl,
|
||||||
|
content.data,
|
||||||
|
content.mimeType,
|
||||||
|
content.encoding,
|
||||||
|
content.historyUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is WebContent.Post -> {
|
||||||
|
wv.postUrl(content.url, content.postData)
|
||||||
|
}
|
||||||
|
is WebContent.NavigatorOnly -> {
|
||||||
|
// NO-OP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the state of the client and chrome client
|
||||||
|
// This is done internally to ensure they always are the same instance as the
|
||||||
|
// parent Web composable
|
||||||
|
client.state = state
|
||||||
|
client.navigator = navigator
|
||||||
|
chromeClient.state = state
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
(factory?.invoke(context) ?: WebView(context))
|
||||||
|
.apply {
|
||||||
|
onCreated(this)
|
||||||
|
|
||||||
|
this.layoutParams = layoutParams
|
||||||
|
|
||||||
|
state.viewState?.let { this.restoreState(it) }
|
||||||
|
|
||||||
|
webChromeClient = chromeClient
|
||||||
|
webViewClient = client
|
||||||
|
}
|
||||||
|
.also { state.webView = it }
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
onRelease = { onDispose(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccompanistWebViewClient
|
||||||
|
*
|
||||||
|
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
|
||||||
|
*
|
||||||
|
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||||
|
* class that can be overriden if further custom behaviour is required.
|
||||||
|
*/
|
||||||
|
public open class AccompanistWebViewClient : WebViewClient() {
|
||||||
|
public open lateinit var state: WebViewState
|
||||||
|
internal set
|
||||||
|
|
||||||
|
public open lateinit var navigator: WebViewNavigator
|
||||||
|
internal set
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
state.loadingState = Loading(0.0f)
|
||||||
|
state.errorsForCurrentRequest.clear()
|
||||||
|
state.pageTitle = null
|
||||||
|
state.pageIcon = null
|
||||||
|
|
||||||
|
state.lastLoadedUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
state.loadingState = Finished
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
|
||||||
|
super.doUpdateVisitedHistory(view, url, isReload)
|
||||||
|
|
||||||
|
navigator.canGoBack = view.canGoBack()
|
||||||
|
navigator.canGoForward = view.canGoForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
error: WebResourceError?
|
||||||
|
) {
|
||||||
|
super.onReceivedError(view, request, error)
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
state.errorsForCurrentRequest.add(WebViewError(request, error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccompanistWebChromeClient
|
||||||
|
*
|
||||||
|
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
|
||||||
|
*
|
||||||
|
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||||
|
* class that can be overridden if further custom behaviour is required.
|
||||||
|
*/
|
||||||
|
public open class AccompanistWebChromeClient : WebChromeClient() {
|
||||||
|
public open lateinit var state: WebViewState
|
||||||
|
internal set
|
||||||
|
|
||||||
|
override fun onReceivedTitle(view: WebView, title: String?) {
|
||||||
|
super.onReceivedTitle(view, title)
|
||||||
|
state.pageTitle = title
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
|
||||||
|
super.onReceivedIcon(view, icon)
|
||||||
|
state.pageIcon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||||
|
super.onProgressChanged(view, newProgress)
|
||||||
|
if (state.loadingState is Finished) return
|
||||||
|
state.loadingState = Loading(newProgress / 100.0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WebContent {
|
||||||
|
public data class Url(
|
||||||
|
val url: String,
|
||||||
|
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||||
|
) : WebContent()
|
||||||
|
|
||||||
|
public data class Data(
|
||||||
|
val data: String,
|
||||||
|
val baseUrl: String? = null,
|
||||||
|
val encoding: String = "utf-8",
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val historyUrl: String? = null
|
||||||
|
) : WebContent()
|
||||||
|
|
||||||
|
public data class Post(val url: String, val postData: ByteArray) : WebContent() {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Post
|
||||||
|
|
||||||
|
if (url != other.url) return false
|
||||||
|
if (!postData.contentEquals(other.postData)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = url.hashCode()
|
||||||
|
result = 31 * result + postData.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public data object NavigatorOnly : WebContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun WebContent.withUrl(url: String) =
|
||||||
|
when (this) {
|
||||||
|
is WebContent.Url -> copy(url = url)
|
||||||
|
else -> WebContent.Url(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sealed class for constraining possible loading states. See [Loading] and [Finished]. */
|
||||||
|
public sealed class LoadingState {
|
||||||
|
/** Describes a WebView that has not yet loaded for the first time. */
|
||||||
|
public data object Initializing : LoadingState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a [progress]
|
||||||
|
* property which is updated by the webview.
|
||||||
|
*/
|
||||||
|
public data class Loading(val progress: Float) : LoadingState()
|
||||||
|
|
||||||
|
/** Describes a webview that has finished loading content. */
|
||||||
|
public data object Finished : LoadingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A state holder to hold the state for the WebView. In most cases this will be remembered using the
|
||||||
|
* rememberWebViewState(uri) function.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
public class WebViewState(webContent: WebContent) {
|
||||||
|
public var lastLoadedUrl: String? by mutableStateOf(null)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** The content being loaded by the WebView */
|
||||||
|
public var content: WebContent by mutableStateOf(webContent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
|
||||||
|
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
|
||||||
|
*/
|
||||||
|
public var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** Whether the webview is currently loading data in its main frame */
|
||||||
|
public val isLoading: Boolean
|
||||||
|
get() = loadingState !is Finished
|
||||||
|
|
||||||
|
/** The title received from the loaded content of the current page */
|
||||||
|
public var pageTitle: String? by mutableStateOf(null)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** the favicon received from the loaded content of the current page */
|
||||||
|
public var pageIcon: Bitmap? by mutableStateOf(null)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list for errors captured in the last load. Reset when a new page is loaded. Errors could be
|
||||||
|
* from any resource (iframe, image, etc.), not just for the main page. For more fine grained
|
||||||
|
* control use the OnError callback of the WebView.
|
||||||
|
*/
|
||||||
|
public val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved view state from when the view was destroyed last. To restore state, use the navigator
|
||||||
|
* and only call loadUrl if the bundle is null. See WebViewSaveStateSample.
|
||||||
|
*/
|
||||||
|
public var viewState: Bundle? = null
|
||||||
|
internal set
|
||||||
|
|
||||||
|
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
|
||||||
|
// onDestroy is called after the state saver and so can't be used.
|
||||||
|
internal var webView by mutableStateOf<WebView?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
|
||||||
|
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
|
||||||
|
*
|
||||||
|
* @see [rememberWebViewNavigator]
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
public class WebViewNavigator(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
) {
|
||||||
|
private sealed interface NavigationEvent {
|
||||||
|
data object Back : NavigationEvent
|
||||||
|
|
||||||
|
data object Forward : NavigationEvent
|
||||||
|
|
||||||
|
data object Reload : NavigationEvent
|
||||||
|
|
||||||
|
data object StopLoading : NavigationEvent
|
||||||
|
|
||||||
|
data class LoadUrl(
|
||||||
|
val url: String,
|
||||||
|
val additionalHttpHeaders: Map<String, String> = emptyMap()
|
||||||
|
) : NavigationEvent
|
||||||
|
|
||||||
|
data class LoadHtml(
|
||||||
|
val html: String,
|
||||||
|
val baseUrl: String? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val encoding: String? = "utf-8",
|
||||||
|
val historyUrl: String? = null
|
||||||
|
) : NavigationEvent
|
||||||
|
|
||||||
|
data class PostUrl(val url: String, val postData: ByteArray) : NavigationEvent {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as PostUrl
|
||||||
|
|
||||||
|
if (url != other.url) return false
|
||||||
|
if (!postData.contentEquals(other.postData)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = url.hashCode()
|
||||||
|
result = 31 * result + postData.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
|
||||||
|
|
||||||
|
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
|
||||||
|
internal suspend fun WebView.handleNavigationEvents(): Nothing =
|
||||||
|
withContext(dispatcherProvider.main()) {
|
||||||
|
navigationEvents.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is NavigationEvent.Back -> goBack()
|
||||||
|
is NavigationEvent.Forward -> goForward()
|
||||||
|
is NavigationEvent.Reload -> reload()
|
||||||
|
is NavigationEvent.StopLoading -> stopLoading()
|
||||||
|
is NavigationEvent.LoadHtml ->
|
||||||
|
loadDataWithBaseURL(
|
||||||
|
event.baseUrl,
|
||||||
|
event.html,
|
||||||
|
event.mimeType,
|
||||||
|
event.encoding,
|
||||||
|
event.historyUrl
|
||||||
|
)
|
||||||
|
is NavigationEvent.LoadUrl -> {
|
||||||
|
loadUrl(event.url, event.additionalHttpHeaders)
|
||||||
|
}
|
||||||
|
is NavigationEvent.PostUrl -> {
|
||||||
|
postUrl(event.url, event.postData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the web view is able to navigate backwards, false otherwise. */
|
||||||
|
public var canGoBack: Boolean by mutableStateOf(false)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** True when the web view is able to navigate forwards, false otherwise. */
|
||||||
|
public var canGoForward: Boolean by mutableStateOf(false)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
public fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
navigationEvents.emit(NavigationEvent.LoadUrl(url, additionalHttpHeaders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun loadHtml(
|
||||||
|
html: String,
|
||||||
|
baseUrl: String? = null,
|
||||||
|
mimeType: String? = null,
|
||||||
|
encoding: String? = "utf-8",
|
||||||
|
historyUrl: String? = null
|
||||||
|
) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
navigationEvents.emit(NavigationEvent.LoadHtml(html, baseUrl, mimeType, encoding, historyUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun postUrl(url: String, postData: ByteArray) {
|
||||||
|
coroutineScope.launch { navigationEvents.emit(NavigationEvent.PostUrl(url, postData)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigates the webview back to the previous page. */
|
||||||
|
public fun navigateBack() {
|
||||||
|
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigates the webview forward after going back from a page. */
|
||||||
|
public fun navigateForward() {
|
||||||
|
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the current page in the webview. */
|
||||||
|
public fun reload() {
|
||||||
|
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the current page load (if one is loading). */
|
||||||
|
public fun stopLoading() {
|
||||||
|
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
|
||||||
|
* override.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun rememberWebViewNavigator(
|
||||||
|
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(),
|
||||||
|
): WebViewNavigator =
|
||||||
|
remember(coroutineScope) { WebViewNavigator(coroutineScope, dispatcherProvider) }
|
||||||
|
|
||||||
|
/** A wrapper class to hold errors from the WebView. */
|
||||||
|
@Immutable
|
||||||
|
public data class WebViewError(
|
||||||
|
/** The request the error came from. */
|
||||||
|
val request: WebResourceRequest?,
|
||||||
|
/** The error that was reported. */
|
||||||
|
val error: WebResourceError
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebView state that is remembered across Compositions.
|
||||||
|
*
|
||||||
|
* @param url The url to load in the WebView
|
||||||
|
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to
|
||||||
|
* [WebView.loadUrl]. Note that these headers are used for all subsequent requests of the WebView.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun rememberWebViewState(
|
||||||
|
url: String,
|
||||||
|
additionalHttpHeaders: ImmutableMap<String, String> = persistentMapOf()
|
||||||
|
): WebViewState =
|
||||||
|
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||||
|
// a recomposition loop when the webview updates the url itself.
|
||||||
|
remember {
|
||||||
|
WebViewState(WebContent.Url(url = url, additionalHttpHeaders = additionalHttpHeaders))
|
||||||
|
}
|
||||||
|
.apply {
|
||||||
|
this.content = WebContent.Url(url = url, additionalHttpHeaders = additionalHttpHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebView state that is remembered across Compositions.
|
||||||
|
*
|
||||||
|
* @param data The uri to load in the WebView
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun rememberWebViewStateWithHTMLData(
|
||||||
|
data: String,
|
||||||
|
baseUrl: String? = null,
|
||||||
|
encoding: String = "utf-8",
|
||||||
|
mimeType: String? = null,
|
||||||
|
historyUrl: String? = null
|
||||||
|
): WebViewState =
|
||||||
|
remember { WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl)) }
|
||||||
|
.apply { this.content = WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebView state that is remembered across Compositions.
|
||||||
|
*
|
||||||
|
* @param url The url to load in the WebView
|
||||||
|
* @param postData The data to be posted to the WebView with the url
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun rememberWebViewState(url: String, postData: ByteArray): WebViewState =
|
||||||
|
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||||
|
// a recomposition loop when the webview updates the url itself.
|
||||||
|
remember { WebViewState(WebContent.Post(url = url, postData = postData)) }
|
||||||
|
.apply { this.content = WebContent.Post(url = url, postData = postData) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebView state that is remembered across Compositions and saved across activity
|
||||||
|
* recreation. When using saved state, you cannot change the URL via recomposition. The only way to
|
||||||
|
* load a URL is via a WebViewNavigator.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun rememberSaveableWebViewState(): WebViewState =
|
||||||
|
rememberSaveable(saver = WebStateSaver) { WebViewState(WebContent.NavigatorOnly) }
|
||||||
|
|
||||||
|
public val WebStateSaver: Saver<WebViewState, Any> = run {
|
||||||
|
val pageTitleKey = "pagetitle"
|
||||||
|
val lastLoadedUrlKey = "lastloaded"
|
||||||
|
val stateBundle = "bundle"
|
||||||
|
|
||||||
|
mapSaver(
|
||||||
|
save = {
|
||||||
|
val viewState = Bundle().apply { it.webView?.saveState(this) }
|
||||||
|
mapOf(
|
||||||
|
pageTitleKey to it.pageTitle,
|
||||||
|
lastLoadedUrlKey to it.lastLoadedUrl,
|
||||||
|
stateBundle to viewState
|
||||||
|
)
|
||||||
|
},
|
||||||
|
restore = {
|
||||||
|
WebViewState(WebContent.NavigatorOnly).apply {
|
||||||
|
this.pageTitle = it[pageTitleKey] as String?
|
||||||
|
this.lastLoadedUrl = it[lastLoadedUrlKey] as String?
|
||||||
|
this.viewState = it[stateBundle] as Bundle?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue