HarmonyOS Next 深入解析:Scroll 组件与 onScroll 滚动监听实战

项目演示

前言

在 HarmonyOS 应用开发中,滚动容器是构建内容展示界面最基础、最常用的组件之一。无论是信息流、商品列表、文章详情页,还是复杂的多模块聚合页面,几乎都离不开滚动容器的支撑。HarmonyOS Next 提供了丰富的滚动能力,其中 Scroll 组件 作为最灵活的滚动容器,配合 onScroll 系列事件回调,可以实现吸顶、加载更多、视差滚动、导航联动等一系列高级交互效果。

本文将系统介绍 Scroll 组件的 24 个核心 API,通过完整的代码示例和原理分析,帮助读者全面掌握 Scroll + onScroll 的组合用法。无论您是鸿蒙开发初学者,还是有一定经验的开发者,都能从中收获实用的开发技巧。


一、Scroll 组件概述

1.1 什么是 Scroll 组件

Scroll 是 HarmonyOS ArkUI 提供的可滚动容器组件,用于在有限的屏幕空间内展示超出可视区域的内容。与 List、Grid 等专门用于列表展示的组件不同,Scroll 组件的设计理念是"自由容器"------它可以包裹任意类型的子组件,不限于列表项。

Scroll vs List 对比:

特性 Scroll List
子组件类型 任意组件 仅 ListItem
性能优化 无懒加载 LazyForEach 懒加载
滚动事件 onScroll 系列 onScroll + onReachBottom
分组能力 需自行实现 ListItemGroup 原生支持
适用场景 页面级容器、复杂布局 长列表数据展示
滚动方向 垂直 + 水平 垂直或水平(二选一)

1.2 Scroll 组件的基本语法

typescript 复制代码
// 最基本的 Scroll 使用方式
Scroll() {
  Column() {
    // 子组件内容
  }
}
.width('100%')
.height('100%')

Scroll 组件接受一个构建函数,内部放置需要滚动的内容。通常与 Column、Row 等布局组件配合使用,形成"滚动容器 + 内容布局"的组合结构。

1.3 Scroll 组件的层级位置

在 ArkUI 的组件树中,Scroll 属于容器组件,可以嵌套其他组件,也可以被嵌套在 Stack、Column、Row 等布局组件中。理解组件层级关系对于实现复杂的滚动交互至关重要。


二、核心属性 API 详解(一)

API 1:Scroll() ------ 构造函数

功能说明: 创建一个可滚动的容器实例。

语法格式:

typescript 复制代码
Scroll(scroller?: Scroller)

参数说明:

  • scroller(可选):滚动控制器实例,用于在代码中控制滚动行为

基础示例:

typescript 复制代码
@Entry
@Component
struct ScrollBasicDemo {
  build() {
    Scroll() {
      Column({ space: 12 }) {
        ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (item: number) => {
          Text('第 ' + item + ' 项')
            .fontSize(20)
            .padding(20)
            .backgroundColor(Color.White)
            .borderRadius(12)
            .width('100%')
        }, (item: number) => item.toString())
      }
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

API 2:.scrollable() ------ 滚动方向控制

功能说明: 设置滚动容器的可滚动方向。

语法格式:

typescript 复制代码
.scrollable(direction: ScrollDirection)

枚举值说明:

枚举值 说明 适用场景
ScrollDirection.Vertical 垂直滚动(默认) 商品列表、文章页
ScrollDirection.Horizontal 水平滚动 图片轮播、分类标签
ScrollDirection.All 双向滚动 地图、大尺寸图片
ScrollDirection.None 不可滚动 禁用滚动交互

垂直滚动示例:

typescript 复制代码
Scroll() {
  Column() {
    // 长内容
  }
}
.scrollable(ScrollDirection.Vertical)

水平滚动示例:

typescript 复制代码
Scroll() {
  Row({ space: 12 }) {
    ForEach([1, 2, 3, 4, 5], (item: number) => {
      Text('卡片 ' + item)
        .fontSize(16)
        .width(120)
        .height(80)
        .backgroundColor(Color.White)
        .borderRadius(12)
    }, (item: number) => item.toString())
  }
  .padding(16)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off) // 水平滚动通常隐藏滚动条

API 3:.scrollBar() ------ 滚动条显示控制

功能说明: 控制滚动条的显示状态。

语法格式:

typescript 复制代码
.scrollBar(barState: BarState)

枚举值说明:

枚举值 说明
BarState.On 始终显示滚动条
BarState.Off 始终隐藏滚动条
BarState.Auto 自动显示(滚动时显示,停止后隐藏)

使用建议:

  • 垂直滚动列表:使用 BarState.Auto 提供视觉引导
  • 水平滚动区域:通常使用 BarState.Off 保持界面简洁
  • 自定义滚动交互:使用 BarState.Off 并自行实现指示器
typescript 复制代码
// 信息流场景:自动显示滚动条
Scroll() {
  // 内容
}
.scrollBar(BarState.Auto)

// 分类标签场景:隐藏滚动条
Scroll() {
  Row() {
    // 标签
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

API 4:.scrollBarColor() ------ 滚动条颜色

功能说明: 自定义滚动条的颜色。

语法格式:

typescript 复制代码
.scrollBarColor(color: ResourceColor)

参数说明: 支持颜色值、十六进制色值、资源引用等多种格式。

typescript 复制代码
Scroll() {
  // 内容
}
.scrollBarColor('#FF4444') // 红色滚动条
// 或使用资源引用
// .scrollBarColor($r('app.color.primary_color'))

API 5:.scrollBarWidth() ------ 滚动条宽度

功能说明: 自定义滚动条的宽度。

语法格式:

typescript 复制代码
.scrollBarWidth(width: Length)

参数说明: 支持数字(vp)、字符串('2vp')、百分比等格式。

typescript 复制代码
Scroll() {
  // 内容
}
.scrollBarWidth(4) // 4vp 宽度

API 6:.scrollBarMode() ------ 滚动条显示模式

功能说明: 设置滚动条的显示模式。

语法格式:

typescript 复制代码
.scrollBarMode(mode: ScrollBarMode)

枚举值说明:

枚举值 说明
ScrollBarMode.Auto 自动模式(默认)
ScrollBarMode.Simple 简洁模式
ScrollBarMode.On 始终显示
ScrollBarMode.Off 始终隐藏

API 7:.edgeEffect() ------ 边缘效果

功能说明: 设置滚动到边缘时的视觉反馈效果。

语法格式:

typescript 复制代码
.edgeEffect(effect: EdgeEffect)

枚举值说明:

枚举值 效果描述 视觉表现
EdgeEffect.Spring 弹簧效果 拉到边缘后有弹性回弹动画
EdgeEffect.Fade 渐隐效果 边缘内容逐渐消失
EdgeEffect.None 无效果 硬边缘,无反馈

三种效果对比:

typescript 复制代码
// 弹簧效果:体验最好,推荐使用
Scroll() {
  // 内容
}
.edgeEffect(EdgeEffect.Spring)

// 渐隐效果:适合图片浏览等场景
Scroll() {
  // 内容
}
.edgeEffect(EdgeEffect.Fade)

// 无效果:适合需要精确控制的场景
Scroll() {
  // 内容
}
.edgeEffect(EdgeEffect.None)

API 8:.enabled() ------ 是否可滚动

功能说明: 控制滚动容器是否响应用户的滚动手势。

语法格式:

typescript 复制代码
.enabled(enabled: boolean)

使用场景:

  • 在某些交互状态下临时禁用滚动
  • 实现嵌套滚动的互斥逻辑
  • 配合手势识别实现复杂交互
typescript 复制代码
@State isScrollEnabled: boolean = true

Scroll() {
  // 内容
}
.enabled(this.isScrollEnabled)

// 动态控制示例
Button('禁用滚动')
  .onClick(() => {
    this.isScrollEnabled = !this.isScrollEnabled
  })

三、核心事件 API 详解(二)

API 9:.onScroll() ------ 滚动过程回调

功能说明: 在滚动过程中持续触发的回调,是实现滚动监听的核心 API。

语法格式:

typescript 复制代码
.onScroll(callback: (offset: number) => void)

参数说明:

  • offset:当前滚动的偏移量
    • 垂直滚动时:正数表示向下滚动
    • 水平滚动时:正数表示向右滚动

重要特性:

  • 触发频率:接近帧率(60Hz/120Hz),性能敏感
  • 适用场景:吸顶判断、视差效果、进度指示器

基础使用示例:

typescript 复制代码
@State scrollOffset: number = 0

Scroll() {
  // 内容
}
.onScroll((offset: number) => {
  this.scrollOffset = offset
})

性能优化建议:

typescript 复制代码
// 避免在 onScroll 中执行耗时操作
.onScroll((offset: number) => {
  // ❌ 错误做法:频繁触发状态更新
  this.scrollOffset = offset
  this.updateSomeUI()
  this.requestNetwork()
  
  // ✅ 正确做法:使用节流或简单判断
  if (Math.abs(offset - this.lastOffset) > 10) {
    this.scrollOffset = offset
    this.lastOffset = offset
  }
})

API 10:.onScrollStart() ------ 滚动开始回调

功能说明: 在滚动开始时触发一次回调。

语法格式:

typescript 复制代码
.onScrollStart(callback: () => void)

触发时机:

  • 用户手指触摸并开始滑动时
  • 惯性滚动开始时(如 fling 动画)
  • 代码调用 scrollTo 触发滚动时

使用场景:

  • 标记滚动状态
  • 暂停某些动画效果
  • 记录埋点日志
typescript 复制代码
@State isScrolling: boolean = false

Scroll() {
  // 内容
}
.onScrollStart(() => {
  this.isScrolling = true
  console.info('Scroll started')
})
.onScrollStop(() => {
  this.isScrolling = false
  console.info('Scroll stopped')
})

API 11:.onScrollStop() ------ 滚动停止回调

功能说明: 在滚动完全停止后触发一次回调。

语法格式:

typescript 复制代码
.onScrollStop(callback: () => void)

触发时机:

  • 用户手指离开且内容停止滚动
  • 惯性滚动结束
  • 代码触发的滚动动画结束

使用场景:

  • 触发加载更多(推荐)
  • 保存滚动位置
  • 恢复动画效果
typescript 复制代码
@State hasMore: boolean = true
@State isLoading: boolean = false

Scroll() {
  Column() {
    // 商品列表
    this.buildProductList()
    
    // 加载更多提示
    this.buildLoadMoreIndicator()
  }
}
.onScrollStop(() => {
  this.checkAndLoadMore()
})

private checkAndLoadMore(): void {
  if (this.hasMore && !this.isLoading) {
    // 判断是否接近底部
    if (this.isNearBottom()) {
      this.loadMore()
    }
  }
}

API 12:.onScrollFrameBegin() ------ 每帧滚动回调

功能说明: 在每一帧滚动开始前触发,可用于拦截或修改滚动行为。

语法格式:

typescript 复制代码
.onScrollFrameBegin(callback: (offset: number, state: ScrollState) => { offsetRemain: number })

参数说明:

  • offset:本帧预计滚动的偏移量
  • state:当前滚动状态(Idle、Scroll、Fling)
  • 返回值:offsetRemain 表示实际允许的滚动偏移量

高级应用:自定义滚动物理效果

typescript 复制代码
Scroll() {
  // 内容
}
.onScrollFrameBegin((offset: number, state: ScrollState) => {
  // 实现自定义减速效果
  if (state === ScrollState.Fling) {
    return { offsetRemain: offset * 0.5 } // 减速 50%
  }
  return { offsetRemain: offset }
})

API 13:.onScrollSizeChange() ------ 滚动区域尺寸变化

功能说明: 当滚动容器的尺寸发生变化时触发。

语法格式:

typescript 复制代码
.onScrollSizeChange(callback: (oldValue: SizeOptions, newValue: SizeOptions) => void)

使用场景:

  • 屏幕旋转适配
  • 分屏模式切换
  • 动态调整布局
typescript 复制代码
Scroll() {
  // 内容
}
.onScrollSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => {
  console.info('Size changed from ' + JSON.stringify(oldValue) + 
               ' to ' + JSON.stringify(newValue))
  this.adaptLayout(newValue)
})

API 14:.onWillScroll() ------ 滚动将要发生

功能说明: 在滚动将要发生前触发,可用于前置处理。

语法格式:

typescript 复制代码
.onWillScroll(callback: () => void)

与 onScrollStart 的区别:

  • onWillScroll:滚动前一刻触发,适合做前置准备
  • onScrollStart:滚动真正开始时触发

四、Scroller 控制器 API 详解(三)

API 15:Scroller ------ 滚动控制器实例

功能说明: 创建一个滚动控制器,用于在代码中主动控制 Scroll 的滚动行为。

基本使用模式:

typescript 复制代码
// 1. 声明控制器
private scroller: Scroller = new Scroller()

// 2. 绑定到 Scroll 组件
Scroll(this.scroller) {
  // 内容
}

// 3. 在任意位置调用控制器方法
Button('滚动到底部')
  .onClick(() => {
    this.scroller.scrollTo({ xOffset: 0, yOffset: 99999 })
  })

API 16:scroller.scrollTo() ------ 滚动到指定位置

功能说明: 控制滚动容器滚动到指定的偏移位置。

语法格式:

typescript 复制代码
scroller.scrollTo(options: ScrollToOptions)

参数说明:

参数 类型 必填 说明
xOffset number 水平偏移量
yOffset number 垂直偏移量
animation boolean 是否启用动画,默认 true

常用场景:

typescript 复制代码
// 滚动到顶部
this.scroller.scrollTo({ xOffset: 0, yOffset: 0 })

// 滚动到底部(使用足够大的值)
this.scroller.scrollTo({ xOffset: 0, yOffset: Number.MAX_SAFE_INTEGER })

// 无动画跳转
this.scroller.scrollTo({ xOffset: 0, yOffset: 500, animation: false })

// 水平滚动到指定位置
this.scroller.scrollTo({ xOffset: 300, yOffset: 0 })

API 17:scroller.scrollEdge() ------ 滚动到边缘

功能说明: 控制滚动容器滚动到顶部、底部、左侧或右侧边缘。

语法格式:

typescript 复制代码
scroller.scrollEdge(edge: Edge)

枚举值说明:

枚举值 说明
Edge.Top 滚动到顶部
Edge.Bottom 滚动到底部
Edge.Start 滚动到起始位置(左/上)
Edge.End 滚动到结束位置(右/下)

使用示例:

typescript 复制代码
// 滚动到顶部
this.scroller.scrollEdge(Edge.Top)

// 滚动到底部
this.scroller.scrollEdge(Edge.Bottom)

scrollEdge vs scrollTo 对比:

typescript 复制代码
// scrollTo:需要计算具体位置
this.scroller.scrollTo({ xOffset: 0, yOffset: 99999 })

// scrollEdge:语义更清晰,无需计算
this.scroller.scrollEdge(Edge.Bottom)

API 18:scroller.scrollIndex() ------ 滚动到指定索引

功能说明: 滚动到指定子组件索引位置(主要用于 List 组件)。

语法格式:

typescript 复制代码
scroller.scrollIndex(index: number, smooth?: boolean, align?: ScrollAlign)

参数说明:

  • index:目标索引
  • smooth:是否平滑滚动(默认 true)
  • align:对齐方式(START、CENTER、END)

注意: Scroll 组件不支持按索引滚动,此 API 主要用于 List/Grid。

API 19:scroller.currentOffset() ------ 获取当前偏移量

功能说明: 获取当前滚动位置的偏移量。

语法格式:

typescript 复制代码
scroller.currentOffset(): { xOffset: number; yOffset: number }

使用示例:

typescript 复制代码
Button('获取位置')
  .onClick(() => {
    const offset = this.scroller.currentOffset()
    console.info('当前偏移:' + offset.xOffset + ', ' + offset.yOffset)
  })

API 20:scroller.isAtEnd() ------ 判断是否在末尾

功能说明: 判断滚动容器是否已滚动到末尾。

语法格式:

typescript 复制代码
scroller.isAtEnd(): boolean

使用场景:

  • 判断是否需要触发加载更多
  • 实现"回到顶部"按钮的显隐逻辑
typescript 复制代码
@State showBackToTop: boolean = false

.onScrollStop(() => {
  this.showBackToTop = this.scroller.isAtEnd()
})

API 21:scroller.getScrollRect() ------ 获取滚动区域信息

功能说明: 获取滚动容器的可视区域尺寸和总内容尺寸。

语法格式:

typescript 复制代码
scroller.getScrollRect(): ScrollRect

返回值说明:

  • xy:当前可视区域起始位置
  • widthheight:可视区域尺寸
  • contentWidthcontentHeight:内容总尺寸

五、高级扩展 API 详解(四)

API 22:.nestedScroll() ------ 嵌套滚动模式

功能说明: 设置嵌套滚动的处理模式,解决多层滚动容器的手势冲突问题。

语法格式:

typescript 复制代码
.nestedScroll(mode: NestedScrollMode)

枚举值说明:

枚举值 说明 适用场景
NestedScrollMode.ParentFirst 父容器优先滚动 外层滚动、内层固定
NestedScrollMode.SelfFirst 自身优先滚动 内层滚动、外层固定
NestedScrollMode.Parallel 平行滚动 内外层同时滚动

嵌套滚动场景示例:

typescript 复制代码
// 外层 Scroll
Scroll() {
  Column() {
    // 头部区域
    this.buildHeader()
    
    // 内层 Scroll
    Scroll() {
      // 商品列表
    }
    .nestedScroll(NestedScrollMode.SelfFirst) // 内层优先
  }
}
.nestedScroll(NestedScrollMode.ParentFirst) // 外层其次

API 23:.scrollSnap() ------ 滚动吸附

功能说明: 设置滚动吸附效果,用于实现分页滚动或定位滚动。

语法格式:

typescript 复制代码
.scrollSnap(snapAlign: ScrollSnapAlign)

枚举值说明:

枚举值 说明
ScrollSnapAlign.None 无吸附
ScrollSnapAlign.Start 吸附到起始位置
ScrollSnapAlign.Center 吸附到中间位置
ScrollSnapAlign.End 吸附到结束位置

实现轮播效果:

typescript 复制代码
Scroll() {
  Row() {
    ForEach([1, 2, 3, 4], (item: number) => {
      Column() {
        Text('页面 ' + item)
          .fontSize(24)
      }
      .width('100%')
      .height(400)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(item % 2 === 0 ? '#FFCCCC' : '#CCCCFF')
    }, (item: number) => item.toString())
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollSnap(ScrollSnapAlign.Center) // 吸附到中间
.scrollBar(BarState.Off)

API 24:.motion() ------ 动画结合滚动

功能说明: 结合动画系统实现滚动驱动的交互动画。

语法格式:

typescript 复制代码
.motion(area: Area, motion: Motion)

实际应用:视差滚动效果

typescript 复制代码
@State scrollOffset: number = 0
@State headerOffset: number = 0

Scroll() {
  Column() {
    // 背景图层
    Stack() {
      Image($r('app.media.bg'))
        .width('100%')
        .height(300)
        .scale(this.getScale()) // 根据滚动缩放
        .translate({ x: 0, y: this.getTranslateY() }) // 视差位移
    }
    
    // 前景内容
    Column() {
      // 内容区域
    }
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius({ topLeft: 20, topRight: 20 })
    .margin({ top: -30 })
  }
}
.onScroll((offset: number) => {
  this.scrollOffset = offset
})

// 计算视差偏移
private getTranslateY(): number {
  return -this.scrollOffset * 0.3 // 30% 视差比例
}

// 计算缩放比例
private getScale(): number {
  const scale = 1 + this.scrollOffset / 1000
  return Math.min(scale, 1.5) // 最大放大 1.5 倍
}

六、实战案例:吸顶 + 加载更多

6.1 完整案例介绍

本章节将综合运用上述 24 个 API,实现一个功能完整的电商商品列表页面,包含以下核心功能:

  1. 分类标题吸顶:滚动时分类标题自动吸附在顶部
  2. 加载更多:滚动到底部自动加载下一页数据
  3. 滚动状态指示:实时显示滚动位置和状态
  4. 分类切换:点击分类切换商品列表
  5. 平滑动画:滚动交互流畅自然

6.2 代码实现

typescript 复制代码
/**
 * 电商商品列表页面
 * 功能:吸顶 + 加载更多 + 视差效果
 * 核心 API:Scroll, onScroll, onScrollStop, Scroller, scrollTo, edgeEffect 等
 */

// 商品数据模型
interface Product {
  id: number
  name: string
  price: number
  imageUrl: string
  category: string
}

// 分类数据模型
interface Category {
  id: number
  name: string
  icon: string
}

@Entry
@Component
struct ECommercePage {
  // ---------- 数据状态 ----------
  @State productList: Product[] = []
  @State categoryList: Category[] = []
  @State currentCategoryId: number = 1
  @State searchKeyword: string = ''
  
  // ---------- 滚动相关状态 ----------
  @State scrollOffset: number = 0
  @State isScrolling: boolean = false
  @State isLoading: boolean = false
  @State hasMore: boolean = true
  @State showBackToTop: boolean = false
  @State headerHeight: number = 200
  @State stickyOffset: number = 0
  
  // ---------- 分页参数 ----------
  private pageSize: number = 20
  private currentPage: number = 1
  private totalPages: number = 5
  
  // ---------- Scroller 控制器 ----------
  private scroller: Scroller = new Scroller()
  
  // ---------- 生命周期 ----------
  aboutToAppear(): void {
    this.initData()
  }
  
  // ---------- 数据初始化 ----------
  private initData(): void {
    // 初始化分类
    this.categoryList = [
      { id: 1, name: '推荐', icon: '🔥' },
      { id: 2, name: '数码', icon: '📱' },
      { id: 3, name: '服饰', icon: '👕' },
      { id: 4, name: '美食', icon: '🍜' },
      { id: 5, name: '家居', icon: '🏠' },
      { id: 6, name: '美妆', icon: '💄' },
      { id: 7, name: '运动', icon: '⚽' },
      { id: 8, name: '图书', icon: '📚' }
    ]
    
    // 加载第一页商品
    this.loadProducts()
  }
  
  // ---------- 数据加载 ----------
  private loadProducts(): void {
    if (this.isLoading) return
    this.isLoading = true
    
    // 模拟网络请求
    setTimeout(() => {
      const newProducts = this.generateMockProducts()
      
      if (this.currentPage === 1) {
        this.productList = newProducts
      } else {
        this.productList = [...this.productList, ...newProducts]
      }
      
      this.hasMore = this.currentPage < this.totalPages
      this.isLoading = false
    }, 500)
  }
  
  // 生成模拟商品数据
  private generateMockProducts(): Product[] {
    const products: Product[] = []
    const names = ['蓝牙耳机', '智能手表', '平板电脑', '笔记本电脑', '机械键盘', 
                   '无线鼠标', '显示器', '移动硬盘', '充电宝', '手机壳']
    
    for (let i = 0; i < this.pageSize; i++) {
      const id = (this.currentPage - 1) * this.pageSize + i + 1
      products.push({
        id: id,
        name: names[i % names.length] + ' #' + id,
        price: Math.floor(Math.random() * 2000 + 99),
        imageUrl: '',
        category: this.currentCategoryId.toString()
      })
    }
    return products
  }
  
  // ---------- 事件处理 ----------
  
  // 滚动过程回调(用于吸顶判断)
  private handleScroll(offset: number): void {
    this.scrollOffset = offset
    this.stickyOffset = Math.max(0, offset - this.headerHeight)
    
    // 显示/隐藏回到顶部按钮
    this.showBackToTop = offset > 500
  }
  
  // 滚动停止回调(用于加载更多)
  private handleScrollStop(): void {
    this.isScrolling = false
    
    // 判断是否接近底部
    if (this.hasMore && !this.isLoading) {
      const isNearBottom = this.checkIsNearBottom()
      if (isNearBottom) {
        this.loadMore()
      }
    }
  }
  
  // 检查是否接近底部
  private checkIsNearBottom(): boolean {
    const scrollRect = this.scroller.getScrollRect()
    const contentHeight = scrollRect.contentHeight
    const viewHeight = scrollRect.height
    const currentY = this.scroller.currentOffset().yOffset
    
    // 距离底部 200vp 时触发
    return currentY >= contentHeight - viewHeight - 200
  }
  
  // 加载更多
  private loadMore(): void {
    if (!this.hasMore || this.isLoading) return
    
    this.currentPage++
    this.loadProducts()
  }
  
  // 切换分类
  private switchCategory(categoryId: number): void {
    if (this.currentCategoryId === categoryId) return
    
    this.currentCategoryId = categoryId
    this.currentPage = 1
    this.hasMore = true
    this.productList = []
    
    // 滚动到顶部
    this.scroller.scrollTo({ xOffset: 0, yOffset: 0 })
    
    // 重新加载数据
    this.loadProducts()
  }
  
  // 回到顶部
  private backToTop(): void {
    this.scroller.scrollEdge(Edge.Top)
  }
  
  // ---------- UI 构建 ----------
  build() {
    Stack({ alignContent: Alignment.Top }) {
      // ========== 底层:可滚动内容 ==========
      Scroll(this.scroller) {
        Column() {
          // 1. 顶部 Banner(随滚动消失)
          this.buildBanner()
          
          // 2. 搜索栏
          this.buildSearchBar()
          
          // 3. 分类标题行(吸顶元素)
          this.buildCategoryHeader()
          
          // 4. 分类 Tab(可横向滚动)
          this.buildCategoryTabs()
          
          // 5. 商品列表
          this.buildProductGrid()
          
          // 6. 加载更多指示器
          this.buildLoadMoreIndicator()
          
          // 7. 底部留白
          Blank().height(30)
        }
        .width('100%')
      }
      .width('100%')
      .height('100%')
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Auto)
      .edgeEffect(EdgeEffect.Spring)
      .onScroll((offset: number) => {
        this.handleScroll(offset)
      })
      .onScrollStart(() => {
        this.isScrolling = true
      })
      .onScrollStop(() => {
        this.handleScrollStop()
      })
      .nestedScroll(NestedScrollMode.ParentFirst)
      
      // ========== 顶层:吸顶元素 ==========
      if (this.scrollOffset > this.headerHeight) {
        this.buildStickyHeader()
      }
      
      // ========== 回到顶部按钮 ==========
      if (this.showBackToTop) {
        this.buildBackToTopButton()
      }
      
      // ========== 状态指示器(调试用) ==========
      this.buildDebugIndicator()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
  
  // ---------- Builder 组件 ----------
  
  // 顶部 Banner
  @Builder
  buildBanner(): void {
    Stack() {
      Column() {
        Text('限时特惠')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ top: 40 })
        
        Text('全场满 199 减 50')
          .fontSize(14)
          .fontColor('#FFFFFFCC')
          .margin({ top: 8 })
        
        Row() {
          Text('立即抢购')
            .fontSize(14)
            .fontColor('#FF4444')
            .fontWeight(FontWeight.Bold)
            .padding({ left: 20, right: 20, top: 8, bottom: 8 })
            .backgroundColor(Color.White)
            .borderRadius(20)
        }
        .margin({ top: 24 })
      }
    }
    .width('100%')
    .height(200)
    .linearGradient({
      direction: Gradient.RightBottom,
      colors: [['#FF6B6B', 0], ['#FF4444', 1]]
    })
    .margin({ bottom: 12 })
  }
  
  // 搜索栏
  @Builder
  buildSearchBar(): void {
    Row() {
      TextInput({ placeholder: '搜索商品' })
        .width('100%')
        .height(40)
        .backgroundColor(Color.White)
        .borderRadius(20)
        .onChange((value: string) => {
          this.searchKeyword = value
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16 })
  }
  
  // 分类标题行(原始版本,会随滚动消失)
  @Builder
  buildCategoryHeader(): void {
    Row() {
      Text(this.categoryList.find(c => c.id === this.currentCategoryId)?.name ?? '推荐')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A1A')
      
      Blank()
      
      Text('排序方式')
        .fontSize(14)
        .fontColor('#999999')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 8 })
  }
  
  // 吸顶分类标题(顶层固定版本)
  @Builder
  buildStickyHeader(): void {
    Column() {
      Row() {
        Text(this.categoryList.find(c => c.id === this.currentCategoryId)?.name ?? '推荐')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        
        Blank()
        
        Text('排序方式')
          .fontSize(14)
          .fontColor('#999999')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 8 })
      
      // 吸顶状态下的分类 Tab
      Scroll() {
        Row({ space: 12 }) {
          ForEach(this.categoryList, (item: Category) => {
            this.buildCategoryTabItem(item)
          }, (item: Category) => item.id.toString())
        }
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ bottom: 8 })
    }
    .width('100%')
    .backgroundColor(Color.White)
    .shadow({ radius: 4, color: '#20000000', offsetY: 2 })
  }
  
  // 分类 Tab 行
  @Builder
  buildCategoryTabs(): void {
    Scroll() {
      Row({ space: 12 }) {
        ForEach(this.categoryList, (item: Category) => {
          this.buildCategoryTabItem(item)
        }, (item: Category) => item.id.toString())
      }
      .padding({ left: 16, right: 16, top: 4, bottom: 4 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ bottom: 12 })
  }
  
  // 单个分类 Tab 项
  @Builder
  buildCategoryTabItem(category: Category): void {
    Row() {
      Text(category.icon)
        .fontSize(16)
      Text(category.name)
        .fontSize(14)
        .margin({ left: 4 })
    }
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .backgroundColor(
      this.currentCategoryId === category.id ? '#FFEAEA' : '#F2F3F5'
    )
    .borderRadius(16)
    .onClick(() => {
      this.switchCategory(category.id)
    })
  }
  
  // 商品网格
  @Builder
  buildProductGrid(): void {
    Column({ space: 12 }) {
      ForEach(this.productList, (item: Product) => {
        this.buildProductCard(item)
      }, (item: Product) => item.id.toString())
    }
    .width('100%')
    .padding({ left: 12, right: 12 })
  }
  
  // 单个商品卡片
  @Builder
  buildProductCard(product: Product): void {
    Row() {
      // 商品图
      Column() {
        Text('📦')
          .fontSize(40)
      }
      .width(100)
      .height(100)
      .backgroundColor('#F8F8F8')
      .borderRadius(12)
      .justifyContent(FlexAlign.Center)
      
      // 商品信息
      Column({ space: 8 }) {
        Text(product.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        
        Text('销量 ' + (product.id * 37 + 120))
          .fontSize(12)
          .fontColor('#999999')
        
        Row() {
          Text('¥' + product.price.toString())
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF4444')
          
          Blank()
          
          Row() {
            Text('购买')
              .fontSize(12)
              .fontColor(Color.White)
          }
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('#FF4444')
          .borderRadius(14)
        }
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
  
  // 加载更多指示器
  @Builder
  buildLoadMoreIndicator(): void {
    Column() {
      if (this.isLoading) {
        Row({ space: 8 }) {
          LoadingProgress()
            .width(16)
            .height(16)
            .color('#FF4444')
          Text('加载中...')
            .fontSize(13)
            .fontColor('#999999')
        }
      } else if (this.hasMore) {
        Text('上拉加载更多')
          .fontSize(13)
          .fontColor('#999999')
      } else {
        Text('------ 已加载全部商品 ------')
          .fontSize(13)
          .fontColor('#CCCCCC')
      }
    }
    .width('100%')
    .height(50)
    .justifyContent(FlexAlign.Center)
  }
  
  // 回到顶部按钮
  @Builder
  buildBackToTopButton(): void {
    Column() {
      Text('↑')
        .fontSize(24)
        .fontColor(Color.White)
    }
    .width(44)
    .height(44)
    .borderRadius(22)
    .backgroundColor('#FF4444')
    .position({ x: '80%', y: '85%' })
    .shadow({ radius: 8, color: '#40000000', offsetY: 4 })
    .onClick(() => {
      this.backToTop()
    })
  }
  
  // 调试状态指示器
  @Builder
  buildDebugIndicator(): void {
    Column() {
      Text('Scroll Debug')
        .fontSize(11)
        .fontColor(Color.White)
        .fontWeight(FontWeight.Bold)
      
      Text('Offset: ' + Math.floor(this.scrollOffset))
        .fontSize(10)
        .fontColor('#FFFFFFCC')
        .margin({ top: 2 })
      
      Text(this.isScrolling ? '滚动中' : '已停止')
        .fontSize(10)
        .fontColor(this.isScrolling ? '#FFD700' : '#FFFFFFCC')
        .margin({ top: 2 })
      
      Text(this.isLoading ? '加载中' : this.hasMore ? '可加载' : '无更多')
        .fontSize(10)
        .fontColor('#00FF88')
        .margin({ top: 2 })
    }
    .padding({ left: 10, right: 10, top: 6, bottom: 6 })
    .backgroundColor('#80000000')
    .borderRadius(8)
    .position({ x: 16, y: 60 })
  }
}

6.3 代码解析

吸顶实现原理

吸顶效果的核心是 Stack 叠层 + 条件渲染

typescript 复制代码
Stack() {
  // 底层:完整的 Scroll 内容
  Scroll() {
    Column() {
      // Banner(200vp)
      // 搜索栏
      // 分类标题(原始)
      // 分类 Tab(原始)
      // 商品列表
    }
  }
  
  // 顶层:吸顶元素(条件渲染)
  if (this.scrollOffset > 200) {
    // 吸顶的分类标题 + Tab
  }
}

判断逻辑:scrollOffset > headerHeight(200vp)时,原始分类标题被滚出屏幕,此时顶层的吸顶元素渲染出来,覆盖在相同位置,形成"粘性固定"的视觉效果。

加载更多实现

加载更多使用 onScrollStop 回调配合 Scroller.getScrollRect()

typescript 复制代码
.onScrollStop(() => {
  // 获取滚动区域信息
  const rect = this.scroller.getScrollRect()
  const contentHeight = rect.contentHeight  // 总内容高度
  const viewHeight = rect.height            // 可视区域高度
  const currentY = this.scroller.currentOffset().yOffset  // 当前位置
  
  // 判断是否接近底部
  if (currentY >= contentHeight - viewHeight - 200) {
    this.loadMore()
  }
})
分类切换与数据重置

切换分类时需要正确处理状态:

typescript 复制代码
private switchCategory(categoryId: number): void {
  // 防抖:避免重复点击
  if (this.currentCategoryId === categoryId) return
  
  this.currentCategoryId = categoryId
  this.currentPage = 1
  this.hasMore = true
  this.productList = []  // 清空旧数据
  
  // 滚动到顶部(带动画)
  this.scroller.scrollTo({ xOffset: 0, yOffset: 0 })
  
  // 重新加载
  this.loadProducts()
}

七、性能优化指南

7.1 onScroll 回调优化

onScroll 是高频回调,不当使用会导致严重的性能问题:

问题代码:

typescript 复制代码
.onScroll((offset: number) => {
  // ❌ 每帧都触发状态更新
  this.scrollOffset = offset
  
  // ❌ 每帧都执行复杂计算
  this.complexCalculation()
  
  // ❌ 每帧都请求网络
  this.requestData()
})

优化方案:

typescript 复制代码
// 1. 使用节流降低更新频率
private lastUpdateTime: number = 0

.onScroll((offset: number) => {
  const now = Date.now()
  if (now - this.lastUpdateTime >= 16) { // ~60fps
    this.scrollOffset = offset
    this.lastUpdateTime = now
  }
})

// 2. 移除不需要的状态更新
@State scrollOffset: number = 0
private smoothOffset: number = 0

.onScroll((offset: number) => {
  // 仅在需要重新渲染的阈值内更新
  if (Math.abs(offset - this.smoothOffset) > 5) {
    this.scrollOffset = offset
    this.smoothOffset = offset
  }
})

7.2 长列表性能优化

当商品数量超过 100 项时,推荐使用 List + LazyForEach 替代 Scroll + ForEach

typescript 复制代码
// Scroll + ForEach:全部渲染,无懒加载
Scroll() {
  Column() {
    ForEach(this.productList, (item: Product) => {
      this.buildProductCard(item)
    }, (item: Product) => item.id.toString())
  }
}

// List + LazyForEach:懒加载,高性能
List() {
  LazyForEach(this.productData, (item: Product) => {
    ListItem() {
      this.buildProductCard(item)
    }
  }, (item: Product) => item.id.toString())
}

7.3 图片资源优化

列表中的图片加载也是性能瓶颈:

  • 使用 WebP 格式代替 PNG/JPG
  • 使用缩略图和自适应加载
  • 配合 Image 组件的缓存能力

7.4 布局层级优化

过深的布局层级会影响渲染性能:

typescript 复制代码
// ❌ 过深嵌套
Scroll() {
  Column() {
    Row() {
      Stack() {
        Column() {
          Row() {
            Text() // 层级过深
          }
        }
      }
    }
  }
}

// ✅ 扁平化布局
Scroll() {
  Column() {
    // 直接子组件,减少嵌套
  }
}

八、常见问题与解决方案

Q1:onScroll 不触发?

可能原因:

  1. 内容高度不够,无法滚动
  2. Scroll 组件没有设置固定高度
  3. 被嵌套的父容器拦截了滚动事件

解决方案:

typescript 复制代码
// 确保 Scroll 有明确的高度
Scroll() {
  Column() {
    // 内容
  }
  .width('100%')
}
.width('100%')
.height('100%')  // 关键:设置明确高度

Q2:吸顶效果抖动?

可能原因:

  1. 条件渲染时机不准确
  2. 吸顶元素与原始元素位置不一致
  3. 状态更新过于频繁

解决方案:

typescript 复制代码
// 使用稳定的判断条件
private shouldSticky(): boolean {
  return this.scrollOffset >= this.headerHeight - 1 // 添加小偏移
}

// 预渲染避免闪烁(推荐方案)
Stack() {
  // 底层完整内容
  Scroll() {
    // ...
  }
  
  // 吸顶层:始终存在,但通过透明度控制
  Column() {
    // 吸顶内容
  }
  .opacity(this.shouldSticky() ? 1 : 0) // 而非条件渲染
}

Q3:加载更多重复触发?

可能原因:

  1. 没有防抖/锁机制
  2. 判断条件过于宽松
  3. 网络请求未完成就重复触发

解决方案:

typescript 复制代码
@State isLoading: boolean = false
@State hasMore: boolean = true

.onScrollStop(() => {
  // 三重检查
  if (!this.hasMore) return      // 1. 还有数据
  if (this.isLoading) return      // 2. 不在加载中
  if (!this.isNearBottom()) return // 3. 确实接近底部
  
  this.loadMore()
})

private loadMore(): void {
  this.isLoading = true  // 加锁
  
  setTimeout(() => {
    // ... 加载逻辑
    this.isLoading = false  // 解锁
  }, 500)
}

Q4:水平滚动与垂直滚动冲突?

可能原因: 多层嵌套滚动容器的手势识别冲突。

解决方案: 使用 .nestedScroll() 明确指定优先级:

typescript 复制代码
// 外层垂直滚动
Scroll() {
  Column() {
    // 内层水平滚动
    Scroll() {
      Row() {
        // 横向内容
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .nestedScroll(NestedScrollMode.SelfFirst) // 内层优先
  }
}
.scrollable(ScrollDirection.Vertical)
.nestedScroll(NestedScrollMode.ParentFirst) // 外层其次

九、进阶技巧

9.1 下拉刷新

typescript 复制代码
@State pullOffset: number = 0
@State isRefreshing: boolean = false

Scroll() {
  Column() {
    // 刷新指示器
    this.buildRefreshIndicator()
    
    // 内容
  }
}
.onScroll((offset: number) => {
  if (offset < 0) {
    this.pullOffset = Math.abs(offset)
  }
})
.onScrollStop(() => {
  if (this.pullOffset > 80 && !this.isRefreshing) {
    this.triggerRefresh()
  }
})

9.2 视差滚动

typescript 复制代码
.onScroll((offset: number) => {
  // 背景层移动速度慢于前景层
  this.bgOffset = offset * 0.3  // 30% 视差
  this.fgOffset = offset * 0.1  // 10% 视差
  
  // 动态缩放
  this.bgScale = 1 + offset / 2000
})

9.3 滚动位置记忆

typescript 复制代码
// 页面出现时恢复位置
aboutToAppear(): void {
  const savedOffset = AppStorage.Get('scrollPosition') as number
  if (savedOffset) {
    setTimeout(() => {
      this.scroller.scrollTo({ xOffset: 0, yOffset: savedOffset, animation: false })
    }, 100)
  }
}

// 页面消失时保存位置
aboutToDisappear(): void {
  const offset = this.scroller.currentOffset()
  AppStorage.SetOrCreate('scrollPosition', offset.yOffset)
}

9.4 滚动动画联动

typescript 复制代码
@State headerOpacity: number = 0

.onScroll((offset: number) => {
  // 根据滚动位置计算透明度
  const maxOffset = 300
  this.headerOpacity = Math.min(1, offset / maxOffset)
})

// 应用透明度
Column() {
  // 导航栏
}
.opacity(this.headerOpacity)

十、总结与最佳实践

10.1 API 使用优先级

优先级 API 说明
⭐⭐⭐ onScroll 最核心的滚动监听
⭐⭐⭐ onScrollStop 加载更多的最佳时机
⭐⭐⭐ Scroller.scrollTo 控制滚动位置
⭐⭐ edgeEffect 提升体验细节
⭐⭐ scrollBar 视觉引导
nestedScroll 解决嵌套冲突
scrollSnap 特殊交互场景

10.2 性能红线

  1. onScroll 中不做网络请求
  2. onScroll 中不做复杂计算
  3. 长列表用 List + LazyForEach
  4. 避免过深的布局嵌套
  5. 图片使用缩略图和缓存

10.3 交互体验要点

  1. 弹簧效果edgeEffect(EdgeEffect.Spring) 必加
  2. 滚动指示:自动隐藏的滚动条
  3. 加载反馈:Loading 动画 + 文字提示
  4. 回到顶部:滚动超过 500vp 显示按钮
  5. 分类切换:切换后自动回到顶部

10.4 学习路线图

复制代码
入门:Scroll 基本用法 + onScroll 基础监听
  ↓
进阶:Scroller 控制器 + 吸顶效果实现
  ↓
高级:嵌套滚动 + 视差效果 + 动画联动
  ↓
精通:性能优化 + List/LazyForEach + 复杂交互

附录:完整 API 速查表

序号 API 类型 功能
1 Scroll() 构造 创建滚动容器
2 .scrollable() 属性 设置滚动方向
3 .scrollBar() 属性 控制滚动条显示
4 .scrollBarColor() 属性 滚动条颜色
5 .scrollBarWidth() 属性 滚动条宽度
6 .scrollBarMode() 属性 滚动条模式
7 .edgeEffect() 属性 边缘效果
8 .enabled() 属性 是否可滚动
9 .onScroll() 事件 滚动过程回调
10 .onScrollStart() 事件 滚动开始回调
11 .onScrollStop() 事件 滚动停止回调
12 .onScrollFrameBegin() 事件 每帧滚动回调
13 .onScrollSizeChange() 事件 尺寸变化回调
14 .onWillScroll() 事件 滚动前回调
15 Scroller 控制器 滚动控制器实例
16 scroller.scrollTo() 方法 滚动到指定位置
17 scroller.scrollEdge() 方法 滚动到边缘
18 scroller.scrollIndex() 方法 滚动到索引
19 scroller.currentOffset() 方法 获取当前偏移
20 scroller.isAtEnd() 方法 判断是否末尾
21 scroller.getScrollRect() 方法 获取滚动区域信息
22 .nestedScroll() 属性 嵌套滚动模式
23 .scrollSnap() 属性 滚动吸附效果
24 .motion() 属性 动画结合滚动

作者寄语: 滚动容器看似简单,但深入研究后会发现其背后涉及丰富的交互设计理念和性能优化策略。希望本文能帮助您在 HarmonyOS 开发中更好地驾驭 Scroll 组件,打造流畅自然的滚动体验。

💡 延伸阅读推荐:

  • HarmonyOS 官方文档:Scroll 组件
  • List 组件进阶:长列表性能优化
  • 手势识别:PanGesture、TapGesture 与滚动的配合使用

相关推荐
2501_919749031 小时前
华为鸿蒙免费铃声APP—小羊免费铃声
华为·harmonyos
不羁的木木1 小时前
《HarmonyOS技术精讲-NearLink Kit(星闪服务)》第3篇:数据收发——点对点数据传输实战
pytorch·华为·harmonyos
春卷同学1 小时前
HarmonyOS掌上记账APP开发实践第25篇:Image 组件高级用法 — 资源引用、网络图片与首字符占位方案
网络·华为·harmonyos
2501_919749031 小时前
华为鸿蒙免费记账与统计APP—小羊统计
华为·harmonyos
AD02272 小时前
32-Token明文存储-把凭据边界收到安全仓储里
harmonyos·arkts·鸿蒙开发
特立独行的猫a3 小时前
Node.js三方库鸿蒙 PC 移植:鸿蒙PC开发者面对面 · 线下课件分享
华为·harmonyos·三方库移植·鸿蒙pc
春卷同学4 小时前
HarmonyOS掌上记账APP开发实践第23篇:弹性动效与手势 — EdgeEffect.Spring 与手势系统实战
spring·华为·harmonyos
l134062082354 小时前
鸿蒙应用开发实战【88】— DevEco Studio高效开发技巧
华为·harmonyos·鸿蒙系统
一缕清烟在人间5 小时前
鸿蒙应用开发实战【54】— 应用详情页AppDetailPage开发(下)
华为·harmonyos·鸿蒙系统