鸿蒙实战:UnclassifiedArchivePage 长按多选与批量归档

前言

鸿蒙实战第97篇:UnclassifiedArchivePage 长按多选与批量归档 — 运行效果截图

图:鸿蒙实战第97篇:UnclassifiedArchivePage 长按多选与批量归档 运行效果截图(HarmonyOS NEXT)

UnclassifiedArchivePage(未归类整理页)解决了一个实际产品问题:用户拍照分析后,笔迹记录暂时没有归属的档案,需要一个批量整理的入口。这 527 行代码的核心是长按触发多选模式——这在 ArkUI 中需要 LongPressGestureonClick 共存的特殊写法。

本篇重点拆解:

  1. LongPressGestureonClick 共存的手势处理
  2. 选择模式的进入/退出与 Transition 动画
  3. selectAll 全选切换逻辑
  4. 批量归档两步写入batchUpdateArchive + incrementRecordBy

系列导航:96 SharePage · 98 FormCardsPreview

相关文档:Gesture 手势 API · transition 组件转场 · HandwritingDao


一、页面功能架构

1.1 两种模式对比

LongPressGesture\n(500ms长按)

点击"取消"按钮

批量归档完成

点击全选/取消全选

浏览模式

选择模式

1.2 状态变量

@Entry
@Component
struct UnclassifiedArchivePage {
  @State handwritings: HandwritingEntity[] = []   // 所有未归类笔迹
  @State selectedIds: Set<number> = new Set()     // 已选中的 ID 集合
  @State isSelectMode: boolean = false            // 选择模式开关
  @State archives: ArchiveEntity[] = []           // 目标档案列表
  @State showArchivePicker: boolean = false       // 归档目标选择器
  @State isBatchMoving: boolean = false           // 批量移动中
}

二、LongPressGesture 与 onClick 共存

2.1 手势冲突问题

ArkUI 默认情况下,.gesture().onClick() 会产生事件竞争:长按 500ms 后系统同时触发长按和点击,导致多选模式激活的同时也触发了"进入详情"。

解决方案:用 GestureGroupGesturePriority 控制优先级:

ListItem()
  .onClick(() => {
    if (this.isSelectMode) {
      // 选择模式:点击切换选中
      this.toggleSelect(item.id)
    } else {
      // 浏览模式:点击进入详情
      this.openDetail(item)
    }
  })
  .gesture(
    LongPressGesture({ duration: 500 })
      .onAction(() => {
        if (!this.isSelectMode) {
          this.enterSelectMode(item.id)  // 长按:进入选择模式并选中当前项
        }
      })
  )

关键点:当 isSelectMode === false 时,长按触发进入选择模式;当 isSelectMode === true 时,长按被忽略(避免重复激活),只有 onClick 负责切换选中状态。通过 isSelectMode 标志在两种模式间切换,而不是替换手势处理器。

2.2 进入选择模式

private enterSelectMode(firstItemId: number): void {
  this.isSelectMode = true
  this.selectedIds = new Set([firstItemId])  // 长按的那一项自动选中

  // 触感反馈(HarmonyOS 振动 API)
  // vibrator.startVibration({ type: 'time', duration: 50 })
}

三、勾选框 Transition 动画

3.1 动画效果

进入选择模式时,每个列表项左侧出现勾选圆圈,配合缩放动画:

@Builder
CheckboxArea(item: HandwritingEntity) {
  if (this.isSelectMode) {
    Column() {
      // 勾选圆圈
      Column() {
        if (this.selectedIds.has(item.id)) {
          Text('✓').fontSize(12).fontColor(Color.White)
        }
      }
      .width(24)
      .height(24)
      .borderRadius(12)
      .backgroundColor(
        this.selectedIds.has(item.id) ? AppColors.PRIMARY : 'transparent'
      )
      .border({
        width: 2,
        color: this.selectedIds.has(item.id)
          ? AppColors.PRIMARY : AppColors.BORDER
      })
      .justifyContent(FlexAlign.Center)
      .animation({ duration: 200, curve: Curve.FastOutSlowIn })
    }
    .transition({
      type: TransitionType.Insert,
      scale: { x: 0, y: 0 }      // 进入时从 0 缩放到 1
    })
    .transition({
      type: TransitionType.Delete,
      scale: { x: 0, y: 0 }      // 退出时从 1 缩放到 0
    })
  }
}

3.2 选中切换逻辑

private toggleSelect(id: number): void {
  const newSet = new Set(this.selectedIds)
  if (newSet.has(id)) {
    newSet.delete(id)  // 已选中:取消选中
  } else {
    newSet.add(id)     // 未选中:选中
  }
  this.selectedIds = newSet  // 触发 @State 更新
}

为什么重新赋值 new Set ArkTS 的 @State 对 Set/Map 的内部变化(add/delete)不能自动检测,必须将 Set 替换为新对象才能触发重渲染。这是 ArkUI 响应式系统的重要细节。


四、全选/取消全选

private toggleSelectAll(): void {
  const allSelected = this.handwritings.every(
    item => this.selectedIds.has(item.id)
  )

  if (allSelected) {
    // 当前全选:取消全选
    this.selectedIds = new Set()
  } else {
    // 当前非全选:全部选中
    this.selectedIds = new Set(this.handwritings.map(item => item.id))
  }
}
当前状态 every() 结果 操作
全部选中 true 清空 selectedIds
部分选中 false 全部加入 selectedIds
全不选中 false 全部加入 selectedIds

五、批量归档两步写入

5.1 操作流程

用户选择目标档案

HandwritingDao.batchUpdateArchive
ids, targetArchiveId
批量更新 archive_id 字段

ArchiveDao.incrementRecordBy
targetArchiveId, selectedIds.size
档案计数 + N

刷新列表
loadHandwritings

退出选择模式
isSelectMode = false

5.2 batchUpdateArchive 实现

// HandwritingDao.ets
static async batchUpdateArchive(
  ids: number[],
  archiveId: number
): Promise<void> {
  if (ids.length === 0) return
  const store = HandwritingDao.getStore()
  const predicates = new relationalStore.RdbPredicates('handwriting')
  predicates.in('id', ids)  // WHERE id IN (1, 2, 3, ...)
  await store.update(
    { archive_id: archiveId } as relationalStore.ValuesBucket,
    predicates
  )
  hilog.info(TAG, '批量迁移 %{public}d 条记录到档案 %{public}d', ids.length, archiveId)
}

predicates.in('id', ids) 是 RDB Predicates 的多值匹配,等同于 SQL WHERE id IN (...)

5.3 调用代码

private async batchMoveToArchive(targetArchiveId: number): Promise<void> {
  this.isBatchMoving = true
  try {
    const ids = Array.from(this.selectedIds)

    // ① 批量更新笔迹的 archive_id
    await HandwritingDao.batchUpdateArchive(ids, targetArchiveId)

    // ② 更新目标档案的记录数
    await ArchiveDao.incrementRecordBy(targetArchiveId, ids.length)

    // ③ 反馈 + 刷新
    promptAction.showToast({
      message: `${ids.length} 条记录已归入档案`
    })
    await this.loadHandwritings()
    this.isSelectMode = false
    this.selectedIds = new Set()

  } catch (e) {
    hilog.error(TAG, '批量归档失败: %{public}s', JSON.stringify(e))
    promptAction.showToast({ message: '归档失败,请重试' })
  } finally {
    this.isBatchMoving = false
  }
}

六、底部操作栏(选择模式专属)

@Builder
SelectModeBar() {
  if (this.isSelectMode) {
    Row({ space: 12 }) {
      Text(`已选 ${this.selectedIds.size}`)
        .fontSize(14)
        .fontColor(AppColors.TEXT)

      Blank()

      Button('全选')
        .type(ButtonType.Capsule)
        .backgroundColor(AppColors.CARD)
        .fontColor(AppColors.TEXT_2)
        .onClick(() => this.toggleSelectAll())

      Button('归入档案')
        .type(ButtonType.Capsule)
        .backgroundColor(AppColors.PRIMARY)
        .fontColor(Color.White)
        .enabled(this.selectedIds.size > 0)
        .opacity(this.selectedIds.size > 0 ? 1 : 0.5)
        .onClick(() => {
          if (this.selectedIds.size > 0) {
            this.showArchivePicker = true
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor(AppColors.CARD)
    .transition({
      type: TransitionType.Insert,
      translate: { y: 60 }   // 从底部滑入
    })
  }
}

八、注意事项与常见问题

8.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期:合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步:跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

8.2 常见错误与解决方案

开发过程中的高频问题汇总:

错误现象 可能原因 解决方案
权限被系统拒绝 未在 module.json5 中静态声明 添加 requestPermissions 权限配置项
页面跳转后白屏 路由路径未在 main_pages.json 注册 检查并补充路由配置文件
UI 状态不刷新 状态变量未添加响应式装饰器 为变量加 @State / @Observed 装饰器
数据库查询返回空 查询条件与存储数据格式不匹配 使用 hilog.debug 打印 SQL 条件排查
相机预览黑屏 运行时相机权限未获取 先调用 requestPermissionsFromUser 申请

总结

UnclassifiedArchivePage 的 527 行代码的核心收获:

  1. LongPressGesture + onClick 共存:用 isSelectMode 标志在回调内部区分两种模式,避免手势冲突
  2. @State Set 必须替换对象:ArkTS 无法检测 Set 内部变化,必须 new Set(...) 触发重渲染
  3. every() 实现全选状态检测:一行代码判断当前是否全选,决定"全选/取消全选"操作
  4. predicates.in(field, values) 批量查询IN (...) 等效 SQL,O(1) 次 DB 操作完成批量更新
  5. Transition 滑入效果:选择模式底部操作栏用 translate.y:60→0 实现底部滑入

下一篇预告第98篇 FormCardsPreview 桌面卡片与 EmptyPage 空状态 Canvas 插画

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:


七、UnclassifiedArchivePage 核心实现

7.1 长按触发多选模式

ArkUI 中 LongPressGestureonClick 的共存写法:

ListItem() { /* 手写记录卡片 */ }
  .gesture(
    GestureGroup(GestureMode.Exclusive,
      LongPressGesture({ duration: 500 })
        .onAction(() => {
          if (!this.isMultiSelect) {
            this.isMultiSelect = true;  // 进入多选模式
            this.selectedIds.add(item.id);  // 长按的项自动选中
          }
        }),
      TapGesture()
        .onAction(() => {
          if (this.isMultiSelect) {
            // 多选模式下,点击切换选中状态
            if (this.selectedIds.has(item.id)) {
              this.selectedIds.delete(item.id);
            } else {
              this.selectedIds.add(item.id);
            }
          } else {
            // 普通模式下,跳转详情
            router.pushUrl({ url: 'pages/ReportDetailPage', params: { reportId: item.reportId } });
          }
        })
    )
  )

7.2 批量归档操作

private async batchAssignToArchive(archiveId: number) {
  const ids = Array.from(this.selectedIds);
  await HandwritingDao.updateArchiveBatch(ids, archiveId);
  promptAction.showToast({ message: `已将 ${ids.length} 条记录归入档案` });
  this.isMultiSelect = false;
  this.selectedIds.clear();
  this.loadUnclassified();
}

7.3 多选状态的视觉反馈

// 选中状态:添加勾选角标 + 边框高亮
Column() { /* 卡片内容 */ }
  .border({
    width: this.selectedIds.has(item.id) ? 2 : 0,
    color: AppColors.accentMain
  })
  .overlay(
    this.selectedIds.has(item.id)
      ? CheckMark()  // 右上角绿色勾选图标
      : null,
    { align: Alignment.TopEnd }
  )

七、UnclassifiedArchivePage 设计总结

功能 核心实现 技术要点
多选模式切换 @State isSelecting animateTo 展示/隐藏底部工具栏
批量归档操作 selectedIds: Set<number> 遍历集合执行 ArchiveDao.update
覆盖层勾选图标 overlay() 修饰符 Alignment.TopEnd 定位
空状态兜底 EmptyState 组件 无数据时居中显示插画

延伸阅读:第92篇 ArchivePage 档案管理页 · 第84篇 ArchiveDao 实现

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐