微信聊天列表左滑出"标为已读"“置顶”“删除”,这个交互从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禁用拖拽。


请添加图片描述

坑点汇总

  1. swipeAction只能用于ListItem内 — 不是List,不是Row,是ListItem。ListItem又必须在List里。层级不对直接不生效,也不会报错,就是左滑没反应。

  2. 删除后数组必须重新赋值 — filter返回新数组,赋值给@State变量才能触发刷新。splice/push等原地修改方法不靠谱。

  3. 滑动按钮高度要和列表项一致 — 否则视觉上会出bug,按钮区要么矮一截要么撑出空白。动态高度列表需要特殊处理。

  4. swipeAction和拖拽排序同时使用会冲突 — 手势打架,目前没有优雅解法,建议用模式切换或改为按钮操作。

  5. Refresh的refreshing需要$$双向绑定 — 单向绑定的话刷新动画无法自动停止。

  6. ForEach的key必须稳定 — 用唯一id做key,不要用index。删除操作后index会变,导致滑动状态错乱。


写在最后

左滑删除看着简单,真正做好需要抠每个像素对齐和每个状态转换,swipeAction的坑远比API文档暗示的多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐