HarmonyOS6.1 列表项滑动删除实战 — 从微信左滑学swipeAction
微信聊天列表左滑出"标为已读"“置顶”“删除”,这个交互从iOS时代沿用至今,几乎成了用户对列表操作的肌肉记忆。HarmonyOS NEXT的ArkUI提供了swipeAction API,理论上一行代码就能实现左滑菜单,但我第一次用的时候踩了一堆坑——按钮高度对不上、删除后列表不刷新、swipeAction和拖拽排序打架……这篇就把这些实战中的细节掰开了说。

swipeAction的基本用法:左滑出操作按钮
swipeAction是ListItem的专属方法,只能在ListItem内部调用。它的参数结构是{ start, end }——start对应右滑时左侧露出的按钮区,end对应左滑时右侧露出的按钮区。微信那种左滑删除,用的就是end。
来看SwipeActionDemo里的核心代码:
interface SwipeItem {
id: number;
title: string;
desc: string;
time: string;
}
List({ space: 8 }) {
ForEach(this.items, (item: SwipeItem) => {
ListItem() {
Row() {
Column() {
Text(item.title)
.fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333')
Text(item.desc)
.fontSize(12).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(item.time)
.fontSize(11).fontColor('#bbbbbb')
}
.width('100%').padding(14).backgroundColor('#ffffff').borderRadius(8)
}
.swipeAction({ end: {
builder: (): void => {
this.swipeEndBuilder(item);
}
}})
}, (item: SwipeItem) => item.id.toString())
}
注意几个关键点:
第一,swipeAction的builder参数类型是() => void,不是直接传@Builder函数名。这里用的是() => { this.swipeEndBuilder(item); }这种包装写法,因为需要把当前item传进去。如果你直接写builder: this.swipeEndBuilder不传参数,是OK的;但一旦要传参,就得用箭头函数包一层。
第二,ForEach的key generator用了item.id.toString()。swipeAction场景下key特别重要——如果key不稳定(比如用index做key),删除某一项后,列表重新渲染时可能导致滑动状态错乱,已经展开的按钮跑到别的行上去。
第三,swipeAction只认ListItem。你有其他容器比如Row、Column,想加swipeAction?不行。必须套在ListItem里,而ListItem又必须在List里。这个层级关系不能打乱。
滑出按钮的设计:高度必须和列表项一致
swipeEndBuilder的实现如下:
@Builder
swipeEndBuilder(item: SwipeItem): void {
Row() {
Button('已读')
.fontSize(12).height(60).backgroundColor('#1a73e8').fontColor(Color.White).borderRadius(0)
.onClick(() => { this.readItem(item.id); })
Button('置顶')
.fontSize(12).height(60).backgroundColor('#e65100').fontColor(Color.White).borderRadius(0)
.onClick(() => { this.pinItem(item.id); })
Button('删除')
.fontSize(12).height(60).backgroundColor('#d32f2f').fontColor(Color.White)
.borderRadius({ topRight: 8, bottomRight: 8 })
.onClick(() => { this.deleteItem(item.id); })
}
}
这里有个很关键的细节——Button的height设成了60。为什么是60?因为列表项的主内容Row,padding是14,里面文字大概32像素高,整体算下来约60。如果按钮高度和列表项高度不一致,左滑出来的按钮区要么矮一截(底部留白),要么撑出多余空间,看起来像bug。
在实际项目中,列表项高度往往是动态的(内容行数不固定),这时候滑动按钮的高度也要跟着动态走。一种做法是给ListItem和swipeAction的builder用同一个固定高度变量;另一种是给按钮设height('100%')让它撑满——但这在ArkUI里不一定按预期工作,因为swipeAction的builder所在的布局容器高度默认不会自动匹配ListItem内容高度。
我踩过的一个坑:列表项用了自适应高度(没设固定height),滑动按钮设了height(60),结果某些长内容的列表项左滑出来按钮只占了一半高,底部一大段空白。最后只能给ListItem加了固定高度约束,滑动按钮也用相同值。
删除逻辑:必须filter后重新赋值
deleteItem的实现:
private deleteItem(id: number): void {
const newItems: SwipeItem[] = this.items.filter((item: SwipeItem) => item.id !== id);
this.items = newItems;
this.addLog('删除: id=' + id.toString());
}
这段代码看似简单,但藏着ArkTS状态管理的核心机制。你不能写this.items.splice(index, 1)——splice虽然修改了数组,但ArkUI的@State对数组内部的变动检测是有限的。splice删除了元素,但this.items的引用没变,框架可能不触发UI刷新。
正确的做法是filter产生新数组,然后重新赋值this.items = newItems。赋值操作改变了引用,@State才能检测到变化并驱动UI更新。
这个规则在ArkTS里是通用的:凡是涉及数组的增删改,都要返回新数组再赋值。push、splice、sort这些原地修改方法,在@State场景下都可能"失灵"。这不是bug,是设计——ArkUI的响应式系统基于引用比较,引用不变就不重渲染。
有人可能会想"那我用@Observed和@ObjectLink行不行",理论上可以,但对这种简单的列表数据来说,filter+重新赋值是最清晰最不容易出问题的方案,别过度设计。
下拉刷新:Refresh组件和$$双向绑定
SwipeActionDemo还演示了Refresh组件配合List实现下拉刷新:
@State isRefreshing: boolean = false;
Refresh({ refreshing: $$this.isRefreshing }) {
List() {
ForEach(['刷新内容1', '刷新内容2', '刷新内容3', '刷新内容4', '刷新内容5'],
(item: string) => {
ListItem() {
Text(item).fontSize(14).fontColor('#333333').padding(16).width('100%')
}
}, (item: string, index: number) => index.toString())
}
.width('90%')
.height(200)
.divider({ strokeWidth: 1, color: '#eeeeee' })
}
.onRefreshing(() => {
this.addLog('下拉刷新触发');
setTimeout(() => {
this.isRefreshing = false;
this.addLog('刷新完成');
}, 1500);
})
这里有两个要点:
$$this.isRefreshing——这是ArkTS的双向绑定语法。Refresh组件需要在数据请求完成后关闭刷新指示器,它通过这个双向绑定来修改isRefreshing的值。如果你写成‘refreshing:this.isRefreshing‘(单向),Refresh组件就无法自动关闭刷新动画,你得手动调别的方法来停。而用‘双向绑定语法。Refresh组件需要在数据请求完成后关闭刷新指示器,它通过这个双向绑定来修改isRefreshing的值。如果你写成`{ refreshing: this.isRefreshing }`(单向),Refresh组件就无法自动关闭刷新动画,你得手动调别的方法来停。而用`双向绑定语法。Refresh组件需要在数据请求完成后关闭刷新指示器,它通过这个双向绑定来修改isRefreshing的值。如果你写成‘refreshing:this.isRefreshing‘(单向),Refresh组件就无法自动关闭刷新动画,你得手动调别的方法来停。而用‘`,组件直接改你的@State变量,你改变量组件也能感知,真正的双向通信。
onRefreshing回调里用setTimeout模拟了1.5秒的网络请求。实际项目中这里应该是异步数据拉取,拉完设this.isRefreshing = false。如果你忘了设这个false,刷新动画就永远转,用户只能杀进程。
还有一个容易被忽略的点:Refresh的子组件必须是可滚动的组件(List、Scroll、Grid等),如果放个Column进去,下拉手势不会被识别。
swipeAction和拖拽排序的冲突
这是一个真实踩过的坑。项目需求是列表既支持左滑删除,又支持长按拖拽排序。swipeAction和ListItem的dragSort组合使用时,手势冲突严重——左滑时可能触发拖拽,拖拽时可能左滑出按钮,用户体验一塌糊涂。
目前的ArkUI版本没有内置的手势优先级解决方案。我最终的做法是二选一——保留左滑删除,用按钮触发排序(上移/下移),而不是拖拽。如果你的业务确实需要两个共存,可以考虑用模式切换:编辑模式下禁用swipeAction开启拖拽,普通模式下启用swipeAction禁用拖拽。

坑点汇总
-
swipeAction只能用于ListItem内 — 不是List,不是Row,是ListItem。ListItem又必须在List里。层级不对直接不生效,也不会报错,就是左滑没反应。
-
删除后数组必须重新赋值 — filter返回新数组,赋值给@State变量才能触发刷新。splice/push等原地修改方法不靠谱。
-
滑动按钮高度要和列表项一致 — 否则视觉上会出bug,按钮区要么矮一截要么撑出空白。动态高度列表需要特殊处理。
-
swipeAction和拖拽排序同时使用会冲突 — 手势打架,目前没有优雅解法,建议用模式切换或改为按钮操作。
-
Refresh的refreshing需要$$双向绑定 — 单向绑定的话刷新动画无法自动停止。
-
ForEach的key必须稳定 — 用唯一id做key,不要用index。删除操作后index会变,导致滑动状态错乱。
写在最后
左滑删除看着简单,真正做好需要抠每个像素对齐和每个状态转换,swipeAction的坑远比API文档暗示的多。
更多推荐



所有评论(0)