18 Scroll 滚动容器与滑动优化:从单页到多段滚动

前言

在这里插入图片描述

图:18 Scroll 滚动容器与滑动优化:从单页到多段滚动 运行效果截图(HarmonyOS NEXT)

在移动应用开发中,滚动容器是页面内容过多时的标准解决方案。ArkUI 的 Scroll 组件提供了功能完善的滚动能力——支持垂直/水平滚动、滚动条控制、边缘回弹、嵌套滚动协调等。

本文以"鹿鹿·笔迹心理分析"项目中两个核心页面——HomePage 首页(多段水平+垂直混合滚动)和 ReportDetailPage 报告详情页(长内容垂直滚动)——为例,深入解析 Scroll 容器的使用场景和优化策略。

鸿蒙官方·Scroll 组件:developer.huawei.com
项目源码仓库:harmony-app GitHub

Scroll 嵌套滚动容器结构图

图:垂直 Scroll 嵌套水平 Scroll——鸿蒙滚动容器的典型模式,两个滚动方向天然兼容

垂直滑动

水平区域水平滑动

EdgeEffect.Spring

EdgeEffect.None

未到边界

用户触摸事件

滚动方向判断

外层 Scroll 垂直响应

内层 Scroll 水平响应

Column 内容上下移动

Row 内容左右移动

到达边界?

弹簧回弹效果

立即停止

继续滚动

onScrollStop 触发

一、Scroll 基础

1.1 Scroll 的核心属性

Scroll 是一个容器组件,使其内容变得可滚动

属性类型默认值说明
scrollableScrollDirectionVertical滚动方向
scrollBarBarStateAuto滚动条显示策略
edgeEffectEdgeEffectEdgeEffect.Spring边缘效果
enableScrollbooleantrue是否启用滚动
// 垂直滚动(最常用)
Scroll() {
  Column() {
    // ... 长内容
  }
}

// 水平滚动
Scroll(.horizontal) {
  Row() {
    // ... 水平排列的内容
  }
}

// 关闭边缘回弹
Scroll() {
  // ...
}
.edgeEffect(EdgeEffect.None)

1.2 项目中的 Scroll 使用概览

页面滚动方向滚动内容Scroll 数量特性
[HomePage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/HomePage.ets)垂直 + 水平全页 + 最近报告列表2 个(嵌套)嵌套滚动
[ReportDetailPage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/ReportDetailPage.ets)垂直4 张卡片 + 按钮1 个长内容滚动
[DuetReportPage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/DuetReportPage.ets)垂直全页报告内容1 个嵌套 Canvas
[WritePage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/WritePage.ets)垂直手写板 + 工具栏1 个适老化

二、垂直滚动:长页面的标准方案

2.1 ReportDetailPage 的 Scroll 结构

[ReportDetailPage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/ReportDetailPage.ets) 使用单层 Scroll + Column 实现完整的报告详情滚动:

build() {
  Scroll() {                       // ① 外层可滚动容器
    Column() {                     // ② 内部垂直排列
      // 状态栏
      Row() { Text(时间); Blank(); Row(📶 100%) }
      // 导航栏
      Row() { Text('←'); Blank(); Text('笔 迹 解 读'); Blank(); Text('⋯') }

      // ③ 来源卡片
      Row() { Stack() + Column() + Text('✎') }
        .opacity(this.srcOpacity)
        .translate({ y: this.srcOffset })

      // ④ 情绪卡片
      Column() { Row() + Stack() + Text() }
        .opacity(this.moodOpacity)
        .translate({ y: this.moodOffset })

      // ⑤ 雷达卡片
      Column() { Text() + HandwritingRadar + Flex(tags) }
        .opacity(this.radarOpacity)
        .translate({ y: this.radarOffset })

      // ⑥ AI 解读卡片
      Column() { Text() + Text(summary) + Row(buttons) }
        .opacity(this.aiOpacity)
        .translate({ y: this.aiOffset })
    }
    .width('100%')
    .padding({ left: AppSpacing.L, right: AppSpacing.L }) // 左右留白
    .backgroundColor(AppColors.BG)
  }
  .width('100%')
  .height('100%')
}

页面高度计算: Scroll 的高度由父容器决定(height: '100%'),内部 Column 的高度由所有子组件的总和决定。当 Column 高度 > Scroll 高度时,Scroll 自动启用滚动。

2.2 Scroll 与入场动画的配合

Scroll 内部的入场动画状态需要与滚动行为兼容:

// 每张卡片独立控制入场动画状态
@State srcOpacity: number = 0      // 来源卡片透明度
@State srcOffset: number = 20      // 来源卡片 Y 偏移
@State moodOpacity: number = 0     // 情绪卡片透明度
@State moodOffset: number = 20     // 情绪卡片 Y 偏移
@State radarOpacity: number = 0    // 雷达卡片透明度
@State radarOffset: number = 20    // 雷达卡片 Y 偏移
@State aiOpacity: number = 0       // AI 卡片透明度
@State aiOffset: number = 20       // AI 卡片 Y 偏移

// 入场动画链(5 段串行)
private startEntranceAnimation(): void {
  animateTo({}, () => { this.srcOpacity = 1; this.srcOffset = 0 })
  animateTo({}, () => { this.moodOpacity = 1; this.moodOffset = 0 })
  animateTo({}, () => { this.radarOpacity = 1; this.radarOffset = 0 })
  animateTo({}, () => { this.aiOpacity = 1; this.aiOffset = 0 })
  animateTo({}, () => { this.btnOpacity = 1 })
}

注意:Scroll 内部元素的 translate 动画对滚动行为没有影响——滚动时 Scroll 滚动的是内部的整个 Column,而非单个卡片。

三、水平滚动:横向滚动的实现

3.1 HomePage 的嵌套滚动

[HomePage.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/HomePage.ets) 实现了外层垂直滚动 + 内层水平滚动的嵌套模式:

build() {
  Scroll() {                       // ① 外层:垂直滚动
    Column() {
      // 欢迎区 + Hero + 子入口(垂直排列)
      Column() { ... }

      // ② 内层:水平滚动(最近报告)
      Column() {
        Row() {
          Text('最近分析')
          Blank()
          Text('查看全部 →').fontSize(12).fontColor(AppColors.PRIMARY)
        }
        .width('100%')

        // 水平 Scroll
        Scroll(.horizontal) {
          Row({ space: 12 }) {
            ForEach(this.recentItems, (item: RecentItem, index: number) => {
              this.RecentSwipeCard(item, index)
            }, (item: RecentItem) => item.reportId.toString())
          }
          .padding({ left: AppSpacing.L, right: AppSpacing.L })
        }
        .scrollBar(BarState.Off)     // 隐藏滚动条
        .scrollable(ScrollDirection.Horizontal)
        .edgeEffect(EdgeEffect.Spring)
      }

      // 灵感卡片
      Column() { ... }
    }
  }
}

3.2 水平 Scroll 的关键参数

Scroll(.horizontal)                    // 设置水平方向
  .scrollBar(BarState.Off)             // 隐藏滚动条(UI 更简洁)
  .scrollable(ScrollDirection.Horizontal) // 明确滚动方向
  .edgeEffect(EdgeEffect.Spring)       // 边缘回弹(默认)

水平 Scroll 的布局约束:

  1. Scroll 宽度固定(父容器宽度)
  2. 内部 Row 宽度 = 所有子卡片宽度总和 + spacing
  3. Row 宽度超出 Scroll 宽度时,Scroll 启用水平滚动
  4. 每个卡片宽度固定(如 120vp),一行放不下自动出现滚动

提示:水平 Scroll + Row 是最常见的横向滑动实现。也可以使用 Swiper 组件实现整页横向翻页。

四、边缘回弹与滚动条

4.1 edgeEffect 边缘效果

edgeEffect 控制用户滚动到内容边界时的视觉效果:

模式效果适用场景
EdgeEffect.Spring弹簧回弹效果(默认)大多数滚动列表
EdgeEffect.None无效果,到达边界立即停止精确分页内容
EdgeEffect.Fade边缘渐隐效果图片浏览、画廊
// 默认弹簧回弹
Scroll() { ... }
.edgeEffect(EdgeEffect.Spring)   // 滚到底时"弹一下"

// 关闭回弹
Scroll() { ... }
.edgeEffect(EdgeEffect.None)     // 到边界立即停止

4.2 scrollBar 滚动条控制

// 自动显示(默认)— 滚动时显示,闲置后隐藏
Scroll() { ... }
.scrollBar(BarState.Auto)

// 始终隐藏
Scroll() { ... }
.scrollBar(BarState.Off)

// 始终显示
Scroll() { ... }
.scrollBar(BarState.On)

项目中基于 UI 美观的权衡:

页面scrollBar理由
HomePage 垂直默认 Auto需要暗示用户可滚动
HomePage 水平Off水平滚动条遮挡内容
ReportDetailPage默认 Auto标准的页面滚动
DuetReportPage默认 Auto长报告内容

五、嵌套滚动协调

5.1 母子 Scroll 的嵌套问题

当垂直 Scroll 内嵌套水平 Scroll 时,触摸事件分配逻辑如下:

  1. 用户垂直滑动 → 外层 Scroll 响应 → 页面上下滚动
  2. 用户在水平 Scroll 区域垂直滑动 → 仍然由外层 Scroll 响应
  3. 用户在水平 Scroll 区域水平滑动 → 内层 Scroll 响应 → 横向滑动
// 嵌套 Scroll 的标准模式
Scroll() {                         // 母 Scroll(垂直)
  Column() {
    // 固定的普通内容
    Text('固定的内容')

    // 内层水平滚动
    Scroll(.horizontal) {          // 子 Scroll(水平)
      Row() {
        ForEach(this.items, (item) => {
          Card(item)
        })
      }
    }

    // 更多内容
    Text('底部内容')
  }
}

5.2 嵌套滚动的注意事项

问题一:内层 Scroll 高度不固定

// ❌ 内层 Scroll 未指定高度
Scroll(.horizontal) {
  Row() {
    // 卡片列表
  }
}
// 此时内层 Scroll 高度为 0,不会显示

// ✅ 内层 Scroll 需要指定高度
Scroll(.horizontal) {
  Row() {
    ForEach(this.recentItems, (item) => {
      this.RecentSwipeCard(item, index)
    })
  }
  .height(180)   // 卡片区域固定高度
}

问题二:触摸冲突

// 如果子 Scroll 的滚动方向与父 Scroll 一致,会出现冲突
Scroll() {                          // 垂直
  Column() {
    Scroll() {                      // 也垂直 — 冲突!
      Column() {
        // ...
      }
    }
  }
}
// ✅ 解决方案:内层 Scroll 使用不同方向(如水平)
// 或使用 scrollable 限制子 Scroll 的方向

5.3 使用 NestedScroll 协调

对于更复杂的嵌套场景,可以使用 nestedScroll 属性:

Scroll() {
  // ...
}
.nestedScroll({
  scrollForward: NestedScrollMode.PARENT_FIRST,    // 父容器优先
  scrollBackward: NestedScrollMode.SELF_FIRST       // 自身优先
})

六、Scroll 的性能优化

6.1 避免过度嵌套

Scroll 内部的 UI 组件应该尽量扁平化:

// ❌ 过度嵌套:每层 Column 都增加了布局计算量
Scroll() {
  Column() {
    Row() {
      Column() {
        Text('内容')
      }
    }
  }
}

// ✅ 扁平化
Scroll() {
  Column() {
    Text('内容')
  }
}

6.2 使用 column 替代多层嵌套

// ✅ 推荐:单层 Column + 多个 Child
Scroll() {
  Column() {
    Header()
    HeroSection()
    RecentSection()    // 内部可以再嵌套水平 Scroll
    InspireSection()
    TabBar()
  }
}

6.3 合理使用懒加载

对于超长列表(如 ArchivePage 的历史档案),结合 LazyForEach 可以大幅提升首屏加载性能:

Scroll() {
  Column() {
    LazyForEach(new ArchiveDataSource(items), (item: ArchiveItem) => {
      ArchiveCard({ item })
    }, (item: ArchiveItem) => item.id.toString())
  }
}

七、Scroll 与底部安全区域

7.1 安全区域适配

在鸿蒙手机上,底部导航栏区域需要额外留白:

Scroll() {
  Column() {
    // ... 所有内容
  }
  .padding({ bottom: 24 })  // 底部安全区域留白
}

7.2 底部内容可见性

如果底部有操作按钮,需要确保其不被 Scroll 裁剪:

Scroll() {
  Column() {
    // ... 卡片内容

    // 底部操作按钮
    Row() {
      Button('生成更多分析').width('45%')
      Button('保存报告').width('45%')
    }
    .padding({ top: 16, bottom: 32 })  // 底部充足留白
  }
}

八、Scroll 高级用法

8.1 滚动到指定位置

通过 scrollTo 方法可以编程控制滚动位置:

// 声明 ScrollController
private scroller: Scroller = new Scroller()

build() {
  Scroll(this.scroller) {        // 传入 controller
    Column() {
      // ...
    }
  }
}

// 滚动到顶部
private scrollToTop(): void {
  this.scroller.scrollTo({ xOffset: 0, yOffset: 0, animation: { duration: 300 } })
}

// 滚动到指定子组件
private scrollToCard(index: number): void {
  this.scroller.scrollTo({ xOffset: 0, yOffset: index * cardHeight, animation: { duration: 300 } })
}

8.2 滚动事件监听

Scroll() {
  Column() { ... }
}
.onScroll((x: number, y: number) => {
  hilog.info(0x0000, 'Scroll', '滚动位置: (%{public}d, %{public}d)', x, y)
})
.onScrollStart(() => {
  hilog.info(0x0000, 'Scroll', '开始滚动')
})
.onScrollStop(() => {
  hilog.info(0x0000, 'Scroll', '停止滚动')
})

8.3 滚动方向判断

@State lastScrollY: number = 0
@State isScrollingUp: boolean = false

.onScroll((_: number, y: number) => {
  this.isScrollingUp = y < this.lastScrollY
  this.lastScrollY = y
  if (this.isScrollingUp) {
    // 上滑 — 显示导航栏
  } else {
    // 下滑 — 隐藏导航栏
  }
})

总结

本文从 Scroll 组件的基础属性出发,通过项目中两个核心页面的实战代码,全面解析了 ArkUI 滚动容器的使用方法:

  1. 垂直滚动:ReportDetailPage 的长内容页面,Scroll + Column 标准模式
  2. 水平滚动:HomePage 的最近报告横向滑动,Scroll + Row + ForEach
  3. 嵌套滚动:垂直 Scroll 内嵌水平 Scroll,天然兼容互不干扰
  4. 边缘回弹:默认 Spring 模式,到达边界回弹提示用户
  5. 滚动条控制:水平 Scroll 通常隐藏滚动条,垂直 Scroll 保持 Auto

Scroll 是实现流畅用户体验的基础组件,合理使用可以大幅提升长内容页面的可浏览性。

下一篇文章将进入第四模块:自定义组件与组件化设计,从 HmTitleBar 开始讲解组件复用的最佳实践。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:

更多推荐