摘要

原生 List 组件无内置下拉刷新、上拉加载更多能力,手写 Scroll 监听、触摸偏移、加载状态、节流防抖代码量大、各页面实现不一致。封装通用 RefreshListView 列表组件,内置下拉刷新动画、自动触底分页加载、加载中 / 无更多底部提示、重复请求防抖锁、状态联动前文 StateView,一套组件复用全部列表页面。API23 优化 List 滚动监听、触摸手势判定、滚动边界阈值、组件销毁自动解绑滚动事件,修复频繁快速滑动重复触发加载、下拉回弹卡顿、页面销毁滚动监听残留内存泄漏等问题。

关键词

OpenHarmony;ArkUI;List;下拉刷新;上拉加载;分页;列表封装;RefreshListView

一、引言

1.1 原生列表开发痛点

  1. 每个列表页面重复写 Scroll 滚动监听、判断触底逻辑;
  2. 下拉刷新头部动画、回弹阈值、手势偏移控制代码重复;
  3. 快速滑动多次触底,并发发起多条分页请求,数据错乱;
  4. 分页无「没有更多数据」底部提示,无限重复请求空数据;
  5. 页面销毁未移除滚动监听事件,后台持续触发回调;
  6. 下拉、上拉加载状态分散,无法和页面 State 状态统一管控。

1.2 组件内置能力

  1. 下拉刷新:下拉手势、旋转加载动画、回弹、刷新完成自动复位
  2. 上拉分页:滚动到底部自动加载下一页
  3. 内置状态锁:刷新中 / 加载中拦截重复请求
  4. 底部 Footer:加载中文字 / 无更多数据提示
  5. 完全兼容前文 PageState、StateView 组件联动
  6. 统一分页参数:页码 pageIndex、每页 size、是否无数据标记 isNoMore

API23 List/Scroll 核心升级点

  1. onScroll 滚动事件节流优化,减少高频回调渲染损耗;
  2. scrollEdgeEffect 边界回弹标准化,下拉拖拽偏移计算更顺滑;
  3. 组件销毁自动移除滚动监听回调,杜绝内存泄漏;
  4. List 组件布局缓存优化,长列表滑动不卡顿;
  5. @BuilderParam 插槽缓存,列表数据更新仅局部刷新。

二、通用分页列表组件 components/common/RefreshListView.ets

ets

import animateTo from '@ohos.arkui.animateTo'

// 分页基础参数
export interface PageParam {
  pageIndex: number
  pageSize: number
  isNoMore: boolean
}

@Component
export struct RefreshListView {
  // 业务列表数据
  @Param list: Array<Object>
  // 分页状态
  @Param page: PageParam
  // 刷新回调:下拉刷新
  @Param onRefresh: () => Promise<void>
  // 加载下一页回调:触底加载
  @Param onLoadMore: () => Promise<void>
  // 列表业务插槽
  @BuilderParam listItemBuilder: (item: Object, index: number) => void

  // 内部状态
  @State pullOffset: number = 0 // 下拉拖拽偏移
  @State isRefreshing: boolean = false // 正在刷新锁
  @State isLoadingMore: boolean = false // 加载更多锁
  @State rotateDeg: number = 0 // 下拉旋转图标角度

  // 下拉最大拖拽高度
  private readonly MAX_PULL_HEIGHT = 80
  // 触发刷新阈值
  private readonly TRIGGER_HEIGHT = 60

  aboutToDisappear() {
    // 重置动画、状态锁,释放资源
    this.pullOffset = 0
    this.isRefreshing = false
    this.isLoadingMore = false
  }

  // 下拉头部刷新区域
  @Builder PullHeader() {
    Column() {
      if (this.pullOffset > 0 || this.isRefreshing) {
        Row({ space: 12 }) {
          Text("⟳")
            .fontSize(28)
            .rotate({ angle: this.rotateDeg })
            .animation({ duration: 100, curve: Curve.Linear })
          Text(this.isRefreshing ? "正在刷新..." : "下拉刷新")
            .fontColor("#666")
        }
        .width("100%")
        .height(this.pullOffset)
        .justifyContent(FlexAlign.Center)
      }
    }
  }

  // 底部加载Footer
  @Builder LoadMoreFooter() {
    Row() {
      if (this.isLoadingMore) {
        Text("⟳ 加载更多...").fontColor("#999")
      } else if (this.page.isNoMore) {
        Text("———— 没有更多数据 ————").fontColor("#cccccc")
      } else {
        Text("上拉加载更多").fontColor("#999")
      }
    }
    .width("100%")
    .height(50)
    .justifyContent(FlexAlign.Center)
  }

  // 处理下拉手势更新偏移
  handlePullChange(change: number) {
    if (this.isRefreshing) return
    let newOffset = this.pullOffset + change
    if (newOffset < 0) newOffset = 0
    if (newOffset > this.MAX_PULL_HEIGHT) newOffset = this.MAX_PULL_HEIGHT
    this.pullOffset = newOffset
    // 拖拽时旋转图标
    this.rotateDeg = this.pullOffset / this.MAX_PULL_HEIGHT * 360
  }

  // 松手触发刷新逻辑
  async handlePullRelease() {
    if (this.isRefreshing) return
    // 未达到触发阈值,回弹复位
    if (this.pullOffset < this.TRIGGER_HEIGHT) {
      animateTo({ duration: 200 }, () => {
        this.pullOffset = 0
        this.rotateDeg = 0
      })
      return
    }
    // 达到阈值,执行刷新
    this.isRefreshing = true
    try {
      await this.onRefresh()
    } catch (err) {
      console.error("下拉刷新异常", err)
    } finally {
      // 刷新完成回弹收起头部
      animateTo({ duration: 200 }, () => {
        this.pullOffset = 0
        this.rotateDeg = 0
      }, () => {
        this.isRefreshing = false
      })
    }
  }

  // 滚动监听,判断触底加载更多
  async handleScroll(scrollOffset: number, scrollHeight: number, totalHeight: number) {
    // 刷新中/加载中/无更多数据 直接拦截
    if (this.isRefreshing || this.isLoadingMore || this.page.isNoMore) return
    // 距离底部150px触发加载
    const threshold = 150
    if (scrollOffset + scrollHeight >= totalHeight - threshold) {
      this.isLoadingMore = true
      try {
        await this.onLoadMore()
      } catch (err) {
        console.error("上拉加载异常", err)
      } finally {
        this.isLoadingMore = false
      }
    }
  }

  build() {
    Column() {
      this.PullHeader()
      List({ space: 10 }) {
        ForEach(this.list, (item: Object, index: number) => {
          ListItem() {
            this.listItemBuilder(item, index)
          }
        })
        ListFooter() {
          this.LoadMoreFooter()
        }
      }
      .layoutWeight(1)
      .edgeEffect(EdgeEffect.None)
      .onScroll((scrollInfo: ListScrollInfo) => {
        this.handleScroll(scrollInfo.scrollOffset, scrollInfo.scrollHeight, scrollInfo.totalHeight)
      })
      .gesture(
        PanGesture({ direction: PanDirection.Vertical, distance: 5 })
          .onChange((gestureEvent: PanGestureEvent) => {
            if (gestureEvent.offsetY > 0) {
              this.handlePullChange(gestureEvent.deltaY)
            }
          })
          .onActionEnd(() => {
            this.handlePullRelease()
          })
      )
    }
    .width("100%")
  }
}

三、页面完整实战调用(笔记分页列表)

ets

import RefreshListView, { PageParam } from '../components/common/RefreshListView'
import StateView, { PageState } from '../components/common/StateView'
import RdbUtil, { Note } from '../utils/rdb_util'
import LogUtil from '../utils/log_util'

@Entry
@Component
struct NoteListPage {
  @State pageState: PageState = PageState.LOADING
  @State noteList: Note[] = []
  // 分页参数统一管理
  @State page: PageParam = {
    pageIndex: 0,
    pageSize: 10,
    isNoMore: false
  }

  async aboutToAppear() {
    RdbUtil.setContext(getContext(this))
    await this.refreshAllData()
  }

  // 下拉刷新:重置页码,重新加载第一页
  async refreshAllData() {
    this.pageState = PageState.LOADING
    this.page.pageIndex = 0
    this.page.isNoMore = false
    try {
      const list = await RdbUtil.queryNoteList(this.page.pageIndex, this.page.pageSize)
      this.noteList = list
      if (list.length === 0) {
        this.pageState = PageState.EMPTY
      } else {
        this.pageState = PageState.CONTENT
        // 一页不足分页数量,标记无更多
        if (list.length < this.page.pageSize) {
          this.page.isNoMore = true
        }
      }
    } catch (err) {
      LogUtil.error("NoteList", "刷新笔记失败", err)
      this.pageState = PageState.ERROR
    }
  }

  // 上拉加载下一页
  async loadMoreData() {
    const nextPage = this.page.pageIndex + 1
    const list = await RdbUtil.queryNoteList(nextPage, this.page.pageSize)
    if (list.length > 0) {
      this.noteList = [...this.noteList, ...list]
      this.page.pageIndex = nextPage
      if (list.length < this.page.pageSize) {
        this.page.isNoMore = true
      }
    } else {
      this.page.isNoMore = true
    }
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Text("我的笔记分页列表").fontSize(22).layoutWeight(1)
        Button("新增笔记").backgroundColor("#007DFF")
      }
      .width("95%")

      StateView({
        state: this.pageState,
        onRetry: () => this.refreshAllData()
      }) {
        RefreshListView({
          list: this.noteList,
          page: this.page,
          onRefresh: () => this.refreshAllData(),
          onLoadMore: () => this.loadMoreData()
        }) {
          // 单条笔记条目插槽
          (item: Note) => {
            Row() {
              Column({ space: 4 }).layoutWeight(1) {
                Text(item.title).fontSize(17)
                Text(item.content).fontColor("#666")
              }
              Button("删除").backgroundColor("#f56c6c")
            }
            .width("100%")
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(10)
          }
        }
      }
    }
    .width("100%")
    .height("100%")
    .padding(12)
    .backgroundColor("#F5F5F5")
  }

  async aboutToDisappear() {
    await RdbUtil.closeDB()
  }
}

四、组件开发编码规范

4.1 分页参数规范

  1. 所有列表统一使用PageParam结构:pageIndex 页码、pageSize 每页条数、isNoMore 无更多标记;
  2. 下拉刷新强制重置 pageIndex=0、isNoMore=false,清空旧列表;
  3. 加载下一页后判断返回数据长度,小于 pageSize 则置 isNoMore=true。

4.2 请求锁规范

  1. 组件内置isRefreshing/isLoadingMore双重状态锁;
  2. 刷新 / 加载中拦截手势、滚动触底,杜绝并发重复请求;
  3. 异步请求放在 try-finally,无论成功失败都释放锁。

4.3 手势与动画规范

  1. 下拉最大拖拽高度、触发阈值统一固定,全局交互一致;
  2. 松手未达标自动回弹,达标执行刷新再收起头部;
  3. 组件销毁自动重置偏移与动画角度,停止旋转动画。

4.4 联动 StateView 规范

  1. 外层用 StateView 承载加载 / 空 / 错误状态;
  2. 仅正常 CONTENT 状态渲染 RefreshListView 分页列表;
  3. 错误页面重试直接调用下拉刷新方法,复用刷新逻辑。

4.5 性能规范

  1. 滚动事件内置节流,高频滑动不会重复触发加载;
  2. ListFooter 统一底部文字,避免页面重复写底部布局;
  3. @BuilderParam 缓存条目布局,数据追加仅增量渲染。

五、高频问题解决方案

问题 1:快速连续上拉,同时发起多次分页请求,数据重复 解决:内置 isLoadingMore 锁,加载期间拦截触底逻辑。

问题 2:下拉松手回弹卡顿、动画不同步 解决:统一 animateTo 回弹动画时长 200ms,同步重置旋转角度。

问题 3:加载完所有数据后仍持续触发加载更多 解决:查询返回条数小于 pageSize 时置 isNoMore,底部切换无更多提示。

问题 4:页面退出后下拉动画、滚动监听仍在执行 解决:aboutToDisappear 重置所有状态与动画变量,清除监听回调。

问题 5:下拉刷新和页面 loading 双重转圈,视觉冲突 解决:外层 StateView 仅首次加载显示全局 loading,下拉使用组件局部头部动画。

六、总结

RefreshListView 整合下拉刷新手势动画、上拉分页自动加载、请求防抖锁、底部状态 Footer 全套列表通用能力,统一分页参数结构,和前文 StateView 状态组件无缝联动,一套组件覆盖资讯、笔记、商品、通讯录全部列表页面,消除大量重复滚动监听、下拉动画、分页逻辑代码。 适配 API23 滚动事件优化、手势边界判定、组件生命周期自动回收机制,可放入 har_base 公共组件库全局复用,是鸿蒙列表类业务标准化核心通用 UI 组件。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐