Flutter for OpenHarmony 教育百科实战:问答
本文介绍了教育百科App中知识问答功能的实现细节。该功能通过交互式答题检验用户知识储备,包含题目分类、难度选择和快速开始等模块。文章重点讲解了状态变量设计、分类数据加载和页面布局实现,包括快速开始卡片的渐变背景设计和难度选择区的视觉优化。代码示例展示了使用Flutter框架构建问答页面的具体方法,强调了错误处理和用户体验的注意事项。
知识问答是教育百科App中最有趣的功能之一,用户可以通过答题来检验自己的知识储备。老实说,做这个模块的时候我挺兴奋的,因为它不像其他页面那样只是展示数据,而是有真正的交互——选择难度、选择分类、答题、看成绩,整个流程下来还挺有成就感的。
这个页面需要展示题目分类、难度选择,还要有快速开始的入口。下面来看看具体怎么实现。

状态变量的设计
问答页面需要管理的状态比较多:
class QuizTab extends StatefulWidget {
const QuizTab({super.key});
State<QuizTab> createState() => _QuizTabState();
}
class _QuizTabState extends State<QuizTab> {
List<dynamic> _categories = [];
bool _isLoading = true;
int _totalScore = 0;
int _totalQuizzes = 0;
void initState() {
super.initState();
_loadCategories();
}
}
_categories存储从API获取的题目分类,_isLoading控制加载状态的显示。_totalScore和_totalQuizzes用于统计用户的答题情况,虽然目前这两个值还没有持久化存储,但预留着以后用。
为什么要在这里加载分类数据? 因为问答页面需要展示热门分类,让用户可以快速选择感兴趣的领域。如果不加载分类数据,用户只能用"快速开始"功能,体验会差很多。
加载题目分类
从Open Trivia API获取所有可用的题目分类:
Future<void> _loadCategories() async {
try {
final categories = await ApiService.getTriviaCategories();
if (mounted) {
setState(() {
_categories = categories;
_isLoading = false;
});
}
} catch (e) {
print('loadCategories error: $e');
if (mounted) {
setState(() {
_categories = [];
_isLoading = false;
});
}
}
}
这个API返回的数据格式是这样的:
{
"trivia_categories": [
{"id": 9, "name": "General Knowledge"},
{"id": 10, "name": "Entertainment: Books"},
...
]
}
每个分类有一个id和name,id用于后续请求特定分类的题目。
关于错误处理: 即使加载失败,也要把_isLoading设为false,否则页面会一直显示加载中。加载失败时_categories设为空数组,页面会显示"暂无分类"的提示,总比卡在加载状态好。
页面整体布局
问答页面用ListView作为主体,包含快速开始卡片、难度选择和分类列表:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('知识问答'),
actions: [
IconButton(
icon: const Icon(Icons.leaderboard),
onPressed: () => _showStats(context),
),
],
),
AppBar右侧有一个排行榜按钮,点击可以查看答题统计。用
Icons.leaderboard这个图标,一看就知道是排行榜或统计相关的功能。
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadCategories,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildQuickStart(context),
const SizedBox(height: 24),
_buildDifficultySection(context),
const SizedBox(height: 24),
_buildCategoriesSection(context),
const SizedBox(height: 100),
],
),
),
);
}
RefreshIndicator让用户可以下拉刷新分类数据,虽然分类数据不太会变,但有这个功能总比没有好。底部留出100的空间是为了避免被底部导航栏遮挡。
快速开始卡片
快速开始是最醒目的入口,用渐变背景突出显示:
Widget _buildQuickStart(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
),
clipBehavior: Clip.antiAlias这个属性很重要,它让渐变背景能正确显示Card的圆角。如果不加这个,渐变色会超出圆角边界,很难看。
为什么用主题色做渐变? 用primary和secondary作为渐变色,好处是会自动适配用户选择的主题。如果用户切换了主题色,这个卡片的颜色也会跟着变,保持整体风格的一致性。
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bolt, color: Colors.white, size: 28),
const SizedBox(width: 8),
Text(
'快速开始',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
闪电图标(Icons.bolt)暗示"快速"的含义,和"快速开始"的文字呼应。图标用28的尺寸,比标题文字稍大一点,视觉上更平衡。
const SizedBox(height: 8),
const Text(
'随机10道题目,测试你的知识储备!',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _startQuiz(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Theme.of(context).colorScheme.primary,
),
child: const Text('开始答题'),
),
],
),
),
),
);
}
按钮用白色背景和主题色文字,在渐变背景上非常醒目。
Colors.white70是70%透明度的白色,用于副标题,不会太抢眼。
难度选择
提供三种难度供用户选择:
Widget _buildDifficultySection(BuildContext context) {
final difficulties = [
{'name': '简单', 'value': 'easy', 'color': Colors.green, 'icon': Icons.sentiment_satisfied},
{'name': '中等', 'value': 'medium', 'color': Colors.orange, 'icon': Icons.sentiment_neutral},
{'name': '困难', 'value': 'hard', 'color': Colors.red, 'icon': Icons.sentiment_very_dissatisfied},
];
用Map来组织难度数据,包括显示名称、API参数值、颜色和图标。图标用的是表情系列:笑脸表示简单,中性脸表示中等,难过脸表示困难。这种设计挺直观的,一看就懂。
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('选择难度', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(
children: difficulties.map((d) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Card(
child: InkWell(
onTap: () => _startQuiz(context, difficulty: d['value'] as String),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(d['icon'] as IconData, color: d['color'] as Color, size: 32),
const SizedBox(height: 8),
Text(d['name'] as String, style: TextStyle(color: d['color'] as Color, fontWeight: FontWeight.bold)),
],
),
),
),
),
),
)).toList(),
),
],
);
}
三个难度卡片用Row和Expanded横向排列,每个卡片等宽。
Padding(padding: const EdgeInsets.symmetric(horizontal: 4))给卡片之间留出8像素的间距(左右各4像素)。
颜色的选择:
- 绿色表示简单,给人轻松、安全的感觉
- 橙色表示中等,有一定挑战但不至于太难
- 红色表示困难,警示用户这会很有挑战性
这种颜色编码是很常见的设计模式,用户不需要看文字就能大概判断难度。
分类网格展示
热门分类使用GridView展示:
Widget _buildCategoriesSection(BuildContext context) {
final popularCategories = _categories.take(8).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('热门分类', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
TextButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuizCategoriesScreen())),
child: const Text('查看全部'),
),
],
),
_categories.take(8)只取前8个分类作为热门分类展示。为什么是8个?因为GridView每行2个,8个刚好4行,不会太多也不会太少。
const SizedBox(height: 12),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: popularCategories.length,
itemBuilder: (context, index) {
final category = popularCategories[index];
return Card(
child: InkWell(
onTap: () => _startQuiz(context, category: category['id'].toString()),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(_getCategoryIcon(category['name']), color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_translateCategory(category['name']),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
],
),
),
),
);
},
),
],
);
}
GridView的配置说明:
shrinkWrap: true— 让GridView根据内容自适应高度,而不是占满剩余空间physics: const NeverScrollableScrollPhysics()— 禁用GridView自身的滚动,让外层ListView统一处理滚动childAspectRatio: 2.5— 宽高比为2.5,意味着每个格子的宽度是高度的2.5倍,是扁平的矩形
这两个属性配合使用,可以把GridView嵌套在ListView里而不会有滚动冲突。
分类图标映射
根据分类名称返回对应的图标:
IconData _getCategoryIcon(String name) {
if (name.contains('Science')) return Icons.science;
if (name.contains('History')) return Icons.history_edu;
if (name.contains('Geography')) return Icons.public;
if (name.contains('Art')) return Icons.palette;
if (name.contains('Sports')) return Icons.sports;
if (name.contains('Music')) return Icons.music_note;
if (name.contains('Film')) return Icons.movie;
if (name.contains('Book')) return Icons.menu_book;
if (name.contains('Computer')) return Icons.computer;
if (name.contains('Math')) return Icons.calculate;
if (name.contains('Animal')) return Icons.pets;
if (name.contains('Vehicle')) return Icons.directions_car;
return Icons.quiz;
}
通过检查分类名称中的关键词来匹配图标。比如名称里包含"Science"就用科学图标,包含"History"就用历史图标。如果都不匹配,就用默认的quiz图标。
为什么用contains而不是精确匹配? 因为API返回的分类名称格式不太统一,有的是"Science & Nature",有的是"Science: Computers"。用contains可以更灵活地匹配。
分类名称翻译
将英文分类名翻译成中文:
String _translateCategory(String name) {
final translations = {
'General Knowledge': '常识',
'Entertainment: Books': '图书',
'Entertainment: Film': '电影',
'Entertainment: Music': '音乐',
'Science & Nature': '科学与自然',
'Science: Computers': '计算机科学',
'Science: Mathematics': '数学',
'Sports': '体育',
'Geography': '地理',
'History': '历史',
'Art': '艺术',
'Animals': '动物',
};
return translations[name] ?? name;
}
用Map存储翻译对照表,如果没有找到对应的翻译就返回原文。这种做法比写一堆if-else简洁多了,而且方便维护——要加新翻译只需要往Map里加一项。
开始答题
点击任何入口都会调用这个方法:
void _startQuiz(BuildContext context, {String? category, String? difficulty}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => QuizScreen(category: category, difficulty: difficulty),
),
);
}
category和difficulty都是可选参数。快速开始时两个都不传,选择难度时只传difficulty,选择分类时只传category。QuizScreen会根据传入的参数请求对应的题目。
答题统计弹窗
点击排行榜按钮显示统计信息:
void _showStats(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.emoji_events, size: 64, color: Colors.amber),
const SizedBox(height: 16),
Text('答题统计', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('总答题数', '$_totalQuizzes'),
_buildStatItem('总得分', '$_totalScore'),
_buildStatItem('正确率', _totalQuizzes > 0 ? '${(_totalScore / _totalQuizzes * 10).toStringAsFixed(1)}%' : '0%'),
],
),
const SizedBox(height: 24),
],
),
),
);
}
用
showModalBottomSheet从底部弹出统计面板,比AlertDialog更现代一些。mainAxisSize: MainAxisSize.min让弹窗高度自适应内容,不会占满整个屏幕。
顶部放一个金色的奖杯图标,增加一点趣味性。正确率的计算要注意除零的情况,如果还没答过题就显示0%。
小结
问答页面的设计重点是让用户能快速开始答题,同时提供足够的选择。快速开始卡片用醒目的渐变色吸引注意,难度选择用颜色和表情直观区分,分类网格让用户可以按兴趣选择。这种层次分明的布局让页面既丰富又不杂乱。
下一篇我们来看收藏功能的实现,了解如何管理用户的收藏数据。
本文是Flutter for OpenHarmony教育百科实战系列的第三篇。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)