鸿蒙 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 (列表,内容很长)用户手指往下拖,问题来了:
- 手势应该给谁?
Scroll接住,还是Tabs接住? - 滚到边界后呢?
Tabs滚到底了,继续往下的手势是交给Scroll,还是直接吞掉? - 回滚时呢?从底部往上拖,是
Scroll先回到 Banner 顶部,还是Tabs先滚回第一项?
在鸿蒙 6.1 之前,Tabs默认是SELF_ONLY(只顾自己滚,不管外层),开发者只能用Scroll+List的nestedScroll拼凑,Tab 标题经常被挤飞,体验很糟。
鸿蒙 6.1 给Tabs加了nestedScroll属性,枚举值和List一致:
| 模式 | 行为 | 适用场景 |
|---|---|---|
SELF_ONLY | Tabs 自己滚,不与父联动 | Tab 内容固定高度 |
SELF_FIRST | Tabs 先滚,到边界后父滚 | 信息流页面最常用 |
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 跑两种模式,差异一眼可见。
②scrollForward和scrollBackward分开配置
很多教程只告诉你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 不滚 | 给Tabs设height或让它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 滚动联动就入门了。建议按这个顺序往下:
List的nestedScroll:和 Tabs 一样的接口,但更多用于「下拉刷新 + 内层列表」场景Swiper嵌套:横向 Swiper 里塞竖向 List,手势方向不同,坑点也不一样Navigation路由:鸿蒙的路由方案,和 Tabs 配合能做出很复杂的多级页面@Watch+@State:动态布局容器切换时,用@Watch监听布局变化、做副作用
写在最后
嵌套滚动这个东西,本质是手势事件的归属权之争。鸿蒙 6.1 把这个权利交给开发者,看似多了一个属性要学,实际上是让整个滑动体验从「系统说了算」变成「你说了算」。
代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手切一下 SELF_FIRST 和 SELF_ONLY 的差异。
跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀
作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-demo
协议:Apache-2.0,随便用,别告我