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.mzph.cn/news/62671.shtml

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

相关文章

如何在小红书进行学习直播

诸神缄默不语-个人CSDN博文目录 因为我是从B站开始的&#xff0c;所以一些直播常识型的东西请见我之前写的如何在B站进行学习直播这一篇。 本篇主要介绍一些小红书之与B站不同之处。 小红书在手机端是可以直接点击“”选择直播的。 文章目录 1. 电脑直播-小红书直播软件2. 电…

【前端demo】背景渐变动画

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

【数据库】关系模型介绍+形式化关系查询语言

目录 第2章 关系模型介绍 2.1 关系数据库的结构 关系 2.2 数据库模式 2.3 码 2.4 模式图 大学数据库的模式图&#xff01;&#xff01;&#xff01; 大学数据库关系模式&#xff01;&#xff01;&#xff01; 2.5 关系查询语言 2.6 关系运算 2. 7 总结 第6章 形式化…

哪个视觉语言模型更优?InstructBLIP、MiniGPT-4?全面评估基准LVLM-eHub告诉你

夕小瑶科技说 原创 作者 | 王思若 LLaMA、GPT-3等大型语言模型实现了对自然语言强大的理解和推理能力&#xff0c;为AI社区构筑了强大的语言基座模型。进而&#xff0c;继续迭代的GPT-4&#xff0c;更是赋予了模型处理图像的视觉能力。 如今&#xff0c;构建强大的多模态模型…

python conda实践 sanic框架gitee webhook实践

import subprocess import hmac import hashlib import base64 from sanic.response import text from sanic import Blueprint from git import Repo# 路由蓝图 hook_blue Blueprint(hook_blue)hook_blue.route(/hook/kaifa, methods["POST"]) async def kaifa(req…

【Unity3D】UI Toolkit容器

1 前言 UI Toolkit简介 中介绍了 UI Builder、样式属性、UQuery&#xff0c;本文将介绍 UI Toolkit 中的容器&#xff0c;主要包含 VisualElement、ScrollView、ListView、UI Toolkit&#xff0c;官方介绍详见→UXML elements reference。 2 VisualElement&#xff08;空容器&…

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. 源码…

简述SpringMVC

一、典型的Servlet JSP JavaBean UserServlet看作业务逻辑处理&#xff08;Controller&#xff09;User看作模型&#xff08;Model&#xff09;user.jsp看作渲染&#xff08;View&#xff09; 二、高级MVC 由DispatcherServlet对请求统一处理 三、SpringMVC MVC与Spr…

springboot实战(四)之整合mybatis-plus

目录 环境&#xff1a; 准备&#xff1a; 开始&#xff1a; 1.创建表t_user 2.项目添加依赖 3.配置 1.配置mysql链接信息 2.在启动类配置mapper扫描路径 4.创建实体类 5.创建mapper 6.测试 环境&#xff1a; jdk&#xff1a;1.8 springboot版本&#xff1a;2.7.15…

面试题-React(七):React组件通信

在React开发中&#xff0c;组件通信是一个核心概念&#xff0c;它使得不同组件能够协同工作&#xff0c;实现更复杂的交互和数据传递。常见的组件通信方式&#xff1a;父传子和子传父 一、父传子通信方式 父组件向子组件传递数据是React中最常见的一种通信方式。这种方式适用…

VMware tools的安装以及按钮灰色的解决方法

VMware tools的安装 ** 注意&#xff1a;** 新版本的 VMware 会自动安装的 VMware tools&#xff0c;如何测试 VMware tools 呢&#xff1f;在Windows 系统里复制一段话&#xff0c;能粘贴到 Ubuntu 系统里终端里&#xff0c;说明 VMware tools 已经安装了。 没有安装的请参考…

问道管理:暂停交易!港交所最新宣布,北向资金也受影响!大涨超180%

今日上午A股商场全体涨跌互现&#xff0c;煤炭板块涨幅居前。新股方面也体现较好&#xff0c;有一只新股盘中一度暴涨超越180%。 别的&#xff0c;飓风“苏拉”对股市也产生了影响。港交所最新公告称&#xff0c;因为八号飓风信号现正收效&#xff0c;今日&#xff08;星期五&…

抢先体验|乐鑫推出 ESP32-S3-BOX-3 新一代开源 AIoT 开发套件

乐鑫科技 (688018.SH) 非常高兴地宣布其开发套件阵容的最新成员 ESP32-S3-BOX-3。这款完全开源的 AIoT 应用开发套件搭载乐鑫高性能 ESP32-S3 AI SoC&#xff0c;旨在突破传统开发板&#xff0c;成为新一代开发工具的引领者。 【乐鑫新品抢先体验】ESP32-S3-BOX-3 新一代开源 A…

代码随想录刷题笔记 (python版本) 持续更新.....

代码随想录刷题笔记总结: https://www.programmercarl.com/ 个人学习笔记 如有错误欢迎指正交流1. 数组 1.1 理论基础 详细介绍:https://www.programmercarl.com/%E6%95%B0%E7%BB%84%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80.html 数组下标都是从0开始的。数组内存空间的地址是…

jmeter单接口和多接口测试

最近接触到了多接口串联&#xff0c;接口串联的技术会在其他帖子有说明&#xff0c;其核心技术点就是通过正则表达式和变量来实现接口的关联。目前为止呢笔者用到的地方还只有一个&#xff0c;就是关于session保持的时候。但是看到很多资料都说测试过程中经常遇到b接口需要用a接…

jvm 新生代的区域划分

虚拟机将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间&#xff0c;每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时&#xff0c;将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上&#xff0c;然后直接清理掉 Eden 和已用过…

【学习笔记】计算机视觉对比学习综述

计算机视觉对比学习综述 前言百花齐放InstDiscInvaSpreadCPCCMC CV双雄MoCoSimCLRMoCo v2SimCLR v2SwAV 不用负样本BYOLSimSiam TransformerMoCo v3DINO 总结参考链接 前言 本篇对比学习综述内容来自于沐神对比学习串讲视频以及其中所提到的论文和博客&#xff0c;对应的链接详…

Scikit-Learn 和深度学习怎么选择

大家好&#xff0c;今天我们要聊聊一个机器学习的话题&#xff1a;Scikit-Learn 和深度学习&#xff0c;到底哪一个更适合解决你的问题&#xff1f;我们先来看看这两种技术的异同点&#xff0c;然后再讲讲如何在实际问题中做出选择。 1. Scikit-Learn 与深度学习&#xff1a;谁…

java+springboot+mysql校园跑腿管理系统

项目介绍&#xff1a; 使用javaspringbootmysql开发的校园跑腿管理系统&#xff0c;系统包含超级管理员&#xff0c;系统管理员、用户角色&#xff0c;功能如下&#xff1a; 超级管理员&#xff1a;管理员管理&#xff1b;用户管理&#xff08;充值&#xff09;&#xff1b;任…

C++面试题(叁)---操作系统篇

目录 操作系统篇 1 Linux中查看进程运行状态的指令、查看内存使用情况的指令、 tar解压文件的参数。 2 文件权限怎么修改 3 说说常用的Linux命令 4 说说如何以root权限运行某个程序。 5 说说软链接和硬链接的区别。 6 说说静态库和动态库怎么制作及如何使用&#xff0c;区…