# HarmonyOS 自定义底部导航栏:悬浮胶囊式 TabBar 的实现
一、前言
在日常开发中,底部导航栏几乎是一个 App 的标配。鸿蒙 Tabs 组件自带的 tabBar 虽然开箱即用,但样式比较"系统味"——通栏、贴底、可定制空间有限。而现在主流 App 的设计越来越偏爱悬浮胶囊式导航栏:不满宽、带圆角、浮在页面上方、有轻微投影,视觉上更轻盈精致。
本文就带大家基于 Tabs + Stack,用纯 ArkTS 实现一个这样的效果,并且保留 Tabs 原生的左右滑动切换手势,点击与滑动状态完全同步。
二、实现思路
先明确目标:
-
页面内容区可左右滑动切换(复用
Tabs的手势能力) -
底部导航栏不满宽,悬浮居中,圆角胶囊样式,带阴影
-
点击导航项 ⇄ 滑动页面,两个方向的状态要双向同步
实现方案非常经典的"三层两步":
- 分层:用
Stack叠加布局——底层放Tabs承载页面内容,顶层放自定义的胶囊导航栏; - 联动:用
@State currentIndex记录当前选中项,TabsController.changeIndex()负责"点击 → 切页",Tabs.onChange回调负责"滑页 → 更新选中态"。
三、代码实现
3.1 状态与数据准备
@State currentIndex: number = 0;
// Tabs 控制器:用于通过代码主动控制页面切换
private controller: TabsController = new TabsController();
// 底部 Tab 的文字与图标数据源,配合 ForEach 循环渲染
private tabTitles: string[] = ['探索', '设置', '我的'];
private tabIcons: Resource[] = [//图片自己准备
$r('app.media.ic_gallery_discover'),
$r('app.media.ic_public_settings'),
$r('app.media.ic_user_portrait')
]
把文字和图标抽成数组,后续加减 Tab 只需要改数组,不用动布局代码——这是 ForEach 渲染带来的天然好处。
3.2 自定义胶囊导航栏
核心是用 @Builder 把整个导航栏封装成可复用的组件:
@Builder
CustomTabBuilder() {
Row() {
ForEach(this.tabTitles, (title: string, index: number) => {
Column() {
Image(this.tabIcons[index])
.width(22)
.height(22)
.objectFit(ImageFit.Contain)
.fillColor(this.currentIndex === index ? '#4CAF50' : '#666666')
Text(title)
.fontSize(12)
.fontColor(this.currentIndex === index ? '#4CAF50' : '#666666')
.fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 4 })
}
.layoutWeight(1) // 三个按钮平分 Row 宽度,各占 1/3
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentIndex = index; // 更新选中态
this.controller.changeIndex(index); // 驱动 Tabs 切页
})
})
}
.width('66%') // 关键1:不满宽,才有"悬浮胶囊"的感觉
.height(60)
.backgroundColor(Color.White)
.borderRadius(30) // 关键2:圆角 = 高度的一半,标准胶囊形态
.shadow({ radius: 10, color: 'rgba(0,0,0,0.1)', offsetY: 5 }) // 关键3:轻投影,制造悬浮感
.margin({ bottom: 20 })
}
几个值得注意的细节:
-
borderRadius(30)与height(60):圆角半径取高度的一半,就是完美的胶囊形; -
fillColor():给Image设置填充色,选中/未选中只改颜色而不用换图,图标资源必须是 SVG 才能生效; -
三态联动:颜色、字重都跟随
currentIndex动态切换,选中项绿色加粗,反馈非常清晰。
3.3 页面布局与双向同步
build() {
NavDestination() {
Stack({ alignContent: Alignment.Bottom }) { // 子元素统一贴底对齐
Tabs({ barPosition: BarPosition.End, controller: this.controller }) {
TabContent() { Text('探索') }.backgroundColor('#F5F5F5')
TabContent() { Text('设置') }.backgroundColor('#F5F5F5')
TabContent() { Text('我的') }.backgroundColor('#F5F5F5')
}
.onChange((index) => { this.currentIndex = index }) // 滑动 → 更新选中态
.clip(false)
this.CustomTabBuilder() // 胶囊栏叠加在 Tabs 上层
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
.title('系统配置文件')
.height('100%')
.width('100%')
}
这里的联动逻辑是双向的,缺一不可:
| 方向 | 负责的 API | 说明 |
|---|---|---|
| 点击 → 切页 | controller.changeIndex(index) | onClick 里主动驱动 Tabs 切换 |
| 滑动 → 选中态 | onChange((index) => ...) | 手势滑动切页后回调,同步高亮 |
只有 onClick 没有 onChange,用户左右滑动页面时导航栏高亮就会"跟丢";反之则点击导航没反应。两个都要写。
四、关键点小结
- Stack 分层:
alignContent: Alignment.Bottom让Tabs和胶囊栏都贴底,胶囊自然悬浮在内容之上,不占用布局空间,页面内容可以"穿"过导航栏下方,沉浸感更好; clip(false):解除Tabs内容裁剪,避免边缘内容被裁掉;expandSafeArea:页面延伸到系统安全区(状态栏、导航条区域),实现全屏沉浸效果,#F5F5F5的浅灰背景正是为了衬托白色胶囊;layoutWeight(1):三个Column均分宽度,无论胶囊栏整体是 66% 还是别的宽度,内部始终均匀分布。
五、可扩展的方向
这套结构是一个很好的"骨架",可以继续往下玩:
-
给切换加转场动画(
animateTo包裹currentIndex的修改); -
给选中项加小圆点 / 角标(消息未读数);
-
中间嵌一个凸起的大按钮(如 “+” 发布按钮),做成不等分胶囊;
-
抽成
@Component封装通用组件,通过参数传入页面路由表,结合Navigation做路由跳转。
六、完整代码
@Entry
@Component
struct test {
@State currentIndex: number = 0;
// 定义 Tabs 控制器,用于通过代码主动控制页面的切换
private controller: TabsController = new TabsController();
// 定义底部 Tab 的文字与图标数据源,方便使用 ForEach 循环渲染
private tabTitles: string[] = ['探索', '设置', '我的'];
private tabIcons: Resource[] = [
$r('app.media.ic_gallery_discover'),
$r('app.media.ic_public_settings'),
$r('app.media.ic_user_portrait')
]
@Builder
CustomTabBuilder() {
Row() {
ForEach(this.tabTitles, (title: string, index: number) => {
Column() {
Image(this.tabIcons[index])
.width(22)
.height(22)
.objectFit(ImageFit.Contain)
.fillColor(this.currentIndex === index ? '#4CAF50' : '#666666')
Text(title)
.fontSize(12)
.fontColor(this.currentIndex === index ? '#4CAF50' : '#666666')
.fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 4 })
}
.layoutWeight(1)
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentIndex = index;
this.controller.changeIndex(index);
})
})
}
.width('66%')
.height(60)
.backgroundColor(Color.White)
.borderRadius(30)
.shadow({ radius: 10, color: 'rgba(0,0,0,0.1)', offsetY: 5 })
.margin({ bottom: 20 })
}
build() {
NavDestination() {
Stack({ alignContent: Alignment.Bottom }) {
Tabs({ barPosition: BarPosition.End, controller: this.controller }) {
TabContent() { Text('探索') }.backgroundColor('#F5F5F5')
TabContent() { Text('设置') }.backgroundColor('#F5F5F5')
TabContent() { Text('我的') }.backgroundColor('#F5F5F5')
}
.onChange((index) => { this.currentIndex = index })
.clip(false)
this.CustomTabBuilder()
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
.title('系统配置文件')
.height('100%')
.width('100%')
.backgroundColor('#fff89b9b')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
}

七、结语
自定义 TabBar 看似是个小需求,实际串起了 Tabs、TabsController、Stack 分层、@Builder、@State 状态驱动这一整条 ArkUI 声明式开发的核心链路。把这套"分层 + 双向联动"的思路吃透,以后做各种异形导航栏(凸起按钮、气泡、不规则形状)都是同一套骨架换个皮。
如果觉得有帮助,欢迎点赞收藏,评论区交流你的实现方案~
更多推荐
所有评论(0)