鸿蒙应用开发实战【20】— 批量操作栏:UI 选择模式完整实现

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
在这里插入图片描述

号码助手首页支持批量操作模式:长按或点击批量按钮后,列表切换为多选模式,顶部标题栏显示「取消 / 已选 N 项 / 全选」,底部弹出「标记状态 / 删除」操作栏。这是移动端 App 最经典的批量管理交互模式。

本篇从零拆解这套 UI 的完整实现:状态机设计 → 选择行 UI → 全选/取消全选 → 批量操作底栏 → showActionMenu + showAlertDialog

本篇涵盖:selectMode 状态机设计、SelectableRow @Builder 复选框实现、toggleSelect/toggleSelectAll 逻辑、标题栏两态切换、批量操作底栏布局、showActionMenu 改状态、showAlertDialog 确认删除、ArkTS async-onClick 陷阱。


批量操作栏架构图

图1:批量选择模式状态机、标题栏两态切换、SelectableRow 设计、操作弹窗流程


一、状态机设计

1.1 批量模式的状态变量

// HomePage.ets — 批量模式状态
@State selectMode: boolean = false    // 是否处于批量选择模式
@State selectedIds: number[] = []     // 已选中的绑定 ID 数组

1.2 模式转换图

正常模式(selectMode=false)
    ↓  长按 AppListItem / 点击「批量」
批量模式(selectMode=true)
    ├── 点击列表行 → toggleSelect(id):切换单项选中
    ├── 点击「全选」 → toggleSelectAll():全选 / 取消全选
    ├── 点击「取消」 → 退出批量模式,清空 selectedIds
    ├── 点击「标记状态」 → showActionMenu → batchChangeStatus()
    └── 点击「删除」 → showAlertDialog → doBatchDelete()

1.3 进入/退出批量模式

// 进入批量模式(AppListItem 长按触发)
.gesture(LongPressGesture().onAction(() => {
  this.selectMode = true
}))

// 退出批量模式
Text('取消').onClick(() => {
  this.selectMode = false
  this.selectedIds = []    // 清空已选
})

二、标题栏两态切换

2.1 普通模式标题栏

// 正常模式:左侧头像,右侧搜索
Row() {
  Column() { Text('👤').fontSize(16) }
    .width(32).height(32).borderRadius(16)
    .backgroundColor(AppColors.CARD_B)
    .border({ width: 1, color: AppColors.LINE, radius: 16 })
    .justifyContent(FlexAlign.Center)
    .onClick(() => { this.navigate('pages/MePage') })

  Blank()   // 弹性空白撑开左右

  Column() { Text('🔍').fontSize(15) }
    .width(32).height(32).borderRadius(16)
    .backgroundColor(AppColors.CARD_B)
    .border({ width: 1, color: AppColors.LINE, radius: 16 })
    .justifyContent(FlexAlign.Center)
    .onClick(() => { this.navigate('pages/SearchPage') })
}
.width('100%').height(44).padding({ left: 16, right: 16 })

2.2 批量模式标题栏

// 批量模式:取消 | 已选 N 项(居中) | 全选
Row() {
  Text('取消')
    .fontSize(AppFonts.SIZE_BODY)
    .fontColor(AppColors.TEXT_2)
    .fontWeight(AppFonts.WEIGHT_MEDIUM)
    .onClick(() => {
      this.selectMode = false
      this.selectedIds = []
    })

  Text(`已选 ${this.selectedIds.length}`)
    .fontSize(14)
    .fontWeight(AppFonts.WEIGHT_BOLD)
    .fontColor(AppColors.TEXT)
    .layoutWeight(1)             // 占中间剩余空间
    .textAlign(TextAlign.Center) // 文字居中

  Text('全选')
    .fontSize(AppFonts.SIZE_BODY)
    .fontColor(AppColors.CYAN)
    .fontWeight(AppFonts.WEIGHT_SEMIBOLD)
    .onClick(() => this.toggleSelectAll())
}
.width('100%').height(44).padding({ left: 16, right: 16 })

2.3 用 if/else 切换两个标题栏

build() {
  Column() {
    // 标题栏——根据 selectMode 渲染不同的标题
    if (this.selectMode) {
      // 批量模式标题栏(见 2.2)
    } else {
      // 正常模式标题栏(见 2.1)
    }
    // ... 下方内容
  }
}

三、SelectableRow 可选择行

3.1 完整代码

// HomePage.ets — SelectableRow @Builder
@Builder
private SelectableRow(row: AppRow) {
  Row() {
    // ① 复选框(圆形,选中时填色 + 显示 ✓)
    Stack() {
      if (this.selectedIds.includes(row.binding.id ?? 0)) {
        Text('✓').fontSize(11).fontColor('#FFFFFF')
      }
    }
    .width(20).height(20).borderRadius(10)
    .backgroundColor(
      this.selectedIds.includes(row.binding.id ?? 0)
        ? AppColors.PRIMARY
        : Color.Transparent
    )
    .border({
      width: 2,
      color: this.selectedIds.includes(row.binding.id ?? 0)
        ? AppColors.PRIMARY
        : '#2E141E3C',
      radius: 10
    })
    .margin({ right: 10 })

    // ② 应用头像(与正常列表一致)
    AvatarBadge({
      text: row.binding.app_name.slice(0, 1),
      colorStart: row.binding.icon_key,
      colorEnd: row.binding.icon_key,
      badgeSize: 42, radius: 12, fontSize: 14
    })

    // ③ 应用名 + 分类标签
    Column() {
      Text(row.binding.app_name)
        .fontSize(AppFonts.SIZE_BODY)
        .fontWeight(AppFonts.WEIGHT_MEDIUM)
        .fontColor(AppColors.TEXT)
      Row({ space: 6 }) {
        Text(row.binding.category)
          .fontSize(AppFonts.SIZE_MINI)
          .fontColor(AppColors.TEXT_2)
          .border({ width: 1, color: AppColors.LINE, radius: 6 })
          .padding({ left: 6, right: 6, top: 1, bottom: 1 })
        if (row.cardLabel.length > 0) {
          Text(row.cardLabel)
            .fontSize(AppFonts.SIZE_MINI)
            .fontColor(AppColors.TEXT_2)
            .border({ width: 1, color: AppColors.LINE, radius: 6 })
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
        }
      }
      .margin({ top: 5 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    // ④ 状态徽标
    StatusBadge({ status: row.binding.status })
  }
  .width('100%')
  .padding({ top: 10, bottom: 10, left: 12, right: 12 })
  .backgroundColor(AppColors.CARD)
  .border({ width: 1, color: AppColors.LINE, radius: 16 })
  .margin({ bottom: 10 })
  .onClick(() => this.toggleSelect(row.binding.id ?? 0))   // 点击切换选中
}

3.2 复选框的两态视觉

状态 边框色 背景色 内部图标
未选中 #2E141E3C(淡灰) Transparent
已选中 AppColors.PRIMARY(蓝) AppColors.PRIMARY(蓝) ✓ #FFFFFF

四、全选/单选逻辑

4.1 单项切换

private toggleSelect(id: number): void {
  if (this.selectedIds.includes(id)) {
    // 已选中 → 取消选中(过滤掉该 id)
    this.selectedIds = this.selectedIds.filter((i: number) => i !== id)
  } else {
    // 未选中 → 加入选中(concat 产生新数组,触发 @State 更新)
    this.selectedIds = this.selectedIds.concat([id])
  }
}

4.2 全选/取消全选

private toggleSelectAll(): void {
  const filtered = this.getFilteredRows()
  const allIds = filtered
    .map((r: AppRow) => r.binding.id ?? 0)
    .filter((id: number) => id > 0)   // 过滤掉 id=0 的异常数据

  // 如果已全选 → 取消全选;否则 → 全选
  this.selectedIds = this.selectedIds.length === allIds.length ? [] : allIds
}

4.3 为什么用 concat/filter 而不是 push/splice

// ❌ 错误:push/splice 直接修改数组,ArkUI 不会检测到变化
this.selectedIds.push(id)        // @State 不触发重渲染!
this.selectedIds.splice(idx, 1)  // @State 不触发重渲染!

// ✅ 正确:产生新数组引用,@State 检测到引用变化 → 重渲染
this.selectedIds = this.selectedIds.concat([id])
this.selectedIds = this.selectedIds.filter((i: number) => i !== id)

五、ForEach key 函数(批量模式)

// 批量模式下的 ForEach key 函数
// key 必须包含"是否选中"状态,否则选中/取消选中时复选框不重渲染

ForEach(
  this.getFilteredRows(),
  (row: AppRow) => {
    this.SelectableRow(row)
  },
  (row: AppRow) => `sel_${row.binding.id ?? 0}_${
    this.selectedIds.includes(row.binding.id ?? 0) ? '1' : '0'
  }`
  // key = "sel_{id}_1" 或 "sel_{id}_0"
  // 选中状态改变时 key 变 → ArkUI 重新渲染该行 → 复选框状态更新
)

六、批量操作底栏

6.1 底栏布局

// 批量操作底栏(仅 selectMode=true 且有数据时显示)
if (this.selectMode && this.rows.length > 0) {
  Row() {
    // 左侧:已选数量提示
    Text(`已选 ${this.selectedIds.length}`)
      .fontSize(12)
      .fontColor(AppColors.TEXT_2)
      .fontWeight(AppFonts.WEIGHT_MEDIUM)

    Blank()   // 弹性空白,把按钮推到右侧

    // 「标记状态」按钮
    Button('标记状态', { type: ButtonType.Normal })
      .height(40)
      .padding({ left: 14, right: 14 })
      .fontSize(12)
      .fontWeight(AppFonts.WEIGHT_SEMIBOLD)
      .fontColor(AppColors.TEXT)
      .backgroundColor(AppColors.CARD_B)
      .border({ width: 1, color: AppColors.LINE, radius: 12 })
      .margin({ right: 8 })
      .onClick(() => this.batchChangeStatus())

    // 「删除」按钮(有选中时红色,无选中时灰色)
    Button('删除', { type: ButtonType.Normal })
      .height(40)
      .padding({ left: 14, right: 14 })
      .fontSize(12)
      .fontWeight(AppFonts.WEIGHT_SEMIBOLD)
      .fontColor(this.selectedIds.length > 0 ? AppColors.DANGER : AppColors.TEXT_3)
      .backgroundColor(AppColors.DANGER_BG)
      .borderRadius(12)
      .onClick(() => this.batchDeleteTap())
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 20 })
  .backgroundColor('#FFFFFFFF')
  .border({ width: { top: 1 }, color: AppColors.LINE })   // 顶部分割线
}

6.2 底栏视觉规格

属性 规格
高度 padding top 12 + bottom 20 + 按钮 40 = 约 72vp
背景 纯白 #FFFFFFFF(与列表区区分)
分割线 顶部 1px,AppColors.LINE(淡灰)
标记状态按钮 白底+边框(次要样式)
删除按钮 危险红背景(有选中时)/灰字(无选中时)

七、批量改状态:showActionMenu

7.1 完整实现

private async batchChangeStatus(): Promise<void> {
  if (this.selectedIds.length === 0) {
    promptAction.showToast({ message: '请先选择应用' })
    return
  }

  // 定义按钮列表
  interface StatusBtn { text: string; color: string | Resource }
  const STATUS_OPTS: string[] = ['使用中', '待换绑', '待注销', '已停用']
  const buttons: StatusBtn[] = STATUS_OPTS.map((s: string): StatusBtn => ({
    text: s,
    color: AppColors.TEXT
  }))

  // 弹出 ActionMenu
  const result = await promptAction.showActionMenu({
    title: '批量更改状态',
    buttons: buttons as [StatusBtn, StatusBtn?, StatusBtn?, StatusBtn?]
  })

  // 根据用户选择执行更新
  const newStatus = STATUS_OPTS[result.index] as BindingStatus
  await AppBindingDao.batchUpdateStatus(this.selectedIds, newStatus, Date.now())
  promptAction.showToast({ message: `已更新 ${this.selectedIds.length} 个应用` })

  // 退出批量模式,刷新列表
  this.selectMode = false
  this.selectedIds = []
  this.loadData()
}

7.2 showActionMenu 参数说明

promptAction.showActionMenu({
  title: string,         // 弹窗标题
  buttons: [             // 最多 6 个按钮(元组类型)
    { text: '按钮1', color: '#RRGGBB' },
    { text: '按钮2', color: '#RRGGBB' },
    // ...
  ]
})
// 返回值:Promise<{ index: number }>
// index = 用户点击的按钮索引(0-based)
// 用户点击取消/外部时 Promise rejected(需 catch)

八、批量删除:showAlertDialog

8.1 触发确认弹窗

private batchDeleteTap(): void {
  if (this.selectedIds.length === 0) return

  // 使用 showAlertDialog 弹出确认提示
  this.getUIContext().showAlertDialog({
    title: '批量删除',
    message: `确定要删除所选的 ${this.selectedIds.length} 个应用绑定吗?此操作不可撤销。`,
    buttons: [
      { value: '取消', action: () => {} },
      { value: '确定删除', action: () => { this.doBatchDelete() } }
    ]
  })
}

8.2 执行删除

private async doBatchDelete(): Promise<void> {
  try {
    await AppBindingDao.deleteMany(this.selectedIds)
    promptAction.showToast({ message: `已删除 ${this.selectedIds.length} 个应用` })
    this.selectMode = false
    this.selectedIds = []
    this.loadData()
  } catch (e) {
    promptAction.showToast({ message: '删除失败,请重试' })
  }
}

8.3 showAlertDialog vs showDialog

方法 特点 适用场景
showAlertDialog 系统风格确认弹窗 危险操作确认 ✅
showDialog 可自定义按钮数量和颜色 多选项弹窗
showActionMenu 底部动作菜单 多选项操作 ✅
showToast 轻提示(自动消失) 操作反馈

九、ArkTS async-onClick 陷阱

9.1 问题描述

// ❌ 危险:onClick 直接写 async 函数
.onClick(async () => {
  const result = await promptAction.showActionMenu(...)
  // 问题:如果 showActionMenu 被拒绝(用户点取消),
  // Promise rejection 不会被 ArkUI onClick 捕获,导致静默失败
})

9.2 正确写法

// ✅ 正确:onClick 调用一个有错误处理的 async 方法
.onClick(() => this.batchChangeStatus())

// 方法内部用 try/catch 处理
private async batchChangeStatus(): Promise<void> {
  try {
    const result = await promptAction.showActionMenu(...)
    // ...
  } catch (e) {
    // 用户取消 / 其他错误 — 静默处理或 toast 提示
    console.warn('ActionMenu cancelled:', JSON.stringify(e))
  }
}

十、完整流程串联

// 批量模式完整流程(状态转换视角)
// 1. 进入批量模式
this.selectMode = true    // (长按列表项触发)

// 2. 用户选择/取消选择
this.toggleSelect(id)       // 单选
this.toggleSelectAll()      // 全选/取消全选

// 3A. 批量改状态
await this.batchChangeStatus()
// → promptAction.showActionMenu → 用户选状态
// → AppBindingDao.batchUpdateStatus(ids, newStatus, now)
// → this.selectMode = false; selectedIds = []; loadData()

// 3B. 批量删除
this.batchDeleteTap()
// → showAlertDialog 确认 → this.doBatchDelete()
// → AppBindingDao.deleteMany(ids)
// → this.selectMode = false; selectedIds = []; loadData()

// 4. 取消批量模式(不操作)
this.selectMode = false; this.selectedIds = []

十一、本篇小结

知识点 核心要点
selectMode 状态机 boolean + selectedIds 两个变量驱动整个批量模式
标题栏两态切换 if/else 分支,批量模式下「取消/已选N/全选」
SelectableRow 圆形复选框:Stack 内嵌 ✓,选中态蓝色填充
toggleSelect concat/filter 产生新数组(不用 push/splice)
ForEach key 包含选中状态 sel_{id}_{0/1},精确触发重渲染
showActionMenu await 返回 { index } 对应用户选的按钮
showAlertDialog 危险操作确认,buttons: [{value,action}]
async-onClick 不直接 async,而是调用有 try/catch 的方法

参考资料

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


相关资源:

Logo

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

更多推荐