【鸿蒙原生应用开发实战】第三篇:表单录入与详情展示——AddPetPage + PetDetailPage 完整实现

【鸿蒙原生应用开发实战】第三篇:表单录入与详情展示------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.cameraohos.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 的好处:

  1. 样式完全可控------下划线动画、颜色、字号全由自己决定
  2. 轻量------不需要引入 Tabs 组件的内部复杂逻辑
  3. 内容独立 ------每个 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 嵌套渲染二维网格
  • 今日待办提醒 + 开关控制
  • 已完成提醒的归档展示

不见不散!🚀


系列导航:

  • 第一篇:项目搭建与架构
  • 第二篇:首页与宠物卡片
  • 第三篇:表单与详情页(本文)
  • 第四篇:相册与提醒功能
  • 第五篇:总结与最佳实践
相关推荐
风满城332 小时前
【鸿蒙原生应用开发实战】第一篇:从零搭建“萌宠日记“项目——Stage模型与工程架构解析
华为·harmonyos
charlee442 小时前
Unity项目适配华为鸿蒙系统的原生库加载问题排查与解决
华为·unity3d·鸿蒙·cmake·c/c++·relro
狼哥16863 小时前
《新闻资讯》二、公共能力层模块实现指南
ui·华为·harmonyos
Ww.xh3 小时前
启用Hypervisor解决模拟器问题
华为·harmonyos
金启攻4 小时前
【鸿蒙原生应用实战】第二篇:装备库页面——分类筛选与数据驱动UI
harmonyos
木咺吟6 小时前
鸿蒙原生应用实战(四):愿望单与个人统计 — 数据聚合与可视化
华为·harmonyos
木咺吟6 小时前
鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
harmonyos
互联网散修8 小时前
鸿蒙实战:从零实现自定义相机(下)——填平预览拉伸、比例错乱、缩略图消失的六大坑
数码相机·华为·harmonyos
风华圆舞8 小时前
鸿蒙 + Flutter 下 AI 助手为什么要支持流式输出
人工智能·flutter·harmonyos