HarmonyOS里做舵式底部导航,列多少坑可能都能写一篇了。我第一次动手时以为这只是视觉设计问题:把中间按钮做大、凸出,再配个渐变阴影就行。结果真机上一跑,点了没反应、页面内容被凸起区域挡住、切到二级页面再返回,底部栏状态全乱。
这篇文章把我从视觉设计到技术实现完整落地舵式导航的经验整理出来,包含设计语义、选型逻辑、核心布局、动效处理和真机排坑,适合正在用 ArkUI 做这类底部导航的开发者参考。
1. 舵式导航的设计语义:它和普通TabBar根本不是一回事
1.1 中间为什么要“凸”出来
舵式导航在视觉上最大的特征就是底部条带中间有一个明显的“舵”——凸起的圆形或胶囊按钮。很多人把它理解为“为了好看”,但设计逻辑其实很清晰:底部导航的常规Tab承载的是高频、低思考成本的路径切换,而中间那个凸起按钮承载的是“创造型操作”,比如发布内容、扫码、添加好友、开启直播。这两类操作在业务优先级上完全不同,视觉权重也必须拉开差距。
底部条带本身是低信息密度的环境,四个普通Tab用同类图标和文字铺开,视觉上是均衡的。中间插入一个高饱和渐变色大圆钮,加一圈阴影,立刻就形成了视觉重心。这就是舵式导航的核心价值:在同一根导航条上建立注意力分层。
我常用的视觉参数是这样一组:底条高度56,凸起按钮直径52到56,按钮圆心与底条中心对齐,按钮上沿比底条上沿高出约24;普通Tab图标24、文字12号;凸起按钮用品牌主色或渐变,阴影透明度0.3到0.4。这套参数不是拍脑袋,56是个很稳定的基础高度,凸出24到28在视觉上足够醒目又不至于显得突兀。
1.2 交互语义:凸起按钮不是第五个Tab
这里是最容易翻车的地方。凸起按钮在视觉上属于导航条,但交互语义完全独立:点击它通常唤起一个半屏面板或弹出层,而不是切换页面。它是一次“操作触发”,不是一次“路径切换”。
对比一下普通Tab的行为:点击首页、消息、我的,切换内容区,同时底部选中态跟着变。凸起按钮点击后,底部选中态不应该变,当前页面也不应该变,反而应该弹出一个发布选择面板:图文、视频、直播。这跟Material Design里FAB的定位有些类似,但舵式导航的特殊之处在于它嵌在Tab条里,视觉上既是条带的一部分,又突破条带。
我把这个差异拆成三条约束,设计阶段就要定死:
- 普通Tab:点击切换内容,选中态变化,角标可挂在图标上。
- 凸起按钮:点击触发主操作,不切换内容,不抢占选中态。
- 凸起按钮的触控区域要大于视觉区域,但“事件边界”不能覆盖到旁边Tab的点击区。
1.3 设计阶段就要防御的视觉误区
第一个误区是把凸起按钮直接做成一个超大的Tab项,内容区和底部条一起整体增高,结果导航条占据太多屏幕空间。正确的做法应该是:底条保持和其他导航一致的高度,凸起部分通过视觉溢出实现,而不是把容器整体垫高。
第二个误区是凸起样式做得太“轻”。我见过有的团队把中间按钮做得和普通Tab一样大小,只是稍微抬高一点,用户根本感知不到这是个主操作入口。凸起按钮至少要满足三个可感知特征:尺寸大于普通图标、色彩对比明显、有悬浮阴影。三个特征缺一个,舵式导航的视觉语义就塌了一半。
第三个误区是无障碍和状态表达只依赖颜色。普通Tab的选中态需要“颜色+图标形态”双重表达,凸起按钮要加无障碍文本说明,触控目标不小于48。这些在视觉评审阶段不提出,最后就得在代码阶段返工。
2. 技术选型背后的三个方案:Tabs、自绘TabBar与Navigation体系
2.1 方案A:Tabs组件改造,能跑但别指望它稳定
首先说很多教程里最常见的做法:用Tabs组件的自定义tabBar能力,在每个TabContent的tabBar里画自己的UI,中间那个凸起按钮也塞进去,通过barHeight把条带高度调高,让按钮凸出。
这个方案的优点是代码量极小,Tabs帮你处理了滑动切换、内容区联动和基本生命周期。但实际用下来有三个硬伤。
第一,Tabs的barHeight设置过大时,内容区会被压短,页面底部一块区域永远露不出来。第二,凸出的按钮一旦超出tabBar的绘制区域,不同版本渲染表现不一致,有的直接裁剪,有的虽然能显示但点击命中区域错位。第三,Tabs对tabBar区域的事件处理有自己的逻辑,中间按钮的点击很容易被判定成Tab切换,你需要额外做事件拦截。
我的建议是:如果你的需求只是“原型演示”或者“内部工具”,方案A够用;如果是正式对外发布的产品,别在这上面省事。
2.2 方案B:Row自绘TabBar加内容区切换
这个方案思路很直白:不用Tabs,自己画一个底部条带,用Row排列四个普通Tab,内容区域用一个if分支根据当前索引渲染对应页面。
代码写起来很直观,TabBar完全可控,凸起按钮想放哪里放哪里,事件也不会和Tabs打架。但它把很多本该由框架处理的问题抛回给了开发者。
比如二级页面怎么压栈?如果用自定义TabBar加if切页面,天然没有路由栈概念,想push到详情页面只能再嵌套一个Navigation,底部栏的显隐就得自己监听路由栈变化。页面状态怎么保存?if分支切走再切回来,页面内部状态默认就丢了,你得自己用状态管理工具或者缓存机制做保活。转场动画怎么做?Tab之间的切换动画完全要自己写。
方案B适合那种“导航数量固定、页面简单、没有二级页”的场景,比如设置中心、电商分类页。但做一套产品级舵式导航,它后劲不足。
2.3 方案C:Navigation容器加自绘TabBar,我推荐的做法
这是我在鸿蒙项目里最终采用的方案。整体结构是:根组件用Navigation承载,内容区根据currentTab渲染对应页面,底部TabBar用自定义组件绘制并和内容区放在同一个Stack里。二级页面通过NavPathStack压栈,压栈后根据路由栈深度控制TabBar显隐。
这套方案兼顾了可控性和扩展性。TabBar是自定义的,凸起布局完全没有框架限制;路由交给Navigation体系,生命周期、转场动画、参数传递都是标准玩法;TabBar显隐用路由栈状态驱动,返回根路径自动恢复。
2.4 选型对照
| 维度 | 方案A Tabs改造 | 方案B 自绘+if切换 | 方案C Navigation+自绘 |
|---|---|---|---|
| 凸起布局自由度 | 低 | 高 | 高 |
| 二级路由支持 | 较弱 | 需自建 | 原生支持 |
| 页面状态保存 | Tabs自带部分能力 | 需自建 | 路由栈天然支持 |
| 转场动画 | 简单 | 难 | 系统级支持 |
| 事件冲突风险 | 高 | 低 | 低 |
| 代码复杂度 | 低 | 中 | 中高 |
| 长期维护成本 | 中 | 高 | 低 |
结论很明确,新项目直接上方案C,不要在A和B之间反复横跳。
3. 核心实现:凸起布局、路由同步与发布面板
3.1 整体结构:Navigation容器与TabBar显隐
方案C的整体框架是这样组织的。根组件是一个Navigation,内部用Stack叠放内容区和TabBar,currentTab用状态管理驱动内容切换:
@Entry @Component struct MainPage { @State currentTab: number = 0 @State showTabBar: boolean = true @State publishPanelVisible: boolean = false @State publishRotate: number = 0 private navStack: NavPathStack = new NavPathStack() build() { Navigation(this.navStack) { Stack() { // 内容区:按currentTab渲染对应页面 Column() { if (this.currentTab === 0) { HomePage() } else if (this.currentTab === 1) { DiscoverPage() } else if (this.currentTab === 2) { MessagePage() } else { MinePage() } } .width('100%') .height('100%') // 底部导航条 if (this.showTabBar) { RudderTabBar({ currentTab: $currentTab, onPublish: () => this.openPublishPanel() }) .width('100%') } // 发布半屏面板 if (this.publishPanelVisible) { PublishPanel({ visible: $publishPanelVisible, onClose: () => this.closePublishPanel() }) } } .width('100%') .height('100%') } .mode(NavigationMode.Stack) } }页面内容用if分支渲染,好处是TabBar切换时能自动触发页面生命周期,配合系统转场可以做出页面过渡效果。这里要留意的是:showTabBar的状态切换不能用if包住整个Stack,否则TabBar和内容区会一起被销毁。
路由栈监听是关键一环。二级页面压栈后需要隐藏TabBar,返回根路径时再恢复。我是在Navigation的pathChange回调里做的:
aboutToAppear(): void { this.navStack.pathChange((info: NavPathInfo) => { this.showTabBar = info.size <= 1 }) }这段逻辑一定要在页面出现前绑定好,否则首次压栈时回调没注册,TabBar不会隐藏。pathChange回调在API版本间的名称略有差异,但核心思路是监听路由栈深度变化。
3.2 TabBar布局:用Stack实现凸起的正确姿势
这是整个实现里最容易被想复杂的地方。凸起按钮的本质是:视觉上超出底条上边界,但交互上仍然锚定在底条范围内。
我直接给出最终用的布局方案。最外层是一个高度84的Stack,底部对齐;底条Row高度56贴底;凸起按钮层高度84,铺满整个Stack宽度,但把事件穿透设置好,只让按钮本身响应点击:
@Component export struct RudderTabBar { @Link currentTab: number onPublish: () => void = () => {} private tabModels: Array<TabModel> = [ { title: '首页', icon: $r('app.media.ic_home_normal'), selectedIcon: $r('app.media.ic_home_selected') }, { title: '发现', icon: $r('app.media.ic_discover_normal'), selectedIcon: $r('app.media.ic_discover_selected') }, { title: '消息', icon: $r('app.media.ic_message_normal'), selectedIcon: $r('app.media.ic_message_selected') }, { title: '我的', icon: $r('app.media.ic_mine_normal'), selectedIcon: $r('app.media.ic_mine_selected') } ] build() { Stack({ alignContent: Alignment.Bottom }) { // 底条:四个普通Tab Row() { ForEach(this.tabModels, (item: TabModel, index: number) => { Column({ space: 4 }) { Image(this.currentTab === index ? item.selectedIcon : item.icon) .width(24) .height(24) Text(item.title) .fontSize(12) .fontColor(this.currentTab === index ? '#E8403F' : '#666666') } .width('25%') .height(56) .justifyContent(FlexAlign.Center) .onClick(() => { this.currentTab = index }) }, (item: TabModel) => item.title) } .width('100%') .height(56) .backgroundColor('#FFFFFF') // 凸起主按钮层:铺满宽度,但事件全部透传 Column() { Stack() { Image($r('app.media.ic_publish')) .width(26) .height(26) } .width(56) .height(56) .borderRadius(28) .linearGradient({ direction: GradientDirection.Bottom, colors: [['#FF6B4A', 0.0], ['#E8403F', 1.0]] }) .shadow({ radius: 12, color: 'rgba(232, 64, 63, 0.35)', offsetY: 6 }) } .width('100%') .height(84) .justifyContent(FlexAlign.End) .alignItems(HorizontalAlign.Center) .padding({ bottom: 4 }) .hitTestBehavior(HitTestMode.Transparent) .onClick(() => this.onPublish()) } .width('100%') .height(84) } }这里有几个细节需要重点解释。
为什么外层高度是84?56是底条高度,凸出部分28,总高84。为什么凸起按钮Column要铺满宽度?因为你要利用Stack的水平居中能力,否则手算居中偏移量会很难看。
为什么Column要设hitTestBehavior为Transparent?这个Column实际覆盖了整个底部区域,如果它参与事件响应,内容区最底部一段的点击都会被它吃掉。Transparent表示“Column自身不拦截事件,但子组件正常响应”,这样只有中间的56直径按钮能点击,两侧的区域事件直接穿透到内容区。
为什么用justifyContent End加padding bottom?为了把按钮底部固定在距底4的位置,让按钮圆心和底条中心对齐。56的底条中心在28,按钮半径28,padding bottom正好4,圆心位置就是28,和底条视觉中心完全重合。这组数字不是凑的,是算出来的。
3.3 发布面板:遮罩加底部卡片
凸起按钮点击后唤起发布面板,我用的是自定义遮罩加底部卡片,不用系统半模态,因为自定义方案的动效和内容可扩展性更强:
@Component export struct PublishPanel { @Link visible: boolean onClose: () => void = () => {} build() { Stack({ alignContent: Alignment.Bottom }) { // 遮罩层 Column() .width('100%') .height('100%') .backgroundColor('rgba(0, 0, 0, 0.5)') .onClick(() => this.onClose()) .opacity(this.visible ? 1 : 0) // 面板层 Column({ space: 16 }) { PublishAction('图文', '发布图文动态') PublishAction('视频', '上传视频作品') PublishAction('直播', '开启一场直播') } .width('100%') .padding({ top: 24, bottom: 24 }) .backgroundColor(Color.White) .borderRadius({ topLeft: 24, topRight: 24 }) .translate({ y: this.visible ? 0 : 300 }) } .width('100%') .height('100%') } }面板的弹出收起靠translate加opacity控制,配合animateTo就能实现平滑过渡。遮罩层的点击一定要绑在遮罩Column上,不能绑在外层Stack上,否则点面板内部也会触发关闭。
4. 动效落地的三层细节:点按、转场与形变
4.1 点按反馈:让凸起按钮有“手感”
舵式导航的凸起按钮是主操作入口,点按反馈不能是系统默认那种生硬的闪一下。我在按钮上做了scale加震动的组合反馈:
@State buttonScale: number = 1 Button() { Image($r('app.media.ic_publish')) .width(26) .height(26) } .width(56) .height(56) .scale({ x: this.buttonScale, y: this.buttonScale }) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down) { animateTo({ duration: 80 }, () => { this.buttonScale = 0.9 }) } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { animateTo({ duration: 120, curve: Curve.Friction }, () => { this.buttonScale = 1 }) } })按压时缩到0.9,松手时弹回原尺寸。不要用线性曲线,回弹要用Friction或Spring类曲线,视觉上才有Q弹感。
震动反馈我建议只在主操作按钮上加,普通Tab不要加。用系统震动接口,轻触档位就行,重了很打扰。
4.2 Tab切换的选中态过渡
普通Tab的选中态不能只做“变了颜色”这一件事。合理的过渡是:图标渐变替换加文字颜色渐变,再加一条短的底部指示条位移动画。
Column({ space: 4 }) { Image(this.currentTab === index ? item.selectedIcon : item.icon) .width(24) .height(24) .opacity(this.currentTab === index ? 1 : 0.6) ... }指示条的位置用animateTo驱动位移。把指示条做成一个绝对定位的圆形或胶囊,根据currentTab算出它的横向偏移量,动画时长180到220毫秒,曲线用EaseOut。这样Tab切换时底部会有“滑动”感,配合内容区切换动画,整体节奏一致。
4.3 中央按钮形变:旋转到叉号
很多产品在发布面板打开后,中间的“+”会变成“×”。这个形变动效是最容易做出高级感的。
// 打开发布面板 openPublishPanel() { animateTo({ duration: 260, curve: Curve.Friction }, () => { this.publishRotate = 45 this.publishPanelVisible = true }) } // 关闭发布面板 closePublishPanel() { animateTo({ duration: 200, curve: Curve.EaseOut }, () => { this.publishRotate = 0 this.publishPanelVisible = false }) }按钮上绑定旋转角:
.rotate({ angle: this.publishRotate })图标本身是“+”号,旋转45度后视觉上就变成“×”。这个方案不需要准备两张图标,一张资源搞定,动画也更顺滑。
打开面板和旋转动画必须放在同一个animateTo块里,保证时间轴一致。否则会出现面板已经弹出来但按钮还没转完的割裂感。
4.4 内容区转场:不复杂但必须做
Tab切换时内容区的转场不该是“闪一下”。我用的方案是在渲染内容区时给根Column加一个轻微的透明度加位移过渡:
Column() { if (this.currentTab === 0) { HomePage() } else if (this.currentTab === 1) { DiscoverPage() } } .transition(TransitionEffect.move(TransitionEdge.TOP).animation({ duration: 220, curve: Curve.EaseOut }))切换Tab时,页面从上方轻微移入,同时透明度变化。时长不要超过250毫秒,否则会影响操作流畅感。
5. 我在真机上踩过的坑
5.1 Tabs的barHeight裁切问题
最早我用方案A做原型,凸起按钮延伸到了底条上方,在Previewer里看着正常,真机上凸出部分直接消失。查了下原因是Tabs的tabBar区域对barHeight之外的内容做了裁切。
这个问题的根源在于Tabs的bar区域是一个独立的绘制区域,超出部分不可控。你没法用常规的overflow手段让子组件溢出。所以方案A只适合凸起幅度很小或者干脆不做凸起的场景。想完整做舵式导航,就绕开Tabs。
5.2 事件穿透:中间按钮点不动的问题
第一次自绘TabBar时,我把凸起按钮层做成了一个高84的Column包住按钮,于是整个底部区域都被这个Column覆盖。结果就是列表内容的最下面一段滑到TabBar底下时,点击全部被吞掉。
解决方案就是我前面写的:凸起按钮所在的外层Column必须设hitTestBehavior(HitTestMode.Transparent),让自身不参与事件拦截,只让按钮命中。这个属性藏得比较深,踩坑时容易反复怀疑人生。
5.3 内容区被凸起遮挡
凸起按钮上沿比底条高出28,这28像素会盖住内容区底部。如果列表滚动到最底部,最后一项的按钮就可能被凸起区域挡住点不到。
处理办法是在内容区的底部留出安全间距。我的经验是给列表组件加padding bottom,数值等于底条高度加凸起高度,也就是84加一点余量到96。这样最底部的元素也能平滑滚动到凸起按钮的上方,而不会被永久遮挡。
5.4 底部安全区与手势条
鸿蒙机型很多底部有系统手势条,如果TabBar直接贴底,HomeBar会压住TabBar的底部甚至重叠。我最早没处理的时候,消息Tab的“我的”按钮在全面屏机型上明显偏下,手感怪异。
正确做法是给TabBar容器加上底部安全区适配。最省事的方案是用系统能力自动避让,靠手动适配的话,用onAreaChange拿到底部避让区域,把底条高度加上避让高度即可。注意避让高度只加到底条高度上,凸起按钮的圆心位置要重新计算,不能整个导航抬高后按钮中心错位。
5.5 深色模式下的颜色表现
渐变按钮用写死的色值,在深色模式下会显得突兀甚至刺眼。我的做法是颜色全部走资源文件,深浅色各准备一份。渐变色的两个端点值要在深色模式下降低饱和度,阴影透明度也要同步降一档,否则暗色背景下阴影会显得很脏。
5.6 路由返回后TabBar状态错乱
这个坑非常典型:从首页push到详情页,再返回时,TabBar有时会闪烁一下或者选中态跳回首页。原因是TabBar显隐用if控制,销毁重建时@Link状态重新初始化,currentTab丢失了。
解决办法是TabBar显隐不要直接销毁实例,用透明度加尺寸变化隐藏,或者把currentTab提升到根组件的@StorageLink级别,保证重建时还能从全局存储恢复。这两个方案我都试过,后者更稳,代码也更简单。
5.7 多余重渲染
早期实现里,每个Tab页面和TabBar都共享同一个大对象作为状态源,导致一次Tab切换会触发整个页面树的重建,表现就是切换时明显卡顿。
优化思路是控制状态粒度:currentTab用基本类型,页面内部状态各自维护,TabBar的图标选中判断用每个item的局部计算。这样切Tab时只有内容区更新,底部导航条不必重新渲染,长列表页的滚动位置也保住了。
6. 收个尾
舵式导航在HarmonyOS开发里确实比普通TabBar要费更多心思,核心难点不在“画一个凸起按钮”,而在布局和事件系统之间的微妙关系。我自己走完一遍下来,最值得记住的三件事是:凸起按钮的逻辑边界要独立于Tab切换;事件穿透要用hitTestBehavior管住;路由栈和TabBar显隐要提前设计联动。
如果你正打算在项目里做类似导航,建议先按文章里的布局参数搭一个最小可运行的原型,真机上验证点击和遮挡,再开始做动效。这一套走通之后,后续加角标、加长按菜单、加发布草稿箱入口都很顺。