鸿蒙原生应用实战(三):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 开发中遇到问题,欢迎留言交流!

Logo

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

更多推荐