前言
如果把 Android
比作一台车的话,传统 View
相当于手动档,而 Compose
则相当于自动档,用过 Compose
就再也回不去了。
今天便来探讨下在 Compose
中如何使用 WebView 及其优化。
最终效果图先行:
在 Compose 中使用 WebView
那么回到今天的主题,在 Compose
如何使用 WebView
呢?
目前为止 Compose
还没有提供 WebView
的可组合项,因此我们要通过 AndroidView
来自定义实现WebView
。
改造 WebViewManager 实现前进和后退功能
之前写过 满满的WebView优化干货,让你的H5实现秒开体验。 效果还不错,所以在此基础上扩展 WebViewManager
实现前进和后退功能。
我们在 WebViewManager
中定义 backStack
和 forwardStack
来保存后退页和前进页。
- 当用户打开新
WebView
时将旧WebView
保存到后退栈中。 - 当用户按下后退时从
backStack
取出后退WebView
,并将旧WebView
保存到前进栈中。 - 当用户按下前进时从
forwardStack
取出后退WebView
,并将旧WebView
保存到后退栈中。
代码如下,为方便阅读,本文只贴关键代码,完整代码移步 fragmject · github 。
class WebViewManager private constructor() {
...
private val webViewMap = mutableMapOf<String, WebView>()
private val webViewQueue: ArrayDeque<WebView> = ArrayDeque()
private val backStack: ArrayDeque<String> = ArrayDeque()
private val forwardStack: ArrayDeque<String> = ArrayDeque()
private var lastBackWebView: WeakReference<WebView?> = WeakReference(null)
private fun obtain(context: Context, url: String): WebView {
val webView = webViewMap.getOrPut(url) {
getWebView(MutableContextWrapper(context))
}
if (webView.parent != null) {
(webView.parent as ViewGroup).removeView(webView)
}
val contextWrapper = webView.context as MutableContextWrapper
contextWrapper.baseContext = context
return webView
}
private fun back(webView: WebView): Boolean {
return try {
backStack.removeLast()//通过NoSuchElementException判断是否处在第一页
forwardStack.add(webView.originalUrl.toString())
true
} catch (e: Exception) {
lastBackWebView = WeakReference(webView)
false
}
}
private fun forward(webView: WebView): Boolean {
return try {
val forwardLastUrl = forwardStack.removeLast()
backStack.add(webView.originalUrl.toString())
forwardLastUrl
} catch (e: Exception) {
Log.e(this.javaClass.name, e.message.toString())
null
}
}
private fun recycle(webView: WebView) {
try {
webView.removeParentView()
val originalUrl = webView.originalUrl.toString()
if (lastBackWebView.get() != webView) {
if (!forwardStack.contains(originalUrl)) {
backStack.add(originalUrl)
}
} else {
destroy()
//重新缓存一个webView
prepare(webView.context)
}
} catch (e: Exception) {
Log.e(this.javaClass.name, e.message.toString())
}
}
...
}
先定义供外部调用 WebView 可组合项的 WebViewNavigator
原理也简单即通过 SharedFlow
来发送和接受命令
@Stable
class WebViewNavigator(
private val coroutineScope: CoroutineScope
) {
private sealed interface NavigationEvent {
data object Back : NavigationEvent
data object Forward : NavigationEvent
data object Reload : NavigationEvent
}
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow()
var lastLoadedUrl: String? by mutableStateOf(null)
internal set
var progress: Float by mutableFloatStateOf(0f)
internal set
@OptIn(FlowPreview::class)
internal suspend fun handleNavigationEvents(
onBack: () -> Unit = {},
onForward: () -> Unit = {},
reload: () -> Unit = {},
) = withContext(Dispatchers.Main) {
// 设置350(切换动画时间)的防抖,防止WebView回收未完成导致的崩溃
navigationEvents.debounce(350).collect { event ->
when (event) {
is NavigationEvent.Back -> onBack()
is NavigationEvent.Forward -> onForward()
is NavigationEvent.Reload -> reload()
}
}
}
fun navigateBack() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
}
fun navigateForward() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
}
fun reload() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
}
}
通过 AndroidView 来自定义 WebView 可组合
@Composable
fun WebView(
url: String,
navigator: WebViewNavigator,
modifier: Modifier = Modifier,
goBack: () -> Unit = {},
goForward: (url: String?) -> Unit = {},
shouldOverrideUrl: (url: String) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
var webView by remember { mutableStateOf<WebView?>(null) }
BackHandler(true) {
navigator.navigateBack()
}
webView?.let {
LaunchedEffect(it, navigator) {
with(navigator) {
handleNavigationEvents(
onBack = {
if (WebViewManager.back(it)) {
goBack()
} else {
onNavigateUp()
}
},
onForward = {
goForward(WebViewManager.forward(it))
},
reload = {
it.reload()
}
)
}
}
}
AndroidView(
factory = { context ->
WebViewManager.obtain(context, url).apply {
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
if (view == null || request == null) {
return false
}
val requestUrl = request.url.toString()
if (!request.isRedirect && URLUtil.isNetworkUrl(requestUrl) && requestUrl != url) {
shouldOverrideUrl(requestUrl)
return true
}
if (!URLUtil.isValidUrl(requestUrl)) {
try {
view.context.startActivity(Intent(Intent.ACTION_VIEW, request.url))
} catch (e: Exception) {
Log.e(this.javaClass.name, e.message.toString())
}
return true
}
return false
}
}
if (url.isValidURL() && !this.url.isValidURL()) {
this.loadUrl(url)
}
}.also { webView = it }
},
modifier = modifier,
onRelease = {
WebViewManager.recycle(it)
}
)
}
@Composable
fun rememberWebViewNavigator(
coroutineScope: CoroutineScope = rememberCoroutineScope()
): WebViewNavigator =
remember(coroutineScope) { WebViewNavigator(coroutineScope) }
通过 Navigation 实现跳转和切换动画
WebView 的跳转和切换动画通过 Navigation 实现。
在 Compose
中使用 Navigation
写起来跟 when
表达式一样,所以没有过的童鞋也无需担心,看下代码就差不多能理解了。
@Composable
fun WebViewNavGraph(
url: String,
navigator: WebViewNavigator,
modifier: Modifier = Modifier,
shouldOverrideUrl: (url: String) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
val navController = rememberNavController()
val navActions = remember(navController) { WebViewNavActions(navController) }
NavHost(
navController = navController,
startDestination = WebViewDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(url)}",
modifier = modifier,
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(350)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(350)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(350)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(350)
)
},
) {
composable("${WebViewDestinations.WEB_VIEW_ROUTE}/{url}") { backStackEntry ->
WebView(
url = backStackEntry.arguments?.getString("url") ?: url,
navigator = navigator,
goBack = {
if (navActions.canBack()) {
navActions.navigateBack()
} else {
onNavigateUp()
}
},
goForward = {
if (it != null) {
navActions.navigateToWebView(it)
}
},
shouldOverrideUrl = {
shouldOverrideUrl(it)
navActions.navigateToWebView(it)
},
onNavigateUp = onNavigateUp
)
}
}
}
class WebViewNavActions(
private val navController: NavHostController
) {
val canBack: () -> Boolean = {
navController.previousBackStackEntry != null
}
val navigateBack: () -> Unit = {
navController.navigateUp()
}
val navigateToWebView: (url: String) -> Unit = { url ->
navController.navigate(
WebViewDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(url)}",
navOptions { launchSingleTop = false }
)
}
}
object WebViewDestinations {
const val WEB_VIEW_ROUTE = "web_view_route"
}
完整的 WebScreen 代码
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WebScreen(
url: String,
webBookmarkList: List<String>,
onWebBookmark: (isAdd: Boolean, text: String) -> Unit = { _, _ -> },
onWebHistory: (isAdd: Boolean, text: String) -> Unit = { _, _ -> },
onNavigateToBookmarkHistory: () -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val wvNavigator = rememberWebViewNavigator()
Column(
modifier = Modifier
.background(colorResource(R.color.white))
.fillMaxSize()
.systemBarsPadding()
) {
ModalBottomSheetLayout(
sheetState = sheetState,
modifier = Modifier.weight(1f),
sheetContent = {
Row(
modifier = Modifier
.background(colorResource(R.color.white))
.height(50.dp)
) {
Button(
onClick = {
try {
val uri = Uri.parse(wvNavigator.lastLoadedUrl)
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.addCategory(Intent.CATEGORY_BROWSABLE)
context.startActivity(intent)
scope.launch { sheetState.hide() }
} catch (e: Exception) {
e.printStackTrace()
}
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_browse),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
Button(
onClick = {
onNavigateToBookmarkHistory()
scope.launch { sheetState.hide() }
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_history),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
Button(
onClick = {
onWebBookmark(
!webBookmarkList.contains(wvNavigator.lastLoadedUrl),
wvNavigator.lastLoadedUrl.toString()
)
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_bookmark),
contentDescription = null,
tint = colorResource(
if (webBookmarkList.contains(wvNavigator.lastLoadedUrl)) {
R.color.theme_orange
} else {
R.color.theme
}
)
)
}
Button(
onClick = {
wvNavigator.injectVConsole()
scope.launch { sheetState.hide() }
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_debug),
contentDescription = null,
tint = colorResource(
if (wvNavigator.injectVConsole) {
R.color.theme_orange
} else {
R.color.theme
}
)
)
}
}
}
) {
WebViewNavGraph(
url = url,
navigator = navigator,
modifier = Modifier.fillMaxSize(),
shouldOverrideUrl = {
onWebHistory(true, it)
},
onNavigateUp = onNavigateUp
)
}
AnimatedVisibility(visible = (wvNavigator.progress > 0f && wvNavigator.progress < 1f)) {
LinearProgressIndicator(
progress = wvNavigator.progress,
modifier = Modifier.fillMaxWidth(),
color = colorResource(R.color.theme_orange),
backgroundColor = colorResource(R.color.white)
)
}
Row(
modifier = Modifier
.background(colorResource(R.color.white))
.height(50.dp)
) {
Button(
onClick = {
wvNavigator.navigateBack()
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(17.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_back),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
Button(
onClick = {
wvNavigator.navigateForward()
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(17.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_forward),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
Button(
onClick = {
wvNavigator.reload()
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(15.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_refresh),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
Button(
onClick = {
scope.launch {
if (sheetState.isVisible) {
sheetState.hide()
} else {
sheetState.show()
}
}
},
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
shape = RoundedCornerShape(0),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.white),
contentColor = colorResource(R.color.theme)
),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.mipmap.ic_web_more),
contentDescription = null,
tint = colorResource(R.color.theme)
)
}
}
}
}
Thanks
以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~