【鸿蒙原生应用开发实战】第三篇:表单录入与详情展示------AddPetPage + PetDetailPage 完整实现
前两篇我们完成了项目搭建和首页开发。这一篇是"重头戏"------添加宠物 (表单页)和宠物详情(Tab 切换页)。这两个页面覆盖了 ArkTS 开发中最核心的场景:表单状态管理、多 Tab 切换、数据可视化,以及路由参数传递。
一、AddPetPage:表单录入页面
添加宠物页面的功能很简单------收集用户输入的宠物信息。但"简单"不等于"简陋",它在交互细节上做了很多设计。
1.1 页面总览
AddPetPage.ets
├── 顶部导航栏 (buildHeader) ← 返回 + 标题 + 保存
├── 宠物类型选择器 (buildTypeSelector) ← 8种类型标签
├── 文本输入区 ← 名字/品种/年龄/体重
├── 性别选择器 (buildGenderSelector) ← 男女二选一
├── 绝育状态 (buildNeuterToggle) ← toggle 切换
├── 头像上传 (buildPhotoUpload) ← 占位上传区域
├── 健康信息 (buildHealthInfo) ← 疫苗/驱虫日期
└── 养宠小贴士 (buildTips) ← 小贴士列表
1.2 状态变量设计
typescript
struct AddPetPage {
@State petName: string = '';
@State petType: string = '🐱';
@State petBreed: string = '';
@State petAge: string = '';
@State petWeight: string = '';
@State petColor: string = '';
@State birthDate: string = '2024-01-01';
@State selectedGender: number = 0; // 0=男孩, 1=女孩
@State isNeutered: boolean = false;
@State types: string[] = ['🐱 猫咪', '🐶 狗狗', '🐰 兔子', '🐹 仓鼠', '🐦 小鸟', '🐢 乌龟', '🐟 鱼', '其他'];
}
每个输入项对应一个 @State 变量,与 UI 双向绑定。这是 ArkTS 表单开发的标准模式。
1.3 宠物类型选择器
typescript
@Builder buildTypeSelector() {
Column() {
Text('选择宠物类型').fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333')
.width('100%').padding({ left: 16, top: 12 })
Column() {
Row() {
ForEach(this.types, (type: string, index?: number) => {
Text(type)
.fontSize(13)
.fontColor(this.petType === type.substring(0, 2) ? '#FFFFFF' : '#666666')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.petType === type.substring(0, 2) ? '#FF6B35' : '#F0F0F0')
.borderRadius(16)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.petType = type.substring(0, 2); })
}, (type: string) => type)
}
.width('100%').padding({ left: 16, right: 16, top: 8 })
}
}
.width('100%').backgroundColor('#FFFFFF').margin({ top: 8 }).alignItems(HorizontalAlign.Start)
}
设计亮点:
type.substring(0, 2)截取 Emoji 作为选中值(如 "🐱 猫咪" → "🐱")- 选中标签使用主色
#FF6B35白字,未选中用#F0F0F0灰字 borderRadius(16)胶囊形状更现代margin({ right: 6, bottom: 6 })让标签自动换行排列
1.4 TextInput 输入框
typescript
Row() {
Text('名字').fontSize(14).fontColor('#333333').width(80)
TextInput({
placeholder: '请输入宠物名字',
text: this.petName
})
.layoutWeight(1).height(40).backgroundColor('#F5F5F5').borderRadius(8)
.fontSize(14).padding({ left: 12 })
.onChange((val: string) => { this.petName = val; })
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 }).backgroundColor('#FFFFFF')
表单布局模式:
| 模式 | 说明 | 适用场景 |
|---|---|---|
| Label-左 + Input-右 | 固定宽度 Label + layoutWeight(1) 的 Input |
单行输入 |
| 标签选择器 | 横向排列的胶囊按钮 | 多选一 |
| Toggle 切换 | 点击切换状态的文本 | 布尔值选择 |
TextInput 构造参数
typescript
TextInput({ placeholder: '提示文字', text: this.bindValue })
两个关键参数:
placeholder:占位提示text:绑定值
值变化通过 .onChange((val: string) => { ... }) 回调同步到 @State 变量。
⚠️ 注意 :ArkTS 中 TextInput 没有
v-model这种语法糖,必须手动onChange同步。这和 Vue/React 的习惯不同,刚上手时容易忘记绑定。
1.5 性别选择器(二选一)
typescript
@Builder buildGenderSelector() {
Row() {
Text('性别').fontSize(14).fontColor('#333333').width(80)
Row() {
Text('👦 男孩子')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.selectedGender === 0 ? '#3498DB' : '#F0F0F0')
.borderRadius(16)
.onClick(() => { this.selectedGender = 0; })
Text('👧 女孩子')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.selectedGender === 1 ? '#E91E63' : '#F0F0F0')
.borderRadius(16)
.margin({ left: 10 })
.onClick(() => { this.selectedGender = 1; })
}
.layoutWeight(1)
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 }).backgroundColor('#FFFFFF')
}
这里有个小细节:男孩子的选中色是蓝色 #3498DB,女孩子是粉色 #E91E63。这在 UI 上形成了直观的性别暗示。
1.6 绝育切换(Toggle)
typescript
@Builder buildNeuterToggle() {
Row() {
Text('是否绝育').fontSize(14).fontColor('#333333').width(80)
Text(this.isNeutered ? '✅ 已绝育' : '⬜ 未绝育')
.fontSize(13)
.fontColor(this.isNeutered ? '#2ECC71' : '#999999')
.onClick(() => { this.isNeutered = !this.isNeutered; })
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 }).backgroundColor('#FFFFFF')
}
ArkTS 中没有专门的 Toggle 或 Switch 组件(在低版本 API 中),这里用「文本 + onClick 取反」模拟了 Toggle 效果。
1.7 头像上传占位
typescript
@Builder buildPhotoUpload() {
Column() {
Text('上传头像').fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333')
Row() {
Stack() {
Column().width(80).height(80).borderRadius(10)
.backgroundColor('#F0F0F0')
.border({ width: 2, color: '#FF6B35', style: BorderStyle.Dashed })
Column() {
Text('📷').fontSize(30)
Text('点击上传').fontSize(10).fontColor('#999999').margin({ top: 4 })
}
}
.width(80).height(80).margin({ left: 16, top: 8 })
Text('建议使用清晰的正面照片\n支持JPG/PNG格式\n文件大小不超过5MB')
.fontSize(11).fontColor('#BBBBBB').lineHeight(16).margin({ left: 12 })
}
}
}
虚拟边框 :.border({ width: 2, color: '#FF6B35', style: BorderStyle.Dashed }) 创建虚线边框,视觉上提示"可点击上传"。
在实际项目中,这里应该接入 ohos.multimedia.camera 或 ohos.file.picker 来实现真实拍照/选图功能。但由于我们做的是 MVP 版本,先用占位 UI 打好架子。
二、PetDetailPage:宠物详情页
详情页是项目中复杂度最高的页面,包含三个 Tab 切换。先看完整结构:
PetDetailPage.ets
├── 渐变头部 (buildHeader) ← 渐变色背景 + 宠物信息展示
├── 快捷信息栏 (buildQuickInfo) ← 体重/绝育/疫苗状态
├── Tab 切换栏 (buildTabs) ← 自定义三Tab切换
├── Tab 0: 健康档案 (buildHealthTab) ← 基本信息展示
├── Tab 1: 体重记录 (buildWeightTab) ← 柱状图 + 记录列表
└── Tab 2: 疫苗记录 (buildVaccineTab) ← 接种历程 + 下次提醒
2.1 路由参数接收
typescript
aboutToAppear(): void {
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
if (params && params['petId'] !== undefined) {
this.petId = params['petId'] as number;
}
this.loadPetData();
}
aboutToAppear 是组件的生命周期函数,在组件即将显示时调用,等价于其他框架的 onMounted / useEffect。
router.getParams() 返回 Record<string, Object> 类型,需要强制转换。这是 API 23 下的标准用法。
2.2 动态数据加载
typescript
loadPetData(): void {
if (this.petId === 1) {
this.petName = '团子'; this.petType = '🐱';
this.petBreed = '英短蓝猫'; this.petAge = '2岁3个月';
this.petWeight = '4.5kg'; this.petGender = 0; this.isNeutered = true;
} else if (this.petId === 2) {
// 豆豆的数据
} else {
// 小乖的数据
}
// 体重历史 & 疫苗记录
}
目前数据是硬编码的,但结构已经为接入后端 API 做好了准备------只需要把 if-else 换成网络请求即可。
2.3 渐变色头部
typescript
@Builder buildHeader() {
Stack() {
Column()
.width('100%').height(180)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [['#FF6B35', 0], ['#FF8F5E', 1]]
})
Column() {
Row() {
Text('←').fontSize(20).fontColor(Color.White)
.onClick(() => { router.back(); })
Blank()
Text('编辑').fontSize(14).fontColor(Color.White)
}
.width('100%').padding({ left: 16, right: 16 }).position({ top: 40 })
Column() {
Text(this.petType).fontSize(48)
Text(this.petName).fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text(`${this.petBreed} · ${this.petAge} · ${this.petGender === 0 ? '👦' : '👧'}`)
.fontSize(13).fontColor('rgba(255,255,255,0.85)')
}
.alignItems(HorizontalAlign.Center).width('100%').position({ top: 70 })
}
.width('100%').height('100%')
}
.width('100%').height(180)
}
关键 API:linearGradient
typescript
Column().linearGradient({
direction: GradientDirection.Bottom, // 从上到下渐变
colors: [['#FF6B35', 0], ['#FF8F5E', 1]] // 起始色 → 结束色
})
colors 数组的每个元素是 [color, offset],offset 范围 0~1。多个颜色可以创建多段渐变。
.position({ top: 40 }) 在 Stack 中实现绝对定位,让返回按钮保持在左上角。
2.4 自定义 Tab 切换
我们没有用系统提供的 Tabs 组件,而是完全自定义了三 Tab 切换:
typescript
@State currentTab: number = 0;
tabs: string[] = ['健康档案', '体重记录', '疫苗记录'];
@Builder buildTabs() {
Row() {
ForEach(this.tabs, (tab: string, index?: number) => {
Column() {
Text(tab)
.fontSize(14)
.fontColor(this.currentTab === (index as number) ? '#FF6B35' : '#999999')
.fontWeight(this.currentTab === (index as number) ? FontWeight.Medium : FontWeight.Normal)
Column()
.width('80%').height(2)
.backgroundColor(this.currentTab === (index as number) ? '#FF6B35' : 'transparent')
.borderRadius(1).margin({ top: 6 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.onClick(() => { this.currentTab = index as number; })
}, (tab: string) => tab)
}
.width('100%').padding({ top: 12 }).backgroundColor('#FFFFFF').margin({ top: 8 })
}
为什么不用 Tabs 组件?
自定义 Tab 的好处:
- 样式完全可控------下划线动画、颜色、字号全由自己决定
- 轻量------不需要引入 Tabs 组件的内部复杂逻辑
- 内容独立 ------每个 Tab 的内容由
if-else分别渲染,互不干扰
内容切换的代码:
typescript
if (this.currentTab === 0) {
this.buildHealthTab()
} else if (this.currentTab === 1) {
this.buildWeightTab()
} else {
this.buildVaccineTab()
}
2.5 健康档案 Tab
展示宠物的所有基本信息:
typescript
@Builder buildHealthTab() {
Column() {
Text('基本信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
Column() {
// 6行信息:宠物名/品种/年龄/体重/性别/绝育
Row() {
Text('宠物名').fontSize(13).fontColor('#999999')
Blank()
Text(this.petName).fontSize(13).fontColor('#333333')
}.width('100%').padding({ left: 16, right: 16, top: 6 })
// ... 其他行类似
}
.width('100%').backgroundColor('#FFFFFF')
}
}
这里使用 Blank() 实现左右对齐------左侧标签靠左,右侧值靠右。Blank() 会填充剩余空间,把两端内容推开。
2.6 体重记录 Tab(柱状图)
这是最具"可视化"效果的部分:
typescript
@Builder buildWeightTab() {
Column() {
Text('体重变化趋势').fontSize(15).fontWeight(FontWeight.Bold)
// 柱状图
Row() {
ForEach(this.weightHistory, (record: WeightRecord) => {
Column() {
Text(record.weight).fontSize(10).fontColor('#FF6B35')
Column()
.width(24)
.height(this.getBarHeight(record.weight)) // 动态计算高度
.backgroundColor('#FF6B35').borderRadius(4)
.margin({ top: 4 })
Text(record.date.substring(5)).fontSize(9).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}, (record: WeightRecord) => record.date)
}
.width('100%').height(140).alignItems(VerticalAlign.Bottom)
// 详细记录列表
ForEach(this.weightHistory, (record: WeightRecord) => {
Row() {
Text(record.date).fontSize(12).fontColor('#999999')
Blank()
Text(record.weight).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#FF6B35')
Text(record.note).fontSize(12).fontColor('#BBBBBB').margin({ left: 8 })
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
}, (record: WeightRecord) => record.date)
}
}
柱状图实现原理:
typescript
getBarHeight(weightStr: string): number {
return Math.round((parseFloat(weightStr.replace('kg', '')) / 5) * 80);
}
parseFloat从 "4.5kg" 中提取数字 4.5- 除以 5(最大预期体重)得到比例
- 乘以 80(最大柱高)得到实际高度
这是一个极简柱状图方案。优点是无须任何图表库,纯 ArkTS 原生实现。缺点是只适用于等间距数据,无法做更复杂的图表。
2.7 疫苗记录 Tab
typescript
@Builder buildVaccineTab() {
Column() {
Text('疫苗接种记录').fontSize(15).fontWeight(FontWeight.Bold)
ForEach(this.vaccineRecords, (record: VaccineRecord, index?: number) => {
Row() {
Stack() {
Column().width(32).height(32).borderRadius(16).backgroundColor('#3498DB')
Text('💉').fontSize(16)
}
Column() {
Text(record.name).fontSize(14).fontWeight(FontWeight.Medium)
Text(`接种日期: ${record.date}`).fontSize(11).fontColor('#999999')
if (index === 0) {
Text(`下次接种: ${record.nextDate}`).fontSize(11).fontColor('#FF6B35')
}
}
.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
}, (record: VaccineRecord) => record.date)
// 下次提醒入口
Text('⏰ 下次疫苗提醒')
.fontSize(14).fontWeight(FontWeight.Medium).fontColor('#FF6B35')
.width('100%').textAlign(TextAlign.Center).padding(16)
.backgroundColor('#FFF0E8').margin({ top: 8 }).borderRadius(8)
.onClick(() => { router.pushUrl({ url: 'pages/ReminderPage' }); })
}
}
这里有个细节:只有最新的一条疫苗记录(index === 0)才显示"下次接种日期",因为最新的那条才是下一次接种的参考依据。这个逻辑在 MVP 阶段足够用,但如果要严格的疫苗管理,应该独立维护"下一次的日期"字段。
三、两个页面的数据流对比
| 维度 | AddPetPage | PetDetailPage |
|---|---|---|
| 数据方向 | ✅ 用户输入 → 状态变量 | ✅ 状态变量 → 展示 |
| 路由参数 | 无参数 | 接收 petId |
| 状态复杂度 | 9个 @State 变量 | 7个 @State + 2个数组 |
| 交互方式 | TextInput + Click | Tab 切换 + 跳转 |
| 数据来源 | 表单录入 | 硬编码(待接 API) |
四、ArkTS 严格模式避坑指南
这一篇的两个页面中,我们遇到了几个 TypeScript 转 ArkTS 时的常见问题:
4.1 数组字面量必须可推断类型
typescript
// ❌ 错误:arkts-no-noninferrable-arr-literals
tabs: string[] = ['健康档案', '体重记录', '疫苗记录'];
// ✅ 正确:显式类型标注
tabs: string[] = ['健康档案', '体重记录', '疫苗记录'];
实际上类型标注已经够了。但有时遇到复杂数组,需要把数组定义提取到函数外部:
typescript
// 提取为独立的类型变量
const PET_TYPES: string[] = ['🐱 猫咪', '🐶 狗狗', '🐰 兔子'];
4.2 对象类型转换
typescript
// ❌ 错误:直接赋值 Object 到 class
const params = router.getParams(); // 返回 Object
// ✅ 正确:强制类型转换
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
4.3 ForEach 的 key 生成
typescript
// 必须传第三个参数作为 key
ForEach(this.pets, (pet: Pet) => { ... }, (pet: Pet) => pet.id.toString())

五、下篇预告
下一篇我们做相册页面 + 提醒管理页面:
- 网格/列表双模式切换
- 按宠物相册筛选
- ForEach 嵌套渲染二维网格
- 今日待办提醒 + 开关控制
- 已完成提醒的归档展示
不见不散!🚀
系列导航:
- 第一篇:项目搭建与架构
- 第二篇:首页与宠物卡片
- 第三篇:表单与详情页(本文)
- 第四篇:相册与提醒功能
- 第五篇:总结与最佳实践