首页 / 资讯中心 / 文章详情

HarmonyOS 6玻璃导航栏与Tabs联动:从毛玻璃效果到滚动状态同步的完整实践

HarmonyOS 6玻璃导航栏与Tabs联动:从毛玻璃效果到滚动状态同步的完整实践 ★ FEATURED ARTICLE
HarmonyOS 6 的底部导航看着简单真正做起来却有一堆隐形门槛。很多人一开始只会用 Tabs 套三个 TabContent跑起来觉得“这不就完了”等真要做适配、做动态样式、做内容联动的时候才发现系统默认 TabBar 丑到没法上线自己写又容易踩一堆状态刷新和模糊效果的坑。这篇文章把我最近在 HarmonyOS 6 项目里做“玻璃导航栏 Tabs 联动”的完整思路、代码片段、踩坑记录整理出来希望能帮你少走几个弯路。我会从一个真实需求出发App 有三个一级页面底部需要一条半透明毛玻璃导航栏页面往下滚动时导航栏从“透明悬浮”变成“实体毛玻璃吸附”切换 Tab 时图标和文字选中态要同步联动手势滑动和点击切换必须状态一致。说白了就是既要好看又要稳。1. 项目拆解底部导航想清楚这 3 件事1.1 底部导航不只是“三个按钮”很多人在 HarmonyOS 6 里做底部导航第一个反应是“放三个按钮绑三个页面”。这个理解没有错但往深了想就会发现它和普通按钮完全是两码事。普通按钮点击完执行一个动作就结束了底部导航是 App 的一级结构每个 Tab 都对应一个相对独立的页面栈。这里最关键的是状态保留。用户在首页列表滑到很长的位置切到发现页再切回首页如果首页重建了体验就很差。HarmonyOS 6 的 Tabs 组件天然会缓存 TabContent 的组件状态这点比很多人想得要好。但如果你自作聪明地在 Tab 切换时用if控制子页面是否创建反而会把状态搞丢。所以我的建议是先确定这三个 Tab 是否需要独立页面栈。如果只是简单表单、列表、个人中心用 Tabs 自带缓存就够了。如果每个 Tab 下还有二级三级页面并且需要返回行为不跨 Tab那就得考虑在 TabContent 里各自维护 Router/Navigation这套复杂度另说。本文的场景是前者先把基础架构做稳。1.2 玻璃导航栏的“玻璃感”由三部分组成玻璃导航栏这几年在移动端很流行根本不是简单地把背景设成半透明就行。我拆了一下真正的玻璃感由三部分叠加而成。第一层是背景模糊。导航栏后面的内容会透过来但需要被打散形成那种“看得见颜色但看不清细节”的毛玻璃效果。HarmonyOS 6 里负责这件事的属性和blur有关但我建议用backgroundBlurStyle或backdropBlur如果你只是给导航栏本身加blur模糊的是导航栏里的按钮和文字不是后面透上来的页面内容效果完全不对。第二层是背景颜色。就算有了模糊也不能把所有颜色都丢掉通常会在导航栏上叠一层透明度很高的白色或者深色。白色配浅色页面是清新风深色配暗色页面是高质感风。透明度要卡在一个合适的范围太高会看不清图标太低会失去玻璃感。我一般会用rgba(255,255,255,0.75)起步再微调。第三层是分割线和高光。底部导航栏如果和页面内容完全融在一起会很飘。一条很淡的顶部阴影或细分割线能瞬间让层次立起来。这部分很多人忽略但确实是“玻璃感”的最后一公里。1.3 搞清楚“联动”的两种含义先说结论这个标题里的“联动”至少包含两层意思而且必须同时实现才算真正做完。第一层是内容滚动联动。页面往上滚动时导航栏从透明悬浮状态变成一个带模糊和阴影的实体状态。这个效果很像 iOS 大标题导航栏的 collapse 效果能让用户明显感觉到“页面滚到内容区了”。在 HarmonyOS 6 里可以通过滚动偏移量驱动导航栏的透明度、模糊度、阴影强度来实现。第二层是选中态联动。点击导航栏按钮页面切换同时图标、文字、小圆点、背景高亮都要同步变化。最容易被忽视的是手势滑动用户用手指在 Tabs 内容区左右滑动切换 Tab此时导航栏的选中态也必须跟着变不能点击是点击、滑动是滑动两套状态各玩各的。搞清楚这两层联动后续代码就不会迷迷糊糊。很多人的导航栏做得生硬根本原因不是不会写样式而是没想清楚联动的是“什么量”和“什么时候触发”。2. 用 Tabs 搭出底部导航骨架2.1 Tabs 的基础写法和关键属性HarmonyOS 6 的 ArkUI 是声明式写法Tabs 的基本用法很直接。我先把一个最干净的骨架放在这里后面在这个基础上做改造。Entry Component struct MainPage { State currentIndex: number 0 private tabsController: TabsController new TabsController() build() { Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { TabContent() { HomePage() }.tabBar(this.tabBuilder(首页)) TabContent() { DiscoverPage() }.tabBar(this.tabBuilder(发现)) TabContent() { MinePage() }.tabBar(this.tabBuilder(我的)) } .barHeight(60) .scrollable(true) .onChange((index: number) { this.currentIndex index }) } Builder tabBuilder(title: string) { Column() { Text(title) .fontSize(14) .fontColor(this.currentIndex 0 ? #1A1A1A : #999999) } } }这里几个关键属性要分清楚barPosition控制 TabBar 在底部还是顶部App 主框架场景基本都是BarPosition.End。controller用来后续手动切换页面比如做跳转、深链。scrollable(true)意味着允许用户横向滑动切换 Tab这个属性要和你的交互设计对齐如果不希望滑动切页就设为 false否则很容易误触。.tabBar()接收的并不只是文字和图片它也可以接收一个Builder构造的组件这就是自定义 TabBar 的入口。上面的写法还比较粗糙因为我在tabBuilder里写死了currentIndex 0真机跑起来会发现只有第一个 Tab 能变颜色。这就是典型的自定义 TabBar 刷新问题下一节专门说。2.2 为什么不要直接使用系统默认 TabBar系统默认 TabBar 在颜色、图标尺寸、间距上的定制能力非常弱。你要是只想快速搭个 demo用默认没问题但要做“玻璃导航栏”这种偏设计的样式基本不可能实现。原因有三点第一默认 TabBar 的背景是一个整体矩形你没法给单个 Tab 加独立的选中态背景也很难做圆角高亮。第二默认文字和图标的颜色切换粒度不够做不到“图标变成彩色、文字变黑、下方再加一个小圆点”这种复杂组合。第三默认 TabBar 的区域高度和内部布局规则比较固定加安全区、加模糊、加阴影都会比较别扭。所以我在真实项目里通常不会用系统默认 bar而是选择自定义。这里有两种姿势一种是用.tabBar(this.tabBuilder(...))配合Builder优点是写起来快缺点是状态响应容易踩坑。另一种是每个.tabBar()里放一个独立的自定义组件比如TabBarItem({ index: 0, ... })组件内部使用Prop接收选中索引这样状态刷新非常干净。我个人建议直接用第二种看似多写一个组件实际上调试成本低很多。2.3 用 TabsController 精确控制切换底部导航还有一个容易被忽略的需求页面并不只是在三个 TabContent 之间被动切换有时需要代码主动切页。比如首页某个按钮点击后要直接跳到“我的”页面或者收到推送后切到“消息”Tab。这时候TabsController就派上用场了。在组件初始化时创建 controller点击导航栏按钮时调用changeIndex(targetIndex)。需要注意changeIndex方法默认是带切换动画的如果不想要动画可以使用changeIndex(targetIndex, false)。this.tabsController.changeIndex(2, false)同时一定要把currentIndex状态同步好。我的习惯是让Tabs的onChange成为唯一的状态入口所有点击、滑动、外部跳转都通过changeIndex触发最终都回归到统一回调里更新currentIndex。这样能避免点击事件、滑动事件、controller 跳转之间出现状态不一致尤其是后面做的联动动画状态入口必须单一。2.3.1 TabBarItem 自定义组件初版不过这里要先说明单用 Tabs 自带的 bar 做玻璃导航栏还差一步。真正方便做毛玻璃的方案是把 Tabs 的 bar 高度设为零或者用全屏 Tabs 配合外部自定义导航栏容器。我先给出一个标准 TabBarItem 组件方便你理解选中态联动的基础。Component export struct TabBarItem { Prop selectedIndex: number Prop tabIndex: number Prop title: string Prop normalIcon: ResourceStr Prop activeIcon: ResourceStr build() { Column() { Image(this.selectedIndex this.tabIndex ? this.activeIcon : this.normalIcon) .width(24) .height(24) Text(this.title) .fontSize(12) .fontColor(this.selectedIndex this.tabIndex ? #111111 : #8A8A8E) } .justifyContent(FlexAlign.Center) .width(100%) .height(100%) } }可以看到这个组件完全通过Prop selectedIndex判断自己是否是选中态。父组件只要在 Tab 变化时更新selectedIndex所有 TabBarItem 都能正确响应不会出现“点击后图标没变”的问题。这个组件后续也会直接用在玻璃导航栏里。3. 玻璃导航栏实现从“半透明”到“真毛玻璃”3.1 把 Tabs 和导航栏拆开用 Stack 做叠加布局真正要让底部导航栏有毛玻璃效果最大胆也最有效的一步是不用 Tabs 自带的 bar而是把它藏起来然后在 Tabs 外部用 Stack 叠加一个自定义导航栏。为什么这么干因为 Tabs 自带的 bar 区域是 Tabs 组件内部管理的一块空间你在.tabBar()里写的内容只是放在它给好的格子里很难让毛玻璃背景完整地横跨整个底部。就算你把.tabBar()里的背景设成模糊它也是按每个 Tab 分割开的中间会有接缝。所以我的做法是把 Tabs 当作一个纯内容容器让它的 bar 区域完全消失然后自己在最外层包一个 Stack导航栏作为覆盖层浮在内容上方。Stack 的alignContent设置为Alignment.Bottom导航栏就自然落到屏幕底部。build() { Stack({ alignContent: Alignment.Bottom }) { // 内容区Tabs 不带任何可见 bar Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { TabContent() { HomePage() } TabContent() { DiscoverPage() } TabContent() { MinePage() } } .barHeight(0) .scrollable(true) .onChange((index: number) { this.currentIndex index }) // 自定义玻璃导航栏 GlassNavigationBar({ selectedIndex: this.currentIndex, onTabClick: (index: number) { this.tabsController.changeIndex(index) } }) } }这里.barHeight(0)很关键它是隐藏默认导航栏的入口。隐藏后Tabs 内容区域直接延伸到底部导航栏以悬浮层形式出现在内容上面。你可能会担心内容被遮挡没关系后面有专门的避让处理现在先把布局跑起来。3.2 毛玻璃核心backdropBlur 和 backgroundBlurStyle 怎么用到了这一步才是真正实现毛玻璃的地方。在 HarmonyOS 6 里实现“模糊它后面的内容”有两个相关属性先说区别blur(value)是模糊组件自身的内容比如一张 Image 加 blur图片本身会糊。backdropBlur(value)和backgroundBlurStyle(value)模糊的是组件背后被遮住的内容这才是毛玻璃的底层原理。如果你只给导航栏设置blur(20)导航栏里的图标和文字全糊了但导航栏下面的列表内容还是清晰的完全不是想要的效果。实战中我更推荐backgroundBlurStyle因为它封装好了系统级材质调用简单效果也统一。比如BlurStyle.Thick是强模糊适合页面滚动后导航栏实体化的情况BlurStyle.Regular是中等模糊BlurStyle.None是不模糊适合初始状态。在导航栏容器上还要配合半透明背景色我常用的组合是.backgroundBlurStyle(this.blurEnabled ? BlurStyle.Thick : BlurStyle.None) .backgroundColor(this.blurEnabled ? rgba(255,255,255,0.78) : rgba(255,255,255,0.2))这里有三个注意点第一blur 一定要和半透明背景一起用。如果背景完全不透明模糊再多也看不见如果背景完全透明视觉上会缺少“玻璃”的实体感。第二backgroundBlurStyle是在导航栏有父容器背景内容的前提下生效的。因为这里用 Stack 叠加导航栏底下正好是列表内容所以模糊有效。第三backgroundColor的颜色值变化要配合animateTo否则毛玻璃是瞬时切换视觉会很生硬。3.3 安全区、阴影和高光细节决定质感玻璃导航栏如果直接贴在屏幕底部手势条区域会很难看。HarmonyOS 6 的安全区处理有两种常见思路一种是导航栏整体不进入安全区下方留白但这种设计不够沉浸另一种是让毛玻璃背景向下延伸到安全区手势条区域也有半透明模糊但图标和文字要避开。我比较推荐第二种做法。在自定义导航栏上背景容器使用.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])让背景模糊延伸到全面屏底部但导航栏内部内容不要写在最底部要加上padding({ bottom: 12 })之类的距离避免内容被系统手势条遮挡。阴影和高光则是玻璃质的加分项。底部导航栏顶部如果直接和内容硬切会显得很平我习惯加一层非常淡的顶部阴影.shadow({ radius: 20, color: rgba(0,0,0,0.06), offsetY: -4 })如果觉得阴影太重可以换成 0.5vp 高度的细线颜色用白色半透明或黑色微透。总之导航栏和内容之间需要有一层非常克制的“分界”才符合玻璃材质贴在内容上的感觉。4. 联动效果让导航栏“活”起来4.1 用滚动偏移量驱动导航栏状态玻璃导航栏最常见的联动不是点击而是页面内容滚动时改变它的形态。实现思路很直接在页面列表的滚动回调里拿到当前滚动偏移量scrollOffset然后把它传到导航栏组件中导航栏内部通过阈值判断是“初始态”还是“实体态”。我先写一个列表页的滚动回调示例。假设首页是一个 List我给它挂一个 Scroller在 onScroll 中不断读取偏移量Component struct HomePage { private scroller: Scroller new Scroller() Prop onScrollOffsetChange: (offset: number) void build() { List({ scroller: this.scroller }) { // 若干列表项 } .onScroll(() { const offset this.scroller.currentOffset().yOffset this.onScrollOffsetChange(offset) }) } }父组件接收到 offset 后更新一个State scrollOffset再把值传给导航栏。这里注意不要天真地认为每次滚动都触发父组件刷新也没关系。滚动过程中回调频率非常高如果每次都在父组件里改多个颜色值、做动画很容易掉帧。更稳的做法是导航栏组件内部只维护一个布尔状态blurEnabled滚动偏移量超过某个阈值时从 false 变 true。不要用连续偏移量去驱动连续模糊度那个视觉收益不高性能代价还大。Event onScrollOffsetChange(offset: number) { this.scrollOffset offset if (offset 100 !this.blurEnabled) { this.blurEnabled true } else if (offset 100 this.blurEnabled) { this.blurEnabled false } }这里阈值我一般取 80vp 到 120vp。太早触发用户刚下拉一点就变成实体导航栏玻璃感不明显太晚触发内容已经顶到边缘视觉上会有点突兀。4.2 滚动内容与导航栏的避让逻辑加了悬浮导航栏之后内容区最底部会被导航栏盖住。如果不做避让列表的最后一项会被遮挡用户滑到底部也看不到完整内容。避让逻辑要在 TabContent 内部的页面去做而不是在 Stack 外层做。怎么理解呢因为 Tabs 内容区本身是全屏的导航栏悬浮在上面所以页面列表需要预留出底部安全区域和导航栏高度。我给列表至少加两层 padding第一层是导航栏高度比如 56vp第二层是安全区高度不同机型不一样。实际代码里可以用padding({ bottom: 56 this.safeBottom })来兜底。如果嫌麻烦也可以专门在列表底部放一个固定高度的空白组件视觉上同样能达到完全展示的效果。有些场景里导航栏初始状态是透明的用户滚动时导航栏从透明变为毛玻璃。这个时候如果列表内容紧贴底部当导航栏变实体后内容会被盖住一部分体验很割裂。所以我建议直接把避让距离固定好不要跟着导航栏高度变化否则滚动过程中布局会一直跳动。4.3 点击切换和手势滑动状态同步联动里最容易被忽略的部分是手势滑动和点击切换状态不同步。很多人在自定义导航栏的点击回调里只写了changeIndex忘了更新当前索引导致内容滑过去了但导航栏高亮还在原来的位置。正确做法是把currentIndex的更新统一放在Tabs的onChange里。不管是用户滑动、点击导航栏还是外部调用changeIndex最终都会触发onChange所以它是唯一的同步入口。.onChange((index: number) { if (this.currentIndex ! index) { this.currentIndex index } })导航栏按钮的点击回调不需要手动设置this.currentIndex index只需要调用tabsController.changeIndex(index)剩下的交给 onChange。如果你在点击回调里又改 currentIndex 又 changeIndex逻辑上没问题但以后加动画、加埋点、加拦截都会变得很乱不建议养成这个习惯。5. 常见问题与排查实录5.1 模糊没生效导航栏还是纯色一块这是我在排查毛玻璃时遇到最多的问题。如果你设置了backgroundBlurStyle但屏幕上看不到任何模糊大概率是背景色完全不透明把模糊效果盖住了。玻璃效果要求背景必须是带透明度的颜色比如rgba(255,255,255,0.78)而不是#FFFFFF。还有一种情况是导航栏底下没有内容。如果 Stack 里导航栏下面直接就是空白区域没有文字、图片、色块可以透过来那模糊等于没有。检查一下导航栏下方是不是空页面。建议在测试阶段放一张带文字的列表或者直接用一张彩色图片做背景这样能明显看出模糊生效没有。5.2 滚动联动时导航栏闪烁导航栏在滚动过程中一闪一闪通常是因为你把连续滚动偏移量直接绑定到了backgroundColor或blur上。滚动回调每秒触发几十次颜色和模糊值也跟着跳系统渲染压力大自然就闪了。解决办法很简单把连续值改成阈值开关。滚动偏移量超过阈值后通过animateTo做一次平滑过渡而不是持续跟着偏移量走。比如滚动值 101 和 200 在视觉上没有必要区分那么细统一进入实体态即可。过渡动画时长控制在 150ms 到 250ms 之间交互手感最自然。5.3 自定义 TabBar 点击后刷新不及时之前在 H2 2.2 里提到过这个问题。如果你用.tabBar(this.tabBuilder(...))并且Builder内部直接访问了父组件状态有时会发现点击导航栏后图标颜色没变但内容已经切换。原因是 Builder 的刷新粒度并不总是绑定到内部每一个this依赖上尤其当 tabBar 作为参数传给 Tabs 时状态追踪会有边界。最稳定的做法就是不要让 Builder 承担复杂联动组件而是把导航栏整体抽出来做成一个独立组件通过Prop传值。这样只要selectedIndex变了组件必然触发刷新。5.4 页面切换后滚动位置丢失Tabs 组件的 TabContent 默认情况下会缓存页面状态滚动位置一般不会丢。如果你发现切换 Tab 后页面回到顶部先检查是不是在 TabContent 里写了不合理的条件渲染比如根据currentIndex控制页面是否创建。另一个容易踩的坑是给 TabContent 设置了visibility隐藏某些版本下隐藏的组件可能会重建滚动位置就保不住了。我通常的做法是让 Tabs 内容区的页面组件始终处于挂载状态不在外面套任何if。如果确实要做懒加载也要用 Tabs 自身的懒加载能力而不是手动控制创建销毁。6. 性能与体验调优6.1 减少模糊重绘开销毛玻璃看起来高端代价是渲染开销比普通纯色背景大。如果把导航栏的模糊状态频繁切换比如在滚动过程中反复从BlurStyle.None切到BlurStyle.Thick就可能遇到帧率不稳。我习惯在滚动开始时先不管模糊等滚动停止后再根据偏移量确定最终状态或者用阈值加动画。真机调试时可以打开服务端性能分析工具看 GPU 渲染情况。如果导航栏区域出现红色 overlay说明过度绘制严重这时候要压缩导航栏上的嵌套层级尽量把背景和内容分成两层不要一个组件套一个组件。6.2 使用 LazyForEach 优化长列表联动要做出流畅的“滚动联动”列表本身的性能也至关重要。HarmonyOS 6 里如果 Tab 内容是长列表直接使用ForEach渲染几百条数据滚动会非常吃力联动更不可能流畅。这里应该用LazyForEach它只渲染视口附近的组件滚动性能好很多。我建议把列表项拆成独立的数据源类实现IDataSource接口这样在长列表场景下才扛得住。class ItemSource implements IDataSource { private list: number[] totalCount(): number { return this.list.length } getData(index: number): object { return this.list[index] } registerDataChangeListener(listener: DataChangeListener): void { } unregisterDataChangeListener(listener: DataChangeListener): void { } }如果列表数据不变化registerDataChangeListener留空即可重点是把LazyForEach用起来让列表滚动和导航栏联动都不掉帧。6.3 统一管理全局状态避免跨组件传参地狱单个页面里的滚动偏移量还好但如果有多个 Tab每个 Tab 都有自己的滚动位置并且都要通知同一个导航栏组件传参就会变得很绕。更麻烦的是导航栏组件悬浮在 Stack 层级和 Tabs 是并列关系不是父子关系直接用属性传参比较别扭。我会在这种场景下引入一个轻量的全局状态比如AppStorage。在Entry组件里初始化一个pageScrollOffset列表滚动时更新它导航栏组件通过StorageLink监听变化。这样每个 Tab 共享同一个滚动偏移量导航栏只需要关心这个全局值不需要知道是哪个 Tab 发的逻辑清晰很多。AppStorage.setOrCreate(pageScrollOffset, 0) StorageLink(pageScrollOffset) scrollOffset: number 0需要注意的是多个 Tab 同时存在时未激活的 Tab 也会持有滚动偏移量。切换 Tab 时导航栏的联动状态必须根据当前 Tab 重新计算而不是沿用上一个 Tab 的值。所以在onChange里切换到新的 Tab 时要先重置pageScrollOffset再让新 Tab 的初始化滚动位置去更新它。7. 写在最后的实操体会这次把整套“玻璃导航栏 Tabs 联动”做完我最大的体会是Tabs 本身只解决“页面切换”真正决定产品质感的是外面那层导航栏。用 Stack 把页面和导航栏叠起来再配合滚动偏移量去切换毛玻璃材质效果确实高级但调渲染性能的过程也够折腾。如果是从零开始做我的建议是先放弃联动用固定毛玻璃把布局跑通。确认了安全区、避让、点击切页都没问题再引入滚动偏移量做状态切换。每加一层联动就要重新检查一遍性能千万别一口气把所有效果都堆上去再调优那样出了问题很难定位。还有一个小技巧导航栏不要做成满宽纯矩形可以在背景层预留一点左右边距或者给菜单项加圆角高亮。这样视觉上比传统底部 Tab 更有呼吸感也让毛玻璃材质更有发挥空间。HarmonyOS 6 的组件能力和属性已经给了不少空间关键是别迷信“一个 Tabs 搞掂全部”学会把它和自定义组件、全局状态、滚动事件组合起来用底部导航才会真正像产品设计稿里那样自然。
阅读完成 · 觉得有帮助?
咨询建站