鸿蒙原生项目实战(三):首页 UI 构建与打卡交互实现

本文剖析「习惯大师」首页的完整实现,涵盖 ArkTS 声明式 UI 构建、组件化设计、路由导航与状态刷新机制。


一、首页功能概览

首页 Index.ets 是应用的核心界面,承载以下功能:

  1. 顶部标题栏:应用名 + 今日日期 + 设置/统计入口
  2. 进度卡片:环形进度 + 今日完成情况
  3. 习惯列表:展示所有习惯卡片,支持打卡与详情跳转
  4. 底部按钮:跳转到添加习惯页

二、ArkTS 组件结构

2.1 组件整体骨架

@Entry
@Component
struct Index {
  @State habits: Habit[] = [];
  @State habitCards: HabitCardInfo[] = [];
  @State totalHabits: number = 0;
  @State checkedCount: number = 0;
  @State todayLabel: string = '';

  private habitManager: HabitManager = HabitManager.getInstance();
  // ...
}

关键装饰器说明:

装饰器 作用
@Entry 标记为页面入口,可被路由加载
@Component 声明为 UI 组件
@State 响应式状态变量,变更时自动触发 UI 重绘

2.2 生命周期管理

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

onPageShow(): void {
  this.refresh();
}

async refresh(): Promise<void> {
  const today = getToday();
  const weekDay = getWeekdayName(today);
  this.todayLabel = `${formatDateDisplay(today)} ${weekDay}`;

  this.habits = await this.habitManager.getAllHabits();
  const stats = await this.habitManager.getTodayStats();
  this.totalHabits = stats.total;
  this.checkedCount = stats.checked;

  const cards: HabitCardInfo[] = [];
  for (const h of this.habits) {
    const record = await this.habitManager.getRecord(h.id, today);
    const isCompleted = record !== null && record.count >= h.goalCount;
    cards.push({
      habit: h, isCompleted,
      currentCount: record !== null ? record.count : 0,
      record: record
    });
  }
  this.habitCards = cards;
}

为什么要同时监听 aboutToAppearonPageShow

  • aboutToAppear:组件首次挂载时触发
  • onPageShow:每次页面进入前台时触发(包括从子页面返回)

两者配合确保数据始终最新——用户从添加页返回后,习惯列表自动刷新。


三、顶部工具栏

Row() {
  Column() {
    Text('习惯大师')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'));
    Text(this.todayLabel)
      .fontSize(13)
      .fontColor($r('app.color.text_secondary'))
      .margin({ top: 2 });
  }
  .alignItems(HorizontalAlign.Start);

  Blank();

  Button() {
    Text('⚙️').fontSize(20)
  }
  .width(36).height(36)
  .backgroundColor(Color.Transparent)
  .margin({ right: 4 })
  .onClick(() => this.goToSettings());

  Button() {
    Text('📊').fontSize(20)
  }
  .width(36).height(36)
  .backgroundColor(Color.Transparent)
  .onClick(() => this.goToStatistics());
}
.padding({ left: 20, right: 16, top: 14, bottom: 8 })
.width('100%');

设计要点:

  • Blank() 自动填充剩余空间,实现"左标题右图标"的 Flex 布局
  • Emoji 图标代替图片资源,减少包体积,风格一致
  • Color.Transparent 背景让按钮不遮挡页面背景色

四、进度卡片——环形进度组件

这是首页视觉最突出的部分:

@Builder
progressCard() {
  Column() {
    Row() {
      Stack() {
        // 底层:灰色圆环(未完成部分)
        Circle()
          .width(72).height(72)
          .fill($r('app.color.incomplete'));

        // 上层:绿色圆环(已完成部分),通过透明度实现裁剪
        Circle()
          .width(72).height(72)
          .fill($r('app.color.completed'))
          .opacity(this.totalHabits > 0 ? this.checkedCount / this.totalHabits : 0);

        // 中心:白色遮罩
        Circle()
          .width(56).height(56)
          .fill(Color.White);

        // 百分比文本
        Text(`${this.totalHabits > 0 ? Math.round(this.checkedCount / this.totalHabits * 100) : 0}%`)
          .fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.primary'));
      }
      .margin({ right: 20 });

      // 右侧文字信息
      Column() {
        Text('今日进度').fontSize(14).fontColor($r('app.color.text_secondary'));
        Row() {
          Text(`${this.checkedCount}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor($r('app.color.primary'));
          Text(` / ${this.totalHabits}`).fontSize(18).fontColor($r('app.color.text_secondary')).margin({ top: 6 });
        }.alignItems(VerticalAlign.Bottom);

        // 状态提示
        if (this.totalHabits === 0) {
          Text('添加习惯开始打卡吧!');
        } else if (this.checkedCount === this.totalHabits) {
          Text('🎉 全部完成!');
        } else {
          Text(`还剩 ${this.totalHabits - this.checkedCount} 个习惯待完成`);
        }
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1);
    }
  }
  .padding(20)
  .backgroundColor($r('app.color.card_bg'))
  .borderRadius(20)
  .shadow({ radius: 8, color: '#1A6C5CE7', offsetX: 0, offsetY: 4 })
}

环形进度实现原理

渲染错误: Mermaid 渲染失败: No diagram type detected matching given configuration for text: Stack 结构: ┌─────────────────────────────────┐ │ Circle(灰色,100% 填充) │ ← 底环 │ Circle(绿色,透明度 = 完成率) │ ← 顶环 │ Circle(白色,中心遮罩) │ ← 中心镂空 │ Text(百分比) │ ← 中心文字 └─────────────────────────────────┘

为什么不直接使用 Arc(弧形)?

鸿蒙 ArkUI 的 Circle 组件不支持部分填充,但利用 opacity + Stack 叠加可以实现环形进度效果。优点是代码极其简洁,缺点是精确度受限于透明度值。


五、习惯列表

5.1 空状态

if (this.habits.length === 0) {
  Column() {
    Text('🎯').fontSize(48);
    Text('还没有习惯').fontSize(16).fontColor($r('app.color.text_secondary'));
    Text('点击下方按钮添加第一个习惯').fontSize(14).fontColor($r('app.color.text_secondary'));
  }
  .padding(40)
  .alignItems(HorizontalAlign.Center);
}

5.2 习惯卡片

每张卡片都是一个可点击的 Row:

@Builder
habitCard(card: HabitCardInfo) {
  Row() {
    // 左侧:分类图标
    Text(CATEGORY_ICONS[card.habit.category] || '📌')
      .fontSize(26).width(44).height(44).textAlign(TextAlign.Center)
      .margin({ right: 12 });

    // 中间:习惯名称 + 标签
    Column() {
      Text(card.habit.name)
        .fontSize(16).fontWeight(FontWeight.Medium)
        .fontColor($r('app.color.text_primary'));

      Row() {
        Text(card.habit.category)
          .fontSize(11).fontColor(Color.White)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor(this.getCategoryColor(card.habit.category))
          .borderRadius(4);
        Text(card.habit.frequency).fontSize(11)
          .fontColor($r('app.color.text_secondary')).margin({ left: 6 });
      }
    }.layoutWeight(1).alignItems(HorizontalAlign.Start);

    // 右侧:打卡区域
    Column() {
      if (card.isCompleted) {
        Text('✓').fontSize(24).fontColor($r('app.color.completed')).fontWeight(FontWeight.Bold);
        Text('已打卡').fontSize(11).fontColor($r('app.color.completed'));
      } else if (card.currentCount > 0) {
        Text(`${card.currentCount}/${card.habit.goalCount}`).fontSize(16)
          .fontWeight(FontWeight.Medium).fontColor($r('app.color.primary'));
        Text('进行中').fontSize(11).fontColor($r('app.color.primary'));
      } else {
        Circle().width(28).height(28).fill($r('app.color.incomplete'));
        Text('打卡').fontSize(11).fontColor($r('app.color.text_secondary'));
      }
    }
    .alignItems(HorizontalAlign.Center).width(48)
    .onClick(() => this.toggleCheck(card));  // ⬅ 打卡点击
  }
  .padding(14).backgroundColor($r('app.color.card_bg'))
  .borderRadius(14).margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0A000000', offsetX: 0, offsetY: 2 })
  .onClick(() => this.goToDetail(card.habit));  // ⬅ 详情点击
}

5.3 点击事件分发

这里有一个重要的交互细节

习惯卡片 Row 的 onClick → 跳转详情页
    ↓
右侧打卡 Column 的 onClick → 打卡/取消打卡
    ↓
通过 .stopPropagation() 避免事件冲突

实际上,ArkTS 中子组件的点击事件会冒泡到父组件。当用户点击打卡区域时,两个 onClick 都会触发。解决方案是使用事件对象的 stopPropagation()

Column()
  .onClick((event: ClickEvent) => {
    event.stopPropagation();
    this.toggleCheck(card);
  })

⚠️ 当前代码未显式调用 stopPropagation()——这是项目需要优化的一个改进点。


六、打卡交互流程

async toggleCheck(card: HabitCardInfo): Promise<void> {
  const today = getToday();
  if (card.isCompleted) {
    await this.habitManager.uncheckIn(card.habit.id, today);
  } else {
    await this.habitManager.checkIn(card.habit.id, today);
  }
  await this.refresh();  // 刷新整个页面
}

完整时序图:

用户点击打卡区
    ↓
toggleCheck(card)
    ↓
判断 isCompleted
    ├─ true  → uncheckIn()  → 打卡记录-1 或删除
    └─ false → checkIn()    → 打卡记录+1 或新建
    ↓
refresh()
    ↓
重新加载 habits + records → 更新 @State 变量 → UI 自动重绘

七、路由导航

// 跳转到添加习惯页
goToAddHabit(): void {
  router.pushUrl({ url: 'pages/AddHabit' });
}

// 跳转到习惯详情页(携带参数)
goToDetail(habit: Habit): void {
  router.pushUrl({
    url: 'pages/HabitDetail',
    params: { habitId: habit.id }
  });
}

路由参数传递要点:

  • params 接收一个 Record<string, Object> 类型对象
  • 目标页通过 router.getParams() 获取参数
  • 参数传递是深拷贝,修改不会影响源页

八、底部添加按钮

Row() {
  Button() {
    Row() {
      Text('+').fontSize(20).fontColor(Color.White);
      Text(' 新习惯').fontSize(16).fontColor(Color.White);
    }
    .justifyContent(FlexAlign.Center)
  }
  .width('100%')
  .height(48)
  .backgroundColor($r('app.color.primary'))
  .borderRadius(24)
  .onClick(() => this.goToAddHabit());
}
.padding({ left: 20, right: 20, bottom: 16 })
.width('100%')
.backgroundColor($r('app.color.bg_page'));

为什么底部按钮单独用 Row 包裹而不是直接放在 Scroll 里?

  • 底部按钮需要固定在屏幕底部,不随列表滚动
  • Scroll 组件内放固定底部元素需要额外处理
  • 使用 layoutWeight(1) 让 Scroll 占满中间区域,底部 Row 自然固定在底部

九、完整的 UI 树

Column(根,100% × 100%)
├── Row(顶部工具栏)
│   ├── Column(标题 + 日期)
│   ├── Blank()
│   ├── Button(设置)
│   └── Button(统计)
├── Scroll(可滚动内容区,layoutWeight=1)
│   └── Column
│       ├── progressCard()  ← @Builder
│       └── habitList()
│           ├── [空状态] 或
│           └── ForEach → habitCard() ← @Builder
└── Row(底部固定按钮栏)
    └── Button(+新习惯)

十、性能优化要点

10.1 ForEach 的 key 值

ForEach(this.habitCards, (card: HabitCardInfo) => {
  this.habitCard(card);
})

ForEach 建议提供第三个参数作为 key 生成器,帮助 ArkUI 做 diff 更新:

ForEach(this.habitCards, (card: HabitCardInfo) => {
  this.habitCard(card);
}, (card: HabitCardInfo) => card.habit.id)

10.2 避免不必要刷新

当前 refresh() 是全量刷新。如果习惯数量多,可以改为增量更新——只更新受影响的那张卡片。但在习惯数量 < 50 的场景下,全量刷新代码更简单、可维护性更好。


在这里插入图片描述

十一、下篇预告

下一篇我们将深入统计图表与日历详情页

  • 横向柱状趋势图的纯 UI 实现
  • 分类分布条形图
  • 日历打卡视图的计算与渲染
  • 连续打卡天数算法

👉 下一篇:统计图表与日历详情页实战


本文所有代码片段均来自真实鸿蒙 NEXT 项目「习惯大师」,你可以对照源码阅读效果更佳。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐