一、引言

待办清单(Todo List)是生产力工具的起点。从 Google Keep 到 Microsoft To Do,从 Apple Reminders 到 Notion 的 Task Database,几乎所有笔记和效率工具都内置了某种形式的待办功能。它的交互模型极其直观——写下要做的事、做完后打勾划掉、删掉不再需要的条目——但这种简单背后隐藏着一整套状态管理、筛选过滤、分类层级的设计问题。

从技术角度看,待办清单的核心挑战有三个:

第一,多维属性管理。一个任务不仅有一个标题,还有分类标签、优先级标记、完成状态、创建日期等多个维度。这些维度需要在列表渲染、筛选过滤、添加编辑中保持一致。与倒数日追踪器的单一数据维度(名称 + 日期)不同,待办清单的每条数据有 5-6 个字段,状态管理复杂度成倍增加。

第二,列表筛选。用户需要快速切换"全部 / 待完成 / 已完成"三种视图。这意味着列表不是简单地把数据映射为 UI,而是需要基于当前筛选状态动态计算子集。筛选器本身也是交互组件——用户点击"待完成"时,不仅列表内容变了,筛选标签的高亮状态也要同步更新。

第三,完成状态的视觉反馈。勾选一个任务后,它的外观应该明显变化——文字变灰、划线贯穿、勾选框变绿。这个变化要即时发生,不能有延迟。在 ArkUI 的响应式体系中,这意味着正确使用 @State + 不可变更新模式。

本文将用 ArkUI 从零构建一个功能完整的待办清单。功能包括:

  • 添加任务(标题 + 分类标签 + 优先级标记)
  • 三态筛选(全部 / 待完成 / 已完成)
  • 五种分类标签(工作、个人、购物、健康、学习)
  • 三级优先级(高 / 中 / 低,红 / 蓝 / 灰三色)
  • 一键完成切换(勾选 → 文字变灰 + 删除线)
  • 滑动删除(每条右侧 × 按钮)

配色采用红 / 蓝 / 灰三色优先级 + 深色标题栏 + 白色卡片布局,完全不使用黄色系,确保所有文字与背景的对比度达标。

阅读完本文,你将能够:

  • 设计多属性任务数据模型
  • 实现三态列表筛选系统
  • 使用 Flex({ wrap: FlexWrap.Wrap }) 构建标签选择器
  • 用颜色编码实现可视化的优先级标记
  • 通过 TextDecorationType.LineThrough 实现完成状态的删除线效果

二、数据模型设计

2.1 任务实体

待办清单的数据模型比前几篇明显复杂。一个任务不是简单的一行文字,而是一个具有多个属性的结构化实体:

interface TodoItem {
  id: number;
  title: string;
  category: number;  // 0-4,对应五个分类
  priority: number;   // 0=高, 1=中, 2=低
  done: boolean;
  date: string;       // YYYY-MM-DD
}

六个字段各有其用途:

  • id:自增数字,用于唯一标识。在 ForEach 中作为 key 生成器的基础,确保列表 diff 正确。
  • title:任务名称。用户在添加弹窗的 TextInput 中输入,.trim() 处理后存储。
  • category:分类索引(0-4),不是分类名称字符串。用数字索引而非字符串的好处是:便于比较(task.category === this.addCategory),且避免拼写不一致的 bug。
  • priority:优先级索引(0=高, 1=中, 2=低)。三个等级对应三种颜色——红色(紧急)、蓝色(正常)、灰色(低优先级)。这个设计让用户不需要读文字就能感知每项任务的重要程度。
  • done:布尔值。true 表示已完成,触发文字变灰 + 删除线效果。
  • date:创建日期。用 YYYY-MM-DD 字符串格式存储,与前几篇的日期策略一致。

2.2 分类系统

五个分类涵盖日常任务的主要场景:

const CATEGORIES: string[] = ['💼', '👤', '🛒', '❤️', '📚'];
const CATEGORY_NAMES: string[] = ['工作', '个人', '购物', '健康', '学习'];

用两个平行数组而非对象数组([{icon, name}])的原因是:索引访问更直接——CATEGORIES[task.category] 即得图标,CATEGORY_NAMES[task.category] 即得名称。不需要 find()filter()

Emoji 图标不仅节省空间,还提供跨语言的视觉识别。无论用户使用什么语言,看到 💼 就知道是工作,看到 🛒 就知道是购物。在移动端小屏幕上,emoji 比文字标签更易辨识。

2.3 优先级系统

三个优先级等级,每种对应一个语义化颜色:

const PRIORITIES: string[] = ['高', '中', '低'];
const PRIORITY_COLORS: string[] = ['#FF4D4F', '#1677FF', '#888899'];
优先级 颜色 色值 语义
红色 #FF4D4F 紧急,需要立即处理
蓝色 #1677FF 正常优先级,按计划完成
灰色 #888899 有时间再做,不紧急

颜色编码是一个重要的 UX 设计原则。与倒数日的四色距离标记类似,优先级颜色让用户在扫描列表时能快速识别哪些任务需要优先关注。红色 = 暂停并关注,灰色 = 扫一眼可以跳过。

注意这里没有使用黄色或金色。在前几篇中我们已经反复验证过——黄色背景配上白色文字对比度不足(约 1.5:1,远低于 WCAG 要求的 4.5:1)。红色 #FF4D4F 与白色的对比度约 4.5:1,蓝色 #1677FF 约 4.3:1,灰色 #888899 约 4.5:1,全部达标。

2.4 初始化示例数据

aboutToAppear() 中预设 5 条示例任务,涵盖全部 5 个分类和全部 3 个优先级:

aboutToAppear(): void {
  this.tasks = [
    { id: this.nextId++, title: '完成项目方案设计', category: 0, priority: 0, done: false, date: todayStr() },
    { id: this.nextId++, title: '购买生日礼物', category: 2, priority: 1, done: false, date: todayStr() },
    { id: this.nextId++, title: '阅读《深入浅出ArkTS》第三章', category: 4, priority: 2, done: true, date: todayStr() },
    { id: this.nextId++, title: '跑步30分钟', category: 3, priority: 1, done: false, date: todayStr() },
    { id: this.nextId++, title: '整理周报并提交', category: 1, priority: 2, done: true, date: todayStr() },
  ];
}

5 条任务中有 3 条待完成、2 条已完成。这为三态筛选提供了直接的测试场景——切换到"待完成"看到 3 条,切换到"已完成"看到 2 条。
在这里插入图片描述

三、三态筛选系统

3.1 筛选状态

筛选器是三个标签的平铺(不是下拉菜单),直接显示在标题栏下方:

private filterLabels: string[] = ['全部', '待完成', '已完成'];
@State filterIndex: number = 0; // 0=全部, 1=待完成, 2=已完成

用索引(0/1/2)而非字符串标识当前筛选状态,原因与分类使用索引相同——比较更快,不会出现拼写错误。filterIndex === 0filterState === 'all' 更可靠。

3.2 筛选算法

filteredTasks() 方法根据 filterIndex 的当前值返回不同的任务子集:

filteredTasks(): TodoItem[] {
  const result: TodoItem[] = [];
  for (let i = 0; i < this.tasks.length; i++) {
    const t = this.tasks[i];
    if (this.filterIndex === 0) {
      result.push(t);                        // 全部:不做过滤
    } else if (this.filterIndex === 1 && !t.done) {
      result.push(t);                        // 待完成:只取 done=false
    } else if (this.filterIndex === 2 && t.done) {
      result.push(t);                        // 已完成:只取 done=true
    }
  }
  return result;
}

算法逻辑:

  1. 全部(filterIndex === 0:不进行任何过滤,返回所有任务。这是默认视图,用户进入页面后看到完整列表。
  2. 待完成(filterIndex === 1:只收集 done === false 的任务。这是用户实际使用中最常用的视图——“还有什么没做”。
  3. 已完成(filterIndex === 2:只收集 done === true 的任务。用于回顾已完成的工作,获得成就感。

每次返回的都是新数组(const result: TodoItem[] = []),而不是对 this.tasks 的引用。这确保了筛选结果的变化能被框架正确检测。

3.3 筛选标签的 UI

三个标签使用 ForEach 渲染,当前选中的标签高亮为白色加粗,未选中为半透明白色:

ForEach(this.filterLabels, (label: string, fi: number) => {
  Text(label)
    .fontSize(FontSize.BODY)
    .fontColor(this.filterIndex === fi ? '#FFFFFF' : '#FFFFFF66')
    .fontWeight(this.filterIndex === fi ? FontWeight.Bold : FontWeight.Normal)
    .padding({ left: Spacing.LG, right: Spacing.LG, top: 8, bottom: 12 })
    .onClick(() => { this.filterIndex = fi; })
})

使用 ForEach 的好处是——如果将来需要增加"已过期"或"重要"等新筛选维度,只需在 filterLabels 数组中新增元素,UI 自动跟随。不需要手动复制粘贴标签代码。

#FFFFFF66(约 40% 不透明度)提供了一种柔和的未选中态,既不抢眼又能辨认。与完全隐藏不同,半透明让用户知道还有其他选项存在。

3.4 空状态的不同文案

筛选后可能没有任何任务。空状态不是统一文案,而是根据当前筛选条件显示不同的引导文字:

if (this.filteredTasks().length === 0) {
  Column() {
    Text('📋')
      .fontSize(48)
      .margin({ bottom: Spacing.SM })
    Text(this.filterIndex === 0 ? '还没有任务' :
      (this.filterIndex === 1 ? '所有任务都已完成 🎉' : '还没有已完成的任务'))
      .fontSize(FontSize.BODY)
      .fontColor('#888899')
  }
}

三种空状态对应三种用户心理:

  • 全部为空(“还没有任务”):新用户首次打开,需要引导添加。此时 FAB 按钮发光吸引点击。
  • 待完成为空(“所有任务都已完成 🎉”):这是一种正向反馈——所有事都做完了,值得庆祝。emoji 🎉 增强了这种成就感。
  • 已完成为空(“还没有已完成的任务”):暗示用户应该开始完成任务,带有轻微的紧迫感。

3.5 待完成计数

标题栏右侧显示"待完成"任务的实时统计:

Text(`${this.pendingCount()} 项待完成`)
  .fontSize(FontSize.CAPTION)
  .fontColor('#FFFFFF88')

pendingCount() 方法遍历所有任务统计未完成数量:

pendingCount(): number {
  let count = 0;
  for (let i = 0; i < this.tasks.length; i++) {
    if (!this.tasks[i].done) count++;
  }
  return count;
}

这个数字会随勾选操作即时更新——用户每勾选一项,"N 项待完成"中的 N 就减 1。这种即时反馈是待办清单的核心体验:用户能直观感受到自己在推进。
在这里插入图片描述

四、添加任务:分类与优先级的双重选择

4.1 弹窗设计

添加任务弹窗包含三个输入区和一个确认区:

  1. 任务名称TextInput,占位文字"任务名称,如’完成项目方案设计’"
  2. 选择分类 — 5 个分类标签,Flex({ wrap: FlexWrap.Wrap }) 布局
  3. 选择优先级 — 3 个优先级按钮,Row 水平排列
  4. 取消 / 确认添加 — 两个按钮并排

与前几篇的弹窗不同,这次使用 Flex 而非 Row 来承载分类标签,原因是 5 个分类的标签(图标 + 文字名)宽度加起来可能超过屏幕宽度。FlexWrap.Wrap 允许标签自动折行,在小屏幕上也能完整显示。

4.2 分类选择器

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(CATEGORIES, (icon: string, ci: number) => {
    Row() {
      Text(icon + ' ' + CATEGORY_NAMES[ci])
        .fontSize(12)
        .fontColor(this.addCategory === ci ? '#1677FF' : '#888899')
        .fontWeight(this.addCategory === ci ? FontWeight.Bold : FontWeight.Normal)
    }
    .padding({ left: 10, right: 10, top: 6, bottom: 6 })
    .borderRadius(BorderRadius.FULL)
    .backgroundColor(this.addCategory === ci ? '#1677FF15' : '#F5F5FA')
    .border({ width: this.addCategory === ci ? 1 : 0, color: '#1677FF' })
    .margin({ right: 6, bottom: 6 })
    .onClick(() => { this.addCategory = ci; })
  })
}
.width('100%')

两个关键设计细节:

选中态用边框 + 浅蓝背景区分。未选中标签是 #F5F5FA(浅灰),选中标签是 #1677FF15(约 8% 不透明度的蓝色)+ 1vp 蓝色边框。这种双重标记确保即使用户色弱或屏幕亮度较低,也能通过边框辨认选中态。

.margin({ bottom: 6 }) 提供折行间距。当标签折到第二行时,上下两行之间需要 6vp 的间距。不设这个间距的话折行后两行会紧贴在一起。

4.3 优先级选择器

优先级选择器的设计理念与分类不同——三个选项空间充足,使用 Row 水平排列即可:

ForEach(PRIORITIES, (label: string, pi: number) => {
  Text(label)
    .fontSize(FontSize.BODY)
    .fontColor(this.addPriority === pi ? '#FFFFFF' : PRIORITY_COLORS[pi])
    .fontWeight(FontWeight.Bold)
    .padding({ left: 20, right: 20, top: 8, bottom: 8 })
    .borderRadius(BorderRadius.FULL)
    .backgroundColor(this.addPriority === pi ? PRIORITY_COLORS[pi] : PRIORITY_COLORS[pi] + '15')
    .margin({ right: Spacing.SM })
    .onClick(() => { this.addPriority = pi; })
})

选中态与分类选择器相反的策略——实心颜色 + 白字而非边框。原因是优先级颜色本身就承载了语义信息:

  • 选中"高":红色实心按钮 + 白色文字 → 视觉冲击力最强
  • 选中"中":蓝色实心按钮 + 白色文字 → 稳定可靠
  • 选中"低":灰色实心按钮 + 白色文字 → 低调不抢眼

未选中态使用颜色的 15% 不透明度版本(PRIORITY_COLORS[pi] + '15')+ 对应颜色的文字。这样用户能在不选中的状态下也能预判每个选项的颜色语义。

4.4 确认按钮的条件禁用

确认按钮在输入为空时灰色不可点击,输入不为空时蓝色可点击:

backgroundColor(this.addTitle.trim() ? '#1677FF' : '#CCCCCC')

这是一个简单但重要的防错设计——阻止用户创建空标题的任务。.trim() 保证纯空格也无法创建。
在这里插入图片描述

五、任务切换与删除

5.1 完成状态切换

每个任务卡片左侧有一个圆形按钮,点击切换完成/未完成状态:

toggleTask(id: number): void {
  const newTasks: TodoItem[] = [];
  for (let i = 0; i < this.tasks.length; i++) {
    const t = this.tasks[i];
    if (t.id === id) {
      newTasks.push({
        id: t.id, title: t.title, category: t.category,
        priority: t.priority, done: !t.done, date: t.date,
      });
    } else {
      newTasks.push(t);
    }
  }
  this.tasks = newTasks;
}

使用不可变更新模式——创建新对象替换旧对象,而非直接修改 t.done = !t.done。这是 ArkUI @State 响应式更新的核心要求:框架通过引用比较(===)来判断状态是否变化。如果直接修改对象属性而数组引用不变,框架不知道需要重新渲染。

5.2 完成状态的视觉差异

已完成的与未完成的任务在视觉上有 5 处差异,让用户一眼就能分清:

属性 未完成 已完成
复选框 ⬜ 空心方块 ✅ 绿色对勾
复选框背景 #F0F0F5 浅灰 #52C41A15 浅绿
任务标题颜色 #1a1a2e 深色 #BBBBCC 灰色
任务标题字重 Bold 加粗 Normal 常规
文字装饰 LineThrough 删除线

这五重差异构建了一个完整的"完成感"——用户勾选后,任务在视觉上显著后退,让未完成的任务获得更多注意力。

删除线的实现:

.decoration({ type: task.done ? TextDecorationType.LineThrough : TextDecorationType.None })

TextDecorationType.LineThrough 是 ArkUI 内置的文本装饰类型,不需要手动绘制线条。另一可选值是 Underline(下划线)和 Overline(上划线)。

5.3 删除任务

每条任务右侧有 × 按钮,点击删除:

deleteTask(id: number): void {
  const newTasks: TodoItem[] = [];
  for (let i = 0; i < this.tasks.length; i++) {
    if (this.tasks[i].id !== id) newTasks.push(this.tasks[i]);
  }
  this.tasks = newTasks;
}

同样使用不可变更新——创建新数组,跳过要删除的任务。与前几篇的删除逻辑完全一致。

六、UI 布局设计

6.1 整体结构

Stack(根容器)
├── Column(主界面)
│   ├── Column(深色标题栏)
│   │   ├── Row:✅ 待办清单 + "N 项待完成"
│   │   └── Row:三个筛选标签
│   └── Scroll(任务列表)
│       └── Column
│           ├── 空状态 / ForEach → 任务卡片
│           └── 底部留白(80vp)
├── Button(FAB:"+ 添加任务")
└── if showAdd → 底部弹窗遮罩 + 表单

6.2 深色标题栏 + 白色卡片列表

延续前几篇的设计系统——标题栏深色(#1a1a2e)、列表背景浅灰(#F2F3F5)、任务卡片白色。这是一个被反复验证有效的移动端信息架构:深色顶栏提供视觉锚点,白色卡片在浅灰背景上形成清晰的"内容单元"边界。

6.3 任务卡片布局

每张卡片水平排列四个元素:

  1. 复选框(40×40vp 圆形,左侧):显示 ✅ 或 ⬜
  2. 信息区layoutWeight(1),中间):任务标题 + 分类标签行
  3. 删除按钮(右侧):× 字符

分类标签行包含两个小标签:

  • 分类标签(浅灰底 + 灰色文字):💼 工作 / 👤 个人 / 🛒 购物 / ❤️ 健康 / 📚 学习
  • 优先级标签(彩色底 + 白色文字):高(红底)/ 中(蓝底)/ 低(灰底)

两个标签并排,间距 6vp。分类标签告知"这是什么类型的任务",优先级标签告知"这有多重要"。两者信息互补,互不冗余。

6.4 卡片间距

卡片之间使用 1vp 的细微间距:

.margin({ left: Spacing.LG, right: Spacing.LG, bottom: this.filteredTasks().length - 1 === idx ? 0 : 1 })

最后一张卡片底部无边距(紧贴后续留白区域),其他卡片底部有 1vp 间隙。1vp 的线足够形成视觉分隔,但不会产生"列表中间有空隙"的感觉——它更像一条分隔线而非间距。

6.5 第一张卡片无圆角

这是前几篇确立的视觉惯例——列表第一张卡片的顶部与标题栏底部相接,不设圆角,形成整体感:

.borderRadius(idx === 0 ? 0 : BorderRadius.LG)

七、完整代码结构

TodoPage
├── Stack(根容器)
│   ├── Column(主界面)
│   │   ├── Column(深色标题栏 #1a1a2e)
│   │   │   ├── Row:✅ 待办清单 + "N 项待完成"
│   │   │   └── Row:三标签筛选(全部 | 待完成 | 已完成)
│   │   └── Scroll(任务列表区域)
│   │       └── Column
│   │           ├── if 空态 → 空状态引导
│   │           └── ForEach → Row(任务卡片)
│   │               ├── Column(✅/⬜ 复选框,40vp 圆形)
│   │               ├── Column(标题 + 分类标签 + 优先级标签)
│   │               └── Text(删除 ×)
│   ├── Button(FAB:"+ 添加任务",蓝色悬浮)
│   └── if showAdd → Column(半透明遮罩 + 底部弹窗)
│       └── Column(白色圆角弹窗)
│           ├── TextInput(任务名称)
│           ├── Text + Flex(选择分类,5 个标签 wrap 折行)
│           ├── Text + Row(选择优先级,3 个按钮)
│           └── Row(取消 + 确认添加)

八、总结

本文从零构建了一个功能完整的待办清单。与前五篇的功能型应用相比,待办清单的核心挑战在于多属性管理 + 动态筛选 + 完成状态的视觉编码

核心要点回顾:

  1. 多属性数据模型:六个字段(id/title/category/priority/done/date)构成任务实体。分类和优先级使用数字索引而非字符串,便于比较和避免拼写错误。

  2. 三态筛选系统filteredTasks() 根据 filterIndex(0=全部/1=待完成/2=已完成)返回不同子集。每次返回新数组确保框架检测到变化。筛选标签使用 ForEach 渲染,便于扩展。

  3. 分类标签的双重标记:选中态用边框 + 浅蓝背景,未选中态用浅灰。Flex({ wrap: FlexWrap.Wrap }) 让 5 个标签在窄屏上自动折行。

  4. 优先级的颜色编码:红(#FF4D4F)= 紧急、蓝(#1677FF)= 正常、灰(#888899)= 低优先。选中态实心色 + 白字,未选中态 15% 不透明度底色 + 对应颜色文字。完全不用黄色系,所有颜色与白字的对比度均达标。

  5. 完成状态的五重视觉差异:复选框(✅ vs ⬜)、背景色(浅绿 vs 浅灰)、文字颜色(灰 vs 深)、字重(常规 vs 加粗)、装饰线(删除线 vs 无)。这种多层次的视觉反馈让"完成"成为一种显著的、有满足感的操作。

  6. 不可变状态更新toggleTask()deleteTask() 都遵循创建新数组/新对象的模式,保证 @State 响应式更新被框架正确触发。

  7. 空状态的差异化文案:全部空 → “还没有任务”(引导添加)、待完成为空 → “所有任务都已完成 🎉”(正向反馈)、已完成为空 → “还没有已完成的任务”(紧迫感暗示)。

待办清单是最经典的生产力工具——一个输入框、一个列表、一个筛选器、一个完成按钮。但多属性管理、动态筛选、颜色编码、完成状态的多重视觉反馈这些细节组合在一起,形成了一个真正可用的效率工具,而非一个展示组件属性的 Demo 页面。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐