鸿蒙原生应用 ArkTS 严格模式:投票页的单选、提交与 voted 锁定
App 22「校园投票管理」投票 Tab(Func1Tab),是本系列最完整的"单选 → 提交 → 锁定"投票交互。整页用 Header + TitleCard(标题/票数/剩余/规则)+ 候选项列表(4 个候选人 + ⬜/✅ 复选框 + 进度条 Progress 跟随 picked 变色)+ 提交按钮(picked<0 拦截、已投票变绿"已投票 ✓")。本篇基于
22-campus-vote/entry/src/main/ets/pages/Func1Tab.ets(约 128 行)逐段拆解,附 4 张实机截图(未选/选张/选王/已投票 4 种状态演示)。
一、整体结构:4 个 @Builder + 2 状态字段
Func1Tab 是**"投票交互"页面**——用户勾选候选人 → 进度条实时变色 → 提交后整页"锁定"(不能再选):
build() { Column() { this.Header() Scroll() { Column({ space: 16 }) { this.TitleCard() this.SectionTitle('候选项') this.CandidateList() this.SubmitBtn() } .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 个块按"信息 → 选项 → 提交"漏斗展开:
- Header— "参与投票"标题
- TitleCard— 投票标题 🏆 + 1,820 票 + 剩 3 天 + 规则
- SectionTitle('候选项') + CandidateList— 4 个候选人
- SubmitBtn— 提交按钮(已投票变"已投票 ✓"绿)
2 个 @State(picked/voted)——**"2 个状态控制整页"**是本页最大亮点。
项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
二、Header + TitleCard:投票信息
Header 是"参与投票"单行(与系列同款)。
TitleCard是投票信息卡:
@Builder TitleCard() { Column({ space: 8 }) { Text('🏆 最佳校园歌手评选').fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.text) Row({ space: 8 }) { Text('1,820 票').fontSize(13).fontColor(C.textDim) Text('剩 3 天').fontSize(13).fontColor(C.warn) } .width('100%') Text('每人限投 1 票,投票后不可修改').fontSize(12).fontColor(C.textDim) } .width('100%') .padding(16) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) }3 行信息:
- 第 1 行:投票标题 🏆("最佳校园歌手评选")——"emoji + 文字"是 Header 风格延续
- 第 2 行:1,820 票(灰)+ 剩 3 天(橙
C.warn)——"票数 + 剩余"双指标 - 第 3 行:规则"每人限投 1 票,投票后不可修改"(灰)——"投票规则"是投票场景的合规提示(对比问卷"每人限填 1 次")
"1,820 票"是当前总票数(与首页首页"1,856 票"略有差异——首页是 App 启动时初始值、投票页是 Func1Tab 实例化时的值)——"跨页数据不完全一致"是 demo 的小瑕疵。
"剩 3 天"用C.warn橙——"剩余时间 = 警示色"的延续(首页同款)——"截止前 3 天"已是中等警示(< 1 天才是真紧迫)。
"投票后不可修改"是合规规则——真实项目应服务端验证(防止前端绕过)——"投票一次性"是大多数选举/评比的规则(一次投票不重复)。
三、CandidateList:4 候选人 + ⬜ 复选框 + 进度条(本页核心)
CandidateList 是4 个候选人的列表,核心交互在 onClick 的 picked 状态联动:
@Builder CandidateList() { Column({ space: 12 }) { ForEach(this.candidates, (c: Candidate) => { Column({ space: 8 }) { Row({ space: 10 }) { Text(this.picked === c.id ? '✅' : '⬜').fontSize(20) .onClick(() => { if (!this.voted) { this.picked = c.id; } }) Text(c.name).fontSize(14).fontColor(C.text).layoutWeight(1) Text(c.votes + '票').fontSize(12).fontColor(C.primary) } .width('100%') Progress({ value: c.pct, total: 100 }) .color(this.picked === c.id ? C.primary : C.stroke).width('100%') } .width('100%') .padding(14) .backgroundColor(this.picked === c.id ? C.primarySoft : C.card) .borderRadius(D.rMd) .border({ width: 1, color: this.picked === c.id ? C.primary : C.stroke }) }, (c: Candidate) => c.id.toString()) } .width('100%') }3.1 4 个候选人的数据
private candidates: Candidate[] = [ { id: 1, name: '张同学 · 计算机学院', votes: 680, pct: 37 }, { id: 2, name: '李同学 · 艺术学院', votes: 520, pct: 28 }, { id: 3, name: '王同学 · 文学院', votes: 380, pct: 21 }, { id: 4, name: '赵同学 · 外语学院', votes: 240, pct: 14 } ];4 个候选人覆盖 4 个学院——**"候选人 = 学院代表"**让投票变成"学院之战"(增加投票参与度)——真实项目应给候选人加头像/简介/竞选口号。
4 个票数 37%+28%+21%+14% = 100%——百分比严格自洽(4 个候选人之和 100%)。
4 学院票数差异:计算机(680)> 艺术(520)> 文(380)> 外语(240)——"计算机学院人多 + 技术宅活跃"是合理的数据故事(真实校园里计算机学院常是最大院系)。
3.2 ⬜/✅ 复选框的状态联动
Text(this.picked === c.id ? '✅' : '⬜').fontSize(20).onClick(() => { if (!this.voted) { this.picked = c.id; } })——这是整个投票页的核心交互:
Text(this.picked === c.id ? '✅' : '⬜').fontSize(20) .onClick(() => { if (!this.voted) { this.picked = c.id; } })3 个关键设计:
picked === c.id决定 ⬜/✅——picked是"被选中的候选人 id",id 匹配的项显示 ✅、其他显示 ⬜——"单选语义"if (!this.voted)拦截——已投票(voted = true)后不能修改——**"投票后锁定"**的真实业务约束onClick只绑在 ⬜ 文本——整行其他位置(候选人名/票数)不响应点击——只点 ⬜/✅ 区域才能选——**"点击区域限制"**防止误操作
注意:这里没绑定整行 onClick——与 App 21 问卷"整行可点"不同——投票要求"精确点击 ⬜"(避免用户误选),问卷"整行可点"(降低操作门槛)。
3.3 进度条跟随 picked 变色
Progress({ value: c.pct, total: 100 }) .color(this.picked === c.id ? C.primary : C.stroke).width('100%')进度条颜色随 picked 联动:
- 未选中的候选人→
C.stroke浅灰(视觉弱化) - 被选中的候选人→
C.primary紫(视觉强调)
"未选中 = 灰、选中 = 紫"——**"进度条颜色 = 选中状态"**的视觉映射。
3.4 整卡背景的"选中态"
.backgroundColor(this.picked === c.id ? C.primarySoft : C.card) .borderRadius(D.rMd) .border({ width: 1, color: this.picked === c.id ? C.primary : C.stroke })**整张候选人卡的"三件套"**全部跟随 picked 联动:
- 背景:
C.primarySoft浅紫 vsC.card白 - 边框:
C.primary紫 vsC.stroke灰 - 进度条颜色:
C.primary紫 vsC.stroke灰
"3 处联动 = 强烈的选中视觉"——被选中的候选人在 4 张卡里"脱颖而出"。
对比 App 18 同款"选中三件套"(背景/边框/文字)——App 22 是"背景/边框/进度条"——"三件套 = 视觉强调的通用模式"。
四、SubmitBtn:双态按钮 + 校验 + 锁定
SubmitBtn 是双态按钮(未投票/已投票)+ 校验 + 锁定:
@Builder SubmitBtn() { Button(this.voted ? '已投票 ✓' : '提交投票') .width('100%').height(48) .fontSize(16).fontColor('#FFFFFF') .backgroundColor(this.voted ? C.ok : C.primary) .borderRadius(D.rMd) .onClick(() => { if (this.picked < 0) { promptAction.showToast({ message: '请先选择候选项' }); return; } this.voted = true; promptAction.showToast({ message: '投票成功!' }); }) }按钮双态:
- 未投票→ "提交投票" 紫底
- 已投票→ "已投票 ✓" 绿底(
C.ok)——绿色 = 成功(与 App 19 作品完成、App 18 互关成功同款语义)
校验 + 早返回:
if (this.picked < 0) { promptAction.showToast({ message: '请先选择候选项' }); return; } this.voted = true; promptAction.showToast({ message: '投票成功!' });- 未选(
picked < 0)→ 拦截 + Toast "请先选择候选项" - 已选→
voted = true锁定 + Toast "投票成功!"
picked初始值-1("无选中"用 -1 标记)——onClick设置picked = c.id(正整数 1-4)——"-1 是合法值(无选中)"——比用null更省心(数字比较 vs 对象比较)。
4.1 提交后的"3 重锁定"
提交成功后,voted = true触发 3 重连锁反应:
1. 候选人不可改:if (!this.voted) { this.picked = c.id; }——voted=true后 onClick 拦截,再点 ⬜/✅ 无效
2. 按钮变绿:按钮文字/背景变 "已投票 ✓" + 绿底
3. 进度条不响应:进度条颜色仍由picked决定(不是 voted)——但因为候选人锁定,整个页面进入"展示态"——"投票完成"是只读态
"1 状态变 → 3 联动"是"提交后锁定"的完整范式——"状态驱动 + 多处联动"是 ArkUI 的核心红利。
五、@State 的 2 字段设计
Func1Tab 有2 个 @State:
| @State | 类型 | 用途 | 联动 |
|---|---|---|---|
picked | number | 选中的候选人 id(-1=未选、1-4=已选) | 4 张卡的 ⬜/✅ + 进度条 + 背景 + 边框 |
voted | boolean | 是否已投票 | 按钮文案/颜色 + 候选人锁定 |
picked影响 4 张卡的 3 个属性(⬜/✅ + 进度条 + 背景)——picked一变,4 张卡整体重渲染。voted影响按钮 + 候选人可交互性——voted=true后整页进入"只读态"。
2 个状态 = 整页交互——**"少状态、强联动"的问卷设计器(App 21)和"投票页 2 状态控制"**的设计哲学一致——业务复杂度低时,少状态 + 强联动比多状态 + 弱联动更优雅。
六、4 状态实机演示(本文 4 张图)
本篇 4 张图是App 22 投票页的 4 种状态——真实展示 picked/voted 联动的视觉变化:
- top(初始态):4 个 ⬜ 复选框 + 灰色进度条 + "提交投票"紫按钮
- mid(选张同学):第 1 张变 ✅ + 紫进度条 + 浅紫背景 + 紫边框
- lower(选王同学):第 3 张变 ✅ + 紫进度条 + 浅紫背景 + 紫边框(张同学回 ⬜)
- bottom(已投票):王同学保持 ✅ + "已投票 ✓" 绿按钮 + "投票成功!" Toast
4 张图是同一页面的 4 帧——读者可对比看"未选→选中→换选→已投票"的状态变化——"交互演示"比静态截图更有信息量。
七、投票页的"投票后只读"与"问卷后跳转"的对比
App 22 投票 vs App 21 问卷——两种"任务型"应用的提交后行为:
| 维度 | App 22 投票 | App 21 问卷 |
|---|---|---|
| 提交后页面 | 锁定只读 | 跳转下一页 |
| 提交后用户 | 看结果(结果 Tab) | 继续填/看统计 |
| 提交后状态 | voted=true 持久 | 不持久 |
| 重复提交 | 禁止(拦截) | 通常允许 |
"投票后只读"是投票的合规要求(一人一票、不可改)——"问卷后跳转"是问卷的常规流程(填完看结果)——"业务规则决定提交后行为"。
八、Progress 进度条的双重作用
Progress({ value: c.pct, total: 100 }).color(this.picked === c.id ? C.primary : C.stroke):
- 作用 1:显示当前票数占比(37% / 28% / 21% / 14%)
- 作用 2:跟随 picked 变色(未选灰/选中紫)
"一个组件承担两个职责"——Progress既是"数据可视化"(显示比例)也是"交互反馈"(选中态颜色)——真实项目应拆分:单独的"票数进度"和单独的"选中态高亮"(避免混淆)。
App 22 的简化:demo 用同一个 Progress 兼任两职——读者做产品时可拆成"背景进度条(始终显示)+ 选中态前景高亮"两层。
九、投票交互的 5 个最佳实践
App 22 投票页的设计遵循 5 个最佳实践:
- 限制点击区域(只 ⬜ 可点)——避免误选
- "选中三件套"(背景/边框/进度条)——视觉强反馈
- 早返回校验(picked<0 拦截)——避免空提交
- 提交后锁定(voted=true 整体只读)——投票合规
- 按钮双态(紫"提交"/绿"已投票")——状态可视化
**"5 步投票交互设计"**值得读者在所有"单选 → 提交 → 锁定"场景复用(投票/投票/确认/支付/同意协议……)。
十、picked/voted 2 状态 vs 系列其他页面的状态对比
回顾系列各 Tab 的 @State 数量与"状态联动深度":
| App/Tab | @State | 状态联动 |
|---|---|---|
| App 11 快递 | 1 | 物流状态切换 |
| App 12 ACG | 1 | 标签筛选 |
| App 13 音乐 | 1 | 搜索 |
| App 14 运动 | 1 | Tab 视觉 |
| App 15 环保 | 1 | 联动预览卡 |
| App 16 商城 | 6 | 联动积分计算链 |
| App 17 志愿 | 1 | Tab 视觉 |
| App 18 社交 | 6 | 联动预览卡 + 实时计算 |
| App 19 摄影 | 1 | 联动封面图 |
| App 20 阅读 | 1 | 搜索 |
| App 21 问卷 | 1 | 联动条件渲染 |
| App 22 首页 | 1 | Tab 视觉 |
| App 22 投票 | 2 | 联动 ⬜/✅ + 进度条 + 背景 + 边框 + 按钮 |
| App 22 结果 | 0 | 纯展示 |
| App 22 我的 | 0 | 纯展示 |
App 22 投票页的 2 @State 是"精确控制"型——picked 决定 4 张卡的视觉(⬜/✅/进度条/背景/边框),voted 决定按钮 + 锁定——"2 状态覆盖整页交互"。
对比 App 16 商品兑换页(6 @State 控制 5 处联动)vs App 22 投票页(2 @State 控制 5 处联动):
- App 16:
productId联动预览卡 +quantity联动积分 +addrId/name/phone/note联动表单 + 校验——6 状态各管一处 - App 22:
picked联动 4 张卡 +voted联动按钮——2 状态各管多处
**"2 状态控制 5 处"比"6 状态控制 5 处"更优雅——状态数量 ≠ 业务复杂度——"少状态 + 强联动"**是设计水平的体现。
十一、picked 用 -1 而非 null 的工程考量
@State picked: number = -1;用 -1 而非 null/undefined是 ArkTS 状态管理的常见做法:
- 数字比较简单:
picked < 0判断"未选"、picked >= 0判断"已选" - 数字有默认值:-1 是合法数字,初始化不报错
- 避免空值陷阱:null/undefined 在模板里要加
?判空,数字不用
对比:
@State picked: number | null = null——picked ? ... : ...判空更繁琐@State picked: number = -1——picked < 0更简洁
"哨兵值(sentinel value)模式"——用 -1 作为"无值"的标志——比 null 简单——但要小心 -1 与合法值冲突(如果 id 可能是负数就要用别的哨兵)。
十二、投票页与系列其他表单的对比
App 22 投票页与系列其他"创建/填写"页对比:
| App | 表单类型 | @State | 特殊交互 |
|---|---|---|---|
| 13 音乐创建 | 多字段 | 5 | 类型/优先级/模板 |
| 14 场地预约 | 多字段 | 6 | 数量/地址/动态积分 |
| 16 商品兑换 | 多字段 | 6 | 数量+积分实时计算 |
| 17 活动创建 | 多字段 | 5 | 模板 |
| 19 作品发布 | 多字段 | 6 | 场景+滤镜预览 |
| 20 书库 | 多字段 | 5 | 模板 |
| 21 问卷创建 | 1 字段 | 1 | 4 种题型条件渲染 |
| 22 投票 | 2 字段 | 2 | 单选 + 提交锁定 |
"投票页"是系列最简的填写页——2 状态就够了——"投票的填写 = 选一个人"——比"音乐创建(5 字段)"或"商品兑换(6 字段)"简单得多。
"投票 = 极简填写"的业务意义:
- 用户来投票,不愿意填 10 个字段
- 投票的核心是"选谁",不是"填什么"
- 1-2 步完成 → 投票转化率高
真实投票平台的"1 步完成"原则:"尽量让用户用最少的点击完成任务"——App 22 的"1 次 ⬜ + 1 次提交 = 2 步"是投票的最佳实践。
十三、UI 组件的"语义复用"vs"角色复用"
App 22 投票页的 ⬜/✅ 文本是**"语义复用"**——同一个 Text 元素在不同状态显示不同内容:
- 未选:
⬜(语义"未选") - 已选:
✅(语义"已选")
"语义复用" vs "角色复用":
- 语义复用:同一个组件承担"相似但不同"的语义(⬜/✅ 都是"复选框语义")
- 角色复用:同一个组件承担"完全不同"的角色(按钮既做"提交"又做"显示已投票")
App 22 投票页的 ⬜/✅ 是语义复用(表达"选/未选")——按钮文字是角色复用("提交投票" + "已投票 ✓"两个角色)——两种复用模式都用得上。
"语义复用"在排版上更紧凑(同一位置不重排),"角色复用"在功能上更高效(同一按钮覆盖多个状态)——读者设计时根据场景选择。
十四、总结
App 22 投票交互页解析完毕。2 状态(picked/voted)驱动 4 张卡的 ⬜/✅ + 进度条 + 背景 + 边框 + 按钮是核心交互。"提交后锁定"是投票合规的必然要求,"早返回校验 + 双态按钮"是 2 状态控制的标准模式。"投票 = 极简填写(1 步完成)"是投票平台的转化率关键。