HarmonyOS 鸿蒙 HdsNavigation 标题栏框架实战 —— 把 Header 当「系统托管的页面控制面」,而不是手写工具条
一、先校准心智模型:Header 是「页面控制面」
动手之前先做 Benchmark。看四个官方应用怎么用 Header:
|
应用 |
Header 承担什么 |
|---|---|
|
设置 |
Search 在标题附近,滚动后 Header 收敛,靠模糊/分割线保持可读 |
|
图库 |
Header 与内容材质融合,Tabs/分类入口与滚动内容共同决定层级 |
|
文件 |
搜索、排序、筛选、更多操作都靠近页面身份区,滚动时操作保持可达 |
|
应用市场 |
Tabs、搜索、消息提醒、页面级菜单同时出现,Header 支持多状态入口 |
提炼出的设计原则:
-
标题是页面身份,不是业务内容标题的重复装饰;
-
Search 可以放在标题下方(bottom custom area),滚动后收起;
-
Segment 适合轻量视图切换(Today / Pinned / Shared),Tabs 要克制数量;
-
Toolbar 只放页面级轻操作,批量编辑类强操作下放到内容区或底部;
-
Badge 放在 menu / subIcon,不散落在内容顶部;
-
Dynamic Blur 是 Header 与内容融合的核心,由系统/HDS 实现,不手写 blur overlay。
一句话:Header 是系统托管的页面控制面(页面身份 + 页面级命令 + 轻量状态提醒),不是一个自由布局的容器。这个心智模型直接决定了后面所有 API 怎么用。
本实验还基于一条已有决策(ADR-001):Navigation 拥有标题栏。所有页面身份、菜单、模糊都配置在 HdsNavigation.titleBar / HdsNavDestination.titleBar 上,页面内容区不再出现第二个顶栏。
二、能力地图:HdsNavigation Header 给了你什么
先盘点当前 SDK(本机 @kit.UIDesignKit.d.ts + @hms.hds.hdsBaseComponent.d.ets 交叉验证)暴露的 Header 能力:
|
能力 |
入口 |
实验验证结果 |
|---|---|---|
|
标题 + 副标题 |
|
✅ |
|
页面菜单 |
|
✅ |
|
菜单红点 |
|
✅ |
|
副图标 |
|
✅ 编译验证 |
|
顶部自定义区 |
|
✅ 编译验证;要显式留状态栏缓冲 |
|
底部自定义区 |
|
✅ 成熟项:单一 Search |
|
工具栏 |
|
✅ 编译验证,未进基线 |
|
半模态标题模式 |
|
✅ 用于 Search Overlay / Quick Access |
|
滚动联动 |
|
✅ 每个目的页自己的 Scroller |
|
动态模糊 |
|
✅ 核心能力,见第五节 |
|
分割线 |
|
✅ |
覆盖度结论:页面身份、搜索、分段、Tabs、提醒、页面级菜单和半模态快捷入口都能留在 HDS Navigation 体系内,不需要手写顶栏。
下面挑四个最有料的展开。
三、Badge:状态驱动,不是按钮自增
第一个验证项:菜单 A、N、Q 带动态 Badge,验证状态提醒、多 Badge 形态、点击更新和 Header 刷新。
实现的关键心智是:Badge 是状态的投影,不是按钮自己的计数器。菜单项构造时从页面状态读 Badge:
private MenuItem(label: string, badgeCount: number = -1, badgeValue: string = ''): HdsNavigationMenuItemOptions {
const item: HdsNavigationMenuItemOptions = {
content: {
label: label,
type: TextStyleMode.SINGLE_CHARACTER,
action: (): void => {
// 点击只改状态,不改 UI
if (label === '一个') {
this.syncBadgeCount = this.syncBadgeCount + 1; // 模拟同步进度
}
if (label === '氮') {
this.notificationBadgeCount = 0; // 进入即清除未读
}
}
}
};
if (badgeCount >= 0 || badgeValue.length > 0) {
item.badge = {
count: badgeCount, // 数字形态
value: badgeValue, // 字符形态('!')
accessibilityText: `${label} status badge` // 无障碍必配
};
}
return item;
}
private TitleMenuItems(): Array<HdsNavigationMenuItemOptions> {
return [
this.MenuItem('一个', this.syncBadgeCount), // 数字 Badge
this.MenuItem('氮', this.notificationBadgeCount),
this.MenuItem('问', -1, '!') // 字符 Badge
];
}
syncBadgeCount / notificationBadgeCount 是宿主 @State。状态一变,TitleMenuItems() 重新求值,Header 整体刷新——Badge 的生命周期、消除策略、同步来源都由业务状态机控制,菜单项只是渲染器。这正是实验沉淀的 Navigation Badge Pattern。
两条实践细节:
-
accessibilityText必须配。红点对读屏用户是「有东西」,配了文本才是「有 3 条同步提醒」。 -
菜单数量克制在
maxCount: 3。生产力应用的 Header 菜单超过三个,就该进「更多」面板而不是排开。
四、Search bottomBuilder:一个高价值轻控件的基线
第二个验证项:Search 放进 Header 的底部自定义区,并且随滚动方向收起/展开。
4.1 官方基线:56vp 容器,40vp 胶囊
按官方示例,bottom custom area 放一个 Search,容器固定 56vp、内部搜索胶囊 40vp、左右 16vp 边距:
titleBar: {
content: {
...
bottomBuilder: {
builder: (): void => { this.SearchBottomBuilder(); },
height: this.searchVisible ? 56 : 0,
showType: BottomBuilderShowType.DIRECTLY_SHOW
}
}
}
4.2 收起/展开:动画驱动,禁止 if 插拔
显隐用一个 220ms 的小动画,height 和 opacity 同步变化:
private SetSearchVisible(visible: boolean): void {
if (this.searchVisible === visible) {
return; // 状态幂等,避免重复动画
}
this.getUIContext().animateTo({
duration: 220,
curve: Curve.EaseOut
}, () => {
this.searchVisible = visible; // bottomBuilder.height / capsule.height / opacity 全部由它派生
});
}
Bottom custom area 不能用 if 生硬插拔——插拔是布局突变,动画驱动才是连续收敛。胶囊的高度与透明度同样从 searchVisible 派生:
SharedSearchCapsule({ displayText: this.searchText, placeholder: '搜索' })
.height(this.searchVisible ? 40 : 0)
.opacity(this.searchVisible ? 1 : 0)
.margin({ left: 16, right: 16 })
4.3 滚动方向:4vp 阈值防抖
什么时候收、什么时候展?由列表滚动方向驱动,但要做阈值防抖,否则手指微抖搜索框会闪烁:
.onDidScroll((): void => {
const offset: OffsetResult = this.scroller.currentOffset();
this.onChromeStateChange(offset.yOffset > 28); // 标题栏收敛态
const delta: number = offset.yOffset - this.lastScrollOffset;
if (offset.yOffset <= 2) {
this.onSearchVisibilityChange(true); // 回到顶部:强制显示
} else if (delta > 4) {
this.onSearchVisibilityChange(false); // 向上滑(内容下行):收起
} else if (delta < -4) {
this.onSearchVisibilityChange(true); // 向下滑:展开
}
this.lastScrollOffset = offset.yOffset;
})
三条规则各司其职:回顶强制显示(顶部永远是搜索入口)、±4vp 阈值(方向判定去抖)、保持 bindToScrollable([pageScroller])(每个目的页用自己的 Scroller,滚动联动由 HDS 自己消费)。
4.4 撤回的故事:为什么只留一个 Search
实验过程中真的做过「Search + Segment + Tabs + Toolbar 同时塞进 Header」的版本——能力上全部编译通过、运行正常,然后我们把它撤回了。
原因不是技术,是信息架构:
-
Segment/Tabs 进 Header 后,Header 变成了二级导航树,和底部 TabBar、页面内切换职责打架;
-
Toolbar 的批量编辑类强操作放头部,拇指可达性差,系统规范本来就建议下放;
-
多控件堆叠后,
stackBuilder自定义区还要自己处理状态栏缓冲与稳定高度,否则控件贴到系统栏。
所以基线收敛为:bottomBuilder 只放一个高价值轻控件(Search),Quick Access 拆成独立 Destination。Segment/Tabs/Toolbar 若要启用,需重新做视觉密度、safe area、动效与跨页面复用验证——这是「能力可用」和「该不该用」的区别。
五、动态模糊与双态配色:Header 融合的核心
5.1 开启沉浸式渐变模糊
Header 与内容融合靠系统动态模糊,一条配置:
style: {
scrollEffectOpts: {
enableScrollEffect: true,
scrollEffectType: ScrollEffectType.IMMERSIVE_GRADIENT_BLUR
},
blurStrategy: BlurStrategy.ADAPTIVE
}
模糊由系统/HDS 实现,应用侧只能配置类型、样式和阈值——不要手写 blur overlay 替代,那是 E006 之前各项目反复交过的学费。
5.2 双态样式:originalStyle 与 scrollEffectStyle
HDS Header 有两个视觉状态:原始态(未滚动,Header 与页面融合、背景透明)和滚动态(滚动后,模糊+背景+分割线出现)。样式必须两套都配:
originalStyle: {
backgroundStyle: {
backgroundColor: ThemeTransparentWhiteColor(),
maskExtraHeight: 56, // 给 bottomBuilder 的 Search 留遮罩高度
blurRadius: 0
},
contentStyle: { titleStyle: {...}, menuStyle: {...} }
},
scrollEffectStyle: {
backgroundStyle: {
backgroundColor: NavigationTitleBarButtonBackground(),
maskExtraHeight: 84, // 收敛后遮罩更高
blurRadius: 72 // 模糊半径
},
contentStyle: { titleStyle: {...}, menuStyle: {...}, dividerStyle: {...} }
}
两个容易栽的细节:
-
maskExtraHeight要跟着 Header 内容高度走。加了 56vp 的 Search bottomBuilder,原始态遮罩至少 56,否则模糊区域罩不住搜索框。 -
只配一套 = 状态切换时穿帮。比如只配了
scrollEffectStyle的浅色标题,未滚动时标题沿用默认深色,一滚动颜色跳变。
5.3 三套样式入口互不相干
真机验证中最重要的 API 行为发现:
menuStyle(右上菜单)、backIconStyle(返回按钮)、titleStyle(标题)是三套独立的样式入口,互相不影响。
配了 menuStyle 的颜色,返回按钮纹丝不动;配了 backIconStyle,菜单也不受影响。而 HDS 默认的返回按钮本身是状态机:透明/未滚动态使用浅色图标和淡背景,滚动触发 scrollEffectStyle 后切换为更深的图标和背景。
由此推出三条决策:
-
产品接受 HDS 默认状态机 → 不配
backIconStyle,让返回按钮跟随系统; -
产品要求 title/back/menu 滚动前后颜色固定 → 必须同时配置
originalStyle.contentStyle和scrollEffectStyle.contentStyle,一套都不能少; -
颜色永远来自系统 token,不硬编码十六进制。
六、系统颜色 token:别靠名称猜颜色
上一节的「固定颜色」用什么值?实验沉淀了四个系统 token,封装成 NavigationTitleBarTokens.ets:
|
Token |
用途 |
|---|---|
|
|
内页背景 / 滚动态标题栏背景 |
|
|
标题栏胶囊按钮背景 |
|
|
标题、返回图标、菜单文字/图标 |
|
|
标题栏 divider |
export function NavigationTitleBarButtonForeground(): ResourceColor {
return $r('sys.color.ohos_id_color_text_primary');
}
为什么要封装成函数而不是各页面散落 $r(...)?两个原因:一处改全局生效,以及真机/深色模式/高对比模式下 token 的实际渲染值会变,散落的十六进制颜色做不到跟随。
配套还做了一个 SystemColorTokenLab 目的页——不是替代设计系统,而是在真机、深色、主题变化下直接观察 token 实际渲染值。「靠名称猜颜色」是深色模式翻车的第一大来源。
七、Quick Access:独立 Destination,不混进 AppShell
最后一个架构决策:Quick Access(下拉快捷入口)这类半模态页面,独立成 QuickAccessDestination 文件和路由,使用独立 Scroller、动态模糊标题栏和生命周期显隐,标题模式用 HdsNavDestinationTitleMode.MODAL。
反面做法是把它塞进 NavigationAppShell 的 if/else 分支里——AppShell 会迅速膨胀成千行文件,且半模态生命周期与主页面互相污染。Shell 只负责壳,每个 Lab/功能页是独立 Destination,这是从 E001 一路保持的模块边界(ADR-002)。
八、沉淀:五条 Pattern 与使用守则
实验沉淀的 Navigation Header Pattern:
-
Header Status Entries:AI、同步、通知、未读统一建模为状态实体,Badge 由状态驱动。
-
Badge Pattern:状态驱动 +
accessibilityText,禁止按钮内部自增计数长期持有。 -
Search bottomBuilder Pattern:单一 Search,56/40/16 尺寸,滚动方向 4vp 阈值 + 回顶强制显示。
-
TitleBar Token Pattern:original 与 scroll 双态共用一套 token helper,不散落硬编码。
-
Quick Access Destination Pattern:独立页面文件 + 独立路由,不混入 AppShell。
使用守则一句话版:
不要手写 Navigation Header。 优先
HdsNavigation.titleBar/HdsNavDestination.titleBar;高级需求优先官方bottomBuilder、menu.badge和独立HdsNavDestination;禁止为了 Search、Tabs、Toolbar 重新手写一个顶部栏。Header 的职责是页面身份、页面级命令和轻量状态提醒——HDS 负责框架和模糊,信息架构永远归你自己。
结语
E006 验证完后,ArkUILab 的 Navigation Framework 标记为 v2 Candidate。尚欠的功课也很明确:真机 Benchmark 录屏对比、深色/高对比模式检查、Header 状态从实验态局部 @State 升级为统一模型。这些不影响本文结论:能力是够的,约束你的应该是信息架构判断,而不是框架能力恐慌。
更多推荐




所有评论(0)