动态表单封面图

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

动态表单的核心数据结构

动态表单概念图

动态表单和静态表单最大的区别在于数据结构。静态表单用固定的@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这套组合拳,就是动态表单的技术基础。掌握了这个模式,不只是表单,任何需要动态列表的场景(购物车、待办事项、评论列表)都能用同样的思路来做。

下篇文章是这个系列的最后一篇——综合表单实战,把之前学过的所有技能(分组、多类型组件、验证、提交、重置)综合在一起,做一个企业级的综合表单。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐