HarmonyOS《柚兔学伴》项目实战09-待办列表 UI——List+ForEach+滑动删除

09

数据持久化只是第一步,将数据以优雅的交互形式呈现给用户才是最终目标。本文将围绕 TodoView.ets,讲解如何使用 ArkUI 的 List + ForEach 渲染待办列表、swipeAction 实现滑动删除、CustomImageToggle 处理完成状态,以及空状态、计时器、诗词阅读等辅助功能的集成。


1. TodoView 组件概览

typescript 复制代码
@Component
export struct TodoView {
  private pageContext: PageContext = AppStorage.get('pageContext') as PageContext;
  @StorageProp('GlobalInfoModel') globalInfoModel: GlobalInfoModel = AppStorage.get('GlobalInfoModel')!;
  @State arr: TodoItem[] = []
  private todoDb: TodoDatabase = new TodoDatabase(getContext());
  @State isTodoEmpty: boolean = false
  // ... 其他状态
}

核心状态:

  • @State arr: TodoItem[]:待办事项数组,驱动列表渲染。
  • @StorageProp('GlobalInfoModel'):全局设备信息,获取状态栏高度等参数。
  • todoDb:数据库实例,负责 CRUD 操作。
  • isTodoEmpty:控制空状态与列表状态的切换。

2. 初始化:数据库与示例数据

typescript 复制代码
async aboutToAppear(): Promise<void> {
  await this.todoDb.initialize();

  let isFirst = PreferencesUtil.getBooleanSync('isFirst', true)
  if (isFirst) {
    this.initDemoTodoList();
  } else {
    this.arr = await this.todoDb.getAllTodos();
  }

  this.getPoem()

  font.registerFont({
    familyName: 'kaiti',
    familySrc: $rawfile('font/kaiti.ttf')
  })
}

初始化流程:

  1. 初始化数据库 ------await this.todoDb.initialize()
  2. 判断首次启动 ------通过 PreferencesUtil 读取标记。
  3. 首次启动 :调用 initDemoTodoList() 添加示例数据。
  4. 非首次启动:从数据库加载所有待办事项。
  5. 注册楷体字体 ------font.registerFont 注册自定义字体,供诗词显示使用。

2.1 示例数据

typescript 复制代码
private initDemoTodoList() {
  this.arr.push({
    id: 1, time: '08:00', content: '(示例)晨读', isCompleted: true, date: '', dayOfWeek: ''
  });
  this.arr.push({
    id: 2, time: '09:00', content: '(示例)吃早饭', isCompleted: false, date: '', dayOfWeek: ''
  });
  this.arr.push({
    id: 3, time: '10:00', content: '(示例)语文作业', isCompleted: false, date: '', dayOfWeek: ''
  });
}

首次用户看到三条示例待办,帮助他们快速理解应用功能。


3. List + ForEach 渲染待办列表

3.1 空状态与列表状态切换

typescript 复制代码
if (this.isTodoEmpty) {
  this.buildEmptyList()
} else {
  this.buildTodoList()
}

3.2 空状态展示

typescript 复制代码
@Builder
buildEmptyList() {
  Column({ space: 10 }) {
    Text('当前暂无待办事项')
      .fontColor($r('app.color.color_sub_text'))
      .fontSize(12)
    Button('添加')
      .height(36)
      .fontSize(14)
      .backgroundColor($r('app.color.app_primary'))
      .fontColor($r('app.color.color_card'))
      .borderRadius(25)
      .onClick(() => {
        this.isShowTask = true;
      });
  }
  .backgroundColor($r('app.color.color_card'))
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .width('100%')
  .margin({ bottom: 40 })
  .borderRadius(12)
}

当列表为空时,显示提示文字和「添加」按钮,引导用户创建第一条待办。

3.3 列表渲染

typescript 复制代码
@Builder
buildTodoList() {
  List({ space: 12, scroller: this.scrollerForList }) {
    ForEach(this.arr, (item: TodoItem, index: number) => {
      ListItem() {
        Row() {
          Column() {
            Text(item.content)
              .layoutWeight(1)
              .fontWeight(FontWeight.Bold)
            Row({ space: 6 }) {
              Image($r('app.media.ic_time_icon')).width(18);
              Text(item.time)
                .fontColor($r('app.color.color_text_light'));
            };
          }.layoutWeight(1).alignItems(HorizontalAlign.Start);

          CustomImageToggle({
            isOn: item.isCompleted,
            onToggleChange: (value) => {
              // 完成状态切换逻辑
            }
          });
        }
        .width('100%')
        .height('100%');
      }
      .swipeAction({
        end: {
          builder: () => { this.itemEnd(index); },
          actionAreaDistance: 40,
        }
      })
      .height(80)
      .padding({ left: 12, top: 5, bottom: 5, right: 12 })
      .borderRadius(12)
      .backgroundColor($r('app.color.color_card'));
    }, (item: TodoItem) => item.id?.toString());
  }
  .listDirection(Axis.Vertical)
  .scrollBar(BarState.Off)
  .friction(0.6)
  .margin({ bottom: 40 })
  .width('100%')
}

关键组件解析:

  • List :可滚动列表容器,space: 12 设置列表项间距。
  • ForEach :渲染列表数据,第三个参数是键值生成函数 (item) => item.id?.toString(),用于高效 diff 更新。
  • ListItem :列表项容器,支持 swipeAction 滑动操作。
  • scrollerForListScroller 控制器,支持编程式滚动。

ForEach 键值 :必须为每个列表项提供唯一键值,否则可能导致列表更新异常。本项目使用 item.id?.toString() 作为键值。


4. swipeAction:滑动删除

4.1 滑动操作配置

typescript 复制代码
.swipeAction({
  end: {
    builder: () => { this.itemEnd(index); },
    actionAreaDistance: 40,
  }
})
  • end:向左滑动后显示的操作区域(尾部)。
  • builder:滑动操作区域的 UI 构建器。
  • actionAreaDistance: 40:滑动操作区域的宽度。

4.2 删除按钮构建

typescript 复制代码
@Builder
itemEnd(index: number) {
  Row() {
    Button('删除')
      .borderRadius(5)
      .backgroundColor($r('app.color.app_primary'))
      .onClick(async () => {
        if (!this.arr[index].date) {
          this.arr.splice(index, 1)
        } else {
          this.todoDb.deleteTodo(this.arr[index].id ?? 0).then((isDelete: boolean) => {
            if (isDelete) {
              this.arr.splice(index, 1)
            }
          })
        }

        if (this.arr.length === 0) {
          this.isTodoEmpty = true
        }
      })
  }.margin({ left: 7 })
}

删除逻辑说明:

  1. 判断数据来源 :如果 item.date 为空,说明是示例数据(未入库),直接从数组移除。
  2. 数据库中的数据 :先调用 todoDb.deleteTodo() 删除,成功后再从数组移除。
  3. 空列表检测:删除后如果数组为空,切换到空状态视图。

5. CustomImageToggle:完成状态切换

typescript 复制代码
CustomImageToggle({
  isOn: item.isCompleted,
  onToggleChange: (value) => {
    let currentBonus = PreferencesUtil.getNumberSync(AgentConstant.SP_BONUS, 0)
    if (!value && currentBonus === 0) {
      item.isCompleted = true;
      return;
    }
    item.isCompleted = value;
    PreferencesUtil.putSync(AgentConstant.SP_BONUS, value ? currentBonus + 1 : currentBonus - 1)
    this.todoDb.updateTodo(item.id!!, item)
    this.finishVisible = this.arr.every(item => item.isCompleted === true);
  }
});

完成状态切换涉及积分(bonus)系统:

  1. 获取当前积分 :从 PreferencesUtil 读取 SP_BONUS
  2. 取消完成的保护逻辑:如果积分为 0,不允许取消完成状态(防止积分变为负数)。
  3. 更新积分 :完成 +1,取消完成 -1,通过 PreferencesUtil.putSync 持久化。
  4. 更新数据库 :调用 todoDb.updateTodo() 保存完成状态。
  5. 全完成检测 :如果所有待办都已完成,显示庆祝弹窗(finishVisible = true)。

6. 添加待办:半模态弹窗

typescript 复制代码
.bindSheet($$this.isShowTask, this.todoAddBuilder(), {
  blurStyle: BlurStyle.Thick,
  dragBar: true,
  title: this.DialogTitle('今日任务', '添加', async () => {
    if (StrUtil.isEmpty(this.value)) {
      ToastUtil.showToast("任务内容不能为空")
      return
    }
    if (StrUtil.isEmpty(this.startTime)) {
      ToastUtil.showToast("开始时间不能为空")
      return
    }
    if (StrUtil.isEmpty(this.stopTime)) {
      ToastUtil.showToast("结束时间不能为空")
      return
    }
    const newTodo: TodoItem = {
      date: DateUtil.getTodayStr('yyyy-MM-dd'),
      dayOfWeek: '周' + DateUtil.getWeekDay(DateUtil.getTodayStr('HH-mm-ss')),
      time: `${this.startTime}-${this.stopTime}`,
      content: this.value,
      isCompleted: false
    };
    const todoId = await this.todoDb.addTodo(newTodo);
    PreferencesUtil.putSync('isFirst', false);
    this.arr = []
    this.arr = await this.todoDb.getAllTodos();
    this.startTime = this.stopTime = ''
    this.isShowTask = false
    this.isTodoEmpty = false
  }),
  height: this.sheetHeight,
  keyboardAvoidMode: SheetKeyboardAvoidMode.TRANSLATE_AND_RESIZE
})

**半模态(bindSheet)**关键点:

  • $$this.isShowTask :双向绑定显示状态,$$ 语法允许框架内部修改该值(如用户下滑关闭)。
  • blurStyle: BlurStyle.Thick:背景毛玻璃效果。
  • dragBar: true:显示拖拽条,用户可下滑关闭。
  • keyboardAvoidMode:键盘弹出时的避让模式。

添加待办的表单包含任务名称、开始时间和结束时间,通过 IBestFieldIBestCell 组件实现输入和时间选择。

6.1 刷新数据的方式

typescript 复制代码
this.arr = []
this.arr = await this.todoDb.getAllTodos();

先清空数组再重新赋值,确保 @State 能检测到变化并触发 UI 刷新。直接使用 this.arr = await ... 赋值也是可行的,但清空后赋值的模式在某些场景下更可靠。


7. 功能入口:IconText 组件

typescript 复制代码
Flex({ justifyContent: FlexAlign.SpaceBetween }) {
  IconText({
    icon: $r('app.media.ic_func_stroke'),
    txt: '每日诗词',
    fontColor: $r('app.color.color_function_txt_green'),
    bgColor: $r('app.color.color_function_green'),
    onFuncClick: async () => {
      this.userId = UserInfoManager.getUserInfo()?.unionID ?? ''
      if (StrUtil.isBlank(this.userId)) {
        this.login()
        return
      }
      await this.getPoem();
      if (this.poemInfo) {
        this.pageContext.openPage({
          param: this.poemInfo,
          routerName: 'PoemPage',
          onReturn: async (data) => {
            this.getPoem()
          }
        }, true);
      }
    }
  }).width('45%')

  IconText({
    icon: $r('app.media.ic_func_speak'),
    txt: '练字字帖',
    fontColor: $r('app.color.color_function_txt_purple'),
    bgColor: $r('app.color.color_function_blue'),
    onFuncClick: () => {
      this.pageContext.openPage({ routerName: 'CopyPage' }, true);
    }
  }).width('45%')
}.width('100%')

IconText 是项目封装的图标+文字入口组件,每个功能入口有独立的配色方案。点击「每日诗词」时需要登录验证,而「练字字帖」则直接跳转。


8. TimerComponent:番茄钟计时器

typescript 复制代码
TimerComponent({
  totalTime: this.settingTime,
  onTimeUp: this.onTimerFinished,
  onTimeChange: this.onTimeChanged,
  controller: this.timerController
}).onClick(() => {
  showDialogTime({
    titleBarAttribute: { titleText: "请选择计时时间" },
    timeAttribute: {
      timeType: TimeDialogType.HMS,
      startHours: 0, startMinutes: 0, startSeconds: 0,
      selectTime: "00-05-00",
      endHours: 23, endMinutes: 59, endSeconds: 59,
    },
    timeConfirmClick: (date) => {
      this.settingTime =
        (Number(date.getHours()) || 0) * 3600 +
        (Number(date.getMinutes()) || 0) * 60 +
        (Number(date.getSeconds()) || 0)
    },
  })
})

计时器默认 25 分钟(番茄钟),点击后弹出时间选择对话框,用户可自定义时长。计时结束时触发 onTimerFinished 播放提醒铃声。


9. 诗词阅读与字体注册

9.1 楷体字体注册

typescript 复制代码
font.registerFont({
  familyName: 'kaiti',
  familySrc: $rawfile('font/kaiti.ttf')
})

通过 font.registerFont 注册自定义字体文件,注册后可在 Text 组件中使用 .fontFamily('kaiti') 指定字体。

9.2 诗词显示与朗读

typescript 复制代码
Column({ space: 12 }) {
  Text(this.poem)
    .fontFamily('kaiti')
    .fontWeight(FontWeight.Bold)
    .textAlign(TextAlign.Center)
    .lineSpacing(LengthMetrics.fp(18))
}
.mainCardStyle()
.onClick(() => {
  PoemReader.play(this.poem);
})
  • .fontFamily('kaiti'):使用注册的楷体字体,营造传统文化氛围。
  • PoemReader.play:点击诗词卡片触发朗读,基于 TTS(文本转语音)能力。

10. Scroll 容器配置

typescript 复制代码
Scroll(this.scroller) {
  Column({ space: 10 }) {
    // 标题行
    // 倒计时 + 诗词
    // 功能入口
    // 待办列表
  }
}
.height('100%')
.width('100%')
.friction(0.6)
.align(Alignment.Top)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
属性 说明
.friction(0.6) 滚动摩擦系数,小于 1 更灵敏
.scrollBar(BarState.Off) 隐藏滚动条
.edgeEffect(EdgeEffect.Spring) 边界回弹效果,提升手感

11. 总结

知识点 说明
List + ForEach 可滚动列表渲染,ForEach 需提供唯一键值
swipeAction 滑动操作,end 配置左滑删除
CustomImageToggle 自定义开关组件,处理完成状态与积分联动
bindSheet 半模态弹窗,$$ 双向绑定显示状态
PreferencesUtil 轻量偏好存储,保存积分和首次启动标记
font.registerFont 注册自定义字体,$rawfile 引用 rawfile 资源
TimerComponent 番茄钟计时器,时间选择对话框集成
PoemReader.play TTS 诗词朗读
IconText 功能入口组件,带图标、文字和配色
Scroll + EdgeEffect.Spring 弹性滚动效果
空状态切换 条件渲染:空列表 vs 数据列表
数据刷新模式 清空数组 → 重新查询赋值,确保 @State 触发更新

待办列表看似是一个简单的 CRUD 页面,实则涉及列表渲染、手势交互、状态管理、数据持久化、自定义字体、计时器、TTS 朗读等多个技术点的综合运用。理解这些模式的组合方式,是构建复杂交互页面的基础。

相关推荐
不羁的木木1 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第6篇:图片滤镜效果实现
图像处理·深度学习·harmonyos
拥抱太阳06162 小时前
HarmonyOS 应用开发《掌上英语》第9篇—ArkTS @ObservedV2 + @Trace 响应式编程精讲
华为·harmonyos
:-)2 小时前
算法-归并排序
java·开发语言·数据结构·算法·排序算法
拥抱太阳06162 小时前
HarmonyOS 应用开发《掌上英语》第10篇——@Local vs @Param vs @Trace 组件状态管理三剑客
华为·harmonyos
春卷同学8 小时前
HarmonyOS掌上记账APP开发实践第8篇:构建可测试的鸿蒙应用 — MoneyTrack 的自动化测试体系
华为·harmonyos
兰令水9 小时前
hot100【acm版】【2026.7.14打卡-java版本】
java·数据结构·算法·leetcode·面试
cpp_25019 小时前
P10098 [ROIR 2023] 地铁建设 (Day 2)
数据结构·c++·算法·贪心·二分答案·洛谷题解
梦想不只是梦与想9 小时前
鸿蒙中declare关键字
harmonyos·declare