从定制化页签tab到compose列表使用

1 compose中Tab页签简介

在compose的官方UI包实现中有TabRow和ScrollableTabRow两个主要的tab页签实现,通过两个tab页签的名字可以看出,TabRow页签主要用于页签数量有限的情况下使用,而ScrollableTabRow通常用于网络下发不确定数量的页签场景,如电商、视频播放软件不同类型数据展示的情况,可以滑动展示出超出一行宽度限制的内容区域。

1.1 定制化页签tab的始末

这里以官方给的ScrollableTabRow为例,给出官方页签tab的使用参数和参数简介:

kotlin 复制代码
@Composable
fun ScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.primaryContainerColor,
    contentColor: Color = TabRowDefaults.primaryContentColor,
    edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit =
        @Composable { tabPositions ->
            TabRowDefaults.SecondaryIndicator(
                Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
            )
        },
    divider: @Composable () -> Unit = @Composable { HorizontalDivider() },
    tabs: @Composable () -> Unit
)

可以看到,containerColor可以设置TabRow容器的颜色,contentColor则可以设置单个tab容器的颜色,edgePadding可以设置容器的起始位置,indicator也就是指示器样式,在选中的index情况下才展示indicator内容,divider参数则时设置TabRow容器底部的分割线,tabs是TabRow容器内单个tab样式。可以看到官方提供的参数其实已经不少了,但是国内的UI、UE设计基本都不会遵循官方的那套规范来。

1.2 某音的页签tab分析

前面提到国内的UI、UE实际的设计基本都不会遵循Google官方的那套设计来,下面还是以知名的视频软件,某音的首页推荐tab为例,如图所示:

从交互中来看,某音的tab交互存在如下需求点:

  1. 当选中tab为tab列表项的最左侧(最右侧)的三个tab时,tab列表滑动到第一个item位置(最后一个item位置)
  2. 当tab容器内最后一个tab为热点tab之前的tab时,展示滑到tab最右侧的按钮
  3. 当选中中间的tab(非最左侧tab、非最右侧tab)时,tab需要自动移动到屏幕中间显示
  4. 当前已选中的tab,再次点击时,会进入数据刷新状态
  5. tab切换不同的tab时,tab页签内文字、indicator颜色会变化(实际的实现不是很复杂,本文内不做介绍)

2 某音可滑动tab实现

根据前面的分析结论,我们不难看出来,使用Google官方的ScrollableTabRow不太好实现,这里我们考虑使用LazyList来实现前述需求。

2.1 LazyRow实现tab页签UI

首先看下单个tab页签的数据结构,具体的实现如下

kotlin 复制代码
data class SingleTabUiState(
    val tabName: String, // tab名称
    val hasRedDot: Boolean, // 红点
    val message: String, // 消息
    val isSpecialActivity: Boolean, // 特殊活动
    val isSelected: Boolean, // tab是否被选中 
    val tabItemState: TabItemState // 刷新或者非刷新状态
)

接下来看列表内的单个tab的UI实现,具体的代码实现如下

kotlin 复制代码
@Composable
private fun TabItem(
    index: Int,
    isSelected: Boolean,
    state: SingleTabUiState,
    onSelectTabChange: (Int) -> Unit = {},
    onRefreshTab: (Int) -> Unit = {},
    onLongClickTab: () -> Unit = {}
) {
    when (state.tabItemState) {
        is TabItemState.Refreshing -> {
            RefreshTabWidget(index)
        }

        is TabItemState.NormalTab -> {
            NormalTabWidget(
                index = index,
                isSelected = isSelected,
                state = state,
                onSelectTabChange = onSelectTabChange,
                onRefreshTab = onRefreshTab,
                onLongClickTab = onLongClickTab
            )
        }
    }
}

可以看到一共有两个状态:刷新状态和普通tab状态,其中刷新状态的实现如下:

kotlin 复制代码
@Composable
private fun RefreshTabWidget() {
    var rotateDegree by remember {
        mutableIntStateOf(0)
    }

    LaunchedEffect(Unit) {
        // 刷新时动态旋转图片显示
        while (true) {
            delay(200)
            if (rotateDegree >= 360) {
                rotateDegree = 0
            }
            rotateDegree += 45
        }
    }
    Box(
        modifier = Modifier
            .height(40.cdp)
            .padding(horizontal = 7.cdp)
            .wrapContentWidth()
            .background(color = Color.Gray)
    ) {
        Image(
            modifier = Modifier
                .size(16.cdp)
                .align(alignment = Alignment.Center)
                .rotate(rotateDegree.toFloat()),
            painter = painterResource(R.drawable.video_tab_refresh),
            contentDescription = ""
        )
    }
}

然后是普通的文字tab实现,代码如下所示

kotlin 复制代码
@Composable
private fun NormalTabWidget(
    index: Int,
    isSelected: Boolean = false,
    state: SingleTabUiState,
    onSelectTabChange: (Int) -> Unit = {},
    onRefreshTab: (Int) -> Unit = {},
    onLongClickTab: () -> Unit = {}
) {
    Box(
        modifier = Modifier
            .height(40.cdp)
            .wrapContentWidth()
    ) {
        Column(
            modifier = Modifier
                .height(40.cdp)
                .padding(horizontal = 7.cdp)
                .wrapContentWidth()
                .align(Alignment.BottomCenter),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                modifier = Modifier.wrapContentSize(),
                text = state.tabName,
                color = if (isSelected) {
                    Color.White
                } else {
                    Color(0xffc3c3c3)
                },
                fontSize = 16.csp,
                fontWeight = if (state.isSpecialActivity) {
                    FontWeight.Bold
                } else {
                    FontWeight.Normal
                }
            )

            if (isSelected) {
                // 选中状态下展示白色的下划线在tab文字底部
                VerticalDivider(height = 8.cdp)
                Spacer(
                    modifier = Modifier
                        .height(2.cdp)
                        .width(24.cdp)
                        .background(color = Color.White)
                )
            }
        }

        if (state.message.isNotEmpty()) {
            // 展示消息文案(直播中等消息)
            Column(
                modifier = Modifier
                    .wrapContentSize()
                    .align(alignment = Alignment.TopEnd)
            ) {
                VerticalDivider(4.cdp)
                Text(
                    modifier = Modifier
                        .height(16.cdp)
                        .background(
                            color = Color(0xfffe2c55),
                            shape = RoundedCornerShape(24.cdp)
                        )
                        .padding(horizontal = 3.cdp),
                    text = state.message,
                    fontSize = 10.csp,
                    color = Color.White,
                )
            }

        } else if (state.hasRedDot) {
            // 展示消息红点
            Column(
                modifier = Modifier
                    .wrapContentSize()
                    .align(alignment = Alignment.TopEnd)
            ) {
                VerticalDivider(4.cdp)
                Spacer(
                    modifier = Modifier
                        .size(9.cdp)
                        .background(
                            color = Color(0xfffe2c55),
                            shape = RoundedCornerShape(9.cdp)
                        )
                )
            }
        }
    }
}

然后是整个LazyRow的实现,可以看到整个代码如下:

kotlin 复制代码
@Composable
fun ScrollableTabList(
    selectedIndex: Int = 0,
    tabList: SnapshotStateList<SingleTabUiState>,
    lazyListState: LazyListState,
    onSelectTabChange: (Int) -> Unit = {},
    onRefreshTab: (Int) -> Unit = {},
    onLongClickTab: () -> Unit = {}
) {
    // a、最后一个可见item的index
    val lastVisibleItemIndex by remember {
        derivedStateOf {
            lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
        }
    }

    Box(
        modifier = Modifier
            .height(40.cdp)
            .width(288.cdp),
    ) {
        LazyRow(
            modifier = Modifier
                .height(40.cdp)
                .width(288.cdp),
            state = lazyListState
        ) {
            itemsIndexed(
                items = tabList,
                key = { _: Int, item: SingleTabUiState ->
                    when (item.tabItemState) {
                        is TabItemState.NormalTab -> {
                            item.tabName
                        }

                        is TabItemState.Refreshing -> {
                            item.tabName
                        }
                    }
                }
            ) { index: Int, item: SingleTabUiState ->
                TabItem(
                    index = index,
                    isSelected = item.isSelected,
                    state = item,
                    onSelectTabChange = {
                        if (lazyListState.isScrollInProgress) {
                            return@TabItem
                        }
                        onSelectTabChange(it)
                    },
                    onRefreshTab = {
                        if (lazyListState.isScrollInProgress) {
                            return@TabItem
                        }
                        onRefreshTab(it)
                    },
                    onLongClickTab = {
                        if (lazyListState.isScrollInProgress) {
                            return@TabItem
                        }
                        onLongClickTab()
                    }
                )
            }
        }

        if (lastVisibleItemIndex != tabList.lastIndex) {
            // b、当前最后一个可见tab的index不是列表的最后一个时,展示右滑到底的按钮
            Box(
                modifier = Modifier
                    .height(40.cdp)
                    .width(40.cdp)
                    .padding(top = 5.cdp)
                    .background(
                        brush = Brush.horizontalGradient(
                            listOf(
                                Color(0x99000000),
                                Color.Black
                            )
                        )
                    )
                    .align(Alignment.TopEnd)
                    .clickable {
                        if (lazyListState.isScrollInProgress) {
                            return@clickable
                        }
                        onSelectTabChange(tabList.lastIndex)
                    }
            ) {
                Image(
                    modifier = Modifier
                        .size(20.cdp)
                        .background(
                            color = Color(0xffffffff),
                            shape = RoundedCornerShape(20.cdp)
                        )
                        .align(Alignment.TopEnd),
                    painter = painterResource(R.drawable.video_common_right_arrow),
                    contentDescription = ""
                )
            }
        }
    }
}

从列表的实现代码可以看出来,滑动到最右侧的按钮展示主要依赖lazyListState.layoutInfo.visibleItemsInfo的返回值,该属性返回的值类似于RecyclerView中LayoutManager的findFirstVisibleItemPosition和findLastVisibleItemPosition方法组合的效果,返回当前可见视窗内的所有item的信息(包括index、key、offset等相关信息),上述整个代码的UI展示如下所示


暂时看起来还是比较还原tab的整个UI展示的,嘻嘻整体效果不错。

2.2 tab与HorizontalPager的联动处理

前面已经实现了整个滑动tab的UI,准确来说只实现了UI本身和滑动到底部图标展示逻辑,现在需要实现前面列举的需求点剩余部分,这里需要介绍一下tab的切换与HorizontalPager之间需要依赖PagerState的处理,具体的代码实现如下:

kotlin 复制代码
    // a、横向滑动HorizontalPager页面
    val horPageState = rememberPagerState(horPageCount - 1) { horPageCount }
    // b、列表状态
    val tabListState = rememberLazyListState(initialFirstVisibleItemIndex = horPageCount - 1)

    val scope = rememberCoroutineScope { Dispatchers.Main.immediate }

    LaunchedEffect(horPageState) {
        snapshotFlow {
            horPageState.settledPage
        }.collect {
            onSelectTabChange(it)

            if (it in 0 until 3) {
                // c、当前选中页面为前三个,直接滑动到第0个item
                tabListState.scrollToItem(0)
            }

            if (it in horPageCount - 3 until horPageCount) {
                // d、当前选中页面为前三个,直接滑动到第0个item
                tabListState.scrollToItem(horPageCount - 1)
            }

            val visibleItemsInfo = tabListState.layoutInfo.visibleItemsInfo
            // e、计算LazyRow的可视部分居中像素位置
            val middlePixels =
                (tabListState.layoutInfo.viewportEndOffset + tabListState.layoutInfo.viewportStartOffset) / 2
            visibleItemsInfo.forEachIndexed { index, info ->
                if (it == info.index && index in 0 until visibleItemsInfo.size - 1) {
                    // f、计算当前选中item内容中间位置距离可视窗口中心的偏移量并滑动LazyRow
                    val offsetPixels =
                        (info.offset + visibleItemsInfo[index + 1].offset) / 2 - middlePixels
                    tabListState.scrollBy(offsetPixels.toFloat())
                }
            }
        }
    }

从上面的代码和注释,可以看到通过horPageState.settledPage值即可监听到页面切换变化,同时根据当前选中的页面值,依次完成前述需求点,接下来看看实际的展示效果:

可以看到,基本完成了前述的需求点,整个可滑动tab还是比较还原,下次再看见可滑动tab的需求再也不怕实现不了。

2.3 LazyListLayoutInfo属性详解

在2.2我们通过LazyRow实现了可滑动tab,整个自动滑动的需求主要依赖androidx.compose.foundation.lazy.LazyListState#getLayoutInfo属性来实现,现在我们仔细看看这个里面到底都有哪些日常开发中需要用到的属性和属性对应的作用:

kotlin 复制代码
interface LazyListLayoutInfo {
    /** 表示当前所有可见项的 [LazyListItemInfo] 列表。 */
	val visibleItemsInfo: List<LazyListItemInfo>

	/**
	* 布局视口的起始偏移量(以像素为单位)。你可以将其视为可见的最小偏移量。
	* 通常为 0,但如果设置了非零的 [beforeContentPadding],且内容显示在内容内边距区域内,
	* 则该值可能为负数,因为内容内边距区域中的内容仍然是可见的。
	*
	* 你可以使用它结合 [visibleItemsInfo] 来判断哪些项是完全可见的。
	*/
	val viewportStartOffset: Int
	
	/**
	* 布局视口的结束偏移量(以像素为单位)。你可以将其视为可见的最大偏移量。
	* 其值为列表布局的大小减去 [beforeContentPadding]。
	*
	* 你可以使用它结合 [visibleItemsInfo] 来判断哪些项是完全可见的。
	*/
	val viewportEndOffset: Int
	
	/** 传递给 [LazyColumn] 或 [LazyRow] 的项的总数。 */
	val totalItemsCount: Int
	
	/**
	* 可视窗口的大小(以像素为单位)。它是包含所有内容内边距的列表布局大小。
	*/
	val viewportSize: IntSize
		get() = IntSize.Zero
	
	/** 惰性列表的滚动方向。 */
	val orientation: Orientation
		get() = Orientation.Vertical
	
	/** 如果滚动和布局方向是反向的,则为 true。 */
	val reverseLayout: Boolean
		get() = false
	
	/**
	* 在滚动方向上、第一个项之前应用的内容内边距(以像素为单位)。
	* 例如,对于 [reverseLayout] 为 false 的 LazyColumn,这就是顶部内容内边距。
	*/
	val beforeContentPadding: Int
		get() = 0
	
	/**
	* 在滚动方向上、最后一个项之后应用的内容内边距(以像素为单位)。
	* 例如,对于 [reverseLayout] 为 false 的 LazyColumn,这就是底部内容内边距。
	*/
	val afterContentPadding: Int
		get() = 0
	
	/** 在滚动方向上,项与项之间的间距。 */
	val mainAxisItemSpacing: Int
		get() = 0
}

上面列出了LazyListLayoutInfo接口的所有属性值,其中比较重要的visibleItemsInfo返回的是可视窗口内所有可见的列表项内容信息,其中包含的信息如下

kotlin 复制代码
interface LazyListItemInfo {
    val index: Int // 索引

    val key: Any // 列表创建时传入的值

    val offset: Int // 偏移量 (在前面计算item的内容中心位置时用到了)
    
    val size: Int // item大小
    
    val contentType: Any? // 列表创建时传入的类型参数
        get() = null
}

另外还有viewportStartOffset和viewportEndOffset根据这个两个值也是比较重要的,可以根据这两个值和LazyListItemInfo中的size算出当前完全可见item的所有index值,具体的计算方法如下:

kotlin 复制代码
fun LazyListState.getFirstFullyVisibleItemIndex(): Int {
    return layoutInfo.visibleItemsInfo.firstOrNull { item ->
        item.offset >= layoutInfo.viewportStartOffset &&
        item.offset + item.size <= layoutInfo.viewportEndOffset
    }?.index ?: -1
}

相信聪明的你应该也能算出来最后一个可见item的计算方法。所以可以看出来,在compose的列表中想要拿到类似于RecyclerView中LayoutManager的findFirstVisibleItemPosition和findLastVisibleItemPosition方法还是要复杂一些的,需要自己进行一些计算才能拿到。

3 总结

本文通过对某音 Tab 交互的深入分析,展示了在 Compose 中如何摆脱官方组件的束缚,使用更底层的 LazyRow 实现复杂的滑动交互需求。弥补了官方组件ScrollableTabRow 灵活性不足,无法实现某音式 Tab 的精确滚动控制,边界吸附、居中显示、二次刷新等逻辑可直接复用。当然文章中的实现也还是有不足之处的,例如在2.2处理item居中显示的时候,可以考虑把那部分逻辑提取成为LazyListState的一个扩展方法。除了上述的问题外,我还给读者留了一个小小的问题:滑动到最右侧的按钮可见的逻辑判断lastVisibleItemIndex != tabList.lastIndex其实不够精确。相信你在读完这篇文章后有了怎么解决这个问题的想法了!!!最后,年后一直忙于公司的工作任务,没有太多时间来做自己的事情,导致最近的三个多月没时间提笔写文章,后续还是争取挤出一些自己的时间来写文!!!

相关推荐
软弹2 小时前
快速了解前端中的跨域问题
前端·javascript·vue.js·react.js·node.js·跨域
RePeaT2 小时前
React 常用知识点整理
前端·react.js·面试
533_2 小时前
[vxe-table] 合并单元格
前端
kekegdsz2 小时前
高丢包、高延迟、断网秒切:开源一个 Android 弱网测试利器
android·测试
浩星2 小时前
electron系列9:调用原生能力,剪贴板、通知、开机自启
前端·javascript·electron
Mapmost2 小时前
【Mapmost渲染指北】灯光+后处理,一招切出立体感
前端
J船长2 小时前
Kotlin 协程:从入门到深度理解
前端
StarShip3 小时前
JVM堆栈溢出监测原理
android·java
Hilaku3 小时前
做中后台业务,为什么我不建议你用 Tailwind CSS?
前端·css·代码规范