Jetpack Compose 自定义 好看的TabRow Indicator

背景

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.maxWidthval tabMeasurables = subcompose(TabSlots.Tabs, tabs)val tabCount = tabMeasurables.sizevar tabWidth = 0if (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.sizemeasurables.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的TabPositionval 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的leftcurrentTab.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.sizevar tabWidth = 0if (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.sizemeasurables.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 + widthoverride fun equals(other: Any?): Boolean {if (this === other) return trueif (other !is TabPosition) return falseif (left != other.left) return falseif (width != other.width) return falsereturn 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@Composablefun 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? = nullfun 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 = selectedTabtabPositions.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() + edgeOffsetval visibleWidth = totalTabRowWidth - scrollState.maxValueval tabOffset = left.roundToPx()val scrollerCenter = visibleWidth / 2val 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.rhkb.cn/news/115510.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

新手练习python+selenium自动化测试实战项目【还有其他项目】

说明&#xff1a;本项目采用流程控制思想&#xff0c;未引用unittest&pytest等单元测试框架 【项目都放在最下面小卡片了&#xff0c;有需要可以自取】 一.项目介绍 目的 测试某官方网站登录功能模块可以正常使用 用例 1.输入格式正确的用户名和正确的密码&#xff0c;验…

Stable Diffusion 提示词技巧

文章目录 背景介绍如何写好提示词提示词的语法正向提示词负向提示词 随着AI技术的不断发展&#xff0c;越来越多的新算法涌现出来&#xff0c;例如Stable Diffusion、Midjourney、Dall-E等。相较于传统算法如GAN和VAE&#xff0c;这些新算法在生成高分辨率、高质量的图片方面表…

Gazebo仿真环境下的强化学习实现

Gazebo仿真环境下的强化学习实现 主体源码参照《Goal-Driven Autonomous Exploration Through Deep Reinforcement Learning》 文章目录 Gazebo仿真环境下的强化学习实现1. 源码拉取2. 强化学习实现2.1 环境2.2 动作空间2.3 状态空间2.4 奖励空间2.5 TD3训练 3. 总结 1. 源码…

HTTP协议详解:互联网通信背后的规则与秘密

个人主页&#xff1a;insist--个人主页​​​​​​ 本文专栏&#xff1a;网络基础——带你走进网络世界 本专栏会持续更新网络基础知识&#xff0c;希望大家多多支持&#xff0c;让我们一起探索这个神奇而广阔的网络世界。 目录 一、HTTP协议的基本概念 二、HTTP协议的主要特…

web题型

本文在别人的基础上对于一些地方做了一点补充 0X01 命令执行 漏洞原理 没有对用户输入的内容进行一定过滤直接传给shell_exec、system一类函数执行 看一个具体例子 cmd1|cmd2:无论cmd1是否执行成功&#xff0c;cmd2将被执行 cmd1;cmd2:无论cmd1是否执行成功&#xff0c;cm…

SpringBoot项目(jar)部署,启动脚本

需求 SpringBoot项目&#xff08;jar&#xff09;部署&#xff0c;需要先关闭原来启动的项目&#xff0c;再启动新的项目。直接输入命令&#xff0c;费时费力&#xff0c;还容易出错。所以&#xff0c;使用脚本启动。 脚本 脚本名&#xff1a;start.sh 此脚本需要放置在jar包…

【算法】递归的概念、基本思想

创作不易&#xff0c;本篇文章如果帮助到了你&#xff0c;还请点赞 关注支持一下♡>&#x16966;<)!! 主页专栏有更多知识&#xff0c;如有疑问欢迎大家指正讨论&#xff0c;共同进步&#xff01; &#x1f525;c系列专栏&#xff1a;C/C零基础到精通 &#x1f525; 给大…

JavaScript Web APIs-01学习

复习&#xff1a; splice() 方法用于添加或删除数组中的元素。 **注意&#xff1a;**这种方法会改变原始数组。 删除数组&#xff1a; splice(起始位置&#xff0c; 删除的个数) 比如&#xff1a;1 let arr [red, green, blue] arr.splice(1,1) // 删除green元素 consol…

ASUS华硕VivoBook15笔记本V5200EA_X515EA原装出厂Win11预装OEM系统

华硕11代酷睿笔记本电脑VivoBook_ASUSLaptop X515EA_V5200EA原厂Windows11系统 自带显卡、声卡、网卡、蓝牙等所有驱动、出厂主题壁纸、Office办公软件、华硕电脑管家MyASUS、迈克菲等预装程序 链接&#xff1a;https://pan.baidu.com/s/1yAEdA7aiuHK4CTdGLlSOKw?pwdo45a …

Azure - AzCopy学习

使用 AzCopy 将本地数据迁移到云存储空间 azcopy login 创建存储账号 ./azcopy login --tenant-id 40242385-c249-4746-95dc-4a0b64d49dc5这里的—tenant-id 在下面的地方查看&#xff1a;目录 ID&#xff1b;需要拥有Storage Blob Data Owner 的权限账号下可能会有很多目录&am…

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

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

RabbitMQ入门

1、RabbitMQ概念简介 RabbitMQ是一个开源的消息代理和队列服务器&#xff0c;用来通过普通协议在完全不同的应用之间共享数据&#xff0c;RabbitMQ是使用Erlang语言来编写的&#xff0c;并且RabbitMQ是基于AMQP协议的。 AMQP协议模型 AMQP全称&#xff1a;Advanced Message Q…

浅探Android 逆向前景趋势~

前段时间&#xff0c;我和朋友偶然间谈起安卓逆向&#xff0c;他问我安卓逆向具体是什么&#xff0c;能给我们带来什么实质性的东西&#xff0c;我也和朋友大概的说了一下&#xff0c;今天在这里拿出来和大家讨论讨论&#xff0c;也希望帮助大家来了解安卓逆向。 谈起安卓逆向…

Annual Inspection

机动车年检流程【交警12123】APP 到【检查地方】门口墙上贴着 然后上缴钥匙&#xff0c;等待&#xff0c;本次等待不到半小时搞定&#xff0c;速度很满意&#xff0c; 发现检测人员把你的里程数纠正了。 给你的行驶证&#xff0c;打印这些字样&#xff1a;检验有效期至XXXX 再给…

WebGPU助力客户端Crypto/ZK

1. 引言 前序博客&#xff1a; CUDA入门WebGPUZKP&#xff1a;客户端证明WebGPU入门 正如Personae Labs团队2022年11月博客 Efficient ECDSA & the case for client-side proving 中所指出&#xff1a; 仅适用于高端笔记本电脑的5分钟证明生成时长&#xff0c;远不是可行…

Leetcode: 1. 两数之和 【题解超详细】

前言 有人夜里挑灯看花&#xff0c;有人相爱&#xff0c;有人夜里开车看海&#xff0c;有人leetcode第一题都做不出来。 希望下面的题解可以帮助你们开始 你们的 leetcode 刷题 的 天降之路 题目 给定一个整数数组 nums 和一个整数目标值 target&#xff0c;请你在该数组中…

【ES6】Promise的入门介绍

Promise 是 JavaScript 中的一个对象&#xff0c;用于处理异步操作。Promise 对象代表一个最终可能完成&#xff08;并得到结果&#xff09;或失败&#xff08;并被拒绝&#xff09;的操作&#xff0c;以及其结果的值。 一个 Promise 有三种状态&#xff1a; Pending&#xf…

基于costas环的载波同步系统matlab性能仿真

目录 1.算法运行效果图预览 2.算法运行软件版本 3.部分核心程序 4.算法理论概述 5.算法完整程序工程 1.算法运行效果图预览 2.算法运行软件版本 matlab2022a 3.部分核心程序 ............................................................................ I_Dataroun…

Spring-5.0.x源码下载及本地环境搭建

一、Spring源码下载 从github上下载Spring的源代码 下载地址&#xff1a;https://github.com/spring-projects/spring-framework 访问地址之后&#xff0c;打开Spring的代码页面找到你想下载的版本&#xff0c;如5.0.x&#xff0c;如下图所示&#xff1a; 下载方式一&#x…

java+jsp+servlet+mysql蛋糕商城

项目介绍&#xff1a; 本系统为基于jspservletmysql的蛋糕商城&#xff0c;包含管理员和用户角色&#xff0c;用户功能如下&#xff1a; 用户&#xff1a;注册、登录系统&#xff1b;查看商品分类&#xff1b;查看热销、新品商品&#xff1b;查看商品详情&#xff1b;搜索商品…