在这里插入图片描述

前言:列表能显示,不代表它能在筛选后保持正确

待办页面的任务卡片看起来是最容易写的部分:遍历数组,展示一行标题,再放一个复选框。问题通常不在第一次渲染,而在数据开始变化以后。新任务插到顶部,复选框勾选到了另一项;切换“进行中”后再切回来,某张卡片仍保留旧样式;搜索结果变化时,列表滚动和内容区域互相挤压。

要避免这些问题,不能把 ForEach 当成“普通循环”。在可交互列表中,它要和稳定身份、滚动容器、单项承载组件、状态写入路径一起设计。本文以鸿蒙 ArkUI 待办面板中的真实页面结构为例,讲清 ListForEachListItem 和任务卡片各自该负责什么,以及为什么 key、布局权重和事件回调都是列表正确性的一部分。

一、先决定谁负责滚动,谁负责生成,谁负责展示

当前任务区域采用四层结构:

List
  └── ForEach(可见任务)
        └── ListItem
              └── TodoCard(一条任务)

它们并非可以随意互换。

  • List 是列表容器,负责承载一组可滚动的条目;
  • ForEach 根据当前的任务数组生成多个子节点;
  • ListItem 表示列表中的单个项目,让每张卡片处于正确的列表语义中;
  • TodoCard 负责一条任务的排版、文本和交互控件。

当前页面代码如下:

List({ space: 12 }) {
  ForEach(this.getVisibleTodos(), (item: TodoItem): void => {
    ListItem() {
      this.TodoCard(item);
    }
  }, (item: TodoItem): string => `todo-${item.id}`);
}
.width('100%')
.height('100%')
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring);

space: 12 让卡片之间留出统一间距;layoutWeight(1) 让列表在顶部摘要和输入区占用高度后填满剩余空间;edgeEffect(EdgeEffect.Spring) 提供边缘滚动反馈。这里的重点不是具体数值,而是把“可滚动内容区”限制在右侧主区域,避免整个页面和列表同时争抢高度。

二、稳定 key 为什么不能用数组下标代替

ForEach 的第三个参数为每条任务提供 key:

(item: TodoItem): string => `todo-${item.id}`

这行代码的意义不只是让语法完整。列表在新增、筛选、搜索和完成任务后都会重新排列。例如当前数组是 [1, 2, 3],在顶部插入新任务后变成 [4, 1, 2, 3]。如果 key 使用下标,原先下标 0 对应任务 1,插入后却对应任务 4;框架复用节点时就可能把前一项的局部表现错误地带给后一项。

任务 id 才是稳定身份。任务从“进行中”切换到“已完成”,它的数组位置和是否可见会变,但 id 不变。因此 key 使用 todo-${item.id} 能把渲染节点与任务身份绑定,而不是与短暂位置绑定。

下面的写法应尽量避免:

// 不建议:排序、筛选或插入后,下标含义会变化
ForEach(this.getVisibleTodos(), (item, index) => {
  // ...
}, (item, index) => index.toString());

只有列表元素天然没有身份、永远不会插入删除重排时,下标才勉强可用。待办任务显然不满足这个前提。

三、把每张卡片做成只依赖 item 的展示单元

当前工程通过 @Builder 定义 TodoCard(item),将一条任务的显示集中起来:左边是复选框,中间是标题、分类和时间标签,右边是优先级标签。

@Builder
private TodoCard(item: TodoItem): void {
  Row({ space: 14 }) {
    Checkbox({ name: `todo-${item.id}`, group: 'todos' })
      .select(item.done)
      .selectedColor($r('app.color.primary'))
      .onChange((value: boolean): void => {
        this.toggleTodo(item.id, value);
      });

    Column({ space: 6 }) {
      Text(item.title)
        .fontSize(17)
        .fontWeight(FontWeight.Medium)
        .fontColor(item.done ? $r('app.color.text_done') : $r('app.color.text_primary'))
        .decoration({ type: item.done ? TextDecorationType.LineThrough : TextDecorationType.None });
      // 分类与时间标签
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1);

    Text(`${item.priority}优先级`)
      .fontSize(12)
      .fontColor(this.priorityColor(item.priority));
  }
  .width('100%')
  .padding(18)
  .borderRadius(14)
  .backgroundColor($r('app.color.card_background'));
}

卡片拿到的输入是 item,由它决定标题、完成样式、分类、时间和优先级。卡片不再另外声明一个 @State checked 来保存勾选结果;复选框的 select(item.done) 永远从任务源数据读取。事件发生时把新的布尔值交给页面层,由页面统一更新数组。

这是一条很实用的规则:卡片负责展示和发出动作,页面负责修改集合。 一旦让每张卡片维护自己的完成状态,筛选、复用、重新渲染时就容易产生两份不同的真相。

四、完成状态不只改变复选框

勾选任务后,用户应该立即能理解发生了什么。当前卡片通过文本颜色和删除线表达完成状态:

.fontColor(item.done ? $r('app.color.text_done') : $r('app.color.text_primary'))
.decoration({
  type: item.done ? TextDecorationType.LineThrough : TextDecorationType.None
});

这样做有两个好处。第一,任务是否完成不依赖用户是否注意到小复选框;第二,文本仍然存在,用户可以回顾已完成事项,而不是把它们彻底隐藏。颜色来自资源文件中的 text_primarytext_done 等命名资源,而非散落的十六进制值;后续调整主题时,页面逻辑不需要跟着修改。

优先级也采用文字加颜色的组合:高优先级普通优先级低优先级。仅用红黄绿圆点对色觉差异用户不够友好,文字能补足含义。注意,优先级颜色由 priorityColor() 映射,而不是让卡片自己判断一长串样式分支;同一种业务规则应有集中出口。

五、中间内容为什么要使用 layoutWeight

一条卡片内部有固定宽度倾向的控件:左侧复选框、右侧优先级标签。标题和分类区域的宽度则应该随可用空间变化,因此中间 Column 使用:

.layoutWeight(1)
.alignItems(HorizontalAlign.Start)

layoutWeight(1) 让中间区域占用剩余宽度。没有它时,长标题可能和右侧标签挤在一起;把宽度写死,又会在不同窗口尺寸中留下空洞或发生截断。宽屏待办面板的核心不是给每个元素设置一堆固定像素,而是识别哪些部分固定、哪些部分弹性。

页面外层也遵循同一逻辑:左侧导航设置 220 的宽度,右侧内容列 layoutWeight(1);右侧顶部的标题区域再 layoutWeight(1),搜索框使用固定的 260 宽度。层层分配后,搜索框和添加按钮保持可用,任务列表仍能拿到剩余空间。

六、列表只渲染“当前可见任务”

当前 ForEach 的输入不是 this.todos,而是 this.getVisibleTodos()。这意味着列表组件不关心用户是因为筛选、搜索还是任务完成才看到这一批卡片;它只接收已经计算好的可见结果。

ForEach(this.getVisibleTodos(), (item: TodoItem): void => {
  ListItem() {
    this.TodoCard(item);
  }
}, (item: TodoItem): string => `todo-${item.id}`);

这种分工很重要。若在卡片里再判断“这是不是已完成”“标题是否包含搜索词”,一方面重复了查询规则,另一方面会让列表计数、空状态和实际卡片采用不同条件。筛选属于页面层的派生数据,卡片只展示传进来的任务。

getVisibleTodos().length === 0 时,页面不创建空 List,而是显示一个解释性的空状态。它告诉用户可以清空搜索词、切换条件或添加任务。对列表来说,这不是额外装饰,而是和数据结果同样重要的分支:没有匹配项与还没有任何任务,是两种不同情境。

七、从事件到重绘:复选框应该走完整链路

勾选一项任务时,推荐按以下路径理解:

Checkbox.onChange(value)
        ↓
toggleTodo(item.id, value)
        ↓
this.todos = 新数组
        ↓
getVisibleTodos() 重新计算
        ↓
ForEach 重新根据稳定 key 渲染卡片

这条路径说明为什么不必在 TodoCard 中手动处理“从进行中列表移除”。只要 done 已写回 todos,当当前筛选是“进行中”时,该任务自然不再满足条件;当切换到“已完成”时,又会自然出现在相应列表。UI 的变化来自数据和规则,而不是多个组件互相通知。

八、建议按这些场景检查列表是否可靠

  1. 新增任务到顶部:新增后,原有卡片的勾选状态和内容不能错位;这能检查 key 是否稳定。
  2. 筛选后勾选任务:在“进行中”中勾选,当前卡片应消失,计数同步变化;这能检查事件是否回写源数组。
  3. 搜索后切换筛选:列表只显示同时满足两个条件的项;这能检查 ForEach 是否使用了统一派生结果。
  4. 长标题:标题区域应获得弹性空间,右侧优先级标签不应被挤出容器;这能检查 layoutWeight 的分配。
  5. 没有匹配项:应显示可理解的空状态,而不是一片白色区域;这能检查结果分支是否完整。
  6. 已完成任务:复选框、删除线、颜色应同时反映 done,避免只有一种视觉提示。

当前模拟器画面

在这里插入图片描述

这张图对应当前模拟器已验证的运行场景;它不替代签名、发布链接或未实现功能的验证。

小结

鸿蒙 ArkUI 中的 List + ForEach 不只是把数组“画出来”。稳定 key 让节点跟随任务身份,ListItem 保持每条记录的列表结构,TodoCard 集中展示一条任务,layoutWeight 处理弹性空间,复选框事件则回到页面层修改唯一任务数组。

把这些职责分清后,筛选、搜索、新增和完成状态变化都不会破坏列表。等以后再加入编辑、删除或拖拽排序时,也能继续围绕稳定 id 和单一数据源扩展,而不必推倒当前的卡片结构。

Logo

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

更多推荐