鸿蒙 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_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 跑两种模式,差异一眼可见。

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 和 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 篇开始:

Tabs 嵌套滚动初始状态:Banner + 推荐 Tab

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

Tabs 嵌套滚动滚到边界后 Banner 上移

切到「关注」Tab,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 数据模型 + ArticleCard Builder
  • 可直接用 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,随便用,别告我

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐