【HarmonyOS6】列表滑动操作实战
前言
在移动应用开发中,列表是最常见的 UI 组件之一。通过左滑或右滑,用户可以快速执行删除、编辑、收藏等操作,无需进入详情页面。
本文将通过一个实际案例——饮水记录列表,带你深入理解 SwipeAction 的使用方法和交互设计技巧。
什么是 SwipeAction
SwipeAction(滑动操作)是一种常见的移动端交互模式。用户在列表项上左滑或右滑,会显示隐藏的操作按钮,点击按钮执行相应的操作。
案例背景
我们要实现一个饮水记录列表,具备以下功能:
- 显示每条饮水记录的时间和饮水量
- 左滑显示删除按钮
- 点击删除按钮移除记录
- 删除后列表自动更新
- 提供流畅的滑动动画效果
最终效果如下图所示:
完整代码
首先,让我们看一下完整的实现代码:
import{PreferencesService,getTodayDateString}from'../service/PreferencesService';import{WaterRecord,DailyWaterSummary}from'../model/HealthModels';import{CommonCard}from'../components/CommonCard';importcommonfrom'@ohos.app.ability.common';@Entry@Componentstruct WaterPage{@StatewaterCurrent:number=0;@StatewaterTarget:number=2000;@StatewaterRecords:WaterRecord[]=[];privateprefService:PreferencesService|null=null;aboutToAppear():void{constctx=getContext(this)ascommon.UIAbilityContext;this.prefService=PreferencesService.getInstance(ctx);this.loadWaterData();}asyncloadWaterData():Promise<void>{if(!this.prefService)return;consttoday=getTodayDateString();constdata=awaitthis.prefService.getWaterData(today);this.waterCurrent=data.totalAmount;this.waterTarget=data.targetAmount;this.waterRecords=data.records;}asyncremoveRecord(recordId:number):Promise<void>{if(!this.prefService)return;consttoday=getTodayDateString();awaitthis.prefService.removeWaterRecord(today,recordId);awaitthis.loadWaterData();}@BuilderDeleteButton(recordId:number){Row(){Text('删除').fontSize(14).fontColor(Color.White)}.width(70).height('100%').justifyContent(FlexAlign.Center).backgroundColor($r('app.color.error_color')).onClick(()=>{this.removeRecord(recordId);})}build(){Scroll(){Column(){// 今日记录Column(){CommonCard({title:'今日记录'}){Column(){if(this.waterRecords.length===0){Text('暂无记录').fontSize(14).fontColor($r('app.color.text_secondary')).padding({top:16,bottom:16})}else{List(){ForEach(this.waterRecords.slice().reverse(),(record:WaterRecord)=>{ListItem(){Row(){Text('💧').fontSize(18)Text(`${record.amount}ml`).fontSize(16).fontColor($r('app.color.text_primary')).margin({left:8})Blank()Text(record.time).fontSize(14).fontColor($r('app.color.text_secondary'))}.width('100%').padding({top:12,bottom:12})}.swipeAction({end:this.DeleteButton(record.id)})})}.width('100%').divider({strokeWidth:1,color:$r('app.color.divider_color'),startMargin:0,endMargin:0})}}}}.margin({left:16,right:16,bottom:32})}}.width('100%').height('100%').backgroundColor($r('app.color.background_color'))}}逐步解析
1. 数据模型定义
在开始实现 UI 之前,我们需要先了解数据结构:
// 单条饮水记录interfaceWaterRecord{id:number;// 唯一标识amount:number;// 饮水量(毫升)time:string;// 记录时间 HH:mmdate:string;// 记录日期 YYYY-MM-DD}// 每日饮水汇总interfaceDailyWaterSummary{date:string;// 日期totalAmount:number;// 总饮水量targetAmount:number;// 目标饮水量records:WaterRecord[];// 饮水记录列表}数据结构设计要点:
- 唯一标识 id: 每条记录都有唯一的 id,用于删除操作时精确定位
- 时间分离: date 和 time 分开存储,便于按日期查询和按时间排序
- 汇总数据: DailyWaterSummary 包含统计信息和详细记录,避免重复计算
为什么需要 id?
考虑这样的场景:用户在同一时间添加了两条 200ml 的记录。如果没有 id,我们无法区分要删除哪一条。所以id是必不可少的,这样可以方便我们精确地去删除某一条记录。
2. 组件状态管理
@StatewaterCurrent:number=0;@StatewaterTarget:number=2000;@StatewaterRecords:WaterRecord[]=[];状态变量详解:
waterCurrent: 当前饮水总量
- 类型:
number - 用途: 显示今日已喝水量,计算完成度
- 更新时机: 添加或删除记录后重新计算
waterTarget: 目标饮水量
- 类型:
number - 用途: 显示目标值,计算完成百分比
- 更新时机: 用户修改设置时更新
waterRecords: 饮水记录列表
- 类型:
WaterRecord[] - 用途: 渲染列表,展示所有记录
- 更新时机: 加载数据、添加记录、删除记录后更新
为什么使用 @State?
@State装饰器让变量具有响应式特性。当变量值改变时,使用该变量的 UI 会自动重新渲染:
this.waterRecords=newRecords;如果不使用@State,修改变量后 UI 不会更新,用户看不到变化。
3. 数据加载
privateprefService:PreferencesService|null=null;aboutToAppear():void{constctx=getContext(this)ascommon.UIAbilityContext;this.prefService=PreferencesService.getInstance(ctx);this.loadWaterData();}asyncloadWaterData():Promise<void>{if(!this.prefService)return;consttoday=getTodayDateString();constdata=awaitthis.prefService.getWaterData(today);this.waterCurrent=data.totalAmount;this.waterTarget=data.targetAmount;this.waterRecords=data.records;}生命周期方法 aboutToAppear:
aboutToAppear()是组件的生命周期方法,在组件即将显示时调用。这是初始化数据的最佳时机。
为什么使用 async/await?
数据加载是异步操作(需要读取本地存储),使用async/await可以让代码更清晰:
// 使用 async/await: 代码清晰,易读asyncloadWaterData():Promise<void>{constdata=awaitthis.prefService.getWaterData(today);this.waterRecords=data.records;}空值检查的重要性:
if(!this.prefService)return;这行代码防止prefService为null时调用方法导致崩溃。虽然正常情况下不会为null,但防御性编程是好习惯。
4. 删除操作实现
asyncremoveRecord(recordId:number):Promise<void>{if(!this.prefService)return;consttoday=getTodayDateString();awaitthis.prefService.removeWaterRecord(today,recordId);awaitthis.loadWaterData();}为什么要重新加载数据?
删除记录后,我们需要更新三个数据:
waterRecords: 记录列表waterCurrent: 当前饮水总量(需要重新计算)- 本地存储: 持久化删除操作
直接调用loadWaterData()可以一次性更新所有数据,保证数据一致性:
异步操作的顺序:
注意await的使用顺序:
awaitthis.prefService.removeWaterRecord(today,recordId);// 先删除awaitthis.loadWaterData();// 再加载必须等删除操作完成后,再加载数据。如果不用await,可能出现数据不一致:
// ❌ 错误示例: 不等待删除完成this.prefService.removeWaterRecord(today,recordId);// 开始删除(异步)awaitthis.loadWaterData();// 立即加载(删除可能还没完成)// 结果: 加载的数据中还包含刚删除的记录5. 删除按钮的设计
@BuilderDeleteButton(recordId:number){Row(){Text('删除').fontSize(14).fontColor(Color.White)}.width(70).height('100%').justifyContent(FlexAlign.Center).backgroundColor($r('app.color.error_color')).onClick(()=>{this.removeRecord(recordId);})}使用 @Builder 的原因:
@Builder装饰器用于封装可复用的 UI 片段。删除按钮会在每个列表项中使用,封装成@Builder有以下好处:
- 代码复用: 避免在每个 ListItem 中重复编写相同的代码
- 易于维护: 修改按钮样式时,只需修改一处
- 参数化: 可以传入不同的 recordId,实现不同的删除逻辑
点击事件处理:
.onClick(()=>{this.removeRecord(recordId);})点击按钮时,调用removeRecord()方法,传入当前记录的 id。这样每个删除按钮都知道要删除哪条记录。
6. 列表结构实现
List(){ForEach(this.waterRecords.slice().reverse(),(record:WaterRecord)=>{ListItem(){Row(){Text('💧').fontSize(18)Text(`${record.amount}ml`).fontSize(16).fontColor($r('app.color.text_primary')).margin({left:8})Blank()Text(record.time).fontSize(14).fontColor($r('app.color.text_secondary'))}.width('100%').padding({top:12,bottom:12})}.swipeAction({end:this.DeleteButton(record.id)})})}.width('100%').divider({strokeWidth:1,color:$r('app.color.divider_color'),startMargin:0,endMargin:0})List 组件:
List是 ArkTS 提供的列表容器组件,专门用于渲染列表数据。它的特点:
- 高性能: 支持虚拟滚动,只渲染可见区域的列表项
- 丰富的功能: 支持分组、索引、滑动操作等
- 自动优化: 自动管理列表项的创建和销毁
ForEach 循环渲染:
ForEach(this.waterRecords.slice().reverse(),(record:WaterRecord)=>{// 渲染每一项})为什么使用 slice().reverse()?
this.waterRecords.slice().reverse()这行代码包含两个操作:
slice(): 创建数组的浅拷贝
- 原因:
reverse()会修改原数组 - 如果直接
this.waterRecords.reverse(),会改变原始数据 - 使用
slice()创建副本,保护原始数据
- 原因:
reverse(): 反转数组顺序
- 原因: 数据按时间正序存储(早→晚)
- 显示时需要倒序(晚→早),最新的记录在最上面
- 符合用户习惯(最新的信息最重要)
如下图所示:
Blank() 组件的作用:
Row(){Text('💧')Text(`${record.amount}ml`)Blank()// 占据剩余空间Text(record.time)}Blank()是一个弹性空白组件,会占据 Row 中的所有剩余空间。
divider 分割线:
.divider({strokeWidth:1,color:$r('app.color.divider_color'),startMargin:0,endMargin:0})divider在列表项之间添加分割线,参数说明:
- strokeWidth: 线条粗细(1px)
- color: 线条颜色(通常是浅灰色)
- startMargin: 左侧边距(0 表示从最左边开始)
- endMargin: 右侧边距(0 表示到最右边结束)
7. SwipeAction 核心实现
ListItem(){Row(){// 列表项内容}}.swipeAction({end:this.DeleteButton(record.id)})swipeAction 参数详解:
swipeAction是ListItem的一个方法,用于配置滑动操作。它接受一个对象参数:
interfaceSwipeActionOptions{start?:CustomBuilder;// 右滑显示的内容end?:CustomBuilder;// 左滑显示的内容}start 和 end的对比:
start: 从左向右滑动时显示(右滑)
- 通常用于"标记已读"、"收藏"等非破坏性操作
- 显示在列表项左侧
end: 从右向左滑动时显示(左滑)
- 通常用于"删除"、"移除"等破坏性操作
- 显示在列表项右侧
为什么删除操作使用 end?
这是移动端的通用设计规范,大多数应用都是这样设计的。
多个操作按钮:
如果需要多个操作按钮,可以使用Row组合:
@BuilderMultipleActions(recordId:number){Row(){// 编辑按钮Row(){Text('编辑').fontSize(14).fontColor(Color.White)}.width(70).height('100%').justifyContent(FlexAlign.Center).backgroundColor('#FF9800').onClick(()=>{this.editRecord(recordId);})// 删除按钮Row(){Text('删除').fontSize(14).fontColor(Color.White)}.width(70).height('100%').justifyContent(FlexAlign.Center).backgroundColor('#F44336').onClick(()=>{this.removeRecord(recordId);})}}// 使用ListItem(){// ...}.swipeAction({end:this.MultipleActions(record.id)})效果:
8. 空状态处理
if(this.waterRecords.length===0){Text('暂无记录').fontSize(14).fontColor($r('app.color.text_secondary')).padding({top:16,bottom:16})}else{List(){// 列表内容}}为什么需要空状态?
当用户第一次使用应用,或删除了所有记录后,列表为空。如果不处理空状态,用户会看到一片空白,不知道发生了什么。
交互设计的实践
1. 滑动方向的选择
左滑(end) vs 右滑(start):
| 操作类型 | 推荐方向 | 原因 |
|---|---|---|
| 删除 | 左滑(end) | 符合用户习惯,大多数应用都是左滑删除 |
| 编辑 | 左滑(end) | 与删除一起出现时,放在删除左侧 |
| 收藏 | 右滑(start) | 正向操作,与删除区分开 |
| 标记已读 | 右滑(start) | 非破坏性操作,使用右滑更安全 |
| 归档 | 左滑(end) | 类似删除的移除操作 |
以上只是一些简单的建议,可以综合考虑实际需求来确定是左滑还是右滑
总结
通过本文的学习,我们深入了解了 SwipeAction 的使用方法和交互设计技巧。也通过了一个实际的案例来实践。