HarmonyOS NEXT - 记事本实例三 - 长按事件(变更分类信息和批量删除操作)

        在记事本App中实现多选模式并在弹框中勾选对应的分类的功能,是一种常见的UI设计模式。现在移动端和Web开发模式都支持多选功能的实现,可通过复选框或列表项的多选模式来实现。

        用户可以通过长按面板方式点击进入多选模式,选中内容后点击“分类”按钮弹出分类选择弹框。弹框的设计可以避免页面跳转,减少用户操作路径,提升用户体验。而且用户可以通过多选模式一次性选择多条内容,然后在弹框中快速分类,避免逐条操作的繁琐。另外,多选模式和弹框是用户熟悉的交互模式,学习成本低,易于上手。

一、设计流程

        以下内容部分,实现多选的示例流程,具体如下:

  1. 进入主界面,用户长按某条内容进入多选模式,选中条目高亮显示或显示为勾选模式。
  2.  点击“分类”按钮,弹出分类选择弹框,在弹框中勾选一个分类进行移动。
  3.  点击选择分类后,校验结果并作出相应提示,关闭弹框;如果流程正常,退出多选模式,否则继续等待。

        以下为弹框的中,选择分类信息的设计流程,具体如下:

  • 分类列表:弹框中显示分类列表,点击分类后选择该分类结果。
  • 确认按钮:弹框底部添加“确认”按钮,用户选择分类后点击确认完成操作。

二、ArkTS组件

2.1、LongPressGesture

        在HarmonyOS ArkTS中,可以通过LongPressGesture来触发长按事件。本项目中,将在onActionEnd回调事件中,记录长按状态,并显示底部操作栏界面。

LongPressGesture参数:

  • fingers:触发长按的最少手指数,默认为 1。
  • repeat:是否连续触发事件回调,默认为 false。
  • duration:触发长按的最短时间,单位为毫秒,默认为 500ms。

事件回调:

  • onAction:长按手势识别成功时触发的回调。
  • onActionEnd:长按手势识别成功后,手指抬起时触发的回调。
  • onActionCancel:长按手势识别成功后,接收到触摸取消事件时触发的回调。

2.2、ActionSheet

        在HarmonyOS ArkTS中,ActionSheet是一种用于显示列表选择的弹框组件,支持多种配置和交互方式。

ActionSheet的基本用法:

        ActionSheet 通过 ActionSheet.show() 方法弹出,支持标题、副标题、消息、选项列表等配置。以下是常用的配置参数:

参数名称 类型 必填 说明
title string 弹窗标题
subtitle string 弹窗副标题
message string 弹窗消息
autoCancel boolean 点击遮罩层是否关闭弹窗,默认为 true
confirm { value: string, action: () => void } 确认按钮的文本和回调
cancel () => void 点击遮罩层关闭时的回调
alignment DialogAlignment 弹窗对齐方式,默认为 DialogAlignment.Bottom
sheets Array<{ title: string, action: () => void }> 选项列表
onWillDismiss (dismissDialogAction: DismissDialogAction) => void 关闭前的回调,可用于拦截关闭

三、界面开发

3.1、显示操作面板

        打开src/main/ets/pages/Index.ets主界面文件,定义@State状态变量isLongTouch,记录内容面板是长按事件状态,为true时,显示底部“移动”按钮的操作面板;为flase时,隐藏其操作面板。

        此篇是接长两篇内容的补充部分,前面讲述过部分就不再阐述了。

地址一:HarmonyOS开发 - 记事本实例一(界面搭建)-CSDN博客

地址二:HarmonyOS开发 - 记事本实例二(关系型数据库数据存储)-CSDN博客

        界面效果如下图:

        主界面在之前基础上,内容面板作了一些调整,自定义构建函数listItem()中,修改为了左右两列,当长按状态开启时,显示右侧复选框部分。并且增加checkboxList和isLongTouch状态变量,复选框为true时,将当前行内容数据记录在checkboxList容器中。底部操作面板定义在构建函数BottomCard()中,代码如下:

import Header from '../components/Header'
import { ClassifyIndexInfo, NotesInfo } from '../types/types'
import { formatDate } from '../utils/utils'
import router from '@ohos.router'
import { NotesModal } from '../model/Notes'

@Entry
@Component
struct Index {
  @State notes: Array<NotesInfo> = []
  private classifyActiveId: number = -1   //分类ID
  private keyword: string = ''           // 搜索关键词
  @State checkboxList: Map<number, NotesInfo> = new Map() // 选中项
  @State isLongTouch: boolean = false    // 是否长按面板
  /**
   * 获取记事本内容
   */
  async updateNotes(){
    const result = await NotesModal.getAllRows(this.classifyActiveId, this.keyword)
    if (Array.isArray(result) && result.length > 0) {
      this.notes = result
    } else {
      this.notes = []
    }
    console.log('update notes list:', JSON.stringify(result))
  }

  aboutToAppear(): void {
    this.updateNotes()
    const context = getContext(this)
    // 监听分类索引变化
    context.eventHub.on('classifyIndexChange', (data: ClassifyIndexInfo) => {
      this.keyword = ''
      this.classifyActiveId = data.currentId  // 记录选中的分类ID
      this.isLongTouch = false      // 取消长按事件,还原列表状态
      this.updateNotes()    // 重新更新记事本内容
      console.log('classifyIndexChange', JSON.stringify(data))
    })
    // 监听内容变化
    context.eventHub.on('contentChange', () => {
      this.keyword = ''
      this.updateNotes()   // 重新更新记事本内容
      console.log('update content', JSON.stringify(this.notes))
    })
    // 监听搜索关键词
    context.eventHub.on('searchChange', (e: string) => {
      this.keyword = e
      this.updateNotes()
    })
  }

  // 自定义面板panel item
  @Builder listItem(item: NotesInfo){
    Column(){
      Row(){
        Column(){
          Row(){
            Text(item.title)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .padding({ right: 10 }) // 右侧内填充10,与日期相隔10个间距
            Text(formatDate(new Date(item.create_time as number), 'YYYY/MM/DD'))
              .fontSize(12)
              .fontColor(Color.Gray)
          }.width('100%')
          .justifyContent(FlexAlign.SpaceBetween) // 两边对齐
          .alignItems(VerticalAlign.Top)          // 内容顶部对齐
          // 判断描述内容是否存在
          if (item.content) {
            Text(item.content.substring(0, 50) + (item.content.length>50?'...':''))
              .fontSize(12)
              .fontColor(Color.Gray)
              .width('100%')
              .align(Alignment.Start)
              .margin({top: 10})
          }
        }.layoutWeight(1)
        // 长按事件被触发后,显示复选框
        if (this.isLongTouch) {
          Column(){
            Checkbox().onChange(e =>   {
              this.checkboxList.set(item.id as number, item)
            })
          }.padding({left: 15})
        }
      }.width('100%').justifyContent(FlexAlign.SpaceBetween)
    }.padding(15)
    .backgroundColor(Color.White)
    .borderRadius(10)
    .shadow({color: '#00000050', radius: 10})
    // 列表信息 点击跳转到编辑界面
    .onClick(() => {
      // 如果为长按钮模式,禁止跳转
      if (this.isLongTouch) return
      router.pushUrl({
        url: 'pages/CreateNote',
        params: { item, cid: this.classifyActiveId }
      })
    })
    // 长按事件
    .gesture(
      LongPressGesture({repeat: true}).onAction(() => {
        console.log('long event')
      }).onActionEnd(() => {
        this.isLongTouch = true
        console.log('long event end')
      })
    )
  }
  /**
   * 底部卡片
   */
  @Builder BottomCard(){
    Row({space: 20}){
      Button(){
        Column(){
          Image($rawfile('move_in.png')).width(20).margin({bottom: 5})
          Text('移动').fontSize(14)
        }
      }.backgroundColor(Color.Transparent)
      Button(){
        Column(){
          Image($rawfile('delete.png')).width(20).margin({bottom: 5})
          Text('删除').fontSize(14)
        }
      }.backgroundColor(Color.Transparent)
    }.width('100%')
     .justifyContent(FlexAlign.SpaceAround)
    .padding({top: 10})
  }

  build() {
    Row() {
      Column() {
        Header()  // 顶部组件
        Divider() //分割线
        // List容器
        List(){
          // 循环输出笔记列表内容
          ForEach(this.notes, (item: NotesInfo) => {
            ListItem(){
              this.listItem(item) // 渲染面板内容
            }.border({color: Color.Gray, style: BorderStyle.Dashed})
             .padding({ top: 10, bottom: 10 })
          })
        }.width('100%')
         .layoutWeight(6)
         .padding({ left: 10, right: 10, top: 10, bottom: 10 })
         .backgroundColor('#f1f1f1')
        // 添加按钮
        Button(){
          Image($rawfile('add.png'))
            .width(40)
        }.position({ right: 10, bottom: 60 })
         .backgroundColor(Color.Transparent)
         // 新增按钮 点击跳转到编辑界面
         .onClick(() => {
           // 如果为长按钮模式,禁止跳转
           if (this.isLongTouch) return
           router.pushUrl({
             url: 'pages/CreateNote',
             params: { cid: this.classifyActiveId }
           })
         })
        // 当长按事件被触发,显示底部操作按钮面板
        if (this.isLongTouch) {
          this.BottomCard()
        }
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .height('100%').alignItems(VerticalAlign.Top)
  }
}

3.2、还原复选状态

        在主界面的struct中定义restoreCheckStatus()函数,在分类选项重选时,或在其他操作中需要返取消复选框编辑状态时,调用执行restoreCheckStatus()函数。代码如下:

// 还原选择状态
restoreCheckStatus(){
  if (this.isLongTouch) this.isLongTouch = false
  this.checkboxList.clear()
}

        如在分类选项重选时,取消编辑状态。如下图:

3.3、缓存勾选行数据

        在主界面的struct中,找到listItem()构建函数,在复选框数据发生变更时,记录或移除当前行数据;当回调函数返回为true时,将当前行数据追加到状态变量checkboxList中;当回调函数返回false时,将状态变量checkboxList中存在的当前行数据移除掉。代码如下图:

3.4、弹框界面

        ArkTS组件中介绍了ActionSheet,这里就用它完成弹框分类列表的展示。在主界面的struct中定义记事本分类修改函数,点击“移动”按钮调用noteChangeClassify()函数,显示弹框分类列表。

// 记事本修改分类项
async noteChangeClassify(){
  // 获取分类信息
  const result = await ClassifyModal.getAllRows()
  // 判断是否有选择的移动项
  if(this.checkboxList.size == 0) {
    promptAction.showToast({message: '请选择要移动的内容后,再操作~'})
    return;
  }
  // 获取分类信息
  ActionSheet.show({
    title: '更改分类',
    message: '选择需要更改的分类',
    autoCancel: true,
    sheets: result.map(item => {
      const sheetItem: SheetInfo = {
        title: item.name,
        icon: $rawfile('dot.png'),
        action: async () => {
          console.log('change', item.id)
        }
      }
      return sheetItem
    }),
    confirm: {
      value: '知道了',
      action: () => {}
    }
  })
}

        页面效果如下:

3.5、model增加变更分类函数

        打开src/main/ets/model/Notes.ets文件,在Notes类中新增changeClassify()函数,用于更新记事本信息的行数据。注意的是,这里是批量处理,所以增加了事务处理机制,当循环提交中某环节出现错误时,回滚结果。代码如下:

/**
 * 修改分类ID
 * @param ids 修改的记事行数据ID
 * @param classifyId 更改的分类ID
 */
async changeClassify(ids: Array<number>, classifyId: number) {
  store.beginTransaction()  // 开始事务
  try {
    // 循环提交数据
    ids.forEach(async (_id) => {
      // 组合更新数据
      const dynamicObj: Record<string, number> = {}
      dynamicObj[this._CLASSIFY_ID] = classifyId
      // 构造查询条件
      const predicates = new relationalStore.RdbPredicates(this.tableName)
      predicates.equalTo(this._ID, _id)
      // 执行数据更新
      await store.update(dynamicObj, predicates)
    })
    store.commit()  // 提交
    console.log('update success...')
  } catch (e) {
    store.rollBack()  // 回滚
    console.log('update error, roll back...')
  }
  //
}

3.6、执行分类更改

        当用户选择某个分类时,将记事本信息列表中勾选项的ID提取出来,以及获取当前选择的分类的ID,执行NotesModal.changeClassify()函数更新分类ID。更新后,重新获取记事本信息的行数据,并取消编辑状态。代码如下:

        代码如下:

// 记事本修改分类项
async noteChangeClassify(){
  // 获取分类信息
  const result = await ClassifyModal.getAllRows()
  // 判断是否有选择的移动项
  if(this.checkboxList.size == 0) {
    promptAction.showToast({message: '请选择要移动的内容后,再操作~'})
    return;
  }
  // 获取分类信息
  ActionSheet.show({
    title: '更改分类',
    message: '选择需要更改的分类',
    autoCancel: true,
    sheets: result.map(item => {
      const sheetItem: SheetInfo = {
        title: item.name,
        icon: $rawfile('dot.png'),
        action: async () => {
          // 获取选中的ID值
          const checkedIds = Array.from(this.checkboxList.keys())
          // 执行分类更改
          await NotesModal.changeClassify(checkedIds, item.id)
          // 更新记事本信息行数据
          this.updateNotes()
          // 还原状态
          this.restoreCheckStatus()
        }
      }
      return sheetItem
    }),
    confirm: {
      value: '知道了',
      action: () => {}
    }
  })
}

        在“移动”按钮位置,绑定变更分类ID的事件函数。如下图:

        上述操作完成后,就可以修改记事本信息的分类ID了。现在将“全部”状态的“日记五”,更新到“默认”分类中。如下图:

        当选择“默认”分类后,切换到“默认”分类项中后,“日记五”这篇内容也在该分类下展示出来了。

四、批量删除操作

4.1、model增加批量删除函数

        打开src/main/ets/model/Notes.ets文件,继续增加批量删除的函数,将形参中传入的ids循环处理并执行删除操作。代码如下:

/**
 * 多行数据删除
 * @param ids
 */
async deleteMultiRowData(ids: Array<number>) {
  store.beginTransaction()  // 开始事务
  try {
    ids.forEach(async (_id) => {
      const predicates = new relationalStore.RdbPredicates(this.tableName)
      predicates.equalTo(this._ID, _id)
      await store.delete(predicates)
    })
    store.commit()    // 提交
  }catch (e) {
    store.rollBack()  // 回滚
  }
}

4.2、执行删除操作

        既然勾选多条数据可以修改分类,也可以勾选多条数据进行删除操作。同样,在主界面的struct中定义多行删除函数deleteMultiNotesInfo(),删除操作完成后,更新记事本内容数据,并且退出编辑状态。代码如下:

/**
 * 删除多条记事本内容
 */
deleteMultiNotesInfo() {
  AlertDialog.show({
    title: '提示',
    message: `确认要删除选中的信息吗?`,
    alignment: DialogAlignment.Center,
    buttons: [
      {
        value: '删除',
        action: async () => {
          // 获取选中的ID值
          const checkedIds = Array.from(this.checkboxList.keys())
          // 批量删除行数据
          await NotesModal.deleteMultiRowData(checkedIds)
          // 更新记事本信息行数据
          this.updateNotes()
          // 还原状态
          this.restoreCheckStatus()
        }
      },
      {
        value: '取消',
        action: () => {}
      }
    ]
  })
}

        同样,在“删除”按钮的点击事件处绑定删除事件。如下图:

        此时,我们在记事本中添加一条“test”测试数据,使用批量删除功能将它移除掉。

        长按几秒"test"内容面板松开,待为编辑状态时,勾选“test”项,点击“删除”按钮将它删除掉。如下图:

        此篇就讲到这了,希望对大家会有所帮助。感谢阅读,期待能有更多关于HarmonyOS的探索和新的总结经验。