: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 状态变量定义EntryComponentstruct Index{Stateentries:DiaryEntry[][];// 所有日记StatetodayEntry:DiaryEntry|undefined;// 今天的日记StaterecentEntries:DiaryEntry[][];// 最近5条Statestreak:number0;// 连续签到天数StatehasTodayEntry:booleanfalse;// 今天是否已写}State 的作用被 State 装饰的变量是响应式的当变量值变化时自动触发 UI 重新渲染。1.3 数据加载与页面生命周期// 页面初始化时调用仅首次aboutToAppear():void{this.loadData();}// 每次页面显示时调用包括从其他页面返回onPageShow():void{this.loadData();}为什么需要两个生命周期aboutToAppear仅在组件首次创建时调用onPageShow每次页面出现在前台时都调用当用户在写日记页保存后返回首页onPageShow负责重新加载数据确保首页显示最新内容。1.4 连续签到算法详解calcStats():void{// ... 计算今日日记、最近列表等 ...// 连续签到天数计算letstreakCount0;letcheckDatenewDate();while(true){letycheckDate.getFullYear();letm(checkDate.getMonth()1).toString().padStart(2,0);letdcheckDate.getDate().toString().padStart(2,0);letds${y}-${m}-${d};// 查找这一天是否有日记letfoundfalse;for(leti0;ithis.entries.length;i){if(this.entries[i].dateds){foundtrue;break;}}if(found){streakCount;checkDate.setDate(checkDate.getDate()-1);// 往前推一天}else{break;// 断签了就停止}}this.streakstreakCount;}算法思路从今天开始逐天往前检查是否有日记记录直到某一天没有记录为止。这个算法简单直观时间复杂度 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.hasTodayEntrythis.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:MoodLevelMoodLevel.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.selectedMoodm.level?#6C63FF:#999999)}.width(100%).padding({top:10,bottom:10}).backgroundColor(this.selectedMoodm.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.titlev;})// 正文输入多行Text(正文)TextArea({placeholder:写下今天的感受和故事...,text:this.content}).fontSize(15).height(180)// 固定高度.backgroundColor(#F9F9F9).borderRadius(8).onChange((v:string){this.contentv;})// 标签输入Text(标签用逗号分隔)TextInput({placeholder:如: 工作,生活,旅行,text:this.tags}).onChange((v:string){this.tagsv;})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()};// 存入全局状态letstoredAppStorage.getDiaryEntry[](entries);letlist:DiaryEntry[]stored?stored:[];list.unshift(entry);// 新日记插到最前面AppStorage.setDiaryEntry[](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.selectedMoodm.level?#EEEAFF:#F8F8F8).fontColor(this.selectedMoodm.level?#6C63FF:#999999)双重反馈背景色 文字颜色让选中状态一目了然。3.3 空状态处理if(this.recentEntries.length0){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 开发中遇到问题欢迎留言交流