HarmonyOS问卷引擎连载05:分组导航与跨页缺答定位【共创稿事节】
课程评价问卷做到动态必填、草稿恢复以后,一个新的问题开始显现:问卷内容一多,提交前的“还有两道必填未答”并不能帮助填写者找到那两道题。上一节已经规定,只有当前可见且必填的题目参与提交检查;这一节进一步把问题定位到分组,再定位到题目。页面需要保留基础信息、教学评价、实践环节和总体反馈四个分组,同时保证条件跳题改变可见性时,分组进度与定位索引随之更新。

题目身份不能跟随页面顺序变化
问卷最初只有几个题目时,直接通过下标找到第几题似乎很自然。后来加上条件跳题和问卷版本迁移,同一题可能从第三位变成第五位,或者整个分组临时隐藏。把草稿答案、错误提示和滚动位置全部绑定数组下标,任何一次规则变化都可能定位错题。这里继续沿用前四篇的稳定题目ID,并新增groupId作为导航归属。
组织工程时,把校验函数与UI放在不同文件:model/Question.ets保留题目与依赖规则,service/VisibleQuestionResolver.ets计算当前可见集合,service/GroupProgress.ets生成每组进度和缺答索引,pages/SurveyPage.ets负责列表与跳转。题目的顺序可以由问卷规则版本提供,但同一个ID在版本升级时应有明确的迁移映射。
先定义分组、题目和答案模型。下面第一段代码解决“当前应该统计哪些题目”。它不去修改原始题库,而是从完整题库派生可见问题;前面章节的条件依赖规则在同一个函数里得到复用。
import { QuestionRule } from '../model/QuestionRule';
import { checkDependencies } from '../rules/DependencyGraph';
import { rebuildSurvey } from '../rules/VisibilityEngine';
export interface Question extends QuestionRule {
groupId: string;
required: boolean;
order: number;
}
export type AnswerMap = Map<string, string>;
export function visibleQuestions(questions: Question[], answers: AnswerMap): Question[] {
const report = checkDependencies(questions);
if (report.errors.length > 0 || report.unresolvedIds.length > 0) {
throw new Error('题目依赖配置存在错误');
}
const snapshot = rebuildSurvey(questions, report.orderIds, answers);
const visible = new Set<string>(snapshot.visibleIds);
return questions.filter((q: Question) => visible.has(q.id))
.sort((a: Question, b: Question) => a.order - b.order);
}
export function isAnswered(q: Question, answers: AnswerMap): boolean {
const value = answers.get(q.id) ?? '';
return value.trim().length > 0;
}
这里继续使用第二篇建立的依赖图检查和拓扑重算函数,避免把多级规则退化为单层条件判断;Question在原有QuestionRule基础上增加分组和必填字段。答案仍由Map按题目ID保存,字符串承接单选题与文本题的基本答案。多选题需要在数据层使用独立的数组字段,或把答案标准化后再交给isAnswered;不能简单地把空数组序列化成字符串[]就认为用户已经完成回答。条件跳题导致某个子题隐藏时,要调用此前的清理逻辑移除旧答案,避免提交快照中残留不可见题目的评分。
每个分组展示的是可见必填完成度
左侧导航或者顶部卡片可以显示“基础信息3/3、教学评价2/4、实践环节1/3、总体反馈0/2”。分母必须来自当前可见且必填的题目,而不是整个题库的题目总数。否则用户选择“未安排实践项目”后,实践环节中的依赖题消失,分组进度仍显示未完成,看起来像怎么填写也不能通过。
第二段代码把可见集合整理成进度记录,并从全部可见题中提取缺答目标。这份计算结果同时提供给进度卡片、提交前校验和缺答跳转;三处不能分别做一遍筛选,避免某一处忘记条件依赖后出现不一致。
export interface GroupProgress {
groupId: string;
done: number;
total: number;
missingIds: string[];
}
export function collectGroupProgress(
groupIds: string[], questions: Question[], answers: AnswerMap
): GroupProgress[] {
const visible = visibleQuestions(questions, answers);
return groupIds.map((groupId: string) => {
const required = visible.filter((q: Question) =>
q.groupId === groupId && q.required);
const missingIds = required.filter((q: Question) =>
!isAnswered(q, answers)).map((q: Question) => q.id);
return { groupId, total: required.length,
done: required.length - missingIds.length, missingIds };
});
}
某个分组的可见必填总数为零时,它不应该被显示为“0/0未完成”。界面可以写成“当前无必填”,让用户知道这不是错误状态。对于选填题,回答与否可以独立显示,但不会影响提交门禁。如果在输入过程中调用这些纯函数,建议先更新答案快照,再统一生成下一次的进度数据;如果先算统计再改答案,导航上的数字通常会晚一拍。

用一份扁平索引解决分组与题目跳转
我更倾向于让分组导航和题目列表共用一份扁平显示数据,而不是为每组创建互不相干的页码。显示数据包含分组标题行和题目行,缺答定位时按题目ID取得当前渲染位置;即使题目由于条件规则隐藏,也不会留下一个旧下标。列表顺序确定后,Scroller.scrollToIndex可以将对应行带入视区。
第三段代码生成扁平行数据。为了让代码的含义清楚,标题行和题目行分别有稳定的key;页面仅对当前可见题目建索引,不能把隐藏题保存在列表里靠透明度处理。
export interface SurveyRow {
key: string;
kind: 'HEADER' | 'QUESTION';
groupId: string;
questionId: string;
title: string;
}
export function buildSurveyRows(
groupIds: string[], names: Map<string, string>,
questions: Question[], answers: AnswerMap
): SurveyRow[] {
const visible = visibleQuestions(questions, answers);
const rows: SurveyRow[] = [];
groupIds.forEach((groupId: string) => {
rows.push({ key: `group:${groupId}`, kind: 'HEADER', groupId,
questionId: '', title: names.get(groupId) ?? groupId });
visible.filter((q: Question) => q.groupId === groupId)
.forEach((q: Question) => rows.push({ key: `question:${q.id}`,
kind: 'QUESTION', groupId, questionId: q.id, title: q.title }));
});
return rows;
}
export function rowIndexOf(rows: SurveyRow[], id: string): number {
return rows.findIndex((row: SurveyRow) =>
row.kind === 'QUESTION' && row.questionId === id);
}
分组标题本身也占列表的一行,索引从零开始,因此跳转时应直接从实际rows中查找,而不是把题目在组内的下标相加。对于长问卷,还可以用ListItemGroup显示带标题的内容,但定位下标需要考虑分组容器;本节先使用普通ListItem统一索引,便于维护。发布问卷新版本后,要重新派生列表和缺答集合,不能用旧草稿保存的像素偏移恢复定位。
把定位结果和输入焦点分开
滚动到某个缺答题,只意味着它进入可见区域,并不等于输入框获得焦点。对单选、评分和文本题应分别处理焦点策略:单选可以突出目标卡片,文本题可以在用户明确点击后获得键盘焦点,评分题则突出待评分星级。不要在自动跳转瞬间同时弹出键盘,避免目标卡片反而被遮挡。
第四段代码在页面中绑定Scroller,根据缺答列表找到第一个未完成ID。这里保证rows已经建立,并且点击定位时可见条件没有改变。条件答案更新时先调用业务层清理隐藏答案,再刷新列表与进度;目标若已隐藏,应当取消高亮而不是继续滚动到原来的位置。
@Entry
@Component
struct SurveyGroupPage {
@State rows: SurveyRow[] = [];
@State missing: string[] = [];
@State focusedQuestionId: string = '';
private listScroller: Scroller = new Scroller();
locateFirstMissing(): void {
if (this.missing.length === 0) return;
const id = this.missing[0];
const index = rowIndexOf(this.rows, id);
if (index < 0) {
this.focusedQuestionId = '';
return;
}
this.focusedQuestionId = id;
this.listScroller.scrollToIndex(index, true);
}
build() {
Column({ space: 12 }) {
Button('查看下一处缺答').onClick(() => this.locateFirstMissing())
List({ space: 8, scroller: this.listScroller }) {
ForEach(this.rows, (row: SurveyRow) => {
ListItem() {
Text(row.title)
.fontColor(row.questionId === this.focusedQuestionId ?
Color.Red : Color.Black)
.padding(12)
}
}, (row: SurveyRow) => row.key)
}.height('75%')
}.padding(12)
}
}
@State里保存的rows与missing应由同一次计算结果替换,避免用户正准备跳转时缺答列表和页面显示不同步。若选项触发题目重新排序,可在新的列表完成布局后再提供定位入口,或者通过稳定ID重新求索引;过早用旧索引滚动会看到错误位置。对于分组特别多的问卷,列表还应考虑懒加载的使用条件和题目组件的状态复用,不能只依赖增加预加载行数解决性能问题。

如何观察真正重要的故障线索
调试过程中,我会优先检查四个状态序列。第一类是从“有实践项目”改成“没有”,实践组必填数量是否同步缩小,隐藏问题的答案是否已清理。第二类是从草稿恢复到新问卷版本,是否使用新题库派生分组统计,缺答ID是否仍属于当前可见集合。第三类是点击“下一处缺答”后,目标ID与滚动索引对应的题目是否一致。第四类是用户已经完成一道题,却仍高亮旧缺答位置,说明列表、答案和校验集合的更新次序需要调整。
在HiLog中记录formRevision、groupId、visibleCount、missingCount、targetQuestionId和rowIndex就足以跟踪主要路径。具体回答文本不应该出现在调试日志里,尤其是带有个人信息的自由填写题。对于必填判定,服务层返回题目ID而非把错误提示文本固定写死,这样后续更换问卷文案时也能稳定定位。
整套导航的关键不是按钮多,而是让问卷规则、可见题集合、分组进度和滚动索引都从同一份答案状态推导。这样条件跳题、草稿恢复、题库修订都不会让“还有一道题没答”变成一条找不到目标的提示。下一步再接入提交结果汇总时,也可以直接复用这套分组与缺答索引,不需要增加另一份问卷状态。
分组统计还有一个很实际的展示细节:同一题目可能因为问卷规则升级从“教学评价”调整到“实践环节”。刷新分组后,应按题目的当前groupId重新聚合,而不是保留草稿里旧的分组名。对于长文本答案,页面切换或滚动时要让输入内容回写稳定ID对应的答案快照;复用列表组件不能让上一行的输入状态带到下一行。定位记录可以短暂保存目标题目ID,但不能把列表下标当成需要长期持久化的草稿字段。
此外,用户点击某组的进度卡片时,可以先定位该组第一行;点击“缺答”入口才定位当前最早的缺答题。两种行为共享rowIndexOf,但提示含义不同。为了便于无障碍操作,目标行除颜色高亮外还应给出文字提示,错误位置应能通过朗读或键盘导航识别。这些细节比单纯增加一个红色边框更能减少反复滚动。
相关资料
更多推荐


所有评论(0)