表单是移动应用中最基础也最关键的交互模式。从注册登录到订单提交,从设置页面到反馈表单——几乎每个应用都离不开表单。HarmonyOS NEXT ArkUI 提供了丰富的表单组件:TextInput 用于文本输入、Toggle 用于开关切换、以及通过组合基础组件构建的选项组(Chip 选择器)和步进器(Stepper)——开发者可以用这些组件组合出满足各种业务需求的表单界面。

但表单开发不只是"放几个输入框"。真正的挑战在于:如何组织表单的视觉层级?如何让价格随着选项变化实时更新?如何校验输入并给出友好的错误提示?如何管理表单的状态流转(编辑→校验→提交→成功)?

本文将通过一个完整的"旅行预订"实战案例,从组件选择、布局设计、状态管理到校验提交,系统讲解在 ArkUI 中构建专业表单的方法。

关键词:HarmonyOS、ArkUI、TextInput、Toggle、表单验证、选项组、状态管理

一、表单组件概览

1.1 TextInput 文本输入

TextInput 是单行文本输入框,适用于姓名、手机号、邮箱等短文本输入。核心属性:

属性 说明
placeholder 占位提示文字
type 输入类型:Normal / Number / PhoneNumber / Email
maxLength 最大输入长度
onChange 输入变化回调,接收当前输入值
placeholderColor 占位文字颜色

本文 Demo 中,姓名输入使用 Normal 类型(默认),手机号输入使用 PhoneNumber 类型并限制 11 位:

TextInput({ placeholder: '请输入联系人姓名', text: this.personName })
  .fontSize(14)
  .placeholderColor('#CCCCDD')
  .backgroundColor('#F2F3F5')
  .borderRadius(8)
  .onChange((value: string) => { this.personName = value; })

TextInput({ placeholder: '请输入11位手机号', text: this.phone })
  .type(InputType.PhoneNumber)
  .maxLength(11)
  .onChange((value: string) => { this.phone = value; })

关键设计细节:

  • 输入框背景色 #F2F3F5(浅灰)与卡片背景 #FFFFFF(白)形成视觉层次——输入框是"可交互的凹陷区域",卡片是"容器"
  • placeholder 颜色 #CCCCDD 足够淡以区别于实际输入内容
  • maxLength(11) 防止用户输入超长手机号(视觉约束),配合验证逻辑(提交时校验长度)实现双重保障

1.2 Toggle 开关

Toggle 用于二值选择,适合开启/关闭类选项。本文 Demo 用于"旅行保险"的开关:

Toggle({ type: ToggleType.Switch, isOn: this.insurance })
  .selectedColor('#1677FF')
  .onChange((value: boolean) => { this.insurance = value; })

Toggle 与传统的 Checkbox 有明确的适用场景区别:Toggle 强调"立即生效的开关状态"(如开启通知、启用保险),Checkbox 强调"确认选择"(如同意条款、多选项目)。旅行保险是一个"开启/不开启"的二元决策,Toggle 是自然的选择。

Toggle 的值通过 isOn 双向绑定,变化时 onChange 回调触发状态更新和价格重算。

1.3 选项组(Chip 选择器)

对于从有限选项中选一的场景(如目的地、日期),自定义 Chip 选择器比下拉 Select 更直观——所有选项一目了然,点击即选中,无需展开/收起的步骤:

ForEach(this.destOpts, (dest: DestOption, idx: number) => {
  Column() {
    Text(dest.icon).fontSize(24).margin({ bottom: 4 })
    Text(dest.name).fontSize(12)
      .fontColor(idx === this.activeDest ? '#FFFFFF' : '#666677')
      .fontWeight(idx === this.activeDest ? FontWeight.Bold : FontWeight.Normal)
    Text('¥'.concat(dest.basePrice.toString()))
      .fontSize(10)
      .fontColor(idx === this.activeDest ? '#FFFFFF88' : '#9999AA')
  }
  .width(64)
  .padding({ top: 10, bottom: 10 })
  .borderRadius(10)
  .backgroundColor(idx === this.activeDest ? '#1677FF' : '#F2F3F5')
  .onClick(() => { this.activeDest = idx; })
})

每个 Chip 包含三个信息维度:图标(emoji 城市特征)、名称、价格。选中态使用蓝色背景 + 白色文字,未选中态使用灰色背景 + 灰色文字——对比强烈,状态一目了然。

旅程日期选择则使用单选列表模式(Radio-like),更适合文字较长的选项:

ForEach(this.dateOpts, (d: string, idx: number) => {
  Row() {
    Row() { /* 圆形单选指示器 */ }
    Text(d) /* 日期文字 */
  }
  .onClick(() => { this.activeDate = idx; })
})

Chip 和 Radio-like 两种选择器模式分别适用于不同场景:

  • Chip:图标 + 短文字,适合 4-6 个视觉差异大的选项
  • Radio-like:纯文字,适合选项文字较长或需要强调文字内容的场景

1.4 步进器(自定义 Stepper)

人数选择使用自定义的步进器——两个操作按钮夹着当前数值:

Row() {
  Text('−') /* 减号按钮,人数=1时禁用 */
  Text(this.travelers.toString()) /* 当前值 */
  Text('+') /* 加号按钮,人数=10时禁用 */
}

步进器的边界逻辑:

  • 最小值 = 1:至少需要 1 位旅客,减号按钮在 travelers <= 1 时置灰且不可点击
  • 最大值 = 10:最多 10 人(产品规则),加号按钮在 travelers >= 10 时置灰

按钮禁用状态的视觉反馈:文字和背景变为浅灰(#CCCCDD / #F5F5F5),明确传达"不可操作"的信号。

ArkUI 没有内置的 Stepper/InputNumber 组件,但手动构建只需一个 Row + 两个 Button + Text,约 20 行代码。这种"基础组件组合"的模式在 ArkUI 开发中非常常见——理解基础组件的组合能力比寻找"一站式"组件更重要。

二、价格实时计算

2.1 响应式价格模型

价格由三个因素决定,任何一个变化都会触发总价重算:

总价 = 目的地基础价 × 人数 + 保险费用(¥50/人, 可选)

三个因素分别绑定到三个 @State 变量:

  • activeDestgetBasePrice() → 基础单价
  • travelers → 人数乘数
  • insurancegetInsureFee() → 保险附加费

每当用户切换目的地、调整人数或开关保险时,对应的 @State 变量更新,触发框架的重渲染,价格自动刷新——无需手动调用"计算价格"函数。

2.2 费用明细展示

价格不只是显示一个总数字,而是展示费用构成的明细:

// 基础费用行
Row() {
  Text('基础费用')  // 费用名称
  Blank()
  Text('¥1299 × 2人')  // 单价 × 人数
}

// 旅行保险行(仅当开启时显示)
if (this.insurance) {
  Row() {
    Text('旅行保险')
    Blank()
    Text('¥100')  // 50/人 × 2人
  }
}

// 分隔线
Row().width('100%').height(1).backgroundColor('#F2F3F5')

// 合计行
Row() {
  Text('合计')
  Blank()
  Text('¥2698')  // 大号红色总价
}

费用明细不只是"好看"——它帮助用户理解"为什么是这个价格",减少下单顾虑。保险费用仅在开启时显示(条件渲染),未开启时不占用空间,避免"0 元保险"这种无意义的信息。
在这里插入图片描述

三、表单布局与信息架构

3.1 表单区块化

长表单如果不分区块,用户会感到"一大片要填的东西"。将表单按语义分成独立的卡片式区块,每个区块承载一组相关的字段:

  1. 目的地(Chip 选择器)——“去哪”
  2. 出行日期(单选列表)——“什么时候”
  3. 出行人数(步进器)——“几个人”
  4. 联系人信息(TextInput × 2)——“谁去”
  5. 旅行保险(Toggle)——“额外保障”
  6. 费用明细(动态计算)——“花多少钱”
  7. 提交按钮(操作入口)

每个区块是独立的白色圆角卡片(backgroundColor('#FFFFFF') + borderRadius(LG) + margin(SM)),卡片之间用页面背景色(#F2F3F5)分隔。这种"分块"设计让用户在每一屏只需关注一个信息组,心理负担远小于一整面墙的表单。

3.2 区块内部布局

每个区块的内部结构统一:

区块标题(小号灰色文字)
表单字段内容

区块标题使用 fontSize(13) + fontColor('#9999AA') + fontWeight(Medium)——足够醒目以示分组,但不喧宾夺主。标题之后是实际的表单控件,两者之间通过 margin({ bottom: 8 }) 分隔。

统一的区块结构形成了视觉节奏——用户浏览时自然感知到"这里是新的一组",形成"扫视→填写→下一组"的流畅操作流。
在这里插入图片描述

四、表单验证

4.1 验证规则

简洁的验证规则,覆盖核心字段:

  • 姓名:不能为空(trim().length === 0 → 错误)
  • 手机号:必须为 11 位(trim().length !== 11 → 错误)

这两个规则覆盖了 80% 的表单提交问题。在实际产品中,还可以增加手机号格式验证(正则匹配)、姓名长度限制等,但本文聚焦验证模式本身。

4.2 验证函数

validate(): boolean {
  let errs: string[] = [];
  if (this.personName.trim().length === 0) {
    errs.push('请输入联系人姓名');
  }
  if (this.phone.trim().length !== 11) {
    errs.push('请输入正确的11位手机号');
  }
  this.errors = errs;
  return errs.length === 0;
}

返回布尔值表示"是否全部通过",同时将错误信息存入 this.errors 数组。调用方通过返回值决定后续流程——通过则展示成功页,不通过则显示错误列表。

4.3 错误展示

验证失败时,在提交按钮上方显示红色错误卡片:

if (this.errors.length > 0) {
  Column() {
    ForEach(this.errors, (e: string) => {
      Row() {
        Text('⚠').fontSize(13).margin({ right: 6 })
        Text(e).fontSize(13).fontColor('#FF4D4F')
      }
    })
  }
  .width('100%')
  .padding(Spacing.LG)
  .backgroundColor('#FFF1F0')    // 浅红背景
  .borderRadius(BorderRadius.LG)
}

浅红色背景(#FFF1F0)是"错误/警告"的通用色彩编码,白色文字用红色(#FF4D4F),配合 ⚠ 图标——三个视觉信号一致指向"有问题需要修正"。错误卡片出现在提交按钮上方,用户的视线自然从按钮向上移动到错误信息,符合"操作→反馈→修正"的直觉路径。

多个错误同时显示,用户可以一次性看到所有需要修正的字段,避免"改一个→再提交→又报另一个错"的挫败循环。

五、提交与成功反馈

5.1 提交流程

点击"提交预订"按钮 → handleSubmit()validate() → 两个分支:

  • 验证失败 → 填充 errors 数组 → 显示错误卡片
  • 验证通过 → 设置 submitted = true → 显示成功弹层

这是一个经典的"乐观提交"流程——前端验证通过后立即展示成功,不等待后端响应。对于 Demo 级别来说完全够用,实际产品中应在成功弹层前增加 Loading 状态并等待 API 响应。

5.2 成功弹层

成功弹层使用半透明遮罩 + 底部弹出的白色卡片:

if (this.submitted) {
  Column() {
    Column() {
      Text('🎉').fontSize(56).margin({ bottom: 12 })
      Text('预订成功!').fontSize(20).fontColor('#1a1a2e').fontWeight(FontWeight.Bold)
      Text('北京 · 8月15日-8月18日 · 2人')
        .fontSize(14).fontColor('#666677').margin({ bottom: 20 })
      Text('¥2698').fontSize(32).fontColor('#FF4D4F').fontWeight(FontWeight.Bold)
      Text('返回修改')
        .onClick(() => { this.handleReset(); })
    }
    .width('100%').padding({ top: 40, bottom: 40 })
    .backgroundColor('#FFFFFF').borderRadius({ topLeft: 20, topRight: 20 })
  }
  .width('100%').height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor('#00000055')
  .position({ x: 0, y: 0 })
}

弹层结构:

  • 外层 Column:全屏覆盖,#00000055 半透明黑色遮罩,justifyContent(FlexAlign.End) 内容靠底,position({ x: 0, y: 0 }) 绝对定位覆盖整个页面
  • 内层 Column:白色卡片,顶部圆角,包含成功信息

成功信息展示:

  • 情感图标:🎉(庆祝/完成感)
  • 确认标题:“预订成功!”(明确告知操作结果)
  • 订单摘要:目的地 + 日期 + 人数(确认具体信息)
  • 金额强调:大号红色总价($$$ 确认)
  • 返回入口:"返回修改"按钮(提供修正入口)

"返回修改"按钮使用边框样式(border({ width: 1.5, color: '#1677FF' }))而非实心样式——这是一个次级操作(通常不需要修改),视觉权重低于"提交预订"的主操作按钮。

5.3 重置流程

点击"返回修改"调用 handleReset()

handleReset(): void {
  this.submitted = false;
  this.errors = [];
}

两个状态回退:submitted = false 隐藏成功弹层回到表单,errors = [] 清空之前的验证错误。表单字段的值保持不变(用户不需要重新填写),可以直接修改后再次提交。

六、完整交互流程

6.1 初始状态

页面加载后展示预订表单。默认选中"北京"(第一个目的地)、“8月15日-8月18日”(第一个日期)、1 人、未开启保险。费用明细显示:基础费用 ¥1299 × 1人 = ¥1299,合计 ¥1299。

6.2 选择目的地

点击"成都" Chip,当前选中变为蓝色,之前选中的"北京"恢复灰色。费用明细实时更新:基础费用 ¥899 × 1人 = ¥899。

目的地 Chip 的点击响应是即时的——视觉切换 + 价格更新在同一帧内完成。用户能直观感受到"选择 = 生效",不需要额外的"确认"按钮。

6.3 调整人数

连续点击"+“按钮 3 次,人数从 1 变为 4。费用明细同步更新:基础费用 ¥899 × 4人 = ¥3596。人数达到 10 时”+"按钮置灰禁用,人数为 1 时"−"按钮置灰。

6.4 开启保险

打开保险 Toggle,费用明细新增一行"旅行保险 ¥200"(50/人 × 4人),合计变为 ¥3796。关闭 Toggle,保险行消失,合计恢复 ¥3596。

6.5 表单验证

  • 留空姓名直接提交 → 红色错误卡片出现:“请输入联系人姓名”
  • 输入 10 位手机号提交 → 新增错误:“请输入正确的11位手机号”
  • 修正所有错误 → 错误卡片消失,成功弹层出现

6.6 成功提交

填写完整信息后提交 → 半透明遮罩覆盖页面 → 底部白色卡片滑入 → 显示预订详情和金额。点击"返回修改"回到表单。

七、表单设计原则

7.1 标签对齐

本文采用顶部标签(Top-aligned Label)——标签在输入框上方,左对齐:

联系人信息           ← 区块标题
姓名                 ← 字段标签(在输入框上方)
[请输入联系人姓名]    ← 输入框
手机
[请输入11位手机号]

顶部对齐的优势:

  • 标签和输入框的视觉关联最强(距离最近)
  • 标签长度不受限制(不像左侧标签需要固定宽度)
  • 移动端最常用,符合用户阅读习惯(从上到下)

左侧标签的替代方案(标签在左,输入框在右)在移动端有宽度限制的劣势——长标签会被截断或折行。但对于短标签(如"姓名"、“手机”),左侧布局可以节省垂直空间,适合"紧凑型"表单。

7.2 输入框高度

输入框的视觉高度 = fontSize(14) + padding(top: 10, bottom: 10) ≈ 34vp,加上圆角和边框视觉上约 36-38vp。这个高度在"足够点击"和"不浪费空间"之间平衡——符合 Material Design 推荐的 48dp 最小触摸目标(行内还有其他元素分摊高度)。

7.3 必填与选填

本文 Demo 的字段全部为必填(提交时校验)。这是旅行预订场景的合理假设——目的地、日期、人数、联系方式缺一不可。如果实际产品中有选填字段,应该在标签旁标注"(选填)"或使用不同的视觉样式(如标签颜色更淡)。

7.4 提交按钮位置

提交按钮放在表单底部的 Scroll 内部,跟随表单内容一起滚动。这是"长表单"的常见做法——用户浏览完所有内容后自然看到提交按钮。如果表单很短(一屏以内),可以将按钮固定在页面底部(使用 position: fixed 等价效果),确保任何时候都可见。

7.5 价格锚定

总价以红色大号字体展示(fontSize(24) + fontColor('#FF4D4F')),放在提交按钮上方。这是电商页面的惯用模式——用户在点击"下单"前最后看到的是价格,形成"看到价格→确认→点击"的决策链。

八、进阶扩展方向

8.1 异步验证

对于需要服务端校验的字段(如手机号是否已注册、优惠券是否有效),在 onChange 中触发防抖的异步校验:

onChange((value: string) => {
  this.phone = value;
  this.debounceValidatePhone(value);  // 300ms 后发起 API 校验
})

8.2 表单草稿保存

使用 PersistentStorage 在 onChange 中自动保存表单数据。用户重新打开页面时恢复草稿——这在长表单场景中至关重要,避免意外退出导致已填写内容丢失。

8.3 分步表单

对于更复杂的预订流程(如"选航班→填信息→加保险→支付"),可以结合之前的"步骤引导(Stepper)"模式,将表单拆分为多步。每步聚焦一个决策,降低单页复杂度。

8.4 输入格式化

手机号输入时自动添加空格(如 138 0000 0000),提高可读性。在 TextInput 的 onChange 中实现格式化逻辑——这需要谨慎处理光标位置,避免格式化导致光标跳动。

8.5 键盘类型适配

为不同类型的输入配置对应的键盘:

  • 姓名:默认键盘(InputType.Normal
  • 手机号:数字键盘(InputType.PhoneNumber
  • 邮箱:邮箱键盘(InputType.Email

正确的键盘类型减少用户的键盘切换操作,提升输入效率。ArkUI 的 TextInput 支持这些类型,只需设置 type 属性即可。

九、总结

本文通过"旅行预订"这个实战案例,系统讲解了在 ArkUI 中构建专业表单的完整方法论。核心知识点包括:

  1. 表单组件选型:TextInput(文本输入)、Toggle(开关)、Chip 选择器(多选一)、自定义步进器(数量调整)
  2. 表单布局:区块化分卡、顶部标签对齐、统一的区块内部结构
  3. 价格实时计算@State 驱动的响应式价格模型 + 费用明细展示
  4. 表单验证:多规则校验 + 错误信息收集 + 红色错误卡片展示
  5. 提交流程:验证→分支(错误/成功)→ 成功弹层 → 重置返回
  6. 表单设计原则:标签对齐、触摸目标、必填/选填区分、按钮位置、价格锚定
  7. 组件组合模式:用基础组件组合出专业表单控件,而非依赖"一站式"组件

表单开发的核心挑战不在"用什么组件",而在"组件如何配合"——选项变化如何驱动价格更新、输入如何被校验、错误如何被展示、提交流程如何管理。理解了这些"配合关系",就能构建出任何复杂度的表单界面。


Logo

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

更多推荐