前言
实际开发中,切换Tab的功能很常见,根据选中的Tab类型,切换对应的界面:
在Compose中做这个功能的时候,容易陷入固定思维,写出以下的代码:
kotlin
@Composable
private fun Content() {
/** 当前选中的Tab */
var selectedTab by remember { mutableStateOf(TabType.Home) }
Box {
when (selectedTab) {
TabType.Home -> TabHome()
TabType.Me -> TabMe()
}
}
}
这样子用起来没什么问题,但是每次切换的时候,Tab对应的可组合项都会从组合中做移除和添加的操作,如果Tab的UI比较复杂那么切换动作是一个比较重的操作。
问题重现
我们先重现一下问题,通过日志打印一下Tab可组合项的生命周期:
kotlin
private enum class TabType {
Home,
Me,
}
@Composable
private fun Content() {
/** 当前选中的Tab */
var selectedTab by remember { mutableStateOf(TabType.Home) }
Column(modifier = Modifier.fillMaxSize()) {
// 显示Tab
Tabs(selectedTab, modifier = Modifier.weight(1f))
// 底部导航栏,点击切换Tab
BottomNavigation(selectedTab) {
Log.i("compose-demo", "click $it")
selectedTab = it
}
}
}
@Composable
private fun Tabs(
selectedTab: TabType,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
when (selectedTab) {
TabType.Home -> TabContent(TabType.Home)
TabType.Me -> TabContent(TabType.Me)
}
}
}
@Composable
private fun TabContent(tabType: TabType) {
// 打印生命周期日志
DisposableEffect(tabType) {
Log.i("compose-demo", "tab:${tabType.name}")
onDispose { Log.i("compose-demo", "tab:${tabType.name} onDispose") }
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = tabType.name)
}
}
代码比较简单,Tabs
函数根据选中的Tab类型selectedTab
来展示对应的Tab内容,TabContent
是Tab要显示的内容,里面的DisposableEffect
用来打印可组合项的生命周期日志。
运行代码,查看日志:
less
compose-demo I tab:Home
compose-demo I click Me
compose-demo I tab:Home onDispose
compose-demo I tab:Me
compose-demo I click Home
compose-demo I tab:Me onDispose
compose-demo I tab:Home
可以看到点击切换Tab的时候会打印onDispose
,说明TabContent
在切换的时候会被移除。
分析解决
如果Tab的内容是复杂的UI,那么每次切换都移除创建,是不合理的,应该缓存下来。
怎么缓存呢?可以把未选中的Tab隐藏起来而不是移除掉 。在Compose中显示隐藏很容易想到AnimatedVisibility
和AnimatedContent
这两个可组合项,然而它们也会更改组合,有兴趣的读者可以测试一下。
我们可以设置缩放值来达到显示隐藏的效果,选中的话就是缩放1
,未选中的话就是缩放0
,写一个包装缩放功能的函数:
kotlin
@Composable
private fun SelectedBox(
modifier: Modifier = Modifier,
// 当前是否选中
selected: Boolean,
content: @Composable () -> Unit
) {
Box(
modifier = modifier.graphicsLayer {
// 根据selected改变缩放值
this.scaleX = if (selected) 1f else 0f
}
) {
content()
}
}
代码很简单,用Box
包装一下要显示的content
,根据选中状态selected
切换缩放值。
使用一下SelectedBox
:
kotlin
@Composable
private fun Tabs(
selectedTab: TabType,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
SelectedBox(selected = selectedTab == TabType.Home) {
TabContent(TabType.Home)
}
SelectedBox(selected = selectedTab == TabType.Me) {
TabContent(TabType.Me)
}
}
}
直接放置了两个SelectedBox
,相当于一开始,组合就是固定的,通过修改缩放值来实现显示和隐藏。
运行代码,查看日志:
可以看到,现在切换Tab不会导致Tab的内容被移除了,但是又有一个新的问题,一开始就加载了所有Tab,继续解决一下这个问题。
kotlin
@Composable
private fun Tabs(
selectedTab: TabType,
modifier: Modifier = Modifier,
) {
// 保存所有选中过的Tab
val selectedTabHolder = remember { mutableStateListOf<TabType>() }
LaunchedEffect(selectedTab) {
// 保存选中的Tab
if (!selectedTabHolder.contains(selectedTab)) {
selectedTabHolder.add(selectedTab)
}
}
Box(modifier = modifier.fillMaxSize()) {
// 判断选中过才添加组合
if (selectedTabHolder.contains(TabType.Home)) {
SelectedBox(selected = selectedTab == TabType.Home) {
TabContent(TabType.Home)
}
}
// 判断选中过才添加组合
if (selectedTabHolder.contains(TabType.Me)) {
SelectedBox(selected = selectedTab == TabType.Me) {
TabContent(TabType.Me)
}
}
}
}
用selectedTabHolder
保存所有选中过的Tab,当selectedTab
变化时,把它添加到selectedTabHolder
里面,最后再判断一下selectedTabHolder
里面存在的Tab才做显示。
这里提一下,selectedTabHolder
是一个SnapshotStateList
,它的内容变化可以被Compose监测到。
运行代码,查看日志:
可以看到一开始只加载了Home
的内容,点击Me
之后才加载并显示了Me
。
封装
如果每个地方都这么写的话,很麻烦,我们可以把这个功能封装起来。
先看一下期望的Api:
kotlin
@Composable
private fun Tabs(
selectedTab: TabType,
modifier: Modifier = Modifier,
) {
TabContainer(selectedKey = selectedTab, modifier = modifier) {
tab(key = TabType.Home) {
TabContent(TabType.Home)
}
tab(key = TabType.Me) {
TabContent(TabType.Me)
}
}
}
封装一个TabContainer
函数,在它的Lambda参数中,可以通过tab
方法为每个Tab类型配置要显示的内容,最后把当前选中的selectedTab
传给TabContainer
就可以了。
这样子的Api还是挺方便的,我们来实现一下。
kotlin
interface TabContainerScope {
fun tab(
key: Any,
content: @Composable () -> Unit,
)
}
Lambda参数的接收者TabContainerScope
,用于调用tab
方法配置要显示的内容。
再写一下TabContainerScope
的实现类:
kotlin
private class TabContainerImpl : TabContainerScope {
// 所有Tab的配置信息
private val _store: MutableMap<Any, @Composable () -> Unit> = hashMapOf()
// 选中过的Tab的配置信息
private val _activeTabs: MutableMap<Any, MutableState<@Composable () -> Unit>> = mutableStateMapOf()
// 当前是否处于配置模式
private var _config = false
// 开始配置
fun startConfig() {
_config = true
}
override fun tab(
key: Any,
content: @Composable () -> Unit,
) {
// 检查是否处于配置模式
check(_config) { "Config not started." }
// 保存配置
_store[key] = content
}
// 显示当前选中的Tab
@Composable
fun Content(selectedKey: Any) {
SideEffect {
if (_config) {
_config = false
// 把新配置更新到已经选中过的Tab中
_activeTabs.forEach { active ->
active.value.value = checkNotNull(_store[active.key])
}
}
}
LaunchedEffect(selectedKey) {
if (!_activeTabs.containsKey(selectedKey)) {
// 把选中的Tab信息加载到_activeTabs中
val content = checkNotNull(_store[selectedKey]) { "Key $selectedKey was not found." }
_activeTabs[selectedKey] = mutableStateOf(content)
}
}
// 遍历_activeTabs,根据选中状态设置缩放值
for ((key, state) in _activeTabs) {
key(key) {
SelectedBox(
selected = key == selectedKey,
content = state.value
)
}
}
}
}
TabContainerImpl
中重要的地方都用注释标记了,整体逻辑不复杂,应该都看得懂,大概流程如下:
- 调用
tab
方法为每个Tab配置要显示的内容 - 把所有Tab的配置信息保存在
_store
中 Content
函数显示selectedKey
对应的内容- 根据
selectedKey
把配置从_store
加载到_activeTabs
中 - 遍历
_activeTabs
,把Tab对应的可组合项添加到组合中
最后在写一下TabContainer
函数:
kotlin
@Composable
fun TabContainer(
modifier: Modifier = Modifier,
// 当前选中的Key
selectedKey: Any,
apply: TabContainerScope.() -> Unit,
) {
val container = remember { TabContainerImpl() }.apply {
startConfig()
apply()
}
Box(modifier = modifier) {
container.Content(selectedKey)
}
}
使用TabContainer
的代码就不展示了,和上面期望的Api一模一样。
运行代码,测试一下:
可以看到,已经可以正常工作了。
结束
到此为止,大概的功能已经实现了,当然,还有可以优化的地方,比如:
- 把显示隐藏逻辑,即
SelectedBox
的逻辑,抽取出来,可以单独配置 - 当key减少的时候,
_store
和_activeTabs
中对应的配置要移除
由于篇幅有限,就不继续展开了,以上提到的优化都已经完整实现了,有兴趣的读者可以看一下:compose-tab-container
以上就是全部内容,如果有错误的地方,还请读者评论指出,一起学习,如果有任何问题,也可以加作者的微信探讨,感谢你的阅读。
作者微信:zj565061763