鸿蒙原生应用 ArkTS 表单工程:志愿活动创建页的优先级三色与动态按钮

App 17「志愿服务」活动 Tab(Func1Tab),是活动创建的表单页。整页由 7 个 @Builder 块拼出 Header + 基本信息 + 类型选择 + 优先级选择 + 提醒设置 + 快捷模板 + 提交按钮(动态文案),是 ArkUI 声明式表单的"模板范本"。与 App 13 歌曲创建页几乎同源——是"模板换肤适配新业务"的最佳示例。本篇基于 17-volunteer/entry/src/main/ets/pages/Func1Tab.ets(约 215 行)逐段拆解,附 4 张实机截图。

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

Func1Tab 的骨架与 App 13 歌曲创建页几乎完全相同——"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:固定标题 + 右侧图标

Header 单行结构(与 App 13 同构),是"页面身份"标识:

@Builder
Header() {
  Row({ space: 12 }) {
    Column({ space: 2 }) {
      Text('创建').fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text)
      Text('填写信息快速创建').fontSize(10).fontColor(C.textDim)
    }.alignItems(HorizontalAlign.Start)
    Blank()
    Row() { Text('📋').fontSize(18) }
      .width(36).height(36).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
  }
  .width('100%').height(this.safeTop + 60)
  .padding({ top: this.safeTop, left: D.pad, right: D.pad })
  .backgroundColor(C.card).alignItems(VerticalAlign.Bottom)
  .border({ width: { bottom: 1 }, color: C.stroke })
}

height(this.safeTop + 60) + padding({ top: this.safeTop }) + alignItems(VerticalAlign.Bottom) 三件套实现"安全区顶部让出 + 内容靠底对齐"——height 等于状态栏+60vp 标题区,padding.top 让出状态栏区域,alignItems(Bottom) 让标题/图标贴底对齐。这是 ArkUI 沉浸式 Header 的标准写法。

border({ width: { bottom: 1 }, color: C.stroke }) 底部 1vp 浅灰分隔线,把 Header 与下方 FormCard 视觉分隔——比给下方 FormCard 加 border-top 简洁(避免重复边框)。

与 App 13 几乎完全相同——只有 2 处差异:① 文案"创建"+"填写信息快速创建" vs App 13 略有不同;② 主题色背景(虽然 Header 是白底 + 边框,但 C.card / C.stroke 都是同一套主题色变量,红色 #EF4444 间接通过 C.primary 体现在按钮/选中态)。

三、FormCard:基本输入 + 字数统计 + 附件标签

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 + 字数统计三件套

TextInput({ placeholder: '请输入标题', text: this.inputTitle })
  .onChange((v: string) => { this.inputTitle = v; })
  .height(44).backgroundColor(C.cardSoft).borderRadius(D.rSm).placeholderColor(C.textDim)

onChange 双向绑定text: this.inputTitle(受控)+ onChange(写回 @State)—— ArkUI 声明式表单的标准模式。backgroundColor(C.cardSoft).borderRadius(D.rSm) 形成"软灰底"输入框,比纯白底更聚焦用户视线。

${this.inputTitle.length}/50 字数统计——@State inputTitle 变化自动重渲染,用户每敲一个字就更新"0/50→1/50→2/50"。"50 字"上限暗示活动标题要精简。

"图片/链接"伪按钮📷 图片 + 🔗 链接 两个 Text 套 padding + 背景色做成"标签型按钮"——Blank() 撑开,把字数统计推到右侧。"文字 + padding + 背景"是 ArkUI 轻量按钮的标准模式

与 App 13 完全同源——inputTitle/inputDesc 两个 @State 双向绑定,字数统计实时同步。"同样的表单骨架换主题色"——C.cardSoft 变量决定了所有"软灰底"是浅红色调(在红色主题下略偏暖)。

四、TypeCard:4 选 1 类型图标网格

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; }) 更新选中。

C.primarySoft: '#FEE8E8' 浅红 vs C.primary: '#EF4444' 主红——浅红背景 + 红边 + 红字 = "同色系深浅"高亮。**"选中不变色、只加深"**是本系列单色主题下的标准手法(与 App 15 的"四色分类"是相反思路——App 15 4 分类颜色不同,必须用同色深浅表达选中;App 17 单色主题,直接用主题色深浅即可)。

五、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

  • (idx=0)→ C.ok 绿色(#2BB673)——"风险低"用绿色
  • (idx=1)→ C.warn 橙色(#FF9F1C)——"中等风险"用橙色
  • (idx=2)→ C.danger 深红(#FF5A6E)——"高风险"用红色

绿-黄-红"风险色阶" 是国际通用的"严重性映射"(信号灯、地图、告警都这么用)。**"颜色 = 风险等级"**是直觉认知,无需文字解释。

注意 App 17 的 C.danger#FF5A6E(偏深红),与主题色 #EF4444(偏鲜红)略有差异——"业务警示色与品牌色分离"是设计系统的成熟做法:品牌色是"主调",警示色是"风险信号",两套红但用途不同。"相同色系、不同饱和度/明度"是高级设计

右上角实时显示当前优先级Text(this.priorities[this.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) — 选中时主色(红)——**"主题色作为 Toggle 开启色"**是系列标准
  • onChange((on: boolean) => { this.remindOn = on; }) — 双向绑定

Divider() 分割线——上半部分(提醒通知)和下半部分(隐私设置)之间用 1vp 浅灰线分隔。Column({ space: 0 }) 因为分割线本身已经提供视觉间距,避免上下空白过大。

右箭头 — 表示"点击进入二级页面"(Text('›') 是常用 iOS 风格的"disclosure indicator")。"主开关 + 子设置"的两层结构避免一个开关页面塞 20 个 Toggle。

与 App 13 完全同源——包括 @State remindOn: boolean = true 默认值、selectedColor(C.primary) 主题色 Toggle、Toggle/Disclosure 双形态。

七、TemplateCard:3 个快捷模板

TemplateCard 是 3 个快捷模板(快速创建/高级模式/批量导入)的列表:

@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 })
}

3 个模板

  • 📋 快速创建 — 使用默认模板(懒人用户)
  • 📋 高级模式 — 自定义所有字段(专家用户)
  • 📋 批量导入 — 从文件导入(重度用户)

每行📋 浅红底图标块(C.primarySoft)+ 主标题 + 副标题 + 箭头。"图标块颜色跟随主题色"——C.primarySoft 是浅红(在红色主题下是品牌色的"软化版")。

与 App 13 完全同源——3 个模板、图标、主副标题、箭头全相同。"模板卡片"的视觉结构稳定复用

志愿服务活动页底部 · 完整表单+快捷模板+动态提交按钮+底部Tab栏

八、SubmitBtn:动态文案 + 早返回校验

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%')
}

两态按钮

  • 标题为空 → 文字"请填写标题" + 浅灰底(C.cardSoft)+ 白字(其实浅灰底 + 白字对比度不好,可能是设计小瑕疵)+ Toast 拦截
  • 标题填写 → 文字"✓ 提交创建" + 主色背景(红)+ 白字

"白字"是写死的 fontColor('#FFFFFF')——在浅灰底上白字几乎看不见,这是设计 bug#FFFFFFC.cardSoft 上对比度极低)。真实项目应把"未填写"态的文字色改为 C.textDimC.textSub

早返回校验

.onClick(() => {
  if (this.inputTitle.length === 0) { promptAction.showToast({ message: '请输入标题' }); return; }
  promptAction.showToast({ message: '创建成功!' });
  this.inputTitle = ''; this.inputDesc = '';
})

if (!inputTitle) return 早返回 + Toast 提示——比 if-else 嵌套更扁平。成功路径除了弹 Toast 还清空两个 @StateinputTitle = '' + inputDesc = '')——用户提交后表单清空,可以立即再创建下一条。

Text('提交即表示同意相关条款') 法律提示——9sp 灰色居中,"小字+灰"层级最低但合规必须。任何"提交按钮"都该配一段条款提示(电商注册、隐私协议、金融产品都是)。

志愿服务活动页下部 · 提醒通知+隐私设置+快捷模板+提交按钮+安全区留白+底部Tab栏

九、@State 的 5 状态映射

Func1Tab 有 5 个 @State

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

inputTitle 是"最忙"的状态——同时驱动按钮文案、按钮颜色、字数统计 3 处 UI。一处状态、三处渲染是响应式 UI 的精髓。

selectedType + selectedPriority 是"单选"状态——典型模式 @State number + 索引。

remindOn 是"二元"状态——典型模式 @State boolean + Toggle 双向绑定。

5 个 @State = 5 个交互点——**"@State 数量 = 交互点数"**是设计健康状态管理的指标。

志愿服务活动页底部 · 完整表单+提交按钮+安全区留白+底部Tab栏

十、模板换肤的方法论(系列最重要的设计原则)

App 17 与 App 13 几乎 100% 同构——唯一的差异是 Theme.ets 里的几个颜色值

颜色App 13(紫)App 17(红)
C.primary#8B5CF6#EF4444
C.accent#A78BFA#F87171
C.primarySoft#F0EBFE#FEE8E8
C.warn / C.danger / C.ok

"主题色 3 件套(主色/浅色/超浅色)换肤" + "业务色不变(C.warn/C.danger/C.ok 通用)" = 完整的模板换肤

这套设计原则值得所有 ArkUI 项目学习

  1. 定义"品牌色 3 件套"primary + accent + primarySoft)——所有品牌相关的 UI(按钮/选中态/进度条/链接)用这 3 个变量
  2. 定义"业务色"warn + danger + ok)——所有状态色用这 3 个变量(与品牌色解耦)
  3. 所有组件用变量名而非色码——.backgroundColor(C.primary) 而不是 .backgroundColor('#EF4444')
  4. 换肤只改 Theme.ets——3 个颜色值全换 = 整个 App 换肤

这种"主题与代码分离"的模式让"换主题色"从"改 200 个文件"变成"改 1 个文件"——工程效率提升 200 倍

真实项目建议

  • 暗色模式(夜间/省电):dark: { primary: '#...', accent: '#...', ... }light: { ... } 配套
  • 多品牌:企业版/个人版/校园版/医院版,换 Theme.ets 即可
  • 节日主题:春节红、国庆红、圣诞绿,通过动态主题色实现AppStorage.set('primary', '#FF5A5A')

"一套代码、多个主题"是 ArkUI 设计系统的最高目标——App 13/17 的对比就是这个目标的最佳示范。

十一、表单设计的"四层校验"进阶

App 17 SubmitBtn 只做了"标题非空"单校验,真实项目应做四层校验

  1. 客户端校验@State 同步校验)——本页当前实现
  2. 边界校验(如"标题不能全是空格" if (this.inputTitle.trim().length === 0)
  3. 业务规则校验(如"高优先级活动必须填描述")
  4. 服务端校验(最强,最终防线)

"四层校验 = 客户端体验 + 业务完整性 + 数据安全"demo 只做了第 1 层是简化,真实项目应把"边界校验"(trim/长度限制)也补上:

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

trim() 是边界校验的关键——用户可能输入一堆空格" "绕过非空检查。trim().length 确保"实际有效内容"非空。

十二、Toggle 组件的"持久化"陷阱

@State remindOn: boolean = true 的"提醒通知"开关当前只存在内存里——应用重启后 remindOn 会重置为 true(默认值),用户设置的"关闭提醒"丢失。

真实项目应持久化到本地存储@ohos.data.preferences):

import { preferences } from '@kit.ArkData';

aboutToAppear() {
  // 读取持久化值
  preferences.getPreferences(this.getUIContext().getHostContext(), 'volunteer_pref')
    .then(p => p.get('remindOn', true))
    .then(v => { this.remindOn = v; });
}

onChange((on: boolean) => {
  this.remindOn = on;
  // 持久化
  preferences.getPreferences(this.getUIContext().getHostContext(), 'volunteer_pref')
    .then(p => p.put('remindOn', on))
    .then(p => p.flush());
})

aboutToAppear 是组件的生命周期钩子(类似 Vue 的 mounted),在组件创建时执行。getPreferences 异步读取 → 更新 @State → UI 重渲染。onChange 写持久化——**"状态变更即持久化"**是用户偏好设置的标准模式。

"Toggle 持久化"是 demo 简化——读者在自己项目里应记住:用户偏好必须持久化,否则重启丢失。

十三、总结

App 17 活动创建页解析完毕。与 App 13 的"模板换肤"关系展示了一个重要方法论:一套 UI 框架 + 一套 Theme.ets 配色 = 任意主题的应用"红色志愿"主题色让 6 个表单组件焕发公益热情——C.primary 红色在按钮/选中态/进度条/链接文字上反复出现,让"志愿"主题深入骨髓。

"表单校验"和"Toggle 持久化"是 demo 到产品的关键差距——读者在抄这个模板时记得补齐。

Logo

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

更多推荐