关注

自定义 TabBar 实战:浮动标签栏与舵式标签栏

一、为什么需要自定义 TabBar?

系统默认的 Tabs 组件能快速搭建标准底部导航,但遇到以下场景时无能为力:

场景系统 Tabs 是否支持
标准底部导航(图标+文字)支持,直接用 BarPosition.End
带阴影和圆角的浮动标签栏不支持,需完全自定义
突出中间项的舵式标签栏不支持,需完全自定义
选中指示条动画有限,需配合自定义实现

二、公共数据类型

两种标签栏都依赖项目的 TabItemTabBarTheme 接口:

// entry/src/main/ets/types/TabBarTypes.ets

export interface TabItem {
  id: string;           // 唯一标识
  icon: Resource;       // 未选中图标
  activeIcon: Resource; // 选中图标
  title: string;        // 标签文字
  badge?: number;       // 角标数量(可选)
  showDot?: boolean;    // 是否显示红点(可选)
}

export interface TabBarTheme {
  backgroundColor: ResourceColor;
  activeColor: ResourceColor;
  inactiveColor: ResourceColor;
  height: number;
  iconSize: number;
  fontSize: number;
}

代码说明:把数据结构和主题配置拆成独立接口,修改外观只需换一个主题对象,UI 代码不需要改动。

三、浮动标签栏

3.1 视觉效果图

3.2 FloatingTabBar 核心实现

 

@Component
struct FloatingTabBar {
  @Link currentIndex: number;  // 双向绑定,子组件可直接修改父组件状态
  @Prop theme: TabBarTheme;
  @Prop tabs: TabItem[];
  onTabChange?: (index: number) => void;

  build() {
    Row() {
      ForEach(this.tabs, (tab: TabItem, index: number) => {
        Column() {
          SymbolGlyph(this.currentIndex === index ? tab.activeIcon : tab.icon)
            .fontSize(24)
            // SymbolGlyph.fontColor 接受 ResourceColor[] 数组,必须用方括号包裹
            .fontColor(
              this.currentIndex === index
                ? [this.theme.activeColor]
                : [this.theme.inactiveColor]
            )

          Text(tab.title)
            .fontSize(this.theme.fontSize)
            // Text.fontColor 直接传 ResourceColor,无需数组
            .fontColor(
              this.currentIndex === index
                ? this.theme.activeColor
                : this.theme.inactiveColor
            )
            .margin({ top: 2 })
            .fontWeight(
              this.currentIndex === index ? FontWeight.Medium : FontWeight.Normal
            )

          // 选中指示条:使用 if/else + transition 实现淡入动画
          if (this.currentIndex === index) {
            Row()
              .width(20).height(3)
              .backgroundColor(this.theme.activeColor)
              .borderRadius(1.5)
              .margin({ top: 4 })
              .transition({ type: TransitionType.Insert, opacity: 0 })
              .animation({ duration: 200, curve: Curve.EaseInOut })
          } else {
            Row().width(20).height(3).margin({ top: 4 })
          }
        }
        .width(64)
        .height(this.theme.height)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.onTabChange?.(index); })
      })
    }
    .height(this.theme.height + 16)
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .margin({ left: 24, right: 24, bottom: 20 }) // 不贴边,四周留白
    .backgroundColor(this.theme.backgroundColor)
    .borderRadius(28)                             // 大圆角
    .justifyContent(FlexAlign.SpaceEvenly)
    .shadow({
      radius: 20,
      color: 'rgba(0, 0, 0, 0.08)',
      offsetX: 0,
      offsetY: 4                                  // 向下偏移,模拟悬浮投影
    })
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  }
}

代码说明:

  • @Link 双向绑定,父组件传递 $currentIndex,子组件内部可直接读写,两侧数据同步;
  • SymbolGlyph.fontColor() 参数类型是 ResourceColor[],一定要写成数组形式;而 Text.fontColor() 参数是 ResourceColor,不需要数组;
  • 指示条用 if/else 条件渲染配合 transition,这样在元素插入时可以触发淡入动画;
  • .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) 让标签栏延伸到系统导航条区域,避免被遮挡。

3.3 父页面:Stack 叠层组合

@Entry
@Component
struct FloatingTabsDemo {
  @State currentIndex: number = 0;
  @State theme: TabBarTheme = FloatingTheme;
  @State tabs: TabItem[] = [];

  build() {
    Stack({ alignContent: Alignment.Bottom }) {  // Stack 让标签栏叠在内容上方
      Column() {
        FloatingContentPage({ ... })
      }
      .width('100%').height('100%')

      FloatingTabBar({
        currentIndex: $currentIndex,   // $ 传递双向绑定引用
        theme: this.theme,
        tabs: this.tabs,
        onTabChange: (index: number) => {
          animateTo({ duration: 250, curve: Curve.EaseInOut }, () => {
            this.currentIndex = index;
          });
        }
      })
    }
    .width('100%').height('100%')
  }
}

代码说明:Stack 布局是浮动标签栏的关键,它让标签栏悬浮在内容上方,而不是和内容并排布局。animateTo 包裹状态变更,为内容区切换添加过渡动画。


四、舵式标签栏

4.1 视觉结构

标签数量必须是奇数(3、5、7),保证中间项落在正中央。

4.2 SteeredTabBar 核心实现

// entry/src/main/ets/pages/demos/SteeredTabsDemo.ets

@Component
struct SteeredTabBar {
  @Link currentIndex: number;
  @State tabs: TabItem[] = [];

  // 动态计算中间索引,支持任意奇数个标签
  private isCenterItem(index: number): boolean {
    return index === Math.floor(this.tabs.length / 2);
  }

  build() {
    Row() {
      ForEach(this.tabs, (tab: TabItem, index: number) => {
        if (this.isCenterItem(index)) {
          // 中间突出项:圆形背景
          Column() {
            SymbolGlyph(this.currentIndex === index ? tab.activeIcon : tab.icon)
              .fontSize(28)
              .fontColor(['#FFFFFF'])
          }
          .width(56).height(56)
          .backgroundColor('#007AFF')
          .borderRadius(28)  // 宽高一半 = 正圆
          .justifyContent(FlexAlign.Center)
          .shadow({
            radius: 8,
            color: 'rgba(0, 122, 255, 0.3)', // 阴影颜色与背景色呼应
            offsetX: 0,
            offsetY: 4
          })
          .onClick(() => { this.currentIndex = index; })

        } else {
          // 普通项
          Column() {
            SymbolGlyph(this.currentIndex === index ? tab.activeIcon : tab.icon)
              .fontSize(24)
              .fontColor(this.currentIndex === index ? ['#007AFF'] : ['#8E8E93'])

            Text(tab.title)
              .fontSize(11)
              .fontColor(this.currentIndex === index ? '#007AFF' : '#8E8E93')
              .margin({ top: 2 })
          }
          .width(60).height(50)
          .justifyContent(FlexAlign.Center)
          .onClick(() => { this.currentIndex = index; })
        }
      })
    }
    .width('100%').height(70)
    .padding({ bottom: 8 })
    .justifyContent(FlexAlign.SpaceEvenly)
    .alignItems(VerticalAlign.Bottom)  // 底部对齐,中间圆形按钮自然向上凸出
  }
}

代码说明:

  • isCenterItem()Math.floor(length / 2) 动态计算中间索引,标签数量变化无需改代码;
  • 圆形通过 borderRadius 等于宽高的一半来实现,width(56) + borderRadius(28) 即正圆;
  • alignItems(VerticalAlign.Bottom) 让所有项底部对齐,中间项更高,视觉上自然向上凸起;
  • 中间项的阴影颜色用带透明度的蓝色,与背景色呼应,比黑色阴影更精致。

4.3 与 Swiper 联动实现手势滑动

本项目的舵式标签栏使用 Swiper 作为内容区,支持手势滑动:

Swiper() {
  ForEach(this.contents, (item: string[], index: number) => {
    ContentPage({ ... })
  })
}
.index(this.currentIndex)  // 与 currentIndex 绑定
.loop(false)
.indicator(false)           // 隐藏默认指示点,标签栏已起到指示作用
.duration(300)
.onChange((index: number) => {
  this.currentIndex = index;  // 手势滑动时同步更新标签选中状态
})

代码说明:SwiperSteeredTabBar 共享同一个 currentIndex,互相监听 onChange,任一方的变化都会驱动另一方更新,无需手动同步。


五、尺寸规范参考

场景属性推荐值
浮动左右外边距20-24vp
浮动圆角半径24-28vp
浮动阴影模糊半径16-24vp
舵式中间项直径52-60vp
舵式标签数量3、5 或 7(奇数)

总结

浮动标签栏和舵式标签栏看起来效果很炫,但核心原理其实很简单:前者靠 Stack 叠层、圆角和 shadow 实现悬浮感,后者靠 borderRadius 等于宽高一半画出正圆突出中间项。两种样式背后的逻辑都是:把 barHeight 设为 0 隐藏系统默认标签栏,然后自己用组件拼出想要的 UI。希望这两个完整的例子能让你在拿到设计稿时,少走一些弯路,直接动手就能写出来。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_33681891/article/details/160383030

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--