在这里插入图片描述
········# 鸿蒙原生 ArkTS 布局实战:List + multipleSelect 多选列表(多选删除 / 批量操作)

示例工程版本:HarmonyOS NEXT 6.1.1(API 24)
开发框架:ArkTS(Stage 模型)
布局主题:List + multipleSelect 多选列表(多选删除 / 批量操作)

一、前言:为什么需要多选列表

在移动应用开发中,列表(List)是最常见、最重要的 UI 容器之一。无论是社交应用的消息流、电商应用的购物车、文件管理器的文件清单,还是办公应用的任务看板,几乎所有涉及「数据集合展示」的场景都离不开列表组件。而当列表需要与用户进行批量交互——例如多选删除、批量标记已读、批量归档、批量移动——时,多选列表便成为必不可少的交互模式。

试想一下:一个拥有上百条待办事项的应用,如果只能一条一条地删除,用户需要重复执行上百次「点击—确认—等待」的循环,体验无疑是灾难性的。而多选列表允许用户先批量勾选目标项,再一次性执行操作,把上百次重复操作压缩成两次点击,交互效率提升数十倍。这就是多选列表的核心价值所在。

在鸿蒙生态中,ArkUI 框架为开发者提供了完整的原生多选能力:List 容器负责高性能的虚拟滚动渲染,ListItem 通过 selectable 属性开启选中交互,onSelect 回调负责状态同步,Checkbox 组件提供直观的视觉反馈。开发者无需引入任何第三方库,仅用原生组件即可搭建出流畅、完整的批量操作体验。

本文将从一个真实的「待办事项多选删除」示例应用出发,系统拆解 List + multipleSelect 多选列表的完整实现:从数据模型设计、状态管理,到核心交互机制、业务闭环,再到性能优化与常见问题,力图覆盖多选列表开发的全生命周期。文中所有代码均来自可直接运行的示例工程,读者可以在 DevEco Studio 中新建工程、复制代码、直接运行体验。

二、场景与需求分析

本文示例围绕「待办事项管理」这一经典场景展开。用户每天产生大量待办事项,当事项积压时,需要支持批量删除以快速清理列表。

经过分析,我们抽象出以下核心需求:

需求编号 需求描述 优先级
R1 点击「选择」按钮进入多选模式
R2 多选模式下,点击列表项任意区域即可选中 / 取消选中
R3 支持「全选 / 取消全选」一键操作
R4 实时统计已选数量,并反馈到界面
R5 删除前弹出确认对话框,防止误操作
R6 批量删除后自动清理选中状态,删空后自动退出多选模式
R7 列表为空时展示友好的空状态占位

这七条需求覆盖了多选列表的典型交互闭环:进入模式 → 点选 / 全选 → 实时反馈 → 二次确认 → 批量执行 → 状态清理。掌握了这条链路,无论是邮件批量管理、文件多选删除,还是购物车批量结算,都可以快速迁移实现。

三、技术栈概览

本示例的核心技术组合如下:

技术组件 角色说明
List 高性能虚拟滚动列表容器,负责列表布局与节点复用
ListItem 列表项容器,承载每一项的具体内容,是多选交互的核心载体
selectable() ListItem 属性,开启列表项的「可选中」交互能力
onSelect() ListItem 属性,选中 / 取消选中状态变化时的回调
Checkbox 复选框组件,直观展示选中 / 未选状态
ForEach 循环渲染指令,绑定数据源与 UI,支持 key 复用优化
@State 状态装饰器,驱动 UI 响应式刷新
Set<number> 选中 ID 集合,O(1) 判定、天然去重
promptAction 系统弹窗能力,用于删除前的确认提示

其中,selectable + onSelect 是 ListItem 组件独有的原生多选机制,也是本示例的灵魂所在;Checkbox 与其形成「交互 + 反馈」的互补关系,二者共同构成完整的双通道事件体系,这一点会在后文重点展开。

四、开发环境准备

在开始编码之前,请先确认开发环境满足以下条件:

  1. DevEco Studio:建议使用 6.x 及以上版本,内置 HarmonyOS NEXT SDK 的管理能力;
  2. HarmonyOS NEXT SDK:本文示例基于 HarmonyOS NEXT 6.1.1(API 24)开发与验证;
  3. 工程模型:采用 Stage 模型(apiType: "stageMode"),这也是 HarmonyOS NEXT 唯一支持的工程模型;
  4. 语言特性:ArkTS 是 TypeScript 的超集,在标准 TS 语法之上增加了装饰器(@Entry@Component@State 等)与严格的类型约束(例如不支持 any、对象字面量需要显式类型等)。

创建工程时,选择「Empty Ability」模板即可得到一个最简页面骨架。本文示例只有单个页面,所有代码集中在 entry/src/main/ets/pages/ 目录下的一个 .ets 文件中,便于读者完整阅读与直接运行。

这里补充一个关键概念:ArkTS 的装饰器体系。装饰器是 ArkTS 声明式 UI 的核心语法,按作用可分为三类:

  1. 页面级装饰器@Entry,标记一个 @Component 结构体为页面入口,一个 .ets 文件中只能有一个;
  2. 组件级装饰器@Component,声明一个自定义组件,其 build() 方法描述 UI 结构;
  3. 状态级装饰器@State,声明响应式状态变量,此外还有 @Prop@Link@Provide / @Consume 等用于不同范围的组件间状态传递。

理解「数据变了,UI 自动变」的响应式模型,是读懂本示例全部代码的前提——本示例中的按钮组切换、复选框勾选、数量统计,本质上都是 @State 变量变化驱动的自动刷新,代码里没有一行手动操作 DOM 的逻辑。

五、项目结构说明

示例工程采用 Stage 模型的标准目录结构,与多选列表相关的文件只有一个页面文件。整体结构如下:

MyApplication11/
├── AppScope/                          # 应用级配置(应用名称、图标等)
│   └── app.json5
├── entry/                             # entry 模块(可独立安装运行的模块)
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/          # 应用入口 Ability(Stage 模型的启动单元)
│       │   │   └── EntryAbility.ets
│       │   └── pages/                 # 页面目录
│       │       └── ListMultipleSelectDemo.ets   # ★ 本文的核心示例页面
│       ├── module.json5               # 模块配置(注册 Ability、声明页面路由)
│       └── resources/                 # 资源目录(字符串、颜色、媒体等)
│           └── base/profile/main_pages.json      # 页面路由表(注册页面)
├── build-profile.json5                # 工程级构建配置(SDK 版本等)
└── hvigorfile.ts                      # 构建脚本入口

页面文件 ListMultipleSelectDemo.ets 通过 @Entry 装饰器声明为页面入口,并在路由表 main_pages.json 中注册后即可运行。本示例将多选列表页面注册为首页,运行应用即可直接看到效果。

六、数据模型设计

良好的数据模型是多选列表功能正确运行的基础。我们使用 interface 定义一个「待办事项」类型:

/**
 * 数据模型:待办事项
 * id    —— 唯一标识(主键):既用作 ForEach 的 keyGenerator,也用作选中集合的判定依据
 * title —— 事项标题
 * time  —— 创建 / 截止时间
 * tag   —— 标签分类(渲染为列表项右上角「徽章」效果)
 */
interface TodoItem {
  id: number;
  title: string;
  time: string;
  tag: string;
}

设计时有三个关键原则:

  1. id 必须稳定且唯一:它是列表复用的 key,也是选中集合的元素。绝不能用数组下标代替 id——删除操作会导致下标错位,进而引发渲染错乱(详见后文「常见问题」);
  2. 数据模型只描述业务数据:任何与 UI 相关的状态(如「是否选中」)都不应塞进数据模型,而是由专门的状态变量管理,保证模型纯净、职责单一;
  3. 显式类型声明:ArkTS 要求接口或类必须显式定义,避免使用匿名的松散对象,这样在编译期就能发现字段拼写错误,提升代码健壮性。

七、状态管理设计:@State + Set

多选列表的状态管理是整个功能的中枢。本示例使用四个 @State 响应式变量:

@State private todoList: TodoItem[] = [];               // 列表数据源
@State private selectedIds: Set<number> = new Set<number>(); // 选中项 ID 集合
@State private isSelectingMode: boolean = false;         // 是否处于选择模式
@State private isAllSelected: boolean = false;           // 是否已全选
  • todoList:列表数据源,增删数据直接修改此数组,@State 会驱动 UI 响应式刷新;
  • selectedIds:选中项的 ID 集合。选用 Set 而非数组,是因为 Set.has(id) 是 O(1) 查找、天然去重、增删语义清晰,在大量数据场景下性能优势明显;
  • isSelectingMode:控制顶部操作栏按钮、复选框的显示,以及列表项的选中能力开关;
  • isAllSelected:用于「全选 / 取消全选」按钮文字的动态切换。

这里有一个新手极易踩坑的 ArkTS 特性:Set 是引用类型,直接调用 add() / delete() 不会改变对象引用,@State 无法感知变化,UI 不会刷新。正确的做法是每次修改后重新赋值一份新的 Set

this.selectedIds.add(id);                  // 第一步:修改内容
this.selectedIds = new Set<number>(this.selectedIds); // 第二步:重新赋值,触发 UI 刷新

这是整个示例中最容易遗漏的一行代码,请务必牢记:凡是用 @State 修饰的容器类型(数组、Set、Map 等),修改内容后都必须整体重新赋值,才能触发响应式更新

八、页面整体布局:三层结构

页面采用「自上而下的三层结构」布局,层次清晰、关注点分离:

Column(整体容器,灰色背景)
├── 第 1 层:顶部操作栏(Row)
│    ├── 标题文本(layoutWeight(1) 占满剩余空间,实现右对齐)
│    └── 操作按钮(选择 / 全选 / 删除 / 完成,随模式动态切换)
├── 第 2 层:统计信息行(Row)
│    ├── 左侧:「共 N 项」总数
│    └── 右侧:「已选 M 项」实时统计(Blank() 实现两端对齐)
└── 第 3 层:列表主体(List)
     ├── ForEach 循环渲染 ListItem × N
     └── 每个 ListItem = Checkbox(选择模式才显示) + 文字信息区

选择这种分层结构的优势:

  1. 关注点分离:操作栏管交互入口,统计行管反馈,列表管展示,每层职责单一;
  2. 响应式友好:状态变化在层间单向传递,不会产生级联重排;
  3. 易于扩展:后续无论是加搜索框、加下拉刷新,还是在中间插入新层,都不会影响既有结构。

其中「第 3 层列表主体」是本示例的核心,我们在下一节开始逐层实现。

九、顶部操作栏实现

顶部操作栏是多选交互的「指挥中心」。它的核心设计是根据 isSelectingMode 动态切换两组按钮:普通模式下只有一个「选择」按钮;进入选择模式后,按钮组替换为「全选 / 取消全选」「删除」「完成」。

Row({ space: 8 }) {
  // layoutWeight(1):让标题占满水平剩余空间,把按钮全部挤到右侧(右对齐)
  Text(this.isSelectingMode ? '选择事项' : '待办事项')
    .fontSize(20)
    .fontWeight(FontWeight.Bold)
    .fontColor('#1A1A1A')
    .layoutWeight(1)

  if (this.isSelectingMode) {
    // 「全选 / 取消全选」:按钮文字随 isAllSelected 状态动态切换,降低用户认知负担
    Button(this.isAllSelected ? '取消全选' : '全选')
      .fontSize(14)
      .height(32)
      .backgroundColor('#E8F0FE')
      .fontColor('#007AFF')
      .onClick(() => this.toggleSelectAll())

    // 「删除」:enabled 与 opacity 联动——无选中项时按钮置灰且不可点击
    Button('删除')
      .fontSize(14)
      .height(32)
      .backgroundColor('#FF3B30')
      .enabled(this.selectedIds.size > 0)
      .opacity(this.selectedIds.size > 0 ? 1 : 0.4)
      .onClick(() => this.deleteSelectedItems())

    Button('完成')
      .fontSize(14)
      .height(32)
      .backgroundColor('#007AFF')
      .onClick(() => this.exitSelectMode())
  } else {
    Button('选择')
      .fontSize(14)
      .height(32)
      .backgroundColor('#007AFF')
      .onClick(() => this.enterSelectMode())
  }
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 8 })

实现要点有三个:

  1. layoutWeight(1) 布局技巧:标题设置 layoutWeight(1) 后会自动占满 Row 中的剩余宽度,从而把按钮自然推到最右侧,无需手动计算间距;
  2. 条件渲染切换按钮组:ArkUI 支持在 build() 中使用 if / else 条件渲染,切换 isSelectingMode 时框架会自动增删对应节点;
  3. 禁用态双重反馈:删除按钮同时使用 enabled(false)(拦截点击)与 opacity(0.4)(视觉置灰),避免出现「按钮看起来可点、点下去却没反应」的困惑。

十、统计信息行

统计行给用户提供「实时反馈」,是多选体验的加分项:随时能看到列表总数与已选数量,心里有数。

Row() {
  Text(`${this.todoList.length}`)
    .fontSize(13)
    .fontColor('#999999')
  Blank() // 占满中间剩余空间,实现左右两端对齐
  if (this.isSelectingMode) {
    Text(`已选 ${this.selectedIds.size}`)
      .fontSize(13)
      .fontWeight(FontWeight.Medium)
      .fontColor(this.selectedIds.size > 0 ? '#007AFF' : '#999999')
  } else {
    Text('点击「选择」进入多选模式')
      .fontSize(13)
      .fontColor('#CCCCCC')
  }
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 8 })

这里用到两个 ArkUI 细节:

  1. Blank() 弹性空白:在 Row 中插入 Blank() 会自动占据所有剩余空间,实现「左文本、右文本」的两端对齐布局,比手动加 margin 更优雅;
  2. 模板字符串插值:ArkTS 支持 `共 ${this.todoList.length} 项` 形式的模板字符串,@State 变量变化时文本会自动刷新——「已选 N 项」的数量就是这样实时更新的。

十一、列表主体:List + ForEach + ListItem

列表主体是多选交互的舞台,也是本文「List + multipleSelect」布局的核心。完整的列表构建代码如下:

@Builder
buildList() {
  List({ space: 12 }) { // space:列表项之间的间距,单位 vp
    ForEach(
      this.todoList, // 数据源
      (item: TodoItem) => {
        ListItem() {
          Row() {
            // Checkbox 仅在选择模式下显示,负责选中状态的视觉反馈
            if (this.isSelectingMode) {
              Checkbox()
                .select(this.selectedIds.has(item.id)) // 选中态与选中集合双向绑定
                .shape(CheckBoxShape.ROUNDED_SQUARE)   // 圆角方形外观
                .selectedColor('#007AFF')              // 选中时的填充色
                .onChange((value: boolean) => this.updateSelection(item.id, value))
            }

            // 文字信息区:标题 +(时间 + 标签徽章)的纵向嵌套布局
            Column() {
              Text(item.title)
                .fontSize(16)
                .fontWeight(FontWeight.Medium)
                .fontColor('#1A1A1A')
                .maxLines(1)                                        // 单行显示
                .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超长省略号

              Row({ space: 8 }) {
                Text(item.time).fontSize(12).fontColor('#999999')
                // 「标签徽章」:背景色 + 圆角模拟
                Text(item.tag)
                  .fontSize(11)
                  .fontColor('#007AFF')
                  .backgroundColor('#E8F0FE')
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              }
              .margin({ top: 6 })
            }
            .layoutWeight(1) // 文字区自动填满剩余宽度
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
          }
          .width('100%')
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
          .borderRadius(10) // 卡片圆角
          .backgroundColor(this.selectedIds.has(item.id) ? '#E8F0FE' : '#FFFFFF') // 选中项高亮
        }
        // ===== 核心①:开启该项的「可选中」交互能力 =====
        .selectable(this.isSelectingMode)
        // ===== 核心②:选中状态变化回调(闭包携带 item.id,不依赖数组下标)=====
        .onSelect((isSelected: boolean) => this.updateSelection(item.id, isSelected))
      },
      // keyGenerator:必须使用数据实体的唯一 id,而非数组索引
      (item: TodoItem) => item.id.toString()
    )
  }
  .width('100%')
  .layoutWeight(1) // 填满剩余空间,保证列表可滚动
  .padding({ left: 16, right: 16, bottom: 16 })
  .scrollBar(BarState.Off) // 隐藏滚动条,保持界面清爽
}

这段代码里有几个容易忽略但至关重要的细节:

  1. List 必须占据确定高度List 的滚动依赖明确的高度约束。这里通过 layoutWeight(1) 让它填满父容器剩余空间,若不给高度,列表将无法滚动(这是高频踩坑点,后文 FAQ 会再次提到);
  2. @Builder 方法封装:将列表构建逻辑抽到 @Builder buildList() 中,让 build() 主体保持精简,也便于在「空状态」与「列表」之间做条件切换;
  3. 选中项高亮backgroundColor 根据 selectedIds.has(item.id) 动态切换颜色,用户能一眼看出哪些项已被选中,配合 Checkbox 形成双重视觉反馈;
  4. keyGenerator 用 id 不用索引:删除操作后数组索引会变化,用索引做 key 会导致节点复用错位、渲染异常,用唯一 id 才能保证 diff 精准。

十二、核心机制详解:selectable 与 onSelect

多选功能之所以「原生」,靠的就是 ListItem 的这两个独门属性:

ListItem() {
  // ... 列表项内容
}
.selectable(true) // 开启该列表项的「可选中」交互能力
.onSelect((isSelected: boolean) => {
  // isSelected:本次切换后该项的选中状态(true 选中 / false 取消)
  this.updateSelection(item.id, isSelected);
})

selectable(value: boolean) 的作用:控制列表项是否参与选中交互。置为 true 后,系统会自动为该列表项挂载选中相关的事件监听与状态管理——用户点击列表项的任意区域,都会触发选中状态的切换。置为 false(默认值)时,列表项不响应选中逻辑,点击事件会正常透传给内部子组件。

onSelect(event) 的作用:当列表项的选中状态发生变化时触发,回调参数 isSelected 是该项切换后的最新状态。开发者在这里拿到状态变化,同步更新自己的业务数据。

本示例做了一个值得借鉴的细节处理——selectableisSelectingMode 绑定

.selectable(this.isSelectingMode)

这样在普通模式下,列表项完全不可选中,点击不会产生任何「隐藏选中」;只有进入选择模式后,选中交互才真正生效。如果始终 selectable(true),用户会在非选择模式下误触产生选中状态,而界面上又没有 Checkbox 显示,极易造成数据混乱。

另外注意:onSelect 回调中优先使用闭包捕获的 item.id 而不是回调下标。虽然部分版本的回调会携带 index 参数,但删除操作后 index 会错位,用 id 才能保证状态同步永远准确。

十三、Checkbox 组件与「双通道事件」设计

Checkbox 负责多选状态的「视觉反馈」:select() 控制勾选与否,selectedColor() 控制选中颜色,shape() 控制外形(圆形或圆角方形)。

Checkbox()
  .select(this.selectedIds.has(item.id)) // 从选中集合读取状态,双向绑定
  .shape(CheckBoxShape.ROUNDED_SQUARE)
  .selectedColor('#007AFF')
  .onChange((value: boolean) => this.updateSelection(item.id, value))

本示例采用了双通道事件设计,这也是多选列表易错点最多的位置:

路径 A:点击 ListItem 任意区域  ──►  onSelect 回调  ──►  updateSelection(item.id, isSelected)
路径 B:点击 Checkbox 组件      ──►  onChange 回调  ──►  updateSelection(item.id, value)
                              (两条路径最终汇入同一个方法)

为什么需要两条路径?

  • 路径 A 让用户点击列表项任意位置都能切换选中状态,操作面积大、效率高,符合「手指点到哪都能选」的直觉;
  • 路径 B 提供精确点击 Checkbox 的入口,符合传统多选交互习惯,也方便无障碍读屏聚焦;
  • 两条路径互为备份,最终都汇入同一个 updateSelection() 方法,保证无论从哪条路径进入,状态永远一致。

这里有新手最容易踩的坑:如果两条路径各自维护状态而互不同步,就会出现「点击一次、状态翻转两次」的诡异现象。解决办法就是本文的做法——所有选中逻辑收敛到一个方法里,且 Set.add() 本身幂等(重复添加无副作用),天然免疫重复触发。

十四、业务逻辑实现:从模式切换到批量删除

多选列表的业务逻辑围绕「选择模式」展开,全部封装在组件方法中,数据单向流动、职责清晰。

1. 进入 / 退出选择模式

/** 进入选择模式:先清空历史选中记录,避免遗留脏数据 */
private enterSelectMode(): void {
  this.isSelectingMode = true;
  this.selectedIds.clear();
  this.isAllSelected = false;
  this.selectedIds = new Set<number>(this.selectedIds); // 重新赋值以触发 UI 刷新
}

/** 退出选择模式(点击「完成」按钮时调用) */
private exitSelectMode(): void {
  this.isSelectingMode = false;
  this.selectedIds.clear();
  this.isAllSelected = false;
  this.selectedIds = new Set<number>(this.selectedIds);
}

2. 选中状态同步中枢

updateSelection 是整个多选逻辑的中枢方法,两条事件通道都汇入此处:

private updateSelection(id: number, value: boolean): void {
  if (value) {
    this.selectedIds.add(id);
  } else {
    this.selectedIds.delete(id);
  }
  // 关键点:Set 是引用类型,必须重新赋值才能触发 @State 刷新
  this.selectedIds = new Set<number>(this.selectedIds);
  // 每次变化后重算全选状态,防止「全选」按钮文字滞后
  this.isAllSelected = this.selectedIds.size === this.todoList.length;
}

3. 全选 / 取消全选

private toggleSelectAll(): void {
  if (this.isAllSelected) {
    this.selectedIds.clear(); // 取消全选
    this.isAllSelected = false;
  } else {
    this.todoList.forEach((item: TodoItem) => { // 全选:遍历数据源逐个加入
      this.selectedIds.add(item.id);
    });
    this.isAllSelected = true;
  }
  this.selectedIds = new Set<number>(this.selectedIds);
}

4. 批量删除(带确认弹窗)

不可逆操作必须二次确认,这是移动端交互的铁律。借助 promptAction.showDialog 可以轻松实现系统级确认弹窗:

private deleteSelectedItems(): void {
  if (this.selectedIds.size === 0) {
    return; // 无选中项时按钮已禁用,此处为兜底保护
  }
  promptAction.showDialog({
    title: '确认删除',
    message: `确定要删除选中的 ${this.selectedIds.size} 项吗?此操作不可撤销。`,
    buttons: [
      { text: '取消', color: '#666666' }, // 取消在左,使用中性色
      { text: '删除', color: '#FF3B30' }  // 确认在右,使用警示红色
    ]
  }).then((result: promptAction.ShowDialogSuccessResponse) => {
    if (result.index === 1) { // 用户点击了「删除」
      this.doDelete();
    }
  });
}

/** 执行实际删除:过滤数据源 + 清理选中状态 + 空列表自动退出选择模式 */
private doDelete(): void {
  this.todoList = this.todoList.filter((item: TodoItem) => !this.selectedIds.has(item.id));
  this.selectedIds.clear();
  this.isAllSelected = false;
  this.selectedIds = new Set<number>(this.selectedIds);
  if (this.todoList.length === 0) { // 列表删空后自动退出选择模式
    this.isSelectingMode = false;
  }
}

弹窗设计有两个细节值得借鉴:一是按钮顺序遵循平台惯例(取消在左、确认在右),二是颜色语义化(取消用中性灰、删除用警示红),让用户在没有阅读文字的情况下也能凭颜色感知操作的风险等级。至此,从「进入模式」到「批量删除」的业务闭环已经全部打通。

十五、完整代码

下面是示例页面的完整代码,位于 entry/src/main/ets/pages/ListMultipleSelectDemo.ets。将其复制到工程中,并在路由表 main_pages.jsonsrc 数组中注册 "pages/ListMultipleSelectDemo",运行即可体验完整的多选删除功能。

/**
 * ListMultipleSelectDemo.ets
 * 鸿蒙原生 ArkTS 布局方式之「List + multipleSelect 多选列表」
 * 场景:待办事项列表的「多选删除 / 批量操作」
 */
import { promptAction } from '@kit.ArkUI';
// List / ListItem / Checkbox 等均为 ArkUI 内置全局组件,无需 import

/** 数据模型:待办事项 */
interface TodoItem {
  id: number;      // 唯一标识(主键):keyGenerator 与选中集合的判定依据
  title: string;   // 事项标题
  time: string;    // 创建 / 截止时间
  tag: string;     // 标签分类(渲染为徽章)
}

@Entry
@Component
struct ListMultipleSelectDemo {
  // ============ 响应式状态变量 ============
  @State private todoList: TodoItem[] = [
    { id: 1, title: '完成项目需求文档评审', time: '08-12 09:00', tag: '工作' },
    { id: 2, title: '修复登录页闪退问题', time: '08-12 10:30', tag: '开发' },
    { id: 3, title: '制定本周团队周会议程', time: '08-12 14:00', tag: '会议' },
    { id: 4, title: '提交 Q3 季度绩效自评', time: '08-12 16:30', tag: '工作' },
    { id: 5, title: '整理客户反馈意见表', time: '08-13 09:30', tag: '产品' },
    { id: 6, title: '更新用户操作手册', time: '08-13 11:00', tag: '文档' },
    { id: 7, title: '预约牙科复诊', time: '08-13 15:00', tag: '生活' },
    { id: 8, title: '给家人订周末出行车票', time: '08-14 10:00', tag: '生活' }
  ];
  /** 选中项 ID 集合:O(1) 查找、天然去重 */
  @State private selectedIds: Set<number> = new Set<number>();
  /** 是否处于选择模式 */
  @State private isSelectingMode: boolean = false;
  /** 是否已全选 */
  @State private isAllSelected: boolean = false;

  // ============ 业务逻辑 ============
  /** 进入选择模式:清空历史选中记录 */
  private enterSelectMode(): void {
    this.isSelectingMode = true;
    this.selectedIds.clear();
    this.isAllSelected = false;
    this.selectedIds = new Set<number>(this.selectedIds); // 重新赋值以触发 UI 刷新
  }

  /** 退出选择模式 */
  private exitSelectMode(): void {
    this.isSelectingMode = false;
    this.selectedIds.clear();
    this.isAllSelected = false;
    this.selectedIds = new Set<number>(this.selectedIds);
  }

  /**
   * 选中状态同步中枢:
   * ListItem.onSelect 与 Checkbox.onChange 两条事件通道都汇入此方法(幂等)
   */
  private updateSelection(id: number, value: boolean): void {
    if (value) {
      this.selectedIds.add(id);
    } else {
      this.selectedIds.delete(id);
    }
    // Set 是引用类型,必须重新赋值才能触发 @State 刷新
    this.selectedIds = new Set<number>(this.selectedIds);
    this.isAllSelected = this.selectedIds.size === this.todoList.length;
  }

  /** 全选 / 取消全选(互斥操作) */
  private toggleSelectAll(): void {
    if (this.isAllSelected) {
      this.selectedIds.clear();
      this.isAllSelected = false;
    } else {
      this.todoList.forEach((item: TodoItem) => {
        this.selectedIds.add(item.id);
      });
      this.isAllSelected = true;
    }
    this.selectedIds = new Set<number>(this.selectedIds);
  }

  /** 批量删除:先弹确认框,防止误操作 */
  private deleteSelectedItems(): void {
    if (this.selectedIds.size === 0) {
      return;
    }
    promptAction.showDialog({
      title: '确认删除',
      message: `确定要删除选中的 ${this.selectedIds.size} 项吗?此操作不可撤销。`,
      buttons: [
        { text: '取消', color: '#666666' },
        { text: '删除', color: '#FF3B30' }
      ]
    }).then((result: promptAction.ShowDialogSuccessResponse) => {
      if (result.index === 1) {
        this.doDelete();
      }
    });
  }

  /** 执行实际删除 */
  private doDelete(): void {
    this.todoList = this.todoList.filter((item: TodoItem) => !this.selectedIds.has(item.id));
    this.selectedIds.clear();
    this.isAllSelected = false;
    this.selectedIds = new Set<number>(this.selectedIds);
    if (this.todoList.length === 0) { // 删空后自动退出选择模式
      this.isSelectingMode = false;
    }
  }

  // ============ 页面布局:三层结构 ============
  build() {
    Column() {
      // ---- 第 1 层:顶部操作栏 ----
      Row({ space: 8 }) {
        Text(this.isSelectingMode ? '选择事项' : '待办事项')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .layoutWeight(1) // 占满剩余空间,按钮右对齐

        if (this.isSelectingMode) {
          Button(this.isAllSelected ? '取消全选' : '全选')
            .fontSize(14).height(32)
            .backgroundColor('#E8F0FE').fontColor('#007AFF')
            .onClick(() => this.toggleSelectAll())
          Button('删除')
            .fontSize(14).height(32)
            .backgroundColor('#FF3B30')
            .enabled(this.selectedIds.size > 0)          // 无选中时禁用
            .opacity(this.selectedIds.size > 0 ? 1 : 0.4) // 视觉置灰
            .onClick(() => this.deleteSelectedItems())
          Button('完成')
            .fontSize(14).height(32).backgroundColor('#007AFF')
            .onClick(() => this.exitSelectMode())
        } else {
          Button('选择')
            .fontSize(14).height(32).backgroundColor('#007AFF')
            .onClick(() => this.enterSelectMode())
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 8 })

      // ---- 第 2 层:统计信息行 ----
      Row() {
        Text(`${this.todoList.length}`)
          .fontSize(13).fontColor('#999999')
        Blank() // 两端对齐
        if (this.isSelectingMode) {
          Text(`已选 ${this.selectedIds.size}`)
            .fontSize(13).fontWeight(FontWeight.Medium)
            .fontColor(this.selectedIds.size > 0 ? '#007AFF' : '#999999')
        } else {
          Text('点击「选择」进入多选模式')
            .fontSize(13).fontColor('#CCCCCC')
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })

      // ---- 第 3 层:列表主体 ----
      if (this.todoList.length === 0) {
        Column() { // 空状态占位
          Text('暂无待办事项')
            .fontSize(16).fontColor('#999999')
        }
        .width('100%').layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        this.buildList()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F6FA')
  }

  /** @Builder 列表构建函数:多选列表核心布局 */
  @Builder
  buildList() {
    List({ space: 12 }) {
      ForEach(
        this.todoList,
        (item: TodoItem) => {
          ListItem() {
            Row() {
              // Checkbox 仅在选择模式下显示,负责视觉反馈
              if (this.isSelectingMode) {
                Checkbox()
                  .select(this.selectedIds.has(item.id))
                  .shape(CheckBoxShape.ROUNDED_SQUARE)
                  .selectedColor('#007AFF')
                  .onChange((value: boolean) => this.updateSelection(item.id, value))
              }

              // 文字信息区
              Column() {
                Text(item.title)
                  .fontSize(16).fontWeight(FontWeight.Medium)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                Row({ space: 8 }) {
                  Text(item.time).fontSize(12).fontColor('#999999')
                  Text(item.tag) // 标签徽章
                    .fontSize(11).fontColor('#007AFF')
                    .backgroundColor('#E8F0FE').borderRadius(4)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                }
                .margin({ top: 6 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .borderRadius(10)
            .backgroundColor(this.selectedIds.has(item.id) ? '#E8F0FE' : '#FFFFFF') // 选中高亮
          }
          // 核心①:开启该列表项的「可选中」交互能力
          .selectable(this.isSelectingMode)
          // 核心②:选中状态变化回调(闭包携带 item.id)
          .onSelect((isSelected: boolean) => this.updateSelection(item.id, isSelected))
        },
        (item: TodoItem) => item.id.toString() // key 用唯一 id,不用数组索引
      )
    }
    .width('100%')
    .layoutWeight(1) // 填满剩余空间,保证列表可滚动
    .padding({ left: 16, right: 16, bottom: 16 })
    .scrollBar(BarState.Off)
  }
}

这段完整代码与上文各章节讲解的片段一一对应:三层布局结构、双通道事件、Set 引用刷新、确认弹窗、空状态处理,全部浓缩在一个 .ets 文件中。读者可以直接复制运行,也可以在此基础上按需裁剪。

十六、运行效果与交互演示

在 DevEco Studio 中运行示例,真机或模拟器上的完整交互流程如下:

  1. 初始状态:页面显示 8 条待办事项卡片,顶部标题为「待办事项」,右上角只有「选择」按钮,统计行提示「共 8 项」,右侧灰色提示「点击『选择』进入多选模式」;
  2. 进入选择模式:点击「选择」按钮,标题切换为「选择事项」,右上角变为「全选 / 删除 / 完成」三个按钮,每个列表项左侧出现圆角方形复选框;
  3. 点选列表项:点击任意列表项的任意区域,该项复选框被勾选、卡片背景变为浅蓝高亮(#E8F0FE),统计行右侧实时显示「已选 N 项」;再次点击则取消选中,高亮消失;
  4. 精确点击复选框:只点复选框同样可以切换选中状态,与点击整行效果一致,两条路径互不干扰;
  5. 全选 / 取消全选:点击「全选」,全部 8 项同时选中,按钮文字变为「取消全选」;再次点击则全部取消;
  6. 删除按钮禁用态:未选中任何项时,「删除」按钮呈灰色半透明且无法点击;选中任意项后立即恢复高亮可点击状态;
  7. 批量删除:点击「删除」,系统弹出确认对话框,提示「确定要删除选中的 N 项吗?此操作不可撤销。」,左侧为灰色「取消」、右侧为红色「删除」;点击「取消」列表不变,点击「删除」则选中的事项被一次性移除;
  8. 删除后清理:删除完成后选中状态清空、统计行数字同步更新;若删空全部事项,列表区域自动切换为「暂无待办事项」的空状态占位,并自动退出选择模式;
  9. 完成退出:随时点击「完成」按钮退出选择模式,复选框消失,恢复普通浏览状态。

整个流程中,所有界面变化(按钮组切换、复选框勾选、数量统计、高亮反馈、空状态)全部由 @State 驱动自动刷新,无需任何手动刷新操作,这正是 ArkUI 响应式框架的威力所在。

十七、常见问题与解决方案

多选列表的开发中,以下几个问题出现频率最高,这里逐一给出根因与解法:

1. 点击复选框后选中状态翻转两次(或完全反向)

现象:点一下复选框,状态变成「选中 → 取消 → 选中」或直接反向。根因:点击复选框同时触发了 Checkbox.onChangeListItem.onSelect 两条事件路径,若两条路径各自维护状态,就会互相覆盖。解法:把两条路径统一收敛到同一个 updateSelection() 方法中,利用 Set.add() 的幂等性天然免疫重复触发——重复添加同一 id 不会产生副作用。

2. 修改了 Set 但界面不刷新

现象:执行了 selectedIds.add(id) 后,Checkbox、统计数字都没有任何变化。根因:Set 是引用类型,add() / delete() 不改变对象引用地址,@State 无法感知「内容变了」。解法:每次修改后重新赋值——this.selectedIds = new Set<number>(this.selectedIds)。这是本示例最重要的一行代码。

3. 列表内容无法滚动

现象:数据很多但列表滚不动。根因:List 的滚动依赖确定的高度约束,若高度为「自适应」或未显式指定,内容超出后无处可滚。解法:给 List 设置明确高度(如 layoutWeight(1) 填满父容器,或 .height('100%'))。

4. 删除若干项后出现空白项或数据错位

现象:删除后列表渲染异常,部分项消失或内容错配。根因:ForEach 的 keyGenerator 使用了数组索引,删除后索引整体前移,框架复用节点时对不上号。解法:keyGenerator 永远使用数据实体的唯一 id(item.id.toString()),而非索引。

5. 「全选」按钮文字滞后

现象:全选后手动取消了某一项,按钮仍显示「取消全选」。根因:isAllSelected 只在全选操作时更新,没有在单次取消时同步重算。解法:在 updateSelection() 中每次变化后都执行 this.isAllSelected = this.selectedIds.size === this.todoList.length

6. 普通模式下误触产生「隐藏选中」

现象:不在选择模式时点击列表项,之后进入选择模式发现有些项莫名被选中。根因:selectable 恒为 true,普通模式下点击也触发了选中逻辑,而界面又没有反馈。解法:将 selectableisSelectingMode 绑定(.selectable(this.isSelectingMode)),并保证进入选择模式时清空历史选中。

十八、性能优化进阶

当列表数据量增大时,以下优化手段值得关注:

1. 大数据量使用 LazyForEach

ForEach 会一次性渲染所有列表项,当数据超过 100 条时建议替换为 LazyForEach——它只在列表项即将进入视口时才创建对应 UI 节点,滚动时复用节点、动态回收,内存占用显著降低:

class TodoDataSource implements IDataSource {
  private data: TodoItem[] = [];

  totalCount(): number {
    return this.data.length;
  }

  getData(index: number): TodoItem {
    return this.data[index];
  }
  // registerDataChangeListener / unregisterDataChangeListener 等接口按需实现
}

// 使用:
List() {
  LazyForEach(this.dataSource, (item: TodoItem) => {
    ListItem() {
      // ... 与 ForEach 版完全相同的列表项布局
    }
    .selectable(this.isSelectingMode)
    .onSelect((isSelected: boolean) => this.updateSelection(item.id, isSelected))
  }, (item: TodoItem) => item.id.toString())
}

2. 状态批量更新,减少重渲染次数

每次给 @State 变量赋值都会触发一次组件刷新。批量操作时应「先集中修改、再一次性赋值」:

// 推荐:一次修改、一次触发
this.todoList.forEach(item => this.selectedIds.add(item.id));
this.selectedIds = new Set<number>(this.selectedIds);

3. 维持列表项结构稳定

List 的节点复用要求所有列表项布局结构一致、key 稳定。应避免在 ListItem 内部使用频繁变化的条件渲染导致结构增删,例如本示例中把 Checkbox 的显隐放在外层判断即可,不要按数据内容动态拼接不同的子组件树。

十九、扩展与变体

多选列表的套路掌握后,可以快速衍生出多种变体:

1. 单选模式:把 Set<number> 换成单个 number 变量,每次选择时覆盖旧值即可。适用于「单选设置项」「选择默认地址」等场景。

2. 侧滑删除(SwipeAction):侧滑适合「快速单条操作」,与多选(批量操作)互补,两者可以共存:

ListItem() {
  SwipeAction({ end: this.deleteButtonBuilder }) {
    // 列表项默认内容(含 Checkbox)
  }
}
.selectable(this.isSelectingMode)

3. 拖拽排序:通过 List.onItemDragStart()onItemDrop() 事件可实现长按拖拽调整顺序,常用于「自定义排序」「置顶」等需求。

4. 搜索过滤:增加一个 @State searchText,用 getter 动态返回过滤后的列表。注意此时「全选」应针对过滤后的可见列表还是全部数据,需根据产品需求决定。

5. 空状态占位:本示例已内置——todoList.length === 0 时渲染「暂无待办事项」提示,还可扩展为图标 + 引导按钮的组合。

6. 与后端同步:批量删除往往需要调用后端接口,可采用「乐观更新」策略——先本地更新 UI 提升响应速度,再异步同步后端,失败时回滚并提示。

7. 无障碍适配:为列表项添加 accessibilityGroup(true) 与动态的 accessibilityText(如「待办事项:完成文档,已选中」),让读屏用户可以感知选中状态。

8. 对比:editMode 编辑模式与 selectable 多选

提到多选删除,很多开发者会联想到 List 的另一个能力——编辑模式(editMode)。需要说明的是,这两者解决的是不同的需求:editMode(true) 是系统级编辑模式,进入后列表项可拖拽排序,配合 ListItem.deleteOption 会在每项右侧显示删除按钮,删除动作由 List.onItemDelete 回调接管;而 selectable 是纯粹的「选中交互」能力,只负责状态切换与回调上报。二者的官方组合用法是「editMode(true) + selectable(true)」,进入编辑模式后列表项自带勾选框。

那么本示例为何不直接用编辑模式,而是采用「selectable + isSelectingMode 自管理」方案?原因有三:其一,编辑模式会同时开启拖拽手柄等附加交互,在纯多选删除场景下显得多余;其二,全选、取消全选、实时统计、批量删除等业务诉求都需要拿到「选中状态」做二次处理,自管理状态(Set)比依赖系统内部状态更可控、可扩展;其三,自管理方案与数据模型解耦,后续无论是接后端、做搜索过滤还是换 UI 样式,都只需改业务层。如果你的场景恰好需要「拖拽排序 + 批量删除」一体,那么编辑模式是更合适的起点。

9. 单元测试与 UI 自动化

多选逻辑适合用自动化测试保障。业务层(如 updateSelection)可以抽取为纯函数或独立类后编写单元测试;交互层则推荐使用 @ohos.uitest 编写 UI 自动化用例:

import { Driver, ON } from '@ohos.uitest';

async function testMultipleSelect(): Promise<void> {
  const driver = await Driver.create();
  await driver.findComponent(ON.text('选择')).click();      // 1. 进入选择模式
  await driver.findComponent(ON.text('完成项目需求文档评审')).click(); // 2. 点选第一项
  // 3. 断言统计信息已更新
  expect(await driver.findComponent(ON.text('已选 1 项')).isExist()).toBe(true);
  // 4. 再点一次取消选中
  await driver.findComponent(ON.text('完成项目需求文档评审')).click();
  expect(await driver.findComponent(ON.text('已选 1 项')).isExist()).toBe(false);
}

建议至少覆盖以下边界场景:空列表进入选择模式、全选后逐项取消、删除弹窗取消、未选中时点击删除、删除全部后自动退出模式。

10. 状态管理 V2 与跨页面共享

单页面内 @State 已足够。若多选状态需要跨页面共享(例如 A 页勾选、B 页展示结果),HarmonyOS NEXT 还提供了状态管理 V2(@ObservedV2 / @Trace)与 AppStorage 等方案,可以把选中集合提升到应用级存储,实现跨页面响应式同步。选择哪个方案取决于状态的作用域:页面内用 @State,组件树共享用 @Provide / @Consume,应用级共享用 AppStorage 或 V2 的全局状态。

二十、总结

本文以「待办事项多选删除」为场景,完整实现了鸿蒙原生 ArkTS 的「List + multipleSelect 多选列表」布局。回顾全文,核心要点可以浓缩为四句话:

  1. 机制原生:多选能力来自 ListItem.selectable() + onSelect(),无需任何第三方库;Checkbox 负责视觉反馈,两者形成「交互 + 反馈」的互补;
  2. 状态驱动@State + Set<number> 管理选中集合,牢记「容器类型修改后必须重新赋值」这条 ArkTS 响应式铁律;
  3. 事件收敛onSelectCheckbox.onChange 双通道事件统一汇入 updateSelection(),状态永远一致、天然幂等;
  4. 闭环完整:进入模式 → 点选 / 全选 → 实时统计 → 确认弹窗 → 批量删除 → 状态清理 → 空态兜底,每一步都有对应的工程化处理。

掌握这套「数据模型 + 状态管理 + 双通道事件 + 业务闭环」的组合拳,无论是邮件批量管理、文件多选操作、购物车批量结算,还是后台数据清洗工具,都能举一反三、快速落地。建议读者打开 DevEco Studio,亲手把本文的代码跑起来,再动手改一改——把 Set 换成数组看会不会出问题、把 selectable 改成恒 true 试试普通模式下的误触,这些「破坏性实验」才是理解多选列表最快的方式。学习布局,最终要落到亲手实践的每一行代码上。

最后再强调一点:多选列表只是「批量交互」众多形态中的一种,但它的设计与实现思路——数据模型与 UI 状态分离、事件通道统一收敛、不可逆操作二次确认、状态清理形成闭环——完全可以复用到长按多选、分页批量操作、批量导入导出等更复杂的场景中去。掌握这些底层方法论,比死记任何一个单一 API 都更有价值,这也是本文希望传递给读者的核心理念。愿你在鸿蒙原生开发的路上,越走越顺。

Logo

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

更多推荐