HarmonyOS 校园应用系列之showAns 状态驱动:ArkUI 实战下,AI 单词练习页三种答题模式切换设计
HarmonyOS 校园应用系列之showAns 状态驱动:ArkUI 实战下,AI 单词练习页三种答题模式切换设计
源码位置:
entry/src/main/ets/pages/Func2Tab.ets(199 行)
核心主题:答题模式切换 + 进度指示器 + 选择题卡片 + 拼写输入卡 + 选项交互反馈
技术关键词:Progress、条件渲染、状态机驱动 UI、选项按钮组、@Builder
一、页面定位与功能概述
单词练习页是用户进行学习互动的核心界面——它模拟了标准的在线答题体验:顶部显示当前题目进度,中间展示题目内容和四个选项(或拼写输入框),底部是"确认答案/下一题"按钮。页面支持三种答题模式切换:选择题(默认)、拼写题、听力题。
这个页面的核心设计思想是 “状态机驱动渲染”——整个页面由 showAns(是否已提交答案)驱动两种主要状态,不同状态下选项的样式、按钮的文案和行为都不同。虽然当前实现是前端 Mock 数据,但其组件结构完全兼容后端题库 API 的接入。


二、页面整体布局结构
build() {
Column() {
this.Header()
Scroll() {
Column({ space: 16 }) {
this.ModeRow()
this.ProgressRow()
this.QuestionCard()
if (this.mode === 0) {
this.OptionList()
} else {
this.InputCard()
}
this.ActionBtn()
}
.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)
}
// Header @Builder — 固定标题栏
@Builder
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)
}
根布局采用 Column 垂直排列:固定标题栏(Header)+ 可滚动内容区(Scroll 包裹)。内容区从上到下依次是模式切换 → 进度条 → 题目卡片 → 选项列表/输入卡(由 if (this.mode === 0) 条件渲染切换)→ 操作按钮。题目卡片、选项列表、输入卡都是白色圆角卡片(C.card 底 + D.rLg/D.rMd 圆角 + C.stroke 描边),答题场景的卡片化让每道题的边界清晰,同时 Scroll 保证小屏设备上内容可滚动。
三、ModeRow 答题模式切换
模式切换区提供三种答题方式:
@Builder
ModeRow() {
Row({ space: 10 }) {
ForEach(this.modes, (m: string, idx: number) => {
Text(m)
.fontSize(13)
.fontColor(this.mode === idx ? '#FFFFFF' : C.textSub)
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.backgroundColor(this.mode === idx ? C.primary : C.card)
.borderRadius(18)
.onClick(() => {
this.mode = idx;
this.picked = -1;
this.showAns = false;
})
}, (m: string) => m)
}
.width('100%')
}
这个组件的设计模式与首页的 CategoryBar 一脉相承——胶囊形状标签 + 条件三元表达式控制选中态。差异在于:首页的 CategoryBar 外层套了横向 Scroll(为未来扩展分类预留),而 ModeRow 是固定的 Row({ space: 10 }),三个标签左对齐紧凑排列,右侧留白。
切换模式时的状态重置 是本组件的关键细节:onClick 里不仅更新 this.mode,还同时重置 this.picked = -1(清除已选选项)和 this.showAns = false(回到未提交状态)——避免用户在选择题选了 A 后切到拼写题再切回来,残留的选中态造成困惑。
从产品角度看,三种模式的实际功能差异会很大:
- 选择题:显示题目 + ABCD 四选一(
mode === 0时渲染 OptionList) - 拼写题:显示提示 + TextInput 输入拼写(
mode !== 0时渲染 InputCard) - 听力题:题目卡顶部追加"🔊 听发音,选择正确拼写"提示,同样用 InputCard 输入
在真实产品中,切换模式时不仅 mode 变化,下方的作答区内容也会通过 if/else 条件渲染完全切换——这正是声明式 UI 的优势所在:数据驱动视图,只需改变数据,框架自动处理 DOM 更新。
四、ProgressRow 进度指示器
进度指示器是文字说明和进度条组成的单行布局:
@Builder
ProgressRow() {
Row({ space: 10 }) {
Text('第 ' + (this.current + 1) + ' / ' + this.quiz.length + ' 题')
.fontSize(12).fontColor(C.textSub)
Progress({ value: (this.current + 1) * 100 / this.quiz.length, total: 100 })
.color(C.primary).layoutWeight(1)
}
.width('100%')
}
进度值计算:(this.current + 1) * 100 / this.quiz.length 将"第几题/总题数"转为百分比。current 从 0 开始计数,所以第一题时 current=0,进度值为 (0+1)*100/4 = 25%。Progress 组件内部用 value/total 计算填充宽度。
布局设计:文字标签(12fp C.textSub)与进度条同行排列,Row({ space: 10 }) 控制间距,进度条用 layoutWeight(1) 占据剩余宽度——比上下两行布局更紧凑,也符合"文字说明 + 视觉进度"的扫视习惯。
进度条在答题场景中的心理作用:研究表明,可见的进度指示器能显著提升用户的任务完成率——它将抽象的"做题"转化为具体的"25% → 50% → 75% → 100%"目标感。游戏化的进度反馈让重复性的学习任务变得更有动力。
五、QuestionCard 题目卡片与 OptionList 选项列表
QuestionCard 与 OptionList 是本页最核心的两个 @Builder——前者展示题目(听力题模式追加发音提示),后者渲染四个可点击选项:
@Builder
QuestionCard() {
Column({ space: 8 }) {
if (this.mode === 2) {
Text('🔊 听发音,选择正确拼写').fontSize(12).fontColor(C.textDim)
}
Text(this.getCur().question)
.fontSize(16).fontWeight(FontWeight.Medium).fontColor(C.text).width('100%')
}
.width('100%').padding(18).backgroundColor(C.card).borderRadius(D.rLg)
.border({ width: 1, color: C.stroke })
}
@Builder
OptionList() {
Column({ space: 10 }) {
ForEach(this.getCur().options, (opt: string, idx: number) => {
Row({ space: 10 }) {
Text(String.fromCharCode(65 + idx)) // A, B, C, D
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(this.picked === idx ? '#FFFFFF' : C.textSub)
.width(30).height(30).textAlign(TextAlign.Center)
.backgroundColor(this.picked === idx ? C.primary : C.cardSoft)
.borderRadius(15)
Text(opt).fontSize(14).fontColor(C.text).layoutWeight(1)
if (this.showAns && idx === this.getCur().answer) {
Text('✓').fontSize(16).fontColor(C.ok)
}
}
.width('100%').padding(14)
.backgroundColor(this.picked === idx ? C.primarySoft : C.card)
.borderRadius(D.rMd)
.border({ width: 1, color: this.picked === idx ? C.primary : C.stroke })
.onClick(() => {
if (!this.showAns) { this.picked = idx; }
})
}, (opt: string, idx: number) => opt + idx)
}.width('100%')
}
5.1 题目展示
Text(this.getCur().question)
题目文本来自 getCur() 辅助方法——private getCur(): Quiz { return this.quiz[this.current]; },所有 @Builder 都通过它读取当前题目,current 变化时整页自动刷新。题目左对齐(16fp Medium),卡片 padding(18)、C.card 白底、D.rLg(20vp)大圆角。听力题模式(mode === 2)下题目上方会追加一行 🔊 听发音,选择正确拼写 的 12fp 提示。
5.2 选项按钮组
每个选项是一个 Row,包含三个子元素:
- 字母标签(A/B/C/D):圆形背景 + 居中文字
- 选项文字:左对齐的答案文本(
layoutWeight(1)占满剩余宽度) - 对勾标记(✓):仅提交后显示在正确答案上
选中态的四维变化:
- 字母标签:
C.cardSoft灰底灰字 →C.primary蓝底白字 - 整行背景:
C.card白色 →C.primarySoft浅蓝 - 整行边框:
C.stroke灰色 →C.primary蓝色 - 字母字重:Bold 常驻,颜色随选中态切换
这几个维度同时变化确保了选中状态的 “不可错过性” ——即使用户 peripheral vision(周边视力)不好,也能通过颜色变化感知到选中了哪个选项。
5.3 提交后的选项锁定与答案显示
.onClick(() => {
if (!this.showAns) { this.picked = idx; }
})
选项的点击回调里有 if (!this.showAns) 保护——提交答案后(showAns === true)再点击选项不会改变 picked,防止用户"提交后偷偷改答案"。同时提交后正确答案选项右侧会显示绿色 ✓(C.ok),遵循 “即时反馈” 原则;用户选错的选项不会显示 ✗,避免负面反馈打击学习积极性。
5.4 Quiz 接口
interface Quiz {
id: number; // 题目 ID
word: string; // 目标单词
question: string; // 题目文本(含完整问句)
options: string[]; // 选项数组
answer: number; // 正确答案索引
}
接口定义了完整的题目结构——question 字段直接存储完整问句(如 "abandon" 的意思是?),answer 记录正确答案索引用于提交后显示 ✓。在实际产品中,这个接口还会扩展 explanation: string(解析)、difficulty: number(难度)等字段。
六、ActionBtn 动态操作按钮
操作按钮根据 showAns 状态在"确认答案"和"下一题"之间切换:
@Builder
ActionBtn() {
Row({ space: 12 }) {
if (!this.showAns) {
Button('确认答案')
.width('100%').height(46).fontSize(15).fontColor('#FFFFFF')
.backgroundColor(C.primary).borderRadius(D.rMd)
.onClick(() => {
this.showAns = true;
promptAction.showToast({ message: '答案: ' + this.getCur().options[this.getCur().answer] });
})
} else {
Button('下一题')
.width('100%').height(46).fontSize(15).fontColor('#FFFFFF')
.backgroundColor(C.primary).borderRadius(D.rMd)
.onClick(() => {
this.showAns = false;
this.picked = -1;
if (this.current < this.quiz.length - 1) {
this.current++;
} else {
this.current = 0;
promptAction.showToast({ message: '练习完成!' });
}
})
}
}.width('100%')
}
按钮文案的条件切换:if (!this.showAns) 分支渲染
- 未提交时:显示"确认答案",点击后
showAns = true并 Toast 揭示正确答案('答案: ' + this.getCur().options[this.getCur().answer]) - 已提交后:显示"下一题",点击后重置
showAns = false、picked = -1,然后current++进入下一题
提交流程逻辑:
- 点击"确认答案":
showAns = true,选项锁定、正确答案显示 ✓,Toast 提示正确答案 - 点击"下一题":如果不是最后一题(
current < this.quiz.length - 1),current++进入下一题 - 如果是最后一题:
current = 0回到第一题,Toast 提示"练习完成!"
这个流程完整覆盖了 “单题作答 → 提交反馈 → 多题递进 → 全部完成” 的完整生命周期。按钮高度 46vp、字号 15fp、D.rMd 圆角,两种状态共用同一套视觉样式,仅文案与行为不同——用户注意力始终落在同一个位置。
七、Mock 数据与状态初始化
@State mode: number = 0; // 当前模式(0=选择题)
@State current: number = 0; // 当前题索引(0 起)
@State picked: number = -1; // 选中的选项(-1=未选)
@State showAns: boolean = false; // 是否已提交答案
private modes: string[] = ['选择题', '拼写题', '听力题'];
private quiz: Quiz[] = [
{ id: 1, word: 'abandon', question: '"abandon" 的意思是?', options: ['放弃', '获得', '坚持', '隐藏'], answer: 0 },
{ id: 2, word: 'brilliant', question: '"brilliant" 的意思是?', options: ['黑暗的', '聪明的', '粗糙的', '缓慢的'], answer: 1 },
{ id: 3, word: 'curious', question: '"curious" 的意思是?', options: ['冷漠的', '好奇的', '愤怒的', '疲惫的'], answer: 1 },
{ id: 4, word: 'fragile', question: '"fragile" 的意思是?', options: ['坚固的', '易碎的', '灵活的', '昂贵的'], answer: 1 },
];
picked 初始值为 -1 是一个重要的设计决策——用 -1 表示"未选择"而不是 0,因为 0 是合法的选项索引(对应 A 选项)。如果初始值用 0,那么 A 选项一开始就会呈现"已选中"状态,这显然不符合预期。
quiz 是 private 数组而非 @State:题库是静态展示数据,不参与状态变化;真正驱动 UI 的是 current(当前题索引)、picked(选中项)、showAns(提交态)三个 @State 变量。showAns 是本页状态机的核心——它同时控制选项锁定、✓ 显示和按钮文案切换。

八、ForEach 的 keyGenerator 设计
选项列表的 ForEach 使用"选项文字 + 索引"拼接作为 keyGenerator:
ForEach(this.getCur().options, (opt: string, idx: number) => {
// ... option UI
}, (opt: string, idx: number) => opt + idx)
keyGenerator 返回 opt + idx(如 '放弃0'、'获得1')。这种设计兼顾了两点:
- 选项文字可能重复(理论上不同选项可能有相同文字),纯文字做 key 会冲突
- 纯索引做 key 在数组重排时会错乱,拼接文字后即使顺序变化也能保持稳定
但在更通用的场景中(如选项支持动态添加/删除),建议使用业务 id 或内容的 hash 作为 key。

九、页面配色与主题一致性
练习页严格遵循了 Theme.ets 定义的蓝色主题体系:
- 进度条前景色:
C.primary(天蓝色 #0EA5E9) - 选中选项边框/背景:
C.primarySoft(极浅蓝 #E0F4FE)+C.primary(蓝色) - 操作按钮:
C.primary背景 + 白色文字 - 模式标签选中态:
C.primary背景 + 白色文字,未选中态C.card白底 +C.textSub灰字
统一的配色方案让四个页面虽然功能不同,但视觉上属于同一个应用。用户在 Tab 之间切换时不会感到"跳脱"——颜色、圆角、间距、字号都保持一致。这种一致性是专业级 App 的基本素养,而实现它的成本很低——只需要共享同一套 Theme.ets 常量定义即可。

十一、写在最后
练习页以 199 行代码实现了完整的答题交互流程:模式切换 Tab、进度指示器、题目展示、四选一选项按钮组、拼写输入卡、确认/下一题双态按钮。它是学习 ArkUI 状态机驱动 UI 和 条件样式 的最佳实战案例之一。
特别值得学习的是其 “提交后锁定” 设计——showAns 状态在提交后锁定选项(if (!this.showAns) 保护 onClick)、显示正确答案 ✓、切换按钮为"下一题",点击下一题后重置 picked = -1 并推进 current。这种 “防错 > 报错” 的设计理念让用户几乎不可能犯错,从而保证了流畅的学习体验。
十二、答题场景的状态机扩展设计
当前实现已经用 picked + showAns 两个变量组合出了"未选/已选/已提交"三种状态(提交后选项锁定、正确答案显示 ✓、按钮切换为"下一题")。在实际产品中,一个完整的答题状态机还可以进一步扩展:
| 状态 | 视觉表现 | 可执行操作 |
|---|---|---|
| idle | 选项灰色 | 选择选项 |
| selected | 选中项高亮 | 确认/更换选项 |
| submitted | 正确选项绿色 ✓,错误选项红色 ✗,按钮显示"下一题" | 进入下一题 |
| completed | 显示得分统计,按钮变为"查看解析" | 查看解析/重新挑战 |
实现这种扩展状态机的方式是引入一个新的 @State 变量:
@State answerState: 'idle' | 'selected' | 'submitted' | 'completed' = 'idle';
QuestionCard 和 ActionBtn 都根据 answerState 的值渲染不同的 UI。这种 “单一状态源驱动全局视图” 的模式是 React/Vue/ArkUI 等声明式框架的核心思想——状态即 UI 的函数(UI = f(state))。
十三、选择题 vs 拼写题 vs 听力题的模式差异分析
练习页顶部提供了三种模式切换,源码通过 if/else 条件渲染让选择题与拼写/听力题共用同一套骨架。从技术角度看,三种模式的 UI 差异显著:
选择题(mode === 0):题目文字 + ABCD 四个选项按钮 + 单选逻辑。核心组件是 ForEach 渲染的选项 Row 组(OptionList)。
拼写题/听力题(mode !== 0):共用 InputCard 输入卡——提示文字 + TextInput 输入框。核心变化是将选项按钮组替换为单个 TextInput:
@Builder
InputCard() {
Column({ space: 8 }) {
Text('请输入单词拼写').fontSize(12).fontColor(C.textDim)
TextInput({ placeholder: '拼写答案...' })
.height(48).backgroundColor(C.cardSoft).borderRadius(D.rSm)
.placeholderColor(C.textDim).placeholderFont({ size: 15 })
.onChange(() => { this.picked = 0; })
}
.width('100%').padding(16).backgroundColor(C.card).borderRadius(D.rLg)
.border({ width: 1, color: C.stroke })
}
注意 onChange 里 this.picked = 0 的巧妙用法——输入内容后把 picked 置为 0(非 -1),让"确认答案"按钮的提交逻辑与选择题保持一致。听力题模式下 QuestionCard 顶部还会追加 🔊 听发音,选择正确拼写 提示。在真实产品中还需要考虑:
- 自动聚焦到 TextInput
- 校验逻辑从"选中索引匹配"变为"输入字符串匹配(忽略大小写和空格)"
- 字数限制提示
听力题的完整形态(待扩展):播放按钮 + 音频进度条 + 选项/输入。这是最复杂的模式,需要引入 HarmonyOS 的音频播放能力(media 模块),额外组件包括播放/暂停按钮、当前时间/总时长显示、音量滑块等。
三种模式共享相同的 ProgressRow 和模式切换 Tab,但作答区完全不同。源码正是用 ArkUI 的 条件渲染(if/else) 实现的:
if (this.mode === 0) { this.OptionList() }
else { this.InputCard() }
更多推荐




所有评论(0)