ARTICLE DETAIL

资讯详情

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

HarmonyOS自定义TabBar:带小三角导航项的实现与滑动动画

HarmonyOS自定义TabBar:带小三角导航项的实现与滑动动画 做HarmonyOS应用开发的朋友应该都被底部导航条的“样式自由度”坑过系统自带Tabs的TabBar可以改字体颜色、背景色、图标但设计师丢来一张“首页下方要带一个小三角”的高保真图时常用配置立刻见底。这篇我直接用ArkUI把“下方带小三角的导航项”完整实现了一遍分简单版和滑动版两套代码顺手把三角形绘制、选中态联动、宽度计算、安全区适配这些坑全部拆开讲清楚。你如果是刚开始接触ArkTS可以照着代码先跑通效果如果已经被Tabs的barHeight折磨过那直接跳到第4节看排查部分应该能少走不少弯路。1. 思路拆解小三角导航栏的两种实现路线1.1 小三角到底在表达什么小三角不是单纯为了好看它承担的是“当前态层级指示”的功能。文字或图标下方的尖角会把用户的视觉重心从选中项往下带相当于给整个导航栏增加了一个纵深层次。这个效果落到代码层面可以拆成三个问题三角形怎么画、画在谁下面、切换时怎么移动。系统自带的TabBar只支持常规样式配置想在特定项下方精准控制一个小三角的颜色、位置和动画已经超出它的能力范围所以必须走自定义路线。这里很多人有一个误区觉得“自定义TabBar”就是把每个导航项用Builder包起来塞进原来的tabBar属性里。但实际工程里还有另一种做法就是把整个底栏移出Tabs组件放在页面布局的最外层让Tabs只负责页面内容切换这样控制力会强很多。两条路没有绝对好坏取决于你对“三角跟随动画”和“角标等其他需求”的预期。1.2 方案ATabs自带customTabBar适合快速出效果这种方法是在每个TabContent的.tabBar()参数里传入一个自定义构建函数用ArkUI的Builder把文字、图标、三角全部画出来。当前选中的索引currentIndex作为一个状态变量在Builder内部控制文字的fontColor、fontWeight以及三角的opacity。优点很明显结构简单代码量少Tabs组件自带的切换逻辑不用管页面滑动、切换动画都能保留。缺点也比较明确你触不到Tabs内部Bar的很多细节比如三角的平滑平移动画只能靠每个Item内部显隐做纯粹的跟随滑动比较别扭。另外自定义Builder里的点击事件需要自己绑定TabsController.changeIndex不绑定的话“页面不会跟着点击切换”这个坑一定会踩。1.3 方案B外部自定义TabBar适合复杂生产场景方案B的做法是把Tabs的barHeight(0)直接压掉然后在Tabs下方用一个完全独立的Column或Stack自己画底部导航栏。页面内容由Tabs管理底栏纯粹是“看起来像TabBar”的自定义组件。这样做的第一个好处是所有元素的位置、尺寸、动画都在你手里三角形可以通过position或offset做平滑位移角标、红点、复杂背景、主题色切换都不受限制。第二个好处是你可以把TabBar抽成独立组件用Prop或Link接收currentIndex多个页面复用。代价则是需要自己处理Bar的宽度测量、安全区高度和Tabs之间的状态同步工程量比方案A大。1.4 我的选型结论如果是做技术验证、内部Demo或者样式很简单直接用方案A半小时跑通。如果是正式项目、设计稿比较复杂或者后续要加角标、动效、暗黑模式建议直接用方案B虽然前期多写一点代码但后面省心。下面第2章先把两种方案都会用到的核心细节讲明白第3章再给完整代码你按需取用。2. 画小三角与选中态联动最容易翻车的三个细节2.1 三角形绘制四种方式怎么选ArkUI里没有现成的“Triangle”组件实现一个倒三角通常有四种思路Polygon、Path、Text符号、图片资源。我实际用下来最推荐Polygon。绘制方式核心写法优点缺点PolygonPolygon().points([[0,0],[12,0],[6,8]])坐标精确控制不依赖资源颜色可随状态变化需要理解坐标语义PathPath().commands(M0,0 L12,0 L6,8 Z)能画任意异形命令串可读性差不好维护Text符号Text(▼)写法最简不同设备字体渲染有差异三角形心会偏移Image资源Image($r(app.media.arrow))和设计稿完全一致增加资源体积换主题色要换图以宽12、高8的倒三角为例Polygon的坐标含义是[0,0]是左上角[12,0]是右上角[6,8]是底边中间的尖点。三点连起来底边在上、尖角朝下正好是导航栏下方那种小三角。如果你需要更宽的三角把12改成16再把尖点横坐标改成8三角自动跟变。提示不要为了省事用Text(▼)。字符的垂直居中在不同字体和系统版本上表现不一致我遇到过在模拟器上完美、真机上三角明显偏左一像素的情况设计师盯着一放大就露馅。用Polygon之后再也没有这种玄学偏移。2.2 选中态状态链路currentIndex是唯一真相自定义TabBar的一切视觉状态都依赖一个核心变量currentIndex。它的变化链路一般是用户点击Tab项触发TabsController.changeIndex(index)或直接修改currentIndexTabs的onChange回调同步更新currentIndexTabBar内部的文字颜色、字体粗细、三角透明度或位移全部通过this.currentIndex index计算得出。这条链路最容易犯的错是“只改一半”。比如自定义Builder里写得很好但点击Tab时只改了currentIndex没有调用changeIndex结果是三角会换位置页面却纹丝不动。反过来如果只依赖onChange同步但点击事件里没有触发任何页面切换逻辑那页面永远停在第一页。实战中最稳妥的做法是点击统一走changeIndexonChange里只做状态同步形成闭环。2.3 对齐策略别再手写122.5新手拿到“第2个Tab下方放一个三角”的需求第一反应往往是position({ x: 122.5 })。这种硬编码在单一台设备上看着没问题换一台宽度不同的设备马上露馅因为TabBar宽度是设备相关的dp和px在不同机型的映射也不一样。正确的抓手有两个。第一种策略每个Tab内部自带一个三角选中时opacity为1未选中为0。这种方案天然居中完全不需要计算位置三角自动落在文字或图标正下方。缺点是三角不会做横向滑动只能淡入淡出。第二种策略整个TabBar里只放一个“会移动的三角”通过position或offset动态计算坐标。计算基础来自onAreaChange回调拿到的实时宽度再除以Tab个数得到每个Tab的实际宽度然后让三角的x坐标等于第N个Tab的中心点偏移半个三角宽。这样在任意分辨率下都能对齐代价是需要多写一次宽度测量。注意如果你需要三角平滑滑动优先考虑第二种策略。透明度显隐虽然简单但交互手感偏“硬”设计师如果要求“指示器跟随手指滑过去”就只能上位移方案。3. 完整代码实现从opacity显隐版到平滑移动版3.1 简单版完整代码Tabs builder opacity这个版本适合快速验证效果代码短依赖少。核心思路是三个Tab的Builder内部各放一个Polygon用opacity控制显隐。Entry Component struct SimpleTabBarDemo { State currentIndex: number 0 private tabsController: TabsController new TabsController() private titles: string[] [首页, 消息, 我的] Builder tabBuilder(index: number) { Column() { Text(this.titles[index]) .fontSize(14) .fontColor(this.currentIndex index ? #1677FF : #8A8F99) .fontWeight(this.currentIndex index ? FontWeight.Medium : FontWeight.Regular) .margin({ bottom: 6 }) Polygon() .width(12) .height(8) .points([[0, 0], [12, 0], [6, 8]]) .fill(#1677FF) .opacity(this.currentIndex index ? 1 : 0) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .onClick(() { this.tabsController.changeIndex(index) }) } build() { Tabs({ barPosition: BarPosition.End, controller: this.tabsController, index: this.currentIndex }) { ForEach(this.titles, (title: string, index: number) { TabContent() { Column() { Text(这是${title}页面) .fontSize(18) .fontColor(#333333) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .backgroundColor(index % 2 0 ? #F5F6FA : #FFFFFF) } .tabBar(this.tabBuilder(index)) }, (title: string) title) } .barHeight(56) .barBackgroundColor(#FFFFFF) .scrollable(false) .onChange((index: number) { this.currentIndex index }) } }这里有两个关键点。第一个是.tabBar(this.tabBuilder(index))的写法有的ArkTS版本如果在编译期报类型不匹配可以改成.tabBar(() { this.tabBuilder(index) })两种都是文档里出现过的写法目的是让CustomBuilder类型能够识别。第二个是scrollable(false):如果你不希望页面能左右滑动切换一定要关掉否则手势滑动和点击切换会在某些边缘场景下互相干扰。3.2 滑动版完整代码外部Bar 单一三角 animateTo这个版本把系统Bar完全隐藏用一个外部Stack承载单一三角三角坐标通过公式计算配合animateTo实现平滑滑动。Entry Component struct SlideTabBarDemo { State currentIndex: number 0 State private barWidth: number 360 private tabsController: TabsController new TabsController() private titles: string[] [首页, 消息, 我的] private readonly tabCount: number 3 private readonly tabBarHeight: number 56 private readonly triangleWidth: number 12 private readonly triangleHeight: number 8 build() { Column() { Tabs({ barPosition: BarPosition.End, controller: this.tabsController, index: this.currentIndex }) { ForEach(this.titles, (title: string, index: number) { TabContent() { Column() { Text(这是${title}页面) .fontSize(18) .fontColor(#333333) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .backgroundColor(index % 2 0 ? #F5F6FA : #FFFFFF) } }, (title: string) title) } .barHeight(0) .scrollable(false) .layoutWeight(1) .onChange((index: number) { this.animatedSwitch(index) }) this.customBar() } .width(100%) .height(100%) .backgroundColor(#FFFFFF) } Builder customBar() { Stack() { Row() { ForEach(this.titles, (title: string, index: number) { Column() { Text(title) .fontSize(14) .fontColor(this.currentIndex index ? #1677FF : #8A8F99) .fontWeight(this.currentIndex index ? FontWeight.Medium : FontWeight.Regular) } .layoutWeight(1) .height(this.tabBarHeight) .justifyContent(FlexAlign.Center) .onClick(() { if (this.currentIndex index) { return } this.animatedSwitch(index) this.tabsController.changeIndex(index) }) }, (title: string) title) } .width(100%) Polygon() .width(this.triangleWidth) .height(this.triangleHeight) .points([[0, 0], [12, 0], [6, 8]]) .fill(#1677FF) .position({ x: this.triangleX(), y: this.tabBarHeight }) } .width(100%) .height(this.tabBarHeight this.triangleHeight) .backgroundColor(#FFFFFF) .border({ width: { top: 0.5 }, color: #E5E6EB }) .onAreaChange((oldVal: Area, newVal: Area) { const w newVal.width as number if (w 0) { this.barWidth w } }) } private animatedSwitch(index: number) { animateTo({ duration: 250, curve: Curve.EaseOut }, () { this.currentIndex index }) } private triangleX(): number { const tabWidth this.barWidth / this.tabCount const center (this.currentIndex 0.5) * tabWidth return center - this.triangleWidth / 2 } }triangleX()的计算逻辑是先拿整个Bar的实时宽度除以Tab数量得到单个Tab的宽度再用“第几个Tab”加0.5计算中心点最后减去三角宽度的一半让三角正好居中。onAreaChange负责在布局真正完成时把宽度写进barWidth不然组件刚创建时拿不到正确值。position({ y: this.tabBarHeight })把三角放到文字Row的正下方三角尖朝下视觉上正好形成导航栏底部的小指示器。这里有一个需要注意的兼容细节position在部分API版本里配合animateTo可能不触发过渡动画如果遇到这种情况把.position({ x: ..., y: ... })改成.offset({ x: ... })同时把Stack的布局配合alignContent(Alignment.TopStart)微调一下效果基本等同。实测下来offset的动画兼容性更稳。3.3 两版代码的切换要点从简单版切到滑动版大多数人只改了一个动画却忽略了三件事一是barHeight一定要设成0否则系统Bar还占着一块空间二是底部Bar的点击必须同时触发animatedSwitch和changeIndex两者缺一不可三是onAreaChange一定要写在最终呈现的Stack上而不是写在Row上因为Row的宽度可能受子组件影响Stack才是最终的外框。我实际做项目时会给这个底部导航单独封装成一个CustomTabBar组件对外暴露currentIndex和onChange两个接口页面只负责传入索引和处理回调。这样三角逻辑只维护一次首页、消息页、我的页都能直接复用后续加角标也只在组件内部改。4. 踩坑实录常见问题与排查手册4.1 出现两个TabBar怎么办如果你在Tabs上既用了tabBar自定义Builder又在外部写了一个customBar()同时没有把系统Bar高度压掉屏幕上就会出现两条底栏。排查思路很简单先确定你走的是哪条路线。方案A保留.barHeight(56)方案B必须.barHeight(0)。如果方案B已经设置了0但系统Bar还隐约可见检查是不是有父组件的padding或者Stack重叠导致视觉残留一般用背景色盖掉即可。4.2 小三角总是对不齐这个问题绝大多数出在宽度计算上。手动写死“第2个Tab的中心点x122.5”本质上就是埋雷换分辨率必爆。正解是用onAreaChange拿实时宽度再按比例计算。还有一部分原因是三角容器和文字Tab没有对齐最简单的验证方法是给三角和文字临时加上backgroundColor肉眼确认中心线是否重合排查完再删掉调试色。如果用的是方案A的opacity显隐三角天然在Tab内部一般不会歪。如果歪了排查Polygon的points坐标准不准或者Text字体垂直居中带来的偏移。再一次强调尽量避免用Text(▼)世界就会清净很多。4.3 状态改了UI却不刷新最常见的低级错误是currentIndex没有加State装饰或者用了普通的类成员变量。ArkUI的响应式刷新只认装饰器普通成员变量改了不会触发UI更新。另一个坑是ForEach的key生成器写得不稳定比如直接拿数组下标当作key在数据删除、插入时会导致Builder缓存异常。底部导航这种固定数组场景要么不传第三个key参数要么传title字符串不要传动态id。还有一个隐蔽问题如果你把自绘Bar抽到了子组件里currentIndex通过Prop传入那么子组件内部必须用Prop接收不能直接当作普通入参使用。我见过好几次“父组件状态变了子组件UI死也不动”的提问百度半天不是布局问题就是装饰器问题。4.4 底部安全区遮挡三角真机上底部手势横条会占据一部分屏幕空间如果TabBar高度只有固定的56dp小三角很可能被横条区域遮住一截。线上项目里一般要预留安全区高度最简单的做法是把TabBar外层的Column高度改成56 安全区高度安全区高度可以通过系统接口获取。如果你用的是新版API也可以结合ArkUI的安全区扩展属性expandSafeArea让Bar延伸到安全区底部同时在里面做一层padding兜底。注意这里不要粗暴地把整个Bar压在安全区之外否则横条区域点击会失灵手势和Bar事件互相抢。正确姿势是“Bar背景延伸到安全区但可点击内容仍在安全区上方”。4.5 三角形被容器裁切三角形如果跑出父容器边界第一反应检查父组件是否有clip属性被设成true。有的容器默认开启裁剪Polygon超出边界的部分会被直接切掉。另一种情况是Stack内部的层级顺序不对后写的组件在上层前写的组件会被盖住。如果你发现三角明明画了却不显示把三角移到Stack的子组件列表最后再试试。5. 最后分享一点个人经验小三角这个需求看着不大但涉及的点其实很碎三角形怎么画、状态链路怎么串、宽度怎么算、安全区怎么留。我在第一个版本里偷懒用了Text(▼)当时模拟器和旧版真机都没问题后来拿到一台新设备做适配三角的位置直接偏了被设计师抓了个正着。换成Polygon之后我再也没因为三角的形状和位置返过工。如果你现在正准备在项目里做这个功能我的建议是先抄第3章的简单版代码把整条链路跑通确认页面切换、颜色变化、三角显隐都正常后再替换成滑动版。不要一上来就追求动画动画只是视觉结果状态链路才是骨架。骨架稳了后面加角标、加图标切换、加暗黑模式适配都只是在这个骨架上加肉而已。
返回列表