背景
WebView是我们在开发中经常会使用到的组件,我们可以用它来展示动态的Html页面。在Android的View体系中,我们可以直接在xml中添加WebView组件即可使用。但是在Jetpack Compose中,并没有可以直接使用的WebView组件。那么我们该如何在Compose中使用WebView呢?
原生使用
最简单的办法就是直接使用AndroidView来包裹原生的WebView:
@Composable
fun MyContent(){
// Declare a string that contains a url
val mUrl = "https://www.google.com"
// Adding a WebView inside AndroidView
// with layout as full screen
AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
webViewClient = WebViewClient()
loadUrl(mUrl)
}
}, update = {
it.loadUrl(mUrl)
})
}
可以看到,我们直接在AndroidView的factory中新建了一个WebView并指定了layoutParams还有webViewClient,然后调用loadUrl(mUrl)执行了网页的加载。
但是这只适用于无状态的单次加载,如果我们需要获取网页的标题,加载状态,当前加载Url该怎么办呢?同样的,如果我们需要控制网页的前进,后退,以及加载新链接呢?更复杂的是如果我们需要在加载Url的基础上同时支持Post和Html代码的加载呢?
这就需要引出我们这篇文章的主角 Accompanist WebView。
Accompanist
https://github.com/google/accompanist
这个库相信大部分Compose开发者应该都有接触过。它的初衷就是为Jetpack Compose扩展一些实用的能力。到目前为止,它已经提供了FlowLayout,Pager,SwipeRefresh等多个实用的组件。而它也提供了一个对于WebView组件的封装,可以帮助我们轻松完成上面提到的需求。
注意:根据这篇官方公告,Accompanist从0.33.1-alpha版本开始将WebView标记为废弃,即不再提供维护和更新。并且它会在两个版本后将相关API从仓库中移除。然而,我们本身仍在项目中依赖和使用该库。因此,我fork了这个库并且做了一些功能的改善,后续也会持续更新新的能力。该库已经发布到Jitpack上了,项目地址:https://github.com/KevinnZou/compose-webview
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation "com.github.KevinnZou:compose-webview:0.33.2"
}
基础使用
它的基础使用非常简单:
val state = rememberWebViewState("https://example.com")
WebView(
state
)
主要用到了两个关键的API,WebView用来提供UI布局,rememberWebViewState用来提供状态。
WebViewState
这是WebView组件的状态类,内部维护了关于WebView的状态属性。例如当前加载的Url,当前加载内容,加载状态,页面标题和icon以及错误状态等。
/**
* 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)
}
而rememberWebViewState则是提供了一个基础的加载Url的WebViewState
/**
* 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: Map<String, String> = emptyMap()
): 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
)
}
有了WebViewState,我们就可以在外部去获取Web相关的属性并展示了:
Column {
val state = rememberWebViewState("https://example.com")
Text(text = "${state.pageTitle}")
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = loadingState.progress,
modifier = Modifier.fillMaxWidth()
)
}
WebView(
state
)
}
WebViewNavigator
还有一个重要的类就是WebViewNavigator,它封装了WebView导航相关的能力并暴露给开发者。例如前进,后退,重新加载,停止加载等。此外,加载链接,Post数据,加载Html等基础的加载能力也封装在了这里,最终会传递到WebView完成链接的加载。
/**
* 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 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
}
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(Dispatchers.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) }
}
}
rememberWebViewNavigator则是提供了一个默认的navigator并保存在remember中
/**
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
* override.
*/
@Composable
public fun rememberWebViewNavigator(
coroutineScope: CoroutineScope = rememberCoroutineScope()
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
使用上我们就可以通过navigator来操控WebView的前进后退了
Column {
val state = rememberWebViewState("https://example.com")
val navigator = rememberWebViewNavigator()
TopAppBar(
title = { Text(text = "WebView Sample") },
navigationIcon = {
if (navigator.canGoBack) {
IconButton(onClick = { navigator.navigateBack() }) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
}
)
Text(text = "${state.pageTitle}")
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = loadingState.progress,
modifier = Modifier.fillMaxWidth()
)
}
WebView(
state = state,
navigator = navigator
)
}
WebView
最后让我们来看一下它的完整API:
/**
* 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,
)
可以看到,几个重要的参数已经在之前介绍过了,剩下的也都很好理解。最后一个参数要注意一下,它允许开发者提供一个工厂方法来创建自定义的WebView。如果工程中已经有定制好的WebView,可以通过这个方法传入。
WebView(
...
factory = { context -> CustomWebView(context) }
)
完整示例
class BasicWebViewSample : ComponentActivity() {
val initialUrl = "https://google.com"
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AccompanistSampleTheme {
val state = rememberWebViewState(url = initialUrl)
val navigator = rememberWebViewNavigator()
var textFieldValue by remember(state.lastLoadedUrl) {
mutableStateOf(state.lastLoadedUrl)
}
Column {
TopAppBar(
title = { Text(text = "WebView Sample") },
navigationIcon = {
if (navigator.canGoBack) {
IconButton(onClick = { navigator.navigateBack() }) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
}
)
Row {
Box(modifier = Modifier.weight(1f)) {
if (state.errorsForCurrentRequest.isNotEmpty()) {
Image(
imageVector = Icons.Default.Error,
contentDescription = "Error",
colorFilter = ColorFilter.tint(Color.Red),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(8.dp)
)
}
OutlinedTextField(
value = textFieldValue ?: "",
onValueChange = { textFieldValue = it },
modifier = Modifier.fillMaxWidth()
)
}
Button(
onClick = {
textFieldValue?.let {
navigator.loadUrl(it)
}
},
modifier = Modifier.align(Alignment.CenterVertically)
) {
Text("Go")
}
}
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = loadingState.progress,
modifier = Modifier.fillMaxWidth()
)
}
// A custom WebViewClient and WebChromeClient can be provided via subclassing
val webClient = remember {
object : AccompanistWebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: Bitmap?
) {
super.onPageStarted(view, url, favicon)
Log.d("Accompanist WebView", "Page started loading for $url")
}
}
}
WebView(
state = state,
modifier = Modifier
.weight(1f),
navigator = navigator,
onCreated = { webView ->
webView.settings.javaScriptEnabled = true
},
client = webClient
)
}
}
}
}
}
下载
repositories {
mavenCentral()
}
dependencies {
implementation "com.google.accompanist:accompanist-webview:0.33.1-alpha"
}
注意:accompanist webview库已经停止维护,建议使用我fork的版本:
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation "com.github.KevinnZou:compose-webview:0.33.2"
}
总结
相比于简易的使用AndroidView包裹的WebView的方法,Accompanist库提供了一个功能更加完善,自定义程度更高,使用更加简易的WebView。但是很遗憾它已经停止维护了,欢迎大家使用我fork的版本并且提出改进建议!