鸿蒙 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 数据模型
先定义一个最简单的文章列表项:
class ArticleItem {
title: string = ''
summary: string = ''
ArticleItem() {
}
set(title: string, summary: string): ArticleItem {
this.title = title
this.summary = summary
return this
}
}
这里用
class+ 工厂方法,不用interface,是因为ForEach在给子组件传引用时,class实例的属性改动能触发@State重绘,interface字面量做不到这一点。这是新手最容易踩的坑之一。
2.2 文章卡片 Builder
用 @Builder 把卡片抽出来,比每个 ListItem 里写一堆属性干净得多:
@Builder
function ArticleCard(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
@Component
struct Index {
@State currentTab: number = 0
@State nestedMode: number = 0 // 0: SELF_FIRST, 1: SELF_ONLY
@State articleList: ArticleItem[] = []
aboutToAppear(): void {
const list: ArticleItem[] = []
for (let i = 1; i <= 20; i++) {
const item = new ArticleItem()
item.set(`第 ${i} 篇文章标题`, `这是文章摘要内容,用于演示列表在 Tabs 内部的滚动行为。当前条目编号 ${i}。`)
list.push(item)
}
this.articleList = list
}
@Builder
TabContentPage(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 和 React map 最大的差异,务必养成手写 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,随便用,别告我
更多推荐



所有评论(0)