Jetpack Compose 自定义 好看的TabRow Indicator

news2025/1/16 16:45:45

背景

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

Jetpack Compose 中使用 TabRow

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

  1. 定义 Tab 数据模型

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

    
    data class TabItem(
       val title: String,
       val icon: ImageVector?
    )
    
  2. 在 TabRow 中添加 Tab 项

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

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

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

    var selectedTabIndex by remember { mutableStateOf(0) }
    
    TabRow(
       selectedTabIndex = selectedTabIndex,
       onTabSelected = {
          selectedTabIndex = it
       }
    ){
       // ... 
    }
    

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

笔记共享App

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

效果图

1693561049999.gif

自定义 TabRow 的样式

效果图

请添加图片描述

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

证据如下

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

代码解释

重写TabRow

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

 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文本

 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文本的宽度

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 做比较

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 实现了滑动切换时指示器平滑过渡的效果

具体可以看代码的注释

使用方法

//默认显示第一页
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

@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

@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
)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/960546.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

QTday3(对话框、发布软件、事件处理核心机制)

一、Xmind整理&#xff1a; 二、上课笔记整理&#xff1a; 1.消息对话框&#xff08;QMessageBox&#xff09; ①基于属性版本的API QMessageBox::QMessageBox( //有参构造函数名QMessageBox::Icon icon, //图标const Q…

大数据课程K16——Spark的梯度下降法

文章作者邮箱&#xff1a;yugongshiyesina.cn 地址&#xff1a;广东惠州 ▲ 本章节目的 ⚪ 了解Spark的梯度下降法&#xff1b; ⚪ 了解Spark的梯度下降法家族&#xff08;BGD&#xff0c;SGD&#xff0c;MBGD&#xff09;&#xff1b; ⚪ 掌握Spark的MLlib实现…

Python进行数据相关性分析实战

平时在做数据分析的时候&#xff0c;会要对特征进行相关性分析&#xff0c;分析某些特征之间是否存在相关性。本文将通过一个实例来对数据进行相关性分析与展示。 一、数据集介绍 本次分析的是企业合作研发模式效果分析&#xff0c;企业的合作研发大致分为 企企合作、企学合作…

D357周赛复盘:模拟双端队列反转⭐⭐+贪心

文章目录 2810.故障键盘1.直接用reverse解决2.双端队列 2811.判断能否拆分数组&#xff08;比较巧妙的贪心&#xff09;思路完整版 2812.找出最安全路径2810.故障键盘1.直接用reverse解决2.双端队列 2811.判断能否拆分数组&#xff08;比较巧妙的贪心&#xff09;思路完整版 28…

OS | 4 抽象:进程

OS | 4 抽象&#xff1a;进程 文章目录 OS | 4 抽象&#xff1a;进程CPU虚拟化的方法进程&#xff1a;是一种抽象非正式定义 进程的机器状态 进程API程序转换为进程的过程进程状态>>>>> 欢迎关注公众号【三戒纪元】 <<<<< CPU虚拟化的方法 操作…

分布式调度Elastic-job

分布式调度Elastic-job 1. 概述 1.1什么是任务调度 我们可以思考⼀下下⾯业务场景的解决⽅案: 某电商平台需要每天上午10点&#xff0c;下午3点&#xff0c;晚上8点发放⼀批优惠券某银⾏系统需要在信⽤卡到期还款⽇的前三天进⾏短信提醒某财务系统需要在每天凌晨0:10分结算前…

进程控制相关 API-创建进程、进程分离、进程退出、进程阻塞

进程控制相关 API p.s 进程控制中的状态转换 相关 API&#xff0c;用户很少用到&#xff0c;在此不提。 一般来说&#xff0c;这些内核标准 API&#xff0c;在执行出错&#xff08;可能是资源不够、权限不够等等&#xff09;会返回负值&#xff08;比如 -1&#xff09;&#…

Go实现LogAgent:海量日志收集系统【上篇——LogAgent实现】

Go实现LogAgent 项目架构图&#xff1a; 0 项目背景与方案选择 背景 当公司发展的越来越大&#xff0c;业务越来越复杂时&#xff0c;每个业务系统都有自己的日志。此时我们就应该将不同业务线的日志进行实时收集&#xff0c;存储到一个日志收集中心&#xff0c;最后再通过…

手写Mybatis:第7章-SQL执行器的定义和实现

文章目录 一、目标&#xff1a;SQL执行的定义和实现二、设计&#xff1a;SQL执行的定义和实现三、实现&#xff1a;SQL执行的定义和实现3.1 工程结构3.2 SQL执行实现的关系图3.3 执行器的定义和实现3.3.1 Executor 接口3.3.2 BaseExecutor 抽象基类3.3.3 SimpleExecutor 简单执…

编译工具:CMake(六) | 使用外部共享库和头文件

编译工具&#xff1a;CMake&#xff08;六&#xff09; | 使用外部共享库和头文件 步骤引入头文件搜索路径为 target 添加共享库 步骤 在/Compilation_tool/cmake 目录建立 t4 目录 建立src目录&#xff0c;编写源文件main.c&#xff0c;内容如下&#xff1a; #include <…

ModaHub魔搭社区——决胜大模型时代,算力、网络、向量数据库缺一不可

大模型应用场景日趋多样,需求也随着增加,进而倒逼着多元算力方面的创新,为满足AI工作负载的需求,采用GPU、FPGA、ASIC等加速卡的服务器越来越多。 根据IDC数据统计,2022年,中国加速服务器市场相比2019年增长44.0亿美元,服务器市场增量的一半更是来自加速服务器。 这意味…

shell bash中设置命令set

1 Preface/Foreword set命令用于shell脚本在执行命令时候&#xff0c;遇到异常的处理机制。 2 Usage 2.1 set -e 当执行命令过程中遇到异常&#xff0c;那么就退出脚本&#xff0c;不会往下执行其它命令。 #!/bin/bash #set -eroot GIT_TAG${CI_BUILD_TAG-NOTAG} GIT_REV…

MySQL创建用户时报错“Your password does not satisfy the current policy requirements“

MySQL创建用户时报错"Your password does not satisfy the current policy requirements" MySQL是一个流行的关系型数据库管理系统&#xff0c;它提供了许多安全性特性&#xff0c;其中之一是密码策略。在创建或更改用户密码时&#xff0c;MySQL会检查密码是否符合当…

2023开学礼《乡村振兴战略下传统村落文化旅游设计》许少辉农大图书馆

2023开学礼《乡村振兴战略下传统村落文化旅游设计》许少辉农大图书馆

HCIP---BGP协议

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、pandas是什么&#xff1f;二、使用步骤 1.引入库2.读入数据总结 前言 根据AS自治系统可以将动态路由协议划分为IGP和EGP协议。IGP协议是应用在AS内部&#…

手摸手2-springboot编写基础的增删改查

目录 手摸手2-springboot编写基础的增删改查创建controller层添加service层接口service层实现添加mapper层mapper层对应的sql添加扫描注解,对应sql文件的目录 手摸手2-springboot编写基础的增删改查 创建controller层 实现 test 表中的添加、修改、删除及列表查询接口&#x…

PHP8函数包含文件-PHP8知识详解

在php中&#xff0c;可以使用以下函数来包含其他文件&#xff1a;include()、include_once()、require()、require_once()。 1、include(): 包含并运行指定文件中的代码。如果文件不存在或包含过程中出现错误&#xff0c;将发出警告。 <?php include filename.php; ?>…

【前端demo】背景渐变动画

文章目录 效果过程代码htmlcss 其他demo 效果 效果预览&#xff1a;https://codepen.io/karshey/pen/OJrXZwQ 过程 注意&#xff0c;直接在body上加height:100%可能也会出现height为0的情况&#xff0c;这是需要令html的height:100% 代码 html <!DOCTYPE html> <…

面试题--从键盘输入网站到网页显示,之间发生了什么

文章目录 首先进入HTTP阶段协议栈阶段TCP阶段IP阶段MAC网卡交换机路由器抵达 首先进入HTTP阶段 1.解析对应的URL&#xff0c;访问一个对应的服务器xxx.com的一个文件index.html; 2 使用DNS查询对应的ip地址&#xff0c;通过DNS服务器进行查找 3 组装http报文&#xff0c;生成h…

成集云 | 多维表自动查询快递100信息 | 解决方案

源系统成集云目标系统 方案介绍 产品详情 维格表是一种新一代的团队数据协作和项目管理工具&#xff0c;它结合了可视化数据库、电子表格、实时网络协同、低代码开发技术四项功能&#xff0c;且支持API与可视化看板&#xff0c;操作简单&#xff0c;能提升中小企业的数字化生…