鸿蒙原生应用 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 个块按"信息 → 选项 → 提交"漏斗展开

  1. Header — "参与投票"标题
  2. TitleCard — 投票标题 🏆 + 1,820 票 + 剩 3 天 + 规则
  3. SectionTitle('候选项') + CandidateList — 4 个候选人
  4. SubmitBtn — 提交按钮(已投票变"已投票 ✓"绿)

2 个 @Statepicked/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 天才是真紧迫)。

"投票后不可修改"是合规规则——真实项目应服务端验证(防止前端绕过)——"投票一次性"是大多数选举/评比的规则(一次投票不重复)。

校园投票页首屏 · 参与投票Header+TitleCard(1,820票+剩3天+规则)+4个候选人(⬜复选框+进度条+680票)+提交投票按钮

三、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 个关键设计

  1. picked === c.id 决定 ⬜/✅——picked 是"被选中的候选人 id",id 匹配的项显示 ✅、其他显示 ⬜——"单选语义"

  2. if (!this.voted) 拦截——已投票(voted = true)后不能修改——**"投票后锁定"**的真实业务约束

  3. 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 浅紫 vs C.card
  • 边框C.primary 紫 vs C.stroke
  • 进度条颜色C.primary 紫 vs C.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类型用途联动
pickednumber选中的候选人 id(-1=未选、1-4=已选)4 张卡的 ⬜/✅ + 进度条 + 背景 + 边框
votedboolean是否已投票按钮文案/颜色 + 候选人锁定

picked 影响 4 张卡的 3 个属性(⬜/✅ + 进度条 + 背景)——picked 一变,4 张卡整体重渲染。 voted 影响按钮 + 候选人可交互性——voted=true 后整页进入"只读态"。

2 个状态 = 整页交互——**"少状态、强联动"的问卷设计器(App 21)和"投票页 2 状态控制"**的设计哲学一致——业务复杂度低时,少状态 + 强联动比多状态 + 弱联动更优雅

六、4 状态实机演示(本文 4 张图)

本篇 4 张图是App 22 投票页的 4 种状态——真实展示 picked/voted 联动的视觉变化

  1. top(初始态):4 个 ⬜ 复选框 + 灰色进度条 + "提交投票"紫按钮
  2. mid(选张同学):第 1 张变 ✅ + 紫进度条 + 浅紫背景 + 紫边框
  3. lower(选王同学):第 3 张变 ✅ + 紫进度条 + 浅紫背景 + 紫边框(张同学回 ⬜)
  4. 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 兼任两职——读者做产品时可拆成"背景进度条(始终显示)+ 选中态前景高亮"两层。

校园投票页底部 · 已投票状态 · "已投票 ✓"绿按钮+投票成功Toast

九、投票交互的 5 个最佳实践

App 22 投票页的设计遵循 5 个最佳实践:

  1. 限制点击区域(只 ⬜ 可点)——避免误选
  2. "选中三件套"(背景/边框/进度条)——视觉强反馈
  3. 早返回校验(picked<0 拦截)——避免空提交
  4. 提交后锁定(voted=true 整体只读)——投票合规
  5. 按钮双态(紫"提交"/绿"已投票")——状态可视化

**"5 步投票交互设计"**值得读者在所有"单选 → 提交 → 锁定"场景复用(投票/投票/确认/支付/同意协议……)。

校园投票页中段 · 选中张同学 · ⬜变✅+紫进度条+浅紫背景

十、picked/voted 2 状态 vs 系列其他页面的状态对比

回顾系列各 Tab 的 @State 数量与"状态联动深度":

App/Tab@State状态联动
App 11 快递1物流状态切换
App 12 ACG1标签筛选
App 13 音乐1搜索
App 14 运动1Tab 视觉
App 15 环保1联动预览卡
App 16 商城6联动积分计算链
App 17 志愿1Tab 视觉
App 18 社交6联动预览卡 + 实时计算
App 19 摄影1联动封面图
App 20 阅读1搜索
App 21 问卷1联动条件渲染
App 22 首页1Tab 视觉
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 字段14 种题型条件渲染
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 步完成)"是投票平台的转化率关键

配图

Logo

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

更多推荐