鸿蒙原生ArkTS布局方式之List+multipleSelect多选列表

········# 鸿蒙原生 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 与其形成「交互 + 反馈」的互补关系,二者共同构成完整的双通道事件体系,这一点会在后文重点展开。
四、开发环境准备
在开始编码之前,请先确认开发环境满足以下条件:
- DevEco Studio:建议使用 6.x 及以上版本,内置 HarmonyOS NEXT SDK 的管理能力;
- HarmonyOS NEXT SDK:本文示例基于 HarmonyOS NEXT 6.1.1(API 24)开发与验证;
- 工程模型:采用 Stage 模型(
apiType: "stageMode"),这也是 HarmonyOS NEXT 唯一支持的工程模型; - 语言特性:ArkTS 是 TypeScript 的超集,在标准 TS 语法之上增加了装饰器(
@Entry、@Component、@State等)与严格的类型约束(例如不支持any、对象字面量需要显式类型等)。
创建工程时,选择「Empty Ability」模板即可得到一个最简页面骨架。本文示例只有单个页面,所有代码集中在 entry/src/main/ets/pages/ 目录下的一个 .ets 文件中,便于读者完整阅读与直接运行。
这里补充一个关键概念:ArkTS 的装饰器体系。装饰器是 ArkTS 声明式 UI 的核心语法,按作用可分为三类:
- 页面级装饰器:
@Entry,标记一个@Component结构体为页面入口,一个.ets文件中只能有一个; - 组件级装饰器:
@Component,声明一个自定义组件,其build()方法描述 UI 结构; - 状态级装饰器:
@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;
}
设计时有三个关键原则:
- id 必须稳定且唯一:它是列表复用的 key,也是选中集合的元素。绝不能用数组下标代替 id——删除操作会导致下标错位,进而引发渲染错乱(详见后文「常见问题」);
- 数据模型只描述业务数据:任何与 UI 相关的状态(如「是否选中」)都不应塞进数据模型,而是由专门的状态变量管理,保证模型纯净、职责单一;
- 显式类型声明: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(选择模式才显示) + 文字信息区
选择这种分层结构的优势:
- 关注点分离:操作栏管交互入口,统计行管反馈,列表管展示,每层职责单一;
- 响应式友好:状态变化在层间单向传递,不会产生级联重排;
- 易于扩展:后续无论是加搜索框、加下拉刷新,还是在中间插入新层,都不会影响既有结构。
其中「第 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 })
实现要点有三个:
layoutWeight(1)布局技巧:标题设置layoutWeight(1)后会自动占满 Row 中的剩余宽度,从而把按钮自然推到最右侧,无需手动计算间距;- 条件渲染切换按钮组:ArkUI 支持在
build()中使用if / else条件渲染,切换isSelectingMode时框架会自动增删对应节点; - 禁用态双重反馈:删除按钮同时使用
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 细节:
Blank()弹性空白:在 Row 中插入Blank()会自动占据所有剩余空间,实现「左文本、右文本」的两端对齐布局,比手动加 margin 更优雅;- 模板字符串插值: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) // 隐藏滚动条,保持界面清爽
}
这段代码里有几个容易忽略但至关重要的细节:
List必须占据确定高度:List的滚动依赖明确的高度约束。这里通过layoutWeight(1)让它填满父容器剩余空间,若不给高度,列表将无法滚动(这是高频踩坑点,后文 FAQ 会再次提到);@Builder方法封装:将列表构建逻辑抽到@Builder buildList()中,让build()主体保持精简,也便于在「空状态」与「列表」之间做条件切换;- 选中项高亮:
backgroundColor根据selectedIds.has(item.id)动态切换颜色,用户能一眼看出哪些项已被选中,配合 Checkbox 形成双重视觉反馈; - 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 是该项切换后的最新状态。开发者在这里拿到状态变化,同步更新自己的业务数据。
本示例做了一个值得借鉴的细节处理——把 selectable 与 isSelectingMode 绑定:
.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.json 的 src 数组中注册 "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 中运行示例,真机或模拟器上的完整交互流程如下:
- 初始状态:页面显示 8 条待办事项卡片,顶部标题为「待办事项」,右上角只有「选择」按钮,统计行提示「共 8 项」,右侧灰色提示「点击『选择』进入多选模式」;
- 进入选择模式:点击「选择」按钮,标题切换为「选择事项」,右上角变为「全选 / 删除 / 完成」三个按钮,每个列表项左侧出现圆角方形复选框;
- 点选列表项:点击任意列表项的任意区域,该项复选框被勾选、卡片背景变为浅蓝高亮(#E8F0FE),统计行右侧实时显示「已选 N 项」;再次点击则取消选中,高亮消失;
- 精确点击复选框:只点复选框同样可以切换选中状态,与点击整行效果一致,两条路径互不干扰;
- 全选 / 取消全选:点击「全选」,全部 8 项同时选中,按钮文字变为「取消全选」;再次点击则全部取消;
- 删除按钮禁用态:未选中任何项时,「删除」按钮呈灰色半透明且无法点击;选中任意项后立即恢复高亮可点击状态;
- 批量删除:点击「删除」,系统弹出确认对话框,提示「确定要删除选中的 N 项吗?此操作不可撤销。」,左侧为灰色「取消」、右侧为红色「删除」;点击「取消」列表不变,点击「删除」则选中的事项被一次性移除;
- 删除后清理:删除完成后选中状态清空、统计行数字同步更新;若删空全部事项,列表区域自动切换为「暂无待办事项」的空状态占位,并自动退出选择模式;
- 完成退出:随时点击「完成」按钮退出选择模式,复选框消失,恢复普通浏览状态。
整个流程中,所有界面变化(按钮组切换、复选框勾选、数量统计、高亮反馈、空状态)全部由 @State 驱动自动刷新,无需任何手动刷新操作,这正是 ArkUI 响应式框架的威力所在。
十七、常见问题与解决方案
多选列表的开发中,以下几个问题出现频率最高,这里逐一给出根因与解法:
1. 点击复选框后选中状态翻转两次(或完全反向)
现象:点一下复选框,状态变成「选中 → 取消 → 选中」或直接反向。根因:点击复选框同时触发了 Checkbox.onChange 与 ListItem.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,普通模式下点击也触发了选中逻辑,而界面又没有反馈。解法:将 selectable 与 isSelectingMode 绑定(.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 多选列表」布局。回顾全文,核心要点可以浓缩为四句话:
- 机制原生:多选能力来自
ListItem.selectable()+onSelect(),无需任何第三方库;Checkbox负责视觉反馈,两者形成「交互 + 反馈」的互补; - 状态驱动:
@State+Set<number>管理选中集合,牢记「容器类型修改后必须重新赋值」这条 ArkTS 响应式铁律; - 事件收敛:
onSelect与Checkbox.onChange双通道事件统一汇入updateSelection(),状态永远一致、天然幂等; - 闭环完整:进入模式 → 点选 / 全选 → 实时统计 → 确认弹窗 → 批量删除 → 状态清理 → 空态兜底,每一步都有对应的工程化处理。
掌握这套「数据模型 + 状态管理 + 双通道事件 + 业务闭环」的组合拳,无论是邮件批量管理、文件多选操作、购物车批量结算,还是后台数据清洗工具,都能举一反三、快速落地。建议读者打开 DevEco Studio,亲手把本文的代码跑起来,再动手改一改——把 Set 换成数组看会不会出问题、把 selectable 改成恒 true 试试普通模式下的误触,这些「破坏性实验」才是理解多选列表最快的方式。学习布局,最终要落到亲手实践的每一行代码上。
最后再强调一点:多选列表只是「批量交互」众多形态中的一种,但它的设计与实现思路——数据模型与 UI 状态分离、事件通道统一收敛、不可逆操作二次确认、状态清理形成闭环——完全可以复用到长按多选、分页批量操作、批量导入导出等更复杂的场景中去。掌握这些底层方法论,比死记任何一个单一 API 都更有价值,这也是本文希望传递给读者的核心理念。愿你在鸿蒙原生开发的路上,越走越顺。
更多推荐


所有评论(0)