Jetpack Compose - Tab切换状态缓存

前言

实际开发中,切换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中显示隐藏很容易想到AnimatedVisibilityAnimatedContent这两个可组合项,然而它们也会更改组合,有兴趣的读者可以测试一下。

我们可以设置缩放值来达到显示隐藏的效果,选中的话就是缩放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中重要的地方都用注释标记了,整体逻辑不复杂,应该都看得懂,大概流程如下:

  1. 调用tab方法为每个Tab配置要显示的内容
  2. 把所有Tab的配置信息保存在_store
  3. Content函数显示selectedKey对应的内容
  4. 根据selectedKey把配置从_store加载到_activeTabs
  5. 遍历_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

相关推荐
踏雪羽翼8 小时前
android TextView实现文字字符不同方向显示
android·自定义view·textview方向·文字方向·textview文字显示方向·文字旋转·textview文字旋转
lxysbly8 小时前
安卓玩MRP冒泡游戏:模拟器下载与使用方法
android·游戏
夏沫琅琊10 小时前
Android 各类日志全面解析(含特点、分析方法、实战案例)
android
程序员JerrySUN11 小时前
OP-TEE + YOLOv8:从“加密权重”到“内存中解密并推理”的完整实战记录
android·java·开发语言·redis·yolo·架构
TeleostNaCl12 小时前
Android | 启用 TextView 跑马灯效果的方法
android·经验分享·android runtime
TheNextByte113 小时前
Android USB文件传输无法使用?5种解决方法
android
quanyechacsdn14 小时前
Android Studio创建库文件用jitpack构建后使用implementation方式引用
android·ide·kotlin·android studio·implementation·android 库文件·使用jitpack
程序员陆业聪15 小时前
聊聊2026年Android开发会是什么样
android
编程大师哥15 小时前
Android分层
android
极客小云16 小时前
【深入理解 Android 中的 build.gradle 文件】
android·安卓·安全架构·安全性测试