鸿蒙原生应用 ArkTS 表单工程:书库创建页的类型选择与动态提交按钮

App 20「阅读管理」书库 Tab(Func1Tab),是添加书籍的表单页。整页由 6 个 @Builder 块拼出 Header + 基本信息 + 类型选择 + 优先级选择 + 设置 + 快捷模板 + 提交按钮(动态文案)。与 App 13/17 创建表单几乎完全同源——是"模板换肤适配新业务"的又一例证。本篇基于 20-reading-manager/entry/src/main/ets/pages/Func1Tab.ets(约 215 行)逐段拆解,附 4 张实机截图。

一、整体结构:6 个 @Builder 的表单漏斗

Func1Tab 的骨架与 App 13/17 创建页完全同源(Header + 6 个表单卡片 + 提交按钮):

build() {
  Column() {
    this.Header()
    Scroll() {
      Column({ space: 14 }) {
        this.FormCard()
        this.TypeCard()
        this.PriorityCard()
        this.SettingCard()
        this.TemplateCard()
        this.SubmitBtn()
        Blank().height(this.safeBottom + 20)
      }
      .width('100%').padding({ left: D.pad, right: D.pad, top: 6 })
    }
    .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
  }
  .width('100%').height('100%').backgroundColor(C.bg)
}

6 个块按"必填 → 必选 → 选填 → 操作"漏斗展开

  1. FormCard — 标题/描述/图片链接/字数统计(必填区
  2. TypeCard — 4 种类型图标网格(必选
  3. PriorityCard — 优先级三色(低/中/高)
  4. SettingCard — 提醒通知 Toggle + 隐私设置入口
  5. TemplateCard — 3 个快捷模板
  6. SubmitBtn — 提交按钮(动态文案 + 早返回校验

"Scroll 容器 + 固定 Header"——Header 不滚动(填到一半还能看到"创建"标题)。

阅读管理书库页首屏 · Header+基本信息+选择类型+优先级+提醒设置+快捷模板完整呈现

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

二、Header + FormCard:标题/描述/字数统计

Header 是"创建/填写信息快速创建"单行 + 右侧 📋 图标(与 App 13 同构)。FormCard 是标题 TextInput、描述 TextArea、图片/链接"伪按钮"、字数统计:

@Builder FormCard() {
  Column({ space: 12 }) {
    Row() {
      Text('基本信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text('✕').fontSize(16).fontColor(C.textDim)
    }.width('100%')

    Column({ space: 6 }) {
      Text('标题').fontSize(12).fontColor(C.textSub)
      TextInput({ placeholder: '请输入标题', text: this.inputTitle })
        .onChange((v: string) => { this.inputTitle = v; })
        .height(44).backgroundColor(C.cardSoft).borderRadius(D.rSm).placeholderColor(C.textDim)
    }.alignItems(HorizontalAlign.Start).width('100%')

    Column({ space: 6 }) {
      Text('描述').fontSize(12).fontColor(C.textSub)
      TextArea({ placeholder: '请输入详细描述...', text: this.inputDesc })
        .onChange((v: string) => { this.inputDesc = v; })
        .height(90).backgroundColor(C.cardSoft).borderRadius(D.rSm).placeholderColor(C.textDim)
    }.alignItems(HorizontalAlign.Start).width('100%')

    Row({ space: 8 }) {
      Text('📷 图片').fontSize(12).fontColor(C.textSub)
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm)
      Text('🔗 链接').fontSize(12).fontColor(C.textSub)
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm)
      Blank()
      Text(`${this.inputTitle.length}/50`).fontSize(10).fontColor(C.textDim)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

TextInput + TextArea + 字数统计三件套(与 App 13/17 完全同源):

  • TextInput 44vp 单行 + TextArea 90vp 多行(3-4 行)
  • onChange 双向绑定(text: this.inputTitle 受控 + 回调写回)
  • ${this.inputTitle.length}/50 字数统计——输入实时更新"0/50→1/50→2/50"

"50 字上限"的语义(在阅读 App 里)——书名标题要精简("三体"两个字),50 字足够任何书名的长度——"上限 = 业务约束"(App 13 音乐 50 字、App 20 书名 50 字同款)。

"📷 图片 / 🔗 链接"伪按钮——添加书的封面图/豆瓣链接(阅读 App 添加书常要填封面和链接)——Blank() 撑开把字数统计推到右侧。

三、TypeCard:4 种类型图标网格

TypeCard 是 4 种类型(📝/🎯/⭐/📊)的等宽图标网格,单选高亮:

@Builder TypeCard() {
  Column({ space: 12 }) {
    Text('选择类型').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    Row() {
      ForEach(this.types, (item: OptionItem, idx: number) => {
        Column({ space: 6 }) {
          Row() { Text(item.icon).fontSize(24) }
            .width(48).height(48).borderRadius(D.rMd).justifyContent(FlexAlign.Center)
            .backgroundColor(this.selectedType === idx ? C.primarySoft : C.cardSoft)
            .border({ width: this.selectedType === idx ? 2 : 0, color: C.primary })
          Text(item.name).fontSize(10)
            .fontColor(this.selectedType === idx ? C.primary : C.textDim)
            .fontWeight(this.selectedType === idx ? FontWeight.Bold : FontWeight.Normal)
        }.layoutWeight(1)
        .onClick(() => { this.selectedType = idx; })
      }, (item: OptionItem) => item.name)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

选中态三件套(背景/边框/文字)——本系列标准做法:

  • 背景:C.primarySoft 浅紫 vs C.cardSoft 浅灰
  • 边框:2vp 主色(紫)vs 无边框
  • 文字:主色加粗 vs 灰色常规

@State selectedType: number = 0 默认选中"类型一"——onClick(() => { this.selectedType = idx; }) 更新。

"类型一/二/三/四"的通用命名(App 13/17 同款)——"类型名没换"是模板换肤的遗留(真实阅读 App 应换成"文学/科幻/历史/心理"等真实分类)——"通用命名 vs 业务命名"的取舍(demo 用通用名省事,读者做产品应换业务名)。

四、PriorityCard:三色优先级

PriorityCard 是 3 档优先级(低/中/高),绿-橙-红风险色阶

@Builder PriorityCard() {
  Column({ space: 12 }) {
    Row() {
      Text('优先级').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text(this.priorities[this.selectedPriority]).fontSize(13).fontColor(C.primary).fontWeight(FontWeight.Bold)
    }.width('100%')
    Row({ space: 6 }) {
      ForEach(this.priorities, (p: string, idx: number) => {
        Text(p).fontSize(13)
          .fontColor(this.selectedPriority === idx ? '#FFFFFF' : C.textSub)
          .backgroundColor(this.selectedPriority === idx ? (idx === 2 ? C.danger : idx === 1 ? C.warn : C.ok) : C.cardSoft)
          .borderRadius(D.rSm).padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .onClick(() => { this.selectedPriority = idx; })
      }, (p: string) => p)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

三色三元 idx === 2 ? C.danger : idx === 1 ? C.warn : C.ok

  • 低 → 绿(C.ok)、中 → 橙(C.warn)、高 → 红(C.danger

"绿-黄-红"国际通用风险色阶(信号灯语义)。优先级在阅读 App 里的语义——"这本书多急着读"(高优先级 = 马上要读,如课程指定书)。

右上角实时显示 priorities[selectedPriority](默认"中" idx=1)——@State selectedPriority 变化同步。

五、SettingCard:Toggle + 隐私入口

SettingCard 是"提醒通知"开关(Toggle)+ "隐私设置"入口( 箭头):

@Builder SettingCard() {
  Column({ space: 0 }) {
    Row({ space: 12 }) {
      Row() { Text('🔔').fontSize(16) }
        .width(32).height(32).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
      Column({ space: 2 }) {
        Text('提醒通知').fontSize(14).fontColor(C.text)
        Text('开启后将推送提醒').fontSize(11).fontColor(C.textDim)
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      Toggle({ type: ToggleType.Switch, isOn: this.remindOn })
        .selectedColor(C.primary)
        .onChange((on: boolean) => { this.remindOn = on; })
    }.width('100%').padding({ top: 12, bottom: 12 })
    Divider().color(C.stroke)
    Row({ space: 12 }) {
      Row() { Text('🔒').fontSize(16) }
        .width(32).height(32).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
      Column({ space: 2 }) {
        Text('隐私设置').fontSize(14).fontColor(C.text)
        Text('仅自己可见').fontSize(11).fontColor(C.textDim)
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      Text('›').fontSize(22).fontColor(C.textDim)
    }.width('100%').padding({ top: 12, bottom: 12 })
  }
  .width('100%').padding({ left: 14, right: 14 }).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

Toggle 标准用法

  • type: ToggleType.Switch iOS 风格开关
  • isOn: this.remindOn(默认 true,默认开启提醒
  • selectedColor(C.primary) 紫色开启色
  • onChange 双向绑定

"提醒通知"在阅读 App 里的语义——"阅读打卡提醒"(每天 20:00 提醒读书)——"提醒 = 习惯养成"(阅读 App 的核心价值是帮用户养成阅读习惯)。

Divider() 分割线 + Column({ space: 0 })(分割线本身提供间距)。 箭头表示二级页面(隐私设置入口)。

六、TemplateCard + SubmitBtn

TemplateCard 是 3 个快捷模板(快速创建/高级模式/批量导入)列表(与 App 13/17 同源):

@Builder TemplateCard() {
  Column({ space: 10 }) {
    Text('快捷模板').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    ForEach(this.templates, (item: QuickTemplate) => {
      Row({ space: 10 }) {
        Row() { Text('📋').fontSize(18) }
          .width(32).height(32).backgroundColor(C.primarySoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
        Column({ space: 2 }) {
          Text(item.title).fontSize(13).fontWeight(FontWeight.Medium).fontColor(C.text)
          Text(item.desc).fontSize(10).fontColor(C.textDim)
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('›').fontSize(18).fontColor(C.textDim)
      }.width('100%')
      .onClick(() => { promptAction.showToast({ message: item.title }); })
    }, (item: QuickTemplate) => item.title)
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

SubmitBtn 是动态文案 + 早返回校验

@Builder SubmitBtn() {
  Column({ space: 8 }) {
    Button(this.inputTitle.length > 0 ? '✓ 提交创建' : '请填写标题')
      .width('100%').height(48)
      .backgroundColor(this.inputTitle.length > 0 ? C.primary : C.cardSoft).fontColor('#FFFFFF')
      .fontSize(16).fontWeight(FontWeight.Bold).borderRadius(D.rMd)
      .onClick(() => {
        if (this.inputTitle.length === 0) { promptAction.showToast({ message: '请输入标题' }); return; }
        promptAction.showToast({ message: '创建成功!' });
        this.inputTitle = ''; this.inputDesc = '';
      })
    Text('提交即表示同意相关条款').fontSize(9).fontColor(C.textDim).width('100%').textAlign(TextAlign.Center)
  }.width('100%')
}

两态按钮

  • 标题为空 → "请填写标题" + 浅灰底 + Toast 拦截
  • 标题填写 → "✓ 提交创建" + 紫底白字 + Toast "创建成功!" + 清空两个输入

早返回校验if (!title) return)——比 if-else 嵌套更扁平。成功清空inputTitle = ''; inputDesc = '')——提交后可立即再添加。

注意:"提交即表示同意相关条款"在"添加书"场景有点违和(看书 App 加书不需要同意条款)——"模板复刻的文案遗留"(App 13 的音乐创建表单文案没换)——**"业务文案要随业务换"**是模板复刻必须注意的点(真实阅读 App 应改成"添加后将同步到书架"之类)。

七、@State 的 5 状态映射

Func1Tab 有 5 个 @State:

@State类型用途联动
inputTitlestring标题输入按钮文案/颜色/字数统计
inputDescstring描述输入
selectedTypenumber类型选中(0-3)TypeCard 高亮
selectedPrioritynumber优先级选中(0-2)PriorityCard 高亮 + 文字
remindOnboolean提醒开关Toggle

inputTitle 是"最忙"的状态(同时驱动按钮文案/颜色/字数统计 3 处)——"一处状态、三处渲染"

5 个 @State = 5 个交互点——"@State 数量 = 交互点数"规律(App 13/17/20 表单页都是 5 个,高度稳定)。

八、"创建表单"模板的系列复用史

App 20 书库页与 App 13 歌曲创建、App 17 活动创建几乎 100% 同源——回顾系列 3 个"创建表单":

App主题业务@State提交校验
13 音乐紫 #8B5CF6添加歌曲5标题非空
17 志愿红 #EF4444创建活动5标题非空
20 阅读紫 #A855F7添加书籍5标题非空

3 个创建表单的差异只有"主题色 + 图标 + 文案"——"创建表单模板"已稳定复用 3 次——"FormCard/TypeCard/PriorityCard/SettingCard/TemplateCard/SubmitBtn 六件套"是 ArkUI 创建类页面的标准骨架

"创建表单六件套"可复用到:添加歌曲、创建活动、添加书籍、新增订单、发布帖子、新建任务——任何"填信息 + 选类型 + 设优先级 + 开关设置 + 模板 + 提交"的场景

阅读管理书库页底部 · 完整表单+提交按钮+底部Tab栏

九、表单校验的"边界防护"进阶

App 20 SubmitBtn 只做了"标题非空"单校验(if (this.inputTitle.length === 0)),真实项目应加"边界防护":

1. 空串校验(当前有):inputTitle.length === 0 → 拦截

2. 纯空格校验(应补):

if (this.inputTitle.trim().length === 0) {
  promptAction.showToast({ message: '标题不能为空白' });
  return;
}

trim() 去掉首尾空格——用户输入" "(纯空格)时 .length 是 3 但 .trim().length 是 0——**"trim 校验防纯空格"**是表单必备。

3. 长度上限(应补):

if (this.inputTitle.length > 50) {
  promptAction.showToast({ message: '标题最多 50 字' });
  return;
}

4. 字数统计联动${this.inputTitle.length}/50 达到 50 时输入框变红(C.danger)提示——**"视觉预警"**比"事后拦截"更友好。

"边界防护 = 空串 + 纯空格 + 长度 + 格式"四件套——demo 只做了空串,真实项目要补全(尤其是 trim 防空格——"空格绕过非空校验"是表单最常见的漏洞)。

十、"六件套"表单模板的组件化建议

App 20 书库页的"表单六件套"(FormCard/TypeCard/PriorityCard/SettingCard/TemplateCard/SubmitBtn)是高度复用的模板,建议拆成独立组件

方案一:@Builder 参数化(当前做法)——适合"单页使用",6 个 @Builder 在 Func1Tab 内。

方案二:独立 @Component(推荐真实项目)——把 FormCard/TypeCard 抽成 components/ 下的独立组件:

@Component
export struct TypeSelector {
  @Prop selectedType: number;
  @Prop options: OptionItem[];
  onSelect: (idx: number) => void; // 回调
  ...
}

"抽组件"的 3 个好处

  1. 跨页复用——书籍创建页、笔记创建页、书签创建页都能用 TypeSelector
  2. 独立测试——每个表单组件可单独 Preview 调试
  3. 协作分工——A 写 FormCard、B 写 TypeCard,互不干扰

"六件套"抽组件后,任何"创建类页面"只需拼装:

build() {
  Column() {
    FormCard(...)
    TypeSelector(...)
    PrioritySelector(...)
    SettingToggle(...)
    TemplatePicker(...)
    SubmitButton(...)
  }
}

"表单组件化"是 ArkUI 工程化的重要一步——App 20 的六件套是现成的拆分清单。

阅读管理书库页下部 · 快捷模板3个+提交按钮+安全区留白

十一、"模板复刻"的正确姿势

App 20 书库页与 App 13/17 创建页的同源,展示了一个关键方法论——"模板复刻"的正确姿势

该复刻的(代码结构):

  • 6 个 @Builder 的布局骨架
  • @State 状态管理(5 个)
  • TextInput/TextArea/Toggle/ForEach 交互
  • 主题色变量引用(C.primary/C.cardSoft)

该换的(业务内容):

  • 占位文案("请输入标题" → "请输入书名")
  • 类型名("类型一/二" → "文学/科幻/历史")
  • 提交成功文案("创建成功" → "已添加到书架")
  • 条款文案("同意相关条款" → "添加后将同步书架")

**"复刻骨架、换业务"**是模板复刻的正确打开方式——App 13/17/20 的书库页复刻了骨架但没换业务文案("类型一"仍是通用名)——读者应把"该换的"清单记下来:占位/类型/成功文案/条款/数据全换。

阅读管理书库页中段 · 提醒通知+隐私设置+快捷模板完整+提交按钮

十二、阅读场景下的"表单语义"再设计

App 20 书库页虽是模板复刻,但**"添加书"场景下的表单语义**值得重新设计(读者可对比 App 13 音乐场景理解"同一表单不同语义"):

FormCard(基本信息)

  • 音乐场景:标题 = 歌曲名、描述 = 歌曲介绍
  • 阅读场景:标题 = 书名、描述 = 书籍简介——"输入框语义随业务"(占位符应改"请输入书名")

TypeCard(类型)

  • 音乐场景:类型一/二/三/四(流派占位)
  • 阅读场景:应改为"文学/科幻/历史/心理学"(真实分类)——"类型选项 = 业务分类体系"

PriorityCard(优先级)

  • 音乐场景:歌曲重要程度
  • 阅读场景:"这本书多急着读"(如"高" = 课程指定必读)——"优先级语义可复用"(急迫程度通用)

SettingCard(设置)

  • 音乐场景:提醒通知(新歌提醒)
  • 阅读场景:阅读提醒(每天 20:00 提醒读书)——"提醒 = 习惯养成"——阅读 App 的提醒比音乐更有价值(读书需要自律,提醒帮用户坚持)

TemplateCard(模板)

  • 音乐场景:快速创建/高级模式/批量导入
  • 阅读场景:"从豆瓣导入""ISBN 扫码添加""书单模板"——"模板 = 业务快捷方式"

SubmitBtn(提交)

  • 音乐场景:创建成功
  • 阅读场景:"已添加到书架"——"成功反馈 = 业务动作确认"

"同一表单六件套,业务语义全换"——这是"表单模板复用"的最高级姿势:骨架不变、语义全换,就是完全不同的产品。

十三、总结

App 20 书库创建页解析完毕。与 App 13/17 的"模板复刻"展示了创建表单模板的稳定复用——"表单六件套"是 ArkUI 创建类页面的标准骨架。同时指出"类型名/条款文案"等模板复刻的遗留问题(读者应随业务替换)。

配图

Logo

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

更多推荐