一、案例背景

在健康管理 APP 的每日打卡模块中,我们需要实现一个交互友好的打卡列表:

  • 使用 Checkbox 组件实现打卡状态切换
  • 点击列表项或复选框都能切换状态
  • 已完成项自动排序到列表底部
  • 完成项显示半透明效果,视觉上弱化
  • 实时统计完成进度并显示百分比

每日打卡

本文将聚焦 Checkbox 组件的使用,详细讲解如何实现这个功能完善的打卡列表。

二、完整代码实现

@Component
export struct CheckInTabContent {
  @State checkInItems: CheckInItemData[] = [];
  @State completedCount: number = 0;

  aboutToAppear(): void {
    this.loadCheckInData();
  }

  loadCheckInData(): void {
    // 从数据服务加载打卡数据
    this.checkInItems = [
      { id: 'early_sleep', name: '早睡', icon: '🌙', isChecked: false },
      { id: 'early_wake', name: '早起', icon: '🌅', isChecked: true },
      { id: 'drink_water', name: '喝水', icon: '💧', isChecked: false },
      { id: 'exercise', name: '运动', icon: '🏃', isChecked: true },
      { id: 'breakfast', name: '吃早餐', icon: '🍳', isChecked: false },
      { id: 'study', name: '学习', icon: '📚', isChecked: false }
    ];
    this.completedCount = this.checkInItems.filter(
      (item: CheckInItemData): boolean => item.isChecked
    ).length;
  }

  // 获取排序后的打卡项(未完成在前,已完成在后)
  getSortedCheckInItems(): CheckInItemData[] {
    const unchecked: CheckInItemData[] = [];
    const checked: CheckInItemData[] = [];
    for (let i = 0; i < this.checkInItems.length; i++) {
      if (this.checkInItems[i].isChecked) {
        checked.push(this.checkInItems[i]);
      } else {
        unchecked.push(this.checkInItems[i]);
      }
    }
    return unchecked.concat(checked);
  }

  // 通过ID切换打卡状态
  toggleCheckInById(itemId: string): void {
    let targetIndex = -1;
    for (let i = 0; i < this.checkInItems.length; i++) {
      if (this.checkInItems[i].id === itemId) {
        targetIndex = i;
        break;
      }
    }
    if (targetIndex === -1) return;

    // 创建新数组以触发UI更新
    const newItems: CheckInItemData[] = [];
    for (let i = 0; i < this.checkInItems.length; i++) {
      const item = this.checkInItems[i];
      newItems.push({
        id: item.id,
        name: item.name,
        icon: item.icon,
        isChecked: i === targetIndex ? !item.isChecked : item.isChecked
      });
    }
    this.checkInItems = newItems;
    this.completedCount = this.checkInItems.filter(
      (item: CheckInItemData): boolean => item.isChecked
    ).length;
  }

  build() {
    Column() {
      // 标题
      Row() {
        Text('每日打卡')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.text_primary'))
      }
      .width('100%')
      .padding(16)

      // 进度显示
      Row() {
        Text(`已完成 ${this.completedCount}/${this.checkInItems.length}`)
          .fontSize(16)
          .fontColor($r('app.color.text_secondary'))
        Blank()
        Text(`${Math.round((this.completedCount / Math.max(this.checkInItems.length, 1)) * 100)}%`)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.primary_color'))
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })

      Progress({
        value: this.completedCount,
        total: Math.max(this.checkInItems.length, 1),
        type: ProgressType.Linear
      })
        .color($r('app.color.primary_color'))
        .height(8)
        .borderRadius(4)
        .margin({ left: 16, right: 16, bottom: 16 })

      // 打卡列表
      List() {
        ForEach(this.getSortedCheckInItems(), (item: CheckInItemData) => {
          ListItem() {
            Row() {
              Text(item.icon)
                .fontSize(24)
                .opacity(item.isChecked ? 0.5 : 1)

              Text(item.name)
                .fontSize(16)
                .fontColor($r('app.color.text_primary'))
                .margin({ left: 12 })
                .layoutWeight(1)

              Checkbox()
                .select(item.isChecked)
                .selectedColor($r('app.color.primary_color'))
                .onChange((value: boolean) => {
                  this.toggleCheckInById(item.id);
                })
            }
            .width('100%')
            .padding(16)
            .backgroundColor(
              item.isChecked 
                ? $r('app.color.secondary_background') 
                : $r('app.color.card_background')
            )
            .borderRadius(12)
            .onClick(() => {
              this.toggleCheckInById(item.id);
            })
          }
          .margin({ left: 16, right: 16, bottom: 8 })
        }, (item: CheckInItemData): string => `${item.id}_${item.isChecked}`)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.background_color'))
  }
}

interface CheckInItemData {
  id: string;
  name: string;
  icon: string;
  isChecked: boolean;
}

三、Checkbox 组件详解

3.1 Checkbox 基本属性

Checkbox()
  .select(false)                              // 是否选中
  .selectedColor($r('app.color.primary_color')) // 选中时的颜色
  .onChange((value: boolean) => {             // 状态变化回调
    console.log('新状态:', value);
  })

核心属性说明:

属性 类型 说明
select boolean 控制复选框是否选中
selectedColor ResourceColor 选中状态的颜色
onChange (value: boolean) => void 状态变化时的回调函数

3.2 Checkbox 在列表中的应用

Checkbox()
  .select(item.isChecked)  // 绑定数据状态
  .selectedColor($r('app.color.primary_color'))
  .onChange((value: boolean) => {
    this.toggleCheckInById(item.id);  // 触发状态切换
  })

选中/未选中

3.3 Checkbox 样式定制

Checkbox()
  .select(item.isChecked)
  .selectedColor($r('app.color.primary_color'))
  .width(24)   // 设置宽度
  .height(24)  // 设置高度

虽然 Checkbox 样式定制选项有限,但可以通过颜色和尺寸调整来适配设计风格。

四、列表项状态管理

4.1 状态定义

@State checkInItems: CheckInItemData[] = [];  // 打卡项列表
@State completedCount: number = 0;            // 已完成数量

使用 @State 装饰器确保状态变化时 UI 自动更新。

4.2 状态切换的核心逻辑

关键点:必须创建新数组才能触发 UI 更新

toggleCheckInById(itemId: string): void {
  // 1. 找到目标项的索引
  let targetIndex = -1;
  for (let i = 0; i < this.checkInItems.length; i++) {
    if (this.checkInItems[i].id === itemId) {
      targetIndex = i;
      break;
    }
  }
  if (targetIndex === -1) return;

  // 2. 创建新数组(这是触发UI更新的关键)
  const newItems: CheckInItemData[] = [];
  for (let i = 0; i < this.checkInItems.length; i++) {
    const item = this.checkInItems[i];
    newItems.push({
      id: item.id,
      name: item.name,
      icon: item.icon,
      isChecked: i === targetIndex ? !item.isChecked : item.isChecked
    });
  }
  
  // 3. 更新状态
  this.checkInItems = newItems;
  
  // 4. 重新计算完成数量
  this.completedCount = this.checkInItems.filter(
    (item: CheckInItemData): boolean => item.isChecked
  ).length;
}

状态更新流程图:

用户点击 Checkbox 或列表项
         ↓
   触发 onChange 回调
         ↓
  调用 toggleCheckInById()
         ↓
    查找目标项索引
         ↓
   创建新数组并修改状态
         ↓
  更新 @State 变量
         ↓
   UI 自动重新渲染
         ↓
  显示新的选中状态

4.3 为什么必须创建新数组?

错误示例:

// ❌ 直接修改数组元素,UI 不会更新
toggleCheckInById(itemId: string): void {
  const index = this.checkInItems.findIndex(item => item.id === itemId);
  this.checkInItems[index].isChecked = !this.checkInItems[index].isChecked;
  // UI 不会更新!
}

正确示例:

// ✅ 创建新数组,UI 会更新
toggleCheckInById(itemId: string): void {
  const newItems = this.checkInItems.map(item => {
    if (item.id === itemId) {
      return { ...item, isChecked: !item.isChecked };
    }
    return item;
  });
  this.checkInItems = newItems;  // 触发 UI 更新
}

ArkTS 的响应式系统通过对象引用来检测变化,只有当 @State 变量的引用改变时,才会触发 UI 更新。

五、完成/未完成样式区分

5.1 视觉差异设计

通过不同的视觉效果区分已完成和未完成的打卡项:

Row() {
  // 图标透明度
  Text(item.icon)
    .fontSize(24)
    .opacity(item.isChecked ? 0.5 : 1)  // 已完成:半透明

  Text(item.name)
    .fontSize(16)
    .fontColor($r('app.color.text_primary'))
    .margin({ left: 12 })
    .layoutWeight(1)

  Checkbox()
    .select(item.isChecked)
    .selectedColor($r('app.color.primary_color'))
}
.backgroundColor(
  item.isChecked 
    ? $r('app.color.secondary_background')  // 已完成:浅色背景
    : $r('app.color.card_background')       // 未完成:卡片背景
)
.borderRadius(12)

5.2 样式对比表

元素 未完成状态 已完成状态 视觉效果
图标透明度 1.0 0.5 已完成项图标变暗
背景颜色 card_background secondary_background 已完成项背景变浅
复选框 未选中 选中(主题色) 明确的状态标识

5.3 样式实现技巧

使用三元运算符根据状态动态设置样式:

.opacity(item.isChecked ? 0.5 : 1)
.backgroundColor(item.isChecked ? $r('app.color.secondary_background') : $r('app.color.card_background'))

这种方式简洁明了,易于维护。

六、列表自动排序

6.1 排序需求

将已完成的打卡项自动移到列表底部,让用户优先看到未完成的任务。

排序前:                排序后:
✓ 早睡                 ○ 喝水
○ 喝水                 ○ 吃早餐
✓ 运动                 ○ 学习
○ 吃早餐               ✓ 早睡
○ 学习                 ✓ 运动

6.2 排序算法实现

getSortedCheckInItems(): CheckInItemData[] {
  const unchecked: CheckInItemData[] = [];
  const checked: CheckInItemData[] = [];
  
  // 遍历分类
  for (let i = 0; i < this.checkInItems.length; i++) {
    if (this.checkInItems[i].isChecked) {
      checked.push(this.checkInItems[i]);
    } else {
      unchecked.push(this.checkInItems[i]);
    }
  }
  
  // 未完成在前,已完成在后
  return unchecked.concat(checked);
}

算法步骤:

  1. 创建两个空数组:unchecked(未完成)和 checked(已完成)
  2. 遍历原始列表,根据 isChecked 状态分类
  3. 将未完成数组和已完成数组拼接,返回新数组

6.3 在 ForEach 中使用排序

List() {
  ForEach(
    this.getSortedCheckInItems(),  // 使用排序后的数据
    (item: CheckInItemData) => {
      ListItem() {
        // 列表项内容
      }
    },
    (item: CheckInItemData): string => `${item.id}_${item.isChecked}`
  )
}

注意: 键值生成器使用 ${item.id}_${item.isChecked},确保状态变化时列表项能正确更新。

6.4 排序的用户体验

  • 未完成项始终在顶部,用户一眼就能看到待办任务
  • 已完成项沉底,视觉上不会干扰用户
  • 配合半透明样式,已完成项进一步弱化
  • 排序是自动的,用户无需手动操作

七、进度统计与显示

7.1 完成数量统计

使用 filter 方法统计已完成项:

this.completedCount = this.checkInItems.filter(
  (item: CheckInItemData): boolean => item.isChecked
).length;

7.2 完成百分比计算

const percentage = Math.round(
  (this.completedCount / Math.max(this.checkInItems.length, 1)) * 100
);

注意: 使用 Math.max(this.checkInItems.length, 1) 避免除以零错误。

7.3 进度显示 UI

// 文字进度
Row() {
  Text(`已完成 ${this.completedCount}/${this.checkInItems.length}`)
    .fontSize(16)
    .fontColor($r('app.color.text_secondary'))
  
  Blank()  // 占据中间空间
  
  Text(`${Math.round((this.completedCount / Math.max(this.checkInItems.length, 1)) * 100)}%`)
    .fontSize(16)
    .fontWeight(FontWeight.Bold)
    .fontColor($r('app.color.primary_color'))
}

// 进度条
Progress({
  value: this.completedCount,
  total: Math.max(this.checkInItems.length, 1),
  type: ProgressType.Linear
})
  .color($r('app.color.primary_color'))
  .height(8)
  .borderRadius(4)

进度

八、整行可点击优化

8.1 提升交互体验

不仅复选框可以点击,整个列表项都可以点击来切换状态:

Row() {
  Text(item.icon)
  Text(item.name)
  Checkbox()
    .onChange((value: boolean) => {
      this.toggleCheckInById(item.id);
    })
}
.onClick(() => {
  this.toggleCheckInById(item.id);  // 整行可点击
})

8.2 避免重复触发

由于整行和 Checkbox 都绑定了点击事件,需要确保它们调用同一个方法,避免状态混乱:

// ✅ 正确:都调用同一个方法
.onClick(() => {
  this.toggleCheckInById(item.id);
})

Checkbox()
  .onChange((value: boolean) => {
    this.toggleCheckInById(item.id);
  })

九、总结

本文通过健康管理 APP 的打卡列表案例,深入讲解了 Checkbox 组件在列表中的应用:

  1. Checkbox 组件的基本用法和属性配置
  2. 列表项状态管理的核心逻辑(创建新数组触发更新)
  3. 完成/未完成状态的视觉区分(透明度、背景色)
  4. 自动排序算法(未完成在前,已完成在后)
  5. 进度统计与显示
  6. 整行可点击的交互优化

掌握这些技巧后,你可以轻松实现各种带复选框的列表场景,如待办事项、任务清单、多选列表等。

Logo

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

更多推荐