一、引言

在移动应用中,"下拉刷新"是最核心的交互模式之一。无论是微博首页的最新动态、微信朋友圈的新鲜内容、还是资讯 App 的实时新闻,用户都习惯性地下滑列表顶部,期待看到最新的数据更新。"下拉→加载→新内容出现"这个流畅的动作序列,已经成为移动端内容消费的肌肉记忆。

然而,实现下拉刷新并非简单地在列表顶部挂一个加载动画。开发者需要精确处理:下拉距离与视觉反馈的对应关系(下拉多远才触发刷新?回弹动画如何衔接?)、刷新状态管理(空闲→拖动→刷新中→完成→重置)、手势冲突(列表自身的滚动与下拉手势如何区分?)、以及多场景适配(列表为空时要不要显示刷新?网络失败时如何提示?)。

而 HarmonyOS 提供了 Refresh 组件——一个专用于下拉刷新的容器组件。它包裹在列表内容外层,自动处理手势识别、回弹动画和加载状态切换。开发者只需声明 refreshing 状态(通过 $$ 双向绑定)并在 onRefreshing 回调中执行数据加载逻辑,即可获得完整的下拉刷新体验。

本文通过一个"动态资讯"Demo 深入讲解 Refresh 组件的核心用法:如何创建下拉刷新列表?refreshing 状态如何通过 $$ 双向绑定?onRefreshing 回调如何配合数据加载?以及如何实现手动刷新、展开详情等完整交互。

阅读完本文,你将能够:

  • 使用 Refresh 组件实现下拉刷新功能
  • 使用 $$ 双向绑定管理 refreshing 状态
  • onRefreshing 回调中执行异步数据加载
  • 将 Refresh 与 List 组件结合构建资讯流
  • 实现手动刷新按钮作为下拉刷新的补充交互

二、Refresh 组件 API 总览

2.1 构造函数

Refresh(options: RefreshOptions) {
  // 子内容:通常是一个 List、Grid 或 Scroll
}
interface RefreshOptions {
  refreshing: boolean; // 当前的刷新状态
}
参数 类型 说明
refreshing boolean true 时显示刷新动画(loading 旋转图标),false 时隐藏。通常使用 $$ 前缀实现双向绑定

$$ 是 ArkUI 提供的双向绑定语法糖,格式为 $$this.stateVar。它同时完成了两件事:将 stateVar 的值传递给组件,以及当组件内部改变该值时自动同步回 stateVar。在 Refresh 中,用户下拉超过阈值时组件会自动将 refreshing 设为 true,数据加载完成后开发者手动将其设回 false。

2.2 链式方法

// 刷新状态变化回调(用户下拉触发时调用)
.onRefreshing(callback: () => void): RefreshAttribute

// 刷新状态变化(包含完整的生命周期状态)
.onStateChange(callback: (state: RefreshStatus) => void): RefreshAttribute

// 下拉触发阈值距离(vp)
.offset(value: number | string | Resource): RefreshAttribute

// 自定义下拉时的显示内容
.refreshContent(content: () => void): RefreshAttribute
方法 说明
.onRefreshing(Callback) 下拉触发的回调。在此回调中执行数据加载(网络请求、数据库查询等),完成后将 refreshing 设回 false
.onStateChange(Callback<RefreshStatus>) 刷新状态的完整生命周期回调,包含 Drag(拖动)、Refreshing(刷新中)、Done(完成)三种状态。用于更细粒度的 UI 反馈(如"释放立即刷新"文案切换)
.offset(number) 下拉触发阈值,单位 vp。默认值约为 64vp。设置越大需要下拉越深才触发刷新
.refreshContent(CustomBuilder) 完全自定义下拉区域的显示内容,替代默认的 loading 旋转图标。可嵌入自定义图标、文字提示等

2.3 RefreshStatus 枚举

enum RefreshStatus {
  Inactive,   // 未激活(空闲状态)
  Drag,       // 正在拖动(下拉中,未达阈值)
  OverDrag,   // 超过阈值(可释放触发刷新)
  Refresh,    // 正在刷新(加载动画显示中)
  Done        // 刷新完成
}
状态 说明 UI 反馈示例
Inactive 空闲,列表未下拉 无特殊指示
Drag 正在下拉,未超过阈值 “下拉刷新” 文字
OverDrag 超过阈值,松手即触发 “释放立即刷新” 文字 + 箭头翻转动画
Refresh 正在加载数据 loading 旋转图标 + “加载中…”
Done 加载完成,即将收起 loading 消失,内容区域回弹

Demo 中使用了 onRefreshing(核心回调)管理加载逻辑,考虑到 Demo 的简洁性未使用 onStateChange。对于需要"松手刷新"提示文案的产品级应用,建议使用 onStateChange 监听 Drag 和 OverDrag 状态。

2.4 Refresh 与手动刷新按钮的配合

Refresh 组件的 refreshing 状态默认为 false。当开发者通过代码设置 this.isRefreshing = true(例如点击"手动刷新"按钮),即使没有下拉手势,Refresh 组件也会进入刷新状态(显示 loading 动画),等待加载完成后设回 false。

这意味着一套刷新逻辑(doRefresh() 方法)可以同时服务于两个触发源:下拉手势(通过 onRefreshing 回调调用)和手动按钮(通过 onClick 直接调用)。这是 Demo 中的关键设计——用户既可以通过下拉刷新获取最新资讯,也可以点击顶部"手动刷新"按钮触发加载。
在这里插入图片描述

三、Demo 设计:动态资讯

3.1 功能概述

Demo 是一个"动态资讯"应用,模拟资讯类 App 的新闻信息流:

  1. 资讯列表:15 条预置新闻内容池,每次加载随机抽取。每条新闻包含分类标签、来源、发布时间、标题和摘要
  2. 下拉刷新:下拉列表顶部触发刷新,loading 1.5 秒后新增 3 条随机新闻插入列表头部
  3. 手动刷新:顶部状态栏"手动刷新"按钮触发相同加载逻辑
  4. 上次刷新时间:每次刷新后更新时间,格式为"HH:MM"
  5. 展开/收起详情:点击"展开全文"查看新闻摘要,点击"收起"折叠
  6. 列表滚动:Refresh 包裹的 List 支持完整滚动交互

3.2 交互点

# 交互 说明
1 下拉刷新 下滑列表顶部触发 onRefreshing → 加载 3 条新资讯 → 插入列表头部
2 手动刷新 点击"手动刷新"按钮,同样触发加载逻辑,更新刷新时间
3 展开详情 点击新闻卡片"展开全文"→ 显示完整摘要 → "收起"折叠
4 滚动浏览 List 中滚动浏览全部资讯,分类标签和文章来源辅助筛选

四、完整代码实现

在这里插入图片描述

4.1 数据模型与状态

interface NewsItem {
  id: number;
  title: string;
  summary: string;
  source: string;
  time: string;
  category: string;
  catColor: string;
}

@State isRefreshing: boolean = false;
@State lastRefreshTime: string = '暂无';
@State newsList: NewsItem[] = [];
@State expandedId: number = -1;
private newsPool: NewsItem[] = []; // 15 条预置新闻

NewsItem 包含新闻的完整信息:标题、摘要(展开后显示)、来源、发布时间、分类(带颜色标签)。newsPool 存储全部 15 条预置新闻作为数据池,newsList 是当前显示的列表(初始 5 条随机新闻)。expandedId 记录当前展开的新闻 ID(-1 表示无展开)。

4.2 Refresh 包裹 List

Refresh({ refreshing: $$this.isRefreshing }) {
  List() {
    ForEach(this.newsList, (news: NewsItem, idx: number) => {
      ListItem() {
        Column() {
          Row() {
            Text(news.category)
              .fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
              .padding({ top: 2, bottom: 2, left: 6, right: 6 })
              .borderRadius(4).backgroundColor(news.catColor)
            Text(news.source)
              .fontSize(11).fontColor('#BBBBCC').margin({ left: 8 })
            Blank()
            Text(news.time)
              .fontSize(11).fontColor('#BBBBCC')
          }
          .width('100%')

          Text(news.title)
            .fontSize(16).fontColor('#1a1a2e').fontWeight(FontWeight.Medium)
            .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
            .width('100%').margin({ top: 10, bottom: this.expandedId === news.id ? 10 : 6 })

          if (this.expandedId === news.id) {
            Text(news.summary)
              .fontSize(13).fontColor('#666677').lineHeight(20)
              .width('100%').margin({ bottom: 10 })
          }

          Row() {
            Text(this.expandedId === news.id ? '收起' : '展开全文')
              .fontSize(11).fontColor('#1677FF').fontWeight(FontWeight.Medium)
              .onClick(() => { this.toggleExpand(news.id); })
          }
        }
        .width('100%')
        .padding({ left: Spacing.LG, right: Spacing.LG, top: 14, bottom: 14 })
      }
      .border({ width: { bottom: 0.5 }, color: '#F2F3F5' })
    }, (news: NewsItem, idx: number) => news.id.toString())
  }
  .width('100%')
  .height('100%')
  .scrollBar(BarState.Off)
}
.onRefreshing(() => { this.doRefresh(); })

逐行解析:

  • Refresh({ refreshing: $$this.isRefreshing })$$ 双向绑定 isRefreshing 状态。用户下拉超过阈值时,组件自动将 isRefreshing 设为 true,触发 loading 动画;加载完成后开发者将 isRefreshing 设回 false
  • List():Refresh 内部包裹一个 List 组件作为可滚动内容。List 每项是 ListItem(),通过 ForEachnewsList 数据数组生成
  • .onRefreshing(() => { this.doRefresh(); }):下拉触发回调,调用 doRefresh() 执行数据加载
  • 展开/收起逻辑expandedId 记录当前展开项,toggleExpand() 切换 expandedId。展开时显示 news.summary(约 80~100 字摘要),折叠时仅显示标题

4.3 数据加载逻辑

doRefresh(): void {
  this.isRefreshing = true;
  setTimeout(() => {
    const freshNews = this.randomNews(3);
    const newList: NewsItem[] = [];
    for (let i = 0; i < freshNews.length; i++) {
      newList.push(freshNews[i]);
    }
    for (let i = 0; i < this.newsList.length; i++) {
      newList.push(this.newsList[i]);
    }
    this.newsList = newList;
    const now = new Date();
    this.lastRefreshTime = now.getHours().toString().concat(':',
      now.getMinutes().toString().length === 1 ? '0'.concat(now.getMinutes().toString()) : now.getMinutes().toString());
    this.isRefreshing = false;
  }, 1500);
}

doRefresh() 是核心加载方法,既由 onRefreshing 回调触发(下拉手势),也由"手动刷新"按钮的 onClick 触发:

  1. 设置刷新状态this.isRefreshing = true 激活 loading 动画
  2. 模拟网络请求setTimeout 延迟 1.5 秒模拟网络加载延迟
  3. 随机抽取新内容randomNews(3) 从 15 条预置新闻中随机抽取 3 条不重复的新闻
  4. 插入列表头部:将新 3 条新闻放在 newList 头部,原有内容拼接在后(newList.push(this.newsList[i])
  5. 更新刷新时间:获取当前时间的"HH:MM"格式,处理分钟补零(如 9:05)
  6. 结束刷新this.isRefreshing = false 关闭 loading 动画,Refresh 组件自动播放回弹动画

需要注意:数组更新遵循 ArkUI 的不可变数据模式——创建新的数组 newList 而非修改原数组,确保 @State 能检测到变化并触发 UI 更新。

4.4 手动刷新按钮

Row() {
  Text('上次刷新:'.concat(this.lastRefreshTime))
    .fontSize(11).fontColor('#9999AA')
  Blank()
  Text('手动刷新')
    .fontSize(11).fontColor('#1677FF').fontWeight(FontWeight.Medium)
    .padding({ top: 3, bottom: 3, left: 10, right: 10 })
    .borderRadius(10).backgroundColor('#EEF3FF')
    .onClick(() => { this.doRefresh(); })
}
.width('100%')
.height(36)
.padding({ left: Spacing.LG, right: Spacing.LG })
.backgroundColor('#F2F3F5')

状态栏左侧显示"上次刷新:HH:MM"提供时间参考,右侧"手动刷新"按钮直接调用 doRefresh()。这展示了 Refresh 组件的一个重要特性:刷新逻辑与触发方式解耦。同一个 doRefresh() 方法,通过下拉手势触发和按钮点击触发都能正确工作——因为 $$ 双向绑定确保了两种触发路径下 isRefreshing 状态的一致性。

4.5 新闻展开/折叠

toggleExpand(id: number): void {
  if (this.expandedId === id) {
    this.expandedId = -1; // 收起:清除展开状态
  } else {
    this.expandedId = id; // 展开:记录当前展开项
  }
}

toggleExpand() 实现"手风琴"式展开行为——同一时间只有一个新闻项处于展开状态。点击新的"展开全文"会自动收起之前展开的项。展开时标题下方的 margin 从 6vp 扩大到 10vp,为摘要文本留出视觉间距。

五、关键技术点详解

5.1 $$ 双向绑定的底层机制

$$this.isRefreshing 是 ArkUI 声明式框架的核心特性之一。它等价于同时做两件事:

  1. 属性传递refreshing: this.isRefreshing —— 将当前 isRefreshing 的值传给 Refresh 组件
  2. 事件监听:当 Refresh 组件内部改变 refreshing 时(如下拉触发),自动调用 this.isRefreshing = newValue 同步回状态变量

需要注意的是,$$ 只能用于 @State@Prop@Link 等可观察状态变量,不能用于普通 private 变量。此外,在 $$ 模式下,开发者不应在 onRefreshing 回调中再次设置 isRefreshing = true(因为组件已经自动设置了),而应该在数据加载完成后设置 isRefreshing = false

不过 Demo 中 doRefresh() 同时服务于下拉和手动按钮,所以包含了 isRefreshing = true。对于下拉触发的路径,这个赋值是冗余的(一次无副作用的相同值写入),对于按钮触发的路径则是必要的。

5.2 onRefreshing 的触发时机

onRefreshing 在下拉距离超过 Refresh 组件的触发阈值时自动调用。具体触发流程:

  1. 用户在列表顶部继续向下拖动
  2. 下拉距离逐渐增加,Refresh 组件内部计算偏移量
  3. 当偏移量超过阈值(默认约 64vp),组件判定用户意图为"刷新"
  4. 组件内部将 refreshing 设为 true(通过 $$ 同步到状态变量)
  5. 调用 onRefreshing 回调
  6. 开发者执行数据加载,完成后将 refreshing 设回 false

如果在步骤 4 之前用户松手(下拉不够深),Refresh 组件会自动播放回弹动画回到顶部,不会触发刷新。这个"下拉距离阈值"可以通过 .offset(value) 自定义,单位为 vp。

5.3 列表为空时的刷新行为

当列表内容为空(newsList 为空数组)时,Refresh 组件仍然可以触发刷新。此时的交互略有不同:

  • 有内容时:用户需要将列表滚动到顶部再继续下拉(两次手势——先上滑、再下拉),或者列表本身就在顶部时直接下拉
  • 空列表时:Refresh 占据整个容器空间,用户在任何位置下拉都能触发刷新

这意味着 Refresh 在空列表场景下更加灵敏——非常适合"首次加载"和"数据清空后重新加载"的场景。Demo 中初始状态有 5 条新闻(不为空),但删除所有新闻后列表为空,Refresh 依然能正确触发。

5.4 手动刷新与下拉刷新的 UX 互补

在产品设计中,不应仅依赖下拉刷新。以下是常见的补充触发方式:

触发方式 适用场景 优点 缺点
下拉刷新 列表在顶部时 符合直觉,手势自然 需要将列表滚到顶部才能触发
手动按钮(Demo 中使用) 任意滚动位置 无需滚回顶部,即时触发 占用额外屏幕空间
自动轮询 实时性要求高的场景(聊天、交易) 零用户操作 耗电、耗流量
长按菜单"刷新" 工具栏/TabBar 中的刷新选项 不需额外空间 发现性差

Demo 中使用"下拉刷新 + 手动按钮"双触发模式:快速浏览时随手势下拉刷新(自然),精确操作时点击按钮(快捷)。

5.5 自定义 refreshContent

默认的刷新动画是一个简单的 loading 旋转图标。对于品牌化需求,可以使用 .refreshContent() 自定义下拉区域的显示内容:

Refresh({ refreshing: $$this.isRefreshing }) {
  List() { ... }
}
.refreshContent(() => {
  this.customRefreshBuilder()
})

customRefreshBuilder 可以是包含图标、文字、动画的任意组件组合。例如"下拉刷新" / "释放立即刷新"的状态切换 + 品牌 Logo 动画。Demo 中使用默认样式以保持简洁,但产品级应用建议自定义以增强品牌识别度。

5.6 性能考量:高频刷新与数据更新策略

刷新操作可能被高频触发(用户反复下拉),需要注意以下性能点:

  1. 防抖(Debounce):如果 onRefreshing 中触发的是真实网络请求,建议加入防抖逻辑——在刷新动画进行中时忽略新的刷新请求。Demo 中 isRefreshing 本身起到了一定防护作用(在 true 状态时重复触发 doRefresh() 不会产生新动画)

  2. 不可变数据更新:列表数据更新时必须使用新数组(newList = [...])而非 this.newsList.push(item),否则 @State 无法检测到变化导致 UI 不刷新。Demo 中演示了正确的不可变更新模式

  3. 列表 key 函数ForEach 的第三个参数(key 函数)帮助框架识别哪些列表项发生了变化、哪些可以复用。Demo 中使用 news.id.toString() 作为唯一 key,确保新增项正确渲染、已有项节点复用

  4. 控制列表长度:实际产品中列表可能包含数百条数据。每次刷新插入新数据后,如果列表无限增长,应考虑分页加载 + 旧数据清理策略(如保留最近 50 条)

六、运行效果

6.1 初始状态

进入"动态资讯"页面,顶部状态栏显示"上次刷新:暂无"和蓝色"手动刷新"按钮。下方白色说明卡片介绍 Refresh 组件。资讯列表展示 5 条随机新闻,每条包含彩色分类标签(蓝/绿/橙/紫/红)、来源名称(如"华为官方"“开发者社区”)、发布时间和标题。底部有 0.5vp 分隔线。

6.2 下拉刷新

将列表滑到顶部,继续向下拖动 → Refresh 组件检测到超过阈值 → loading 旋转图标出现在列表上方 → onRefreshing 触发 doRefresh() → 1.5 秒后 3 条新随机新闻插入列表头部 → loading 消失、列表回弹 → 上次刷新时间更新为当前 HH:MM。

6.3 手动刷新

滚动到列表中间位置 → 点击顶部"手动刷新"按钮 → loading 动画立即显示(无需下拉手势)→ 1.5 秒后 3 条新新闻插入 → 刷新时间更新 → loading 消失。手动刷新与下拉刷新使用完全相同的 doRefresh() 逻辑,效果一致。

6.4 展开详情

点击任意新闻的"展开全文"→ 该条新闻扩展显示完整摘要(约 80~100 字中文描述),“展开全文"变为"收起”。点击"收起"→ 摘要折叠,仅显示标题。点击另一条新闻的"展开全文"→ 之前展开的自动折叠,新点击的展开(同一时间仅一项展开)。

6.5 多次刷新效果

连续点击 3 次"手动刷新"→ 每次插入 3 条,列表顶部累积 9 条新新闻。滚动浏览所有内容 → 分类标签颜色帮助快速区分新闻类型。列表流畅滚动无卡顿。

七、总结

本文通过一个"动态资讯"实战 Demo,深入讲解了 HarmonyOS Refresh 下拉刷新组件的核心用法:

  1. Refresh 容器:包裹 List 内容,自动处理下拉手势识别和回弹动画
  2. $$ 双向绑定$$this.isRefreshing 实现刷新状态与 Refresh 组件的自动同步,组件感知状态变化,状态反映组件内部变化
  3. onRefreshing 回调:下拉触发的数据加载入口,在此执行异步加载并在完成后将 refreshing 设回 false
  4. 手动刷新:按钮直接调用同一 doRefresh() 方法,展示"一套逻辑、多种触发"的设计模式
  5. 不可变数据更新:每次刷新创建新数组插入头部,确保 @State 检测到变化触发 UI 更新

Refresh 将"手势识别→阈值判断→加载动画→数据更新→回弹收起"这一整套下拉刷新的交互流程封装进一个容器组件,开发者只需声明状态绑定和加载回调即可获得完整的下拉刷新体验。希望本文能帮助你在实际项目中高效运用 Refresh 组件。


本文基于 HarmonyOS NEXT API 24 编写,代码经 DevEco Studio 6.1.1 编译验证通过。

Logo

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

更多推荐