鸿蒙原生应用 ArkTS 表单工程:作品发布页的分类多选与实时预览
App 19「校园影像」发布 Tab(Func1Tab),是系列交互最丰富的作品发布表单——作品信息(标题/描述)+8 分类多选(已选计数)+ 拍摄场景单选(教学楼/操场/图书馆/食堂/宿舍/湖边)+滤镜选择(原图/复古/黑白/暖色/冷色/胶片)+ 隐私三态(公开/仅好友/私密)+实时预览卡(滤镜/标题/场景/隐私/描述/已选分类全联动)+ 4 个快捷模板 + 发布按钮。本篇基于
19-campus-photo/entry/src/main/ets/pages/Func1Tab.ets(约 279 行)逐段拆解,附 4 张实机截图。
一、整体结构:7 个 @Builder + 2 个辅助方法的发布漏斗
Func1Tab 与 App 18 发现页同构(多选 + 预览 + 模板),但多了"场景选择"和"滤镜选择"两个模块:
build() { Column() { this.Header() Scroll() { Column({ space: 14 }) { this.FormSection() this.CategorySection() this.SceneSection() this.FilterSection() this.PreviewCard() this.TemplateSection() this.SubmitBtn() } .width('100%') .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) }7 个块按"填信息 → 选分类 → 选场景 → 选滤镜 → 看预览 → 用模板 → 发布"漏斗展开:
- FormSection— 标题 + 描述(输入)
- CategorySection— 8 分类多选(本页核心交互)
- SceneSection— 拍摄场景单选(6 个)
- FilterSection— 滤镜选择(6 个)+ 隐私三态
- PreviewCard— 实时预览(本页最大亮点)
- TemplateSection— 4 个快捷模板
- SubmitBtn— 发布作品
对比 App 18:App 18 是"3 组单选(学院/年龄/范围)+ 匿名 Toggle",App 19 是"场景单选 + 滤镜单选 + 隐私三态"——"滤镜"和"隐私三态"是本页新增。**"发布作品 = 填信息 + 选分类 + 选场景 + 选滤镜 + 定隐私"**是摄影 App 的完整发布流程。
项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
二、Header + FormSection:作品信息
Header 是"发布作品/分享你的校园摄影作品"两行品牌区。FormSection 是标题/描述输入(与 App 18 同款"上标签 + 描边输入框"):
@Builder FormSection() { Column({ space: 14 }) { Text('作品信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Column({ space: 8 }) { Text('标题').fontSize(12).fontColor(C.textDim).width('100%') TextInput({ placeholder: '给作品起个名字...', text: this.title }) .onChange((val: string) => { this.title = val; }) .height(44).borderRadius(D.rMd).backgroundColor(C.bg) .border({ width: 1, color: C.stroke }).placeholderColor(C.textDim).placeholderFont({ size: 13 }) }.width('100%') Column({ space: 8 }) { Text('描述').fontSize(12).fontColor(C.textDim).width('100%') TextArea({ placeholder: '分享拍摄故事或心得...', text: this.desc }) .onChange((val: string) => { this.desc = val; }) .height(80).borderRadius(D.rMd).backgroundColor(C.bg) .border({ width: 1, color: C.stroke }).placeholderColor(C.textDim).placeholderFont({ size: 13 }) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }占位文案的"引导性":
- 标题:"给作品起个名字..."("给...起个名字"是口语化引导,比"请输入标题"亲切)
- 描述:"分享拍摄故事或心得..."("分享故事"是摄影 App 的文案调性——不只是填描述,而是"讲故事")
@State title+@State desc双向绑定——输入实时存入状态,供 PreviewCard 读取。
三、CategorySection:8 分类多选(本页核心)
CategorySection 是 8 个分类标签的多选胶囊+已选计数:
@Builder CategorySection() { Column({ space: 12 }) { Row() { Text('作品分类').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('已选 ' + this.selectedCount() + ' 个').fontSize(12).fontColor(C.primary) }.width('100%') Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.categories, (item: CategoryTag) => { Text(item.label) .fontSize(13) .fontColor(item.selected ? '#FFFFFF' : C.textSub) .backgroundColor(item.selected ? C.primary : C.cardSoft) .borderRadius(16) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .margin({ right: 8, bottom: 8 }) .border({ width: 1, color: item.selected ? C.primary : C.stroke }) .onClick(() => { this.toggleCategory(item.id); }) }, (item: CategoryTag) => item.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }8 个分类:风景(选)/人像/建筑(选)/美食/运动/夜景(选)/花卉/动物——初始 3 个选中(风景/建筑/夜景)——"预选降低门槛"(App 18 同款设计)。
Flex({ wrap: FlexWrap.Wrap })自动换行+margin({ right: 8, bottom: 8 })标签间距——"8 个长短不一标签"的最佳布局(同 App 18 发现页)。
toggleCategory(id)+selectedCount()两个辅助方法(与 App 18 完全同构):
toggleCategory(id: number): void { for (let i = 0; i < this.categories.length; i++) { if (this.categories[i].id === id) { this.categories[i] = { id: this.categories[i].id, label: this.categories[i].label, selected: !this.categories[i].selected }; } } } selectedCount(): number { let count: number = 0; for (let i = 0; i < this.categories.length; i++) { if (this.categories[i].selected) { count++; } } return count; }toggleCategory创建新对象赋值(this.categories[i] = {...}而非改字段)——ArkTS 响应式数组的"整体替换"写法(改字段不触发重渲染)。selectedCount()线性遍历统计——右上角"已选 3 个"。
四、SceneSection:拍摄场景单选
SceneSection 是 6 个拍摄场景的 emoji 胶囊单选:
@Builder SceneSection() { Column({ space: 12 }) { Text('拍摄场景').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Row({ space: 8 }) { ForEach(this.scenes, (s: SceneItem) => { Row({ space: 6 }) { Text(s.icon).fontSize(14) Text(s.name).fontSize(12) } .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .borderRadius(D.rMd) .backgroundColor(this.selectedScene === s.id ? C.primarySoft : C.cardSoft) .border({ width: 1, color: this.selectedScene === s.id ? C.primary : C.stroke }) .onClick(() => { this.selectedScene = s.id; promptAction.showToast({ message: s.name }); }) }, (s: SceneItem) => s.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }6 个场景:教学楼🏫/操场⚽/图书馆📚/食堂🍜/宿舍🏠/湖边🌊——"校园拍摄地全覆盖"。默认选中"教学楼"(selectedScene = 1)。
单选交互:selectedScene === s.id ? C.primarySoft : C.cardSoft(选中浅青绿 vs 未选浅灰)+ 青绿描边。点击还弹 Toast(s.name)——"选择反馈"。
与 App 18 学院选择的对比:App 18 是"学院",App 19 是"拍摄场景"——**"场景 = 照片拍摄地"**是摄影 App 特有的元数据(图库按地点分类)。
五、FilterSection:滤镜选择 + 隐私三态
FilterSection 是 6 个滤镜选择 + 3 档隐私(公开/仅好友/私密):
@Builder FilterSection() { Column({ space: 12 }) { Text('滤镜选择').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Row({ space: 8 }) { ForEach(this.filters, (f: FilterItem) => { Column({ space: 6 }) { Row() { Text(f.preview).fontSize(22) } .width(44).height(44).borderRadius(D.rMd) .backgroundColor(this.selectedFilter === f.id ? C.primarySoft : C.cardSoft) .justifyContent(FlexAlign.Center) Text(f.name).fontSize(10) .fontColor(this.selectedFilter === f.id ? C.primary : C.textDim) }.layoutWeight(1) .onClick(() => { this.selectedFilter = f.id; promptAction.showToast({ message: '滤镜: ' + f.name }); }) }, (f: FilterItem) => f.id.toString()) }.width('100%') Row() { ForEach(this.privacyOptions, (o: string, idx: number) => { Text(o) .fontSize(12).fontColor(this.privacy === idx ? '#FFFFFF' : C.textSub) .backgroundColor(this.privacy === idx ? C.primary : C.cardSoft) .borderRadius(D.rMd) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .border({ width: 1, color: this.privacy === idx ? C.primary : C.stroke }) .onClick(() => { this.privacy = idx; }) }, (o: string, idx: number) => o + idx) }.width('100%').margin({ top: 8 }) } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }5.1 6 个滤镜(预览 emoji)
private filters: FilterItem[] = [ { id: 1, name: '原图', preview: '🔆' }, { id: 2, name: '复古', preview: '📷' }, { id: 3, name: '黑白', preview: '🖤' }, { id: 4, name: '暖色', preview: '🌅' }, { id: 5, name: '冷色', preview: '❄️' }, { id: 6, name: '胶片', preview: '🎬' } ];"滤镜名 + 预览 emoji"——原图🔆/复古📷/黑白🖤/暖色🌅/冷色❄️/胶片🎬——"emoji 表达滤镜效果"(暖色用🌅夕阳、冷色用❄️冰雪、黑白用🖤)——"emoji 即滤镜预览"(demo 替代真实滤镜渲染)。
滤镜选中:44×44 图标块(C.primarySoft浅青绿 vs 灰)+ 文字色。默认选中"原图"(selectedFilter = 1)。
FilterItem的preview: string字段是"滤镜预览"的 demo 化——真实项目这里应该是"滤镜滤镜预览图"或"滤镜的 CSS/Shader 效果"。
5.2 隐私三态(公开/仅好友/私密)
Row() { ForEach(this.privacyOptions, (o: string, idx: number) => { Text(o) .fontSize(12).fontColor(this.privacy === idx ? '#FFFFFF' : C.textSub) .backgroundColor(this.privacy === idx ? C.primary : C.cardSoft) .borderRadius(D.rMd) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .border({ width: 1, color: this.privacy === idx ? C.primary : C.stroke }) .onClick(() => { this.privacy = idx; }) }, (o: string, idx: number) => o + idx) }privacyOptions: string[] = ['公开', '仅好友', '私密']+@State privacy: number = 0(默认公开)——"三态隐私"是社交内容发布的标准(微信朋友圈"公开/仅好友/私密"同款)——"隐私层级"设计。
选中态:白字青绿底 vs 灰字灰底。"公开/仅好友/私密"三档可见范围递增收窄——"内容可见性控制"。
六、PreviewCard:实时预览(本页最大亮点)
PreviewCard 是实时预览卡——滤镜/标题/场景/隐私/描述/已选分类全部联动:
@Builder PreviewCard() { Column({ space: 12 }) { Row() { Text('预览效果').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('他人将看到').fontSize(11).fontColor(C.textDim) }.width('100%') Row({ space: 14 }) { Row() { Text(this.filters[this.selectedFilter - 1].preview).fontSize(40) } .width(64).height(64).backgroundColor(C.primarySoft).borderRadius(D.rMd).justifyContent(FlexAlign.Center) Column({ space: 4 }) { Text(this.title.length > 0 ? this.title : '作品标题').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text) Text(this.scenes[this.selectedScene - 1].name + ' · ' + this.filters[this.selectedFilter - 1].name) .fontSize(12).fontColor(C.textDim) Text('🔒 ' + this.privacyOptions[this.privacy]).fontSize(11).fontColor(C.textSub) }.alignItems(HorizontalAlign.Start).layoutWeight(1) }.width('100%') if (this.desc.length > 0) { Text(this.desc).fontSize(12).fontColor(C.textSub).width('100%').maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) } Row({ space: 6 }) { ForEach(this.categories, (item: CategoryTag) => { if (item.selected) { Text(item.label).fontSize(11).fontColor(C.primary) .backgroundColor(C.primarySoft).borderRadius(4) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) } }, (item: CategoryTag) => item.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) }6.1 滤镜联动(最精彩的联动)
Row() { Text(this.filters[this.selectedFilter - 1].preview).fontSize(40) }filters[selectedFilter - 1].preview——预览卡的封面图 = 当前选中滤镜的预览 emoji——"选滤镜 → 预览封面换 emoji"(选"暖色"预览卡封面变🌅、选"黑白"变🖤)——"滤镜实时预览"是本页最直观的联动。
注意selectedFilter - 1的下标偏移——id 从 1 开始、数组下标从 0 开始(selectedFilter = 1原图 →filters[0])——"id-1 取下标"的经典写法(本系列多次出现)。
6.2 标题/场景/隐私联动
Text(this.title.length > 0 ? this.title : '作品标题').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text) Text(this.scenes[this.selectedScene - 1].name + ' · ' + this.filters[this.selectedFilter - 1].name) .fontSize(12).fontColor(C.textDim) Text('🔒 ' + this.privacyOptions[this.privacy]).fontSize(11).fontColor(C.textSub)- 标题:
title.length > 0 ? title : '作品标题'(空时显示占位) - 场景+滤镜:
scenes[selectedScene-1].name + ' · ' + filters[selectedFilter-1].name("教学楼 · 原图")——场景和滤镜的拼接显示 - 隐私:
🔒 + privacyOptions[privacy]("🔒 公开")——锁图标 + 隐私状态
6.3 描述 + 已选分类联动
if (this.desc.length > 0) { Text(this.desc).fontSize(12).fontColor(C.textSub).width('100%').maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) } Row({ space: 6 }) { ForEach(this.categories, (item: CategoryTag) => { if (item.selected) { Text(item.label).fontSize(11).fontColor(C.primary) .backgroundColor(C.primarySoft).borderRadius(4) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) } }, (item: CategoryTag) => item.id.toString()) }- 描述:
if (desc.length > 0)有才显示 +maxLines(2)截断 - 分类:
if (item.selected)只显示已选标签——点选/取消分类,预览卡标签实时增删
"一次操作、五处联动"的完整闭环:
选滤镜 → 预览封面换 emoji + 场景·滤镜文字变 输标题 → 预览标题实时变 选场景 → 场景·滤镜文字变 选隐私 → 🔒 文字变 点分类 → 预览标签增删 + 计数变5 个交互点全部实时反馈到预览卡——"发布即所见"是摄影 App 的信任设计(发布前完全掌控"别人看到的")。
七、TemplateSection + SubmitBtn
TemplateSection 是 4 个快捷模板(校园风景/活动记录/人物特写/创意构图),每行"emoji + 标题 + 描述":
@Builder TemplateSection() { Column({ space: 12 }) { Text('快捷模板').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Row({ space: 12 }) { ForEach(this.templates, (t: TemplateItem) => { Column({ space: 6 }) { Row() { Text(t.icon).fontSize(24) } .width(44).height(44).backgroundColor(C.primarySoft).borderRadius(22).justifyContent(FlexAlign.Center) Text(t.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(C.text) Text(t.desc).fontSize(10).fontColor(C.textDim).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1).padding({ top: 10, bottom: 10 }) .backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) .alignItems(HorizontalAlign.Center) .onClick(() => { promptAction.showToast({ message: '应用模板: ' + t.title }); }) }, (t: TemplateItem) => t.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }4 个模板 = 4 种摄影题材:校园风景🌄/活动记录🎉/人物特写👤/创意构图🎨——"模板即题材预设"(点击应自动填充标题/分类/场景/滤镜)。
SubmitBtn:"发布作品" 50vp 高青绿按钮——点击弹 Toast "作品发布中..."(发布流程占位)。
八、@State 的 6 状态设计
Func1Tab 有 6 个 @State:
| @State | 类型 | 用途 | 联动 |
|---|---|---|---|
title | string | 标题输入 | 预览卡标题 |
desc | string | 描述输入 | 预览卡描述 |
categories | CategoryTag[] | 8 分类选中 | 胶囊高亮/计数/预览标签 |
selectedScene | number | 场景(1-6) | 场景胶囊 + 预览场景名 |
selectedFilter | number | 滤镜(1-6) | 滤镜胶囊 + 预览封面/滤镜名 |
privacy | number | 隐私(0-2) | 隐私胶囊 + 预览🔒文字 |
6 个 @State 覆盖 6 个交互点——"@State 数量 = 交互点数"规律延续。
categories是"对象数组 @State"(toggleCategory整体替换元素)——ArkTS 响应式数组的关键知识点(与 App 18 相同)。
九、与 App 18 发现页的对比
| 维度 | App 18 发现匹配 | App 19 作品发布 |
|---|---|---|
| 核心 | 兴趣多选 + 匹配范围 | 分类多选 + 场景/滤镜 |
| 多选 | 10 兴趣 | 8 分类 |
| 单选 | 学院/年龄/范围 | 场景/滤镜 |
| 隐私 | 匿名 Toggle | 公开/仅好友/私密三态 |
| 预览 | 昵称/兴趣/匿名 | 滤镜封面/标题/场景/隐私/分类 |
| 特色 | 匿名模式 | 滤镜实时预览 |
App 19 的进步:① 场景选择(拍摄地元数据);②滤镜选择 + 预览封面联动(filters[selectedFilter-1].preview换 emoji);③ 隐私三态(比 App 18 的二元 Toggle 更细)。"发布表单 + 实时预览"模板已复用 2 次(App 18/19),未来 App 20+ 会继续。
十、滤镜选择的"emoji 预览"与真实滤镜的实现
App 19 的滤镜选择用preview: '🔆'/'📷'/'🖤'/...的 emoji 做"滤镜预览",这是 demo 的高明简化——真实项目的滤镜实现有 4 个层级:
1. 静态预览(emoji/色块)——demo 当前实现:选滤镜 → 预览卡封面换 emoji。适合原型/教学。
2. 颜色矩阵滤镜(CSS Filter/Canvas)——真实滤镜用"颜色矩阵"变换像素:黑白(去饱和度)、暖色(加红色调)、冷色(加蓝色调)——"每个滤镜 = 一组颜色矩阵参数":
// 伪代码:颜色矩阵滤镜 const FILTERS = { '黑白': { saturation: 0, contrast: 1.1 }, '暖色': { temperature: 1.3, saturation: 1.1 }, '冷色': { temperature: 0.8, saturation: 1.05 } };3. 叠加纹理(胶片颗粒/光晕)——胶片滤镜不只是调色,还叠加"颗粒感""暗角"纹理——"滤镜 = 颜色变换 + 纹理叠加"。
4. 一键增强(AI 滤镜)——AI 自动调色(智能白平衡/对比度/人像美颜)——"AI 滤镜"是现代相机 App 的标配。
"滤镜"的 UI 交互设计(App 19 的参考价值):
- 滤镜列表横滑(可滚动,6 个以上)
- 选中的滤镜实时应用到预览图
- 滤镜强度滑杆(原图 ↔ 滤镜效果可调)
"emoji 预览 → 颜色矩阵 → AI 滤镜"是滤镜实现的进化路径——App 19 的 emoji 版是"第 1 层",读者做真实产品时按需求选层。
十一、隐私三态与"内容可见性"设计
App 19 的隐私三态(公开/仅好友/私密)是内容型 App 的可见性模型:
三态可见性递进:
| 隐私 | 谁能看到 | 场景 |
|---|---|---|
| 公开 | 所有人(推荐/广场) | 想被更多人看到 |
| 仅好友 | 好友列表 | 私密分享 |
| 私密 | 仅自己 | 草稿/私人记录 |
"公开 → 仅好友 → 私密"是可见范围的递增收窄——微信朋友圈(公开/仅好友/私密)、小红书(公开/私密)同款——"三档可见性"是社交内容的标准模型。
"公开"的推荐逻辑:公开的作品 → 进首页推荐(PhotoList)、参与比赛、进分类排行——"公开 = 参与内容生态";"仅好友/私密"→ 不出现在公共列表——"可见性 = 内容分发"。
真实项目的隐私进阶:
- 分组可见(仅某分组好友可见)
- 指定不可见(拉黑名单)
- 时间限定(24 小时后转私密)
- 作品级权限(不同作品不同可见性)
"隐私三态 + 预览卡🔒联动"让用户在发布前就确认"谁将看到"——"可见性前置确认"是内容发布的信任设计(用户不怕"发错可见范围")。
十二、总结
App 19 作品发布页解析完毕。8 分类多选(toggleCategory 翻转)+ 6 场景单选 + 6 滤镜选择(预览联动)+ 隐私三态 + 实时预览卡(5 处联动)是五大亮点。"滤镜实时预览"是摄影 App 发布页的核心体验——发布前完全掌控作品效果。