鸿蒙原生项目实战(三):首页 UI 构建与打卡交互实现
本文详细介绍了鸿蒙应用"习惯大师"首页的UI实现过程,主要内容包括: 功能架构 包含顶部标题栏、进度卡片、习惯列表和底部按钮四大模块 实现日期显示、打卡进度、习惯管理等功能 技术实现 使用ArkTS的声明式UI开发模式 采用@State装饰器管理响应式状态 通过aboutToAppear和onPageShow生命周期确保数据实时更新 核心组件 顶部工具栏使用Flex布局和Emoji图标 创新性地使
鸿蒙原生项目实战(三):首页 UI 构建与打卡交互实现
本文剖析「习惯大师」首页的完整实现,涵盖 ArkTS 声明式 UI 构建、组件化设计、路由导航与状态刷新机制。
一、首页功能概览
首页 Index.ets 是应用的核心界面,承载以下功能:
- 顶部标题栏:应用名 + 今日日期 + 设置/统计入口
- 进度卡片:环形进度 + 今日完成情况
- 习惯列表:展示所有习惯卡片,支持打卡与详情跳转
- 底部按钮:跳转到添加习惯页
二、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;
}
为什么要同时监听 aboutToAppear 和 onPageShow?
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 })
}
环形进度实现原理
为什么不直接使用 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 项目「习惯大师」,你可以对照源码阅读效果更佳。
更多推荐

所有评论(0)