答题挑战是教育百科App里互动性最强的功能,也是我花时间最多的一个模块。用户需要在有限时间内回答问题,答对加分,答错显示正确答案。听起来简单,但要做好用户体验,细节真的很多——选项要打乱顺序、答案要即时反馈、进度要清晰可见……

今天就来聊聊这个答题流程是怎么实现的。
请添加图片描述

从页面参数说起

答题页面需要接收两个可选参数:题目分类和难度:

class QuizScreen extends StatefulWidget {
final String? category;
final String? difficulty;

const QuizScreen({super.key, this.category, this.difficulty});

@override
State createState() => _QuizScreenState();
}

为什么是可选的?因为用户可以选择"随机答题",不指定分类和难度,这时候这两个参数就是null。API会返回随机类别的题目。

状态变量的设计

答题页面的状态比较多,我来一个个解释:

class _QuizScreenState extends State {
List _questions = [];
int _currentIndex = 0;
int _score = 0;
bool _isLoading = true;
String? _selectedAnswer;
bool _answered = false;
List _shuffledAnswers = [];

  • _questions:存储从API获取的题目列表
  • _currentIndex:当前是第几题(从0开始)
  • _score:答对了几题
  • _isLoading:是否正在加载题目
  • _selectedAnswer:用户选择的答案
  • _answered:当前题目是否已作答
  • _shuffledAnswers:打乱顺序后的选项列表

为什么需要_answered这个变量?

这是为了防止用户重复作答。一旦选择了答案,就不能再改了。如果没有这个控制,用户可以一直点直到蒙对为止,那答题就没意义了。

加载题目

页面初始化时加载题目:

@override
void initState() {
super.initState();
_loadQuestions();
}

Future _loadQuestions() async {
try {
final questions = await ApiService.getTriviaQuestions(
amount: 10,
category: widget.category,
difficulty: widget.difficulty,
);

每次加载10道题,这个数量比较合适——太少了不过瘾,太多了用户容易疲劳。

if (mounted) {
  setState(() {
    _questions = questions;
    _isLoading = false;
    if (_questions.isNotEmpty) {
      _shuffleAnswers();
    }
  });
}

} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}

加载完成后立刻调用_shuffleAnswers()打乱第一题的选项。注意这里的mounted检查,老生常谈了,防止Widget销毁后还调用setState。

选项打乱的逻辑

这是答题功能的核心逻辑之一:

void _shuffleAnswers() {
final question = _questions[_currentIndex];
final correctAnswer = question[‘correct_answer’] as String;
final incorrectAnswers = question[‘incorrect_answers’] as List;

final answers = [
correctAnswer,
…incorrectAnswers.map((e) => e.toString()),
];
answers.shuffle();

_shuffledAnswers = answers.map((a) => _decodeHtml(a)).toList();
}

API返回的数据里,正确答案和错误答案是分开的。我们需要把它们合并成一个列表,然后调用shuffle()方法随机打乱。

为什么要打乱?

如果不打乱,正确答案永远在第一个位置,用户很快就会发现这个规律。打乱后每道题的正确答案位置都是随机的,才能真正考验用户的知识。

HTML实体解码

API返回的文本经常包含HTML实体,比如"表示引号,'表示撇号:

String _decodeHtml(String text) {
return text
.replaceAll(‘"’, ‘"’)
.replaceAll(‘’‘, "’")
.replaceAll(‘&’, ‘&’)
.replaceAll(‘<’, ‘<’)
.replaceAll(‘>’, ‘>’)
.replaceAll(‘é’, ‘é’)
.replaceAll(‘ñ’, ‘ñ’)
.replaceAll(‘ö’, ‘ö’)
.replaceAll(‘ü’, ‘ü’);
}

这个方法把常见的HTML实体转换成正常字符。说实话,这种处理方式不够优雅,更好的做法是用html_unescape这样的库。但为了减少依赖,我选择手动处理最常见的几个。

用户选择答案的处理

这是整个答题流程最关键的部分:

void _selectAnswer(String answer) {
if (_answered) return; // 已作答则忽略

final question = _questions[_currentIndex];
final correctAnswer = _decodeHtml(question[‘correct_answer’]);

setState(() {
_selectedAnswer = answer;
_answered = true;
if (answer == correctAnswer) {
_score++;
}
});

首先检查是否已作答,已作答就直接返回。然后记录用户的选择,标记为已作答,如果答对了就加分。

// 延迟1.5秒后进入下一题
Future.delayed(const Duration(milliseconds: 1500), () {
if (!mounted) return;

if (_currentIndex < _questions.length - 1) {
  setState(() {
    _currentIndex++;
    _selectedAnswer = null;
    _answered = false;
    _shuffleAnswers();
  });
} else {
  // 答完所有题目,跳转到结果页
  Navigator.pushReplacement(
    context,
    MaterialPageRoute(
      builder: (_) => QuizResultScreen(
        score: _score,
        total: _questions.length,
        category: widget.category,
        difficulty: widget.difficulty,
      ),
    ),
  );
}

});
}

选择答案后等待1.5秒,让用户有时间看到结果(对了还是错了),然后自动进入下一题。如果是最后一题,就跳转到结果页面。

为什么用pushReplacement而不是push

因为用户不应该从结果页返回到答题页。想象一下,用户看完成绩按返回键,回到了最后一题的页面,这体验多奇怪。pushReplacement会替换当前页面,按返回键会直接回到答题入口。

页面布局

AppBar显示当前进度和得分:

@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: const Text(‘答题挑战’)),
body: const Center(child: CircularProgressIndicator()),
);
}

if (_questions.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text(‘答题挑战’)),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(‘加载题目失败,请返回重试’),
],
),
),
);
}

先处理加载中和加载失败的情况。加载失败时显示友好的提示,而不是空白页面。

return Scaffold(
appBar: AppBar(
title: Text(‘第 ${_currentIndex + 1} / questions.length题′),actions:[Center(child:Padding(padding:constEdgeInsets.only(right:16),child:Container(padding:constEdgeInsets.symmetric(horizontal:12,vertical:6),decoration:BoxDecoration(color:Theme.of(context).colorScheme.primaryContainer,borderRadius:BorderRadius.circular(20),),child:Row(mainAxisSize:MainAxisSize.min,children:[constIcon(Icons.star,size:18),constSizedBox(width:4),Text(′{_questions.length} 题'), actions: [ Center( child: Padding( padding: const EdgeInsets.only(right: 16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.star, size: 18), const SizedBox(width: 4), Text( 'questions.length),actions:[Center(child:Padding(padding:constEdgeInsets.only(right:16),child:Container(padding:constEdgeInsets.symmetric(horizontal:12,vertical:6),decoration:BoxDecoration(color:Theme.of(context).colorScheme.primaryContainer,borderRadius:BorderRadius.circular(20),),child:Row(mainAxisSize:MainAxisSize.min,children:[constIcon(Icons.star,size:18),constSizedBox(width:4),Text(_score’,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
],
),
body: _buildQuizContent(),
);
}

标题显示"第 X / 10 题",右上角用一个小标签显示当前得分。这样用户随时都能知道自己的进度和成绩。

题目内容区域

这部分包含进度条、难度标签、题目和选项:

Widget _buildQuizContent() {
final question = _questions[_currentIndex];
final correctAnswer = _decodeHtml(question[‘correct_answer’]);
final difficulty = question[‘difficulty’] ?? ‘medium’;
final category = _decodeHtml(question[‘category’] ?? ‘’);

return Column(
children: [
// 进度条
LinearProgressIndicator(
value: (_currentIndex + 1) / _questions.length,
backgroundColor: Colors.grey[200],
minHeight: 4,
),

进度条放在最顶部,用颜色填充来表示答题进度。value是0到1之间的数值,当前是第3题就是0.3。

  Expanded(
    child: SingleChildScrollView(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 难度和分类标签
          Wrap(
            spacing: 8,
            children: [
              _buildDifficultyBadge(difficulty),
              if (category.isNotEmpty)
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.1),
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Text(
                    category,
                    style: TextStyle(fontSize: 12, color: Colors.grey[600]),
                  ),
                ),
            ],
          ),

难度标签用不同颜色区分,分类标签用灰色,两者并排显示。

          const SizedBox(height: 20),
          // 题目文字
          Text(
            _decodeHtml(question['question']),
            style: Theme.of(context).textTheme.titleLarge?.copyWith(
              fontWeight: FontWeight.w600,
              height: 1.4,
            ),
          ),
          const SizedBox(height: 28),
          // 选项列表
          ..._shuffledAnswers.asMap().entries.map((entry) {
            final index = entry.key;
            final answer = entry.value;
            return Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: _buildAnswerOption(answer, correctAnswer, index),
            );
          }),
        ],
      ),
    ),
  ),
],

);
}

题目文字用大号字体,行高1.4让多行文字更易读。选项用asMap().entries遍历,这样可以同时获取索引和值。

选项按钮的实现

这是答题交互的核心组件:

Widget _buildAnswerOption(String answer, String correctAnswer, int index) {
final letters = [‘A’, ‘B’, ‘C’, ‘D’];
final letter = index < letters.length ? letters[index] : ‘’;

// 确定选项的状态和颜色
Color? backgroundColor;
Color? borderColor;
Color textColor = Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black;
IconData? trailingIcon;

每个选项前面加上A、B、C、D的字母标识,这是答题界面的常见设计。

if (_answered) {
if (answer == correctAnswer) {
// 正确答案:绿色
backgroundColor = Colors.green.withOpacity(0.1);
borderColor = Colors.green;
trailingIcon = Icons.check_circle;
} else if (answer == _selectedAnswer) {
// 用户选错了:红色
backgroundColor = Colors.red.withOpacity(0.1);
borderColor = Colors.red;
trailingIcon = Icons.cancel;
}
}

作答后,正确答案显示绿色背景和勾选图标;如果用户选错了,错误选项显示红色背景和叉号图标。这样用户可以清楚地看到哪个是对的、自己选的是什么。

return Material(
color: backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: _answered ? null : () => _selectAnswer(answer),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor ?? Colors.transparent,
width: 2,
),
),
child: Row(
children: [
// 字母标识
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: borderColor?.withOpacity(0.2) ??
Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
letter,
style: TextStyle(
fontWeight: FontWeight.bold,
color: borderColor ?? Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(width: 12),
// 选项文字
Expanded(
child: Text(
answer,
style: TextStyle(
fontSize: 15,
fontWeight: _selectedAnswer == answer ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
),
// 结果图标
if (trailingIcon != null)
Icon(trailingIcon, color: borderColor, size: 24),
],
),
),
),
);
}

已作答后onTap设为null,这样选项就不可点击了。用户选中的选项文字会加粗,方便识别。

写在最后

答题挑战的实现涉及到很多细节:选项打乱保证公平性,延迟跳转让用户看清结果,颜色和图标让对错一目了然,进度条和得分让用户了解当前状态。

这个功能的核心是状态管理。_answered控制是否可以作答,_selectedAnswer记录用户选择,_currentIndex跟踪进度,_score累计得分。这些状态相互配合,构成了完整的答题流程。

下一篇我们来看答题分类页面,那里会展示所有可用的题目类别,让用户可以选择感兴趣的领域进行挑战。


本文是Flutter for OpenHarmony教育百科实战系列的第十四篇,后续会持续更新更多内容。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐