鸿蒙新特性:表单输入与验证——打造旅行预订系统

表单是移动应用中最基础也最关键的交互模式。从注册登录到订单提交,从设置页面到反馈表单——几乎每个应用都离不开表单。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{leterrs:string[]=[];if(this.personName.trim().length===0){errs.push('请输入联系人姓名');}if(this.phone.trim().length!==11){errs.push('请输入正确的11位手机号');}this.errors=errs;returnerrs.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. 组件组合模式:用基础组件组合出专业表单控件,而非依赖"一站式"组件

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