【鸿蒙原生应用开发实战】第四篇:相册与提醒------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.picker 或 ohos.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)
}
设计细节:
- 空状态处理 :
getTodayReminders().length > 0条件分支,无待办时显示绿色提示 - 视觉强调 :今日待办卡片用
#FFF0E8暖色背景 +#FF6B35文字,视觉上突出 - 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 到生产环境的升级路线图
- 性能优化建议
- 项目打包与发布
最终篇见!🚀
系列导航:
- 第一篇:项目搭建与架构
- 第二篇:首页与宠物卡片
- 第三篇:表单与详情页
- 第四篇:相册与提醒功能(本文)
- 第五篇:总结与最佳实践