行业资讯
📅 2026/7/22 23:19:30
【HarmonyOS6】列表滑动操作实战
前言在移动应用开发中列表是最常见的 UI 组件之一。通过左滑或右滑,用户可以快速执行删除、编辑、收藏等操作,无需进入详情页面。本文将通过一个实际案例——饮水记录列表,带你深入理解 SwipeAction 的使用方法和交互设计技巧。什么是 SwipeActionSwipeAction(滑动操作)是一种常见的移动端交互模式。用户在列表项上左滑或右滑,会显示隐藏的操作按钮,点击按钮执行相应的操作。案例背景我们要实现一个饮水记录列表,具备以下功能显示每条饮水记录的时间和饮水量左滑显示删除按钮点击删除按钮移除记录删除后列表自动更新提供流畅的滑动动画效果最终效果如下图所示:完整代码首先,让我们看一下完整的实现代码:import{PreferencesService,getTodayDateString}from../service/PreferencesService;import{WaterRecord,DailyWaterSummary}from../model/HealthModels;import{CommonCard}from../components/CommonCard;importcommonfromohos.app.ability.common;EntryComponentstruct WaterPage{StatewaterCurrent:number0;StatewaterTarget:number2000;StatewaterRecords:WaterRecord[][];privateprefService:PreferencesService|nullnull;aboutToAppear():void{constctxgetContext(this)ascommon.UIAbilityContext;this.prefServicePreferencesService.getInstance(ctx);this.loadWaterData();}asyncloadWaterData():Promisevoid{if(!this.prefService)return;consttodaygetTodayDateString();constdataawaitthis.prefService.getWaterData(today);this.waterCurrentdata.totalAmount;this.waterTargetdata.targetAmount;this.waterRecordsdata.records;}asyncremoveRecord(recordId:number):Promisevoid{if(!this.prefService)return;consttodaygetTodayDateString();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.length0){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:number0;StatewaterTarget:number2000;StatewaterRecords:WaterRecord[][];状态变量详解:waterCurrent: 当前饮水总量类型:number用途: 显示今日已喝水量,计算完成度更新时机: 添加或删除记录后重新计算waterTarget: 目标饮水量类型:number用途: 显示目标值,计算完成百分比更新时机: 用户修改设置时更新waterRecords: 饮水记录列表类型:WaterRecord[]用途: 渲染列表,展示所有记录更新时机: 加载数据、添加记录、删除记录后更新为什么使用 State?State装饰器让变量具有响应式特性。当变量值改变时,使用该变量的 UI 会自动重新渲染:this.waterRecordsnewRecords;如果不使用State,修改变量后 UI 不会更新,用户看不到变化。3. 数据加载privateprefService:PreferencesService|nullnull;aboutToAppear():void{constctxgetContext(this)ascommon.UIAbilityContext;this.prefServicePreferencesService.getInstance(ctx);this.loadWaterData();}asyncloadWaterData():Promisevoid{if(!this.prefService)return;consttodaygetTodayDateString();constdataawaitthis.prefService.getWaterData(today);this.waterCurrentdata.totalAmount;this.waterTargetdata.targetAmount;this.waterRecordsdata.records;}生命周期方法 aboutToAppear:aboutToAppear()是组件的生命周期方法,在组件即将显示时调用。这是初始化数据的最佳时机。为什么使用 async/await?数据加载是异步操作(需要读取本地存储),使用async/await可以让代码更清晰:// 使用 async/await: 代码清晰,易读asyncloadWaterData():Promisevoid{constdataawaitthis.prefService.getWaterData(today);this.waterRecordsdata.records;}空值检查的重要性:if(!this.prefService)return;这行代码防止prefService为null时调用方法导致崩溃。虽然正常情况下不会为null,但防御性编程是好习惯。4. 删除操作实现asyncremoveRecord(recordId:number):Promisevoid{if(!this.prefService)return;consttodaygetTodayDateString();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.length0){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 的使用方法和交互设计技巧。也通过了一个实际的案例来实践。