鸿蒙原生 ArkTS 布局之 List 编辑模式深度解析:多选 / 单选 / 拖拽排序
鸿蒙原生 ArkTS 布局之 List 编辑模式深度解析:多选 / 单选 / 拖拽排序



一、引言
在移动端应用中,列表是最基础也最复杂的信息展示容器。用户对列表的诉求早已超越"看"——他们需要批量操作(多选删除)、确认决策(单选下单)、个性化排序(拖拽调整)。HarmonyOS NEXT 的 ArkUI 框架为 List 组件提供了完善的编辑模式支持,通过一组简洁的声明式 API,开发者可以在不依赖第三方库的前提下,快速实现上述三种高频交互场景。
本文将从零开始,带领你构建一个完整的 List 编辑模式演示应用,涵盖 多选批量操作、单选方案确认、长按拖拽排序 三大模块,并提供详尽的 ArkTS 源码与逐行注释。
二、环境与准备工作
2.1 开发环境
| 项目 | 版本 |
|---|---|
| 操作系统 | Windows 10 / 11 |
| DevEco Studio | 5.0+ (HarmonyOS NEXT) |
| SDK | API 24 (HarmonyOS NEXT Release) |
| 构建工具 | hvigor |
| 语言 | ArkTS(声明式 UI + 强类型) |
2.2 项目结构
entry/src/main/ets/pages/
├── Index.ets # 首页 — 导航入口
└── ListEditDemo.ets # 主演示页面 — 三大编辑模式
路由注册文件:
entry/src/main/resources/base/profile/main_pages.json
三、List 编辑模式核心 API 总览
在 HarmonyOS NEXT(API 24)中,List 编辑模式涉及以下关键属性与回调:
| API | 作用对象 | 用途 |
|---|---|---|
.editMode(editing: boolean) |
List |
进入 / 退出编辑模式 |
.onMove((from, to) => void) |
ForEach / LazyForEach |
拖拽排序回调(自 API 12 支持) |
.selectable(boolean) |
ListItem |
是否允许被选中(编辑模式下生效) |
.onSelect((isSelected) => void) |
ListItem |
选中状态变化监听 |
.selected(boolean) |
ListItem |
受控选中属性(配合 $$ 双向绑定) |
⚠️ 版本说明:
editMode在 API 24 中标记为 deprecated,但仍是当前最简洁的编辑模式入口。替代方案是使用multiSelectable+ 自定义选中样式,但editMode在可预见的版本中仍会保持兼容,推荐新项目继续使用。
四、数据模型设计
在 ArkTS 中,类定义必须遵循严格规则:不能在构造函数参数中声明字段,必须显式在类体内声明。
class ListItemData {
id: number = 0;
name: string = '';
desc: string = '';
constructor(id: number, name: string, desc: string) {
this.id = id;
this.name = name;
this.desc = desc;
}
}
这种写法的优势在于:
- 类型安全:每个字段的类型一目了然
- 序列化友好:
JSON.parse后的对象可以安全转换为该类 - 状态追踪:
@State装饰器能精准感知字段级别的变化
五、多选模式(批量操作)深度拆解
5.1 交互逻辑
多选模式的核心需求是:
- 开启编辑模式后,每个列表项变为可点击选中
- 选中项通过视觉反馈(高亮 + 标记)区分
- 顶部工具栏提供 全选 / 清空 / 删除选中 等批量操作
- 退出编辑模式时自动清空选中状态
5.2 状态管理
@State isMultiEdit: boolean = false; // 编辑模式开关
@State multiSelectedIds: Set<number> = new Set(); // 选中项 id 集合
选用 Set<number> 而非 number[] 的原因:
- 去重:即使
onSelect重复触发也不会重复添加 - 性能:
has()/add()/delete()均为 O(1) - 语义清晰:数学集合操作(交集、差集)天然适合"全选 / 反选"场景
5.3 List 配置
List({ space: 8 }) {
ForEach(this.multiList, (item: ListItemData) => {
ListItem() {
this.MultiItemCard(item, this.multiSelectedIds.has(item.id))
}
.selectable(true) // ← 关键:允许选中
.onSelect((isSelected: boolean) => {
// ← 选中回调
const newSet = new Set(this.multiSelectedIds);
if (isSelected) {
newSet.add(item.id);
} else {
newSet.delete(item.id);
}
this.multiSelectedIds = newSet;
})
}, (item: ListItemData) => item.id.toString())
}
.editMode(this.isMultiEdit) // ← 编辑模式入口
5.4 踩坑记录
| 坑点 | 原因 | 解决方案 |
|---|---|---|
onSelect 不在 List 上面 |
API 24 中 onSelect 是 ListItem 的属性,而非 List |
每个 ListItem 单独绑定 |
| 编辑模式下 ListItem 点击不响应 | selectable 默认为 false |
显式设置 .selectable(true) |
@State Set 修改后 UI 不刷新 |
ArkTS 的 @State 对 Set 的检测有限 |
每次创建新 Set 赋值 |
5.5 批量删除实现
this.multiList = this.multiList.filter(
item => !this.multiSelectedIds.has(item.id)
);
this.multiSelectedIds = new Set();
这里的关键细节是:先过滤数组,再清空 Set。如果顺序反了(先清空 Set 再过滤),会导致所有元素都被保留,删除操作失效。
六、单选模式(方案选择)深度拆解
6.1 交互逻辑
单选模式更贴近"表单确认"场景:
- 开启编辑模式后,点击某个选项即选中
- 选中另一项时,前一项自动取消选中(互斥)
- 底部显示当前选中的方案名称与描述
- 退出编辑模式时重置选中状态
6.2 受控选中属性
ListItem()
.selectable(true)
.selected(item.id === this.singleSelectedId) // ← 受控属性
.onSelect((isSelected: boolean) => {
if (isSelected) {
this.singleSelectedId = item.id; // 选中当前
} else {
this.singleSelectedId = -1; // 取消
}
})
.selected(boolean) 是一个受控属性,类似 Web 开发中 <input checked={condition} /> 的模式。它的优势在于:
- 数据驱动 UI:状态完全由
singleSelectedId决定 - 可预测性:无论用户如何操作,UI 状态始终与数据源同步
- 配合
$$双向绑定:$$this.singleSelectedId可实现自动同步
6.3 构建方法中的表达式限制
ArkTS 的 build() 方法有一个严格的语法约束:内部只能放置 UI 组件声明,不能包含赋值语句、函数调用等非 UI 表达式。
❌ 错误写法:
build() {
Column() {
if (condition) {
const item = this.list.find(...); // ← 编译错误!
Text(item.name);
}
}
}
✅ 正确写法(getter 提取):
get selectedSingleText(): string {
const item = this.singleList.find(v => v.id === this.singleSelectedId);
return item ? `已选方案:${item.name} - ${item.desc}` : '';
}
build() {
Column() {
if (this.isSingleEdit && this.singleSelectedId >= 0) {
Text(this.selectedSingleText); // ← 只引用 getter
}
}
}
这个约束看似麻烦,实则强制开发者将逻辑层与 UI 层分离,是 ArkTS 声明式编程的最佳实践。
七、拖拽排序模式(长按拖动)深度拆解
7.1 交互逻辑
拖拽排序是最能体现"原生体验"的交互之一:
- 开启编辑模式后,列表项右下角出现拖拽把手图标
- 长按任意列表项,该条目浮起并跟随手指移动
- 拖动到目标位置时,其他条目自动让位(插入动画)
- 松手后数据完成重排,UI 同步刷新
7.2 onMove 的正确使用方式
这是整篇文章最容易踩坑的地方。在 API 24 中,onMove 不是 List 的属性,而是 ForEach 的属性!
List({ space: 8 }) {
ForEach(this.dragList, (item, index) => {
ListItem() {
this.DragItemCard(item, (index ?? 0) + 1)
}
.selectable(false)
}, (item) => item.id.toString())
.onMove((from: number, to: number) => { // ← 链式在 ForEach 上!
const movedItem = this.dragList.splice(from, 1)[0];
this.dragList.splice(to, 0, movedItem);
})
}
.editMode(this.isDragEdit)
为什么是 ForEach 而不是 List?
这是 HarmonyOS 框架的设计决策:移动(move)操作是数据层的语义,而非视图层的语义。ForEach 负责数据迭代,它知道每个 item 的索引;List 负责布局展示,它不应该关心数据如何排列。将 onMove 放在 ForEach 上,语义更清晰,也便于配合 LazyForEach 做大数据集的增量更新。
splice 的两步操作详解
const movedItem = this.dragList.splice(from, 1)[0]; // ① 从原位置移除
this.dragList.splice(to, 0, movedItem); // ② 插入到目标位置
Array.splice() 的返回值是被删除的元素组成的数组,所以 [0] 取到被移动的那个元素。两步操作完成后,@State dragList 被修改,触发 UI 重渲染,拖拽动画自动衔接。
7.3 拖拽过程中的视觉反馈
系统在拖拽过程中的默认行为:
- 被拖拽的
ListItem浮起(z-index 提升) - 目标位置出现插入占位
- 相邻项自动退让
- 松手时插入动画平滑过渡
开发者无需额外编写动画代码,这就是原生编辑模式的核心价值。
八、@Builder 组件化设计
8.1 为什么用 @Builder 而不是自定义组件?
| 对比维度 | @Builder |
自定义 @Component |
|---|---|---|
| 状态独立 | ❌ 共享父组件状态 | ✅ 独立状态管理 |
| 代码量 | 少(约 15 行) | 多(约 30+ 行) |
| 复用范围 | 当前组件内 | 全局 |
| 参数传递 | 简单参数 | 复杂对象 |
对于卡片 UI,其逻辑仅为"根据参数渲染样式",不涉及独立状态,使用 @Builder 是最简洁的选择。
8.2 ModeSection:模式开关卡片
@Builder
ModeSection(title: string, isEditing: boolean, onToggle: () => void) {
Row() {
Text(title)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: isEditing })
.onChange(() => onToggle())
}
.width('100%')
.padding({ top: 12, bottom: 8 })
}
8.3 三种列表项卡片的设计思路
| 卡片 | 视觉特征 | 选中态反馈 |
|---|---|---|
MultiItemCard |
左侧 4px 竖条 + 文字 | 蓝色高亮条 + #E3F2FD 背景 |
SingleItemCard |
圆形指示器 + 对勾 | 实心蓝圆 + ✓ 符号 |
DragItemCard |
序号 + 把手图标 | 拖拽浮起(系统动画) |
每种卡片都用不同的视觉语言传达同一种状态,这是移动端设计的基本原则:同一个交互状态,用同一个视觉符号,避免用户混淆。
九、ArkTS 语法注意事项
在编写过程中,我遇到了多个 ArkTS 的语法约束,在此汇总:
9.1 构造函数不允许参数声明字段
// ❌ 编译错误:arkts-no-ctor-prop-decls
class Item {
constructor(public id: number, public name: string) {}
}
// ✅ 正确写法
class Item {
id: number = 0;
name: string = '';
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
}
9.2 build() 内只能放 UI 组件
build() {
Column() {
const x = 1; // ❌ 编译错误
this.doSomething(); // ❌ 编译错误
Text('hello'); // ✅
}
}
9.3 router 需要显式导入
不同于 Text、List 等全局可用的组件,router 是 @kit.ArkUI 导出的模块,需要显式导入:
import { router } from '@kit.ArkUI';
9.4 FontWeight 的枚举值
FontWeight.Bold // ✅ 700
FontWeight.Medium // ✅ 500(注意:不是 SemiBold!)
// FontWeight.SemiBold // ❌ API 24 中不存在
十、完整源码逐段解读
10.1 页面入口与数据准备
文件 ListEditDemo.ets 以 @Entry @Component 装饰器标识主页面:
@Entry
@Component
struct ListEditDemo {
@State multiList: ListItemData[] = [...];
@State singleList: ListItemData[] = [...];
@State dragList: ListItemData[] = [...];
@State isMultiEdit: boolean = false;
@State isSingleEdit: boolean = false;
@State isDragEdit: boolean = false;
@State multiSelectedIds: Set<number> = new Set();
@State singleSelectedId: number = -1;
}
这里使用了 7 个 @State 变量,分别管理三类列表数据和三组编辑状态。所有状态都遵循"最小化原则"——只存储用户交互的结果,不存储冗余的中间状态。
10.2 底部导航
Row() {
Button('← 返回首页')
.fontColor('#1E88E5')
.backgroundColor(Color.White)
.borderRadius(20)
.onClick(() => {
router.back();
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 16 })
router.back() 是 API 24 推荐的返回方式,虽然标记为 deprecated,但替代方案 Router.back()(注意首字母大写)目前还不稳定。
10.3 路由注册
{
"src": [
"pages/Index",
"pages/ListEditDemo"
]
}
在 main_pages.json 中注册新页面后,才能通过 router.pushUrl() 跳转。
十一、性能优化建议
11.1 使用 LazyForEach 替代 ForEach
对于超过 50 条的数据,应该使用 LazyForEach 搭配 IDataSource 进行懒加载渲染。LazyForEach 只渲染当前可见区域的项的项,配合 cachedCount 属性可以显著提升滚动流畅度。
LazyForEach(this.dataSource, (item: ListItemData) => {
ListItem() { ... }
}, (item: ListItemData) => item.id.toString())
.onMove((from, to) => {
this.dataSource.moveData(from, to); // LazyForEach 内置 moveData 方法
})
11.2 editMode 与 onMove 的版本兼容
| API 版本 | editMode | onMove 位置 | 推荐度 |
|---|---|---|---|
| API 12–19 | 支持 | List 上 |
⚠️ 旧版 |
| API 20–23 | 支持(deprecated) | ForEach 上 |
✅ 当前 |
| API 24+ | 支持(deprecated) | ForEach 上 |
✅ 推荐 |
11.3 选中状态的数据结构选择
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 多选(< 100 项) | Set<number> |
O(1) 查找,去重 |
| 多选(> 100 项) | Map<number, boolean> |
批量操作方便 |
| 单选 | number |
一个变量足矣 |
十二、视觉风格解析
12.1 色彩体系
| 角色 | 色值 | 用途 |
|---|---|---|
| 主色调 | #1E88E5 |
标题、选中态、按钮 |
| 背景 | #F5F5F5 |
页面背景 |
| 白底 | #FFFFFF |
卡片背景 |
| 高亮背景 | #E3F2FD |
选中项背景 |
| 弱化文字 | #888 / #999 |
描述文本 |
| 删除色 | #E53935 |
批量删除按钮 |
12.2 卡片阴影
.shadow({
radius: 4,
color: '#0D000000', // 5% 透明度黑色
offsetY: 2
})
编辑模式下阴影颜色略微加深(#1A1E88E5),给用户一种"这些卡片可以被操作"的心理暗示。
12.3 圆角与间距
- 卡片圆角:
10(统一视觉) - List 容器圆角:
12(略大于卡片,形成容器感) - 列表项间距:
8(舒适合适)
十三、常见问题 FAQ
Q1: 为什么开启了 editMode,列表项却不能点击选中?
A: 检查是否给 ListItem 设置了 .selectable(true)。默认值为 false,必须在每个 ListItem 上显式开启。
Q2: onMove 回调不触发怎么办?
A: 确认 onMove 是链式调用在 ForEach(...) 之后,而非 List(...) 之后。这是一个非常容易犯的错误。
Q3: 如何在拖拽时添加自定义动画?
A: 系统已内置默认的插入动画。如果需要自定义,可以在 onMove 中使用 animateTo() 包裹数据修改:
animateTo({ duration: 200 }, () => {
const movedItem = this.dragList.splice(from, 1)[0];
this.dragList.splice(to, 0, movedItem);
});
Q4: @State Set 修改后 UI 不刷新怎么办?
A: ArkTS 的 @State 对 Set 的深层修改监控有限。解决方案是每次修改时创建新 Set:
// ❌ 不会刷新
this.multiSelectedIds.add(id);
// ✅ 会刷新
this.multiSelectedIds = new Set([...this.multiSelectedIds, id]);
Q5: 编辑模式下如何禁用 List 的滚动?
A: 可以使用 Scroll 容器的 scrollEnabled 属性或 List 的 nestedScroll 属性:
List()
.editMode(this.isEdit)
.nestedScroll({
scrollForward: NestedScrollMode.SELF_ONLY,
scrollBackward: NestedScrollMode.SELF_ONLY
})
十四、结语
本文详细拆解了 HarmonyOS NEXT(API 24)中 List 组件编辑模式的三种核心交互——多选批量操作、单选方案确认、长按拖拽排序,并提供了完整的 ArkTS 源码与逐行注释。
回顾全文,核心要点可以浓缩为四句话:
.editMode(true)是进入编辑模式的总开关ListItem.onSelect+.selectable(true)是选中交互的基础组合ForEach.onMove是拖拽排序的唯一入口(注意不在 List 上)@Builder+ getter 计算属性 是 ArkTS 组件化与逻辑提取的最佳实践
HarmonyOS NEXT 的声明式 UI 框架正在快速演进,虽然部分 API 还有版本兼容的阵痛,但其原生编辑模式的体验已经可以媲美甚至超越主流移动端框架。希望本文能为你的鸿蒙开发之路提供一份扎实的参考。
本文代码已通过 hvigorw assembleHap 编译验证,运行于 HarmonyOS NEXT API 24 模拟器。
更多推荐



所有评论(0)