ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

鸿蒙ARKUI自定义TabBar:带小三角指示器的完整实现方案

鸿蒙ARKUI自定义TabBar:带小三角指示器的完整实现方案 最近在做鸿蒙应用的一个首页底部导航栏不想用系统默认那种样式产品提了个需求选中项下面要有一个小三角指示器像网页端那种 tab 切换的箭头感。翻了一圈 ARKUI 的 Tabs 组件发现原生的 barStyle 能配置的东西有限根本不支持在导航项下方挂一个小三角。研究了一晚上最后还是决定自己封装一套带小三角的自定义 TabBar。这篇文章就把整个实现思路、核心代码、样式扩展、以及实际操作中踩过的坑整理出来给正在做同类需求的朋友一个参考。先说结论ARKUI 里想要实现小三角导航项最稳的方案不是去改造 Tabs 自带的 bar而是隐藏系统导航栏 自定义 TabBar 组件 小三角贴边定位配合 TabsController 做页面联动。整体难度不大核心难点在于小三角的位置计算和动画衔接这两个点处理好效果会比系统默认的方条指示器好看很多。1. 需求拆解这个 TabBar 到底要长成什么样1.1 为什么默认 TabBar 满足不了这个需求HarmonyOS 的 ARKUI 框架里底部导航栏最直接的实现方式是用 Tabs 组件配合 TabContent 的 tabBar 属性或者给 Tabs 设置barStyle。系统默认的导航栏样式支持设置选中文字颜色、图标、背景色也支持切换时显示一个系统默认的指示条。但问题是这个指示条是矩形的样式、尺寸都相对固定你想在指示位置改成一个小三角官方 API 里并没有暴露这样的自定义入口。我最初尝试过用barStyle: BarStyle.MainAxisEqualWidth配合indicator去设置颜色和宽度确实能改出一个彩条指示器但依然无法把指示器改成三角形。后来也考虑过在 TabContent 里硬塞一个三角图片通过切换页面的时机控制显隐但这样做三角无法平滑滑动切换动画很生硬。综合对比下来最灵活的还是彻底抛弃系统导航栏自己在底部画一个。1.2 小三角导航项的视觉构成拆开来看这个自定义导航栏其实由三部分组成底部导航栏主体横向排列的若干个导航项每个项包含图标和文字选中态与未选中态使用不同颜色或图标资源。小三角指示器位于当前选中项的下方居中位置指向页面内容区域形状通常是倒置的等边三角形或带圆角的三角形。页面切换容器承载四个左右的页面内容与导航栏的选中索引双向联动点击导航项切换页面。这三部分缺一不可。尤其是小三角它不像普通的矩形指示器可以靠背景色直接平铺三角是一个独立的图形元素必须单独控制它的横向位置。所以整个实现方案可以概括为底部放一个横向排列的导航栏容器小三角作为容器内的独立子组件通过计算选中索引来设置它的横向偏移量最终让三角“贴”在选中项的正下方。1.3 三条技术路线的取舍做技术方案的时候我列了三种实现思路都实测过对比后留下了最合适的一条。方案实现方式优点缺点结论方案一使用 Tabs 的 tabBar 属性传入自定义构建器页面切换逻辑完整复用 Tabs自定义构建器无法自由控制指示器动画三角定位受 Tabs 内部布局影响大不推荐方案二隐藏系统 bar自定义底部导航栏 TabsController 联动导航栏完全自主可控动画自由代码直观需要自行处理切换逻辑强烈推荐方案三不用 Tabs使用 Stack 和条件渲染切换页面内容最灵活无组件约束页面生命周期管理麻烦需要手动处理预加载代码量大不推荐方案二的做法本质上是把页面容器和导航栏解耦Tabs 只负责承载页面内容和切换能力导航栏完全由自定义组件承担两者通过 TabsController 沟通。这样做的好处是导航栏的任何细节都可以自由修改小三角的位置计算、动画曲线、甚至多个小三角并存都能轻松实现。2. 环境准备与基础工程搭建2.1 开发环境与关键依赖说明我使用的是 DevEco Studio 4.0 及以上版本SDK 选择 API 10 或更高。这里要说明一个背景知识ARKUI 是 HarmonyOS 的声明式 UI 框架它的语法和 SwiftUI、Flutter 有相似之处都是通过状态驱动 UI 刷新但组件命名和 API 设计上又自成一派。Tabs 组件在 API 10 中功能已经比较完善支持TabsController控制器、onChange事件、barHeight属性等这套方案完全兼容。如果你还在使用 API 9需要注意部分属性名和动画能力会有些差异但整体思路仍然适用。建议优先升级到 API 10 以上否则Path组件和animateTo的配合会受限。2.2 自定义 TabBar 的数据模型设计在实际动手写 UI 之前先把导航栏的数据结构定义好。一个导航项至少需要三个信息显示文案、正常态图标、选中态图标。把这三样封装成一个类后续用数组批量渲染比一个项一个项手写要高效得多。// model/TabModel.ets export class TabModel { label: string normalIcon: Resource selectedIcon: Resource constructor(label: string, normalIcon: Resource, selectedIcon: Resource) { this.label label this.normalIcon normalIcon this.selectedIcon selectedIcon } }图标资源放在resources/base/media目录下通过$r(app.media.xxx)引用。注意$r返回的类型是Resource所以构造函数里用Resource类型接收这样在组件里直接用Image(item.selectedIcon)就能加载。2.3 页面布局的总体结构设计整个页面的布局采用 Stack 叠加方案。Stack 是 ARKUI 中的层叠容器可以让后面的子组件覆盖在前面的子组件之上。我的页面结构是Stack整个页面容器 ├── Tabs承载页面内容设置 barHeight 为 0 隐藏系统导航栏 └── Column自定义 TabBar位于底部通过 Stack 的 alignContent 控制位置 ├── Row横向排列的导航项 └── Path小三角指示器位置随选中项变化为什么用 Stack 而不是 Column 从上到下排列因为 Tabs 组件如果和自定义 TabBar 在同一个 Column 里依次排列Tabs 的高度计算会变得繁琐底部栏也容易受压。Stack 叠加之后Tabs 自动撑满整个页面自定义 TabBar 通过alignContent: Alignment.Bottom固定在底部视觉上正好覆盖在 Tabs 区域的最下方互不干扰。3. 核心实现一步一步写出带小三角的 TabBar3.1 第一步用 TabsController 接管页面切换先写页面的骨架。创建一个 Index.ets 入口页面在里面声明 TabsController、当前选中索引、以及小三角的横向偏移量。这三个是核心状态变量后续的联动都围绕它们展开。// pages/Index.ets Entry Component struct Index { private tabsController: TabsController new TabsController() State currentIndex: number 0 State triangleX: number 0 build() { Stack({ alignContent: Alignment.Bottom }) { Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { // 这里放四个 TabContent代表四个页面 } .scrollable(false) // 禁止左右滑动切换 .barHeight(0) // 关键隐藏系统导航栏 .onChange((index: number) { this.currentIndex index this.updateTrianglePosition(index) }) CustomTabBar({ currentIndex: this.currentIndex, onTabClick: (index: number) { this.tabsController.changeIndex(index) } }) } } updateTrianglePosition(index: number) { // 稍后实现 } }这里有个关键细节barHeight(0)不等于完全移除 Tabs 自带的导航栏区域在某些版本中即使设置为 0 仍然会保留一层薄薄的高度。严格的做法是同时设置barHeight(0)并配合.barStyle()把系统 bar 的样式清空确保不会在底部残留一个不可见但占据空间的区域。实测下来 API 11 上只设置barHeight(0)就够用但 API 10 上偶尔会遇到空白间距问题保险起见可以再加一层padding({ bottom: 0 })处理。3.2 第二步实现自定义 TabBar 组件主体自定义 TabBar 是一个独立的Component组件。它接收两个参数当前选中的索引、点击回调。内部用一个Row横向排列所有导航项每个导航项由Column包裹图标和文字。// components/CustomTabBar.ets Component export struct CustomTabBar { Prop currentIndex: number onTabClick: (index: number) void private tabs: TabModel[] [ new TabModel(首页, $r(app.media.home_normal), $r(app.media.home_selected)), new TabModel(消息, $r(app.media.msg_normal), $r(app.media.msg_selected)), new TabModel(发现, $r(app.media.discover_normal), $r(app.media.discover_selected)), new TabModel(我的, $r(app.media.mine_normal), $r(app.media.mine_selected)) ] build() { Column() { Row({ space: 0 }) { ForEach(this.tabs, (item: TabModel, index: number) { Column({ space: 4 }) { Image(this.currentIndex index ? item.selectedIcon : item.normalIcon) .width(24) .height(24) Text(item.label) .fontSize(this.currentIndex index ? 14 : 12) .fontColor(this.currentIndex index ? #1890FF : #666666) .fontWeight(this.currentIndex index ? FontWeight.Bold : FontWeight.Normal) } .width(25%) .height(56) .justifyContent(FlexAlign.Center) .onClick(() { this.onTabClick(index) }) }, (item: TabModel, index: number) item.label index) } .width(100%) .height(56) } .width(100%) .backgroundColor(#FFFFFF) .shadow({ radius: 8, color: rgba(0, 0, 0, 0.05), offsetY: -2 }) } }几个细节说明。ForEach的第三个参数是键值生成器最好加上 index 或者唯一的 label避免列表更新时出现复用错乱。每个导航项宽度设置为25%也就是四个项均分屏幕宽度这是为了保证后续小三角的定位计算与项宽一致。如果用固定像素宽度不同分辨率设备上会错位。文字大小在选中和未选中有差异这是常规导航栏的自适应表现后面在样式扩展里可以进一步优化。3.3 第三步绘制小三角并计算位置算法小三角的绘制有多种方式我最终选了 Path 组件而不是 Canvas因为 Path 的命令式写法更直观几行代码就能画出一个三角形。// 小三角路径顶点居中底边朝下 Path() .commands(M0 0 L16 0 L8 10 Z) .fill(#1890FF) .width(16) .height(10)这里的 16 和 10 是宽度和高度三角形底边 16vp高 10vp。M0 0 L16 0 L8 10 Z的含义是从左上角 (0, 0) 画线到右上角 (16, 0)再到下方中点 (8, 10)最后回到起点闭合路径。这样画出来的是一个倒三角形正好作为向下指示的小箭头。位置计算是这个需求的核心算法。假设 TabBar 一共 4 个导航项每个项宽度是screenWidth / 4小三角宽度是TRIANGLE_WIDTH那么第 index 个项被选中时三角应该在水平方向平移的距离是triangleX index * itemWidth (itemWidth - TRIANGLE_WIDTH) / 2这个公式的意思是先跳到第 index 个导航项的起始位置然后在当前项内部居中对齐。以小三角宽度 16vp 为例若导航栏总宽为 360vp常见手机逻辑宽度每个项宽 90vp则四个位置分别是选中索引计算结果说明00 * 90 (90 - 16) / 2 37第一个项居中11 * 90 37 127第二个项居中22 * 90 37 217第三个项居中33 * 90 37 307第四个项居中如果导航项数量不是 4或者每项宽度用了百分比就用总宽度除以tabs.length。总之只要保证每个导航项的实际宽度和计算时使用的宽度一致三角就能精准定位。3.4 第四步联动动画让小三角平滑滑动有了位置计算还差最后一步从当前位置平滑过渡到目标位置而不是瞬间跳变。这里用animateTo包装状态变量的修改即可实现补间动画。// 更新三角位置 private updateTrianglePosition(index: number) { const itemWidth this.tabBarWidth / this.tabs.length animateTo({ duration: 300, curve: Curve.EaseInOut }, () { this.triangleX index * itemWidth (itemWidth - TRIANGLE_WIDTH) / 2 }) }animateTo是 ARKUI 的动画接口它会拦截第一个参数中闭包所修改的状态变量并以动画的形式过渡到新值。这里把triangleX的赋值语句包在闭包里Tabs 的onChange回调触发时就会自动触发动画。把这个动画逻辑封装进自定义 TabBar 内部更合理让组件自治。我最终的写法是CustomTabBar 内部通过Watch监听currentIndex的变化一旦外部传入的索引变了内部自动更新小三角位置。Component export struct CustomTabBar { Prop Watch(onIndexChanged) currentIndex: number onIndexChanged() { this.updateTrianglePosition(this.currentIndex) } }这样一个关键能力就是点击导航项切换页面后Tabs 的onChange更新currentIndexCustomTabBar 监听到变化自动移动小三角。逻辑闭环完成开发者在使用组件时完全不用手动操作三角位置。完整的 CustomTabBar 核心代码大致如下Component export struct CustomTabBar { Prop Watch(onIndexChanged) currentIndex: number onTabClick: (index: number) void private tabs: TabModel[] [] private tabBarWidth: number 0 private readonly TRIANGLE_WIDTH: number 16 State triangleX: number 0 aboutToAppear() { // 首次加载时计算初始位置 this.tabBarWidth this.getTabBarWidth() this.triangleX this.calcTriangleX(0) } build() { Column() { Row() { ForEach(this.tabs, (item: TabModel, index: number) { // 导航项 UI }) } .width(100%) .height(56) .onAreaChange((oldValue: Area, newValue: Area) { // 通过 onAreaChange 获取实际宽度 this.tabBarWidth newValue.width as number this.triangleX this.calcTriangleX(this.currentIndex) }) Path() .commands(M0 0 L16 0 L8 10 Z) .fill(#1890FF) .width(this.TRIANGLE_WIDTH) .height(10) .position({ x: this.triangleX }) } .width(100%) .backgroundColor(#FFFFFF) } private calcTriangleX(index: number): number { if (this.tabBarWidth 0) return 0 const itemWidth this.tabBarWidth / this.tabs.length return index * itemWidth (itemWidth - this.TRIANGLE_WIDTH) / 2 } private onIndexChanged() { animateTo({ duration: 300, curve: Curve.EaseInOut }, () { this.triangleX this.calcTriangleX(this.currentIndex) }) } }注意onAreaChange的使用。在 ARKUI 中组件的实际宽度不一定等于我们写在资源文件里的逻辑宽度特别是不同屏幕适配下实际宽度可能包含 padding、border 等影响。onAreaChange会在组件尺寸变化时回调最新的 Area 信息从中拿到的width才是渲染的真实宽度用它来做位置计算最保险。4. 样式扩展从“能跑”到“好看”4.1 文字与图标的选中态增强小三角导航栏如果只是像系统一样换个颜色未免浪费了自定义的能力。常见的增强思路有三个文字随选中态放大、图标切换为实心/空心、选中项增加背景高亮。文字放大可以通过animateTo实现配合scale属性给文字一个细小的放大效果。实测中fontSize不能直接配合动画平滑变化但用scale属性是可行的注意Text组件的scale作用于组件整体。还有一个更稳的方法在选中项的文字外层Text上设置fontSize然后让它的变化也包在animateTo里ARKUI 对字体大小的动画支持在 API 10 以上表现不错。图标切换则更简单用两个不同资源文件的图标根据选中态条件渲染。如果不想做两套图标资源也可以用同一个图标加fillColor修改颜色但需要注意部分 PNG 资源无法正确着色建议用 SVG 矢量图资源才可以配合fillColor改变颜色。4.2 小三角样式的多个变体默认的小三角是纯色直边箭头但实际项目中你可能需要更复杂的样式。我整理几个可用的变体方案圆角三角Path 支持二次贝塞尔曲线命令可以用Q控制圆角画出一个底部带圆角的三角。例如M0 0 L6 0 Q8 0 8 3 L8 8 Q8 10 6 10 L0 10 Z这种写法就能在底部拐角处形成圆角。渐变小三角保留 Path 图形外面包一层linearGradient让三角呈现从上到下的渐变过渡色。胶囊指示器 小三角组合有些设计喜欢底部一个细长的胶囊块胶囊块下方再伸出一个三角形成“锚点”样式。实现起来也不难就是 Stack 叠加两个图形。我实际项目里就用了渐变圆角三角视觉上比纯色直边三角更柔和和整体页面圆角风格也更统一。4.3 支持角标和红点提示导航栏的角标需求很常见尤其“消息”Tab。系统有Badge组件但放在自定义 TabBar 里布局控制不够灵活。我更推荐自己写一个角标布局在每个导航项的 Column 外层包一个 Stack角标定位在右上角。Stack({ alignContent: Alignment.TopEnd }) { Column({ space: 4 }) { Image(...) Text(item.label) } // 角标 if (item.badgeCount 0) { Text(${item.badgeCount}) .backgroundColor(#FF3B30) .fontSize(10) .fontColor(#FFFFFF) .borderRadius(8) .padding({ left: 5, right: 5, top: 2, bottom: 2 }) .margin({ top: -2, right: -8 }) } }如果只是显示小红点而不显示数字把 Text 换成固定宽高的圆形比如width(8).height(8).borderRadius(4).backgroundColor(#FF3B30)即可。注意Stack alignContent的TopEnd是指子组件定位在右上角配合外边距微调位置。5. 常见问题与排查实录5.1 问题速查表下面这些问题是实际调试中容易遇到的按出现频率排序现象原因解决方案小三角初始位置不对默认停留在第一个位置但没居中首次渲染时 tabBarWidth 为 0位置计算返回 0用onAreaChange获取真实宽度后重新计算初始位置点击导航项页面切换了但小三角不动CustomTabBar 没有监听当前索引变化或动画包裹的变量错误检查Watch装饰器是否生效确认动画闭包里改的是triangleX底部残留系统导航栏的高度空白barHeight(0)未完全隐藏系统 bar额外设置.barStyle清空样式或设置.barBackgroundColor(Color.Transparent)小三角位置偏左或偏右不在项的正中导航项实际宽度和计算宽度不一致或存在 padding导航项切勿设置水平 padding宽度优先使用百分比并确保总和为 100%点击导航项触发切换动画与点击动画冲突出现抖动TabsController.changeIndex 和 onChange 同时触发了两次位置更新在onIndexChanged里做幂等判断如果triangleX已经是目标位置则跳过动画真机上图标偶现重叠资源图片尺寸不一致导致 Image 宽高设置失效给 Image 设置统一的objectFit(ImageFit.Contain)并检查资源尺寸5.2 动态宽度问题深入排查动态宽度带来的位置错误调试起来比较隐蔽。去掉了导航项的 padding 之后还需要注意ForEach内部如果包含了layoutWeight或flexGrow计算出来的宽度可能是 0 或异常值。我的建议是导航项容器不要使用layoutWeight直接用百分比width(25%)如果导航项数量是动态的用width(100 / n %)的字符串拼接。百分比方案在 ARKUI 中解析稳定且不会受剩余空间分配策略影响。另外还要提防Path组件自身带的布局尺寸。如果 Path 组件使用了position定位它在父容器中是不占位置的此时如果父 Column 没有额外设置高度三角可能会和导航栏重叠或高度不够。解决方案是在导航栏 Column 下方设一个固定高度的透明占位容器专门存放三角的绘制区域保证三角不会遮挡文字区域。5.3 动画不生效的细节排查动画不生效时第一步检查animateTo的闭包内是否真的修改了被State修饰的变量。State是 ARKUI 的状态管理装饰器只有它修饰的变量变化才会触发 UI 重绘如果你把triangleX定义成普通成员变量哪怕动画写了也看不到任何效果。第二步检查动画闭包里是否有多个变量同时被修改。animateTo会把闭包内所有状态变化统一捕捉如果同时改了currentIndex和triangleX可能导致不必要的联动动画。尽量保持闭包内只操作一个和指示器相关的变量其他状态放在闭包外处理。第三步是不要忽略Curve曲线类型的选择。Curve.EaseInOut是默认的缓入缓出曲线适合指示器滑动如果使用Curve.Linear动画看起来会比较机械。如果你的需求是模拟“弹跳感”试试Curve.Spring或Curve.Smooth体验完全不同。5.4 关于生命周期和内存释放这个问题容易被忽略。TabsController 是一个较重的对象如果在页面aboutToDisappear时没有及时释放引用页面反复创建销毁可能导致内存缓增。虽然 ARKUI 的自动垃圾回收能兜底但我在长列表场景下遇到过 TabContent 保留旧状态的问题。建议页面销毁时把tabsController置空或者在页面级组件复用时重新初始化 TabsController。对于大部分单页面应用场景这个坑也许不会触发但养成清理控制器的习惯总没坏处。6. 写在最后一点个人实操体会做完这个自定义 TabBar 之后最大的体会是ARKUI 里很多看似“官方不支持”的效果绕一下反而更自由。用 Tabs 自带的导航栏做小三角不仅样式受限制动画也难操控不如隐藏系统导航栏把导航栏完全握在自己手里。组件化改造之后这个自定义 TabBar 可以直接装进其他页面参数传数组就行扩展性和复用性都远超预期。最后分享一个小技巧在实际项目中导航栏的每一项不要写死宽度尤其是未来可能动态增减 Tab 数量的场景用100 / this.tabs.length %来自动分配宽度这样无论是 4 个 Tab 还是 5 个 Tab小三角的位置算法都无需改动。如果后续想把小三角改成顶部导航栏的“下划线”样式同样的算法和结构稍微调整方向即可复用。做 UI 自定义这件事思路通了剩下都是细节打磨。
返回列表