鸿蒙实战:多级Tab联动支持横向滚动、指示器动画与边界手势切换
2026/6/17 11:14:59 网站建设 项目流程

在鸿蒙应用开发中,顶部 Tab 栏的多页面结构十分常见,例如首页的“关注-发现-城市”三个一级 Tab,而“发现”页内部又包含“推荐-热门-同城…”等多个二级 Tab。如何优雅地实现一级与二级 Tab 的滑动联动(当二级滑动到边界并继续向外滑动时,自动切换一级 Tab),同时让顶部标签栏具备横向滚动、指示器动画、自定义主题色等能力?本文将分享一套完整的生产级解决方案,已在实际项目中稳定运行。

一、最终效果预览

  • 一级 Tab:关注 / 发现 / 北京(城市名随定位变化),支持横向滑动切换,底部有红色指示器(动画跟随)。
  • 二级 Tab(发现页内):推荐、热门、同城等十几个标签,不显示指示器,选中的标签自动放大 1.1 倍。
  • 边界联动:当用户在“发现”页的二级内容区域滑动到最左(推荐)或最右(旅游)并继续向外滑动时,一级 Tab 自动切换到“关注”或“北京”。
  • 顶部导航栏:左侧菜单按钮、中间标签栏、右侧搜索按钮,三者垂直居中对齐,底部有分割线。

二、整体架构图

┌────────────────────────────────────────────┐ │ HomePage │ │ ┌────────────────────────────────────────┐ │ │ │ Row (菜单 + TopTabBar + 搜索) │ │ │ │ - 一级标签 (关注/发现/北京) │ │ │ │ - 底部指示器 (红色横条) │ │ │ └────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────┐ │ │ │ Tabs (一级内容区域) │ │ │ │ ┌──────┐ ┌───────────┐ ┌──────────┐ │ │ │ │ │关注页│ │ 发现页 │ │ 城市页 │ │ │ │ │ │ │ │ ┌───────┐ │ │ │ │ │ │ │ │ │ │ │二级Tab │ │ │ │ │ │ │ │ │ │ │ │(无指示器│ │ │ │ │ │ │ │ │ │ │ │选中放大)│ │ │ │ │ │ │ │ │ │ │ └───────┘ │ │ │ │ │ │ │ │ │ │ Tabs内容 │ │ │ │ │ │ │ └──────┘ └───────────┘ └──────────┘ │ │ │ └────────────────────────────────────────┘ │ └────────────────────────────────────────────┘

三、核心组件实现

1. 通用标签栏组件TopTabBar(自定义)

该组件独立于业务逻辑,接收标签数组、当前索引、颜色配置等参数,内部处理:

  • 标签横向滚动:当标签总宽度超出可视区宽度时,自动滚动当前选中项到可视区中间;若标签总宽未超出,则所有标签自然排布,不产生滚动。
  • 指示器位置计算与动画:通过translate+animateTo实现平滑移动。
  • 两种视觉模式showIndicator=true时显示底部红条;showIndicator=false时隐藏红条,选中标签自动放大 1.1 倍。
@ComponentV2exportstruct TopTabBar{@Paramtabs:string[]=[];@ParamcurrentIndex:number=0;@ParamtopPadding:number=0;@ParamactiveColor:ResourceColor=$r('app.color.tabbar_selected_color');@ParamnormalColor:ResourceColor='#333333';@ParamshowIndicator:boolean=true;@ParamnormalFontSize:number=16;@EventonTabClick:(index:number)=>void;@LocalindicatorTranslateX:number=0;privatereadonlyTAB_WIDTH:number=70;privatereadonlyINDICATOR_WIDTH:number=40;privatetotalWidth:number=0;privatepageWidth:number=0;privatetabScroller:Scroller=newScroller();privatecalcIndicatorX(index:number):number{returnindex*this.TAB_WIDTH+(this.TAB_WIDTH-this.INDICATOR_WIDTH)/2;}privateupdateIndicator(index:number){if(!this.showIndicator)return;this.getUIContext().animateTo({duration:200,onFinish:()=>this.scrollToCenter()},()=>{this.indicatorTranslateX=this.calcIndicatorX(index);});}@Monitor('currentIndex')onCurrentIndexChange(){this.updateIndicator(this.currentIndex);}privatescrollToCenter(){// 仅当标签总宽超出可视区时,才将选中标签滚动到中间if(this.totalWidth<=this.pageWidth)return;constindex=this.currentIndex;consttabCenterX=index*this.TAB_WIDTH+this.TAB_WIDTH/2;consttargetX=Math.max(tabCenterX-this.pageWidth/2,0);this.tabScroller.scrollTo({xOffset:targetX,yOffset:0,animation:{duration:200}});}build(){Stack(){Scroll(this.tabScroller){Row(){ForEach(this.tabs,(title:string,idx:number)=>{Text(title).width(this.TAB_WIDTH).padding({top:10,bottom:10}).fontSize(this.normalFontSize).fontColor(idx===this.currentIndex?this.activeColor:this.normalColor).fontWeight(idx===this.currentIndex?FontWeight.Bold:FontWeight.Normal).textAlign(TextAlign.Center).scale(idx===this.currentIndex&&!this.showIndicator?{x:1.1,y:1.1}:{x:1,y:1}).animation({duration:200,curve:Curve.EaseOut}).onClick(()=>this.onTabClick(idx));},(item:string)=>item)}.onAreaChange((_,area)=>{this.totalWidth=area.widthasnumber;})}.width("100%").scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)if(this.showIndicator){Line().width(this.INDICATOR_WIDTH).height(2).backgroundColor(this.activeColor).position({bottom:0}).translate({x:this.indicatorTranslateX})}}.padding({top:this.topPadding}).width("100%").backgroundColor(Color.White).onAppear(()=>this.updateIndicator(this.currentIndex)).onAreaChange((_,area)=>{this.pageWidth=area.widthasnumber;})}}

2. 发现页DiscoverPage(二级 Tab + 边界联动)

二级 Tab 使用同样的TopTabBar,只是showIndicator=false。二级内容使用系统Tabs组件,并绑定onGestureSwipe事件来检测边界滑动。

import{TopTabBar}from"common";@ComponentV2exportstruct DiscoverPage{@LocalsubTabs:string[]=['推荐','热门','同城','影视','美食','游戏','音乐','直播','综艺','动漫','纪录片','体育','财经','科技','数码','汽车','旅游'];@LocalcurrentSubIndex:number=0;privatetabsController:TabsController=newTabsController();@EventonEdgeReach?:(direction:'left'|'right')=>void;privateonSubTabClick(index:number){if(this.currentSubIndex===index)return;this.currentSubIndex=index;this.tabsController.changeIndex(index);}privateonTabsSelected(index:number){this.currentSubIndex=index;}@BuilderSubPageContent(title:string,index:number){Column(){Text(`${title}内容页`).fontSize(22)}.width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(Color.White)}privateonGestureSwipe(_targetIndex:number,extraInfo:TabsAnimationEvent){// currentOffset > 0 表示向右滑动(试图向左越界)if(this.currentSubIndex===0&&extraInfo.currentOffset>0){this.onEdgeReach?.('left');}elseif(this.currentSubIndex===this.subTabs.length-1&&extraInfo.currentOffset<0){this.onEdgeReach?.('right');}}build(){Column(){TopTabBar({showIndicator:false,tabs:this.subTabs,currentIndex:this.currentSubIndex,normalColor:'#666666',onTabClick:(idx)=>this.onSubTabClick(idx)})Tabs({barPosition:BarPosition.Start,index:this.currentSubIndex,controller:this.tabsController}){ForEach(this.subTabs,(title:string,idx:number)=>{TabContent(){this.SubPageContent(title,idx)}},(item:string)=>item)}.barHeight(0).animationMode(AnimationMode.NO_ANIMATION).onGestureSwipe((_,extraInfo)=>this.onGestureSwipe(_,extraInfo)).onSelected((index)=>this.onTabsSelected(index)).layoutWeight(1).width('100%').backgroundColor(Color.White)}.width('100%').height('100%').backgroundColor(Color.White)}}

3. 主页HomePage(一级 Tab + 联动处理)

一级 Tab 同样使用TopTabBarshowIndicator=true),并放置在一个Row中与菜单/搜索图标组合。一级内容使用系统Tabs,根据标题动态渲染FollowPageDiscoverPageCityPage

联动逻辑在DiscoverPageonEdgeReach回调中实现:

import{SafeAreaState}from'common';import{AppStorageV2}from'@kit.ArkUI';import{TopTabBar}from'common/src/main/ets/component/TopTabBar';import{FollowPage}from'./FollowPage';import{DiscoverPage}from'./DiscoverPage';import{CityPage}from'./CityPage';@ComponentV2exportstruct HomePage{@LocalprimaryTabs:string[]=['关注','发现','北京'];@LocalcurrentIndex:number=1;@LocalsafeArea:SafeAreaState=AppStorageV2.connect<SafeAreaState>(SafeAreaState,()=>newSafeAreaState())!;privatetabsController:TabsController=newTabsController();privateonTabClick(index:number){this.currentIndex=index;this.tabsController.changeIndex(index);}privateonTabsSelected(index:number){this.currentIndex=index;}build(){Column(){// 顶部导航栏:左侧菜单 + 一级 Tab + 右侧搜索Row(){Image($r('app.media.ic_menu')).width(35).height(35).padding(5).objectFit(ImageFit.Contain)TopTabBar({tabs:this.primaryTabs,currentIndex:this.currentIndex,normalFontSize:17,onTabClick:(idx)=>this.onTabClick(idx)}).layoutWeight(1).backgroundColor(Color.White)Image($r('app.media.ic_search')).width(35).height(35).padding(5).objectFit(ImageFit.Contain)}.padding({left:10,right:10,top:this.safeArea.statusBarHeightVp}).width('100%').alignItems(VerticalAlign.Center).justifyContent(FlexAlign.SpaceBetween).backgroundColor(Color.White).border({width:{bottom:1},color:'#E5E5E5'})// 一级内容区域Tabs({barPosition:BarPosition.Start,index:this.currentIndex,controller:this.tabsController}){ForEach(this.primaryTabs,(title:string,idx:number)=>{TabContent(){if(title==="关注"){FollowPage()}elseif(title==="发现"){DiscoverPage({onEdgeReach:(direction)=>{letnewIndex=this.currentIndex+(direction==='left'?-1:1);if(newIndex>=0&&newIndex<this.primaryTabs.length){this.currentIndex=newIndex;this.tabsController.changeIndex(newIndex);}}})}else{CityPage()}}},(item:string)=>item)}.barHeight(0).animationMode(AnimationMode.NO_ANIMATION).onSelected((index)=>this.onTabsSelected(index)).layoutWeight(1).width('100%').backgroundColor(Color.White)}.width('100%').height('100%').backgroundColor(Color.White)}}

四、关键技术点

  1. 禁用系统 Tab 动画:设置.animationMode(AnimationMode.NO_ANIMATION)不取消系统动画影响视觉一致性。
  2. 边界检测原理onGestureSwipe回调中,currentOffset > 0表示向右滑动(试图查看左边不存在的页面),此时触发左边界事件;currentOffset < 0表示向左滑动(试图查看右边),触发右边界事件。
  3. 标签自动居中:仅在标签总宽度超出可视区时,TopTabBar才会将当前选中标签滚动到屏幕中间;若总宽度未超出,则所有标签自然排布,不滚动。
  4. 性能优化:每个页面独立封装,利用Tabs的页面缓存,避免重复渲染。
  5. 使用onSelected不要使用onChange,因为执行时序onSelectedonChange早,需要API18以上。

如果使用onChange,执行效果就是页面切换完了,然后指示器开始做成响应,会慢半拍。随着API版本升级基本可以满足效果需求。当前tab切换联动通过边界判断我们自己实现的,在API24提供了nestedScroll设置Tabs组件与其父组件的嵌套滚动模式。如果考虑低版本就需要全部自定义了。

六、总结

通过自定义TopTabBar与系统Tabs组合,我们实现了功能完整、交互顺滑的多级 Tab 联动。适合资讯、社交、电商等需要复杂导航的应用场景。完整源码可直接参考本文代码块,按需引入即可。

希望这份经验能帮助大家少走弯路,如有疑问欢迎交流!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询