滑动操作是移动端列表交互的核心模式。本文用 ArkUI 的
swipeActionAPI 构建一个完整的滑动操作列表——左滑删除、右滑置顶,覆盖双方向滑动、滑动阈值、操作确认以及空状态反馈。
一、我们要做什么
一个待办事项列表,每条支持两个方向的滑动操作:
- 左滑删除— 从右向左滑动,露出红色"删除"按钮。滑过阈值(80vp)自动触发删除,也可轻点按钮删除。删除后 Toast 反馈。
- 右滑置顶— 从左向右滑动,露出蓝色"置顶"按钮。已置顶的项(列表第一条)不再显示右滑按钮——避免无意义的重复置顶。
- 空状态— 删除全部 12 条后,显示空状态提示。
- 底部操作提示— “← 左滑删除 | 右滑置顶 →”,引导用户发现滑动手势。
二、数据模型
classSwipeItem{id:number;title:string;subtitle:string;time:string;constructor(id:number,title:string,subtitle:string,time:string){this.id=id;this.title=title;this.subtitle=subtitle;this.time=time;}}12 条模拟数据,模拟一个开发者的待办清单——标题是主要任务,副标题是描述/备注,时间是最后修改时间。
三、swipeAction API 全解析
3.1 基本结构
ListItem(){// 列表项的正文内容}.swipeAction({start:{// 右滑(从左侧露出按钮)builder:()=>{this.pinButton()},onAction:()=>{this.pinItem(item.id)},actionAreaDistance:70},end:{// 左滑(从右侧露出按钮)builder:()=>{this.deleteButton()},onAction:()=>{this.deleteItem(item.id,item.title)},actionAreaDistance:80}})swipeAction接收一个配置对象,包含两个可选字段:
| 字段 | 方向 | 按钮出现位置 |
|---|---|---|
start | 从左向右滑 | 列表项左侧露出按钮 |
end | 从右向左滑 | 列表项右侧露出按钮 |
3.2 三个关键字段
每个方向的配置对象包含:
| 字段 | 类型 | 说明 |
|---|---|---|
builder | CustomBuilder | 滑动后露出的按钮 UI,必须是() => void类型的 Builder |
onAction | () => void | 滑动距离超过阈值时的回调。触发后自动收起滑动状态 |
actionAreaDistance | number(vp) | 阈值距离。滑过这个距离松手 →onAction触发;未滑过 → 按钮弹回 |
三个执行路径:
- 滑过阈值松手→
onAction触发(自动执行操作) - 轻滑后松手(按钮露出但未过阈值)→ 按钮保持在露出状态,用户可轻点按钮
- 轻点露出的按钮→ 按钮的
onClick触发
onAction的语义是"滑动到位,执行操作"——它替代了按钮的 onClick。所以通常做法是:builder 只负责视觉,onAction 负责逻辑,按钮的 onClick 可以省略。
3.3 为什么 builder 不能直接传 @Builder 引用?
builder字段的类型是CustomBuilder,定义为() => void。你不能写builder: this.pinButton,因为 ArkTS 不支持直接传 @Builder 方法引用。必须用箭头函数包裹:builder: () => { this.pinButton() }。
这个箭头函数也解决了闭包捕获问题——在 ForEach 内部,箭头函数捕获当前循环的item,每个 ListItem 的 swipeAction 持有对各自 item 的正确引用。
四、交互点1:左滑删除
4.1 删除按钮 Builder
@BuilderdeleteButton(){Row(){Text('删除').fontSize(FontSize.BODY).fontColor(Color.White)}.width(80).height('100%').backgroundColor(AppColors.ERROR).justifyContent(FlexAlign.Center)}关键细节:.height('100%')让删除按钮自动撑满 ListItem 的高度。不需要写死高度——List 的每个 ListItem 高度可能不同,百分比高度让按钮与内容等高,视觉效果干净统一。
红色背景AppColors.ERROR (#FF4D4F)是删除操作的通用颜色——用户看到红色就知道是"破坏性操作"。
4.2 删除逻辑
privatedeleteItem(id:number,title:string):void{constidx=this.items.findIndex((item:SwipeItem)=>item.id===id);if(idx>=0){this.items.splice(idx,1);this.items=[...this.items];promptAction.showToast({message:`已删除:${title}`,duration:1500});}}Toast 包含了被删条目的标题——让用户知道删了什么,比"已删除"三个字更有信息量。1500ms 的 duration 足够阅读。
4.3 actionAreaDistance = 80
为什么设 80vp?ArkUI 默认的actionAreaDistance是 56vp。设得更大(80vp)意味着用户需要滑得更远才会触发自动执行——降低误操作概率。删除是破坏性操作,不应该太容易触发。
置顶的actionAreaDistance设 70vp——比删除略低,因为置顶不是破坏性操作,用户不慎触发也容易恢复。
五、交互点2:右滑置顶
5.1 置顶按钮的条件显示
start:this.items[0]?.id!==item.id?{builder:()=>{this.pinButton()},onAction:()=>{this.pinItem(item.id)},actionAreaDistance:70}:undefined,this.items[0]?.id !== item.id判断当前项是否已经是第一条。如果是 →start: undefined→ 右滑无操作。如果不是第一条 →start: { builder, onAction, actionAreaDistance }→ 右滑可置顶。
这个条件判断避免了"第一条也能置顶"的无意义操作——已经是第一条了,再置顶等于没动。
5.2 置顶逻辑
privatepinItem(id:number):void{constidx=this.items.findIndex((item:SwipeItem)=>item.id===id);if(idx>0){constitem=this.items[idx];this.items.splice(idx,1);this.items=[item,...this.items];this.items=[...this.items];promptAction.showToast({message:'已置顶',duration:1200});}}三步:
splice(idx, 1)从原位置取出[item, ...this.items]插入到数组头部[...this.items]触发 @State 更新
注意idx > 0而非idx >= 0——idx 为 0 时已在顶部,不操作。
六、交互点3:滑动阈值与松手回弹
actionAreaDistance定义了"临界点"。用户滑动时有两种松手结果:
滑过 80vp → 松手 → onAction 触发 → 删除 → Toast 未过 80vp → 松手 → 按钮保持露出状态 → 用户可轻点按钮或右滑收回在实际测试中,用户可以:
- 轻滑露出按钮 → 看一眼 → 决定不删 → 反方向滑回 → 按钮隐藏
- 轻滑露出按钮 → 点击红色"删除"文字 → 删除
- 用力滑过 80vp → 松手即删 → 最快路径
这给了用户"分层次的操作自由度"——这是好的手势设计。
七、交互点4:空状态
if(this.items.length===0){Column(){Image($r('sys.symbol.archivebox')).width(48).height(48).fillColor(AppColors.TEXT_DISABLED).margin({bottom:Spacing.MD})Text('列表为空').fontSize(FontSize.BODY).fontColor(AppColors.TEXT_DISABLED)Text('左右滑动列表项进行操作').fontSize(FontSize.CAPTION).fontColor(AppColors.TEXT_DISABLED).margin({top:Spacing.XS})}.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)}用sys.symbol.archivebox(已验证可用的符号)作为空状态图标。两条提示文字——"列表为空"是当前状态,"左右滑动列表项进行操作"是操作引导。在空状态下这条引导没有实际作用,但它强化了用户对滑动手势的认知——下次有数据的时候就知道可以滑了。
八、列表项内容布局
Row(){Column(){Text(item.title).fontSize(FontSize.BODY).fontColor(AppColors.TEXT_PRIMARY).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(item.subtitle).fontSize(FontSize.CAPTION).fontColor(AppColors.TEXT_TERTIARY).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:2})}.layoutWeight(1).alignItems(HorizontalAlign.Start)Text(item.time).fontSize(FontSize.CAPTION).fontColor(AppColors.TEXT_DISABLED).margin({left:Spacing.SM})}.width('100%').padding({left:Spacing.LG,right:Spacing.LG,top:Spacing.MD,bottom:Spacing.MD}).backgroundColor(Color.White)双行文本 + 右侧时间,和微信消息列表布局一致。layoutWeight(1)让标题区域占据剩余宽度,时间文字靠右对齐。两个maxLines(1) + textOverflow防止长文本溢出。
九、底部操作提示
Text('← 左滑删除 | 右滑置顶 →').fontSize(FontSize.CAPTION).fontColor(AppColors.TEXT_DISABLED).width('100%').textAlign(TextAlign.Center).padding({top:Spacing.SM,bottom:Spacing.SM}).backgroundColor(AppColors.BACKGROUND)这条提示固定在列表底部。左右箭头符号配合文字,直观告诉用户两个方向分别对应什么操作——滑动手势的发现性差,很多用户不知道可以滑,一行提示能大幅提升交互的曝光率。
十、页面结构总结
SwipePage (~190 行) ├── 数据层 │ ├── SwipeItem 类 (id, title, subtitle, time) │ └── MOCK_ITEMS 常量 (12 条) ├── 状态层 │ └── @State items: SwipeItem[] ├── 业务方法 │ ├── deleteItem(id, title) — 删除 + Toast │ └── pinItem(id) — 置顶 + Toast ├── 操作按钮 Builder │ ├── deleteButton() — 红色 80vp 宽 │ └── pinButton() — 蓝色 70vp 宽 └── UI 结构 ├── Header (返回 + 标题 + 计数) ├── List + ForEach (swipeAction: start + end) ├── 空状态 (archivebox 图标) └── 底部提示 (← 左滑删除 | 右滑置顶 →)十一、常见面试题 / 踩坑点
11.1actionAreaDistance设多少合适?
- 删除等破坏性操作— 60-80vp,稍长,减少误操作
- 收藏/标记等非破坏性操作— 40-60vp,快速触发
- 默认值— 56vp
- 不要设到 100vp 以上— 用户滑不到,等于这个功能不存在
11.2 为什么用findIndex + splice而不用filter?
两种写法的差异:
// splice 方案constidx=this.items.findIndex(m=>m.id===id);this.items.splice(idx,1);this.items=[...this.items];// filter 方案this.items=this.items.filter(m=>m.id!==id);splice方案在删除后立即退出,O(n) 查找 + O(1) 删除。filter方案遍历整个数组创建新数组,O(n) 遍历 + O(n) 空间。对于 12 条数据差异可忽略,但对于 1000 条数据,splice只要找到目标就停止。
更重要的是,deleteItem需要拿到被删项的title用于 Toast 提示——splice方案在删除前已经拿到了引用,filter方案需要在外面再 find 一次。
11.3start和end可以同时作用于一个 ListItem 吗?
可以,如本 Demo。但要注意:
- 两个方向的滑动操作互斥——同时只能滑一个方向
- 如果某个方向设为
undefined,该方向不能滑动 - 第一条的
start: undefined就是利用了这个特性
11.4builder闭包中的item引用安全吗?
安全。ForEach 的每次迭代创建独立的闭包作用域,builder: () => { this.pinButton() }中的箭头函数捕获了外层 ForEach 回调中的item变量。由于item是const(ForEach 的回调参数),不存在变量被后续迭代覆盖的问题。
11.5swipeAction对 ListItem 的性能有影响吗?
每个 ListItem 的 swipeAction 会创建额外的 DOM 节点(按钮区域)。对于 50 条以内的列表几乎无影响。如果需要优化 500+ 条的列表,建议配合LazyForEach使用,只渲染可见区域的 ListItem。
11.6 滑动手势和 List 的滚动冲突吗?
不冲突。ArkUI 的手势系统内部处理了水平滑动(swipeAction)和垂直滚动(List)的手势竞争——水平滑动优先触发 swipeAction,垂直滑动优先触发列表滚动。用户斜向滑动时,系统根据主要轴向判断意图。
十二、运行方式
代码位于dev/entry/src/main/ets/pages/SwipePage.ets。
用 DevEco Studio 打开dev/项目,首页点击"滑动操作 — 左滑删除与右滑置顶"即可体验:
- 进入页面 → 12 条待办事项,底部提示"← 左滑删除 | 右滑置顶 →"
- 左滑任意条目 → 露出红色"删除"按钮;继续滑过 80vp 松手 → 自动删除 + Toast
- 左滑后松手(未过阈值)→ 按钮保持露出;轻点"删除"文字 → 同样删除
- 右滑非首条 → 露出蓝色"置顶"按钮 → 自动或手动触发 → 条目跳到列表第一
- 列表首条右滑无效 → 没有置顶按钮(已是最顶)
- 删除全部 12 条 → 空状态提示"列表为空"
- 查看顶部计数 → 实时显示剩余条目数
十三、扩展方向
- 两段式滑动— 第一阶段露出单个按钮(70vp),第二阶段滑到 140vp 露出第二个按钮(如"删除 + 归档"),参考 iOS 邮件 App
- 自定义操作菜单— 不止一个按钮,露出后显示 2-3 个操作(编辑、删除、分享),用 Row 布局
- 撤销删除— 删除后不立即 splice,先"软删除"(灰色 + 划线),3 秒内可撤销。类似 Gmail 的撤销模式
- 振动反馈— 滑过阈值时触发
vibrator.vibrate(10),物理反馈增强操作感 - 滑动选择模式— 长按进入多选模式,每个 ListItem 左侧出现 Checkbox,不再触发 swipeAction
- 左滑操作记录— 左滑删除后写入"操作历史"数组,在页面外提供"最近删除"入口恢复
- 弹性动画— 自定义 swipeAction 的回弹曲线,让松手后的回弹更自然
- Dark Mode 适配— 操作按钮在暗黑模式下调整色彩饱和度,避免红色在暗底上刺眼