下拉刷新是移动应用中最基础也最高频的交互之一。从微博到微信,从新闻客户端到电商应用,下拉刷新几乎无处不在——它让用户以最自然的手势获取最新内容,而无需寻找任何按钮。HarmonyOS NEXT ArkUI 提供了 Refresh 组件,将下拉刷新的手势识别、动画反馈和状态管理封装为一个开箱即用的容器。

但一个完整的"新闻资讯流"远不止下拉刷新:分类筛选让用户聚焦感兴趣的内容、收藏标记帮助用户沉淀优质文章、已读/未读状态让用户追踪浏览进度。本文将深入讲解 Refresh 组件与 List 的协作模式,并构建一个完整的"新闻资讯"应用——支持下拉刷新、分类筛选、收藏标记和已读管理。

关键词:HarmonyOS、ArkUI、Refresh、下拉刷新、新闻资讯、分类筛选、收藏

一、Refresh 组件 API

1.1 基本用法

Refresh 是一个容器组件,它的子组件必须是可滚动的(如 List、Scroll、Grid 等)。当用户下拉超过阈值时,触发刷新回调:

@State refreshing: boolean = false;

Refresh({ refreshing: $$this.refreshing }) {
  List() {
    ForEach(this.data, (item: NewsItem) => {
      ListItem() { /* 列表项 */ }
    })
  }
}
.onRefreshing(() => {
  this.refreshing = true;
  // 执行刷新逻辑...
  setTimeout(() => { this.refreshing = false; }, 1500);
})

核心属性:

  • refreshing:通过 $$ 双向绑定一个 @State boolean,控制刷新指示器的显示与隐藏
  • onRefreshing:下拉触发刷新时的回调函数

1.2 双向绑定的含义

$$ 是 ArkUI 的双向绑定语法。与单向绑定 @State(父传子)不同,$$ 允许子组件(Refresh)在特定时机修改父组件的状态变量。具体到 Refresh:

  1. 用户下拉时,Refresh 内部开始动画
  2. 下拉距离超过阈值并松手后,Refresh 调用 onRefreshing 回调
  3. 开发者在回调中设置 this.refreshing = true 显示加载指示器
  4. 数据加载完成后,开发者设置 this.refreshing = false 隐藏指示器

这种设计意味着开发者完全掌控刷新的生命周期——Refresh 组件只负责手势检测和动画,数据加载的时机和逻辑由开发者决定。

1.3 一个容易被忽略的细节

Refresh 组件的刷新回调名称是 onRefreshing,而非 onRefresh。这是 ArkUI 的命名惯例——onRefreshing 描述的是"正在刷新"这个动作,而不仅仅是"触发刷新"这个瞬间事件。如果你从其他框架(如 Flutter 的 onRefresh)迁移过来,这个差异需要特别注意。

二、新闻资讯流的整体设计

2.1 页面架构

本文 Demo 构建一个"新闻资讯"应用,模拟主流新闻客户端的信息流体验:

RefreshPage
├── 标题栏 — "新闻资讯" + 未读数徽标
├── 分类标签栏 — 横向滚动的分类选项卡(带未读计数)
├── 工具栏 — 结果计数 + 收藏筛选 + 全部已读
├── 下拉刷新区域(Refresh 组件)
│   └── List
│       ├── 空状态提示(无结果时)
│       └── 新闻卡片列表
│           ├── 分类标签 + 来源 + NEW 标记 + 时间 + 收藏星标
│           ├── 标题(已读/未读 粗细不同)
│           └── 摘要(最多 2 行)
└── 底部空白

2.2 数据结构

class NewsItem {
  id: number;
  title: string;      // 标题
  summary: string;    // 摘要
  source: string;     // 来源
  time: string;       // 发布时间(相对时间)
  category: string;   // 分类(科技 / 财经 / 体育 / 娱乐)
  bookmarked: boolean; // 是否收藏
  read: boolean;       // 是否已读
}

这里将 NewsItem 定义为 class 而非 interface,原因在于 ArkTS 不支持对象展开运算符(...item)。当需要在数组中"修改"某个对象的某个字段时(如将 bookmarked 置为 true),不能使用 { ...item, bookmarked: true },而必须显示创建一个新对象。为 NewsItem 添加工厂方法可以大幅简化这一操作:

class NewsItem {
  // ... 属性声明 ...

  constructor(id: number, title: string, summary: string, source: string,
    time: string, category: string, bookmarked: boolean, read: boolean) {
    // ... 属性赋值 ...
  }

  withBookmark(v: boolean): NewsItem {
    return new NewsItem(this.id, this.title, this.summary, this.source,
      this.time, this.category, v, this.read);
  }

  withRead(v: boolean): NewsItem {
    return new NewsItem(this.id, this.title, this.summary, this.source,
      this.time, this.category, this.bookmarked, v);
  }
}

withBookmarkwithRead 方法返回一个"除指定字段外完全相同"的新对象,配合 Array.map() 使用:

// 收藏/取消收藏:用 withBookmark 替代展开运算符
this.allNews = this.allNews.slice().map((item: NewsItem) => {
  if (item.id === id) return item.withBookmark(!item.bookmarked);
  return item;
});

这个模式是 ArkTS 中处理"不可变更新"的标准做法——通过 slice() 创建数组新引用,通过 map() + 工厂方法创建新的对象引用,触发 ArkUI 的状态感知和 UI 重绘。

2.3 12 篇预设新闻

Demo 提供了 12 篇新闻,覆盖四大分类:

新闻标题 分类 来源
HarmonyOS NEXT 正式发布,全场景体验再升级 科技 华为官方
全球股市震荡:科技板块领跌,避险资产受追捧 财经 财经日报
世界杯预选赛:国足 2:1 击败对手,出线形势明朗 体育 体育周刊
热门剧集《星途》大结局收视率破纪录 娱乐 娱乐头条
AI 芯片需求爆发,国产 GPU 厂商加速追赶 科技 科技前沿
央行释放流动性信号,市场预期降准临近 财经 财经日报
NBA 季后赛:湖人队逆转晋级,詹姆斯砍下 40 分 体育 体育周刊
新综艺《声生不息》首播获好评,豆瓣评分 8.5 娱乐 娱乐头条
量子计算突破:中国团队实现 512 量子比特纠缠 科技 科技前沿
新能源汽车销量再创新高,渗透率突破 45% 财经 财经日报
城市马拉松热潮:上半年全国举办超 200 场赛事 体育 体育周刊
经典电影《花样年华》4K 修复版即将重映 娱乐 娱乐头条

三、下拉刷新实现

3.1 Refresh 嵌套 List

Refresh({ refreshing: $$this.refreshing }) {
  List() {
    ForEach(this.getFilteredNews(), (item: NewsItem, idx: number) => {
      ListItem() {
        // 新闻卡片
      }
    }, (item: NewsItem) => item.id.toString())
  }
  .width('100%')
  .layoutWeight(1)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.Spring)
}
.onRefreshing(() => { this.doRefresh(); })

layoutWeight(1) 让 List 占据剩余空间,edgeEffect(EdgeEffect.Spring) 提供回弹效果——当滚动到顶部继续下拉时,列表会产生弹性形变,这是触发 Refresh 的前奏。

3.2 刷新逻辑

doRefresh(): void {
  this.refreshing = true;
  setTimeout(() => {
    this.refreshing = false;
    promptAction.showToast({ message: '已刷新,暂无新内容', duration: 1500 });
  }, 1200);
}

Demo 中用 1.2 秒的延迟模拟网络请求。在真实应用中,这里应当调用后端 API 获取最新数据,并在成功回调中设置 this.refreshing = false。Demo 总是返回"暂无新内容"——因为数据是硬编码的,但 Toast 提示让用户明确知道刷新操作已生效。

3.3 Refresh 与 List 的协作机制

这里有一个重要的架构细节:getFilteredNews() 方法每次调用时都会从 this.allNews 重新计算过滤结果。为什么不在 @State results 中维护一个独立的过滤数组?

答案在于 ArkUI 的 @State 响应机制。如果维护两个独立的 @State 数组(allNewsresults),每次修改收藏或已读状态时,需要同时更新两个数组——这不仅增加代码复杂度,还容易出错。

使用"单一数据源 + 计算属性"模式:allNews 是唯一的真实数据,getFilteredNews() 是它的过滤视图。当用户切换分类或点击"仅看收藏"时,ArkUI 检测到 selectedCategoryshowBookmarked 的变化,自动重新渲染列表——无需手动维护中间状态。

四、分类筛选

4.1 横向标签栏

分类标签以横向滚动的标签栏展示:

Scroll() {
  Row() {
    ForEach(this.getCategories(), (cat: string, idx: number) => {
      Row() {
        Text(cat)
        if (this.getUnreadCount(cat) > 0) {
          Text(this.getUnreadCount(cat).toString())
            .backgroundColor(...)
        }
      }
      .backgroundColor(cat === this.selectedCategory ? '#1677FF' : '#FFFFFF')
      .onClick(() => { this.selectedCategory = cat; })
    })
  }
}
.scrollBar(BarState.Off)

选中分类使用蓝色填充 + 白色文字,未选中使用白色填充 + 灰色文字——视觉对比强烈,用户一眼即可识别当前位置。

每个标签旁边显示该分类的未读数量小徽标,让用户无需进入分类就能感知"这个分类里还有多少没看的"。这个徽标在选中和未选中状态下使用不同的颜色(白色半透明 vs 浅灰),保持视觉层次的一致性。

4.2 过滤器组合

分类筛选和收藏筛选可以叠加使用:

getFilteredNews(): NewsItem[] {
  let news = this.allNews.slice();
  if (this.selectedCategory !== '推荐') {
    news = news.filter((item: NewsItem) => item.category === this.selectedCategory);
  }
  if (this.showBookmarked) {
    news = news.filter((item: NewsItem) => item.bookmarked);
  }
  return news;
}

两个过滤器是"与"关系——先按分类缩小范围,再按收藏状态进一步过滤。用户可以在"科技"分类下仅看收藏文章,这在真实使用场景中非常有用(“我在科技分类下收藏了哪些文章?”)。

五、收藏与已读管理

5.1 收藏切换

每条新闻卡片右上角有一个星标图标,点击切换收藏状态:

Text(item.bookmarked ? '★' : '☆')
  .fontSize(18)
  .fontColor(item.bookmarked ? '#FAAD14' : '#CCCCDD')
  .onClick(() => { this.toggleBookmark(item.id); })

已收藏显示金色实心星标(★),未收藏显示灰色空心星标(☆)。收藏操作是即时生效的——点击卡片上的星标时,stopPropagation 不会阻止事件冒泡。因此需要注意:点击星标会同时触发 toggleBookmark(星标的 onClick)和 markRead(卡片的 onClick)。这实际上是合理的行为——用户收藏一篇文章时,通常也已经"阅读"了它(至少浏览了标题)。

5.2 已读标记

点击新闻卡片将文章标记为已读:

.onClick(() => { this.markRead(item.id); })

已读状态通过标题的字重体现:未读文章标题使用 FontWeight.Bold(加粗),已读文章使用 FontWeight.Normal(常规)。同时每张卡片上方会显示一个红色的"NEW"标记表明未读状态。

5.3 全部已读

工具栏右侧提供"全部已读"按钮(仅在有未读文章时显示):

if (this.getUnreadCount() > 0) {
  Text('全部已读')
    .onClick(() => { this.markAllRead(); })
}

markAllRead 将数组中所有对象的 read 字段置为 true,然后显示 Toast 确认:

markAllRead(): void {
  this.allNews = this.allNews.slice().map((item: NewsItem) => {
    return item.withRead(true);
  });
  promptAction.showToast({ message: '已全部标为已读', duration: 1500 });
}

5.4 未读计数

标题栏右侧显示全局未读总数,分类标签上显示各分类未读数。两个计数都通过 getUnreadCount() 方法实时计算:

getUnreadCount(category?: string): number {
  let news = this.allNews.slice();
  if (category) {
    news = news.filter((item: NewsItem) => item.category === category);
  }
  return news.filter((item: NewsItem) => !item.read).length;
}

不传参数时统计全部分类,传分类名时只统计该分类。随着用户浏览和标记已读,这些数字实时变化——这是 @State 响应式更新的直观体现。

六、页面状态管理

6.1 四种视图状态

页面存在四种可能的视图状态:

  1. 正常列表:有新闻数据,正常展示
  2. 空分类:某个分类下没有匹配的新闻
  3. 空收藏:开启了"仅看收藏"但无收藏文章
  4. 刷新中:下拉刷新加载指示器显示中

每种状态都有对应的 UI 反馈:

if (this.getFilteredNews().length === 0) {
  ListItem() {
    Column() {
      Text(this.showBookmarked ? '暂无收藏文章' : '暂无新闻')
      Text(this.showBookmarked ? '点击星标图标收藏文章' : '试试切换其他分类')
    }
  }
}

空状态不是空白页面——它明确告知用户当前状态并给出下一步建议。

6.2 收藏筛选切换

工具栏的"收藏"按钮是一个切换开关:

Row() {
  Text(this.showBookmarked ? '全部' : '收藏')
    .fontColor(this.showBookmarked ? '#1677FF' : '#888899')
}
.backgroundColor(this.showBookmarked ? '#F0F5FF' : '#F8F9FA')
.onClick(() => { this.showBookmarked = !this.showBookmarked; })

激活时按钮文字变为"全部"(点击返回全量视图),未激活时显示"收藏"(点击进入仅看收藏)。按钮的蓝色高亮状态明确告知用户当前处于筛选模式中。

6.3 工具栏

工具栏位于分类标签下方,提供三个信息/操作:

  • 结果计数(左):“共 X 条新闻”
  • 收藏切换(右):切换收藏筛选
  • 全部已读(右):一键标记全部为已读

工具栏的布局使用了 Blank() 将左侧计数和右侧按钮组隔开,信息层级清晰。

七、视觉效果与交互细节

7.1 新闻卡片设计

每条新闻卡片包含三个层次:

  1. 元信息行(顶部):分类标签(蓝底) + 来源名称(灰字) + NEW 标记(红字) + 时间(灰字) + 收藏星标(右对齐)
  2. 标题行(中部):15px 加粗/常规,最多 2 行
  3. 摘要行(下部):13px 灰色,最多 2 行

三行内容形成清晰的信息层级:先看分类和来源判断可信度,再看标题了解核心内容,最后看摘要获取细节。卡片之间用 Divider 分隔,避免拥挤感。

7.2 首尾卡片的圆角处理

多篇新闻以"卡片组"的形式展示——第一项有顶部圆角,最后一项有底部圆角(借用 BorderRadius.LG),中间的卡片为直角。当只有一条结果时,四个角都是圆角:

.borderRadius(idx === 0 && this.getFilteredNews().length === 1 ? BorderRadius.LG :
  (idx === 0 ? { topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 } : {}))

7.3 分类标签的未读徽标

分类标签中的未读计数使用了视觉分层:

backgroundColor(cat === this.selectedCategory ? '#FFFFFF30' : '#F2F3F5')

选中时使用白色半透明(#FFFFFF30),与蓝色选中背景形成层次对比;未选中时使用淡灰(#F2F3F5),与白色背景保持微妙差异。这个细节让 5 个分类标签的视觉处理各异但统一。

八、交互流程演示

8.1 浏览推荐页

进入页面时,默认选中"推荐"分类,展示全部 12 篇新闻。标题栏显示未读数"12",每个分类标签旁有小徽标显示各类别未读数。所有标题加粗显示(未读状态),卡片上有红色 NEW 标记。

8.2 下拉刷新

在列表顶部继续下拉。当超过阈值时,刷新指示器出现。1.2 秒后 Toast 提示"已刷新,暂无新内容"。

8.3 分类切换

点击"科技"分类标签。列表从 12 篇变为 3 篇(仅科技类新闻)。工具栏显示"共 3 条新闻"。点击"财经"标签,列表切换到 3 篇财经新闻。

8.4 阅读与收藏

点击一篇科技新闻。该新闻的标题从加粗变为常规(已读),NEW 标记消失。标题栏未读数从 12 变为 11,"科技"分类标签的未读徽标同步更新。

点击该新闻的星标(☆),变为金色(★),表示已收藏。再点击"收藏"筛选按钮,列表仅显示这一篇文章。

8.5 全部已读

返回"推荐"分类。点击"全部已读"按钮,所有文章的标题变为常规字重,NEW 标记全部消失,标题栏未读数归零。Toast 提示"已全部标为已读"。

九、总结

本文通过"新闻资讯流"这个实战案例,全面讲解了 ArkUI Refresh 组件的使用方法。核心知识点包括:

  1. Refresh 组件$$ 双向绑定 + onRefreshing 回调 + refreshing 状态管理
  2. List + Refresh 协作:List 作为 Refresh 子组件,提供滚动容器和下拉检测
  3. 分类筛选:横向滚动标签栏 + 未读计数徽标 + 多过滤器组合
  4. 收藏/已读管理:class 工厂方法替代对象展开 + slice().map() 触发响应式更新
  5. 四种视图状态:正常 / 空分类 / 空收藏 / 刷新中,每种有对应的 UI 反馈
  6. 单一数据源模式allNews 为唯一真实数据,getFilteredNews() 为计算视图

下拉刷新是移动应用中最值得打磨的交互之一。一个好的下拉刷新不仅仅是"能拉下来转圈"——它需要在最恰当的时机给用户最舒适的反馈,同时与列表筛选、状态管理等周边功能无缝协作。ArkUI 的 Refresh 组件处理了手势识别和动画帧的底层细节,让开发者可以专注于刷新逻辑、数据结构和 UI 反馈的设计。


Logo

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

更多推荐