ArkUI 底部导航实战:BottomNavBar、页面路由与 Scroller 状态刷新
ArkUI 底部导航实战BottomNavBar、页面路由与 Scroller 状态刷新在 HarmonyOS 工具类应用里首页经常会被做成一个“多 Tab 容器”首页、分类、我的都塞在同一个Index.ets然后用State currentTab控制显示哪个分支。这个项目最后没有这么做。当前代码采用的是更适合多页面维护的方案Index.ets、CategoryPage.ets、ProfilePage.ets各自是独立页面底部只复用同一个BottomNavBar组件一级入口之间用router.replaceUrl()切换中心创建按钮和详情页用router.pushUrl()进入下一层页面。这篇按当前真实代码拆解底部导航、首页刷新、详情跳转和滚动状态的处理方式。先明确这里不是一个本地 currentTab 容器如果把所有一级页面都写在Index.ets里代码大概会变成这样State currentTab: string home; if (this.currentTab home) { this.HomeContent(); } else if (this.currentTab category) { this.CategoryContent(); } else { this.ProfileContent(); }这个写法前期很快但后面会有几个问题首页、分类、我的页面状态混在一起。搜索词、滚动位置、头像弹层等局部状态容易互相影响。页面拆分以后底部导航选中态和真实路由容易不一致。从详情页返回时数据刷新和页面生命周期不好管理。当前项目选择把一级页面拆开每个页面自己声明BottomNavBar的选中项。例如首页固定选中homeBottomNavBar({ items: this.bottomTabs, selectedId: home, onChange: (id: string) { this.switchTab(id); } })分类页固定选中category我的页固定选中mine。这样底部导航只是一个共享组件不承担全局状态容器职责。路由常量集中管理项目把页面路径集中放在RoutePathsexport class RoutePaths { static home: string pages/Index; static category: string pages/CategoryPage; static cardDetail: string pages/CardDetailPage; static cardEdit: string pages/CardEditPage; static cardManage: string pages/CardManagePage; static backup: string pages/BackupPage; static profile: string pages/ProfilePage; }底部导航、快捷入口和卡片点击都使用这些常量。好处是路由字符串不会散落在页面里后续改页面路径时不需要全局搜索中文文案或裸字符串。BottomNavBar 的组件契约BottomNavBar只接收三类信息Prop items: BottomTabItem[] []; Prop selectedId: string home; onChange?: (id: string) void;这个接口很克制items决定渲染哪些入口。selectedId决定哪个入口高亮。onChange把点击事件交回页面。组件内部不直接调用router也不读取业务服务。这一点很重要底部导航可以被首页、分类页、我的页、主题页复用具体跳转策略由页面自己决定。图标槽位固定避免底部导航越调越歪底部导航最容易出问题的不是点击而是视觉稳定性。项目里把几个尺寸拆成独立 helperprivate navIconSlotHeight(): number { return AppSizes.navCenterSize - 10; } private navIconTouchSize(): number { return AppSizes.navItemSize 4; } private navCenterButtonSize(): number { return 68; } private navBarHeight(): number { return 88; }普通 tab 和中心按钮都放在同一个图标槽位里。普通 tab 使用IconGlyph中心按钮使用单独的圆形背景if (item.center) { Text() .width(this.navCenterButtonSize()) .height(this.navCenterButtonSize()) .fontSize(this.navCreateIconSize()) .fontColor($r(app.color.text_on_dark)) .textAlign(TextAlign.Center) .lineHeight(this.navCenterButtonSize()) } else { IconGlyph({ code: this.resolveIcon(item.id), iconSize: this.navIconSize(), boxSize: this.navIconTouchSize(), color: this.navIconColor(item.id) }) }这里的关键不是“把图标画出来”而是所有入口都落在稳定的盒子里。否则一旦某个 iconfont glyph 本身偏下开发时就会不断叠加margin、Blank或负偏移最后在不同设备上更难对齐。首页只负责首页数据不承载全部 Tab 状态Index.ets里的状态只和首页有关private scroller: Scroller new Scroller(); State private homeSummaryCard: ShowcaseCardModel appDataService.getHomeSummaryCard(); State private previewCards: ShowcaseCardModel[] appDataService.getHomePreviewCards();页面出现和重新显示时刷新数据aboutToAppear(): void { this.refreshData(); } onPageShow(): void { this.refreshData(); } private refreshData(): void { this.homeSummaryCard appDataService.getHomeSummaryCard(); this.previewCards appDataService.getHomePreviewCards(); }这比在多个页面之间传一堆状态更稳。用户从编辑页保存卡片后回到首页onPageShow()会重新读取服务层数据首页摘要和预览卡都能同步更新。首页预览卡真实数据优先模板补位首页“我的卡片”区域不直接读静态数组而是从服务层拿getHomePreviewCards(): ShowcaseCardModel[] { const targetCount: number 4; const activeCards: CardRecordModel[] this.getActiveCardsSorted().slice(0, targetCount); const cards: ShowcaseCardModel[] activeCards .map((card: CardRecordModel) this.toShowcaseCard(card, 使用 card.usageCount 次)); if (cards.length targetCount) { return cards; } const usedTemplateIds: string[] activeCards.map((card: CardRecordModel) card.templateId); const fallbackCards: ShowcaseCardModel[] this.getTemplateFallbackCategoryCards(recommend, ) .filter((item: ShowcaseCardModel) { const templateId: string item.templateId ?? ; return templateId.length 0 || usedTemplateIds.indexOf(templateId) 0; }) .slice(0, targetCount - cards.length); return cards.concat(fallbackCards); }这个实现解决了两个体验问题用户已有卡片时首页展示真实用户数据。用户卡片不足 4 张时使用内置模板补齐首页不会突然塌成一行。补位时还过滤同templateId避免用户刚保存过的模板又作为推荐卡重复出现。一级导航replaceUrl 和 pushUrl 的分工首页底部导航的跳转逻辑在switchTab()private switchTab(id: string): void { switch (id) { case home: return; case category: router.replaceUrl({ url: RoutePaths.category }); return; case create: router.pushUrl({ url: RoutePaths.cardEdit }); return; case mine: router.replaceUrl({ url: RoutePaths.profile }); return; default: return; } }这里有一个很实用的规则首页、分类、我的属于一级入口用replaceUrl()。创建卡片属于动作入口用pushUrl()。卡片详情、编辑页属于下钻页面也用pushUrl()。如果一级入口也一直pushUrl()用户在底部导航来回点几次后返回栈会堆满首页、分类、我的。按返回键时就会在一级页之间倒退体验很混乱。分类页也复用同一个导航组件分类页不是首页的一个 if 分支而是独立的CategoryPage.ets。它自己维护搜索和筛选状态State searchText: string ; State selectedTab: string recommend; State selectedCategoryId: string ;底部导航点击时分类页只处理自己页面上的语义private openBottomTab(tabId: string): void { switch (tabId) { case home: router.replaceUrl({ url: RoutePaths.home }); return; case category: return; case create: router.pushUrl({ url: RoutePaths.cardEdit }); return; case mine: router.replaceUrl({ url: RoutePaths.profile }); return; default: return; } }注意category分支直接return因为当前已经在分类页不需要重复路由跳转。分类筛选和搜索互不污染首页分类页的筛选逻辑只在分类页内部生效private matchesQuery(item: CategoryListItem): boolean { const query: string this.searchText.trim(); if (!query.length) { return true; } const source: string ${item.title}${item.subtitle}${item.badge}; return source.indexOf(query) 0; } private matchesSelectedCategory(item: CategoryListItem): boolean { if (!this.selectedCategoryId.length) { return true; } return item.categoryIds.indexOf(this.selectedCategoryId) 0; }这就是拆成独立页面的价值分类页的searchText、selectedTab、selectedCategoryId不会和首页的previewCards、我的页的头像弹层状态混在一起。点击卡片时必须区分 cardId 和 templateId首页预览卡可能来自真实用户卡片也可能来自模板补位。因此跳转详情时不能只写一个裸路由private goToPreviewCard(item: ShowcaseCardModel): void { if (item.cardId item.cardId.length 0) { router.pushUrl({ url: RoutePaths.cardDetail, params: { cardId: item.cardId } }); return; } if (item.templateId item.templateId.length 0) { router.pushUrl({ url: RoutePaths.cardDetail, params: { templateId: item.templateId } }); return; } this.goTo(item.route ? item.route : RoutePaths.cardManage); }真实卡片优先传cardId模板卡片传templateId。详情页再根据参数决定展示用户数据还是模板预览避免用户创建过的卡片被模板默认值覆盖。Scroller 的职责每个页面自己管自己的滚动首页、分类页、我的页都各自声明private scroller: Scroller new Scroller();并在页面里绑定Scroll(this.scroller) { Column({ space: 20 }) { // 页面内容 } } .layoutWeight(1) .scrollBar(BarState.Off)这套写法有两个好处底部导航固定在Scroll外部不会随着内容滚走。每个页面的滚动实例独立不会出现首页滚到底后切到分类页仍停在底部的问题。如果未来需要“切回首页自动回顶”只需要在首页自己的scroller上处理不需要动分类页和我的页。UI 层级内容 Scroll 固定 BottomNavBar首页的整体结构很清晰Column({ space: 0 }) { Scroll(this.scroller) { Column({ space: 20 }) { this.PageTitle(); this.HeroCard(); // shortcuts // preview cards } } .layoutWeight(1) BottomNavBar({ items: this.bottomTabs, selectedId: home, onChange: (id: string) { this.switchTab(id); } }) } .width(100%) .height(100%)Scroll使用layoutWeight(1)吃掉剩余高度底部导航留在最外层 Column 的底部。这样可以避免底部导航遮住最后一行内容也避免导航被长列表挤出屏幕。常见问题复盘这个页面结构里最容易踩的坑有五个。第一把中心当普通 Tab 处理。这样会出现selectedIdcreate的空状态用户点创建后没有进入编辑页。第二一级页面用pushUrl()。这会污染返回栈返回键会在首页、分类、我的之间来回退。第三底部导航组件自己调用router。这会让组件和业务路由强耦合主题页、市场页、我的页想复用时都要绕逻辑。第四首页预览卡只读真实用户数据。新用户没有卡片时首屏会显得像空页面所以项目用模板补位保持首页完整。第五详情跳转不传cardId/templateId。这会让详情页只能走 fallback轻则展示空白重则把模板数据误当成用户数据。验证清单改底部导航或首页结构后至少要按下面路径手工过一遍首页点击“分类”应进入CategoryPage底部“分类”高亮。分类页输入搜索词再点“首页”首页不应带着分类页搜索状态。首页点击中心应进入CardEditPage不是切换成某个空 Tab。首页点击真实用户卡片应带cardId进入详情页。首页点击模板补位卡应带templateId进入详情页。从编辑页保存后返回首页首页摘要和预览卡应通过onPageShow()刷新。长列表页面滚动后底部导航仍固定在底部最后一行内容不被遮挡。小结这版首页的重点不是“在一个页面里模拟 Tab”而是把一级入口拆成独立页面再用共享BottomNavBar保持视觉一致。replaceUrl()负责一级入口切换pushUrl()负责创建和详情下钻每个页面拥有自己的Scroller和局部状态首页数据通过aboutToAppear()/onPageShow()从AppDataService刷新。这种结构对工具类应用更稳页面职责清楚返回栈干净底部导航复用简单后续扩展市场页、主题页、统计页时也不会把首页写成一个越来越大的状态容器。