【鸿蒙原生应用开发实战】第四篇:相册与提醒——AlbumPage + ReminderPage 完整实现

【鸿蒙原生应用开发实战】第四篇:相册与提醒------AlbumPage + ReminderPage 完整实现

前一篇我们做了添加宠物和宠物详情的核心业务页面。这篇我们来做剩下两个功能页面------萌宠相册提醒管理。这两个页面分别展示了 ArkTS 中两种不同的列表渲染模式:相册的网格/列表双模式切换,以及提醒页面的分类统计与开关控制。内容同样干货满满。


一、AlbumPage:萌宠相册

1.1 功能设计

相册页面的核心功能:

功能 实现方式 亮点
按宠物筛选 横向滚动的筛选栏 Emoji + 名字组合
网格/列表切换 点击切换按钮 isGridMode 状态驱动
照片展示 ForEach 渲染 纯色背景 + Emoji 占位
上传入口 三个功能按钮 拍照/相册/批量

1.2 数据结构

typescript 复制代码
interface Photo {
  id: number;
  date: string;      // 拍摄日期
  title: string;     // 照片标题
  petName: string;   // 所属宠物
  type: string;      // 类型 Emoji
}

1.3 相册筛选器

typescript 复制代码
@State currentAlbum: string = '全部';
@State albums: string[] = ['全部', '团子', '豆豆', '小乖'];

@Builder buildAlbumFilter() {
  Scroll() {
    Row() {
      ForEach(this.albums, (album: string) => {
        Column() {
          Stack() {
            Column()
              .width(48).height(48).borderRadius(24)
              .backgroundColor(this.currentAlbum === album ? '#FF6B35' : '#F0F0F0')
            Text(album === '全部' ? '📸' : this.getPetEmoji(album)).fontSize(22)
          }
          Text(album).fontSize(11)
            .fontColor(this.currentAlbum === album ? '#FF6B35' : '#999999')
        }
        .margin({ right: 14 })
        .onClick(() => { this.currentAlbum = album; })
      }, (album: string) => album)
    }
    .padding({ left: 16, top: 8 })
  }
  .scrollable(ScrollDirection.Horizontal).height(90)
}

getPetEmoji(name: string): string {
  const map: Record<string, string> = { '团子': '🐱', '豆豆': '🐶', '小乖': '🐰' };
  return map[name] || '🐾';
}

设计分析:

筛选器的交互模式借鉴了 Instagram Stories 的样式------圆形头像 + 底部文字。选中时背景变为主色 #FF6B35,未选中为 #F0F0F0

currentAlbum 作为状态驱动筛选逻辑:

typescript 复制代码
getFilteredPhotos(): Photo[] {
  if (this.currentAlbum === '全部') return this.photos;
  return this.photos.filter((p: Photo) => p.petName === this.currentAlbum);
}

1.4 网格模式(3列布局)

typescript 复制代码
@Builder buildGridPhotos() {
  Column() {
    Text(`共 ${this.getFilteredPhotos().length} 张照片`)

    ForEach([0, 1, 2, 3], (row: number) => {       // 4行
      Row() {
        ForEach([0, 1, 2], (col: number) => {       // 3列
          if (row * 3 + col < this.getFilteredPhotos().length) {
            Column() {
              Stack() {
                Column().width('100%').height(110)
                  .backgroundColor(/* 根据 id 选择颜色 */)
                Text(/* 照片 Emoji */).fontSize(32)
              }
              Text(/* 标题 */).fontSize(11)
              Text(/* 日期 */).fontSize(9)
            }.layoutWeight(1).padding({ left: 4, right: 4 })
          } else {
            Column().layoutWeight(1)    // 占位空列
          }
        }, (col: number) => col.toString())
      }
      .width('100%').padding({ left: 12, right: 12, top: 6 })
    }, (row: number) => row.toString())
  }
}

网格布局的两种实现方案对比:

方案 优点 缺点 适用场景
Grid 组件 原生支持、懒加载 API 要求高、定制复杂 大量数据
嵌套 ForEach 完全可控、无额外依赖 代码较长、需手动计算行列 少量数据(<50)

我们的项目数据量小(12张照片),选择了嵌套 ForEach 方案。ForEach([0,1,2,3], row => ...) 外层控制行,ForEach([0,1,2], col => ...) 内层控制列。

颜色分配技巧 :用 photo.id % 12 从预定义颜色数组中取色,保证同一张照片始终使用同一个背景色:

typescript 复制代码
.backgroundColor(['#FFE0D0','#D0E8FF','#D0FFD0','#FFD0D0',...][photo.id % 12])

1.5 列表模式

typescript 复制代码
@Builder buildListPhotos() {
  Column() {
    Text(`共 ${this.getFilteredPhotos().length} 张照片`)

    ForEach(this.getFilteredPhotos(), (photo: Photo) => {
      Row() {
        Stack() {
          Column().width(56).height(56).borderRadius(6)
            .backgroundColor(['#FFE0D0',...][photo.id % 12])
          Text(photo.type).fontSize(24)
        }
        Column() {
          Text(photo.title).fontSize(14).fontWeight(FontWeight.Medium)
          Text(`${photo.date} · ${photo.petName}`).fontSize(11).fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
      }
      .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(8)
    }, (photo: Photo) => photo.id.toString())
  }
}

1.6 双模式切换

typescript 复制代码
// 切换按钮
Text(this.isGridMode ? '📋' : '🔲')
  .fontSize(18)
  .onClick(() => { this.isGridMode = !this.isGridMode; })

// 渲染逻辑
if (this.isGridMode) {
  this.buildGridPhotos()
} else {
  this.buildListPhotos()
}

isGridMode 是一个 @State 布尔值,切换时直接 toggle。ArkTS 的响应式系统会自动检测状态变化,只重绘变化的部分。

1.7 上传区域

typescript 复制代码
@Builder buildAddButton() {
  Row() {
    Column() {
      Text('📷').fontSize(24)
      Text('拍照上传').fontSize(11).fontColor('#666666')
    }.layoutWeight(1).alignItems(HorizontalAlign.Center)

    Column() {
      Text('🖼️').fontSize(24)
      Text('从相册选择').fontSize(11).fontColor('#666666')
    }.layoutWeight(1).alignItems(HorizontalAlign.Center)

    Column() {
      Text('📁').fontSize(24)
      Text('批量导入').fontSize(11).fontColor('#666666')
    }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF').margin({ top: 12 }).borderRadius(10)
}

这三个按钮目前是 UI 占位,后续接入 ohos.file.pickerohos.multimedia.camera 即可实现真实上传。


二、ReminderPage:提醒管理

2.1 功能设计

提醒页面比相册页面更复杂,包含四个功能区块:

复制代码
ReminderPage.ets
├── 顶部导航 (buildHeader)           ← 返回 + 标题 + 新增
├── 提醒分类统计 (buildCategoryStats) ← 疫苗/驱虫/其他/总数统计
├── 今日待办 (buildTodayReminder)     ← 当日到期提醒
├── 所有提醒 (buildUpcomingReminders) ← 全部提醒列表 + 开关
└── 已完成 (buildCompletedSection)    ← 已完成的提醒归档

2.2 数据结构

typescript 复制代码
interface Reminder {
  id: number;
  title: string;       // 提醒标题
  petName: string;     // 关联宠物
  type: string;        // 分类:疫苗/驱虫/美容/健康/护理/日常
  date: string;        // 到期日期
  repeat: string;      // 重复周期
  isEnabled: boolean;  // 开关状态
  icon: string;        // 图标 Emoji
}

2.3 分类统计

typescript 复制代码
@Builder buildCategoryStats() {
  Column() {
    Text('📊 提醒分类').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')

    Row() {
      Column() {
        Text('💉').fontSize(24)
        Text(`疫苗 ${this.getVaccineCount()}`)
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('🪱').fontSize(24)
        Text(`驱虫 ${this.getDewormCount()}`)
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('📋').fontSize(24)
        Text(`其他 ${this.reminders.length - this.getVaccineCount() - this.getDewormCount()}`)
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('📅').fontSize(24)
        Text(`共${this.reminders.length}项`)
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
    }
    .width('100%').padding({ top: 12 })
  }
  .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 8 })
}

辅助计算方法:

typescript 复制代码
getVaccineCount(): number {
  return this.reminders.filter((r: Reminder) => r.type === '疫苗').length;
}

getDewormCount(): number {
  return this.reminders.filter((r: Reminder) => r.type === '驱虫').length;
}

这展示了 ArkTS 中纯计算属性 的写法------不需要 @Computed 装饰器,直接在方法中计算并返回。每当 reminders 变化时,UI 自动重新渲染这些统计值。

2.4 今日待办

typescript 复制代码
@Builder buildTodayReminder() {
  Column() {
    Row() {
      Text('📅 今日待办').fontSize(15).fontWeight(FontWeight.Bold)
      Blank()
      Text(`${this.getTodayReminders().length}项`).fontSize(12).fontColor('#FF6B35')
    }

    if (this.getTodayReminders().length > 0) {
      ForEach(this.getTodayReminders(), (r: Reminder) => {
        Row() {
          Text(r.icon).fontSize(20)
          Column() {
            Text(r.title).fontSize(14).fontWeight(FontWeight.Medium)
            Text(`${r.petName} · 今日到期`).fontSize(11).fontColor('#FF6B35')
          }.margin({ left: 10 }).layoutWeight(1)
        }
        .width('100%').padding(10).backgroundColor('#FFF0E8').borderRadius(8)
      }, (r: Reminder) => r.id.toString() + 'today')
    } else {
      Text('今天没有待办事项 ✓').fontSize(13).fontColor('#2ECC71')
        .width('100%').textAlign(TextAlign.Center).padding(16)
    }
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(10)
}

设计细节

  1. 空状态处理getTodayReminders().length > 0 条件分支,无待办时显示绿色提示
  2. 视觉强调 :今日待办卡片用 #FFF0E8 暖色背景 + #FF6B35 文字,视觉上突出
  3. Key 唯一r.id.toString() + 'today' 避免和其他 ForEach 的 key 冲突

2.5 所有提醒列表 + 开关控制

typescript 复制代码
ForEach(this.reminders, (reminder: Reminder) => {
  Row() {
    Stack() {
      Column().width(42).height(42).borderRadius(10)
        .backgroundColor(reminder.isEnabled ? '#FFE0D0' : '#F0F0F0')
      Text(reminder.icon).fontSize(20)
    }

    Column() {
      Text(reminder.title)
        .fontColor(reminder.isEnabled ? '#333333' : '#BBBBBB')
      Row() {
        Text(reminder.petName).fontSize(11).fontColor('#999999')
        Text(` · ${reminder.date}`).fontSize(11).fontColor('#999999')
        Text(` · ${reminder.repeat}`).fontSize(11).fontColor('#BBBBBB')
      }
    }.margin({ left: 10 }).layoutWeight(1)

    Column() {
      Text(reminder.isEnabled ? '🔔' : '🔕').fontSize(18)
        .onClick(() => {
          for (let i: number = 0; i < this.reminders.length; i++) {
            if (this.reminders[i].id === reminder.id) {
              this.reminders[i].isEnabled = !this.reminders[i].isEnabled;
              break;
            }
          }
        })
    }
  }
  .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
  .backgroundColor('#FFFFFF')
  .opacity(reminder.isEnabled ? 1.0 : 0.5)
}, (reminder: Reminder) => reminder.id.toString())

开关状态切换的代码有一个值得注意的点:

typescript 复制代码
for (let i: number = 0; i < this.reminders.length; i++) {
  if (this.reminders[i].id === reminder.id) {
    this.reminders[i].isEnabled = !this.reminders[i].isEnabled;
    break;
  }
}

这不是 ArkTS 响应式系统的问题------ArkTS 可以检测数组元素的属性变化。这里用 for 循环找到目标元素的方式是出于代码清晰度考虑。

视觉反馈

  • 开启状态:不透明度 1.0,标题色 #333333
  • 关闭状态:不透明度 0.5,标题色 #BBBBBB
  • 图标切换:🔔(开)/ 🔕(关)

2.6 已完成提醒

typescript 复制代码
@Builder buildCompletedSection() {
  Column() {
    Text('✅ 已完成').fontSize(15).fontWeight(FontWeight.Bold)

    ForEach(this.completedReminders, (reminder: Reminder) => {
      Row() {
        Stack() {
          Column().width(42).height(42).borderRadius(10).backgroundColor('#E8F5E9')
          Text(reminder.icon).fontSize(20)
        }

        Column() {
          Text(reminder.title)
            .fontColor('#999999')
            .decoration({ type: TextDecorationType.LineThrough })  // 删除线
          Text(`${reminder.petName} · ${reminder.date} ✓`)
        }.margin({ left: 10 }).layoutWeight(1)

        Text('✅').fontSize(16).fontColor('#2ECC71')
      }
      .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
      .backgroundColor('#FFFFFF')
    }, (reminder: Reminder) => reminder.id.toString())
  }
}

删除线 .decoration({ type: TextDecorationType.LineThrough }) 是 ArkTS 的文本装饰 API。这里用删除线表示"已完成",视觉语义清晰。


三、ArkTS 进阶技巧

3.1 嵌套 ForEach 的注意事项

buildGridPhotos() 中,我们用了两层 ForEach:

typescript 复制代码
ForEach([0, 1, 2, 3], (row: number) => {
  ForEach([0, 1, 2], (col: number) => { ... })
})

这里的 [0, 1, 2, 3][0, 1, 2] 是固定数组,不会有变化。但如果数据源是动态数组,嵌套 ForEach 的 key 生成器必须保证唯一性。

3.2 空状态设计

我们在提醒页面做了两种空状态:

场景 显示
今日无待办 '今天没有待办事项 ✓' 绿色文字居中
无照片 显示 '共 0 张照片'

空状态是用户体验的重要一环。永远不要假设数据一定不为空

3.3 透明度与禁用态

typescript 复制代码
.opacity(reminder.isEnabled ? 1.0 : 0.5)

ArkTS 中 opacity 接受 0.0~1.0 的浮点数。用透明度 + 颜色变灰的组合实现禁用态,比单纯的 enabled(false) 更柔和。

3.4 列表项 Key 的唯一性

当同一个页面有多个 ForEach 时,key 可能会冲突:

typescript 复制代码
// ✅ 加后缀区分
(r: Reminder) => r.id.toString() + 'today'
(reminder: Reminder) => reminder.id.toString()

四、知识点总结

知识点 AlbumPage ReminderPage
@State 状态驱动 isGridMode, currentAlbum reminders, completedReminders
ForEach 列表渲染 网格模式嵌套 ForEach 三个独立 ForEach
Scroll 滚动 横向筛选器 + 纵向内容 纵向内容
if 条件渲染 网格/列表模式切换 今日待办空状态
@Builder 方法拆分 5个 Builder 6个 Builder
状态计算 getFilteredPhotos() getVaccineCount() 等

五、下篇预告

系列最后一篇!我们来个大总结:

  • 全项目 5 个页面的架构回顾
  • ArkTS 严格模式踩坑全记录
  • 从 MVP 到生产环境的升级路线图
  • 性能优化建议
  • 项目打包与发布

最终篇见!🚀


系列导航:

  • 第一篇:项目搭建与架构
  • 第二篇:首页与宠物卡片
  • 第三篇:表单与详情页
  • 第四篇:相册与提醒功能(本文)
  • 第五篇:总结与最佳实践
相关推荐
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第三篇:实战案例——单手操作优化
华为·harmonyos
浮芷.2 小时前
HarmonyOS 6.1 沉浸式光感效果-样式切换效果问题解决方案-鸿蒙PC方向
华为·harmonyos·鸿蒙
木咺吟2 小时前
鸿蒙原生应用实战(三):表单交互与搜索筛选——添加包裹、搜索过滤与公司管理
华为·harmonyos
xcLeigh2 小时前
鸿蒙平台 gThumb 图片查看器适配实战:从 Linux GTK 到 Electron 鸿蒙壳工程
linux·electron·harmonyos·gnome·桌面环境·gthumb
金启攻3 小时前
鸿蒙原生应用开发实战(四):复杂页面与交互体验——鱼种百科、天气详情与钓点详情
harmonyos
lqj_本人3 小时前
鸿蒙pc:Hoppscotch-hoppscotch-ohos适配全记录
华为·harmonyos
xcLeigh3 小时前
鸿蒙PC平台 imv 图片查看器适配实战:极简主义设计的 Electron 迁移
华为·electron·harmonyos·鸿蒙·imv·图片操作·web_engine
不羁的木木3 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第四篇:进阶应用——横屏游戏手柄模式
游戏·华为·harmonyos
IT大白鼠3 小时前
IPv6过渡技术:原理、分类与应用
网络·网络协议·华为