鸿蒙原生应用实战(三):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@Componentstruct Index{@Stateentries:DiaryEntry[]=[];// 所有日记@StatetodayEntry:DiaryEntry|undefined;// 今天的日记@StaterecentEntries:DiaryEntry[]=[];// 最近5条@Statestreak:number=0;// 连续签到天数@StatehasTodayEntry:boolean=false;// 今天是否已写}@State 的作用:被 @State 装饰的变量是响应式的,当变量值变化时,自动触发 UI 重新渲染。
1.3 数据加载与页面生命周期
// 页面初始化时调用(仅首次)aboutToAppear():void{this.loadData();}// 每次页面显示时调用(包括从其他页面返回)onPageShow():void{this.loadData();}为什么需要两个生命周期?
aboutToAppear:仅在组件首次创建时调用onPageShow:每次页面出现在前台时都调用
当用户在写日记页保存后返回首页,onPageShow负责重新加载数据,确保首页显示最新内容。
1.4 连续签到算法详解
calcStats():void{// ... 计算今日日记、最近列表等 ...// 连续签到天数计算letstreakCount=0;letcheckDate=newDate();while(true){lety=checkDate.getFullYear();letm=(checkDate.getMonth()+1).toString().padStart(2,'0');letd=checkDate.getDate().toString().padStart(2,'0');letds=`${y}-${m}-${d}`;// 查找这一天是否有日记letfound=false;for(leti=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 装饰器复用
@BuilderquickBtn(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 日记列表项
@BuilderdiaryRow(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 状态变量
@Statemoods:MoodInfo[]=[];// 所有心情选项@StateselectedMood:MoodLevel=MoodLevel.HAPPY;// 选中的心情@Statetitle:string='';// 标题@Statecontent:string='';// 正文@Statetags:string='';// 标签@StatetodayDate: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;}// 构造日记条目letentry:DiaryEntry={id:generateId(),date:this.todayDate,mood:this.selectedMood,title:this.title.trim(),content:this.content.trim(),tags:this.tags.trim()};// 存入全局状态letstored=AppStorage.get<DiaryEntry[]>('entries');letlist: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') └─ 重新渲染 UI4.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 开发中遇到问题,欢迎留言交流!