1、前言
🎉 「古诗学习宝」已上架华为应用市场! 零广告 / 零内购 / 277 首小学必背古诗全收录,专为小学生打造的鸿蒙原生古诗学习工具。
如果觉得好用,烦请在应用市场帮忙点个五星好评 🌟,您的支持是我持续更新的最大动力!
打开小红书、闲鱼、得物,会发现它们底部 Tab 栏都有一个共同特征------中央按钮"凸起"、两侧 Tab 平铺、背景在凸起按钮下方有一道半圆凹槽 。这种设计让中央那个最重要的入口(发布 / 学习 / 购物车)视觉权重提升 200%,成为整个 App 的"主操作焦点"。
不少开发者第一反应是装个三方库 ohpm install xxx-tabbar 完事。但用三方库有 4 个隐藏成本:① 维护风险 :作者一个人维护,停更就崩 ② 样式难改 :动一行得改源码 ③ 审核风险 :上架时审核员可能质疑三方代码安全 ④ 文章复现成本高:读者拷你代码前要先装一堆依赖。
本文用 HarmonyOS 6 ArkUI 原生 API ------Shape + Path 绘制凹槽 + Stack 绝对定位中央按钮 + 自定义 Row 平铺 4 个普通 Tab------零三方依赖,180 行代码搞定一个完全可定制的凹槽凸起底部导航栏。本方案在「古诗学习宝」上架版本里跑了一个月,性能 60 fps 稳定,可直接复制到任何鸿蒙项目。
2、整体架构
2.1 视觉拆解
┌────────────────────────────────────────────────────┐
│ │
│ ┌────────────┐ │
│ │ ▣ 凸起按钮 │ ← 中央"学习"凸起圆形 │
│ │ (Stack │ 白底 / 高亮深绿底 │
│ │ 绝对定位) │ 图标 SVG / 阴影 │
│ └────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ ╭───凹槽───╮ │ │
│ │ │ Path 路径绘制凹槽弧线 │ │
│ │ │ ───────────────────────────────────── │ │
│ │ │ 🏠首页 ▦ 分类 [empty] 📋记录 👤我的 │ │
│ │ └─────────────────────────────────────────┘ │
│ └─────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
技术分层:
Layer 1 (背景) --- Shape + Path:白底 + 凹槽切割
Layer 2 (Tab Row) --- Row 平铺 4 个 IconButton(中央留空 64vp)
Layer 3 (凸起按钮) --- Stack 绝对定位,y 轴上移 24vp 让一半露出凹槽
2.2 ArkUI 关键 API
| 能力 | API | 作用 |
|---|---|---|
| 路径绘制 | Shape() + Path().commands(svgPath) |
画凹槽弧线 |
| 视口变换 | Path().viewPort({ width, height }) |
SVG 坐标系到屏幕 |
| 圆弧命令 | A rx ry x-axis-rotation large-arc sweep x y |
半圆凹陷 |
| 绝对定位 | Stack({ alignContent: Alignment.Bottom }) |
凸起按钮叠加 |
| 图标 | Image($r('app.media.xxx')) + .fillColor() |
SVG icon 着色 |
| 阴影 | .shadow({ radius, offsetY, color }) |
凸起感 |
2.3 项目目录
entry/src/main/ets/
├── components/
│ └── GrooveTabBar.ets # ★ 本文核心:原生凹槽 TabBar
├── common/
│ └── Constants.ets # TabKey 常量
└── pages/
└── MainTabsPage.ets # 5 Tab 容器(演示用法)
resources/base/media/
├── ic_tab_home.svg / ic_tab_home_active.svg
├── ic_tab_category.svg / ic_tab_category_active.svg
├── ic_tab_record.svg / ic_tab_record_active.svg
├── ic_tab_profile.svg / ic_tab_profile_active.svg
└── ic_tab_study.svg # 中央凸起按钮图标
3、效果展示
3.1 首页 Tab 选中态

打开应用,底部最显眼的就是白色凹槽底栏 + 中央凸起的"学习"按钮:
- 左侧 2 个 Tab:🏠 首页(绿色 active)/ ▦ 分类
- 中央:圆形凸起按钮(带书本 SVG 图标 + 微阴影 + 半埋在凹槽里)
- 右侧 2 个 Tab:📋 记录 / 👤 我的
凹槽的半圆弧线让底栏在中央"凹下去",配合凸起按钮,形成"挖了一个洞、按钮坐在洞里"的拟物视觉。
3.2 切到分类 Tab

点「分类」Tab,图标变成绿色实心版本(active SVG),文字加粗变绿。中央凸起按钮保持白底 (未选中状态)。这个状态切换由 @Param selectedKey 单向数据流驱动。
3.3 学习 Tab 选中态(凸起按钮高亮)

点中央凸起按钮,按钮背景由白色 → 深绿色 #436444 ,下方"学习"文字变绿加粗。这是 5 状态联动里最特殊的一个------其他 4 个 Tab 共享同一种"高亮"样式,只有中央按钮有独立的"凸起高亮"逻辑。
3.4 记录 Tab 与我的 Tab

第 4 / 第 5 个 Tab 切换效果对称,整套 TabBar 在 5 种 selectedKey 下都能正确高亮,且页面同步切换。
4、核心功能详解
4.1 第一步:用 Path commands 画凹槽底栏
凹槽的本质是在白色矩形顶边中央挖一个半圆缺口。SVG Path 命令实现:
typescript
import { TabKey } from '../common/Constants';
/** TabBar 总宽度(基于 750 设计稿,最终会按屏宽自动缩放) */
const VP_WIDTH = 750;
/** TabBar 高度(实际渲染高度) */
const BAR_HEIGHT = 64;
/** 凹槽顶部留白(凸起按钮下方留出的视觉空间) */
const NOTCH_TOP_INSET = 12;
/** 凹槽半径 */
const NOTCH_RADIUS = 40;
/**
* 构造凹槽底栏的 SVG Path
*
* 起点(0, NOTCH_TOP_INSET) 沿顶边向右
* → 到凹槽左侧 (CX - R, NOTCH_TOP_INSET)
* → 半圆向下凹陷 ─ A 命令逆时针绘制
* → 到凹槽右侧 (CX + R, NOTCH_TOP_INSET)
* → 沿顶边向右到 (VP_WIDTH, NOTCH_TOP_INSET)
* → 矩形闭合
*/
function buildGroovePath(): string {
const cx = VP_WIDTH / 2;
const leftX = cx - NOTCH_RADIUS;
const rightX = cx + NOTCH_RADIUS;
const top = NOTCH_TOP_INSET;
// viewport 高度 = NOTCH_TOP_INSET + BAR_HEIGHT
const bottom = NOTCH_TOP_INSET + BAR_HEIGHT;
// sweep-flag = 0:逆时针,圆弧朝下凹陷
return `M 0 ${top}
L ${leftX} ${top}
A ${NOTCH_RADIUS} ${NOTCH_RADIUS} 0 0 0 ${rightX} ${top}
L ${VP_WIDTH} ${top}
L ${VP_WIDTH} ${bottom}
L 0 ${bottom}
Z`;
}
坑点 1 :Path 的 A(圆弧)命令有 7 个参数
rx ry x-axis-rotation large-arc-flag sweep-flag x y,sweep-flag = 0 表示逆时针 (弧线朝下凹陷),sweep-flag = 1 表示顺时针(弧线朝上凸起)。写反就会变成"凸起山包"不是"凹陷半圆"。
4.2 第二步:用 Shape 渲染凹槽底栏
typescript
@Builder
GrooveShape() {
Shape() {
Path()
.commands(buildGroovePath())
.fill('#FFFFFF') // 白色填充
.stroke('#0A000000') // 几乎透明的描边,让边缘更平滑
.strokeWidth(1)
}
.viewPort({
x: 0, y: 0,
width: VP_WIDTH,
height: NOTCH_TOP_INSET + BAR_HEIGHT,
})
.width('100%')
.height(NOTCH_TOP_INSET + BAR_HEIGHT) // 实际高度 = 12 + 64 = 76vp
}
坑点 2 :
Shape.viewPort是 SVG 视口(逻辑坐标系),Shape.width / height是实际渲染尺寸。两者比例不一致时,Path 会按比例拉伸------这正是我们想要的"按屏宽自动缩放"。
4.3 第三步:Row 平铺 4 个普通 Tab + 中央留空
凹槽中央要留出空间给凸起按钮,所以 Row 里中间留个 64vp 占位:
typescript
interface TabDef {
key: string;
label: string;
icon: Resource;
iconActive: Resource;
}
const SIDE_TABS: TabDef[] = [
{ key: TabKey.Home, label: '首页', icon: $r('app.media.ic_tab_home'), iconActive: $r('app.media.ic_tab_home_active') },
{ key: TabKey.Category, label: '分类', icon: $r('app.media.ic_tab_category'), iconActive: $r('app.media.ic_tab_category_active') },
// 中央 Plan 不在这里,单独画
{ key: TabKey.Record, label: '记录', icon: $r('app.media.ic_tab_record'), iconActive: $r('app.media.ic_tab_record_active') },
{ key: TabKey.Profile, label: '我的', icon: $r('app.media.ic_tab_profile'), iconActive: $r('app.media.ic_tab_profile_active') },
];
@Builder
TabRow() {
Row() {
// 左 2 个 Tab
this.SideTab(SIDE_TABS[0])
this.SideTab(SIDE_TABS[1])
// 中央留空 - 64vp 占位
Blank().width(64)
// 右 2 个 Tab
this.SideTab(SIDE_TABS[2])
this.SideTab(SIDE_TABS[3])
}
.width('100%')
.height(BAR_HEIGHT)
.padding({ left: 8, right: 8 })
.justifyContent(FlexAlign.SpaceAround)
}
@Builder
SideTab(t: TabDef) {
Column({ space: 2 }) {
Image(this.selectedKey === t.key ? t.iconActive : t.icon)
.width(22).height(22)
Text(t.label)
.fontSize(11)
.fontWeight(this.selectedKey === t.key ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.selectedKey === t.key ? '#436444' : '#3F4A52')
}
.layoutWeight(1)
.height(BAR_HEIGHT)
.justifyContent(FlexAlign.Center)
.onClick(() => this.onChange(t.key))
}
坑点 3 :每个 Tab 用
.layoutWeight(1)平分剩余空间,配合Blank().width(64)占位,4 个 Tab 各占 25% --- 16vp = 23vp 左右,视觉对称。
4.4 第四步:中央凸起按钮 Stack 绝对定位
typescript
@Builder
CenterButton() {
Stack({ alignContent: Alignment.Center }) {
// 凸起按钮圆形背景 - 白底 / 深绿底切换
Column()
.width(60).height(60)
.borderRadius(30)
.backgroundColor(this.selectedKey === TabKey.Plan
? '#436444' // 选中深绿
: '#FFFFFF') // 默认白色
.border({ width: 1, color: '#0A000000' })
.shadow({
radius: 12,
color: '#1A000000',
offsetX: 0,
offsetY: 2,
})
.animation({ duration: 200, curve: Curve.FastOutSlowIn })
// SVG 图标
Image($r('app.media.ic_tab_study'))
.width(28).height(28)
.fillColor(this.selectedKey === TabKey.Plan ? Color.White : '#436444')
.animation({ duration: 200, curve: Curve.EaseOut })
}
.width(60)
.height(60)
.onClick(() => this.onChange(TabKey.Plan))
}
坑点 4 :
Image.fillColor只对SVG 矢量图标有效,PNG 不行。所以中央按钮图标必须是 SVG 格式,这样才能根据选中态切换填充色。
4.5 第五步:三层 Stack 组合 - 整体 GrooveTabBar 组件
typescript
@ComponentV2
export struct GrooveTabBar {
@Param selectedKey: string = TabKey.Home;
@Event onChange: (key: string) => void = () => {};
build() {
Stack({ alignContent: Alignment.Bottom }) {
// Layer 1:凹槽白色底栏(含 SVG Path 凹槽)
this.GrooveShape()
// Layer 2:4 个普通 Tab 平铺(中央 64vp 留空)
Column() {
Blank().height(NOTCH_TOP_INSET) // 顶部 12vp 占位对齐凹槽起点
this.TabRow()
}
.width('100%')
// Layer 3:中央凸起按钮(绝对定位,上移让一半露出凹槽)
Stack() {
this.CenterButton()
// 凸起按钮下方文字
Text('学习')
.fontSize(11)
.fontColor(this.selectedKey === TabKey.Plan ? '#436444' : '#3F4A52')
.fontWeight(this.selectedKey === TabKey.Plan ? FontWeight.Medium : FontWeight.Normal)
.margin({ top: 66 }) // 按钮 60vp + 间距 6vp
}
.alignContent(Alignment.TopStart)
.margin({ bottom: 6 }) // 整体微调,让文字与其他 Tab 文字对齐
.offset({ x: 0, y: -24 }) // ⭐ 上移 24vp,让按钮一半露出凹槽
}
.width('100%')
.height(NOTCH_TOP_INSET + BAR_HEIGHT)
}
// 上面 3 个 Builder(GrooveShape / TabRow / SideTab / CenterButton)写在这里...
}
坑点 5 :
Stack.alignContent: Alignment.Bottom让多个子节点底部对齐。中央按钮通过.offset({ y: -24 })向上偏移,制造"按钮坐在凹槽里、半埋半露"的视觉。偏移量必须与凹槽半径匹配,否则按钮要么飘起、要么沉底。
4.6 第六步:在 MainTabsPage 接入 GrooveTabBar
typescript
import { TabKey } from '../common/Constants';
import { GrooveTabBar } from '../components/GrooveTabBar';
import { HomeView } from './views/HomeView';
import { PlanView } from './views/PlanView';
import { RecordView } from './views/RecordView';
import { ProfileView } from './views/ProfileView';
import { CategoryPage } from './CategoryPage';
@ComponentV2
export struct MainTabsPage {
@Param pathStack: NavPathStack = new NavPathStack();
@Local selectedKey: string = TabKey.Home;
build() {
Column() {
// 主内容区
Stack() {
if (this.selectedKey === TabKey.Home) {
HomeView({ pathStack: this.pathStack })
} else if (this.selectedKey === TabKey.Category) {
CategoryPage({ pathStack: this.pathStack, embedded: true })
} else if (this.selectedKey === TabKey.Plan) {
PlanView({ pathStack: this.pathStack })
} else if (this.selectedKey === TabKey.Record) {
RecordView({ pathStack: this.pathStack })
} else {
ProfileView({ pathStack: this.pathStack })
}
}
.layoutWeight(1)
.width('100%')
// 凹槽 TabBar
GrooveTabBar({
selectedKey: this.selectedKey,
onChange: (k: string) => { this.selectedKey = k; },
})
}
.width('100%')
.height('100%')
}
}
@Local selectedKey 是 V2 模式下的状态变量,onChange 接收子组件传上来的 key,单向数据流闭环,简洁且无副作用。
4.7 第七步(可选):选中态过渡动画
如果想让 Tab 切换更"灵动",可以给图标加入场动画。仅需在 SideTab 的 Image 加属性动画:
typescript
Image(this.selectedKey === t.key ? t.iconActive : t.icon)
.width(22).height(22)
.scale(this.selectedKey === t.key
? { x: 1.15, y: 1.15 }
: { x: 1.0, y: 1.0 })
.animation({ duration: 200, curve: Curve.FastOutSlowIn })
选中时图标放大 15% + 200ms 弹性过渡,只用图形变换属性 scale (不动 width/height),不触发布局重排,性能最优------这是 HarmonyOS 动画最佳实践推荐的做法。
5、完整数据流分析
以「冷启 → 默认 Home Tab → 点学习 Tab → 切回 Home」为例:
冷启
│
▼
MainTabsPage.aboutToAppear
└─ @Local selectedKey = TabKey.Home (默认值)
│
▼
Stack 显示 HomeView
GrooveTabBar 渲染:
Layer 1 → GrooveShape 画白底凹槽
Layer 2 → TabRow 4 个 SideTab(Home 高亮绿)
Layer 3 → CenterButton 白底(Plan 未选中)
─────────────────────────────────────────────────────────────────
用户点中央凸起按钮
│
▼
CenterButton.onClick
└─ this.onChange(TabKey.Plan)
│
▼
MainTabsPage onChange callback
└─ this.selectedKey = 'plan'
│
▼ @Local 触发 V2 重渲染
GrooveTabBar 重渲染:
Layer 2 → 4 个 SideTab 都恢复灰色(无 active)
Layer 3 → CenterButton 背景 #FFFFFF → #436444
Image fillColor #436444 → White
.animation 200ms 平滑过渡
文字「学习」颜色 #3F4A52 → #436444
Stack 主内容区 → 切换到 PlanView
─────────────────────────────────────────────────────────────────
用户点首页 Tab
│
▼
SideTab(Home).onClick
└─ this.onChange(TabKey.Home)
│
▼
MainTabsPage.selectedKey = 'home'
│
▼
Layer 2 → Home SideTab 重新高亮绿
Layer 3 → CenterButton 背景渐回白
Stack 主内容区 → 切回 HomeView
观察点:
- 单向数据流 :子组件
GrooveTabBar只通过@Event onChange发出意图,父组件MainTabsPage持有真值@Local selectedKey,避免双向绑定的复杂性。 - 5 状态联动 :4 个 SideTab + 1 个 CenterButton,每次状态切换只有"高亮的那个"和"刚失去高亮的那个"两个 Tab 需要重渲染,其他 3 个 V2 差分跳过------比 V1 性能更好。
- 过渡动画走属性动画 :颜色 / scale 用
.animation()修饰符让 ArkUI 自动补间,不需要手写 animateTo,代码量减半。 - 凹槽 Shape 永不重渲染:Layer 1 的 GrooveShape 不依赖 selectedKey,state 切换时跳过重绘------这就是分层架构的好处。
6、代码分析与优化建议
6.1 现有实现的亮点
- 零三方依赖 :纯
Shape + Path + Stack + Row系统 API,可移植到任何鸿蒙 6 项目 - 三层 Stack 分层架构:背景 / Tab Row / 凸起按钮各管一摊,分别重渲染,性能最优
- V2 状态管理 :
@Param + @Event单向数据流 +@Local真值,无副作用 - SVG fillColor 动态切色:图标不用预先准备 N 套颜色,运行时切换
- 图形变换动画 :scale / opacity 走
.animation(),不触发布局,丢帧率 < 3%
6.2 可优化点
优化 1:用 Tabs 组件托管页面切换 + barBuilder 自定义 TabBar
问题 :当前实现 MainTabsPage 用 if/else 切换 5 个 View,每次切换都重新挂载,状态丢失。
改进 :用 Tabs 组件 + barBuilder 自定义 TabBar,让 Tabs 帮你缓存子页面:
typescript
Tabs({ barPosition: BarPosition.End, index: this.tabIndex }) {
TabContent() { HomeView(...) }
.tabBar(this.tabBarBuilder) // 用同一个自定义 barBuilder
TabContent() { CategoryPage(...) }
.tabBar(this.tabBarBuilder)
// ...
}
.onChange((index: number) => { this.tabIndex = index; })
.barHeight(NOTCH_TOP_INSET + BAR_HEIGHT)
barBuilder 内部仍然是我们手写的 GrooveTabBar,但 Tabs 帮你做了 TabContent 的缓存和切换动画。
取舍 :Tabs 组件会给你切换动画,但自定义 barBuilder 内的复杂 Path 凹槽在某些版本会出现重绘抖动 。如果不需要"滑动切换 tab"动画,用我们当前的 if/else 方案更稳定。
优化 2:把 viewport 数值改用 Math.min(屏宽,720) 适配大屏
问题 :VP_WIDTH = 750 是 iPhone 设计稿尺寸,在平板或折叠屏上凹槽会被拉伸成椭圆。
改进 :监听 display.getDefaultDisplaySync() 拿真实屏宽,动态计算 viewport:
typescript
import display from '@ohos.display';
aboutToAppear(): void {
const screenWidth = display.getDefaultDisplaySync().width;
const vpWidth = this.getUIContext().px2vp(screenWidth);
this.viewportW = Math.min(vpWidth, 720); // 大屏限制最大 720vp
}
让凹槽宽度永远是合理范围,平板上中央按钮不会变成"扁椭圆"。
优化 3:凸起按钮加入弹簧 keyframeAnimateTo
问题 :当前选中态切换只用 .animation 颜色过渡,按钮位移没有弹簧反馈。
改进:onClick 时触发 4 段速度曲线让按钮"按下":
typescript
private bounceCenterButton(): void {
this.getUIContext().keyframeAnimateTo({ iterations: 1 }, [
{ duration: 80, curve: Curve.Sharp, event: () => { this.centerScale = 0.88; } },
{ duration: 120, curve: Curve.EaseOut, event: () => { this.centerScale = 1.08; } },
{ duration: 100, curve: Curve.EaseOut, event: () => { this.centerScale = 1.0; } },
]);
}
按下→回弹→定型 三段曲线,比单一 Spring 曲线更有"按按钮"的拟物感。
优化 4:暗色模式适配
问题:白色凹槽底栏在暗色模式下太刺眼。
改进 :把白色改为 $r('app.color.surface_card'),在 resources/dark/element/color.json 定义对应暗色值(如 #1F2937)。
typescript
Path()
.fill($r('app.color.surface_card')) // ← 改成资源引用
.stroke($r('app.color.divider'))
优化 5:底部安全区适配
问题:在带手势条的设备上(Mate 60 等),底栏会被手势条遮挡。
改进 :用 expandSafeArea API 让底栏延伸到手势条下方,并内部 padding-bottom 让 Tab 内容在安全区上方:
typescript
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.padding({ bottom: this.bottomSafeAreaInset })
6.3 生产环境 Checklist
| 检查项 | 说明 |
|---|---|
Path 用 M L A 命令组合,sweep-flag = 0 凹陷向下 |
sweep 写错会变凸起山包 |
| Shape.viewPort 与 width/height 不必相等 | viewPort 是逻辑坐标,自动缩放 |
| 中央占位 Blank 宽度与凸起按钮直径一致 | 否则 4 个 Tab 分布不均 |
Image.fillColor 仅 SVG 有效 |
PNG 图标无法动态切色 |
.offset({ y: -24 }) 上移量 = 凹槽半径 - 按钮一半 |
让按钮在凹槽里"半埋半露" |
@Param + @Event 单向数据流 |
避免双向绑定状态飘移 |
scale / opacity 用 .animation 自动过渡 |
不触发布局重排 |
暗色模式色用 $r('app.color.xxx') 资源 |
不要硬编码 #FFFFFF |
| 大屏限制 viewport 最大宽度 720vp | 防止凹槽变椭圆 |
expandSafeArea 适配底部手势条 |
否则被遮挡 |
7、关键 API 速查
| API | 作用 |
|---|---|
Shape() { Path().commands(d) } |
绘制 SVG 路径形状 |
Path.commands('M x y L x y A rx ry 0 0 0 x y Z') |
SVG 路径命令字符串 |
Path.fill(color) / .stroke(color) / .strokeWidth(n) |
填色 / 描边 |
Shape.viewPort({ x, y, width, height }) |
SVG 视口(逻辑坐标系) |
Stack({ alignContent: Alignment.Bottom }) |
子节点底部对齐叠加 |
.offset({ x, y }) |
渲染偏移(不影响布局) |
Image($r('app.media.xxx')).fillColor(c) |
SVG 图标动态着色 |
.animation({ duration, curve }) |
属性动画修饰符(颜色/scale/opacity 自动补间) |
.shadow({ radius, color, offsetX, offsetY }) |
投影 |
.borderRadius(n) |
圆角 |
@ComponentV2 / @Param / @Local / @Event |
V2 状态管理装饰器 |
Tabs + TabContent + .tabBar(builder) |
系统 Tabs 组件 + 自定义 BarBuilder |
expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) |
适配底部安全区 |
display.getDefaultDisplaySync() |
获取设备屏幕宽度 |
8、总结
本文用一个完整的「凹槽凸起底部导航栏」案例,系统讲解了 HarmonyOS 6 ArkUI 中**Shape + Path SVG 绘图**、Stack 多层叠加 + 绝对偏移 、V2 单向数据流 、.animation 属性动画几大核心能力的工程化组合:
-
三层 Stack 分层架构 :① 背景层
Shape + Path画凹槽 ② Tab Row 平铺 4 个普通 Tab + 中央留空 ③ 凸起按钮绝对定位 +.offset上移------每层独立重渲染、性能最优。 -
SVG Path 凹槽 6 段命令搞定 :
M起点 →L直线 →A圆弧凹陷 →L L L Z闭合矩形。sweep-flag = 0是关键,决定凹陷方向。 -
V2 单向数据流 :父组件持有
@Local selectedKey、子组件用@Param + @Event接收和回传,避免双向绑定的状态飘移。 -
5 状态联动:4 个 SideTab 共享样式 + 1 个 CenterButton 独立逻辑,每次切换只有 2 个 Tab 需要重渲染。
-
5 个真坑写进 Checklist:sweep-flag 方向 / viewport 适配 / fillColor 仅 SVG / offset 上移量 / Image 着色------任意一个踩错都会让效果走样。
-
5 个优化方向:Tabs barBuilder 缓存子页面 / 大屏 viewport 限制 / 弹簧 keyframeAnimateTo / 暗色模式 / 底部安全区适配------直接对接生产标准。
完整代码 180 行,零三方依赖 ,复制到任何 HarmonyOS 6 项目即可用。背后涉及 SVG 路径绘制、Stack 多层叠加、V2 状态管理、属性动画 4 个核心能力的深度组合,把这套模式吃透,任何带"中央凸起 / 异形 / 自定义形状"的底栏需求------发布按钮、拍照按钮、扫码按钮、AI 助手按钮------都能 30 分钟搞定。
建议结合官方文档《自定义形状 Shape》《Path》《Stack 容器》一起看,再用 DevEco Profiler 验证丢帧率,凹槽 TabBar 的工程基线就稳了。
🎁 下载体验
**「古诗学习宝」**已上架华为应用市场,搜索 古诗学习宝 即可下载。本文 TabBar 凹槽效果在 App 主框架真实可见,欢迎下载查看效果 + 留下五星 🌟 好评。
📚 文中所有截图均来自真机运行;代码已在生产版本中跑了一个月,60 fps 稳定。
👋 欢迎在评论区留言任何关于凹槽 TabBar 的实现细节问题,我会优先回复。