Jetpack Compose 自定义 好看的TabRow Indicator

背景

Jetpack Compose 提供了强大的 Material Design 组件,其中 TabRow 组件可以用于实现 Material Design 规范的选项卡界面。但是默认的 TabRow 样式可能无法满足所有场景,所以我们有时需要自定义 TabRow 的样式。

Jetpack Compose 中使用 TabRow

使用 TabRow 一般可以分为以下几步:

  1. 定义 Tab 数据模型

    每个 Tab 对应一个数据类,包含标题、图标等信息:

kotlin 复制代码
    data class TabItem(
       val title: String,
       val icon: ImageVector?
    )
  1. 在 TabRow 中添加 Tab 项

    使用 Tab 组件添加选项卡,传入标题、图标等:

kotlin 复制代码
    TabRow {
       tabItems.forEach { item ->
          Tab(
             text = {
                Text(item.title) 
             },
             icon = {
                item.icon?.let { Icon(it) }
             }
          ) 
       }
    }
  1. 处理 Tab 选择事件

    通过 selectedTabIndex 跟踪选中的 tab,在 onTabSelected 回调中处理点击事件:

kotlin 复制代码
    var selectedTabIndex by remember { mutableStateOf(0) }

    TabRow(
       selectedTabIndex = selectedTabIndex,
       onTabSelected = {
          selectedTabIndex = it
       }
    ){
       // ...
    }

具体详细可以看我之前的文章 Jetpack Compose TabRow与HorizontalPager 联动

笔记共享App

我新开发的笔记共享App 也用上了TabRow与HorizontalPager联动效果

效果图

自定义 TabRow 的样式

效果图

演示图的姓名都是随机生成的,如有雷同纯属巧合

证据如下

kotlin 复制代码
val lastNames = arrayOf(  
"赵", "钱", "孙", "李", "周", "吴", "郑", "王", "刘", "张", "杨", "陈", "郭", "林", "徐", "罗", "陆", "海"  
)  
val firstNames = arrayOf(  
"伟", "芳", "娜", "敏", "静", "立", "丽", "强", "华", "明", "杰", "涛", "俊", "瑶", "琨", "璐"  
)  
val secondNames =  
arrayOf("燕", "芹", "玲", "玉", "菊", "萍", "倩", "梅", "芳", "秀", "苗", "英")  
// 随机选择一个姓氏  
val lastName = lastNames.random()  
  
// 随机选择一个名字  
val firstName = firstNames.random()  
val secondName = secondNames.random()

代码解释

重写TabRow

通过查看TabRow 组件的源代码 ,单单自定义indicator 指示器是行不通的

kotlin 复制代码
 layout(tabRowWidth, tabRowHeight) {
                //绘制 tab文本
                tabPlaceables.forEachIndexed { index, placeable ->
                    placeable.placeRelative(index * tabWidth, 0)
                }
                //绘制 divider 分割线 
                subcompose(TabSlots.Divider, divider).forEach {
                    val placeable = it.measure(constraints.copy(minHeight = 0))
                    placeable.placeRelative(0, tabRowHeight - placeable.height)
                }
                //最后绘制 Indicator 指示器
                subcompose(TabSlots.Indicator) {
                    indicator(tabPositions)
                }.forEach {
                    it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
                }
            }

根据源代码可以看出TabRow 先绘制文本 再绘制 指示器,这的显示效果,当Indicator高度充满TabRow的时候Tab文本是显示不出来的,因为Indicator挡住了,

所以解决办法就是先绘制Indicator再绘制tab文本

kotlin 复制代码
 layout(tabRowWidth, tabRowHeight) {
                 //先绘制 Indicator 指示器
                subcompose(TabSlots.Indicator) {
                    indicator(tabPositions)
                }.forEach {
                    it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
                }
                //因为divider用不上,我便注释了
                //subcompose(TabSlots.Divider, divider).forEach {
                //    val placeable = it.measure(constraints.copy(minHeight = 0))
                //    placeable.placeRelative(0, tabRowHeight - placeable.height)
                //}
                
                //再绘制 tab文本
                tabPlaceables.forEachIndexed { index, placeable ->
                    placeable.placeRelative(index * tabWidth, 0)
                }

            }

把TabRow宽度改成由内容匹配

TabRow宽度默认的效果是,宽度是父布局的最大宽度,效果如下

TabRow的宽度从源码上看是,直接获取SubcomposeLayout的最大宽度(constraints.maxWidth) 接着利用宽度和tabCount计算平均值,就是每个tab文本的宽度

kotlin 复制代码
SubcomposeLayout(Modifier.fillMaxWidth()) { constraints ->

            //最大宽度
            val tabRowWidth = constraints.maxWidth
            val tabMeasurables = subcompose(TabSlots.Tabs, tabs)
            val tabCount = tabMeasurables.size
            var tabWidth = 0
            if (tabCount > 0) {
                tabWidth = (tabRowWidth / tabCount)
            }
            ...
            }

我们需要TabRow宽度由内容匹配,而不是父布局的最大宽度,这样就要修改测量流程\

不再直接使用constraints.maxWidth作为tabRowWidth,而是记为最大宽度maxWidth

接着封装一个函数,使用标签内容宽度的求和作为 TabRow 的宽度,不再和 maxWidth 做比较

kotlin 复制代码
fun measureTabRow(
    measurables: List<Measurable>,
    minWidth: Int
): Int {
    // 依次测量标签页宽度并求和
    val widths = measurables.map {
        it.minIntrinsicWidth(Int.MAX_VALUE)
    }
    var width = widths.max() * measurables.size
    measurables.forEach {
        width += it.minIntrinsicWidth(Int.MAX_VALUE)
    }
    //maxWidth的作用
    // 如果标签较多,可以取一个较小值作为最大标签宽度,防止过宽
    return minOf(width, minWidth)
}

这样就舒服多了

自定义的 Indicator

主要逻辑是在 Canvas 上绘制指示器

  • indicator 的宽度根据当前 tab 的宽度及百分比计算
  • indicator 的起始 x 轴坐标根据切换进度在当前 tab 和前/后 tab 之间插值
  • indicator 的高度是整个 Canvas 的高度,即占据了 TabRow 的全高

fraction 和前后 tab 的 lerping 实现了滑动切换时指示器平滑过渡的效果

具体可以看代码的注释

使用方法

kotlin 复制代码
//默认显示第一页
val pagerState = rememberPagerState(initialPage = 1,  pageCount = { 3 } )

 WordsFairyTabRow(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 86.dp, start = 24.dp, end = 24.dp),
            selectedTabIndex = pagerState.currentPage,
            indicator = { tabPositions ->
                if (tabPositions.isNotEmpty()) {
                    PagerTabIndicator(tabPositions = tabPositions, pagerState = pagerState)
                }
            },
        ) {
            // 添加选项卡
            tabs.forEachIndexed { index, title ->
                val selected = (pagerState.currentPage == index)
                Tab(
                    selected = selected,
                    selectedContentColor = WordsFairyTheme.colors.textWhite,
                    unselectedContentColor = WordsFairyTheme.colors.textSecondary,
                    onClick = {
                        scope.launch {
                            feedback.vibration()
                            pagerState.animateScrollToPage(index)
                        }
                    },
                    modifier = Modifier.wrapContentWidth() // 设置Tab的宽度为wrapContent
                ) {
                    Text(
                        text = title,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(9.dp)
                    )
                }
            }
        }

完整代码

PagerTabIndicator.kt

kotlin 复制代码
@OptIn(ExperimentalFoundationApi::class) 
@Composable 
fun PagerTabIndicator(
    tabPositions: List<TabPosition>, // TabPosition列表
    pagerState: PagerState, // PageState用于获取当前页和切换进度
    color: Color = WordsFairyTheme.colors.themeUi, // 指示器颜色
    @FloatRange(from = 0.0, to = 1.0) percent: Float = 1f // 指示器宽度占Tab宽度的比例
) {

    // 获取当前选中的页和切换进度
    val currentPage by rememberUpdatedState(newValue = pagerState.currentPage) 
    val fraction by rememberUpdatedState(newValue = pagerState.currentPageOffsetFraction)

    // 获取当前tab、前一个tab、后一个tab的TabPosition
    val currentTab = tabPositions[currentPage]
    val previousTab = tabPositions.getOrNull(currentPage - 1) 
    val nextTab = tabPositions.getOrNull(currentPage + 1)

    Canvas(
        modifier = Modifier.fillMaxSize(), // 充满TabRow的大小
        onDraw = {
            // 计算指示器宽度
            val indicatorWidth = currentTab.width.toPx() * percent  
            
            // 计算指示器x轴起始位置
            val indicatorOffset = if (fraction > 0 && nextTab != null) {
                // 正在向右滑动到下一页,在当前tab和下一tab之间插值
                lerp(currentTab.left, nextTab.left, fraction).toPx() 
            } else if (fraction < 0 && previousTab != null) {
                // 正在向左滑动到上一页,在当前tab和上一tab之间插值 
                lerp(currentTab.left, previousTab.left, -fraction).toPx()
            } else {
                // 未在滑动,使用当前tab的left
               currentTab.left.toPx()
            }
            
            // 绘制指示器
            val canvasHeight = size.height // 高度为整个Canvas高度
            drawRoundRect(
                color = color, 
                topLeft = Offset( // 设置圆角矩形的起始点
                    indicatorOffset + (currentTab.width.toPx() * (1 - percent) / 2),  
                    0F
                ),
                size = Size( // 设置宽高
                    indicatorWidth + indicatorWidth * abs(fraction),
                    canvasHeight
                ),
                cornerRadius = CornerRadius(26.dp.toPx()) // 圆角半径
            )
        }
    )
}

WordsFairyTabRow.kt

kotlin 复制代码
@Composable
fun WordsFairyTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
        if (selectedTabIndex < tabPositions.size) {
            TabRowDefaults.Indicator(
                Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
            )
        }
    },
    tabs: @Composable () -> Unit
) {


    ImmerseCard(
        modifier = modifier.selectableGroup(),
        shape = RoundedCornerShape(26.dp),
        backgroundColor = WordsFairyTheme.colors.whiteBackground.copy(alpha = 0.7f)
    ) {
        SubcomposeLayout(Modifier.wrapContentWidth()) { constraints ->

            val tabMeasurables = subcompose(TabSlots.Tabs, tabs)
            val tabRowWidth = measureTabRow(tabMeasurables, constraints.maxWidth)

            val tabCount = tabMeasurables.size
            var tabWidth = 0
            if (tabCount > 0) {
                tabWidth = (tabRowWidth / tabCount)
            }
            val tabRowHeight = tabMeasurables.fold(initial = 0) { max, curr ->
                maxOf(curr.maxIntrinsicHeight(tabWidth), max)
            }

            val tabPlaceables = tabMeasurables.map {
                it.measure(
                    constraints.copy(
                        minWidth = tabWidth,
                        maxWidth = tabWidth,
                        minHeight = tabRowHeight,
                        maxHeight = tabRowHeight,
                    )
                )
            }

            val tabPositions = List(tabCount) { index ->
                TabPosition(tabWidth.toDp() * index, tabWidth.toDp())
            }

            layout(tabRowWidth, tabRowHeight) {

                subcompose(TabSlots.Indicator) {
                    indicator(tabPositions)
                }.forEach {
                    it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
                }

                tabPlaceables.forEachIndexed { index, placeable ->
                    placeable.placeRelative(index * tabWidth, 0)
                }

            }
        }

    }
}

fun measureTabRow(
    measurables: List<Measurable>,
    minWidth: Int
): Int {
    // 依次测量标签页宽度并求和
    val widths = measurables.map {
        it.minIntrinsicWidth(Int.MAX_VALUE)
    }
    var width = widths.max() * measurables.size
    measurables.forEach {
        width += it.minIntrinsicWidth(Int.MAX_VALUE)
    }

    // 如果标签较多,可以取一个较小值作为最大标签宽度,防止过宽
    return minOf(width, minWidth)
}

@Immutable
class TabPosition internal constructor(val left: Dp, val width: Dp) {
    val right: Dp get() = left + width

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is TabPosition) return false

        if (left != other.left) return false
        if (width != other.width) return false

        return true
    }

    override fun hashCode(): Int {
        var result = left.hashCode()
        result = 31 * result + width.hashCode()
        return result
    }

    override fun toString(): String {
        return "TabPosition(left=$left, right=$right, width=$width)"
    }
}

/**
 * Contains default implementations and values used for TabRow.
 */
object TabRowDefaults {
    /** Default container color of a tab row. */
    val containerColor: Color
        @Composable get() =
            WordsFairyTheme.colors.whiteBackground

    /** Default content color of a tab row. */
    val contentColor: Color
        @Composable get() =
            WordsFairyTheme.colors.whiteBackground

    @Composable
    fun Indicator(
        modifier: Modifier = Modifier,
        height: Dp = 3.0.dp,
        color: Color =
            WordsFairyTheme.colors.navigationBarColor

    ) {
        Box(
            modifier
                .fillMaxWidth()
                .height(height)
                .background(color = color)
        )
    }
    fun Modifier.tabIndicatorOffset(
        currentTabPosition: TabPosition
    ): Modifier = composed(
        inspectorInfo = debugInspectorInfo {
            name = "tabIndicatorOffset"
            value = currentTabPosition
        }
    ) {
        val currentTabWidth by animateDpAsState(
            targetValue = currentTabPosition.width,
            animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
        )
        val indicatorOffset by animateDpAsState(
            targetValue = currentTabPosition.left,
            animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
        )
        fillMaxWidth()
            .wrapContentSize(Alignment.BottomStart)
            .offset(x = indicatorOffset)
            .width(currentTabWidth)
    }
}

private enum class TabSlots {
    Tabs,
    Divider,
    Indicator
}

/**
 * Class holding onto state needed for [ScrollableTabRow]
 */
private class ScrollableTabData(
    private val scrollState: ScrollState,
    private val coroutineScope: CoroutineScope
) {
    private var selectedTab: Int? = null

    fun onLaidOut(
        density: Density,
        edgeOffset: Int,
        tabPositions: List<TabPosition>,
        selectedTab: Int
    ) {
        // Animate if the new tab is different from the old tab, or this is called for the first
        // time (i.e selectedTab is `null`).
        if (this.selectedTab != selectedTab) {
            this.selectedTab = selectedTab
            tabPositions.getOrNull(selectedTab)?.let {
                // Scrolls to the tab with [tabPosition], trying to place it in the center of the
                // screen or as close to the center as possible.
                val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
                if (scrollState.value != calculatedOffset) {
                    coroutineScope.launch {
                        scrollState.animateScrollTo(
                            calculatedOffset,
                            animationSpec = ScrollableTabRowScrollSpec
                        )
                    }
                }
            }
        }
    }

    private fun TabPosition.calculateTabOffset(
        density: Density,
        edgeOffset: Int,
        tabPositions: List<TabPosition>
    ): Int = with(density) {
        val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset
        val visibleWidth = totalTabRowWidth - scrollState.maxValue
        val tabOffset = left.roundToPx()
        val scrollerCenter = visibleWidth / 2
        val tabWidth = width.roundToPx()
        val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
        // How much space we have to scroll. If the visible width is <= to the total width, then
        // we have no space to scroll as everything is always visible.
        val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0)
        return centeredTabOffset.coerceIn(0, availableSpace)
    }
}

private val ScrollableTabRowMinimumTabWidth = 90.dp

/**
 * The default padding from the starting edge before a tab in a [ScrollableTabRow].
 */
private val ScrollableTabRowPadding = 52.dp

/**
 * [AnimationSpec] used when scrolling to a tab that is not fully visible.
 */
private val ScrollableTabRowScrollSpec: AnimationSpec<Float> = tween(
    durationMillis = 250,
    easing = FastOutSlowInEasing
)
相关推荐
wfh1322 天前
国际 Android WPS Office v18.13 解锁版
app
程楠楠&M3 天前
uni-app运行到 Android 真机和Android studio模拟器
android·uni-app·android studio
闪闪加油干(◍>∇<◍)ノ゙3 天前
Android Studio中断点调试
android studio
INF_5123 天前
Android Studio 占满C盘快速解决方法
android·ide·android studio
PleaSure乐事5 天前
【AndroidStudio】关于AndroidStudio的常见控件TextView和Button
java·学习·android studio·安卓·移动应用开发
咸芝麻鱼5 天前
Android Studio中导入了包,但无法识别 WindowSizeClass 和相关函数的问题
android·ide·android studio
佛系小嘟嘟5 天前
Android Studio 新版本 Logcat 的使用详解
android·gitee·android studio
Junerver6 天前
在 Jetpack Compose 中扩展 useRequest 实现自定义数据处理、异常回滚
android·前端·android jetpack
编程乐学7 天前
网络资源模板--Android Studio 零食工坊(商城)
android·android studio·商城·大作业·安卓课设·购物商城
沐言人生7 天前
Android10 Framework—Init进程-5.SEAndroid机制
android·android studio·android jetpack