HarmonyOS6 PC 动态表单——运行时增删表单项,ForEach用起来真香
文章目录

今天做的这个动态表单是我觉得最好玩的一种表单形态。和之前所有文章里的表单不同——之前的表单字段是固定的,页面渲染出来是几个输入框就是几个。动态表单允许用户在运行时添加新的输入字段或删除已有的字段。这个功能在很多场景下都用得上:添加多个紧急联系人、录入多条工作经历、填写动态数量的技能标签等。
动态表单的核心数据结构

动态表单和静态表单最大的区别在于数据结构。静态表单用固定的@State变量存储每个字段的值(比如@State name: string、@State email: string)。动态表单字段数量不确定,所以不能用这种方式。
解决方案是用一个@State数组来存储所有字段。每个字段是一个对象,包含id(唯一标识)和value(字段值)。添加字段就是往数组里push一个新对象,删除字段就是从数组里移除对应id的对象。UI层面用ForEach遍历这个数组来渲染输入框。
这个方案的关键在于@State数组的响应式——数组发生变化(增删元素)时,ForEach会自动重新渲染,新增的输入框会出现在界面上,删除的输入框会消失。完全不需要手动操作UI。
ForEach的正确用法
ForEach是ArkTS里专门用来渲染列表的组件。它的第一个参数是数据数组,第二个参数是渲染函数(每个数组元素渲染什么),第三个参数是key生成函数(用于性能优化)。
写ForEach时最容易踩的坑是key的问题。如果不提供key生成函数或者key不唯一,ForEach在数组变化时可能会复用错误的组件,导致状态混乱。正确的做法是用数组元素的唯一id作为key,确保每个渲染项都有独一无二的标识。
另一个容易忽视的点是ForEach里闭包的问题。渲染函数里引用数组元素时,要通过索引来访问原数组,而不是捕获ForEach回调参数里的值。这是因为回调参数可能是值的拷贝,修改它不会影响原数组。
完整案例代码
下面是一个完整的动态表单示例。用户可以添加多个输入字段,每个字段有独立的删除按钮。最终提交时汇总所有字段的值。
import { promptAction } from '@kit.ArkUI'
interface FieldItem {
id: number
label: string
value: string
}
@Entry
@Component
struct DynamicFormDemo {
@State fields: FieldItem[] = [
{ id: 1, label: '字段 1', value: '' } as FieldItem,
{ id: 2, label: '字段 2', value: '' } as FieldItem
]
@State nextId: number = 3
@State newFieldLabel: string = ''
// 添加新字段
addField(): void {
if (this.fields.length >= 10) {
promptAction.showToast({ message: '最多添加10个字段', duration: 2000 })
return
}
const label: string = this.newFieldLabel.trim().length > 0
? this.newFieldLabel.trim()
: `字段 ${this.nextId}`
const newField: FieldItem = {
id: this.nextId,
label: label,
value: ''
} as FieldItem
this.fields = [...this.fields, newField]
this.nextId++
this.newFieldLabel = ''
promptAction.showToast({ message: `已添加: ${label}`, duration: 1500 })
}
// 删除字段
removeField(id: number): void {
if (this.fields.length <= 1) {
promptAction.showToast({ message: '至少保留一个字段', duration: 2000 })
return
}
const fieldToRemove: FieldItem | undefined = this.fields.find(
(f: FieldItem) => f.id === id
)
this.fields = this.fields.filter((f: FieldItem) => f.id !== id)
if (fieldToRemove) {
promptAction.showToast({
message: `已删除: ${fieldToRemove.label}`,
duration: 1500
})
}
}
// 更新字段值
updateFieldValue(id: number, newValue: string): void {
this.fields = this.fields.map((f: FieldItem) => {
if (f.id === id) {
return { id: f.id, label: f.label, value: newValue } as FieldItem
}
return f
})
}
// 提交汇总
submitAll(): void {
const emptyFields: FieldItem[] = this.fields.filter(
(f: FieldItem) => f.value.trim().length === 0
)
if (emptyFields.length > 0) {
const names: string = emptyFields.map(
(f: FieldItem) => f.label
).join('、')
promptAction.showDialog({
title: '提示',
message: `以下字段尚未填写:${names}\n\n确定要提交吗?`,
buttons: [
{ text: '取消', color: '#666666' },
{ text: '继续提交', color: '#4A90D9' }
]
}).then((res) => {
// 下标1 = 继续提交
if (res.index === 1) {
this.showResult()
}
})
} else {
this.showResult()
}
}
showResult(): void {
const lines: string[] = this.fields.map(
(f: FieldItem) => `${f.label}: ${f.value || '(空)'}`
)
const result: string = `共 ${this.fields.length} 个字段\n\n${lines.join('\n')}`
promptAction.showDialog({
title: '提交结果',
message: result,
buttons: [{ text: '好的', color: '#4A90D9' }]
})
}
// 清空所有
clearAll(): void {
AlertDialog.show({
title: '清空确认',
message: '确定要清空所有字段吗?',
primaryButton: {
value: '取消',
fontColor: '#666666',
action: () => {}
},
secondaryButton: {
value: '清空',
fontColor: '#FF4444',
action: () => {
this.fields = this.fields.map((f: FieldItem) => {
return { id: f.id, label: f.label, value: '' } as FieldItem
})
promptAction.showToast({ message: '已清空所有字段值', duration: 1500 })
}
}
})
}
build() {
Column() {
Scroll() {
Column({ space: 0 }) {
// 标题区
Column({ space: 6 }) {
Text('动态表单')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#222222')
Text(`当前 ${this.fields.length} 个字段 · 运行时自由增删`)
.fontSize(14)
.fontColor('#888888')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ bottom: 20 })
// 添加字段区域
Column({ space: 10 }) {
Text('添加新字段')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.alignSelf(ItemAlign.Start)
Row({ space: 10 }) {
TextInput({ text: this.newFieldLabel, placeholder: '输入字段名称(可选)' })
.layoutWeight(1)
.height(42)
.fontSize(14)
.borderRadius(8)
.border({ width: 1, color: '#DDDDDD' })
.backgroundColor('#FFFFFF')
.onChange((v: string) => {
this.newFieldLabel = v
})
Button('+ 添加')
.height(42)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#4A90D9')
.borderRadius(8)
.onClick(() => {
this.addField()
})
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor('#F8F9FA')
.borderRadius(12)
.margin({ bottom: 16 })
// 动态字段列表
Column({ space: 12 }) {
ForEach(this.fields, (field: FieldItem, index: number) => {
Column({ space: 6 }) {
Row() {
Text(field.label)
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
Blank()
// 序号标记
Text(`#${index + 1}`)
.fontSize(11)
.fontColor('#BBBBBB')
.margin({ right: 8 })
// 删除按钮
Text('删除')
.fontSize(13)
.fontColor(this.fields.length <= 1 ? '#CCCCCC' : '#FF4444')
.onClick(() => {
this.removeField(field.id)
})
}
.width('100%')
TextInput({ text: field.value, placeholder: `请输入${field.label}的值` })
.width('100%')
.height(44)
.fontSize(15)
.borderRadius(8)
.border({ width: 1, color: '#DDDDDD' })
.backgroundColor('#FFFFFF')
.onChange((v: string) => {
this.updateFieldValue(field.id, v)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(10)
}, (field: FieldItem) => field.id.toString())
}
.width('100%')
// 统计信息
Row() {
Text(`已填写: ${this.fields.filter((f: FieldItem) => f.value.trim().length > 0).length}/${this.fields.length}`)
.fontSize(13)
.fontColor('#888888')
Blank()
Text('清空所有值')
.fontSize(13)
.fontColor('#FF9800')
.onClick(() => {
this.clearAll()
})
}
.width('100%')
.margin({ top: 12 })
// 操作按钮
Row({ space: 12 }) {
Button('重置为默认')
.layoutWeight(1)
.height(48)
.fontSize(15)
.fontColor('#666666')
.backgroundColor('#F0F0F0')
.borderRadius(8)
.onClick(() => {
AlertDialog.show({
title: '重置确认',
message: '将恢复为初始的两个字段,当前数据会丢失。',
primaryButton: {
value: '取消',
fontColor: '#666666',
action: () => {}
},
secondaryButton: {
value: '重置',
fontColor: '#FF4444',
action: () => {
this.fields = [
{ id: 1, label: '字段 1', value: '' } as FieldItem,
{ id: 2, label: '字段 2', value: '' } as FieldItem
]
this.nextId = 3
promptAction.showToast({
message: '已重置为默认',
duration: 1500
})
}
}
})
})
Button('提交汇总')
.layoutWeight(1)
.height(48)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.backgroundColor('#4A90D9')
.borderRadius(8)
.onClick(() => {
this.submitAll()
})
}
.width('100%')
.margin({ top: 20 })
}
.width('100%')
.constraintSize({ maxWidth: 520 })
.padding(24)
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F2F3F5')
}
}
运行效果如图:
代码解析——数组驱动的动态渲染
这段代码最核心的设计是用@State fields: FieldItem[]数组驱动整个表单的渲染。FieldItem接口定义了每个字段的三个属性:id(唯一数字标识)、label(字段名)和value(用户输入的值)。
添加字段的逻辑在addField方法里。先检查是否超过上限(10个),然后创建一个新的FieldItem对象,用展开运算符[...this.fields, newField]生成新数组。注意这里不能直接this.fields.push(newField)——在ArkTS里,直接修改@State数组的内容不会触发UI更新,必须重新赋值整个数组。
删除字段的removeField方法用filter过滤掉指定id的元素,生成新数组再赋值。同样不能直接splice,要用不可变的方式生成新数组。
更新字段值的updateFieldValue方法用map遍历数组,找到匹配id的元素就更新value,其他元素保持不变。这个方法在每次TextInput的onChange时都会调用,所以要保证性能——对于10个以内的字段完全没问题。
ForEach的key为什么重要
代码里ForEach的第三个参数是(field: FieldItem) => field.id.toString(),用字段的id作为唯一key。这个key告诉ForEach哪些元素是"同一个",在数组变化时可以正确地复用或销毁组件。
如果没有key或者key不唯一,ForEach可能会错误地复用组件。比如你删除了第一个字段,ForEach可能把第二个字段的输入框直接"搬"到第一个位置,导致输入框里还显示着旧值。有了正确的key,ForEach就知道第一个位置的组件要销毁,第二个位置的组件保持不变。
这个问题在元素少的情况下可能看不出来,但在频繁增删或者字段比较多的时候就会暴露。所以从一开始就养成提供唯一key的习惯,后面能省很多调试时间。
空字段检测与友好提示
提交时的submitAll方法做了一个空字段检测。先用filter找出所有value为空的字段,如果有的话弹出一个确认对话框,告诉用户哪些字段没填,让用户决定是否继续提交。
这种处理方式比直接拦截提交更友好——有些场景下某些字段确实可以留空(比如"备注"),强制要求全部填写反而影响体验。给用户一个选择权,同时提供足够的信息让用户做决定。
PC端适配要点
动态表单在PC端可以做一些更酷的交互。首先是拖拽排序——用户可以拖动字段卡片来调整顺序。在HarmonyOS的PC端,配合鼠标事件可以实现拖拽功能,让表单更灵活。
其次是字段类型的选择。添加字段时除了输入名称,还可以让用户选择字段类型(文本、数字、邮箱、日期等),不同类型的字段渲染不同的输入控件。这就需要扩展FieldItem接口,加一个type属性。
另外PC端可以用网格布局来展示动态字段。手机端每行一个字段没问题,但PC端屏幕宽,可以每行放两到三个字段,用Grid或FlexWrap布局。这样字段很多时页面不会太长。
批量操作在PC端也更方便。可以加"全选"、“批量删除”、"批量清空"等功能,用Checkbox配合Shift键做多选。这些操作在PC端的鼠标+键盘交互下非常自然。
动态表单的进阶玩法
这篇文章实现的是最基础的动态表单——每个字段的类型一样(都是文本输入)。实际项目中可以做得更灵活。
比如字段类型多样化。添加字段时让用户选择类型:文本输入、数字输入、日期选择、下拉选择等。每种类型渲染不同的输入组件。这就需要@Builder配合switch/case来根据类型渲染不同的UI。
又比如字段顺序可调。加一个上移/下移按钮,或者直接支持拖拽排序。调整顺序就是交换数组中两个元素的位置,然后用新数组重新赋值给@State。
还可以做字段模板。预定义几组常用的字段组合(比如"姓名+电话+地址"作为联系人模板),用户一键导入模板,不用一个一个添加。这在表单字段较多的场景下非常实用。
写在最后
动态表单是表单开发里比较进阶的话题,核心是数组驱动的UI渲染。@State数组+ForEach+唯一key这套组合拳,就是动态表单的技术基础。掌握了这个模式,不只是表单,任何需要动态列表的场景(购物车、待办事项、评论列表)都能用同样的思路来做。
下篇文章是这个系列的最后一篇——综合表单实战,把之前学过的所有技能(分组、多类型组件、验证、提交、重置)综合在一起,做一个企业级的综合表单。
更多推荐

所有评论(0)