鸿蒙 ArkUI 进阶:Tabs 嵌套滚动那个磨人的小妖精,鸿蒙 6.1 终于给治了

鸿蒙 ArkUI 进阶:Tabs 嵌套滚动那个磨人的小妖精,鸿蒙 6.1 终于给治了

鸿蒙 ArkUI 进阶:Tabs 嵌套滚动那个磨人的小妖精,鸿蒙 6.1 终于给治了

写在前面

如果你写鸿蒙 ArkUI 写过三个月以上,大概率遇到过这个让人血压飙升的场景:

我外层一个Scroll,里面塞个Tabs,Tabs里再放List。结果往下滑的时候,要么外层先滑走把 Tab 标题挤没,要么内层List死活不滚、整个页面僵在那儿。

这种「子父容器谁该滚」的争夺战,前端圈叫滚动冲突,鸿蒙圈以前没正式名字,反正开发者群里每隔三天就有人问一遍。

好消息是:鸿蒙 6.1(API 23/24 起)给Tabs加了nestedScroll属性,支持SELF_FIRST/SELF_ONLY/PARENT_FIRST等模式,滚动联动的控制权终于交回开发者手里。

本文就用一个真机可跑的 demo,把这个特性从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接。

适合人群:写过 ArkUI、被嵌套滚动坑过的同学。
不适合人群:还在学@State的新手——出门左转看我的入门篇。


一、先讲清楚:嵌套滚动到底在争什么

先看一个最典型的「信息流 + Tab」页面结构:

Scroll (外层,可滚) └─ Banner (头图,占位 220vp) └─ Tabs (内层,自己也能滚) └─ TabContent └─ List (列表,内容很长)

用户手指往下拖,问题来了:

  1. 手势应该给谁?Scroll接住,还是Tabs接住?
  2. 滚到边界后呢?Tabs滚到底了,继续往下的手势是交给Scroll,还是直接吞掉?
  3. 回滚时呢?从底部往上拖,是Scroll先回到 Banner 顶部,还是Tabs先滚回第一项?

在鸿蒙 6.1 之前,Tabs默认是SELF_ONLY(只顾自己滚,不管外层),开发者只能用Scroll+ListnestedScroll拼凑,Tab 标题经常被挤飞,体验很糟。

鸿蒙 6.1 给Tabs加了nestedScroll属性,枚举值和List一致:

模式行为适用场景
SELF_ONLYTabs 自己滚,不与父联动Tab 内容固定高度
SELF_FIRSTTabs 先滚,到边界后父滚信息流页面最常用
PARENT_FIRST父先滚,到边界后 Tabs 滚Tab 标题要常驻顶部

记住这张表,90% 的嵌套滚动场景都能秒选。


二、动手:一个「Banner + Tabs + List」的完整 demo

2.1 数据模型

先定义一个最简单的文章列表项:

classArticleItem{title:string=''summary:string=''ArticleItem(){}set(title:string,summary:string):ArticleItem{this.title=titlethis.summary=summaryreturnthis}}

这里用class+ 工厂方法,不用interface,是因为ForEach在给子组件传引用时,class实例的属性改动能触发@State重绘,interface字面量做不到这一点。这是新手最容易踩的坑之一。

2.2 文章卡片 Builder

@Builder把卡片抽出来,比每个ListItem里写一堆属性干净得多:

@BuilderfunctionArticleCard(item:ArticleItem){Row({space:12}){Column({space:4}){Text(item.title).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#222')Text(item.summary).fontSize(13).fontColor('#888').maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis})}.alignItems(HorizontalAlign.Start).layoutWeight(1)Text('›').fontSize(22).fontColor('#bbb')}.width('100%').padding(14).backgroundColor('#fff').borderRadius(10).margin({bottom:8})}

两个细节值得说:

  • textOverflow({ overflow: TextOverflow.Ellipsis })配合maxLines(2),长摘要自动加省略号。不写maxLines这行没效果。
  • Text('›')用的是中文输入法里的全角箭头,比画一个>优雅。

2.3 主页面:外层 Scroll + 内层 Tabs

完整结构如下:

@Entry@Componentstruct Index{@StatecurrentTab:number=0@StatenestedMode:number=0// 0: SELF_FIRST, 1: SELF_ONLY@StatearticleList:ArticleItem[]=[]aboutToAppear():void{constlist:ArticleItem[]=[]for(leti=1;i<=20;i++){constitem=newArticleItem()item.set(`${i}篇文章标题`,`这是文章摘要内容,用于演示列表在 Tabs 内部的滚动行为。当前条目编号${i}`)list.push(item)}this.articleList=list}@BuilderTabContentPage(idx:number,label:string){Column(){Text(`当前 Tab:${label}(共 20 条)`).width('100%').padding(12).fontSize(13).fontColor('#888')List({space:8}){ForEach(this.articleList,(item:ArticleItem,index:number)=>{ListItem(){ArticleCard({item:item})}},(item:ArticleItem,index:number)=>`${idx}-${index}`)}.width('100%').layoutWeight(1).padding({left:12,right:12}).scrollBar(BarState.Off)}.width('100%').height('100%').backgroundColor('#F5F6F8')}build(){Column(){// 顶部:模式切换按钮Row({space:10}){Text('Tabs 嵌套滚动').fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1)Button(this.nestedMode===0?'模式: SELF_FIRST':'模式: SELF_ONLY').fontSize(12).backgroundColor(this.nestedMode===0?'#007DFF':'#999').fontColor('#fff').onClick(()=>{this.nestedMode=this.nestedMode===0?1:0})}.width('100%').padding({left:14,right:14,top:14,bottom:8})// 外层 Scroll 包裹 Tabs,演示嵌套滚动Scroll(){Column(){// 头部 Banner,占位 220vp,用于演示"滚到边界再传给父"Stack({alignContent:Alignment.Center}){Text('下拉可见 Banner\n滚到边界后父容器接管').fontColor('#fff').fontSize(13).textAlign(TextAlign.Center)}.width('100%').height(220).backgroundColor('#007DFF').borderRadius({bottomLeft:18,bottomRight:18})Tabs({barPosition:BarPosition.Start,index:this.currentTab}){TabContent(){this.TabContentPage(0,'推荐')}.tabBar('推荐')TabContent(){this.TabContentPage(1,'关注')}.tabBar('关注')TabContent(){this.TabContentPage(2,'热点')}.tabBar('热点')}.width('100%').height(460).onChange((i:number)=>{this.currentTab=i})// 关键:Tabs 嵌套滚动模式.nestedScroll({scrollForward:this.nestedMode===0?NestedScrollMode.SELF_FIRST:NestedScrollMode.SELF_ONLY,scrollBackward:this.nestedMode===0?NestedScrollMode.SELF_FIRST:NestedScrollMode.SELF_ONLY}).scrollable(true)}.width('100%')}.width('100%').layoutWeight(1).scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)}.width('100%').height('100%')}}

2.4 这段代码的关键点

nestedScroll是动态绑定的

.nestedScroll({scrollForward:this.nestedMode===0?NestedScrollMode.SELF_FIRST:NestedScrollMode.SELF_ONLY,scrollBackward:this.nestedMode===0?NestedScrollMode.SELF_FIRST:NestedScrollMode.SELF_ONLY})

nestedMode@State,点按钮就能在「SELF_FIRST」和「SELF_ONLY」之间切换。这是一个非常实用的调试手段——同一个 demo 跑两种模式,差异一眼可见。

scrollForwardscrollBackward分开配置

很多教程只告诉你nestedScroll能配模式,但没说前滚和回滚可以配不同模式。比如:

  • 前滚用SELF_FIRST(Tab 先滚,到底了再滚 Banner)
  • 回滚用PARENT_FIRST(先回到 Banner 顶部,再让 Tab 回滚)

这种「不对称」配置在很多内容型 App 里是用户实际期待的体验。

ForEach的 keyGenerator 不能省

ForEach(this.articleList,(item:ArticleItem,index:number)=>{ListItem(){ArticleCard({item:item})}},(item:ArticleItem,index:number)=>`${idx}-${index}`)

第三个参数keyGenerator返回一个全局唯一的字符串(这里用Tab序号-条目序号)。省掉它,ArkUI 会用index当 key,列表一变动状态就可能错乱。这是 ArkUI 和 Reactmap最大的差异,务必养成手写 key 的习惯。

edgeEffect(EdgeEffect.Spring)加在外层 Scroll

外层 Scroll 滚到顶/底时,会有一个弹性回弹效果。这是用户体验的「最后一公里」,加了之后整个滑动手感立刻上一个档次。


三、真机实拍:SELF_FIRST vs SELF_ONLY 的差异

光说不练假把式。我把这个 demo 装到真机上跑(OpenHarmony 6.1.1.125,API 24),下面三张都是真机实拍,没有任何 P 图。

初始状态:Banner 露出 220vp,Tabs 默认在「推荐」Tab,列表从第 1 篇开始:

在 Tab 内容里继续下滑,滚到 Tab 边界后,外层 Scroll 接管手势、Banner 开始上移:

切到「关注」Tab,Tab 标题栏常驻、内容区独立滚动:

重点看第二张图:Banner 已经被滚上去了一部分,这说明外层 Scroll 在 Tab 滚到边界后接管了手势。如果你把模式切成 SELF_ONLY,Banner 就会被永久钉在顶部,Tab 内容再怎么滚也滚不到底。


四、配套能力:动态布局容器(同一个版本的新特性)

鸿蒙 6.1 这次 ArkUI 升级还有个常被忽略但很关键的特性:动态布局容器

简单说就是:同一个容器,运行时可以动态切换不同的布局算法,而不改变子组件的状态

听起来抽象,看个例子就懂。假设你有一个商品列表,竖屏时是上下排列(Column),横屏时是左右排列(Row)。传统做法是用if (isLandscape) { Row() } else { Column() },但if/else在 ArkUI 里会销毁并重建组件,子组件的@State全丢光。

动态布局容器的作用就是:切换布局算法时,只换布局、不换组件实例,子组件状态完整保留。

完整 API 用法在鸿蒙官方文档「动态布局容器」一节,这里不展开,因为对于大多数业务场景,Tabs.nestedScroll+ 状态管理已经够用。


五、常见坑(我替你踩过了)

症状解法
nestedScroll写在Scroll编译过,运行滚动还是乱的nestedScroll写在内层可滚动容器上(Tabs/List/Grid),不是外层
ForEach没 key列表插入/删除后状态错乱必传keyGenerator,返回唯一字符串
Tabs高度没设内容只显示一行,Tabs 不滚Tabsheight或让它layoutWeight(1)撑满
scrollBar不关两个容器都画滚动条,丑到不忍直视至少内层scrollBar(BarState.Off)
edgeEffect不设滚到边界硬邦邦一声闷响外层EdgeEffect.Spring,手感立刻好一档

六、完整代码仓库

本文所有代码都已托管到AtomGit,欢迎 clone、提 issue、点 star:

🔗仓库地址:https://atomgit.com/JaneConan/arkui-demo

仓库包含:

  • 完整的「Banner + Tabs + List」嵌套滚动 demo 工程
  • Index.ets主页面(含模式切换按钮、动态nestedScroll绑定)
  • ArticleItem数据模型 +ArticleCardBuilder
  • 可直接用 DevEco Studio 打开运行,真机/模拟器均可

七、下一步该学什么?

跑通这个 demo 之后,你的 ArkUI 滚动联动就入门了。建议按这个顺序往下:

  1. ListnestedScroll:和 Tabs 一样的接口,但更多用于「下拉刷新 + 内层列表」场景
  2. Swiper嵌套:横向 Swiper 里塞竖向 List,手势方向不同,坑点也不一样
  3. Navigation路由:鸿蒙的路由方案,和 Tabs 配合能做出很复杂的多级页面
  4. @Watch+@State:动态布局容器切换时,用@Watch监听布局变化、做副作用

写在最后

嵌套滚动这个东西,本质是手势事件的归属权之争。鸿蒙 6.1 把这个权利交给开发者,看似多了一个属性要学,实际上是让整个滑动体验从「系统说了算」变成「你说了算」。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手切一下 SELF_FIRST 和 SELF_ONLY 的差异。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-demo
协议:Apache-2.0,随便用,别告我