鸿蒙原生应用 ArkTS 表单工程:活动照片上传页九宫格增删与实时计数

App 40「校园活动照片」上传页(Func2Tab),主题色 #6C5CE7 紫色(violet),4 个 Tab 分别为首页(📸)、活动(🎉)、上传(📤)、我的(👤)。上传页采用"Header + 照片九宫格 + 表单卡 + 上传按钮"四区布局——白色单行 Header("上传照片"20 号加粗) + 3×3 照片九宫格(标题"选择照片 (3/9)"实时计数 + 已选照片 🖼️ 带右上角 ✕ 删除角标 + 虚线边框"+"添加格 + 空位占位,Grid 三列 1fr 1fr 1fr) + 表单卡(关联活动 4 胶囊单选 + 照片说明 TextArea) + 全宽紫色"上传照片"按钮(Toast"上传 N 张照片")。本篇基于 40-event-photo/entry/src/main/ets/pages/Func2Tab.ets(共 99 行)逐段拆解,附 4 张实机截图。

一、整体结构:四区"Header + 九宫格 + 表单 + 按钮"布局

上传页是"品牌头部 + 照片选择 + 信息填写 + 提交"的四区布局:

build() {
  Column() {
    this.Header()
    Scroll() {
      Column({ space: 16 }) {
        this.PhotoGrid()
        this.FormCard()
        Button('上传照片').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
          .width('100%').height(48).borderRadius(D.rMd)
          .onClick(() => { promptAction.showToast({ message: '上传 ' + this.photoCount + ' 张照片' }); })
      }
      .width('100%')
      .padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad + this.safeBottom + 20 })
    }
    .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
  }
  .width('100%').height('100%').backgroundColor(C.bg)
}

4 块结构:

  1. Header — 单行品牌头("上传照片")
  2. PhotoGrid — 3×3 九宫格,标题实时显示 (3/9),是整页最核心的交互区
  3. FormCard — "关联活动"单选胶囊 + "照片说明"多行输入
  4. 上传按钮 — 全宽 48 高紫色主按钮,Toast 汇报上传张数

三个 @State 贯穿全页:photoCount(已选照片数)、activeEvent(关联活动索引)、caption(照片说明文本)——状态清晰、职责单一。

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

二、Header:单行标题 + 安全区避让

Header() {
  Row() { Text('上传照片').fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) }
  .width('100%').height(this.safeTop + 56).padding({ top: this.safeTop, left: D.pad, right: D.pad })
  .backgroundColor(C.card).alignItems(VerticalAlign.Bottom)
}

单行 20 号加粗、safeTop + 56 高度、白底、文字底部对齐——与活动页标题行同款,系列二级页统一范式。

40 上传页首屏

三、PhotoGrid:3×3 九宫格的三态渲染

这是整页技术含量最高的部分:

PhotoGrid() {
  Column({ space: 10 }) {
    Text('选择照片 (' + this.photoCount + '/9)').fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    Grid() {
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        GridItem() {
          if (i < this.photoCount) {
            Stack({ alignContent: Alignment.TopEnd }) {
              Row() { Text('🖼️').fontSize(30) }
              .width('100%').aspectRatio(1).backgroundColor(C.primarySoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
              Text('✕').fontSize(12).fontColor('#FFFFFF')
                .width(20).height(20).textAlign(TextAlign.Center).backgroundColor('#88000000').borderRadius(10)
                .margin({ top: 4, right: 4 })
                .onClick(() => { if (this.photoCount > 0) { this.photoCount--; } })
            }
          } else if (i === this.photoCount) {
            Column() { Text('+').fontSize(32).fontColor(C.textDim) }
            .width('100%').aspectRatio(1).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
            .border({ width: 1, color: C.stroke, style: BorderStyle.Dashed })
            .onClick(() => { this.photoCount++; })
          } else {
            Column().width('100%').aspectRatio(1)
          }
        }
      }, (i: number) => i.toString())
    }.columnsTemplate('1fr 1fr 1fr').columnsGap(10).rowsGap(10).width('100%')
  }
}

技术拆解:

  1. Grid 三列布局:Grid() 容器 + columnsTemplate('1fr 1fr 1fr') 声明三列等宽,columnsGap(10)/rowsGap(10) 控制行列间距;ForEach 生成 6 个 GridItem(0~5),配合 aspectRatio(1) 让每个格子在宽度已知时自动等比例变为正方形——三行两列的 2/3 屏宽九宫格就这样成型。
  2. 三态条件渲染(i 与 photoCount 的关系决定每个格子渲染什么):
    • 已选照片(i < photoCount):Stack({ alignContent: Alignment.TopEnd }) 内放 🖼️ 占位图(30 号、浅紫底、圆角 10)与右上角 ✕ 删除角标——20×20 圆形、#88000000 半透明黑底白字,margin({ top: 4, right: 4 }) 贴角内缩,点击 photoCount--。
    • 加号格(i === photoCount):浅底 + 虚线边框(border({ style: BorderStyle.Dashed }))+ 32 号灰"+",点击 photoCount++——它是"下一个空位"的添加入口,永远紧跟已选照片之后。
    • 空位占位(i > photoCount):空白 Column,纯占位保持九宫格形状。
  3. 计数闭环:标题"选择照片 (3/9)"与加号格位置、已选格数量都由 photoCount 单一状态驱动——加一张、标题变 4/9、加号格后移一格;删一张则反向,状态永远自洽。

为什么是 6 个 GridItem 而非 9 个:Demo 简化了"最多选 9 张"的上限(标题 3/9 暗示 9),但网格只渲染 6 格(两行)——因为 6 格对演示"3 张 + 加号 + 空位"三态已足够,9 格会多占一屏空间。真实产品把 ForEach 数组扩到 0~8 即可。

四、FormCard:关联活动 + 照片说明

FormCard() {
  Column({ space: 14 }) {
    Column({ space: 6 }) {
      Text('关联活动').fontSize(13).fontColor(C.textSub).width('100%')
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.events, (e: string, idx: number) => {
          Text(e).fontSize(12)
            .fontColor(this.activeEvent === idx ? '#FFFFFF' : C.textSub)
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .backgroundColor(this.activeEvent === idx ? C.primary : C.cardSoft).borderRadius(14)
            .margin({ right: 10, bottom: 10 })
            .onClick(() => { this.activeEvent = idx; })
        }, (e: string) => e)
      }.width('100%')
    }
    Column({ space: 6 }) {
      Text('照片说明').fontSize(13).fontColor(C.textSub).width('100%')
      TextArea({ placeholder: '添加照片描述...' })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm).height(80)
        .onChange((v: string) => { this.caption = v; })
    }
  }
  .width('100%').padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke })
}

表单细节:

  1. 关联活动单选:Flex 换行排布 4 个活动胶囊(迎新晚会/秋季运动会/校园音乐节/话剧公演),@State activeEvent 记录选中索引,选中紫底白字、未选浅底灰字——单选胶囊与 App 39 兴趣页的多选胶囊同构,但语义是"单选"(索引而非数组),更轻量。
  2. 关联逻辑:选中的活动即照片归属相册——上传后照片进入该活动的照片集,与首页相册/活动页的照片数直接挂钩。
  3. 照片说明:TextArea 80 高多行输入、浅底圆角 10、placeholder 提示"添加照片描述...",onChange 把输入写入 @State caption。
  4. 数据绑定:events 数组(15 行)与活动页 4 个活动完全同名——上传页关联的活动即活动页展示的活动,全 App 数据源统一。

五、上传按钮与提交链路

Button('上传照片').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
  .width('100%').height(48).borderRadius(D.rMd)
  .onClick(() => { promptAction.showToast({ message: '上传 ' + this.photoCount + ' 张照片' }); })

全宽 48 高紫色主按钮,点击 Toast"上传 N 张照片"——N 与九宫格计数一致,用户从选照片到提交的每一步反馈都有明确的数据支撑。真实实现中,点击上传应执行"读取相册图片 → 压缩 → 携带 activeEvent 与 caption 提交服务端 → 成功回调更新相册 count 并跳转回首页"的完整链路,Demo 的 Toast 是这条链路的占位。

六、跨页数据自洽:上传页是数据的"生产端"

上传页是全 App 唯一的数据生产入口,与消费端严格自洽:

  • 关联活动 4 个胶囊 ↔ 活动页 4 个活动(迎新晚会/秋季运动会/校园音乐节/话剧公演)同名同序——上传即写入对应活动的照片集。
  • 照片归属:上传成功后,对应活动的"📷 N 张照片"、首页相册的 count、我的页"我的照片"统计都应 +N——Demo 静态展示,产品化后上传动作将驱动全 App 数据刷新。
  • 我的页"96 我的照片":96 是历史累计上传量,本次会话上传 3 张后应变为 99——统计的自洽演进是"上传 → 我的"闭环的验证。

40 上传页添加照片

七、实机截图与交互演示

本节结合 4 张实机截图,逐张还原上传页的交互链路。

1. 上传页首屏:3/9 九宫格 + 表单卡

第一张截图是上传页默认首屏:顶部"上传照片"标题,下方"选择照片 (3/9)"与 3×3 九宫格——前 3 格为 🖼️ 已选照片(各带右上角 ✕ 角标)、第 4 格为虚线边框"+"添加格、第 5/6 格为空位占位;往下是"关联活动"4 个胶囊(第一个"迎新晚会"紫色选中态)与"照片说明"TextArea,底部可见紫色"上传照片"按钮。

2. 点击"+":添加第 4 张照片

第二张截图是点击虚线"+"格后的状态:标题变为"选择照片 (4/9)",第 4 格从"+"变为 🖼️ 已选照片(带 ✕ 角标),"+"格自动后移到第 5 格——photoCount 从 3 变为 4,三态条件渲染即时重绘,计数与格子位置完全联动。

3. 点击"✕":删除第一张照片

第三张截图是点击第一张照片右上角"✕"后的状态:标题回到"选择照片 (3/9)",第一格恢复为"+"添加格,后续照片前移——photoCount-- 触发同一套三态逻辑反向流转。删除角标的 #88000000 半透明黑底在浅紫图上清晰可见,是"图片右上角操作"的标准交互。

4. 点击"上传照片":提交 Toast

第四张截图是点击全宽"上传照片"按钮后的反馈:弹出"上传 3 张照片"Toast——数字与九宫格当前计数(3/9)完全一致,验证了提交逻辑读取的是同一个 photoCount 状态源,选择与提交的数据流闭环成立。

八、扩展思考:照片选择的真实产品实现

Demo 用 🖼️ emoji 占位模拟照片,真实产品的照片选择链路复杂得多:

  1. 相册访问:需通过 @ohos.file.photoAccessHelper 申请相册权限并读取本地照片,用 PhotoViewPicker(ArkTS 原生照片选择器)让用户从系统相册多选图片,选中的 PhotoViewPicker 返回的 uri 数组即 photoCount 的数据源。
  2. 压缩与上传:原图可能数 MB,需先用 @ohos.multimedia.image 做采样压缩(createImagePacker 或 ImagePacker),再走 @ohos.net.http 或 @kit.NetworkKit 分片上传到对象存储。
  3. 九宫格升级:真实照片网格应展示缩略图(Image + objectFit(ImageFit.Cover)),配 LazyForEach 避免大图集卡顿;✕ 角标保留,+ 格调用系统选择器。
  4. 进度反馈:上传中显示进度环/百分比,逐张完成后对应格子角标从"✕"变为"✓"或"上传成功"标记——Demo 的即时计数是这一链路的最小演示。

九、扩展思考:Grid 布局 vs 其他照片排布方案

  1. Grid 网格:适合"行列固定、格子等大"的场景(九宫格/照片墙),columnsTemplate + rowsTemplate 精确控制,本页是标准用法。
  2. Flex 换行:适合"数量不定、宽度自适应"的标签流(如关联活动胶囊)——本页 FormCard 正是 Flex 方案,两种布局在同页互补。
  3. WaterFlow 瀑布流:适合"高度不等"的相册展示(如首页),Grid 的等高格子反而破坏瀑布错落感——同一 App 内按场景混用三种布局,是 ArkUI 布局能力的完整展示。
  4. 可拖拽排序:若支持用户调整照片顺序,可在 GridItem 上加 onDragStart + 拖拽事件,属于进阶交互,Demo 未涉及。

十、常见问题与开发避坑

  1. photoCount 越界:+ 格点击 photoCount++ 无上限校验——若 ForEach 扩到 9 格,用户最多加到 9 后"+"格消失,点击其他位置无碍;但若 photoCount 超过格子数会出现全空(无"+"格)的尴尬态,应加 if (this.photoCount < 9) 上限。
  2. ✕ 角标的点击穿透:Stack 中 ✕ 与底层 🖼️ 是叠放关系,✕ 的 onClick 会拦截点击,不会触发下层事件——当前设计正确;若想点击图片本体打开预览,需给图片单独挂事件。
  3. Dashed 虚线边框的圆角:加号格同时有 borderRadius(D.rSm) 与虚线边框,虚线沿圆角裁剪正常;但若圆角过大(>10),虚线与圆角的衔接可能出现锯齿,注意控制圆角值。
  4. TextArea 高度固定:height(80) 固定高度,输入多行文字会自动滚动而非增高——若希望自适应增高,需监听内容变化动态调整高度。
  5. 表单校验缺失:当前无照片时也能点"上传 0 张照片"(Toast"上传 0 张照片")——工程化应在 onClick 校验 photoCount > 0 与 activeEvent 已选,未满足则提示"请先选择照片"。

十一、扩展思考:照片元数据与 EXIF 的工程价值

上传页的"照片说明"(caption)是用户主动补充的元数据,而真实照片还自带大量被动元数据,值得展开:

  1. EXIF 信息:照片文件头自带拍摄时间、设备型号、GPS 定位、曝光参数等 EXIF 数据——用 @ohos.file 的图片接口可读取。上传时自动提取拍摄时间,可替代用户手动填日期。
  2. GPS 隐私:EXIF 中的定位信息是敏感数据(可暴露宿舍/教室位置),合规的上传流程应在客户端剥离 GPS 字段再上传——隐私保护是照片类产品的红线。
  3. 自动分类:按拍摄时间(活动当天)、按地点(体育馆/礼堂)自动归入对应活动相册,减少用户手动关联成本——上传页"关联活动"胶囊其实是在做"人肉分类",产品化后可由服务端图像识别/时间聚类自动推荐。
  4. 图片压缩策略:原图 3-5MB 上传耗流量,客户端应按用途分层压缩——缩略图(列表用,~100KB)+ 原图(预览用,~2MB),对应首页瀑布流与详情页全屏两种消费场景。
  5. 水印与版权:校园照片社区常给照片加"校园摄影师 @昵称"水印,既防搬运又传播创作者品牌——与我的页"校园摄影师"昵称联动,上传即打标。

十二、扩展思考:上传队列与断点续传设计

Demo 的"上传 3 张照片"Toast 背后,真实产品需要一套健壮的上传引擎:

  1. 上传队列:多张照片应串行/并发受限地上传(如同时 2 张),每张维护独立状态(等待中/上传中/成功/失败),九宫格角标从"✕"扩展为"进度环/✓/重试"——photoCount 状态机升级为数组状态对象。
  2. 失败重试:网络抖动导致某张失败时,自动重试 3 次(指数退避),仍失败则标记"重试"按钮——不因单张失败阻塞整批。
  3. 断点续传:大图分片上传,记录已传分片,App 被杀后恢复续传——@ohos.data.preferences 持久化队列状态,下次打开自动续传未完成任务。
  4. 弱网策略:监听 @ohos.net.connection 网络类型,Wi-Fi 下才允许原图上传,移动网络仅传缩略图,兼顾体验与流量成本。
  5. 后端接口设计:POST /api/photos(multipart)+ 携带 eventId(关联活动)、caption(描述)、exif(元数据)——返回照片 id,App 据其更新首页相册 count 与我的页统计,完成"上传 → 展示"闭环。

40 上传页删除照片

十三、系列横向对比:上传/发布类页面的范式演进

上传页属于"内容生产"类页面,本系列多个 App 提供了可对照的发布范式:

范式代表 App内容输入附加表单提交反馈
照片九宫格App 40 上传页3×3 网格选图关联活动 + 说明Toast 张数
表单校验App 38 发布页技能名称输入分类 + 熟练度 + 想学校验 Toast
资料表单App 34 上传页文件选择分类 + 描述Toast
文本发布App 35 发布页笔记标题+正文分类胶囊Toast

App 40 的九宫格选图是系列中唯一的多图选择器——它把"选择照片"这一核心动作放大成页面主体(3×3 网格 + ✕ 删除 + 虚线添加),而关联活动/照片说明退居为辅助表单,主次分明。相比之下 App 38 发布页是"文字输入为主、胶囊为辅",因为技能发布的核心信息是文字描述。发布页的布局重心永远放在"核心内容输入"上——图片内容用网格、文字内容用输入框,其余字段一律折叠为辅助区。

三态条件渲染(已选/加号/空位)是 App 40 上传页区别于其他发布页的技术亮点——它用 photoCount 单一状态驱动三种格子形态,比 App 38 的固定字段表单更"状态驱动"。若把九宫格方案与 App 38 的校验逻辑结合(选图必选 + 关联活动必选 + 说明可选),就是一个接近完整的产品级发布器。

40 上传页上传 Toast

十四、开发者视角:九宫格状态机的调试技巧

  1. photoCount 边界测试:重点测试 0、1、9 三个边界值——photoCount = 0 时首格应为"+"、photoCount = 9(若网格扩到 9 格)时应全为已选且无"+"格、点 ✕ 到 0 后不应出现负数——边界不清会导致 i === this.photoCount 分支永远无法命中。
  2. 三态渲染的单元验证:三态条件 if (i < photoCount) / else if (i === photoCount) / else 可用不同 photoCount 值(0/1/3/5)配合 dumpLayout 逐格验证格子形态,快速定位条件写错的分支。
  3. 点击事件的冒泡:✕ 角标在 Stack 上层、🖼️ 在下层,点击 ✕ 时确认不触发下层事件(当前正确)。若将来给 🖼️ 加"预览大图"事件,注意 ✕ 的 stopPropagation 处理,否则点 ✕ 会同时打开预览。
  4. aspectRatio 与 Grid 的兼容:aspectRatio(1) 在 Grid 单元格内依赖列宽推导高度——若 columnsTemplate 写错(如 '1fr 1fr'),格子会变宽高比失衡,先检查模板字符串语法。
  5. TextArea 与键盘:点击 TextArea 弹出软键盘会压缩 Scroll 可视区,上传按钮可能被键盘遮挡——用 expandSafeArea([SafeAreaType.KEYBOARD]) 或滚动到按钮位置,保证键盘弹出时仍可操作。

十五、FAQ 与一句话总结

Q1:为什么选择照片用 emoji 占位? 原型阶段模拟"已选照片"的状态流(3/9 → 加号位置 → ✕ 删除)才是重点,emoji 占位让这套状态机可完整演示。真实产品替换为 Image 缩略图,状态机逻辑零改动。

Q2:最多能选几张? 标题"选择照片 (3/9)"暗示上限 9 张,但网格只渲染 6 格。产品化时把 ForEach 数组扩到 0~8、+ 格加 9 上限校验即可,UI 会自动补全 3 行九宫格。

Q3:关联活动为什么是单选? 一张照片属于一个活动(一个相册),单选符合数据模型;若产品支持"同一照片多活动"(如跨活动合影),改为多选数组即可。

Q4:TextArea 的输入内容去哪里了? onChange 写入 @State caption,Demo 未提交服务端。真实实现随上传请求体一并提交,作为照片描述展示在相册详情页。

Q5:上传成功后页面会怎样? Demo 只弹 Toast。产品化应清空 photoCount、Toast"上传成功"并跳转回首页/对应相册,让用户看到新照片已出现在瀑布流中——"上传成功可见"是上传类产品的最基本信任闭环。

一句话总结:上传页用"3×3 九宫格三态渲染 + 单选胶囊 + TextArea + 全宽按钮"完成了一次照片上传的最小完整流程。Grid + aspectRatio 的等方格子、photoCount 单一状态驱动的三态条件渲染、Stack 右上角 ✕ 角标、虚线"+"格,是照片上传类页面最经典的一组 ArkUI 手法——状态机设计的精妙,正是本页最值得反复咀嚼的工程价值。

最后补充一处架构观察:上传页的 events 数组(4 个活动名)与活动页、首页的数据源是同名同序的,这保证了"上传的照片归入哪个活动"在全 App 内语义一致。若产品化后统一抽一个 EventStore(全局数据仓库),上传页、活动页、首页都从它读取活动列表,即可彻底消除"三处硬编码活动名"的维护风险——新增一个活动只需在 Store 中加一条记录,三页同步更新。这种"先静态后抽离"的演进路径,正是小型 Demo 走向工程化项目的自然过程:先在各页写死数据快速验证 UI 与交互,确认无误后收敛到统一数据层,改动成本可控、风险最低。上传页作为数据生产端,是最先应该接入统一数据层的页面。

从验收视角回看四张实机截图:首屏(3/9 三态九宫格 + 关联活动胶囊 + TextArea)、添加照片(4/9、加号格后移)、删除照片(回 3/9、加号格前移)、上传提交("上传 3 张照片" Toast)——四个状态完整验证了 photoCount 状态机"增/删/提交"三个方向的流转正确性,以及计数、格子形态、Toast 文案三者始终一致的状态约束。这也说明:状态驱动的页面,验收的重点不是"每个界面长什么样",而是"每个状态转换是否自洽"——本页的验证方法(逐一触发增删、核对计数)正是这种思路的最小实践。 整体而言,本页以三态状态机为核心,把照片选择的最小流程完整落地,配合四张实机截图的增删验证,为上传类页面提供了高完成度的参考实现。 若再接入相册选择器与上传引擎,即可无缝升级为生产级功能,本页已为此预留全部扩展点。 这条从原型到生产的演进路径,正是本页最具参考价值的部分。

配图

Logo

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

更多推荐