鸿蒙原生应用实战(三):UI构建 — 首页与写日记页面开发全流程
·
鸿蒙原生应用实战(三):UI构建 — 首页与写日记页面开发全流程
本文是系列第三篇,聚焦「心情日记」应用的两个核心页面:首页(Index)和写日记页(WritePage)。我们将深入讲解 ArkTS 的声明式 UI 语法、@Builder 装饰器复用、组件化思维和交互设计细节。
一、首页(Index.ets)全面拆解
首页是用户打开应用看到的第一屏,它的设计直接决定了用户的第一印象。
1.1 首页功能需求
┌──────────────────────────────────────┐
│ 📖 心情日记 📊 👤 │ ← 顶部标题栏
├──────────────────────────────────────┤
│ ┌──────────────────────────────┐ │
│ │ 1月20日 连续 3 天 🔥 │ │
│ │ │ │
│ │ 😊 │ │ ← 今日心情卡片
│ │ 开心 │ │
│ │ 发年终奖了 │ │
│ │ 点击查看详情 > │ │
│ └──────────────────────────────┘ │
│ │
│ ✏️写日记 📅日历 📊统计 👤我的 │ ← 快捷操作
│ │
│ 最近记录 全部 > │
│ ┌─────────────────────────────┐ │
│ │ 😊 发年终奖了 2025-01-20 >│ │
│ │ 😌 周末看书 2025-01-21 >│ │ ← 日记列表
│ │ 😢 告别老朋友 2025-01-22 >│ │
│ │ ... │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────┘
1.2 状态变量定义
@Entry
@Component
struct Index {
@State entries: DiaryEntry[] = []; // 所有日记
@State todayEntry: DiaryEntry | undefined; // 今天的日记
@State recentEntries: DiaryEntry[] = []; // 最近5条
@State streak: number = 0; // 连续签到天数
@State hasTodayEntry: boolean = false; // 今天是否已写
}
@State 的作用:被 @State 装饰的变量是响应式的,当变量值变化时,自动触发 UI 重新渲染。
1.3 数据加载与页面生命周期
// 页面初始化时调用(仅首次)
aboutToAppear(): void {
this.loadData();
}
// 每次页面显示时调用(包括从其他页面返回)
onPageShow(): void {
this.loadData();
}
为什么需要两个生命周期?
aboutToAppear:仅在组件首次创建时调用onPageShow:每次页面出现在前台时都调用
当用户在写日记页保存后返回首页,onPageShow 负责重新加载数据,确保首页显示最新内容。
1.4 连续签到算法详解
calcStats(): void {
// ... 计算今日日记、最近列表等 ...
// 连续签到天数计算
let streakCount = 0;
let checkDate = new Date();
while (true) {
let y = checkDate.getFullYear();
let m = (checkDate.getMonth() + 1).toString().padStart(2, '0');
let d = checkDate.getDate().toString().padStart(2, '0');
let ds = `${y}-${m}-${d}`;
// 查找这一天是否有日记
let found = false;
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].date === ds) {
found = true;
break;
}
}
if (found) {
streakCount++;
checkDate.setDate(checkDate.getDate() - 1); // 往前推一天
} else {
break; // 断签了就停止
}
}
this.streak = streakCount;
}
算法思路:从今天开始,逐天往前检查是否有日记记录,直到某一天没有记录为止。这个算法简单直观,时间复杂度 O(n×m)。
1.5 UI 构建
顶部标题栏
Row() {
Text('📖 心情日记')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text('📊')
.fontSize(22)
.onClick(() => { router.pushUrl({ url: 'pages/StatsPage' }); })
Text(' 👤')
.fontSize(22)
.onClick(() => { router.pushUrl({ url: 'pages/ProfilePage' }); })
}
.width('94%')
.padding({ top: 16, bottom: 8 })
设计要点:
- 使用
Blank()实现左右对齐 - 图标直接使用 Emoji,省去图标库依赖
- 标题左对齐,功能图标右对齐
今日心情卡片
Column() {
Row() {
Text(getTodayShort()).fontSize(14).fontColor('rgba(255,255,255,0.8)')
Blank()
Text('连续 ' + this.streak + ' 天 🔥')
.fontSize(12)
.backgroundColor('rgba(255,255,255,0.2)')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(10)
}.width('100%')
if (this.hasTodayEntry && this.todayEntry) {
// 已写日记:展示心情图标+标题
Text(getMoodInfo(this.todayEntry.mood).icon).fontSize(48)
Text(getMoodInfo(this.todayEntry.mood).label).fontSize(18).fontColor('#FFFFFF')
Text(this.todayEntry.title).fontSize(14)
Text('点击查看详情 >').fontSize(12).fontColor('rgba(255,255,255,0.6)')
} else {
// 未写日记:展示写日记入口
Text('🤔').fontSize(48)
Text('今天还没记录心情').fontSize(16).fontColor('#FFFFFF')
Button('写一篇日记')
.backgroundColor('#FFFFFF')
.fontColor('#6C63FF')
.borderRadius(18)
.onClick(() => { router.pushUrl({ url: 'pages/WritePage' }); })
}
}
.padding(20)
.backgroundColor('#6C63FF')
.borderRadius(16)
关键技术点:
| 技术 | 说明 |
|---|---|
条件渲染 if/else |
根据 hasTodayEntry 展示不同内容 |
半透明颜色 rgba(255,255,255,0.8) |
在深色背景上显示浅色文字 |
| 内联圆角徽章 | 连续签到天数用胶囊样式展示 |
| 按钮白色背景+主题色文字 | 反白设计,突出按钮 |
@Builder 装饰器复用
@Builder quickBtn(icon: string, label: string, onClick: () => void) {
Column() {
Text(icon).fontSize(26).width(48).height(48)
.textAlign(TextAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(24)
Text(label).fontSize(12).fontColor('#666666').margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(onClick)
}
// 使用
Row() {
this.quickBtn('✏️', '写日记', () => { router.pushUrl({ url: 'pages/WritePage' }); })
this.quickBtn('📅', '日历', () => { router.pushUrl({ url: 'pages/CalendarPage' }); })
this.quickBtn('📊', '统计', () => { router.pushUrl({ url: 'pages/StatsPage' }); })
this.quickBtn('👤', '我的', () => { router.pushUrl({ url: 'pages/ProfilePage' }); })
}
@Builder 的优势:
- 避免重复代码,一处定义多处使用
- 支持参数传递,灵活配置
- 函数式风格,逻辑清晰
1.6 日记列表项
@Builder diaryRow(item: DiaryEntry) {
Row() {
Text(getMoodInfo(item.mood).icon).fontSize(28)
.width(44).height(44)
.backgroundColor('#F5F5F5')
.borderRadius(22)
Column() {
Text(item.title).fontSize(15).fontWeight(FontWeight.Medium)
Text(item.date.slice(5) + ' · ' + getMoodInfo(item.mood).label)
.fontSize(12).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('>').fontSize(16).fontColor('#CCCCCC')
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.height(60)
.onClick(() => { router.pushUrl({ url: 'pages/CalendarPage' }); })
}
二、写日记页面(WritePage.ets)全面拆解
2.1 页面功能
┌──────────────────────────────────────┐
│ < 返回 写日记 │ ← 顶部导航栏
├──────────────────────────────────────┤
│ 📅 2025-01-20 │
│ │
│ 今天的心情 │
│ ┌────┬────┬────┐ │
│ │ 😊 │ 😌 │ 😢 │ │
│ │开心│平静│难过│ │ ← 心情选择器
│ ├────┼────┼────┤ │
│ │ 😠 │ 🤩 │ 😴 │ │
│ │生气│兴奋│疲惫│ │
│ ├────┼────┼────┤ │
│ │ 😰 │ 🙏 │ 😐 │ │
│ │焦虑│感恩│一般│ │
│ └────┴────┴────┘ │
│ │
│ 标题 * │
│ ┌────────────────────────────┐ │
│ │ 给今天的日记取个标题 │ │ ← TextInput
│ └────────────────────────────┘ │
│ │
│ 正文 │
│ ┌────────────────────────────┐ │
│ │ │ │
│ │ 写下今天的感受和故事... │ │ ← TextArea
│ │ │ │
│ └────────────────────────────┘ │
│ │
│ 标签(用逗号分隔) │
│ ┌────────────────────────────┐ │
│ │ 如: 工作,生活,旅行 │ │ ← TextInput
│ └────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 保存日记 │ │ ← 主题色按钮
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
2.2 状态变量
@State moods: MoodInfo[] = []; // 所有心情选项
@State selectedMood: MoodLevel = MoodLevel.HAPPY; // 选中的心情
@State title: string = ''; // 标题
@State content: string = ''; // 正文
@State tags: string = ''; // 标签
@State todayDate: string = ''; // 今天的日期
2.3 心情选择器:Grid 网格布局
Text('今天的心情').fontSize(14).fontColor('#999999')
Grid() {
ForEach(this.moods, (m: MoodInfo) => {
GridItem() {
Column() {
Text(m.icon).fontSize(32).margin({ bottom: 2 })
Text(m.label).fontSize(11)
.fontColor(this.selectedMood === m.level ? '#6C63FF' : '#999999')
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor(this.selectedMood === m.level ? '#EEEAFF' : '#F8F8F8')
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}
.onClick(() => { this.onMoodClick(m.level); })
}, (m: MoodInfo) => m.level)
}
.columnsTemplate('1fr 1fr 1fr') // 3列等宽
.columnsGap(8)
.rowsGap(8)
.width('90%')
Grid 布局要点:
columnsTemplate('1fr 1fr 1fr'):3 列等分- 选中态:紫色背景 (#EEEAFF) + 紫色文字 (#6C63FF)
- 未选态:灰色背景 (#F8F8F8) + 灰色文字 (#999999)
- 点击后更新
selectedMood,通过===判断高亮
2.4 文本输入组件
// 标题输入
Text('标题 *')
TextInput({ placeholder: '给今天的日记取个标题', text: this.title })
.fontSize(16).height(44)
.placeholderColor('#CCCCCC')
.onChange((v: string) => { this.title = v; })
// 正文输入(多行)
Text('正文')
TextArea({ placeholder: '写下今天的感受和故事...', text: this.content })
.fontSize(15).height(180) // 固定高度
.backgroundColor('#F9F9F9')
.borderRadius(8)
.onChange((v: string) => { this.content = v; })
// 标签输入
Text('标签(用逗号分隔)')
TextInput({ placeholder: '如: 工作,生活,旅行', text: this.tags })
.onChange((v: string) => { this.tags = v; })
TextInput vs TextArea:
| 组件 | 用途 | 行数 | 高度行为 |
|---|---|---|---|
| TextInput | 单行文本(标题、标签) | 1 | 固定 |
| TextArea | 多行文本(正文) | 多行 | 可设置固定高度 |
2.5 保存逻辑
saveEntry(): void {
// 标题不能为空
if (this.title.trim() === '') {
return;
}
// 构造日记条目
let entry: DiaryEntry = {
id: generateId(),
date: this.todayDate,
mood: this.selectedMood,
title: this.title.trim(),
content: this.content.trim(),
tags: this.tags.trim()
};
// 存入全局状态
let stored = AppStorage.get<DiaryEntry[]>('entries');
let list: DiaryEntry[] = stored ? stored : [];
list.unshift(entry); // 新日记插到最前面
AppStorage.set<DiaryEntry[]>('entries', list);
// 返回上一页
router.back();
}
代码细节:
list.unshift(entry):新日记插入数组头部,实现时间倒序title.trim():去除首尾空格router.back():保存后自动返回首页,首页onPageShow触发刷新
三、交互设计细节
3.1 导航交互
| 操作 | 实现方式 | 反馈 |
|---|---|---|
| 返回 | router.back() |
返回上一页 |
| 跳转统计页 | router.pushUrl({ url: 'pages/StatsPage' }) |
推入新页面 |
| 保存日记 | saveEntry() + router.back() |
保存后返回 |
3.2 状态反馈
// 心情选中反馈:颜色+背景同时变化
.backgroundColor(this.selectedMood === m.level ? '#EEEAFF' : '#F8F8F8')
.fontColor(this.selectedMood === m.level ? '#6C63FF' : '#999999')
双重反馈(背景色 + 文字颜色)让选中状态一目了然。
3.3 空状态处理
if (this.recentEntries.length === 0) {
Column() {
Text('还没有日记,开始记录今天的心情吧!')
.fontSize(15).fontColor('#CCCCCC')
}
.width('100%').height(120)
.justifyContent(FlexAlign.Center)
}
空状态展示友好的提示文字,而不是直接显示空白页面。
四、页面间数据一致性
4.1 数据流
WritePage (保存)
│
├─ AppStorage.set('entries', newList)
│
└─ router.back()
│
Index.onPageShow()
│
├─ AppStorage.get('entries')
└─ 重新渲染 UI
4.2 关键保证
所有页面在 onPageShow 中重新加载数据:
onPageShow(): void {
this.loadData(); // 确保每次显示都同步最新数据
}
这个设计确保无论用户在哪个页面修改了数据(新增、删除),其他页面回到前台时都能看到最新状态。
五、样式系统与主题设计
5.1 主题色定义
| 用途 | 颜色值 | 使用场景 |
|---|---|---|
| 主色 | #6C63FF |
按钮、标题、选态 |
| 主色浅色 | #EEEAFF |
选中背景 |
| 背景色 | #F8F9FA |
页面底色 |
| 卡片色 | #FFFFFF |
卡片、列表项 |
| 主文字 | #333333 |
标题、正文 |
| 辅助文字 | #999999 |
日期、标签 |
| 浅色文字 | #CCCCCC |
占位符 |
5.2 圆角系统
// 大圆角卡片
.borderRadius(16) // 首页今日心情卡片
// 中圆角组件
.borderRadius(12) // 快捷按钮、卡片
// 小圆角元素
.borderRadius(8) // TextArea
// 胶囊圆角
.borderRadius(24) // 按钮
六、下篇预告
本篇我们完成了首页和写日记页面的开发。下一篇将进入更复杂的交互实现:
- 日历视图:月份导航、日期网格、心情标记
- 数据统计:统计卡片、心情分布柱状图、7天心情趋势
- 你会学到 Grid 网格的高级用法、柱状图的实现思路
敬请期待!
如果你在 UI 开发中遇到问题,欢迎留言交流!
更多推荐

所有评论(0)