一、前言

在日常开发中,底部导航栏几乎是一个 App 的标配。鸿蒙 Tabs 组件自带的 tabBar 虽然开箱即用,但样式比较"系统味"——通栏、贴底、可定制空间有限。而现在主流 App 的设计越来越偏爱悬浮胶囊式导航栏:不满宽、带圆角、浮在页面上方、有轻微投影,视觉上更轻盈精致。

本文就带大家基于 Tabs + Stack,用纯 ArkTS 实现一个这样的效果,并且保留 Tabs 原生的左右滑动切换手势,点击与滑动状态完全同步。

二、实现思路

先明确目标:

  • 页面内容区可左右滑动切换(复用 Tabs 的手势能力)

  • 底部导航栏不满宽,悬浮居中,圆角胶囊样式,带阴影

  • 点击导航项 ⇄ 滑动页面,两个方向的状态要双向同步

实现方案非常经典的"三层两步":

  1. 分层:用 Stack 叠加布局——底层放 Tabs 承载页面内容,顶层放自定义的胶囊导航栏;
  2. 联动:用 @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,用户左右滑动页面时导航栏高亮就会"跟丢";反之则点击导航没反应。两个都要写。

四、关键点小结

  1. Stack 分层:alignContent: Alignment.Bottom 让 Tabs 和胶囊栏都贴底,胶囊自然悬浮在内容之上,不占用布局空间,页面内容可以"穿"过导航栏下方,沉浸感更好;
  2. clip(false):解除 Tabs 内容裁剪,避免边缘内容被裁掉;
  3. expandSafeArea:页面延伸到系统安全区(状态栏、导航条区域),实现全屏沉浸效果,#F5F5F5 的浅灰背景正是为了衬托白色胶囊;
  4. 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 声明式开发的核心链路。把这套"分层 + 双向联动"的思路吃透,以后做各种异形导航栏(凸起按钮、气泡、不规则形状)都是同一套骨架换个皮。

如果觉得有帮助,欢迎点赞收藏,评论区交流你的实现方案~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐